+
-
diff --git a/app/static/app/js/components/tests/ImportTaskPanel.test.jsx b/app/static/app/js/components/tests/ImportTaskPanel.test.jsx
new file mode 100644
index 000000000..29bc24fe8
--- /dev/null
+++ b/app/static/app/js/components/tests/ImportTaskPanel.test.jsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import ImportTaskPanel from '../ImportTaskPanel';
+
+describe('
', () => {
+ it('renders without exploding', () => {
+ const wrapper = shallow(
{}} />);
+ expect(wrapper.exists()).toBe(true);
+ })
+});
\ No newline at end of file
diff --git a/app/static/app/js/css/ImportTaskPanel.scss b/app/static/app/js/css/ImportTaskPanel.scss
new file mode 100644
index 000000000..bbcc68c58
--- /dev/null
+++ b/app/static/app/js/css/ImportTaskPanel.scss
@@ -0,0 +1,29 @@
+.import-task-panel{
+ padding: 10px;
+ margin-top: 10px;
+ margin-bottom: 10px;
+
+ text-align: center;
+
+ button{
+ margin-right: 12px;
+ margin-bottom: 8px;
+ }
+
+ .glyphicon-arrow-right{
+ font-size: 80%;
+ }
+
+ .close:hover, .close:focus{
+ color: inherit;
+ }
+
+ .upload-progress-bar{
+ margin-top: 12px;
+ }
+
+ .btn-import{
+ margin-top: 8px;
+ margin-left: 8px;
+ }
+}
\ No newline at end of file
diff --git a/app/static/app/js/css/NewTaskPanel.scss b/app/static/app/js/css/NewTaskPanel.scss
index 525d299fc..947bbeca5 100644
--- a/app/static/app/js/css/NewTaskPanel.scss
+++ b/app/static/app/js/css/NewTaskPanel.scss
@@ -36,4 +36,9 @@
display: inline-block;
}
}
+
+ .disabled{
+ opacity: 0.8;
+ pointer-events:none;
+ }
}
\ No newline at end of file
diff --git a/app/static/app/js/css/TaskListItem.scss b/app/static/app/js/css/TaskListItem.scss
index d712c97df..a30647594 100644
--- a/app/static/app/js/css/TaskListItem.scss
+++ b/app/static/app/js/css/TaskListItem.scss
@@ -15,6 +15,7 @@
.name{
padding-left: 0;
+ margin-top: 4px;
}
.details{
diff --git a/app/static/app/js/css/UploadProgressBar.scss b/app/static/app/js/css/UploadProgressBar.scss
new file mode 100644
index 000000000..b2218897a
--- /dev/null
+++ b/app/static/app/js/css/UploadProgressBar.scss
@@ -0,0 +1,5 @@
+.upload-progress-bar{
+ .upload-label{
+ margin-bottom: 6px;
+ }
+}
\ No newline at end of file
diff --git a/app/tests/test_api_task.py b/app/tests/test_api_task.py
index f5e171ea7..7a93bd581 100644
--- a/app/tests/test_api_task.py
+++ b/app/tests/test_api_task.py
@@ -122,6 +122,7 @@ def test_task(self):
multiple_param_task = Task.objects.latest('created_at')
self.assertTrue(multiple_param_task.name == 'test_task')
self.assertTrue(multiple_param_task.processing_node.id == pnode.id)
+ self.assertEqual(multiple_param_task.import_url, "")
image1.seek(0)
image2.seek(0)
diff --git a/app/tests/test_api_task_import.py b/app/tests/test_api_task_import.py
new file mode 100644
index 000000000..2db6bd5b6
--- /dev/null
+++ b/app/tests/test_api_task_import.py
@@ -0,0 +1,168 @@
+import os
+import time
+
+import io
+import requests
+from django.contrib.auth.models import User
+from guardian.shortcuts import remove_perm, assign_perm
+from rest_framework import status
+from rest_framework.test import APIClient
+
+import worker
+from app.models import Project
+from app.models import Task
+from app.tests.classes import BootTransactionTestCase
+from app.tests.utils import clear_test_media_root, start_processing_node
+from nodeodm import status_codes
+from nodeodm.models import ProcessingNode
+from webodm import settings
+
+
+class TestApiTask(BootTransactionTestCase):
+ def setUp(self):
+ super().setUp()
+ clear_test_media_root()
+
+ def test_task(self):
+ client = APIClient()
+
+ node_odm = start_processing_node()
+
+ user = User.objects.get(username="testuser")
+ self.assertFalse(user.is_superuser)
+ project = Project.objects.create(
+ owner=user,
+ name="test project"
+ )
+
+ image1 = open("app/fixtures/tiny_drone_image.jpg", 'rb')
+ image2 = open("app/fixtures/tiny_drone_image_2.jpg", 'rb')
+
+ # Create processing node
+ pnode = ProcessingNode.objects.create(hostname="localhost", port=11223)
+ client.login(username="testuser", password="test1234")
+
+ # Create task
+ res = client.post("/api/projects/{}/tasks/".format(project.id), {
+ 'images': [image1, image2]
+ }, format="multipart")
+ image1.close()
+ image2.close()
+ task = Task.objects.get(id=res.data['id'])
+
+ # Wait for completion
+ c = 0
+ while c < 10:
+ worker.tasks.process_pending_tasks()
+ task.refresh_from_db()
+ if task.status == status_codes.COMPLETED:
+ break
+ c += 1
+ time.sleep(1)
+
+
+ self.assertEqual(task.status, status_codes.COMPLETED)
+
+ # Download task assets
+ task_uuid = task.uuid
+ res = client.get("/api/projects/{}/tasks/{}/download/all.zip".format(project.id, task.id))
+ self.assertEqual(res.status_code, status.HTTP_200_OK)
+
+ if not os.path.exists(settings.MEDIA_TMP):
+ os.mkdir(settings.MEDIA_TMP)
+
+ assets_path = os.path.join(settings.MEDIA_TMP, "all.zip")
+
+ with open(assets_path, 'wb') as f:
+ f.write(res.content)
+
+ remove_perm('change_project', user, project)
+
+ assets_file = open(assets_path, 'rb')
+
+ # Cannot import unless we have permission
+ res = client.post("/api/projects/{}/tasks/import".format(project.id), {
+ 'file': [assets_file]
+ }, format="multipart")
+ self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND)
+
+ assign_perm('change_project', user, project)
+
+ # Import with file upload method
+ assets_file.seek(0)
+ res = client.post("/api/projects/{}/tasks/import".format(project.id), {
+ 'file': [assets_file]
+ }, format="multipart")
+ self.assertEqual(res.status_code, status.HTTP_201_CREATED)
+ assets_file.close()
+
+ file_import_task = Task.objects.get(id=res.data['id'])
+ # Wait for completion
+ c = 0
+ while c < 10:
+ worker.tasks.process_pending_tasks()
+ file_import_task.refresh_from_db()
+ if file_import_task.status == status_codes.COMPLETED:
+ break
+ c += 1
+ time.sleep(1)
+
+ self.assertEqual(file_import_task.import_url, "file://all.zip")
+ self.assertEqual(file_import_task.images_count, 1)
+ self.assertEqual(file_import_task.processing_node, None)
+ self.assertEqual(file_import_task.auto_processing_node, False)
+
+ # Can access assets
+ res = client.get("/api/projects/{}/tasks/{}/assets/odm_orthophoto/odm_orthophoto.tif".format(project.id, file_import_task.id))
+ self.assertEqual(res.status_code, status.HTTP_200_OK)
+
+ # Set task public so we can download from it without auth
+ file_import_task.public = True
+ file_import_task.save()
+
+ # Import with URL method
+ assets_import_url = "http://{}:{}/task/{}/download/all.zip".format(pnode.hostname, pnode.port, task_uuid)
+ res = client.post("/api/projects/{}/tasks/import".format(project.id), {
+ 'url': assets_import_url
+ })
+ self.assertEqual(res.status_code, status.HTTP_201_CREATED)
+ url_task = Task.objects.get(id=res.data['id'])
+
+ # Wait for completion
+ c = 0
+ while c < 10:
+ worker.tasks.process_pending_tasks()
+ url_task.refresh_from_db()
+ if url_task.status == status_codes.COMPLETED:
+ break
+ c += 1
+ time.sleep(1)
+
+ self.assertEqual(url_task.import_url, assets_import_url)
+ self.assertEqual(url_task.images_count, 1)
+
+ # Import corrupted file
+ assets_import_url = "http://{}:{}/task/{}/download/orthophoto.tif".format(pnode.hostname, pnode.port, task_uuid)
+ res = client.post("/api/projects/{}/tasks/import".format(project.id), {
+ 'url': assets_import_url
+ })
+ self.assertEqual(res.status_code, status.HTTP_201_CREATED)
+ corrupted_task = Task.objects.get(id=res.data['id'])
+
+ # Wait for completion
+ c = 0
+ while c < 10:
+ worker.tasks.process_pending_tasks()
+ corrupted_task.refresh_from_db()
+ if corrupted_task.status == status_codes.FAILED:
+ break
+ c += 1
+ time.sleep(1)
+
+ self.assertEqual(corrupted_task.status, status_codes.FAILED)
+ self.assertTrue("Invalid" in corrupted_task.last_error)
+
+ # Stop processing node
+ node_odm.terminate()
+
+
diff --git a/devenv.sh b/devenv.sh
index fe81be10d..6524b9ca4 100755
--- a/devenv.sh
+++ b/devenv.sh
@@ -1,57 +1,3 @@
#!/bin/bash
-set -eo pipefail
-__dirname=$(cd $(dirname "$0"); pwd -P)
-${__dirname}/webodm.sh checkenv
-
-export WO_DEBUG=YES
-
-usage(){
- echo "Usage: $0 [options]"
- echo
- echo "This program helps to setup a development environment for WebODM using docker."
- echo
- echo "Command list:"
- echo " start Start the development environment"
- echo " stop Stop the development environment"
- echo " down Tear down the development environment"
- echo " runtests Run unit tests"
- exit
-}
-
-run(){
- echo $1
- eval $1
-}
-
-start(){
- run "docker-compose -f docker-compose.yml -f docker-compose.nodeodm.yml -f docker-compose.dev.yml up"
-}
-
-stop(){
- run "${__dirname}/webodm.sh stop"
-}
-
-down(){
- run "${__dirname}/webodm.sh down"
-}
-
-runtests(){
- run "docker-compose exec webapp /bin/bash -c \"/webodm/webodm.sh test\""
-}
-
-if [[ $1 = "start" ]]; then
- echo "Starting development environment..."
- start
-elif [[ $1 = "stop" ]]; then
- echo "Stopping development environment..."
- stop
-elif [[ $1 = "down" ]]; then
- echo "Tearing down development environment..."
- down
-elif [[ $1 = "runtests" ]]; then
- echo "Starting tests..."
- runtests "$2"
-else
- usage
-fi
+echo "devenv.sh is deprecated! Use \"./webodm.sh start --dev\" instead."
diff --git a/nodeodm/migrations/0006_auto_20190220_1842.py b/nodeodm/migrations/0006_auto_20190220_1842.py
new file mode 100644
index 000000000..4d9cda0f9
--- /dev/null
+++ b/nodeodm/migrations/0006_auto_20190220_1842.py
@@ -0,0 +1,24 @@
+# Generated by Django 2.1.5 on 2019-02-20 18:42
+
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('nodeodm', '0005_auto_20190115_1346'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='processingnode',
+ name='available_options',
+ field=django.contrib.postgres.fields.jsonb.JSONField(default=dict, help_text='Description of the options that can be used for processing'),
+ ),
+ migrations.AlterField(
+ model_name='processingnode',
+ name='label',
+ field=models.CharField(blank=True, default='', help_text='Optional label for this node. When set, this label will be shown instead of the hostname:port name.', max_length=255),
+ ),
+ ]
diff --git a/nodeodm/models.py b/nodeodm/models.py
index af1b4f157..3d2000272 100644
--- a/nodeodm/models.py
+++ b/nodeodm/models.py
@@ -25,7 +25,7 @@ class ProcessingNode(models.Model):
available_options = fields.JSONField(default=dict, help_text="Description of the options that can be used for processing")
token = models.CharField(max_length=1024, blank=True, default="", help_text="Token to use for authentication. If the node doesn't have authentication, you can leave this field blank.")
max_images = models.PositiveIntegerField(help_text="Maximum number of images accepted by this node.", blank=True, null=True)
- odm_version = models.CharField(max_length=32, null=True, help_text="OpenDroneMap version used by the node")
+ odm_version = models.CharField(max_length=32, null=True, help_text="ODM version used by the node.")
label = models.CharField(max_length=255, default="", blank=True, help_text="Optional label for this node. When set, this label will be shown instead of the hostname:port name.")
def __str__(self):
diff --git a/plugins/measure/public/MeasurePopup.jsx b/plugins/measure/public/MeasurePopup.jsx
index 1878847a4..1aea4ddf3 100644
--- a/plugins/measure/public/MeasurePopup.jsx
+++ b/plugins/measure/public/MeasurePopup.jsx
@@ -1,6 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import './MeasurePopup.scss';
+import Utils from 'webodm/classes/Utils';
import $ from 'jquery';
import L from 'leaflet';
@@ -23,10 +24,46 @@ export default class MeasurePopup extends React.Component {
volume: null, // to be calculated
error: ""
};
+
+ this.exportMeasurement = this.exportMeasurement.bind(this);
+ this.getProperties = this.getProperties.bind(this);
+ this.getGeoJSON = this.getGeoJSON.bind(this);
}
componentDidMount(){
this.calculateVolume();
+ this.props.resultFeature._measurePopup = this;
+ }
+
+ componentWillUnmount(){
+ this.props.resultFeature._measurePopup = null;
+ }
+
+ getProperties(){
+ const result = {
+ Length: this.props.model.length,
+ Area: this.props.model.area
+ };
+ if (this.state.volume !== null && this.state.volume !== false){
+ result.Volume = this.state.volume;
+ }
+
+ return result;
+ }
+
+ getGeoJSON(){
+ const geoJSON = this.props.resultFeature.toGeoJSON();
+ geoJSON.properties = this.getProperties();
+ return geoJSON;
+ }
+
+ exportMeasurement(){
+ const geoJSON = {
+ type: "FeatureCollection",
+ features: [this.getGeoJSON()]
+ };
+
+ Utils.saveAs(JSON.stringify(geoJSON, null, 4), "measurement.geojson")
}
calculateVolume(){
@@ -96,6 +133,7 @@ export default class MeasurePopup extends React.Component {
{volume === null && !error && Volume: computing...
}
{typeof volume === "number" && Volume: {volume.toFixed("2")} Cubic Meters ({(volume * 35.3147).toFixed(2)} Cubic Feet)
}
{error && Volume: 200 ? 'long' : '')}>{error}
}
+ Export to GeoJSON
);
}
}
\ No newline at end of file
diff --git a/plugins/measure/public/MeasurePopup.scss b/plugins/measure/public/MeasurePopup.scss
index b8241e1b2..eb23b92da 100644
--- a/plugins/measure/public/MeasurePopup.scss
+++ b/plugins/measure/public/MeasurePopup.scss
@@ -8,4 +8,9 @@
display: block;
max-height: 200px;
}
+
+ a.export-measurements{
+ display: block;
+ margin-top: 12px;
+ }
}
\ No newline at end of file
diff --git a/plugins/measure/public/app.jsx b/plugins/measure/public/app.jsx
index b1c0e1ac2..d9ffbfd44 100644
--- a/plugins/measure/public/app.jsx
+++ b/plugins/measure/public/app.jsx
@@ -3,6 +3,7 @@ import './app.scss';
import 'leaflet-measure-ex/dist/leaflet-measure';
import 'leaflet-measure-ex/dist/leaflet-measure.css';
import MeasurePopup from './MeasurePopup';
+import Utils from 'webodm/classes/Utils';
import ReactDOM from 'ReactDOM';
import React from 'react';
import $ from 'jquery';
@@ -11,7 +12,7 @@ export default class App{
constructor(map){
this.map = map;
- L.control.measure({
+ const measure = L.control.measure({
labels:{
measureDistancesAndAreas: 'Measure volume, area and length',
areaMeasurement: 'Measurement'
@@ -22,6 +23,25 @@ export default class App{
secondaryAreaUnit: 'acres'
}).addTo(map);
+ const $btnExport = $("
Export Measurements");
+ $btnExport.appendTo($(measure.$startPrompt).children("ul.tasks"));
+ $btnExport.on('click', () => {
+ const features = [];
+ map.eachLayer(layer => {
+ const mp = layer._measurePopup;
+ if (mp){
+ features.push(mp.getGeoJSON());
+ }
+ });
+
+ const geoJSON = {
+ type: "FeatureCollection",
+ features: features
+ };
+
+ Utils.saveAs(JSON.stringify(geoJSON, null, 4), "measurements.geojson")
+ });
+
map.on('measurepopupshown', ({popupContainer, model, resultFeature}) => {
// Only modify area popup, length popup is fine as default
if (model.area !== 0){
diff --git a/slate/source/includes/_fordevelopers.md b/slate/source/includes/_fordevelopers.md
index bf7650ef6..5937392fb 100644
--- a/slate/source/includes/_fordevelopers.md
+++ b/slate/source/includes/_fordevelopers.md
@@ -22,7 +22,7 @@ Once you have a development environment, read about the [project overview](#proj
Follow the [Getting Started](https://github.com/OpenDroneMap/WebODM#getting-started) instructions, then run:
-`./devenv.sh start`
+`./webodm.sh start --dev`
That's it! You can modify any of the files, including SASS and React.js files. Changes will be reflected in the running WebODM instance automatically.
@@ -36,7 +36,7 @@ We think testing is a necessary part of delivering robust software. We try to ac
To run the unit tests, simply type:
-`./devenv.sh runtests`
+`./webodm.sh test`
## Project Overview
diff --git a/webodm.sh b/webodm.sh
index 943e2d114..f4983f8d8 100755
--- a/webodm.sh
+++ b/webodm.sh
@@ -29,6 +29,7 @@ elif [[ $platform = "MacOS / OSX" ]] && [[ $(pwd) == /Users* ]]; then
fi
load_default_node=true
+dev_mode=false
# Load default values
source .env
@@ -83,6 +84,11 @@ case $key in
--debug)
export WO_DEBUG=YES
shift # past argument
+ ;;
+ --dev)
+ export WO_DEBUG=YES
+ dev_mode=true
+ shift # past argument
;;
--broker)
export WO_BROKER="$2"
@@ -137,6 +143,7 @@ usage(){
echo " --ssl-cert
Manually specify a path to the certificate file (.pem) to use with nginx to enable SSL (default: None)"
echo " --ssl-insecure-port-redirect Insecure port number to redirect from when SSL is enabled (default: $DEFAULT_SSL_INSECURE_PORT_REDIRECT)"
echo " --debug Enable debug for development environments (default: disabled)"
+ echo " --dev Enable development mode. In development mode you can make modifications to WebODM source files and changes will be reflected live. (default: disabled)"
echo " --broker Set the URL used to connect to the celery broker (default: $DEFAULT_BROKER)"
if [[ $plugins_volume = false ]]; then
echo " --mount-plugins-volume Always mount the ./plugins volume, even on unsupported platforms (developers only) (default: disabled)"
@@ -184,7 +191,12 @@ run(){
}
start(){
- echo "Starting WebODM..."
+ if [[ $dev_mode = true ]]; then
+ echo "Starting WebODM in development mode..."
+ down
+ else
+ echo "Starting WebODM..."
+ fi
echo ""
echo "Using the following environment:"
echo "================================"
@@ -205,6 +217,10 @@ start(){
if [[ $load_default_node = true ]]; then
command+=" -f docker-compose.nodeodm.yml"
fi
+
+ if [[ $dev_mode = true ]]; then
+ command+=" -f docker-compose.dev.yml"
+ fi
if [ "$WO_SSL" = "YES" ]; then
if [ ! -z "$WO_SSL_KEY" ] && [ ! -e "$WO_SSL_KEY" ]; then
@@ -328,14 +344,22 @@ plugin_disable(){
}
run_tests(){
- echo -e "\033[1mRunning frontend tests\033[0m"
- run "npm run test"
-
- echo "\033[1mRunning backend tests\033[0m"
- run "python manage.py test"
-
- echo ""
- echo -e "\033[1mDone!\033[0m Everything looks in order."
+ # If in a container, we run the actual test commands
+ # otherwise we launch this command from the container
+ in_container=$(grep 'docker\|lxc' /proc/1/cgroup || true)
+ if [[ "$in_container" != "" ]]; then
+ echo -e "\033[1mRunning frontend tests\033[0m"
+ run "npm run test"
+
+ echo "\033[1mRunning backend tests\033[0m"
+ run "python manage.py test"
+
+ echo ""
+ echo -e "\033[1mDone!\033[0m Everything looks in order."
+ else
+ echo "Running tests in webapp container"
+ run "docker-compose exec webapp /bin/bash -c \"/webodm/webodm.sh test\""
+ fi
}
resetpassword(){