kopia lustrzana https://github.com/OpenDroneMap/WebODM
Merge pull request #1122 from HeDo88TH/add-dronedb-integration
Add DroneDB integration (download & share)pull/1132/head
commit
6a16b1593c
|
@ -74,6 +74,7 @@ target/
|
|||
.python-version
|
||||
|
||||
# celery beat schedule file
|
||||
celerybeat-schedule.db
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
|
@ -101,4 +102,4 @@ package-lock.json
|
|||
|
||||
# Debian builds
|
||||
dpkg/build
|
||||
dpkg/deb
|
||||
dpkg/deb
|
|
@ -0,0 +1,5 @@
|
|||
<h1 align="center">DroneDB</h1>
|
||||
|
||||
DroneDB is a WebODM add-on that enables you to import and export your files to DroneDB.
|
||||
|
||||
|
|
@ -0,0 +1 @@
|
|||
from .plugin import *
|
|
@ -0,0 +1,432 @@
|
|||
from genericpath import isfile
|
||||
import importlib
|
||||
import json
|
||||
from posixpath import join
|
||||
import time
|
||||
import requests
|
||||
import os
|
||||
from os import listdir, path
|
||||
|
||||
from app import models, pending_actions
|
||||
from app.plugins.views import TaskView
|
||||
from app.plugins.worker import run_function_async, task
|
||||
from app.plugins import get_current_plugin
|
||||
from app.models import ImageUpload
|
||||
from app.plugins import GlobalDataStore, get_site_settings, signals as plugin_signals
|
||||
|
||||
from coreplugins.dronedb.ddb import DEFAULT_HUB_URL, DroneDB, parse_url, verify_url
|
||||
|
||||
from django.dispatch import receiver
|
||||
|
||||
from worker.celery import app
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
VALID_IMAGE_EXTENSIONS = ['.tiff', '.tif', '.png', '.jpeg', '.jpg']
|
||||
|
||||
def is_valid(file):
|
||||
_, file_extension = path.splitext(file)
|
||||
return file_extension.lower() in VALID_IMAGE_EXTENSIONS or file == 'gcp_list.txt' or file == 'geo.txt'
|
||||
|
||||
def get_settings(request):
|
||||
ds = get_current_plugin().get_user_data_store(request.user)
|
||||
|
||||
registry_url = ds.get_string('registry_url') or DEFAULT_HUB_URL
|
||||
username = ds.get_string('username') or None
|
||||
password = ds.get_string('password') or None
|
||||
token = ds.get_string('token') or None
|
||||
|
||||
return registry_url, username, password, token
|
||||
|
||||
|
||||
def update_token(request, token):
|
||||
ds = get_current_plugin().get_user_data_store(request.user)
|
||||
ds.set_string('token', token)
|
||||
|
||||
def get_ddb(request):
|
||||
registry_url, username, password, token = get_settings(request)
|
||||
|
||||
if registry_url == None or username == None or password == None:
|
||||
raise ValueError('Credentials must be set.')
|
||||
|
||||
return DroneDB(registry_url, username, password, token, lambda token: update_token(request, token))
|
||||
|
||||
def to_web_protocol(registry_url):
|
||||
return registry_url.replace('ddb+unsafe://', 'http://').replace('ddb://', 'https://').rstrip('/')
|
||||
|
||||
class CheckCredentialsTaskView(TaskView):
|
||||
def post(self, request):
|
||||
|
||||
# Read form data
|
||||
hub_url = request.data.get('hubUrl', None)
|
||||
username = request.data.get('userName', None)
|
||||
password = request.data.get('password', None)
|
||||
|
||||
# Make sure both values are set
|
||||
if hub_url == None or username == None or password == None:
|
||||
return Response({'error': 'All fields must be set.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
|
||||
ddb = DroneDB(hub_url, username, password)
|
||||
|
||||
return Response({'success': ddb.login()}, status=status.HTTP_200_OK)
|
||||
|
||||
except(Exception) as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
class OrganizationsTaskView(TaskView):
|
||||
def get(self, request):
|
||||
|
||||
try:
|
||||
|
||||
ddb = get_ddb(request)
|
||||
|
||||
orgs = ddb.get_organizations()
|
||||
|
||||
return Response(orgs, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class DatasetsTaskView(TaskView):
|
||||
def get(self, request, org=None):
|
||||
|
||||
if org == None:
|
||||
return Response({'error': 'Organization must be set.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
|
||||
ddb = get_ddb(request)
|
||||
|
||||
dss = ddb.get_datasets(org)
|
||||
|
||||
return Response(dss, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
class FoldersTaskView(TaskView):
|
||||
def get(self, request, org=None, ds=None):
|
||||
|
||||
if org == None or ds == None:
|
||||
return Response({'error': 'Organization and dataset must be set.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
|
||||
ddb = get_ddb(request)
|
||||
|
||||
folders = ddb.get_folders(org, ds)
|
||||
|
||||
return Response(folders, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
class VerifyUrlTaskView(TaskView):
|
||||
def post(self, request):
|
||||
|
||||
# Read form data
|
||||
url = request.data.get('url', None)
|
||||
|
||||
if url == None:
|
||||
return Response({'error': 'Url must be set.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
_, username, password, _ = get_settings(request)
|
||||
|
||||
try:
|
||||
|
||||
result, org, ds, folder, count, size = verify_url(url, username, password).values()
|
||||
|
||||
if (not result):
|
||||
return Response({'error': 'Invalid url.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
return Response({'count': count, 'success': result, 'ds' : ds, 'org': org, 'folder': folder or None, 'size': size}
|
||||
if org else {'success': False}, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
class InfoTaskView(TaskView):
|
||||
def get(self, request):
|
||||
|
||||
registry_url, username, _, _ = get_settings(request)
|
||||
|
||||
return Response({ 'hubUrl': registry_url, 'username': username }, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class ImportDatasetTaskView(TaskView):
|
||||
def post(self, request, project_pk=None, pk=None):
|
||||
|
||||
task = self.get_and_check_task(request, pk)
|
||||
|
||||
# Read form data
|
||||
ddb_url = request.data.get('ddb_url', None)
|
||||
|
||||
if ddb_url == None:
|
||||
return Response({'error': 'DroneDB url must be set.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
registry_url, orgSlug, dsSlug, folder = parse_url(ddb_url).values()
|
||||
|
||||
_, username, password, token = get_settings(request)
|
||||
ddb = DroneDB(registry_url, username, password, token, lambda token: update_token(request, token))
|
||||
|
||||
# Get the files from the folder
|
||||
rawfiles = ddb.get_files_list(orgSlug, dsSlug, folder)
|
||||
files = [file for file in rawfiles if is_valid(file['path'])]
|
||||
|
||||
# Verify that the folder url is valid
|
||||
if len(files) == 0:
|
||||
return Response({'error': 'Empty dataset or folder.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Update the task with the new information
|
||||
task.console_output += "Importing {} images...\n".format(len(files))
|
||||
task.images_count = len(files)
|
||||
task.pending_action = pending_actions.IMPORT
|
||||
task.save()
|
||||
|
||||
# Associate the folder url with the project and task
|
||||
combined_id = "{}_{}".format(project_pk, pk)
|
||||
|
||||
datastore = get_current_plugin().get_global_data_store()
|
||||
datastore.set_json(combined_id, {
|
||||
"ddbUrl": ddb_url,
|
||||
"token": ddb.token,
|
||||
"ddbWebUrl": "{}/r/{}/{}/{}".format(to_web_protocol(registry_url), orgSlug, dsSlug, folder.rstrip('/'))
|
||||
})
|
||||
|
||||
#ddb.refresh_token()
|
||||
|
||||
# Start importing the files in the background
|
||||
serialized = {'token': ddb.token, 'files': files}
|
||||
run_function_async(import_files, task.id, serialized)
|
||||
|
||||
return Response({}, status=status.HTTP_200_OK)
|
||||
|
||||
def import_files(task_id, carrier):
|
||||
import requests
|
||||
from app import models
|
||||
from app.plugins import logger
|
||||
|
||||
files = carrier['files']
|
||||
|
||||
#headers = CaseInsensitiveDict()
|
||||
headers = {}
|
||||
|
||||
if carrier['token'] != None:
|
||||
headers['Authorization'] = 'Bearer ' + carrier['token']
|
||||
|
||||
def download_file(task, file):
|
||||
path = task.task_path(file['name'])
|
||||
logger.info("Downloading file: " + file['url'])
|
||||
download_stream = requests.get(file['url'], stream=True, timeout=60, headers=headers)
|
||||
|
||||
with open(path, 'wb') as fd:
|
||||
for chunk in download_stream.iter_content(4096):
|
||||
fd.write(chunk)
|
||||
|
||||
models.ImageUpload.objects.create(task=task, image=path)
|
||||
|
||||
logger.info("Will import {} files".format(len(files)))
|
||||
task = models.Task.objects.get(pk=task_id)
|
||||
task.create_task_directories()
|
||||
task.save()
|
||||
|
||||
try:
|
||||
downloaded_total = 0
|
||||
for file in files:
|
||||
download_file(task, file)
|
||||
task.check_if_canceled()
|
||||
models.Task.objects.filter(pk=task.id).update(upload_progress=(float(downloaded_total) / float(len(files))))
|
||||
downloaded_total += 1
|
||||
|
||||
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
|
||||
raise NodeServerError(e)
|
||||
|
||||
task.refresh_from_db()
|
||||
task.pending_action = None
|
||||
task.processing_time = 0
|
||||
task.partial = False
|
||||
task.save()
|
||||
|
||||
class CheckUrlTaskView(TaskView):
|
||||
def get(self, request, project_pk=None, pk=None):
|
||||
|
||||
# Assert that task exists
|
||||
self.get_and_check_task(request, pk)
|
||||
|
||||
# Check if there is an imported url associated with the project and task
|
||||
combined_id = "{}_{}".format(project_pk, pk)
|
||||
data = get_current_plugin().get_global_data_store().get_json(combined_id, default = None)
|
||||
|
||||
if data == None or 'ddbWebUrl' not in data:
|
||||
return Response({'ddbWebUrl': None}, status=status.HTTP_200_OK)
|
||||
else:
|
||||
return Response({'ddbUrl': data['ddbWebUrl']}, status=status.HTTP_200_OK)
|
||||
|
||||
def get_status_key(task_id):
|
||||
return '{}_ddb'.format(task_id)
|
||||
|
||||
@receiver(plugin_signals.task_removed, dispatch_uid="ddb_on_task_removed")
|
||||
@receiver(plugin_signals.task_completed, dispatch_uid="ddb_on_task_completed")
|
||||
def ddb_cleanup(sender, task_id, **kwargs):
|
||||
|
||||
from app.plugins import logger
|
||||
|
||||
# When a task is removed, simply remove clutter
|
||||
# When a task is re-processed, make sure we can re-share it if we shared a task previously
|
||||
|
||||
logger.info("Cleaning up DroneDB datastore for task {}".format(str(task_id)))
|
||||
|
||||
datastore = get_current_plugin().get_global_data_store()
|
||||
status_key = get_status_key(task_id)
|
||||
|
||||
logger.info("Info task {0} ({1})".format(str(task_id), status_key))
|
||||
|
||||
datastore.del_key(status_key)
|
||||
|
||||
|
||||
class StatusTaskView(TaskView):
|
||||
def get(self, request, pk):
|
||||
|
||||
task = self.get_and_check_task(request, pk)
|
||||
|
||||
# Associate the folder url with the project and task
|
||||
status_key = get_status_key(pk)
|
||||
|
||||
datastore = get_current_plugin().get_global_data_store()
|
||||
|
||||
task_info = datastore.get_json(status_key, {
|
||||
'status': 0, # Idle
|
||||
'shareUrl': None,
|
||||
'uploadedFiles': 0,
|
||||
'totalFiles': 0,
|
||||
'uploadedSize': 0,
|
||||
'totalSize': 0,
|
||||
'error': None
|
||||
})
|
||||
|
||||
return Response(task_info, status=status.HTTP_200_OK)
|
||||
|
||||
DRONEDB_ASSETS = [
|
||||
'orthophoto.tif',
|
||||
'orthophoto.png',
|
||||
'georeferenced_model.laz',
|
||||
'dtm.tif',
|
||||
'dsm.tif',
|
||||
'shots.geojson',
|
||||
'report.pdf',
|
||||
'ground_control_points.geojson'
|
||||
]
|
||||
|
||||
class ShareTaskView(TaskView):
|
||||
def post(self, request, pk):
|
||||
|
||||
from app.plugins import logger
|
||||
|
||||
task = self.get_and_check_task(request, pk)
|
||||
|
||||
status_key = get_status_key(pk)
|
||||
|
||||
datastore = get_current_plugin().get_global_data_store()
|
||||
|
||||
data = {
|
||||
'status': 1, # Running
|
||||
'shareUrl': None,
|
||||
'uploadedFiles': 0,
|
||||
'totalFiles': 0,
|
||||
'uploadedSize': 0,
|
||||
'totalSize': 0,
|
||||
'error': None
|
||||
}
|
||||
|
||||
datastore.set_json(status_key, data)
|
||||
|
||||
settings = get_settings(request)
|
||||
|
||||
available_assets = [task.get_asset_file_or_zipstream(f)[0] for f in list(set(task.available_assets) & set(DRONEDB_ASSETS))]
|
||||
|
||||
if 'textured_model.zip' in task.available_assets:
|
||||
texture_files = [join(task.assets_path('odm_texturing'), f) for f in listdir(task.assets_path('odm_texturing')) if isfile(join(task.assets_path('odm_texturing'), f))]
|
||||
available_assets.extend(texture_files)
|
||||
|
||||
assets_path = task.assets_path()
|
||||
|
||||
files = [{'path': f, 'name': f[len(assets_path)+1:], 'size': os.path.getsize(f)} for f in available_assets]
|
||||
|
||||
share_to_ddb.delay(pk, settings, files)
|
||||
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
@task
|
||||
def share_to_ddb(pk, settings, files):
|
||||
|
||||
from app.plugins import logger
|
||||
|
||||
status_key = get_status_key(pk)
|
||||
datastore = get_current_plugin().get_global_data_store()
|
||||
|
||||
registry_url, username, password, token = settings
|
||||
|
||||
ddb = DroneDB(registry_url, username, password, token)
|
||||
|
||||
# Init share (to check)
|
||||
share_token = ddb.share_init()
|
||||
|
||||
status = datastore.get_json(status_key)
|
||||
|
||||
status['totalFiles'] = len(files)
|
||||
status['totalSize'] = sum(i['size'] for i in files)
|
||||
|
||||
datastore.set_json(status_key, status)
|
||||
|
||||
for file in files:
|
||||
|
||||
# check that file exists
|
||||
if not os.path.exists(file['path']):
|
||||
logger.info("File {} does not exist".format(file['path']))
|
||||
continue
|
||||
|
||||
attempt = 0
|
||||
|
||||
while attempt < 3:
|
||||
try:
|
||||
|
||||
attempt += 1
|
||||
|
||||
up = ddb.share_upload(share_token, file['path'], file['name'])
|
||||
|
||||
logger.info("Uploaded " + file['name'] + " to Dronedb (hash: " + up['hash'] + ")")
|
||||
|
||||
status['uploadedFiles'] += 1
|
||||
status['uploadedSize'] += file['size']
|
||||
|
||||
datastore.set_json(status_key, status)
|
||||
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
|
||||
if (attempt == 3):
|
||||
logger.error("Error uploading file {}: {}".format(file['name'], str(e)))
|
||||
status['error'] = "Error uploading file {}: {}".format(file['name'], str(e))
|
||||
status['status'] = 2 # Error
|
||||
datastore.set_json(status_key, status)
|
||||
return
|
||||
else:
|
||||
logger.info("Error uploading file {}: {}. Retrying...".format(file['name'], str(e)))
|
||||
time.sleep(5)
|
||||
continue
|
||||
|
||||
|
||||
res = ddb.share_commit(share_token)
|
||||
|
||||
status['status'] = 3 # Done
|
||||
status['shareUrl'] = registry_url + res['url']
|
||||
|
||||
logger.info("Shared on url " + status['shareUrl'])
|
||||
|
||||
datastore.set_json(status_key, status)
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import requests
|
||||
|
||||
from django import forms
|
||||
from django.contrib import messages
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render
|
||||
from django.contrib.auth.decorators import login_required
|
||||
|
||||
from app.plugins import logger
|
||||
|
||||
def LoadButtonsView(plugin):
|
||||
def view(request):
|
||||
|
||||
return render(
|
||||
request,
|
||||
plugin.template_path("load_buttons.js"),
|
||||
{
|
||||
"api_url": "/api" + plugin.public_url("").rstrip("/"),
|
||||
},
|
||||
content_type="text/javascript",
|
||||
)
|
||||
|
||||
return view
|
|
@ -0,0 +1,303 @@
|
|||
from functools import reduce
|
||||
import requests
|
||||
from os import path
|
||||
from app.plugins import logger
|
||||
from urllib.parse import urlparse
|
||||
|
||||
VALID_IMAGE_EXTENSIONS = ['.tiff', '.tif', '.png', '.jpeg', '.jpg']
|
||||
DEFAULT_HUB_URL = 'https://hub.dronedb.app'
|
||||
|
||||
class DroneDB:
|
||||
|
||||
def __init__(self, registry_url, username, password, token=None, update_token=None):
|
||||
|
||||
if not self.validate_url(registry_url):
|
||||
raise ValueError("Invalid registry URL.")
|
||||
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.token = token
|
||||
self.public = False if username else True
|
||||
self.update_token = update_token
|
||||
|
||||
self.__registry_url = registry_url[:-1] if registry_url.endswith('/') else registry_url
|
||||
self.__authenticate_url = self.__registry_url + "/users/authenticate"
|
||||
self.__refresh_url = self.__registry_url + "/users/authenticate/refresh"
|
||||
self.__get_organizations_url = self.__registry_url + "/orgs"
|
||||
self.__get_datasets_url = self.__registry_url + "/orgs/{0}/ds"
|
||||
self.__get_folders_url = self.__registry_url + "/orgs/{0}/ds/{1}/search"
|
||||
self.__get_files_list_url = self.__registry_url + "/orgs/{0}/ds/{1}/list"
|
||||
self.__download_file_url = self.__registry_url + "/orgs/{0}/ds/{1}/download?path={2}&inline=1"
|
||||
|
||||
self.__share_init_url = self.__registry_url + "/share/init"
|
||||
self.__share_upload_url = self.__registry_url + "/share/upload/{0}"
|
||||
self.__share_commit_url = self.__registry_url + "/share/commit/{0}"
|
||||
|
||||
|
||||
# Validate url
|
||||
def validate_url(self, url):
|
||||
try:
|
||||
result = urlparse(url)
|
||||
return all([result.scheme, result.netloc])
|
||||
except:
|
||||
return False
|
||||
|
||||
def login(self):
|
||||
|
||||
if (self.public):
|
||||
logger.info("No need to login to DroneDB.")
|
||||
return True
|
||||
|
||||
try:
|
||||
|
||||
# Authenticate
|
||||
payload = {'username': self.username, 'password': self.password}
|
||||
response = requests.post(self.__authenticate_url, data=payload)
|
||||
|
||||
if response.status_code != 200:
|
||||
return False
|
||||
|
||||
# Get the token
|
||||
self.token = response.json()['token']
|
||||
|
||||
logger.info("Logged in to DroneDB as user " + self.username + ".")
|
||||
|
||||
if (self.update_token is not None):
|
||||
self.update_token(self.token)
|
||||
|
||||
return True
|
||||
|
||||
except(Exception) as e:
|
||||
logger.error(e)
|
||||
return False
|
||||
|
||||
def refresh_token(self):
|
||||
|
||||
if (self.public):
|
||||
logger.info("Cannot refresh token.")
|
||||
return False
|
||||
|
||||
try:
|
||||
|
||||
response = self.wrapped_call('POST', self.__refresh_url)
|
||||
|
||||
self.token = response.json()['token']
|
||||
|
||||
if (self.update_token is not None):
|
||||
self.update_token(self.token)
|
||||
|
||||
except Exception as e:
|
||||
raise Exception("Failed to refresh token.") from e
|
||||
|
||||
def wrapped_call(self, type, url, data=None, params=None, files=None, attempts=3):
|
||||
|
||||
headers = {}
|
||||
|
||||
cnt = attempts
|
||||
|
||||
while True:
|
||||
|
||||
if not self.public and self.token is None and not self.login():
|
||||
raise ValueError("Could not authenticate to DroneDB.")
|
||||
|
||||
if self.token is not None:
|
||||
headers = {'Authorization': 'Bearer ' + self.token }
|
||||
|
||||
response = requests.request(type, url, data=data, params=params, headers=headers, files=files)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response
|
||||
|
||||
if response.status_code == 401:
|
||||
if (self.public):
|
||||
raise DroneDBException("Failed to call '" + url + "': unauthorized.")
|
||||
|
||||
if not self.login():
|
||||
raise DroneDBException("Failed to re-authenticate to DroneDB, cannot call '" + url + "'.")
|
||||
else:
|
||||
cnt -= 1
|
||||
if cnt == 0:
|
||||
raise DroneDBException("Failed all attempts to re-authenticate to DroneDB, cannot call '" + url + "'.")
|
||||
else:
|
||||
res = response.json()
|
||||
raise DroneDBException("Failed to call '" + url + "'.", res)
|
||||
|
||||
|
||||
def get_organizations(self):
|
||||
|
||||
try:
|
||||
|
||||
response = self.wrapped_call('GET', self.__get_organizations_url)
|
||||
|
||||
return [{'slug': o['slug'], 'name': o['name']} for o in response.json()]
|
||||
|
||||
except Exception as e:
|
||||
raise Exception("Failed to get organizations.") from e
|
||||
|
||||
def get_datasets(self, orgSlug):
|
||||
|
||||
try:
|
||||
|
||||
response = self.wrapped_call('GET', self.__get_datasets_url.format(orgSlug))
|
||||
|
||||
return [
|
||||
{'slug': o['slug'],
|
||||
'name': o['properties'].get('meta', {}).get('name', {}).get('data', o['name']),
|
||||
'public': o['properties'].get('public'),
|
||||
'size': o['size'],
|
||||
'entries': o['properties'].get('entries')
|
||||
} for o in response.json()]
|
||||
|
||||
except Exception as e:
|
||||
raise Exception("Failed to get datasets.") from e
|
||||
|
||||
|
||||
def get_folders(self, orgSlug, dsSlug):
|
||||
|
||||
try:
|
||||
|
||||
# Type 1 is folder
|
||||
payload = {'query': '*', 'recursive': True, 'type': 1}
|
||||
|
||||
response = self.wrapped_call('POST', self.__get_folders_url.format(orgSlug, dsSlug), data=payload)
|
||||
|
||||
return [o['path'] for o in response.json()]
|
||||
|
||||
except Exception as e:
|
||||
raise Exception("Failed to get folders.") from e
|
||||
|
||||
def get_files_list(self, orgSlug, dsSlug, folder=None):
|
||||
|
||||
try:
|
||||
|
||||
# Type 1 is folder
|
||||
params = {'path': '' if folder is None else folder}
|
||||
|
||||
logger.info(self.__get_files_list_url.format(orgSlug, dsSlug))
|
||||
|
||||
# Get the folders
|
||||
response = self.wrapped_call('GET', self.__get_files_list_url.format(orgSlug, dsSlug), params=params)
|
||||
|
||||
# Exclude folders
|
||||
files = filter(lambda itm: itm['type'] != 1, response.json())
|
||||
|
||||
return [
|
||||
{'path': o['path'],
|
||||
# extract name from path
|
||||
'name': o['path'].split('/')[-1],
|
||||
'type': o['type'],
|
||||
'size': o['size'],
|
||||
'url': self.__download_file_url.format(orgSlug, dsSlug, o['path'])
|
||||
} for o in files]
|
||||
|
||||
except Exception as e:
|
||||
raise Exception("Failed to get files list.") from e
|
||||
|
||||
def share_init(self, tag=None):
|
||||
try:
|
||||
|
||||
data = {'tag': tag} if tag is not None else None
|
||||
|
||||
response = self.wrapped_call('POST', self.__share_init_url, data=data)
|
||||
|
||||
return response.json()['token']
|
||||
|
||||
except Exception as e:
|
||||
raise Exception("Failed to initialize share.") from e
|
||||
|
||||
def share_upload(self, token, path, name):
|
||||
try:
|
||||
|
||||
# Get file name
|
||||
files = { 'file': open(path, 'rb') }
|
||||
data = {'path': name}
|
||||
|
||||
response = self.wrapped_call('POST', self.__share_upload_url.format(token), files=files, data=data)
|
||||
|
||||
return response.json()
|
||||
|
||||
except Exception as e:
|
||||
raise Exception("Failed to upload file.") from e
|
||||
|
||||
def share_commit(self, token):
|
||||
try:
|
||||
|
||||
response = self.wrapped_call('POST', self.__share_commit_url.format(token))
|
||||
|
||||
return response.json()
|
||||
|
||||
except Exception as e:
|
||||
raise Exception("Failed to commit share.") from e
|
||||
|
||||
def verify_url(url, username=None, password=None):
|
||||
try:
|
||||
|
||||
registryUrl, orgSlug, dsSlug, folder = parse_url(url).values()
|
||||
|
||||
ddb = DroneDB(registryUrl, username, password)
|
||||
files = ddb.get_files_list(orgSlug, dsSlug, folder)
|
||||
|
||||
# return some info
|
||||
return {
|
||||
'success': True,
|
||||
'orgSlug': orgSlug,
|
||||
'dsSlug': dsSlug,
|
||||
'folder': folder,
|
||||
'count': len(files),
|
||||
'size': sum(i['size'] for i in files)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
return {
|
||||
'success': False,
|
||||
'orgSlug': None,
|
||||
'dsSlug': None,
|
||||
'folder': None,
|
||||
'count': None,
|
||||
'size': None
|
||||
}
|
||||
|
||||
def parse_url(url):
|
||||
|
||||
# Check if the url is valid
|
||||
# Root folder of dataset: ddb://localhost:5001/admin/4uyyyaxcbvahd7qb
|
||||
# 'test' folder of dataset: ddb://localhost:5001/admin/4uyyyaxcbvahd7qb/test
|
||||
# using http instead of https: ddb+unsafe://localhost:5000/admin/4uyyyaxcbvahd7qb
|
||||
# using hub url: https://localhost:5001/r/admin/4uyyyaxcbvahd7qb
|
||||
# using hub url without /r/ http://localhost:5000/admin/4uyyyaxcbvahd7qb/test
|
||||
|
||||
p = urlparse(url)
|
||||
segments = p.path.split('/')
|
||||
|
||||
if p.scheme not in ['ddb', 'ddb+unsafe', 'http', 'https']:
|
||||
raise ValueError("Invalid URL scheme.")
|
||||
|
||||
if p.netloc == '':
|
||||
raise ValueError("Invalid URL.")
|
||||
|
||||
scheme = p.scheme
|
||||
|
||||
# used to skip the /r/: if ddb url we have no /r/ instead if http we have it
|
||||
if p.scheme == 'ddb':
|
||||
scheme = 'https'
|
||||
elif p.scheme == 'ddb+unsafe':
|
||||
scheme = 'http'
|
||||
|
||||
offset = 1 if segments[1] == 'r' else 0
|
||||
|
||||
if (len(segments) < offset + 3):
|
||||
raise ValueError("Invalid URL.")
|
||||
|
||||
return {
|
||||
'registryUrl': scheme + '://' + p.netloc,
|
||||
'orgSlug': segments[1 + offset],
|
||||
'dsSlug': segments[2 + offset],
|
||||
'folder': '/'.join(segments[3 + offset:])
|
||||
}
|
||||
|
||||
class DroneDBException(Exception):
|
||||
def __init__(self, message, res=None):
|
||||
super().__init__(message)
|
||||
self.response = res
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"name": "DroneDB",
|
||||
"webodmMinVersion": "1.1.2",
|
||||
"description": "Integrate WebODM with DroneDB: import images and share results",
|
||||
"version": "0.0.1",
|
||||
"author": "Luca Di Leo",
|
||||
"email": "ldileo@digipa.it",
|
||||
"repository": "https://github.com/OpenDroneMap/WebODM",
|
||||
"tags": ["ddb", "DroneDB", "cloud"],
|
||||
"homepage": "https://github.com/OpenDroneMap/WebODM",
|
||||
"experimental": true,
|
||||
"deprecated": false
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
from app.plugins import PluginBase, Menu, MountPoint, logger
|
||||
from coreplugins.dronedb.app_views import LoadButtonsView
|
||||
from coreplugins.dronedb.ddb import DEFAULT_HUB_URL
|
||||
|
||||
from .api_views import (
|
||||
CheckUrlTaskView,
|
||||
FoldersTaskView,
|
||||
ImportDatasetTaskView,
|
||||
CheckCredentialsTaskView,
|
||||
OrganizationsTaskView,
|
||||
DatasetsTaskView,
|
||||
StatusTaskView,
|
||||
VerifyUrlTaskView,
|
||||
InfoTaskView,
|
||||
ShareTaskView
|
||||
)
|
||||
|
||||
from django.contrib import messages
|
||||
from django.shortcuts import render
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django import forms
|
||||
|
||||
class SettingsForm(forms.Form):
|
||||
username = forms.CharField(label='Username', required=False, max_length=1024, widget=forms.TextInput(attrs={'placeholder': 'Username'}))
|
||||
password = forms.CharField(label='Password', required=False, max_length=1024, widget=forms.PasswordInput(attrs={'placeholder': 'Password'}))
|
||||
registry_url = forms.CharField(label='Registry URL', required=False, max_length=1024, widget=forms.TextInput(attrs={'placeholder': 'Registry Url'}))
|
||||
|
||||
class Plugin(PluginBase):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def main_menu(self):
|
||||
return [Menu("DroneDB", self.public_url(""), "ddb-icon fa-fw")]
|
||||
|
||||
def include_js_files(self):
|
||||
return ["load_buttons.js"]
|
||||
|
||||
def include_css_files(self):
|
||||
return ["build/ImportView.css", "style.css"]
|
||||
|
||||
def build_jsx_components(self):
|
||||
return ["ImportView.jsx", "ShareButton.jsx"]
|
||||
|
||||
def api_mount_points(self):
|
||||
return [
|
||||
MountPoint("projects/(?P<project_pk>[^/.]+)/tasks/(?P<pk>[^/.]+)/import", ImportDatasetTaskView.as_view()),
|
||||
MountPoint("projects/(?P<project_pk>[^/.]+)/tasks/(?P<pk>[^/.]+)/checkforurl", CheckUrlTaskView.as_view()),
|
||||
MountPoint("tasks/(?P<pk>[^/.]+)/status", StatusTaskView.as_view()),
|
||||
MountPoint("tasks/(?P<pk>[^/.]+)/share", ShareTaskView.as_view()),
|
||||
MountPoint("checkcredentials", CheckCredentialsTaskView.as_view()),
|
||||
MountPoint("organizations/(?P<org>[^/.]+)/datasets/(?P<ds>[^/.]+)/folders", FoldersTaskView.as_view()),
|
||||
MountPoint("organizations/(?P<org>[^/.]+)/datasets", DatasetsTaskView.as_view()),
|
||||
MountPoint("organizations", OrganizationsTaskView.as_view()),
|
||||
MountPoint("verifyurl", VerifyUrlTaskView.as_view()),
|
||||
MountPoint("info", InfoTaskView.as_view()),
|
||||
]
|
||||
|
||||
def HomeView(self):
|
||||
@login_required
|
||||
def home(request):
|
||||
ds = self.get_user_data_store(request.user)
|
||||
|
||||
# if this is a POST request we need to process the form data
|
||||
if request.method == 'POST':
|
||||
form = SettingsForm(request.POST)
|
||||
if form.is_valid():
|
||||
ds.set_string('registry_url', form.cleaned_data['registry_url'])
|
||||
ds.set_string('username', form.cleaned_data['username'])
|
||||
ds.set_string('password', form.cleaned_data['password'])
|
||||
ds.set_string('token', None)
|
||||
messages.success(request, 'Settings updated.')
|
||||
|
||||
form = SettingsForm(initial={'username': ds.get_string('username', default=""),
|
||||
'password': ds.get_string('password', default=""),
|
||||
'registry_url': ds.get_string('registry_url', default="") or DEFAULT_HUB_URL})
|
||||
|
||||
return render(request, self.template_path("app.html"), {
|
||||
'title': 'DroneDB',
|
||||
'form': form
|
||||
})
|
||||
|
||||
return home
|
||||
|
||||
def app_mount_points(self):
|
||||
return [
|
||||
MountPoint("$", self.HomeView()),
|
||||
MountPoint("load_buttons.js$", LoadButtonsView(self)),
|
||||
]
|
|
@ -0,0 +1,115 @@
|
|||
import React, { Component, Fragment } from "react";
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import ResizeModes from 'webodm/classes/ResizeModes';
|
||||
import { Modal, Button } from "react-bootstrap";
|
||||
import SelectUrlDialog from "./components/SelectUrlDialog";
|
||||
import ErrorDialog from "./components/ErrorDialog";
|
||||
import ConfigureNewTaskDialog from "./components/ConfigureNewTaskDialog";
|
||||
|
||||
import "./ImportView.scss";
|
||||
|
||||
export default class TaskView extends Component {
|
||||
|
||||
static propTypes = {
|
||||
projectId: PropTypes.number.isRequired,
|
||||
apiURL: PropTypes.string.isRequired,
|
||||
onNewTaskAdded: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
state = {
|
||||
error: "",
|
||||
ddbRes: null,
|
||||
isDialogOpen: false
|
||||
};
|
||||
|
||||
onHideDialog = () => this.setState({ ddbRes: null, taskId: null, isDialogOpen: false });
|
||||
onSelectDdbRes = res => {
|
||||
console.log("Result", res);
|
||||
this.setState({ ddbRes: res, isDialogOpen: false });
|
||||
}
|
||||
|
||||
|
||||
onSaveTask = taskInfo => {
|
||||
// Create task
|
||||
const formData = {
|
||||
name: taskInfo.name,
|
||||
options: taskInfo.options,
|
||||
processing_node: taskInfo.selectedNode.id,
|
||||
auto_processing_node: taskInfo.selectedNode.key == "auto",
|
||||
partial: true
|
||||
};
|
||||
|
||||
if (taskInfo.resizeMode === ResizeModes.YES) {
|
||||
formData.resize_to = taskInfo.resizeSize;
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: `/api/projects/${this.props.projectId}/tasks/`,
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(formData),
|
||||
dataType: 'json',
|
||||
type: 'POST'
|
||||
}).done((task) => {
|
||||
$.ajax({
|
||||
url: `${this.props.apiURL}/projects/${this.props.projectId}/tasks/${task.id}/import`,
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({ddb_url: this.state.ddbRes.url}),
|
||||
dataType: 'json',
|
||||
type: 'POST'
|
||||
}).done(() => {
|
||||
this.onHideDialog();
|
||||
this.props.onNewTaskAdded();
|
||||
}).fail(error => {
|
||||
this.onErrorInDialog("Failed to start importing.");
|
||||
});
|
||||
}).fail(() => {
|
||||
this.onErrorInDialog("Cannot create new task. Please try again later.");
|
||||
});
|
||||
}
|
||||
|
||||
onErrorInDialog = msg => {
|
||||
this.setState({ error: msg });
|
||||
this.onHideDialog();
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
this.setState({ isDialogOpen: true });
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
error,
|
||||
ddbRes,
|
||||
isDialogOpen
|
||||
} = this.state;
|
||||
return (
|
||||
<Fragment>
|
||||
{error ? <ErrorDialog errorMessage={error} /> : ""}
|
||||
<Button
|
||||
bsStyle={"default"}
|
||||
bsSize={"small"}
|
||||
className={"platform-btn"}
|
||||
onClick={this.handleClick}>
|
||||
<i className={"ddb-icon fa-fw"} />
|
||||
DroneDB Import
|
||||
</Button>
|
||||
<SelectUrlDialog
|
||||
show={isDialogOpen}
|
||||
onHide={this.onHideDialog}
|
||||
onSubmit={this.onSelectDdbRes}
|
||||
apiURL={this.props.apiURL}
|
||||
/>
|
||||
{ddbRes ?
|
||||
<ConfigureNewTaskDialog
|
||||
show={ddbRes !== null}
|
||||
ddbRes={ddbRes}
|
||||
onHide={this.onHideDialog}
|
||||
onSaveTask={this.onSaveTask}
|
||||
/> : ""}
|
||||
|
||||
</Fragment>
|
||||
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
.platform-btn {
|
||||
.ddb-icon{
|
||||
margin-right: 8px;
|
||||
}
|
||||
.fa-cloud {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
.caret {
|
||||
margin-left: 1em;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,152 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Storage from 'webodm/classes/Storage';
|
||||
import ErrorMessage from 'webodm/components/ErrorMessage';
|
||||
import $ from 'jquery';
|
||||
|
||||
const STATE_IDLE = 0;
|
||||
const STATE_RUNNING = 1;
|
||||
const STATE_ERROR = 2;
|
||||
const STATE_DONE = 3;
|
||||
|
||||
const ICON_CLASS_MAPPER = [
|
||||
// Idle
|
||||
'ddb-icon fa-fw',
|
||||
// Running
|
||||
'fa fa-circle-notch fa-spin fa-fw',
|
||||
// Error
|
||||
'fa fa-exclamation-triangle',
|
||||
// Done
|
||||
'fas fa-external-link-alt'
|
||||
];
|
||||
|
||||
const BUTTON_TEXT_MAPPER = [
|
||||
// Idle
|
||||
'Share to DroneDB',
|
||||
// Running
|
||||
'Sharing',
|
||||
// Error retry
|
||||
'Error, retry',
|
||||
// Done
|
||||
'View on DroneDB'
|
||||
];
|
||||
|
||||
export default class ShareButton extends React.Component{
|
||||
static defaultProps = {
|
||||
task: null
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
task: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
constructor(props){
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
taskInfo: null,
|
||||
error: '',
|
||||
monitorTimeout: null
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
componentDidMount(){
|
||||
this.updateTaskInfo(false);
|
||||
}
|
||||
|
||||
updateTaskInfo = (showErrors) => {
|
||||
const { task } = this.props;
|
||||
return $.ajax({
|
||||
type: 'GET',
|
||||
url: `/api/plugins/dronedb/tasks/${task.id}/status`,
|
||||
contentType: 'application/json'
|
||||
}).done(taskInfo => {
|
||||
console.log(taskInfo);
|
||||
debugger;
|
||||
this.setState({taskInfo});
|
||||
if (taskInfo.error && showErrors) this.setState({error: taskInfo.error});
|
||||
}).fail(error => {
|
||||
this.setState({error: error.statusText});
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount(){
|
||||
if (this.monitorTimeout) clearTimeout(this.monitorTimeout);
|
||||
}
|
||||
|
||||
shareToDdb = (formData) => {
|
||||
const { task } = this.props;
|
||||
|
||||
return $.ajax({
|
||||
url: `/api/plugins/dronedb/tasks/${task.id}/share`,
|
||||
contentType: 'application/json',
|
||||
dataType: 'json',
|
||||
type: 'POST'
|
||||
}).done(taskInfo => {
|
||||
|
||||
this.setState({taskInfo});
|
||||
this.monitorProgress();
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
monitorProgress = () => {
|
||||
if (this.state.taskInfo.status == STATE_RUNNING){
|
||||
// Monitor progress
|
||||
this.monitorTimeout = setTimeout(() => {
|
||||
this.updateTaskInfo(true).always(this.monitorProgress);
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
handleClick = e => {
|
||||
|
||||
if (this.state.taskInfo.status == STATE_IDLE || this.state.taskInfo.status == STATE_ERROR) {
|
||||
this.shareToDdb();
|
||||
}
|
||||
|
||||
if (this.state.taskInfo.status == STATE_DONE){
|
||||
window.open(this.state.taskInfo.shareUrl, '_blank');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
render(){
|
||||
const { taskInfo, error } = this.state;
|
||||
|
||||
const getButtonIcon = () => {
|
||||
|
||||
if (taskInfo == null) return "fa fa-circle-notch fa-spin fa-fw";
|
||||
if (taskInfo.error) return "fa fa-exclamation-triangle";
|
||||
|
||||
return ICON_CLASS_MAPPER[taskInfo.status];
|
||||
};
|
||||
|
||||
const getButtonLabel = () => {
|
||||
|
||||
if (taskInfo == null) return "Share to DroneDB";
|
||||
if (taskInfo.error) return "DroneDB plugin error";
|
||||
|
||||
var text = BUTTON_TEXT_MAPPER[taskInfo.status];
|
||||
|
||||
if (taskInfo.status == STATE_RUNNING && taskInfo.uploadedSize > 0 && taskInfo.totalSize > 0) {
|
||||
var progress = (taskInfo.uploadedSize / taskInfo.totalSize) * 100;
|
||||
text += ` (${progress.toFixed(2)}%)`;
|
||||
}
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="share-button">
|
||||
<button className="btn btn-primary btn-sm" onClick={this.handleClick} disabled={this.state.taskInfo == null || this.state.taskInfo.status == STATE_RUNNING }>
|
||||
<i className={getButtonIcon()}></i>
|
||||
{getButtonLabel()}
|
||||
</button>
|
||||
{this.state.error && <div style={{ marginTop: '10px' }}><ErrorMessage bind={[this, 'error']} /></div> }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { Component } from "react";
|
||||
import { Modal } from "react-bootstrap";
|
||||
import NewTaskPanel from "webodm/components/NewTaskPanel";
|
||||
|
||||
import "./ConfigureNewTaskDialog.scss";
|
||||
|
||||
export default class ConfigureNewTaskDialog extends Component {
|
||||
static defaultProps = {
|
||||
|
||||
};
|
||||
static propTypes = {
|
||||
onHide: PropTypes.func.isRequired,
|
||||
onSaveTask: PropTypes.func.isRequired,
|
||||
ddbRes: PropTypes.object,
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
onHide,
|
||||
onSaveTask,
|
||||
ddbRes,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Modal className={"new-task"} onHide={onHide} show={true}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>
|
||||
Import from DroneDB
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<NewTaskPanel
|
||||
onSave={onSaveTask}
|
||||
onCancel={onHide}
|
||||
filesCount={ddbRes ? ddbRes.images_count : 0}
|
||||
getFiles={() => []}
|
||||
showResize={false}
|
||||
suggestedTaskName={ddbRes ? ddbRes.name : null}
|
||||
/>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
.modal-backdrop {
|
||||
z-index: 100000 !important;
|
||||
}
|
||||
|
||||
.new-task.modal button i {
|
||||
margin-right: 1em;
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { Component } from "react";
|
||||
import { Modal } from "react-bootstrap";
|
||||
import "./ErrorDialog.scss";
|
||||
|
||||
export default class ErrorDialog extends Component {
|
||||
static propTypes = {
|
||||
errorMessage: PropTypes.string.isRequired,
|
||||
}
|
||||
|
||||
constructor(props){
|
||||
super(props);
|
||||
|
||||
this.state = { show: true };
|
||||
}
|
||||
|
||||
handleOnHide = () => this.setState({show: false});
|
||||
|
||||
render() {
|
||||
const { errorMessage } = this.props;
|
||||
|
||||
return (
|
||||
<Modal show={this.state.show}>
|
||||
<Modal.Header closeButton onHide={this.handleOnHide}>
|
||||
<Modal.Title>
|
||||
There was an error with Cloud Import :(
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{ errorMessage }
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
.modal-backdrop {
|
||||
z-index: 100000 !important;
|
||||
}
|
|
@ -0,0 +1,356 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component, Fragment } from "react";
|
||||
import { Modal, Button, FormGroup, ControlLabel, FormControl, HelpBlock } from "react-bootstrap";
|
||||
import Select from 'react-select';
|
||||
import "./SelectUrlDialog.scss";
|
||||
|
||||
export default class SelectUrlDialog extends Component {
|
||||
static defaultProps = {
|
||||
platform: null,
|
||||
show: false,
|
||||
ddbUrl: null
|
||||
};
|
||||
static propTypes = {
|
||||
onHide: PropTypes.func.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
ddbUrl: PropTypes.string,
|
||||
show: PropTypes.bool.isRequired,
|
||||
apiURL: PropTypes.string.isRequired,
|
||||
}
|
||||
|
||||
constructor(props){
|
||||
super(props);
|
||||
|
||||
this.resetState();
|
||||
}
|
||||
|
||||
resetState() {
|
||||
|
||||
this.state = {
|
||||
error: "",
|
||||
organizations: [],
|
||||
datasets: [],
|
||||
folders: [],
|
||||
loadingOrganizations: true,
|
||||
loadingDatasets: false,
|
||||
loadingFolders: false,
|
||||
hasLogin: false,
|
||||
selectedOrganization: null,
|
||||
selectedDataset: null,
|
||||
selectedFolder: null,
|
||||
info: null,
|
||||
|
||||
verDs: null,
|
||||
verCount: 0,
|
||||
verSize: 0,
|
||||
verFolder: null,
|
||||
|
||||
// verifyStatus: null (not started), 'loading', 'success', 'error'
|
||||
verifyStatus: null
|
||||
};
|
||||
}
|
||||
|
||||
// Format bytes to readable string
|
||||
formatBytes(bytes, decimals=2) {
|
||||
if(bytes == 0) return '0 bytes';
|
||||
var k = 1024,
|
||||
dm = decimals || 2,
|
||||
sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'],
|
||||
i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
handleOnShow = () => {
|
||||
|
||||
this.resetState();
|
||||
|
||||
$.get(`${this.props.apiURL}/organizations`)
|
||||
.done(result => {
|
||||
|
||||
var orgs = result.map(org => {
|
||||
return { label: org.name !== org.slug ? `${org.name} (${org.slug})` : org.slug, value: org.slug };
|
||||
});
|
||||
|
||||
if (orgs.length > 0) {
|
||||
this.setState({organizations: orgs, loadingOrganizations: false});
|
||||
|
||||
// Search for user organization
|
||||
var userOrg = orgs.find(org => org.value === this.state.info.username);
|
||||
|
||||
this.handleSelectOrganization(userOrg != null ? userOrg : orgs[0]);
|
||||
} else
|
||||
this.setState({organizations: orgs, loadingOrganizations: false});
|
||||
})
|
||||
.fail((error) => {
|
||||
this.setState({error: "Cannot load organizations. Check your internet connection.", organizations: []});
|
||||
})
|
||||
.always(() => {
|
||||
this.setState({loadingOrganizations: false});
|
||||
});
|
||||
|
||||
$.get(`${this.props.apiURL}/info`)
|
||||
.done(result => {
|
||||
this.setState({info: result});
|
||||
})
|
||||
.fail((error) => {
|
||||
this.setState({info: null});
|
||||
});
|
||||
}
|
||||
|
||||
handleVerify = () => {
|
||||
|
||||
this.setState({verifyStatus: 'loading'});
|
||||
|
||||
$.post(`${this.props.apiURL}/verifyurl`, { url: this.state.ddbUrl }).done(result => {
|
||||
|
||||
if (result != null) {
|
||||
this.setState({
|
||||
verifyStatus: result.count > 0 ? 'success' : 'error',
|
||||
verCount: result.count,
|
||||
verDs: result.ds,
|
||||
verSize: result.size,
|
||||
verFolder: result.folder
|
||||
});
|
||||
} else {
|
||||
this.setState({verifyStatus: 'error'});
|
||||
}
|
||||
|
||||
|
||||
})
|
||||
.fail((error) => {
|
||||
this.setState({verifyStatus: 'error'});
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
handleSelectOrganization = (e) => {
|
||||
|
||||
if (this.state.selectedOrganization !== null && e.value === this.state.selectedOrganization.value) return;
|
||||
|
||||
this.setState({
|
||||
loadingDatasets: true,
|
||||
selectedOrganization: e,
|
||||
selectedDataset: null,
|
||||
selectedFolder: null,
|
||||
verifyStatus: null,
|
||||
datasets: [],
|
||||
folders: []
|
||||
});
|
||||
|
||||
$.get(`${this.props.apiURL}/organizations/${e.value}/datasets`)
|
||||
.done(result => {
|
||||
|
||||
var dss = result.map(ds => {
|
||||
return { label: ds.name !== ds.slug ?
|
||||
`${ds.name} (${ds.slug}) - ${ds.entries} entries (${this.formatBytes(ds.size)})`:
|
||||
`${ds.name} - ${ds.entries} entries (${this.formatBytes(ds.size)})`, name: ds.name, value: ds.slug };
|
||||
});
|
||||
|
||||
if (dss.length > 0) {
|
||||
this.setState({datasets: dss, loadingDatasets: false});
|
||||
this.handleSelectDataset(dss[0]);
|
||||
} else
|
||||
this.setState({datasets: dss, loadingDatasets: false});
|
||||
|
||||
})
|
||||
.fail((error) => {
|
||||
this.setState({error: "Cannot load datasets. Check your internet connection."});
|
||||
})
|
||||
.always(() => {
|
||||
this.setState({loadingDatasets: false});
|
||||
});
|
||||
};
|
||||
|
||||
handleSelectDataset = (e) => {
|
||||
|
||||
if (this.state.selectedDataset !== null && e.value === this.state.selectedDataset.value) return;
|
||||
|
||||
this.setState({
|
||||
selectedDataset: e,
|
||||
selectedFolder: null,
|
||||
loadingFolders: true,
|
||||
verifyStatus: null,
|
||||
folders: []
|
||||
});
|
||||
|
||||
$.get(`${this.props.apiURL}/organizations/${this.state.selectedOrganization.value}/datasets/${e.value}/folders`)
|
||||
.done(result => {
|
||||
|
||||
var folders = result.map(folder => {
|
||||
return { label: folder, value: '/' + folder };
|
||||
});
|
||||
|
||||
folders.unshift({label: '/', value: '/'});
|
||||
folders.sort();
|
||||
|
||||
if (folders.length > 0) {
|
||||
this.setState({folders: folders, loadingFolders: false});
|
||||
this.handleSelectFolder(folders[0]);
|
||||
} else
|
||||
this.setState({folders: folders, loadingFolders: false});
|
||||
|
||||
})
|
||||
.fail((error) => {
|
||||
this.setState({error: "Cannot load folders. Check your internet connection."});
|
||||
})
|
||||
.always(() => {
|
||||
this.setState({loadingFolders: false});
|
||||
});
|
||||
};
|
||||
|
||||
handleSelectFolder = e => {
|
||||
|
||||
if (this.state.selectedFolder !== null && e.value === this.state.selectedFolder.value) return;
|
||||
|
||||
this.setState({selectedFolder: e, verifyStatus: null});
|
||||
|
||||
if (this.state.info == null || this.state.info.hubUrl == null) {
|
||||
console.warn("Cannot generate ddb url, no hub url");
|
||||
return;
|
||||
}
|
||||
|
||||
var url = `${this.state.info.hubUrl}/${this.state.selectedOrganization.value}/${this.state.selectedDataset.value}${e.value}`
|
||||
.replace('http://', 'ddb+unsafe://')
|
||||
.replace('https://', 'ddb://');
|
||||
|
||||
this.setState({ddbUrl: url});
|
||||
|
||||
}
|
||||
|
||||
handleChange = (e) => {
|
||||
this.setState({ddbUrl: e.target.value, verifyStatus: null});
|
||||
};
|
||||
|
||||
handleSubmit = e => {
|
||||
|
||||
this.props.onSubmit(
|
||||
{
|
||||
name: this.state.verDs != null ? this.state.verDs : "DroneDB",
|
||||
url: this.state.ddbUrl,
|
||||
images_count: this.state.verCount
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
onHide,
|
||||
ddbUrl,
|
||||
show
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Modal className={"folder-select"} onHide={onHide} show={show} onShow={this.handleOnShow}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>
|
||||
Import from DroneDB
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body bsClass="my-modal">
|
||||
{this.state.organizations!= null && this.state.organizations.length > 0 ?
|
||||
<div style={{'marginBottom': '20px'}}>
|
||||
<p>Import images from your DroneDB account</p>
|
||||
<div className={"select-row"}>
|
||||
<div className={"icon-cell"}>
|
||||
<i className={"fas fa-sitemap"}></i>
|
||||
</div>
|
||||
<div className={"select-cell"}>
|
||||
<Select
|
||||
className="basic-single"
|
||||
classNamePrefix="select"
|
||||
isLoading={this.state.loadingOrganizations}
|
||||
isClearable={false}
|
||||
isSearchable={true}
|
||||
value={this.state.selectedOrganization}
|
||||
onChange={this.handleSelectOrganization}
|
||||
options={this.state.organizations}
|
||||
placeholder={this.state.loadingOrganizations ? "Fetching organizations..." : "Please select an organization"}
|
||||
name="organizations"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={"select-row"}>
|
||||
<div className={"icon-cell"}>
|
||||
<i className={"fas fa-database"}></i>
|
||||
</div>
|
||||
<div className={"select-cell"}>
|
||||
<Select
|
||||
className="basic-single"
|
||||
classNamePrefix="select"
|
||||
isLoading={this.state.loadingDatasets}
|
||||
isClearable={false}
|
||||
isSearchable={true}
|
||||
value={this.state.selectedDataset}
|
||||
isDisabled={this.state.selectedOrganization === null}
|
||||
onChange={this.handleSelectDataset}
|
||||
options={this.state.datasets}
|
||||
placeholder={this.state.loadingDatasets ? "Fetching datasets..." : (this.state.datasets.length > 0 ? "Please select a dataset" : "No datasets found")}
|
||||
name="datasets"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={"select-row"}>
|
||||
<div className={"icon-cell"}>
|
||||
<i className={"fas fa-folder"}></i>
|
||||
</div>
|
||||
<div className={"select-cell"}>
|
||||
<Select
|
||||
className="basic-single"
|
||||
classNamePrefix="select"
|
||||
isLoading={this.state.loadingFolders}
|
||||
isClearable={false}
|
||||
isSearchable={true}
|
||||
value={this.state.selectedFolder}
|
||||
isDisabled={this.state.selectedDataset === null || this.state.selectedOrganization === null}
|
||||
onChange={this.handleSelectFolder}
|
||||
options={this.state.folders}
|
||||
placeholder={this.state.loadingFolders ? "Fetching folders..." : "Please select a folder"}
|
||||
name="folders"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div> : <div className="text-center">
|
||||
{this.state.loadingOrganizations ? <i className="fa fa-circle-notch fa-spin fa-fw"></i> :
|
||||
<div className={"alert alert-info"}>
|
||||
<span><a href="/plugins/dronedb"><strong>Setup your DroneDB credentials</strong></a> to browse your organizations, datasets and folders!</span>
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
<p>DroneDB URL</p>
|
||||
<div className={"select-row"}>
|
||||
<div className={"icon-cell"}>
|
||||
<i className={"fas fa-globe"}></i>
|
||||
</div>
|
||||
<div className={"select-cell"}>
|
||||
<FormControl
|
||||
type="url"
|
||||
placeholder={"https://hub.dronedb.app/r/username/dataset"}
|
||||
value={this.state.ddbUrl || ''}
|
||||
onChange={this.handleChange} />
|
||||
</div>
|
||||
<div className={"icon-cell"}>
|
||||
{ this.state.verifyStatus==='loading' && <i className={"fas fa-spinner fa-spin"}></i> }
|
||||
{ this.state.verifyStatus==='success' && <i className={"fas fa-check"}></i> }
|
||||
{ this.state.verifyStatus==='error' && <i className={"fas fa-times"}></i> }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{this.state.verifyStatus != null && this.state.verifyStatus == "success" ?
|
||||
<div className={"alert alert-success"}>
|
||||
<span>Found <strong>{this.state.verCount}</strong> files ({this.formatBytes(this.state.verSize)})</span>
|
||||
</div>
|
||||
: ""}
|
||||
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button onClick={onHide}>Close</Button>
|
||||
<Button bsStyle="success" disabled={this.state.ddbUrl == null || this.state.ddbUrl.length == 0} onClick={this.handleVerify}>
|
||||
<i className={"fas fa-check"} />Verify</Button>
|
||||
<Button bsStyle="primary" disabled={this.state.verifyStatus !== 'success'} onClick={this.handleSubmit}>
|
||||
<i className={"fa fa-upload"} />Import</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
.modal-backdrop {
|
||||
z-index: 100000 !important;
|
||||
}
|
||||
|
||||
.folder-select.modal button i {
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
.my-modal {
|
||||
max-height: calc(100vh - 220px);
|
||||
position: relative;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.select-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
.icon-cell {
|
||||
width: 40px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.select-cell {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
Plik binarny nie jest wyświetlany.
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<metadata>Copyright (C) 2022 by original authors @ fontello.com</metadata>
|
||||
<defs>
|
||||
<font id="ddb" horiz-adv-x="1000" >
|
||||
<font-face font-family="ddb" font-weight="400" font-stretch="normal" units-per-em="1000" ascent="850" descent="-150" />
|
||||
<missing-glyph horiz-adv-x="1000" />
|
||||
<glyph glyph-name="ddb-icon" unicode="" d="M73 167c-32 0-58-26-58-59v-139c0-32 26-58 58-58h1090c33 0 58 26 58 58v139c0 33-25 59-58 59h-1090z m178-64c35 0 64-28 64-63 0-36-29-64-64-64-35 0-63 28-63 64 0 0 0 0 0 0 0 33 28 63 63 63 0 0 0 0 0 0z m374 0c35 0 64-28 64-63 0 0 0 0 0 0 0-36-29-64-64-64 0 0 0 0 0 0-35 0-63 28-63 64 0 0 0 0 0 0 0 33 28 63 63 63 0 0 0 0 0 0z m359 0c35 0 63-28 63-63 0 0 0 0 0 0 0-36-28-64-63-64 0 0 0 0 0 0-35 0-64 28-64 64 0 0 0 0 0 0 0 33 29 63 64 63 0 0 0 0 0 0z m-910 375c-33 0-59-26-59-58v-139c0-33 26-59 59-59h1089c33 0 59 26 59 59v139c0 32-26 58-59 58h-1089z m178-65c35 0 63-28 63-63 0-35-28-64-63-64-36 0-64 29-64 64 0 0 0 0 0 0 0 35 28 63 64 63 0 0 0 0 0 0z m374 0c35 0 63-28 63-63 0 0 0 0 0 0 0-35-28-64-63-64 0 0 0 0 0 0-36 0-64 29-64 64 0 0 0 0 0 0 0 35 28 63 64 63 0 0 0 0 0 0z m358 0c35 0 64-28 64-63 0 0 0 0 0 0 0-35-29-64-64-64 0 0 0 0 0 0-35 0-64 29-64 64 0 0 0 0 0 0 0 35 29 63 64 63 0 0 0 0 0 0z m-910 377c-33 0-59-26-59-59v-139c0-32 26-58 59-58h1089c33 0 59 26 59 58v139c0 33-26 59-59 59h-1089z m178-65c35 0 63-29 63-64 0-35-28-63-63-63-36 0-64 28-64 63 0 0 0 0 0 0 0 35 28 64 64 64 0 0 0 0 0 0z m374 0c35 0 63-29 63-64 0 0 0 0 0 0 0-35-28-63-63-63 0 0 0 0 0 0-36 0-64 28-64 63 0 0 0 0 0 0 0 35 28 64 64 64 0 0 0 0 0 0z m358 0c35 0 64-29 64-64 0 0 0 0 0 0 0-35-29-63-64-63 0 0 0 0 0 0-35 0-64 28-64 63 0 0 0 0 0 0 0 35 29 64 64 64 0 0 0 0 0 0z" horiz-adv-x="1236" />
|
||||
</font>
|
||||
</defs>
|
||||
</svg>
|
Po Szerokość: | Wysokość: | Rozmiar: 1.9 KiB |
Plik binarny nie jest wyświetlany.
Plik binarny nie jest wyświetlany.
Plik binarny nie jest wyświetlany.
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"dependencies": {
|
||||
"react-bootstrap": "^0.32.4",
|
||||
"react-select": "^3.0.4"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
@font-face {
|
||||
font-family: 'ddbfont';
|
||||
src: url('fonts/ddb.eot?16744445');
|
||||
src: url('fonts/ddb.eot?16744445#iefix') format('embedded-opentype'),
|
||||
url('fonts/ddb.woff2?16744445') format('woff2'),
|
||||
url('fonts/ddb.woff?16744445') format('woff'),
|
||||
url('fonts/ddb.ttf?16744445') format('truetype'),
|
||||
url('fonts/ddb.svg?16744445#ddb') format('svg');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.ddb-icon:before {
|
||||
font-family: "ddbfont";
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
speak: never;
|
||||
|
||||
display: inline-block;
|
||||
text-decoration: inherit;
|
||||
width: 1em;
|
||||
margin-right: .2em;
|
||||
text-align: center;
|
||||
/* opacity: .8; */
|
||||
|
||||
/* For safety - reset parent styles, that can break glyph codes*/
|
||||
font-variant: normal;
|
||||
text-transform: none;
|
||||
|
||||
/* fix buttons height, for twitter bootstrap */
|
||||
line-height: 1em;
|
||||
|
||||
/* Font smoothing. That was taken from TWBS */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
/* Uncomment for 3D effect */
|
||||
text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3);
|
||||
|
||||
content: '\e803';
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
{% extends "app/plugins/templates/base.html" %}
|
||||
{% load bootstrap_extras %}
|
||||
{% block content %}
|
||||
<h3>DroneDB</h3>
|
||||
Provide your credentials to access <a href="https://dronedb.app" target="_blank">DroneDB</a>:
|
||||
<br/>
|
||||
<br/>
|
||||
<form action="" method="post" class="oam-form oam-token-form">
|
||||
{% csrf_token %}
|
||||
{% include "app/plugins/templates/form.html" %}
|
||||
<button id="btnVerify" type="button" class="btn btn-success" disabled><i class="fas fa-user-check"></i> Verify Configuration</button>
|
||||
<button type="submit" class="btn btn-primary" disabled><i class="fa fa-save fa-fw"></i> Save Configuration</button>
|
||||
</form>
|
||||
<div id="alert-result" style="display: none; margin-top: 20px" class="alert" role="alert"></div>
|
||||
|
||||
<script>
|
||||
|
||||
$(document).ready(function() {
|
||||
|
||||
var running = false;
|
||||
var timeout = null;
|
||||
|
||||
$("input[name=registry_url],input[name=username],input[name=password]").on('keyup', function() {
|
||||
|
||||
var alert = $("#alert-result").hide();
|
||||
if (timeout != null) clearTimeout(timeout);
|
||||
|
||||
$("#btnVerify,button[type=submit]").prop('disabled',
|
||||
$("input[name=registry_url]").val().length == 0 ||
|
||||
$("input[name=username]").val().length == 0 ||
|
||||
$("input[name=password]").val().length == 0);
|
||||
});
|
||||
|
||||
$("#btnVerify").click(function() {
|
||||
|
||||
if (running) return;
|
||||
running = true;
|
||||
if (timeout != null) clearTimeout(timeout);
|
||||
|
||||
var alert = $("#alert-result");
|
||||
|
||||
alert.attr('class', 'alert alert-info');
|
||||
alert.text("Testing configuration...");
|
||||
alert.show();
|
||||
|
||||
$("#btnVerify").prop('disabled', true);
|
||||
|
||||
$.ajax({
|
||||
url: "/api/plugins/dronedb/checkcredentials",
|
||||
type: "POST",
|
||||
data: {
|
||||
"csrfmiddlewaretoken": $("input[name=csrfmiddlewaretoken]").val(),
|
||||
"hubUrl": $("input[name=registry_url]").val(),
|
||||
"userName": $("input[name=username]").val(),
|
||||
"password": $("input[name=password]").val()
|
||||
},
|
||||
success: function(data) {
|
||||
|
||||
if (data.success) {
|
||||
alert.attr('class', 'alert alert-success');
|
||||
alert.text("Configuration verified successfully!");
|
||||
} else {
|
||||
alert.attr('class', 'alert alert-danger');
|
||||
alert.text("Configuration verification failed!");
|
||||
}
|
||||
|
||||
timeout = setTimeout(function() { alert.hide(); }, 5000);
|
||||
|
||||
},
|
||||
error: function(data) {
|
||||
alert.attr('class', 'alert alert-danger');
|
||||
alert.text("Configuration failed: cannot reach server!");
|
||||
},
|
||||
complete: function() {
|
||||
running = false;
|
||||
$("#btnVerify").prop('disabled', false);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
|
@ -0,0 +1,19 @@
|
|||
PluginsAPI.Dashboard.addNewTaskButton(
|
||||
["dronedb/build/ImportView.js"],
|
||||
function(args, ImportView) {
|
||||
return React.createElement(ImportView, {
|
||||
onNewTaskAdded: args.onNewTaskAdded,
|
||||
projectId: args.projectId,
|
||||
apiURL: "{{ api_url }}",
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
PluginsAPI.Dashboard.addTaskActionButton(['dronedb/build/ShareButton.js'],function(args, ShareButton){
|
||||
var task = args.task;
|
||||
|
||||
if (task.available_assets !== null && task.available_assets.length > 0){
|
||||
return React.createElement(ShareButton, {task: task, token: "${token}"});
|
||||
}
|
||||
}
|
||||
);
|
Ładowanie…
Reference in New Issue