|
@ -18,7 +18,7 @@ RUN printf "deb http://mirror.steadfast.net/debian/ stable main contrib n
|
|||
RUN printf "deb http://mirror.steadfast.net/debian/ testing main contrib non-free\ndeb-src http://mirror.steadfast.net/debian/ testing main contrib non-free" > /etc/apt/sources.list.d/testing.list
|
||||
|
||||
# Install Node.js GDAL, nginx, letsencrypt, psql
|
||||
RUN apt-get -qq update && apt-get -qq install -t testing -y binutils libproj-dev gdal-bin nginx && apt-get -qq install -y gettext-base cron certbot postgresql-client-9.6
|
||||
RUN apt-get -qq update && apt-get -qq install -t testing -y binutils libproj-dev gdal-bin nginx grass-core && apt-get -qq install -y gettext-base cron certbot postgresql-client-9.6
|
||||
|
||||
|
||||
# Install pip reqs
|
||||
|
|
|
@ -189,7 +189,7 @@ Developer, I'm looking to build an app that will stay behind a firewall and just
|
|||
- [X] 2D Map Display
|
||||
- [X] 3D Model Display
|
||||
- [ ] NDVI display
|
||||
- [ ] Volumetric Measurements
|
||||
- [X] Volumetric Measurements
|
||||
- [X] Cluster management and setup.
|
||||
- [ ] Mission Planner
|
||||
- [X] Plugins/Webhooks System
|
||||
|
|
|
@ -21,7 +21,6 @@ class TaskIDsSerializer(serializers.BaseSerializer):
|
|||
def to_representation(self, obj):
|
||||
return obj.id
|
||||
|
||||
|
||||
class TaskSerializer(serializers.ModelSerializer):
|
||||
project = serializers.PrimaryKeyRelatedField(queryset=models.Project.objects.all())
|
||||
processing_node = serializers.PrimaryKeyRelatedField(queryset=ProcessingNode.objects.all())
|
||||
|
@ -193,15 +192,15 @@ class TaskNestedView(APIView):
|
|||
queryset = models.Task.objects.all().defer('orthophoto_extent', 'dtm_extent', 'dsm_extent', 'console_output', )
|
||||
permission_classes = (IsAuthenticatedOrReadOnly, )
|
||||
|
||||
def get_and_check_task(self, request, pk, project_pk, annotate={}):
|
||||
def get_and_check_task(self, request, pk, annotate={}):
|
||||
try:
|
||||
task = self.queryset.annotate(**annotate).get(pk=pk, project=project_pk)
|
||||
task = self.queryset.annotate(**annotate).get(pk=pk)
|
||||
except (ObjectDoesNotExist, ValidationError):
|
||||
raise exceptions.NotFound()
|
||||
|
||||
# Check for permissions, unless the task is public
|
||||
if not task.public:
|
||||
get_and_check_project(request, project_pk)
|
||||
get_and_check_project(request, task.project.id)
|
||||
|
||||
return task
|
||||
|
||||
|
@ -211,7 +210,7 @@ class TaskTiles(TaskNestedView):
|
|||
"""
|
||||
Get a tile image
|
||||
"""
|
||||
task = self.get_and_check_task(request, pk, project_pk)
|
||||
task = self.get_and_check_task(request, pk)
|
||||
tile_path = task.get_tile_path(tile_type, z, x, y)
|
||||
if os.path.isfile(tile_path):
|
||||
tile = open(tile_path, "rb")
|
||||
|
@ -225,7 +224,7 @@ class TaskTilesJson(TaskNestedView):
|
|||
"""
|
||||
Get tile.json for this tasks's asset type
|
||||
"""
|
||||
task = self.get_and_check_task(request, pk, project_pk)
|
||||
task = self.get_and_check_task(request, pk)
|
||||
|
||||
extent_map = {
|
||||
'orthophoto': task.orthophoto_extent,
|
||||
|
@ -256,7 +255,7 @@ class TaskDownloads(TaskNestedView):
|
|||
"""
|
||||
Downloads a task asset (if available)
|
||||
"""
|
||||
task = self.get_and_check_task(request, pk, project_pk)
|
||||
task = self.get_and_check_task(request, pk)
|
||||
|
||||
# Check and download
|
||||
try:
|
||||
|
@ -284,7 +283,7 @@ class TaskAssets(TaskNestedView):
|
|||
"""
|
||||
Downloads a task asset (if available)
|
||||
"""
|
||||
task = self.get_and_check_task(request, pk, project_pk)
|
||||
task = self.get_and_check_task(request, pk)
|
||||
|
||||
# Check for directory traversal attacks
|
||||
try:
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
from django.conf.urls import url, include
|
||||
|
||||
from app.api.presets import PresetViewSet
|
||||
from app.plugins import get_api_url_patterns
|
||||
from .projects import ProjectViewSet
|
||||
from .tasks import TaskViewSet, TaskTiles, TaskTilesJson, TaskDownloads, TaskAssets
|
||||
from .processingnodes import ProcessingNodeViewSet, ProcessingNodeOptionsView
|
||||
|
@ -31,4 +32,6 @@ urlpatterns = [
|
|||
|
||||
url(r'^auth/', include('rest_framework.urls')),
|
||||
url(r'^token-auth/', obtain_jwt_token),
|
||||
]
|
||||
]
|
||||
|
||||
urlpatterns += get_api_url_patterns()
|
46
app/boot.py
|
@ -3,7 +3,7 @@ import os
|
|||
import kombu
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User, Group
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned
|
||||
from django.core.files import File
|
||||
from django.db.utils import ProgrammingError
|
||||
from guardian.shortcuts import assign_perm
|
||||
|
@ -35,6 +35,10 @@ def boot():
|
|||
if settings.DEBUG:
|
||||
logger.warning("Debug mode is ON (for development this is OK)")
|
||||
|
||||
# Make sure our app/media/tmp folder exists
|
||||
if not os.path.exists(settings.MEDIA_TMP):
|
||||
os.mkdir(settings.MEDIA_TMP)
|
||||
|
||||
# Check default group
|
||||
try:
|
||||
default_group, created = Group.objects.get_or_create(name='Default')
|
||||
|
@ -60,18 +64,7 @@ def boot():
|
|||
# Add permission to view processing nodes
|
||||
default_group.permissions.add(Permission.objects.get(codename="view_processingnode"))
|
||||
|
||||
# Add default presets
|
||||
Preset.objects.get_or_create(name='DSM + DTM', system=True,
|
||||
options=[{'name': 'dsm', 'value': True}, {'name': 'dtm', 'value': True}, {'name': 'mesh-octree-depth', 'value': 11}])
|
||||
Preset.objects.get_or_create(name='Fast Orthophoto', system=True,
|
||||
options=[{'name': 'fast-orthophoto', 'value': True}])
|
||||
Preset.objects.get_or_create(name='High Quality', system=True,
|
||||
options=[{'name': 'dsm', 'value': True},
|
||||
{'name': 'mesh-octree-depth', 'value': "12"},
|
||||
{'name': 'dem-resolution', 'value': "0.04"},
|
||||
{'name': 'orthophoto-resolution', 'value': "40"},
|
||||
])
|
||||
Preset.objects.get_or_create(name='Default', system=True, options=[{'name': 'dsm', 'value': True}, {'name': 'mesh-octree-depth', 'value': 11}])
|
||||
add_default_presets()
|
||||
|
||||
# Add settings
|
||||
default_theme, created = Theme.objects.get_or_create(name='Default')
|
||||
|
@ -101,4 +94,29 @@ def boot():
|
|||
|
||||
|
||||
except ProgrammingError:
|
||||
logger.warning("Could not touch the database. If running a migration, this is expected.")
|
||||
logger.warning("Could not touch the database. If running a migration, this is expected.")
|
||||
|
||||
|
||||
def add_default_presets():
|
||||
try:
|
||||
Preset.objects.update_or_create(name='DSM + DTM', system=True,
|
||||
defaults={
|
||||
'options': [{'name': 'dsm', 'value': True}, {'name': 'dtm', 'value': True},
|
||||
{'name': 'mesh-octree-depth', 'value': 6}]})
|
||||
Preset.objects.update_or_create(name='Fast Orthophoto', system=True,
|
||||
defaults={'options': [{'name': 'fast-orthophoto', 'value': True}]})
|
||||
Preset.objects.update_or_create(name='High Quality', system=True,
|
||||
defaults={'options': [{'name': 'dsm', 'value': True},
|
||||
{'name': 'mesh-octree-depth', 'value': "12"},
|
||||
{'name': 'dem-resolution', 'value': "0.04"},
|
||||
{'name': 'orthophoto-resolution', 'value': "40"},
|
||||
]})
|
||||
Preset.objects.update_or_create(name='Default', system=True,
|
||||
defaults={'options': [{'name': 'dsm', 'value': True},
|
||||
{'name': 'mesh-octree-depth', 'value': 6}]})
|
||||
except MultipleObjectsReturned:
|
||||
# Mostly to handle a legacy code problem where
|
||||
# multiple system presets with the same name were
|
||||
# created if we changed the options
|
||||
Preset.objects.filter(system=True).delete()
|
||||
add_default_presets()
|
||||
|
|
|
@ -578,7 +578,7 @@ class Task(models.Model):
|
|||
self.status = status_codes.FAILED
|
||||
self.pending_action = None
|
||||
self.save()
|
||||
|
||||
|
||||
def find_all_files_matching(self, regex):
|
||||
directory = full_task_directory_path(self.id, self.project.id)
|
||||
return [os.path.join(directory, f) for f in os.listdir(directory) if
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import os
|
||||
import logging
|
||||
import importlib
|
||||
import subprocess
|
||||
|
||||
import django
|
||||
import json
|
||||
|
@ -13,30 +14,57 @@ logger = logging.getLogger('app.logger')
|
|||
|
||||
def register_plugins():
|
||||
for plugin in get_active_plugins():
|
||||
|
||||
# Check for package.json in public directory
|
||||
# and run npm install if needed
|
||||
if plugin.path_exists("public/package.json") and not plugin.path_exists("public/node_modules"):
|
||||
logger.info("Running npm install for {}".format(plugin.get_name()))
|
||||
subprocess.call(['npm', 'install'], cwd=plugin.get_path("public"))
|
||||
|
||||
# Check for webpack.config.js (if we need to build it)
|
||||
if plugin.path_exists("public/webpack.config.js") and not plugin.path_exists("public/build"):
|
||||
logger.info("Running webpack for {}".format(plugin.get_name()))
|
||||
subprocess.call(['webpack'], cwd=plugin.get_path("public"))
|
||||
|
||||
plugin.register()
|
||||
logger.info("Registered {}".format(plugin))
|
||||
|
||||
|
||||
def get_url_patterns():
|
||||
def get_app_url_patterns():
|
||||
"""
|
||||
@return the patterns to expose the /public directory of each plugin (if needed)
|
||||
@return the patterns to expose the /public directory of each plugin (if needed) and
|
||||
each mount point
|
||||
"""
|
||||
url_patterns = []
|
||||
for plugin in get_active_plugins():
|
||||
for mount_point in plugin.mount_points():
|
||||
for mount_point in plugin.app_mount_points():
|
||||
url_patterns.append(url('^plugins/{}/{}'.format(plugin.get_name(), mount_point.url),
|
||||
mount_point.view,
|
||||
*mount_point.args,
|
||||
**mount_point.kwargs))
|
||||
|
||||
if plugin.has_public_path():
|
||||
if plugin.path_exists("public"):
|
||||
url_patterns.append(url('^plugins/{}/(.*)'.format(plugin.get_name()),
|
||||
django.views.static.serve,
|
||||
{'document_root': plugin.get_path("public")}))
|
||||
|
||||
return url_patterns
|
||||
|
||||
def get_api_url_patterns():
|
||||
"""
|
||||
@return the patterns to expose the plugin API mount points (if any)
|
||||
"""
|
||||
url_patterns = []
|
||||
for plugin in get_active_plugins():
|
||||
for mount_point in plugin.api_mount_points():
|
||||
url_patterns.append(url('^plugins/{}/{}'.format(plugin.get_name(), mount_point.url),
|
||||
mount_point.view,
|
||||
*mount_point.args,
|
||||
**mount_point.kwargs))
|
||||
|
||||
return url_patterns
|
||||
|
||||
|
||||
plugins = None
|
||||
def get_active_plugins():
|
||||
# Cache plugins search
|
||||
|
@ -86,6 +114,12 @@ def get_active_plugins():
|
|||
return plugins
|
||||
|
||||
|
||||
def get_plugin_by_name(name):
|
||||
plugins = get_active_plugins()
|
||||
res = list(filter(lambda p: p.get_name() == name, plugins))
|
||||
return res[0] if res else None
|
||||
|
||||
|
||||
def get_plugins_path():
|
||||
current_path = os.path.dirname(os.path.realpath(__file__))
|
||||
return os.path.abspath(os.path.join(current_path, "..", "..", "plugins"))
|
||||
|
|
|
@ -0,0 +1,117 @@
|
|||
import logging
|
||||
import shutil
|
||||
import tempfile
|
||||
import subprocess
|
||||
import os
|
||||
import geojson
|
||||
|
||||
from string import Template
|
||||
|
||||
from webodm import settings
|
||||
|
||||
logger = logging.getLogger('app.logger')
|
||||
|
||||
class GrassEngine:
|
||||
def __init__(self):
|
||||
self.grass_binary = shutil.which('grass7') or \
|
||||
shutil.which('grass72') or \
|
||||
shutil.which('grass74') or \
|
||||
shutil.which('grass76')
|
||||
|
||||
if self.grass_binary is None:
|
||||
logger.warning("Could not find a GRASS 7 executable. GRASS scripts will not work.")
|
||||
else:
|
||||
logger.info("Initializing GRASS engine using {}".format(self.grass_binary))
|
||||
|
||||
def create_context(self, serialized_context = {}):
|
||||
if self.grass_binary is None: raise GrassEngineException("GRASS engine is unavailable")
|
||||
return GrassContext(self.grass_binary, **serialized_context)
|
||||
|
||||
|
||||
class GrassContext:
|
||||
def __init__(self, grass_binary, tmpdir = None, template_args = {}, location = None):
|
||||
self.grass_binary = grass_binary
|
||||
if tmpdir is None:
|
||||
tmpdir = os.path.basename(tempfile.mkdtemp('_grass_engine', dir=settings.MEDIA_TMP))
|
||||
self.tmpdir = tmpdir
|
||||
self.template_args = template_args
|
||||
self.location = location
|
||||
|
||||
def get_cwd(self):
|
||||
return os.path.join(settings.MEDIA_TMP, self.tmpdir)
|
||||
|
||||
def add_file(self, filename, source, use_as_location=False):
|
||||
param = os.path.splitext(filename)[0] # filename without extension
|
||||
|
||||
dst_path = os.path.abspath(os.path.join(self.get_cwd(), filename))
|
||||
with open(dst_path, 'w') as f:
|
||||
f.write(source)
|
||||
self.template_args[param] = dst_path
|
||||
|
||||
if use_as_location:
|
||||
self.set_location(self.template_args[param])
|
||||
|
||||
return dst_path
|
||||
|
||||
def add_param(self, param, value):
|
||||
self.template_args[param] = value
|
||||
|
||||
def set_location(self, location):
|
||||
"""
|
||||
:param location: either a "epsg:XXXXX" string or a path to a geospatial file defining the location
|
||||
"""
|
||||
if not location.startswith('epsg:'):
|
||||
location = os.path.abspath(location)
|
||||
self.location = location
|
||||
|
||||
def execute(self, script):
|
||||
"""
|
||||
:param script: path to .grass script
|
||||
:return: script output
|
||||
"""
|
||||
if self.location is None: raise GrassEngineException("Location is not set")
|
||||
|
||||
script = os.path.abspath(script)
|
||||
|
||||
# Create grass script via template substitution
|
||||
try:
|
||||
with open(script) as f:
|
||||
script_content = f.read()
|
||||
except FileNotFoundError:
|
||||
raise GrassEngineException("Script does not exist: {}".format(script))
|
||||
|
||||
tmpl = Template(script_content)
|
||||
|
||||
# Write script to disk
|
||||
with open(os.path.join(self.get_cwd(), 'script.sh'), 'w') as f:
|
||||
f.write(tmpl.substitute(self.template_args))
|
||||
|
||||
# Execute it
|
||||
p = subprocess.Popen([self.grass_binary, '-c', self.location, 'location', '--exec', 'sh', 'script.sh'],
|
||||
cwd=self.get_cwd(), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
out, err = p.communicate()
|
||||
|
||||
out = out.decode('utf-8').strip()
|
||||
err = err.decode('utf-8').strip()
|
||||
|
||||
if p.returncode == 0:
|
||||
return out
|
||||
else:
|
||||
raise GrassEngineException("Could not execute GRASS script {} from {}: {}".format(script, self.get_cwd(), err))
|
||||
|
||||
def serialize(self):
|
||||
return {
|
||||
'tmpdir': self.tmpdir,
|
||||
'template_args': self.template_args,
|
||||
'location': self.location
|
||||
}
|
||||
|
||||
def __del__(self):
|
||||
# Cleanup
|
||||
if os.path.exists(self.get_cwd()):
|
||||
shutil.rmtree(self.get_cwd())
|
||||
|
||||
class GrassEngineException(Exception):
|
||||
pass
|
||||
|
||||
grass = GrassEngine()
|
|
@ -5,7 +5,7 @@ class MountPoint:
|
|||
"""
|
||||
|
||||
:param url: path to mount this view to, relative to plugins directory
|
||||
:param view: Django view
|
||||
:param view: Django/DjangoRestFramework view
|
||||
:param args: extra args to pass to url() call
|
||||
:param kwargs: extra kwargs to pass to url() call
|
||||
"""
|
||||
|
|
|
@ -46,8 +46,8 @@ class PluginBase(ABC):
|
|||
"""
|
||||
return "plugins/{}/templates/{}".format(self.get_name(), path)
|
||||
|
||||
def has_public_path(self):
|
||||
return os.path.isdir(self.get_path("public"))
|
||||
def path_exists(self, path):
|
||||
return os.path.exists(self.get_path(path))
|
||||
|
||||
def include_js_files(self):
|
||||
"""
|
||||
|
@ -73,7 +73,7 @@ class PluginBase(ABC):
|
|||
"""
|
||||
return []
|
||||
|
||||
def mount_points(self):
|
||||
def app_mount_points(self):
|
||||
"""
|
||||
Should be overriden by plugins that want to connect
|
||||
custom Django views
|
||||
|
@ -81,5 +81,13 @@ class PluginBase(ABC):
|
|||
"""
|
||||
return []
|
||||
|
||||
def api_mount_points(self):
|
||||
"""
|
||||
Should be overriden by plugins that want to add
|
||||
new API mount points
|
||||
:return: [] of MountPoint objects
|
||||
"""
|
||||
return []
|
||||
|
||||
def __str__(self):
|
||||
return "[{}]".format(self.get_module_name())
|
|
@ -200,7 +200,7 @@ pre.prettyprint,
|
|||
}
|
||||
|
||||
/* Failed */
|
||||
.task-list-item .status-label.error{
|
||||
.task-list-item .status-label.error, .theme-background-failed{
|
||||
background-color: theme("failed");
|
||||
}
|
||||
|
||||
|
|
|
@ -11,10 +11,17 @@ if (!window.PluginsAPI){
|
|||
SystemJS.config({
|
||||
baseURL: '/plugins',
|
||||
map: {
|
||||
css: '/static/app/js/vendor/css.js'
|
||||
'css': '/static/app/js/vendor/css.js',
|
||||
'globals-loader': '/static/app/js/vendor/globals-loader.js'
|
||||
},
|
||||
meta: {
|
||||
'*.css': { loader: 'css' }
|
||||
'*.css': { loader: 'css' },
|
||||
|
||||
// Globals always available in the window object
|
||||
'jQuery': { loader: 'globals-loader', exports: '$' },
|
||||
'leaflet': { loader: 'globals-loader', exports: 'L' },
|
||||
'ReactDOM': { loader: 'globals-loader', exports: 'ReactDOM' },
|
||||
'React': { loader: 'globals-loader', exports: 'React' }
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -41,11 +41,16 @@ export default class ApiFactory{
|
|||
};
|
||||
}
|
||||
|
||||
const obj = {};
|
||||
let obj = {};
|
||||
api.endpoints.forEach(endpoint => {
|
||||
if (!Array.isArray(endpoint)) endpoint = [endpoint];
|
||||
addEndpoint(obj, ...endpoint);
|
||||
});
|
||||
|
||||
if (api.helpers){
|
||||
obj = Object.assign(obj, api.helpers);
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import Utils from '../Utils';
|
||||
import L from 'leaflet';
|
||||
|
||||
const { assert } = Utils;
|
||||
|
||||
|
|
|
@ -268,7 +268,7 @@ class Map extends React.Component {
|
|||
|
||||
handleMapMouseDown(e){
|
||||
// Make sure the share popup closes
|
||||
this.shareButton.hidePopup();
|
||||
if (this.sharePopup) this.shareButton.hidePopup();
|
||||
}
|
||||
|
||||
render() {
|
||||
|
|
|
@ -16,10 +16,17 @@
|
|||
}
|
||||
}
|
||||
|
||||
.leaflet-right .leaflet-control,
|
||||
.leaflet-control-measure.leaflet-control{
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-layers-toggle{
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background-size: 20px;
|
||||
}
|
||||
|
||||
.popup-opacity-slider{
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
import '../css/main.scss';
|
||||
import './django/csrf';
|
||||
import ReactDOM from 'react-dom';
|
||||
import React from 'react';
|
||||
import PluginsAPI from './classes/plugins/API';
|
||||
|
||||
// Main is always executed first in the page
|
||||
|
||||
// We share the ReactDOM object to avoid having to include it
|
||||
// We share some objects to avoid having to include them
|
||||
// as a dependency in each component (adds too much space overhead)
|
||||
window.ReactDOM = ReactDOM;
|
||||
window.React = React;
|
||||
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
SystemJS Globals loader plugin
|
||||
Piero Toffanin 2018
|
||||
*/
|
||||
|
||||
// this code simply allows loading of global modules
|
||||
// that are already defined in the window object
|
||||
exports.fetch = function(load) {
|
||||
var moduleName = load.name.split("/").pop();
|
||||
return moduleName;
|
||||
}
|
||||
|
||||
exports.instantiate = function(load){
|
||||
return window[load.source] || window[load.metadata.exports];
|
||||
}
|
|
@ -1,6 +1,9 @@
|
|||
import os
|
||||
|
||||
from django.test import Client
|
||||
from rest_framework import status
|
||||
|
||||
from app.plugins import get_plugin_by_name
|
||||
from .classes import BootTestCase
|
||||
|
||||
class TestPlugins(BootTestCase):
|
||||
|
@ -37,6 +40,10 @@ class TestPlugins(BootTestCase):
|
|||
# And our menu entry
|
||||
self.assertContains(res, '<li><a href="/plugins/test/menu_url/"><i class="test-icon"></i> Test</a></li>', html=True)
|
||||
|
||||
# A node_modules directory has been created as a result of npm install
|
||||
# because we have a package.json in the public director
|
||||
test_plugin = get_plugin_by_name("test")
|
||||
self.assertTrue(os.path.exists(test_plugin.get_path("public/node_modules")))
|
||||
|
||||
# TODO:
|
||||
# test API endpoints
|
||||
# test python hooks
|
||||
# test GRASS engine
|
||||
|
|
|
@ -4,7 +4,7 @@ from django.shortcuts import render_to_response
|
|||
from django.template import RequestContext
|
||||
|
||||
from .views import app as app_views, public as public_views
|
||||
from .plugins import get_url_patterns
|
||||
from .plugins import get_app_url_patterns
|
||||
|
||||
from app.boot import boot
|
||||
from webodm import settings
|
||||
|
@ -30,7 +30,7 @@ urlpatterns = [
|
|||
|
||||
# TODO: is there a way to place plugins /public directories
|
||||
# into the static build directories and let nginx serve them?
|
||||
urlpatterns += get_url_patterns()
|
||||
urlpatterns += get_app_url_patterns()
|
||||
|
||||
handler404 = app_views.handler404
|
||||
handler500 = app_views.handler500
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "WebODM",
|
||||
"version": "0.5.0",
|
||||
"version": "0.5.1",
|
||||
"description": "Open Source Drone Image Processing",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
@ -42,7 +42,7 @@
|
|||
"immutability-helper": "^2.0.0",
|
||||
"jest": "^21.0.1",
|
||||
"json-loader": "^0.5.4",
|
||||
"leaflet": "^1.0.1",
|
||||
"leaflet": "^1.3.1",
|
||||
"node-sass": "^3.10.1",
|
||||
"object.values": "^1.0.3",
|
||||
"proj4": "^2.4.3",
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
import os
|
||||
|
||||
import json
|
||||
from rest_framework import serializers
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
from app.api.tasks import TaskNestedView
|
||||
|
||||
from worker.tasks import execute_grass_script
|
||||
|
||||
from app.plugins.grass_engine import grass, GrassEngineException
|
||||
from geojson import Feature, Point, FeatureCollection
|
||||
|
||||
class GeoJSONSerializer(serializers.Serializer):
|
||||
area = serializers.JSONField(help_text="Polygon contour defining the volume area to compute")
|
||||
|
||||
|
||||
class TaskVolume(TaskNestedView):
|
||||
def post(self, request, pk=None):
|
||||
task = self.get_and_check_task(request, pk)
|
||||
if task.dsm_extent is None:
|
||||
return Response({'error': 'No surface model available'})
|
||||
|
||||
serializer = GeoJSONSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
area = serializer['area'].value
|
||||
points = FeatureCollection([Feature(geometry=Point(coords)) for coords in area['geometry']['coordinates'][0]])
|
||||
dsm = os.path.abspath(task.get_asset_download_path("dsm.tif"))
|
||||
|
||||
try:
|
||||
context = grass.create_context()
|
||||
context.add_file('area_file.geojson', json.dumps(area))
|
||||
context.add_file('points_file.geojson', str(points))
|
||||
context.add_param('dsm_file', dsm)
|
||||
context.set_location(dsm)
|
||||
|
||||
output = execute_grass_script.delay(os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)),
|
||||
"calc_volume.grass"
|
||||
), context.serialize()).get()
|
||||
if isinstance(output, dict) and 'error' in output: raise GrassEngineException(output['error'])
|
||||
|
||||
cols = output.split(':')
|
||||
if len(cols) == 7:
|
||||
return Response({'volume': str(abs(float(cols[6])))}, status=status.HTTP_200_OK)
|
||||
else:
|
||||
raise GrassEngineException(output)
|
||||
except GrassEngineException as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
# area_file: Geospatial file containing the area to measure
|
||||
# points_file: Geospatial file containing the points defining the area
|
||||
# dsm_file: GeoTIFF DEM containing the surface
|
||||
# ------
|
||||
# output: prints the volume to stdout
|
||||
|
||||
v.import input=${area_file} output=polygon_area --overwrite
|
||||
v.import input=${points_file} output=polygon_points --overwrite
|
||||
v.buffer -s --overwrite input=polygon_area type=area output=region distance=3 minordistance=3
|
||||
r.external input=${dsm_file} output=dsm --overwrite
|
||||
|
||||
g.region rast=dsm
|
||||
g.region vector=region
|
||||
|
||||
# prevent : removing eventual existing mask
|
||||
r.mask -r
|
||||
r.mask vect=region
|
||||
|
||||
v.what.rast map=polygon_points raster=dsm column=height
|
||||
v.to.rast input=polygon_area output=r_polygon_area use=val value=255 --overwrite
|
||||
|
||||
#v.surf.rst --overwrite input=polygon_points zcolumn=height elevation=dsm_below_pile mask=r_polygon_area
|
||||
v.surf.bspline --overwrite input=polygon_points column=height raster_output=dsm_below_pile lambda_i=100
|
||||
|
||||
r.mapcalc expression='pile_height_above_dsm=dsm-dsm_below_pile' --overwrite
|
||||
r.volume -f input=pile_height_above_dsm clump=r_polygon_area
|
|
@ -1,13 +1,13 @@
|
|||
{
|
||||
"name": "Area/Length Measurements",
|
||||
"name": "Volume/Area/Length Measurements",
|
||||
"webodmMinVersion": "0.5.0",
|
||||
"description": "A plugin to compute area and length measurements on Leaflet",
|
||||
"description": "A plugin to compute volume, area and length measurements on Leaflet",
|
||||
"version": "0.1.0",
|
||||
"author": "Piero Toffanin",
|
||||
"author": "Abdelkoddouss Izem, Piero Toffanin",
|
||||
"email": "pt@masseranolabs.com",
|
||||
"repository": "https://github.com/OpenDroneMap/WebODM",
|
||||
"tags": ["area", "length", "measurements"],
|
||||
"tags": ["volume", "area", "length", "measurements"],
|
||||
"homepage": "https://github.com/OpenDroneMap/WebODM",
|
||||
"experimental": false,
|
||||
"experimental": true,
|
||||
"deprecated": false
|
||||
}
|
|
@ -1,5 +1,12 @@
|
|||
from app.plugins import MountPoint
|
||||
from app.plugins import PluginBase
|
||||
from .api import TaskVolume
|
||||
|
||||
class Plugin(PluginBase):
|
||||
def include_js_files(self):
|
||||
return ['main.js']
|
||||
return ['main.js']
|
||||
|
||||
def api_mount_points(self):
|
||||
return [
|
||||
MountPoint('task/(?P<pk>[^/.]+)/volume', TaskVolume.as_view())
|
||||
]
|
||||
|
|
|
@ -0,0 +1,103 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './MeasurePopup.scss';
|
||||
import $ from 'jquery';
|
||||
import L from 'leaflet';
|
||||
|
||||
module.exports = class MeasurePopup extends React.Component {
|
||||
static defaultProps = {
|
||||
map: {},
|
||||
model: {},
|
||||
resultFeature: {}
|
||||
};
|
||||
static propTypes = {
|
||||
map: PropTypes.object.isRequired,
|
||||
model: PropTypes.object.isRequired,
|
||||
resultFeature: PropTypes.object.isRequired
|
||||
}
|
||||
|
||||
constructor(props){
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
volume: null, // to be calculated
|
||||
error: ""
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount(){
|
||||
this.calculateVolume();
|
||||
}
|
||||
|
||||
calculateVolume(){
|
||||
const { lastCoord } = this.props.model;
|
||||
let layers = this.getLayersAtCoords(L.latLng(
|
||||
lastCoord.dd.y,
|
||||
lastCoord.dd.x
|
||||
));
|
||||
|
||||
console.log(layers);
|
||||
|
||||
// Did we select a layer?
|
||||
if (layers.length > 0){
|
||||
const layer = layers[layers.length - 1];
|
||||
const meta = layer[Symbol.for("meta")];
|
||||
if (meta){
|
||||
const task = meta.task;
|
||||
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: `/api/plugins/measure/task/${task.id}/volume`,
|
||||
data: JSON.stringify({'area': this.props.resultFeature.toGeoJSON()}),
|
||||
contentType: "application/json"
|
||||
}).done(result => {
|
||||
if (result.volume){
|
||||
this.setState({volume: parseFloat(result.volume)});
|
||||
}else if (result.error){
|
||||
this.setState({error: result.error});
|
||||
}else{
|
||||
this.setState({error: "Invalid response: " + result});
|
||||
}
|
||||
}).fail(error => {
|
||||
this.setState({error});
|
||||
});
|
||||
}else{
|
||||
console.warn("Cannot find [meta] symbol for layer: ", layer);
|
||||
this.setState({volume: false});
|
||||
}
|
||||
}else{
|
||||
this.setState({volume: false});
|
||||
}
|
||||
}
|
||||
|
||||
// @return the layers in the map
|
||||
// at a specific lat/lon
|
||||
getLayersAtCoords(latlng){
|
||||
const targetBounds = L.latLngBounds(latlng, latlng);
|
||||
|
||||
const intersects = [];
|
||||
for (let l in this.props.map._layers){
|
||||
const layer = this.props.map._layers[l];
|
||||
|
||||
if (layer.options && layer.options.bounds){
|
||||
if (targetBounds.intersects(layer.options.bounds)){
|
||||
intersects.push(layer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return intersects;
|
||||
}
|
||||
|
||||
render(){
|
||||
const { volume, error } = this.state;
|
||||
|
||||
return (<div className="plugin-measure popup">
|
||||
<p>Area: {this.props.model.areaDisplay}</p>
|
||||
<p>Perimeter: {this.props.model.lengthDisplay}</p>
|
||||
{volume === null && !error && <p>Volume: <i>computing...</i> <i className="fa fa-cog fa-spin fa-fw" /></p>}
|
||||
{typeof volume === "number" && <p>Volume: {volume.toFixed("2")} Cubic Meters ({(volume * 35.3147).toFixed(2)} Cubic Feet)</p>}
|
||||
{error && <p>Volume: <span className={"error theme-background-failed " + (error.length > 200 ? 'long' : '')}>{error}</span></p>}
|
||||
</div>);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
.plugin-measure.popup{
|
||||
p{
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error.long{
|
||||
overflow: scroll;
|
||||
display: block;
|
||||
max-height: 200px;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
import L from 'leaflet';
|
||||
import './app.scss';
|
||||
import 'leaflet-measure-ex/dist/leaflet-measure';
|
||||
import 'leaflet-measure-ex/dist/leaflet-measure.css';
|
||||
import MeasurePopup from './MeasurePopup';
|
||||
import ReactDOM from 'ReactDOM';
|
||||
import React from 'react';
|
||||
import $ from 'jquery';
|
||||
|
||||
module.exports = class App{
|
||||
constructor(map){
|
||||
this.map = map;
|
||||
|
||||
L.control.measure({
|
||||
labels:{
|
||||
measureDistancesAndAreas: 'Measure volume, area and length',
|
||||
areaMeasurement: 'Measurement'
|
||||
},
|
||||
primaryLengthUnit: 'meters',
|
||||
secondaryLengthUnit: 'feet',
|
||||
primaryAreaUnit: 'sqmeters',
|
||||
secondaryAreaUnit: 'acres'
|
||||
}).addTo(map);
|
||||
|
||||
map.on('measurepopupshown', ({popupContainer, model, resultFeature}) => {
|
||||
// Only modify area popup, length popup is fine as default
|
||||
if (model.area !== 0){
|
||||
const $container = $("<div/>"),
|
||||
$popup = $(popupContainer);
|
||||
|
||||
$popup.children("p").empty();
|
||||
$popup.children("h3:first-child").after($container);
|
||||
|
||||
ReactDOM.render(<MeasurePopup
|
||||
model={model}
|
||||
resultFeature={resultFeature}
|
||||
map={map} />, $container.get(0));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
.leaflet-control-measure,
|
||||
.leaflet-measure-resultpopup{
|
||||
h3{
|
||||
font-size: 120%;
|
||||
}
|
||||
}
|
||||
|
||||
.leaflet-control-measure-interaction{
|
||||
a{
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
line-height: auto !important;
|
||||
display: initial !important;
|
||||
|
||||
&:hover{
|
||||
background-color: inherit !important;
|
||||
}
|
||||
}
|
||||
}
|
Przed Szerokość: | Wysokość: | Rozmiar: 397 B |
Przed Szerokość: | Wysokość: | Rozmiar: 762 B |
Przed Szerokość: | Wysokość: | Rozmiar: 387 B |
Przed Szerokość: | Wysokość: | Rozmiar: 692 B |
Przed Szerokość: | Wysokość: | Rozmiar: 326 B |
Przed Szerokość: | Wysokość: | Rozmiar: 462 B |
Przed Szerokość: | Wysokość: | Rozmiar: 192 B |
Przed Szerokość: | Wysokość: | Rozmiar: 277 B |
Przed Szerokość: | Wysokość: | Rozmiar: 491 B |
Przed Szerokość: | Wysokość: | Rozmiar: 1003 B |
Przed Szerokość: | Wysokość: | Rozmiar: 279 B |
Przed Szerokość: | Wysokość: | Rozmiar: 460 B |
|
@ -1 +0,0 @@
|
|||
.leaflet-control-measure h3,.leaflet-measure-resultpopup h3{margin:0 0 12px 0;padding-bottom:10px;line-height:1em;font-weight:normal;font-size:1.1em;border-bottom:solid 1px #DDD}.leaflet-control-measure p,.leaflet-measure-resultpopup p{margin:10px 0 0;line-height:1em}.leaflet-control-measure p:first-child,.leaflet-measure-resultpopup p:first-child{margin-top:0}.leaflet-control-measure a,.leaflet-measure-resultpopup a{color:#5E66CC;text-decoration:none}.leaflet-control-measure a:hover,.leaflet-measure-resultpopup a:hover{opacity:0.5;text-decoration:none}.leaflet-control-measure .tasks,.leaflet-measure-resultpopup .tasks{margin:12px 0 0;padding:10px 0 0;border-top:solid 1px #DDD;list-style:none;list-style-image:none}.leaflet-control-measure .tasks li,.leaflet-measure-resultpopup .tasks li{display:inline;margin:0 10px 0 0}.leaflet-control-measure .tasks li:last-child,.leaflet-measure-resultpopup .tasks li:last-child{margin-right:0}.leaflet-control-measure .coorddivider,.leaflet-measure-resultpopup .coorddivider{color:#999}.leaflet-control-measure{background:#fff;border-radius:5px;box-shadow:0 1px 5px rgba(0,0,0,0.4)}.leaflet-control-measure .leaflet-control-measure-toggle,.leaflet-control-measure .leaflet-control-measure-toggle:hover{display:block;width:36px;height:36px;background-position:50% 50%;background-repeat:no-repeat;background-image:url(images/rulers.png);border-radius:5px;text-indent:100%;white-space:nowrap;overflow:hidden}.leaflet-retina .leaflet-control-measure .leaflet-control-measure-toggle,.leaflet-retina .leaflet-control-measure .leaflet-control-measure-toggle:hover{background-image:url(images/rulers_@2X.png);background-size:16px 16px}.leaflet-touch .leaflet-control-measure .leaflet-control-measure-toggle,.leaflet-touch .leaflet-control-measure .leaflet-control-measure-toggle:hover{width:44px;height:44px}.leaflet-control-measure .startprompt h3{margin-bottom:10px}.leaflet-control-measure .startprompt .tasks{margin-top:0;padding-top:0;border-top:0}.leaflet-control-measure .leaflet-control-measure-interaction{padding:10px 12px}.leaflet-control-measure .results .group{margin-top:10px;padding-top:10px;border-top:dotted 1px #eaeaea}.leaflet-control-measure .results .group:first-child{margin-top:0;padding-top:0;border-top:0}.leaflet-control-measure .results .heading{margin-right:5px;color:#999}.leaflet-control-measure a.start{padding-left:18px;background-repeat:no-repeat;background-position:0% 50%;background-image:url(images/start.png)}.leaflet-retina .leaflet-control-measure a.start{background-image:url(images/start_@2X.png);background-size:12px 12px}.leaflet-control-measure a.cancel{padding-left:18px;background-repeat:no-repeat;background-position:0% 50%;background-image:url(images/cancel.png)}.leaflet-retina .leaflet-control-measure a.cancel{background-image:url(images/cancel_@2X.png);background-size:12px 12px}.leaflet-control-measure a.finish{padding-left:18px;background-repeat:no-repeat;background-position:0% 50%;background-image:url(images/check.png)}.leaflet-retina .leaflet-control-measure a.finish{background-image:url(images/check_@2X.png);background-size:12px 12px}.leaflet-measure-resultpopup a.zoomto{padding-left:18px;background-repeat:no-repeat;background-position:0% 50%;background-image:url(images/focus.png)}.leaflet-retina .leaflet-measure-resultpopup a.zoomto{background-image:url(images/focus_@2X.png);background-size:12px 12px}.leaflet-measure-resultpopup a.deletemarkup{padding-left:18px;background-repeat:no-repeat;background-position:0% 50%;background-image:url(images/trash.png)}.leaflet-retina .leaflet-measure-resultpopup a.deletemarkup{background-image:url(images/trash_@2X.png);background-size:11px 12px}
|
|
@ -1,11 +1,6 @@
|
|||
PluginsAPI.Map.willAddControls([
|
||||
'measure/leaflet-measure.css',
|
||||
'measure/leaflet-measure.min.js'
|
||||
], function(options){
|
||||
L.control.measure({
|
||||
primaryLengthUnit: 'meters',
|
||||
secondaryLengthUnit: 'feet',
|
||||
primaryAreaUnit: 'sqmeters',
|
||||
secondaryAreaUnit: 'acres'
|
||||
}).addTo(options.map);
|
||||
'measure/build/app.js',
|
||||
'measure/build/app.css'
|
||||
], function(options, App){
|
||||
new App(options.map);
|
||||
});
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "measure",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"leaflet-measure-ex": "^3.0.4"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
// Magic to include node_modules of root WebODM's directory
|
||||
process.env.NODE_PATH = "../../../node_modules";
|
||||
require("module").Module._initPaths();
|
||||
|
||||
let path = require("path");
|
||||
let webpack = require('webpack');
|
||||
let ExtractTextPlugin = require('extract-text-webpack-plugin');
|
||||
let LiveReloadPlugin = require('webpack-livereload-plugin');
|
||||
|
||||
module.exports = {
|
||||
context: __dirname,
|
||||
|
||||
entry: {
|
||||
app: ['./app.jsx']
|
||||
},
|
||||
|
||||
output: {
|
||||
path: path.join(__dirname, './build'),
|
||||
filename: "[name].js",
|
||||
libraryTarget: "amd"
|
||||
},
|
||||
|
||||
plugins: [
|
||||
new LiveReloadPlugin(),
|
||||
new ExtractTextPlugin('[name].css', {
|
||||
allChunks: true
|
||||
})
|
||||
],
|
||||
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.jsx?$/,
|
||||
exclude: /(node_modules|bower_components)/,
|
||||
use: [
|
||||
{
|
||||
loader: 'babel-loader',
|
||||
query: {
|
||||
"plugins": [
|
||||
'syntax-class-properties',
|
||||
'transform-class-properties'
|
||||
],
|
||||
presets: ['es2015', 'react']
|
||||
}
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
test: /\.s?css$/,
|
||||
use: ExtractTextPlugin.extract({
|
||||
use: 'css-loader!sass-loader'
|
||||
})
|
||||
},
|
||||
{
|
||||
test: /\.(png|jpg|jpeg|svg)/,
|
||||
loader: "url-loader?limit=100000"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
resolve: {
|
||||
modules: ['node_modules', 'bower_components'],
|
||||
extensions: ['.js', '.jsx']
|
||||
},
|
||||
|
||||
externals: {
|
||||
// require("jquery") is external and available
|
||||
// on the global let jQuery
|
||||
"jquery": "jQuery",
|
||||
"SystemJS": "SystemJS",
|
||||
"PluginsAPI": "PluginsAPI",
|
||||
"leaflet": "leaflet",
|
||||
"ReactDOM": "ReactDOM",
|
||||
"React": "React"
|
||||
}
|
||||
}
|
|
@ -6,7 +6,7 @@ class Plugin(PluginBase):
|
|||
def main_menu(self):
|
||||
return [Menu("GCP Interface", self.public_url(""), "fa fa-map-marker fa-fw")]
|
||||
|
||||
def mount_points(self):
|
||||
def app_mount_points(self):
|
||||
return [
|
||||
MountPoint('$', lambda request: render(request, self.template_path("app.html"), {'title': 'GCP Editor'}))
|
||||
]
|
||||
|
|
|
@ -12,7 +12,7 @@ class Plugin(PluginBase):
|
|||
def include_css_files(self):
|
||||
return ['test.css']
|
||||
|
||||
def mount_points(self):
|
||||
def app_mount_points(self):
|
||||
return [
|
||||
MountPoint('/app_mountpoint/$', lambda request: render(request, self.template_path("app.html"), {'title': 'Test'}))
|
||||
]
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "public",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"pad-left": "^2.1.0"
|
||||
}
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
from .plugin import *
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"name": "Volume Measurements",
|
||||
"webodmMinVersion": "0.5.0",
|
||||
"description": "A plugin to compute volume measurements from a DSM",
|
||||
"version": "0.1.0",
|
||||
"author": "Piero Toffanin",
|
||||
"email": "pt@masseranolabs.com",
|
||||
"repository": "https://github.com/OpenDroneMap/WebODM",
|
||||
"tags": ["volume", "measurements"],
|
||||
"homepage": "https://github.com/OpenDroneMap/WebODM",
|
||||
"experimental": true,
|
||||
"deprecated": false
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
from app.plugins import PluginBase
|
||||
|
||||
class Plugin(PluginBase):
|
||||
def include_js_files(self):
|
||||
return ['hello.js']
|
|
@ -1,6 +0,0 @@
|
|||
PluginsAPI.Map.willAddControls(function(options){
|
||||
console.log("GOT: ", options);
|
||||
});
|
||||
PluginsAPI.Map.didAddControls(function(options){
|
||||
console.log("GOT2: ", options);
|
||||
});
|
|
@ -21,6 +21,7 @@ djangorestframework-jwt==1.9.0
|
|||
drf-nested-routers==0.11.1
|
||||
funcsigs==1.0.2
|
||||
futures==3.0.5
|
||||
geojson==2.3.0
|
||||
gunicorn==19.7.1
|
||||
itypes==1.1.0
|
||||
kombu==4.1.0
|
||||
|
|
|
@ -249,6 +249,7 @@ CORS_ORIGIN_ALLOW_ALL = True
|
|||
|
||||
# File uploads
|
||||
MEDIA_ROOT = os.path.join(BASE_DIR, 'app', 'media')
|
||||
MEDIA_TMP = os.path.join(MEDIA_ROOT, 'tmp')
|
||||
|
||||
# Store flash messages in cookies
|
||||
MESSAGE_STORAGE = 'django.contrib.messages.storage.cookie.CookieStorage'
|
||||
|
|
|
@ -73,6 +73,7 @@ module.exports = {
|
|||
// require("jquery") is external and available
|
||||
// on the global let jQuery
|
||||
"jquery": "jQuery",
|
||||
"SystemJS": "SystemJS"
|
||||
"SystemJS": "SystemJS",
|
||||
"React": "React"
|
||||
}
|
||||
}
|
|
@ -8,6 +8,7 @@ from django.db.models import Q
|
|||
|
||||
from app.models import Project
|
||||
from app.models import Task
|
||||
from app.plugins.grass_engine import grass, GrassEngineException
|
||||
from nodeodm import status_codes
|
||||
from nodeodm.models import ProcessingNode
|
||||
from webodm import settings
|
||||
|
@ -77,3 +78,12 @@ def process_pending_tasks():
|
|||
|
||||
for task in tasks:
|
||||
process_task.delay(task.id)
|
||||
|
||||
|
||||
@app.task
|
||||
def execute_grass_script(script, serialized_context = {}):
|
||||
try:
|
||||
ctx = grass.create_context(serialized_context)
|
||||
return ctx.execute(script)
|
||||
except GrassEngineException as e:
|
||||
return {'error': str(e)}
|