diff --git a/app/plugins/functions.py b/app/plugins/functions.py index 389c3724..f9ec6a37 100644 --- a/app/plugins/functions.py +++ b/app/plugins/functions.py @@ -11,6 +11,7 @@ from string import Template from django.http import HttpResponse +from app.models import Setting from webodm import settings logger = logging.getLogger('app.logger') @@ -174,6 +175,10 @@ def get_dynamic_script_handler(script_path, callback=None, **kwargs): return handleRequest +def get_site_settings(): + return Setting.objects.first() + + def versionToInt(version): """ Converts a WebODM version string (major.minor.build) to a integer value diff --git a/app/plugins/templates/webpack.config.js.tmpl b/app/plugins/templates/webpack.config.js.tmpl index b28f0450..b5498114 100644 --- a/app/plugins/templates/webpack.config.js.tmpl +++ b/app/plugins/templates/webpack.config.js.tmpl @@ -61,7 +61,10 @@ module.exports = { resolve: { modules: ['node_modules', 'bower_components'], - extensions: ['.js', '.jsx'] + extensions: ['.js', '.jsx'], + alias: { + webodm: path.resolve(__dirname, '../../../app/static/app/js') + } }, externals: { diff --git a/app/static/app/js/components/TaskListItem.jsx b/app/static/app/js/components/TaskListItem.jsx index 34a43de9..3a7c8cc3 100644 --- a/app/static/app/js/components/TaskListItem.jsx +++ b/app/static/app/js/components/TaskListItem.jsx @@ -553,21 +553,21 @@ class TaskListItem extends React.Component { return (
-
+
{name}
-
+
{task.images_count}
-
+
{this.hoursMinutesSecs(this.state.time)}
-
+
{showEditLink ? {statusLabel} : statusLabel}
-
+
diff --git a/app/static/app/js/components/TaskPluginActionButtons.jsx b/app/static/app/js/components/TaskPluginActionButtons.jsx index de9d8a8d..ea9928c8 100644 --- a/app/static/app/js/components/TaskPluginActionButtons.jsx +++ b/app/static/app/js/components/TaskPluginActionButtons.jsx @@ -16,7 +16,7 @@ class TaskPluginActionButtons extends React.Component { }; constructor(props){ - super(); + super(props); this.state = { buttons: [] diff --git a/package.json b/package.json index 9e28eba6..c0bc4d17 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "WebODM", - "version": "0.5.3", + "version": "0.6.0", "description": "Open Source Drone Image Processing", "main": "index.js", "scripts": { diff --git a/plugins/measure/public/main.js b/plugins/measure/public/main.js index d89eb536..b6ebeaf2 100644 --- a/plugins/measure/public/main.js +++ b/plugins/measure/public/main.js @@ -1,6 +1,6 @@ PluginsAPI.Map.willAddControls([ 'measure/build/app.js', 'measure/build/app.css' - ], function(options, App){ - new App(options.map); + ], function(args, App){ + new App(args.map); }); diff --git a/plugins/openaerialmap/api.py b/plugins/openaerialmap/api.py index 96c894fe..dbe448a0 100644 --- a/plugins/openaerialmap/api.py +++ b/plugins/openaerialmap/api.py @@ -1,12 +1,26 @@ +import json +from datetime import datetime +import os +from urllib.parse import urlencode + +import piexif +from PIL import Image +from rest_framework import serializers from rest_framework import status from rest_framework.response import Response -from app.plugins import GlobalDataStore +from app.models import ImageUpload +from app.plugins import GlobalDataStore, get_site_settings from app.plugins.views import TaskView from app.plugins.worker import task +from webodm import settings + import requests +import logging + +logger = logging.getLogger('app.logger') ds = GlobalDataStore('openaerialmap') @@ -28,27 +42,77 @@ def set_task_info(task_id, json): # TODO: task info cleanup when task is deleted via signal -class ShareInfo(TaskView): +class Info(TaskView): def get(self, request, pk=None): task = self.get_and_check_task(request, pk) - return Response(get_task_info(task.id), status=status.HTTP_200_OK) + + task_info = get_task_info(task.id) + + # Populate fields from first image in task + img = ImageUpload.objects.filter(task=task).exclude(image__iendswith='.txt').first() + img_path = os.path.join(settings.MEDIA_ROOT, img.path()) + im = Image.open(img_path) + + # TODO: for better data we could look over all images + # and find actual end and start time + # Here we're picking an image at random and assuming a one hour flight + + task_info['endDate'] = datetime.utcnow().timestamp() * 1000 + task_info['sensor'] = '' + task_info['title'] = task.name + task_info['provider'] = get_site_settings().organization_name + + if 'exif' in im.info: + exif_dict = piexif.load(im.info['exif']) + if 'Exif' in exif_dict: + if piexif.ExifIFD.DateTimeOriginal in exif_dict['Exif']: + try: + parsed_date = datetime.strptime(exif_dict['Exif'][piexif.ExifIFD.DateTimeOriginal].decode('ascii'), + '%Y:%m:%d %H:%M:%S') + task_info['endDate'] = parsed_date.timestamp() * 1000 + except ValueError: + # Ignore date field if we can't parse it + pass + if '0th' in exif_dict: + if piexif.ImageIFD.Make in exif_dict['0th']: + task_info['sensor'] = exif_dict['0th'][piexif.ImageIFD.Make].decode('ascii').strip(' \t\r\n\0') + + if piexif.ImageIFD.Model in exif_dict['0th']: + task_info['sensor'] = (task_info['sensor'] + " " + exif_dict['0th'][piexif.ImageIFD.Model].decode('ascii')).strip(' \t\r\n\0') + + task_info['startDate'] = task_info['endDate'] - 60 * 60 * 1000 + set_task_info(task.id, task_info) + + return Response(task_info, status=status.HTTP_200_OK) + +class JSONSerializer(serializers.Serializer): + oamParams = serializers.JSONField(help_text="OpenAerialMap share parameters (sensor, title, provider, etc.)") class Share(TaskView): def post(self, request, pk=None): task = self.get_and_check_task(request, pk) - upload_orthophoto_to_oam.delay(task.id, task.get_asset_download_path('orthophoto.tif')) + serializer = JSONSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + oam_params = serializer['oamParams'].value + + upload_orthophoto_to_oam.delay(task.id, + task.get_asset_download_path('orthophoto.tif'), + oam_params) task_info = get_task_info(task.id) task_info['sharing'] = True + task_info['oam_upload_id'] = '' + task_info['error'] = '' set_task_info(task.id, task_info) return Response(task_info, status=status.HTTP_200_OK) @task -def upload_orthophoto_to_oam(task_id, orthophoto_path): +def upload_orthophoto_to_oam(task_id, orthophoto_path, oam_params): # Upload to temporary central location since # OAM requires a public URL and not all WebODM # instances are public @@ -58,15 +122,28 @@ def upload_orthophoto_to_oam(task_id, orthophoto_path): ('file', ('orthophoto.tif', open(orthophoto_path, 'rb'), 'image/tiff')), ]).json() + task_info = get_task_info(task_id) + if 'url' in res: orthophoto_public_url = res['url'] + logger.info("Orthophoto uploaded to intermediary public URL " + orthophoto_public_url) - import logging - logger = logging.getLogger('app.logger') + # That's OK... we :heart: dronedeploy + res = requests.post('https://api.openaerialmap.org/dronedeploy?{}'.format(urlencode(oam_params)), + json={ + 'download_path': orthophoto_public_url + }).json() - logger.info("UPLOADED TO " + orthophoto_public_url) + if 'results' in res and 'upload' in res['results']: + task_info['oam_upload_id'] = res['results']['upload'] + task_info['shared'] = True + else: + task_info['error'] = 'Could not upload orthophoto to OAM. The server replied: {}'.format(json.dumps(res)) + + # Attempt to cleanup intermediate results + requests.get('https://www.webodm.org/oam/cleanup/{}'.format(os.path.basename(orthophoto_public_url))) else: - task_info = get_task_info(task_id) - task_info['sharing'] = False - task_info['error'] = 'Could not upload orthophoto to intermediate location.' - set_task_info(task_id, task_info) + task_info['error'] = 'Could not upload orthophoto to intermediate location: {}.'.format(json.dumps(res)) + + task_info['sharing'] = False + set_task_info(task_id, task_info) diff --git a/plugins/openaerialmap/load_buttons.js b/plugins/openaerialmap/load_buttons.js index 7119149b..d5ecdb23 100644 --- a/plugins/openaerialmap/load_buttons.js +++ b/plugins/openaerialmap/load_buttons.js @@ -1,12 +1,10 @@ PluginsAPI.Dashboard.addTaskActionButton([ 'openaerialmap/build/ShareButton.js', 'openaerialmap/build/ShareButton.css' - ],function(options, ShareButton){ - var task = options.task; + ],function(args, ShareButton){ + var task = args.task; if (task.available_assets.indexOf("orthophoto.tif") !== -1){ - console.log("INSTANTIATED"); - return { button: React.createElement(ShareButton, {task: task, token: "${token}"}), task: task diff --git a/plugins/openaerialmap/manifest.json b/plugins/openaerialmap/manifest.json index 009a043c..1a46630c 100644 --- a/plugins/openaerialmap/manifest.json +++ b/plugins/openaerialmap/manifest.json @@ -1,6 +1,6 @@ { "name": "OpenAerialMap", - "webodmMinVersion": "0.5.3", + "webodmMinVersion": "0.6.0", "description": "A plugin to upload orthophotos to OpenAerialMap", "version": "0.1.0", "author": "Piero Toffanin", diff --git a/plugins/openaerialmap/plugin.py b/plugins/openaerialmap/plugin.py index a3e4da4d..15bc49c5 100644 --- a/plugins/openaerialmap/plugin.py +++ b/plugins/openaerialmap/plugin.py @@ -5,7 +5,7 @@ from app.plugins import PluginBase, Menu, MountPoint from django.contrib.auth.decorators import login_required from django import forms -from plugins.openaerialmap.api import ShareInfo, Share +from plugins.openaerialmap.api import Info, Share class TokenForm(forms.Form): @@ -45,7 +45,7 @@ class Plugin(PluginBase): def api_mount_points(self): return [ - MountPoint('task/(?P[^/.]+)/shareinfo', ShareInfo.as_view()), + MountPoint('task/(?P[^/.]+)/info', Info.as_view()), MountPoint('task/(?P[^/.]+)/share', Share.as_view()) ] diff --git a/plugins/openaerialmap/public/ShareButton.jsx b/plugins/openaerialmap/public/ShareButton.jsx index 9d33fe73..a9d4de8a 100644 --- a/plugins/openaerialmap/public/ShareButton.jsx +++ b/plugins/openaerialmap/public/ShareButton.jsx @@ -1,6 +1,9 @@ import './ShareButton.scss'; import React from 'react'; import PropTypes from 'prop-types'; +import ShareDialog from './ShareDialog'; +import Storage from 'webodm/classes/Storage'; +import ErrorMessage from 'webodm/components/ErrorMessage'; import $ from 'jquery'; module.exports = class ShareButton extends React.Component{ @@ -19,41 +22,125 @@ module.exports = class ShareButton extends React.Component{ this.state = { loading: true, - shared: false, + taskInfo: {}, error: '' }; - - console.log("AH!"); } componentDidMount(){ - const { task } = this.props; + this.updateTaskInfo(false); + } - $.ajax({ - type: 'GET', - url: `/api/plugins/openaerialmap/task/${task.id}/shareinfo`, - contentType: "application/json" - }).done(result => { - this.setState({shared: result.shared, loading: false}) - }).fail(error => { - this.setState({error, loading: false}); - }); + updateTaskInfo = (showErrors) => { + const { task } = this.props; + return $.ajax({ + type: 'GET', + url: `/api/plugins/openaerialmap/task/${task.id}/info`, + contentType: 'application/json' + }).done(taskInfo => { + // Allow a user to specify a better name for the sensor + // and remember it. + let sensor = Storage.getItem("oam_sensor_pref_" + taskInfo.sensor); + if (sensor) taskInfo.sensor = sensor; + + // Allow a user to change the default provider name + let provider = Storage.getItem("oam_provider_pref"); + if (provider) taskInfo.provider = provider; + + this.setState({taskInfo, loading: false}); + if (taskInfo.error && showErrors) this.setState({error: taskInfo.error}); + }).fail(error => { + this.setState({error, loading: false}); + }); + } + + componentWillUnmount(){ + if (this.monitorTimeout) clearTimeout(this.monitorTimeout); } handleClick = () => { - console.log("HEY!", this.props.token); + const { taskInfo } = this.state; + if (!taskInfo.shared){ + this.shareDialog.show(); + }else if (taskInfo.oam_upload_id){ + window.open(`https://map.openaerialmap.org/#/upload/status/${encodeURIComponent(taskInfo.oam_upload_id)}`, '_blank'); + } + } + + shareToOAM = (formData) => { + const { task } = this.props; + + const oamParams = { + token: this.props.token, + sensor: formData.sensor, + acquisition_start: formData.startDate, + acquisition_end: formData.endDate, + title: formData.title, + provider: formData.provider, + tags: formData.tags + }; + + return $.ajax({ + url: `/api/plugins/openaerialmap/task/${task.id}/share`, + contentType: 'application/json', + data: JSON.stringify({ + oamParams: oamParams + }), + dataType: 'json', + type: 'POST' + }).done(taskInfo => { + // Allow a user to associate the sensor name coming from the EXIF tags + // to one that perhaps is more human readable. + Storage.setItem("oam_sensor_pref_" + taskInfo.sensor, formData.sensor); + Storage.setItem("oam_provider_pref", formData.provider); + + this.setState({taskInfo}); + this.monitorProgress(); + }); + } + + monitorProgress = () => { + if (this.state.taskInfo.sharing){ + // Monitor progress + this.monitorTimeout = setTimeout(() => { + this.updateTaskInfo(true).always(this.monitorProgress); + }, 10000); + } } render(){ - const { loading, shared } = this.state; + const { loading, taskInfo } = this.state; - return (); + {[, getButtonLabel()]} + ]; + + if (taskInfo.sensor !== undefined){ + result.unshift( { this.shareDialog = domNode; }} + task={this.props.task} + taskInfo={taskInfo} + saveAction={this.shareToOAM} + />); + } + + return result; } } \ No newline at end of file diff --git a/plugins/openaerialmap/public/ShareDialog.jsx b/plugins/openaerialmap/public/ShareDialog.jsx new file mode 100644 index 00000000..235f25c3 --- /dev/null +++ b/plugins/openaerialmap/public/ShareDialog.jsx @@ -0,0 +1,144 @@ +import React from 'react'; +import ErrorMessage from 'webodm/components/ErrorMessage'; +import FormDialog from 'webodm/components/FormDialog'; +import PropTypes from 'prop-types'; +import $ from 'jquery'; + +class ShareDialog extends React.Component { + static defaultProps = { + task: null, + taskInfo: null, + title: "Share To OpenAerialMap", + saveLabel: "Share", + savingLabel: "Sharing...", + saveIcon: "fa fa-share", + show: false + }; + + static propTypes = { + task: PropTypes.object.isRequired, + taskInfo: PropTypes.object.isRequired, + saveAction: PropTypes.func.isRequired, + title: PropTypes.string, + saveLabel: PropTypes.string, + savingLabel: PropTypes.string, + saveIcon: PropTypes.string, + show: PropTypes.bool + }; + + constructor(props){ + super(props); + + this.state = this.getInitialState(props); + + this.reset = this.reset.bind(this); + this.getFormData = this.getFormData.bind(this); + this.onShow = this.onShow.bind(this); + this.handleChange = this.handleChange.bind(this); + } + + getInitialState = (props) => { + return { + sensor: props.taskInfo.sensor, + startDate: this.toDatetimeLocal(new Date(props.taskInfo.startDate)), + endDate: this.toDatetimeLocal(new Date(props.taskInfo.endDate)), + title: props.taskInfo.title, + provider: props.taskInfo.provider, + tags: "" + }; + } + + // Credits to https://gist.github.com/WebReflection/6076a40777b65c397b2b9b97247520f0 + toDatetimeLocal = (date) => { + const ten = function (i) { + return (i < 10 ? '0' : '') + i; + }; + + const YYYY = date.getFullYear(), + MM = ten(date.getMonth() + 1), + DD = ten(date.getDate()), + HH = ten(date.getHours()), + II = ten(date.getMinutes()), + SS = ten(date.getSeconds()) + + return YYYY + '-' + MM + '-' + DD + 'T' + + HH + ':' + II + ':' + SS; + }; + + reset(){ + this.setState(this.getInitialState(this.props)); + } + + getFormData(){ + return this.state; + } + + onShow(){ + this.titleInput.focus(); + } + + show(){ + this.dialog.show(); + } + + hide(){ + this.dialog.hide(); + } + + handleChange(field){ + return (e) => { + let state = {}; + state[field] = e.target.value; + this.setState(state); + } + } + + render(){ + // startDate, endDate, tags + return ( + { this.dialog = domNode; }}> +
+ +
+ { this.titleInput = domNode; }} value={this.state.title} onChange={this.handleChange('title')} /> +
+
+
+ +
+ { this.sensorInput = domNode; }} value={this.state.sensor} onChange={this.handleChange('sensor')} /> +
+
+
+ +
+ { this.providerInput = domNode; }} value={this.state.provider} onChange={this.handleChange('provider')} /> +
+
+
+ +
+ { this.startDateInput = domNode; }} value={this.state.startDate} onChange={this.handleChange('startDate')} /> +
+
+
+ +
+ { this.endDateInput = domNode; }} value={this.state.endDate} onChange={this.handleChange('endDate')} /> +
+
+
+ +
+ { this.tagsInput = domNode; }} value={this.state.tags} onChange={this.handleChange('tags')} /> +
+
+
+ ); + } +} + +export default ShareDialog; \ No newline at end of file