From 9275325fe35bcc4084cce08936408e51359195d6 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Mon, 31 Dec 2018 14:35:55 -0500 Subject: [PATCH] Added processing node removed signal, lightnin plugin dashboard, login --- app/plugins/signals.py | 2 + app/tests/test_api.py | 9 +- nodeodm/models.py | 8 ++ plugins/lightning/plugin.py | 82 +++++++++++++- plugins/lightning/public/Dashboard.jsx | 139 ++++++++++++++++++++++++ plugins/lightning/public/Dashboard.scss | 33 ++++++ plugins/lightning/public/Login.jsx | 83 +++++++++++++- plugins/lightning/public/app.jsx | 31 +++++- plugins/lightning/templates/index.html | 2 - 9 files changed, 370 insertions(+), 19 deletions(-) create mode 100644 plugins/lightning/public/Dashboard.jsx create mode 100644 plugins/lightning/public/Dashboard.scss diff --git a/app/plugins/signals.py b/app/plugins/signals.py index 69a75a65..80ad2f66 100644 --- a/app/plugins/signals.py +++ b/app/plugins/signals.py @@ -3,3 +3,5 @@ import django.dispatch task_completed = django.dispatch.Signal(providing_args=["task_id"]) task_removing = django.dispatch.Signal(providing_args=["task_id"]) task_removed = django.dispatch.Signal(providing_args=["task_id"]) + +processing_node_removed = django.dispatch.Signal(providing_args=["processing_node_id"]) diff --git a/app/tests/test_api.py b/app/tests/test_api.py index 8eb56bf8..6e3158f2 100644 --- a/app/tests/test_api.py +++ b/app/tests/test_api.py @@ -9,6 +9,8 @@ from rest_framework_jwt.settings import api_settings from app import pending_actions from app.models import Project, Task +from app.plugins.signals import processing_node_removed +from app.tests.utils import catch_signal from nodeodm.models import ProcessingNode, OFFLINE_MINUTES from .classes import BootTestCase @@ -364,8 +366,11 @@ class TestApi(BootTestCase): client.login(username="testsuperuser", password="test1234") # Can delete a processing node as super user - res = client.delete('/api/processingnodes/{}/'.format(pnode.id)) - self.assertTrue(res.status_code, status.HTTP_200_OK) + # and a signal is sent when a processing node is deleted + with catch_signal(processing_node_removed) as h1: + res = client.delete('/api/processingnodes/{}/'.format(pnode.id)) + self.assertTrue(res.status_code, status.HTTP_200_OK) + h1.assert_called_once_with(sender=ProcessingNode, processing_node_id=pnode.id, signal=processing_node_removed) # Can create a processing node as super user res = client.post('/api/processingnodes/', {'hostname': 'localhost', 'port':'1000'}) diff --git a/nodeodm/models.py b/nodeodm/models.py index a971e0a2..e6bef318 100644 --- a/nodeodm/models.py +++ b/nodeodm/models.py @@ -208,6 +208,14 @@ class ProcessingNode(models.Model): else: raise ProcessingError("Unknown response: {}".format(result)) + def delete(self, using=None, keep_parents=False): + pnode_id = self.id + super(ProcessingNode, self).delete(using, keep_parents) + + from app.plugins import signals as plugin_signals + plugin_signals.processing_node_removed.send_robust(sender=self.__class__, processing_node_id=pnode_id) + + class Meta: permissions = ( ('view_processingnode', 'Can view processing node'), diff --git a/plugins/lightning/plugin.py b/plugins/lightning/plugin.py index c802d258..4aa68a9a 100644 --- a/plugins/lightning/plugin.py +++ b/plugins/lightning/plugin.py @@ -1,10 +1,22 @@ -from django.http import HttpResponse +import json -from app.plugins import PluginBase, Menu, MountPoint, UserDataStore +from django.dispatch import receiver +from django.http import HttpResponse +from guardian.shortcuts import get_objects_for_user, assign_perm +from rest_framework.renderers import JSONRenderer + +from app.plugins import GlobalDataStore, logger +from app.plugins import PluginBase, Menu, MountPoint, UserDataStore, signals from django.shortcuts import render from django.contrib.auth.decorators import login_required from django.views.decorators.http import require_POST +from nodeodm.models import ProcessingNode +from app.api.processingnodes import ProcessingNodeSerializer + +def JsonResponse(dict): + return HttpResponse(json.dumps(dict), content_type='application/json') + class Plugin(PluginBase): def main_menu(self): return [Menu("Lightning Network", self.public_url(""), "fa fa-bolt fa-fw")] @@ -22,19 +34,77 @@ class Plugin(PluginBase): @login_required @require_POST def save_api_key(request): - api_key = request.POST.get('api_key') if api_key is None: - return HttpResponse({'error': 'api_key is required'}, content_type='application/json') + return JsonResponse({'error': 'api_key is required'}) ds = UserDataStore('lightning', request.user) ds.set_string('api_key', api_key) - return HttpResponse({'success': True}, content_type='application/json') + return JsonResponse({'success': True}) + + @login_required + @require_POST + def sync_processing_node(request): + ds = GlobalDataStore('lightning') + + hostname = request.POST.get('hostname') + port = int(request.POST.get('port')) + token = request.POST.get('token') + + if hostname is not None and port is not None and token is not None: + nodes = get_objects_for_user(request.user, 'view_processingnode', ProcessingNode, + accept_global_perms=False) + matches = [n for n in nodes if n.hostname == hostname and n.port == port and n.token == token] + if len(matches) == 0: + # Add + node = ProcessingNode.objects.create(hostname=hostname, port=port, token=token) + assign_perm('view_processingnode', request.user, node) + assign_perm('change_processingnode', request.user, node) + assign_perm('delete_processingnode', request.user, node) + + # Keep track of lightning node IDs + lightning_nodes = ds.get_json('nodes', []) + lightning_nodes.append(node.id) + ds.set_json('nodes', lightning_nodes) + + return get_processing_nodes(request) + else: + # Already added + return get_processing_nodes(request) + else: + return JsonResponse({'error': 'Invalid call (params missing)'}) + + @login_required + def get_processing_nodes(request): + ds = GlobalDataStore('lightning') + + nodes = get_objects_for_user(request.user, 'view_processingnode', ProcessingNode, + accept_global_perms=False) + lightning_node_ids = ds.get_json("nodes", []) + + nodes = [n for n in nodes if n.id in lightning_node_ids] + serializer = ProcessingNodeSerializer(nodes, many=True) + + return JsonResponse(serializer.data) + return [ MountPoint('$', main), - MountPoint('save_api_key$', save_api_key) + MountPoint('save_api_key$', save_api_key), + MountPoint('sync_processing_node$', sync_processing_node), + MountPoint('get_processing_nodes$', get_processing_nodes), ] +@receiver(signals.processing_node_removed, dispatch_uid="lightning_on_processing_node_removed") +def lightning_on_processing_node_removed(sender, processing_node_id, **kwargs): + ds = GlobalDataStore('lightning') + + node_ids = ds.get_json('nodes', []) + try: + node_ids.remove(processing_node_id) + logger.info("Removing lightning node {}".format(str(processing_node_id))) + ds.set_json('nodes', node_ids) + except ValueError: + pass diff --git a/plugins/lightning/public/Dashboard.jsx b/plugins/lightning/public/Dashboard.jsx new file mode 100644 index 00000000..995cb00f --- /dev/null +++ b/plugins/lightning/public/Dashboard.jsx @@ -0,0 +1,139 @@ +import React from 'react'; +import ErrorMessage from 'webodm/components/ErrorMessage'; +import PropTypes from 'prop-types'; +import './Dashboard.scss'; +import $ from 'jquery'; + +export default class Dashboard extends React.Component { + static defaultProps = { + }; + static propTypes = { + apiKey: PropTypes.string.isRequired, + onLogout: PropTypes.func.isRequired + } + + constructor(props){ + super(props); + + this.state = { + error: "", + loading: true, + loadingMessage: "Loading dashboard...", + user: null, + nodes: [], + syncingNodes: false + } + } + + apiUrl = url => { + return `http://192.168.2.253:5000${url}?api_key=${this.props.apiKey}`; + }; + + componentDidMount = () => { + $.get(this.apiUrl('/r/user')).done(json => { + if (json.balance !== undefined){ + this.setState({ loading: false, user: json }); + this.handleSyncProcessingNode(); + }else{ + this.setState({ loading: false, error: `Cannot load dashboard. Invalid response from server: ${JSON.stringify(json)}. Are you running the latest version of WebODM?` }); + } + }) + .fail(() => { + this.setState({ loading: false, error: `Cannot load dashboard. Please make sure you are connected to the internet, or try again in an hour.`}); + }); + } + + handleSyncProcessingNode = () => { + if (!this.state.user) return; + const { node, tokens } = this.state.user; + if (!node || !tokens) return; + + this.setState({syncingNodes: true, nodes: []}); + + $.post('sync_processing_node', { + hostname: node.hostname, + port: node.port, + token: tokens[0].id + }).done(json => { + console.log(json); + if (json.error){ + this.setState({error: json.error}); + }else{ + this.setState({nodes: json}); + } + }) + .fail(e => { + this.setState({error: `Cannot sync nodes: ${JSON.stringify(e)}. Are you connected to the internet?`}); + }) + .always(() => { + this.setState({syncingNodes: false}); + }); + } + + handeLogout = () => { + this.setState({loading: true, loadingMessage: "Logging out..."}); + + $.post("save_api_key", { + api_key: "" + }).done(json => { + if (!json.success){ + this.setState({error: `Cannot logout: ${JSON.stringify(json)}`}); + } + this.setState({loading: false}); + this.props.onLogout(); + }).fail(e => { + this.setState({loading: false, error: `Cannot logout: ${JSON.stringify(e)}`}); + }); + } + + handleBuyCredits = () => { + window.open("https://webodm.net/dashboard?bc=0"); + } + + handleOpenDashboard = () => { + window.open("https://webodm.net/dashboard"); + } + + render(){ + const { loading, loadingMessage, user, syncingNodes, nodes } = this.state; + + return (
+ + + { loading ? +
{ loadingMessage }
: +
+ { user ? +
+
Balance: { user.balance } credits
+

Hello, { user.email }

+ +
+
Synced Nodes
+ { syncingNodes ? : +
+ + +
} +
+ +
+
Cost Calculator
+ Drag & drop some images below to estimate the number of credits required to process them with lightning. +
+ +
+
+ +
+
: ""} +
} +
); + } +} \ No newline at end of file diff --git a/plugins/lightning/public/Dashboard.scss b/plugins/lightning/public/Dashboard.scss new file mode 100644 index 00000000..afc5c308 --- /dev/null +++ b/plugins/lightning/public/Dashboard.scss @@ -0,0 +1,33 @@ +.plugin-lightning .lightning-dashboard{ + .loading{ + margin-top: 12px; + } + + .balance{ + float: right; + font-size: 120%; + } + + .buttons > button{ + clear: both; + margin-right: 4px; + } + + .lightning-nodes{ + margin-top: 12px; + ul{ + padding-left: 0; + } + li{ + list-style-type: none; + } + } + + .estimator{ + margin-top: 12px; + } + + h5{ + font-weight: bold; + } +} \ No newline at end of file diff --git a/plugins/lightning/public/Login.jsx b/plugins/lightning/public/Login.jsx index 37e32139..d36f71e1 100644 --- a/plugins/lightning/public/Login.jsx +++ b/plugins/lightning/public/Login.jsx @@ -1,5 +1,6 @@ import React from 'react'; import './Login.scss'; +import ErrorMessage from 'webodm/components/ErrorMessage'; import PropTypes from 'prop-types'; import $ from 'jquery'; @@ -7,30 +8,106 @@ export default class Login extends React.Component { static defaultProps = { }; static propTypes = { + onLogin: PropTypes.func.isRequired } constructor(props){ super(props); + this.state = { + error: "", + loggingIn: false, + email: "", + password: "" + } + } + + handleEmailChange = (e) => { + this.setState({email: e.target.value}); + } + + handlePasswordChange = (e) => { + this.setState({password: e.target.value}); + } + + handleLogin = () => { + this.setState({loggingIn: true}); + + $.post("http://192.168.2.253:5000/r/auth/login",//"https://webodm.net/r/auth/login", + { + username: this.state.email, + password: this.state.password + } + ).done(json => { + if (json.api_key){ + this.saveApiKey(json.api_key, (err) => { + this.setState({loggingIn: false}); + + if (!err){ + this.props.onLogin(json.api_key); + }else{ + this.setState({ error: err.message }); + } + }); + }else if (json.message){ + this.setState({ loggingIn: false, error: json.message }); + }else{ + this.setState({ loggingIn: false, error: "Cannot login. Invalid response: " + JSON.stringify(json)}); + } + }) + .fail(() => { + this.setState({loggingIn: false, error: "Cannot login. Please make sure you are connected to the internet, or try again in an hour."}); + }); + } + + handleKeyPress = (e) => { + if (e.key === 'Enter'){ + this.handleLogin(); + } + } + + saveApiKey = (api_key, cb) => { + $.post("save_api_key", { + api_key: api_key + }).done(json => { + if (!json.success){ + cb(new Error(`Cannot save API key: ${JSON.stringify(json)}`)); + }else cb(); + }).fail(e => { + cb(new Error(`Cannot save API key: ${JSON.stringify(e)}`)); + }); } render(){ return (
+

- + +

- + +

-

+

diff --git a/plugins/lightning/public/app.jsx b/plugins/lightning/public/app.jsx index 604ab9e6..2cefd038 100644 --- a/plugins/lightning/public/app.jsx +++ b/plugins/lightning/public/app.jsx @@ -1,6 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import Login from './Login'; +import Dashboard from './Dashboard'; import $ from 'jquery'; export default class LightningPanel extends React.Component { @@ -14,16 +15,34 @@ export default class LightningPanel extends React.Component { constructor(props){ super(props); + + this.state = { + apiKey: props.apiKey + } + } + + handleLogin = (apiKey) => { + this.setState({ apiKey }); + } + + handleLogout = () => { + this.setState({ apiKey: ""}); } render(){ + const { apiKey } = this.state; + return (
-

Lightning Network

- - Lightning is a service that allows you to quickly process small and large datasets using high performance servers in the cloud. - Below you can enter your webodm.net credentials to sync your account and automatically setup a new processing node. If you don't have an account, you can register for free. - - + { !apiKey ? +
+

Lightning Network

+ Lightning is a service that allows you to quickly process small and large datasets using high performance servers in the cloud. + Below you can enter your webodm.net credentials to sync your account and automatically setup a new processing node. If you don't have an account, you can register for free. + +
: +
+ +
}
); } } \ No newline at end of file diff --git a/plugins/lightning/templates/index.html b/plugins/lightning/templates/index.html index 50ac41eb..c26644bc 100644 --- a/plugins/lightning/templates/index.html +++ b/plugins/lightning/templates/index.html @@ -20,6 +20,4 @@ PluginsAPI.App.ready([
- -
{% endblock %} \ No newline at end of file