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 (];
+
+ 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