From 84356f1ce790d318e176fffe4e3de0907786529a Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Mon, 21 Aug 2023 11:43:50 -0400 Subject: [PATCH 01/20] External auth PoC, add task sizes --- app/api/tasks.py | 2 +- app/auth/backends.py | 73 +++++++++++++++++++ app/migrations/0036_task_size.py | 50 +++++++++++++ app/models/task.py | 14 ++++ app/static/app/js/classes/Utils.js | 10 +++ app/static/app/js/components/TaskListItem.jsx | 6 ++ .../app/js/components/UploadProgressBar.jsx | 13 +--- package.json | 2 +- webodm/settings.py | 5 ++ 9 files changed, 162 insertions(+), 13 deletions(-) create mode 100644 app/auth/backends.py create mode 100644 app/migrations/0036_task_size.py diff --git a/app/api/tasks.py b/app/api/tasks.py index 8e4d9f2e..4e4da2da 100644 --- a/app/api/tasks.py +++ b/app/api/tasks.py @@ -75,7 +75,7 @@ class TaskSerializer(serializers.ModelSerializer): class Meta: model = models.Task exclude = ('console_output', 'orthophoto_extent', 'dsm_extent', 'dtm_extent', ) - read_only_fields = ('processing_time', 'status', 'last_error', 'created_at', 'pending_action', 'available_assets', ) + read_only_fields = ('processing_time', 'status', 'last_error', 'created_at', 'pending_action', 'available_assets', 'size', ) class TaskViewSet(viewsets.ViewSet): """ diff --git a/app/auth/backends.py b/app/auth/backends.py new file mode 100644 index 00000000..95fcb2c1 --- /dev/null +++ b/app/auth/backends.py @@ -0,0 +1,73 @@ +import requests +from django.contrib.auth.backends import ModelBackend +from django.contrib.auth.models import User +from nodeodm.models import ProcessingNode +from webodm.settings import EXTERNAL_AUTH_ENDPOINT, USE_EXTERNAL_AUTH +from guardian.shortcuts import assign_perm +import logging + +logger = logging.getLogger('app.logger') + +class ExternalBackend(ModelBackend): + def authenticate(self, request, username=None, password=None): + if not USE_EXTERNAL_AUTH: + return None + + try: + r = requests.post(EXTERNAL_AUTH_ENDPOINT, { + 'username': username, + 'password': password + }, headers={'Accept': 'application/json'}) + res = r.json() + + if 'message' in res or 'error' in res: + return None + + logger.info(res) + + if 'user_id' in res: + try: + user = User.objects.get(pk=res['user_id']) + + # Update user info + if user.username != username: + user.username = username + user.save() + except User.DoesNotExist: + user = User(pk=res['user_id'], username=username) + user.save() + + # Setup/update processing node + if ('api_key' in res or 'token' in res) and 'node' in res: + hostname = res['node']['hostname'] + port = res['node']['port'] + token = res['api_key'] if 'api_key' in res else res['token'] + + try: + node = ProcessingNode.objects.get(token=token) + if node.hostname != hostname or node.port != port: + node.hostname = hostname + node.port = port + node.save() + + except ProcessingNode.DoesNotExist: + node = ProcessingNode(hostname=hostname, port=port, token=token) + node.save() + + if not user.has_perm('view_processingnode', node): + assign_perm('view_processingnode', user, node) + + return user + else: + return None + except: + return None + + def get_user(self, user_id): + if not USE_EXTERNAL_AUTH: + return None + + try: + return User.objects.get(pk=user_id) + except User.DoesNotExist: + return None \ No newline at end of file diff --git a/app/migrations/0036_task_size.py b/app/migrations/0036_task_size.py new file mode 100644 index 00000000..1fe42195 --- /dev/null +++ b/app/migrations/0036_task_size.py @@ -0,0 +1,50 @@ +# Generated by Django 2.2.27 on 2023-08-21 14:50 +import os +from django.db import migrations, models +from webodm import settings + +def task_path(project_id, task_id, *args): + return os.path.join(settings.MEDIA_ROOT, + "project", + str(project_id), + "task", + str(task_id), + *args) + +def update_size(task): + try: + total_bytes = 0 + for dirpath, _, filenames in os.walk(task_path(task.project.id, task.id)): + for f in filenames: + fp = os.path.join(dirpath, f) + if not os.path.islink(fp): + total_bytes += os.path.getsize(fp) + task.size = (total_bytes / 1024 / 1024) + task.save() + print("Updated {} with size {}".format(task, task.size)) + except Exception as e: + print("Cannot update size for task {}: {}".format(task, str(e))) + + + +def update_task_sizes(apps, schema_editor): + Task = apps.get_model('app', 'Task') + + for t in Task.objects.all(): + update_size(t) + +class Migration(migrations.Migration): + + dependencies = [ + ('app', '0035_task_orthophoto_bands'), + ] + + operations = [ + migrations.AddField( + model_name='task', + name='size', + field=models.FloatField(blank=True, default=0.0, help_text='Size of the task on disk in megabytes', verbose_name='Size'), + ), + + migrations.RunPython(update_task_sizes), + ] diff --git a/app/models/task.py b/app/models/task.py index be2c4987..09d51aa8 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -279,6 +279,7 @@ class Task(models.Model): epsg = models.IntegerField(null=True, default=None, blank=True, help_text=_("EPSG code of the dataset (if georeferenced)"), verbose_name="EPSG") tags = models.TextField(db_index=True, default="", blank=True, help_text=_("Task tags"), verbose_name=_("Tags")) orthophoto_bands = fields.JSONField(default=list, blank=True, help_text=_("List of orthophoto bands"), verbose_name=_("Orthophoto Bands")) + size = models.FloatField(default=0.0, blank=True, help_text=_("Size of the task on disk in megabytes"), verbose_name=_("Size")) class Meta: verbose_name = _("Task") @@ -1161,3 +1162,16 @@ class Task(models.Model): else: with open(file.temporary_file_path(), 'rb') as f: shutil.copyfileobj(f, fd) + + def update_size(self, commit=False): + try: + total_bytes = 0 + for dirpath, _, filenames in os.walk(self.task_path()): + for f in filenames: + fp = os.path.join(dirpath, f) + if not os.path.islink(fp): + total_bytes += os.path.getsize(fp) + self.size = (total_bytes / 1024 / 1024) + if commit: self.save() + except Exception as e: + logger.warn("Cannot update size for task {}: {}".format(self, str(e))) diff --git a/app/static/app/js/classes/Utils.js b/app/static/app/js/classes/Utils.js index 989da530..e843275d 100644 --- a/app/static/app/js/classes/Utils.js +++ b/app/static/app/js/classes/Utils.js @@ -93,6 +93,16 @@ export default { saveAs: function(text, filename){ var blob = new Blob([text], {type: "text/plain;charset=utf-8"}); FileSaver.saveAs(blob, filename); + }, + + // http://stackoverflow.com/questions/15900485/correct-way-to-convert-size-in-bytes-to-kb-mb-gb-in-javascript + bytesToSize: function(bytes, decimals = 2){ + if(bytes == 0) return '0 byte'; + var k = 1000; // or 1024 for binary + var dm = decimals || 3; + var sizes = ['bytes', 'Kb', 'Mb', 'Gb', 'Tb', 'Pb', 'Eb', 'Zb', 'Yb']; + var i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; } }; diff --git a/app/static/app/js/components/TaskListItem.jsx b/app/static/app/js/components/TaskListItem.jsx index 25611138..c995e49f 100644 --- a/app/static/app/js/components/TaskListItem.jsx +++ b/app/static/app/js/components/TaskListItem.jsx @@ -14,6 +14,7 @@ import PipelineSteps from '../classes/PipelineSteps'; import Css from '../classes/Css'; import Tags from '../classes/Tags'; import Trans from './Trans'; +import Utils from '../classes/Utils'; import { _, interpolate } from '../classes/gettext'; class TaskListItem extends React.Component { @@ -572,6 +573,11 @@ class TaskListItem extends React.Component { {_("Reconstructed Points:")} {stats.pointcloud.points.toLocaleString()} } + {task.size > 0 && + + {_("Size:")} + {Utils.bytesToSize(task.size * 1024 * 1024)} + } {_("Task Output:")}
diff --git a/app/static/app/js/components/UploadProgressBar.jsx b/app/static/app/js/components/UploadProgressBar.jsx index 689b22a1..e0bbe5d7 100644 --- a/app/static/app/js/components/UploadProgressBar.jsx +++ b/app/static/app/js/components/UploadProgressBar.jsx @@ -2,6 +2,7 @@ import '../css/UploadProgressBar.scss'; import React from 'react'; import PropTypes from 'prop-types'; import { _, interpolate } from '../classes/gettext'; +import Utils from '../classes/Utils'; class UploadProgressBar extends React.Component { static propTypes = { @@ -11,22 +12,12 @@ class UploadProgressBar extends React.Component { totalCount: PropTypes.number // number of files } - // http://stackoverflow.com/questions/15900485/correct-way-to-convert-size-in-bytes-to-kb-mb-gb-in-javascript - bytesToSize(bytes, decimals = 2){ - if(bytes == 0) return '0 byte'; - var k = 1000; // or 1024 for binary - var dm = decimals || 3; - var sizes = ['bytes', 'Kb', 'Mb', 'Gb', 'Tb', 'Pb', 'Eb', 'Zb', 'Yb']; - var i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; - } - render() { let percentage = (this.props.progress !== undefined ? this.props.progress : 0).toFixed(2); let bytes = this.props.totalBytesSent !== undefined && this.props.totalBytes !== undefined ? - ' ' + interpolate(_("remaining to upload: %(bytes)s"), { bytes: this.bytesToSize(this.props.totalBytes - this.props.totalBytesSent)}) : + ' ' + interpolate(_("remaining to upload: %(bytes)s"), { bytes: Utils.bytesToSize(this.props.totalBytes - this.props.totalBytesSent)}) : ""; let active = percentage < 100 ? "active" : ""; diff --git a/package.json b/package.json index 05af3481..3887114c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "WebODM", - "version": "2.0.3", + "version": "2.1.0", "description": "User-friendly, extendable application and API for processing aerial imagery.", "main": "index.js", "scripts": { diff --git a/webodm/settings.py b/webodm/settings.py index aff1e75d..943a4705 100644 --- a/webodm/settings.py +++ b/webodm/settings.py @@ -169,6 +169,7 @@ AUTH_PASSWORD_VALIDATORS = [ AUTHENTICATION_BACKENDS = ( 'django.contrib.auth.backends.ModelBackend', # this is default 'guardian.backends.ObjectPermissionBackend', + 'app.auth.backends.ExternalBackend', ) # Internationalization @@ -380,6 +381,10 @@ CELERY_WORKER_HIJACK_ROOT_LOGGER = False # before it should be considered offline NODE_OFFLINE_MINUTES = 5 +USE_EXTERNAL_AUTH = True # TODO: change +EXTERNAL_AUTH_ENDPOINT = "http://192.168.2.253:5000/r/auth/login" +# TODO: make these env vars? + if TESTING or FLUSHING: CELERY_TASK_ALWAYS_EAGER = True From 08608a672729e45b844a41db1c888d7fbc9f193e Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Mon, 21 Aug 2023 12:55:17 -0400 Subject: [PATCH 02/20] Fix non-georeferenced textured models loading --- app/static/app/js/ModelView.jsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/static/app/js/ModelView.jsx b/app/static/app/js/ModelView.jsx index f72b429d..b149743e 100644 --- a/app/static/app/js/ModelView.jsx +++ b/app/static/app/js/ModelView.jsx @@ -644,9 +644,10 @@ class ModelView extends React.Component { return; } - const offset = { - x: gltf.scene.CESIUM_RTC.center[0], - y: gltf.scene.CESIUM_RTC.center[1] + const offset = {x: 0, y: 0}; + if (gltf.scene.CESIUM_RTC && gltf.scene.CESIUM_RTC.center){ + offset.x = gltf.scene.CESIUM_RTC.center[0]; + offset.y = gltf.scene.CESIUM_RTC.center[1]; } addObject(gltf.scene, offset); From 5ba0d472afb7122c52c8668923b4a57127ff107c Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Thu, 24 Aug 2023 12:17:50 -0400 Subject: [PATCH 03/20] Add tests, update size --- app/models/task.py | 2 ++ app/tests/test_api_task.py | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/app/models/task.py b/app/models/task.py index 09d51aa8..79fb1da5 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -433,6 +433,7 @@ class Task(models.Model): shutil.copytree(self.task_path(), task.task_path()) else: logger.warning("Task {} doesn't have folder, will skip copying".format(self)) + return task except Exception as e: logger.warning("Cannot duplicate task: {}".format(str(e))) @@ -886,6 +887,7 @@ class Task(models.Model): self.update_available_assets_field() self.update_epsg_field() self.update_orthophoto_bands_field() + self.update_size() self.potree_scene = {} self.running_progress = 1.0 self.console_output += gettext("Done!") + "\n" diff --git a/app/tests/test_api_task.py b/app/tests/test_api_task.py index 343a5382..2cd2dbcb 100644 --- a/app/tests/test_api_task.py +++ b/app/tests/test_api_task.py @@ -249,6 +249,9 @@ class TestApiTask(BootTransactionTestCase): # Orthophoto bands field should be an empty list self.assertEqual(len(task.orthophoto_bands), 0) + # Size should be zero + self.assertEqual(task.size, 0) + # tiles.json, bounds, metadata should not be accessible at this point tile_types = ['orthophoto', 'dsm', 'dtm'] endpoints = ['tiles.json', 'bounds', 'metadata'] @@ -384,6 +387,9 @@ class TestApiTask(BootTransactionTestCase): # Orthophoto bands field should be populated self.assertEqual(len(task.orthophoto_bands), 4) + # Size should be updated + self.assertTrue(task.size > 0) + # Can export orthophoto (when formula and bands are specified) res = client.post("/api/projects/{}/tasks/{}/orthophoto/export".format(project.id, task.id), { 'formula': 'NDVI' @@ -946,6 +952,7 @@ class TestApiTask(BootTransactionTestCase): self.assertTrue(res.data['success']) new_task_id = res.data['task']['id'] self.assertNotEqual(res.data['task']['id'], task.id) + self.assertEqual(res.data['task']['size'], task.size) new_task = Task.objects.get(pk=new_task_id) From ba1965add0fc9645376b372d95f17577eead485c Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Thu, 24 Aug 2023 15:02:30 -0400 Subject: [PATCH 04/20] Add profile model --- app/admin.py | 14 ++++++++++ app/migrations/0037_profile.py | 35 +++++++++++++++++++++++++ app/models/__init__.py | 1 + app/models/profile.py | 37 +++++++++++++++++++++++++++ app/templates/app/logged_in_base.html | 11 ++++++++ app/templatetags/settings.py | 9 +++++++ requirements.txt | 1 + webodm/settings.py | 10 ++++++++ 8 files changed, 118 insertions(+) create mode 100644 app/migrations/0037_profile.py create mode 100644 app/models/profile.py diff --git a/app/admin.py b/app/admin.py index 81848e19..7ad77d95 100644 --- a/app/admin.py +++ b/app/admin.py @@ -10,10 +10,13 @@ from django.http import HttpResponseRedirect from django.urls import reverse from django.utils.html import format_html from guardian.admin import GuardedModelAdmin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from django.contrib.auth.models import User from app.models import PluginDatum from app.models import Preset from app.models import Plugin +from app.models import Profile from app.plugins import get_plugin_by_name, enable_plugin, disable_plugin, delete_plugin, valid_plugin, \ get_plugins_persistent_path, clear_plugins_cache, init_plugins from .models import Project, Task, Setting, Theme @@ -260,3 +263,14 @@ class PluginAdmin(admin.ModelAdmin): admin.site.register(Plugin, PluginAdmin) + +class ProfileInline(admin.StackedInline): + model = Profile + can_delete = False + +class UserAdmin(BaseUserAdmin): + inlines = [ProfileInline] + +# Re-register UserAdmin +admin.site.unregister(User) +admin.site.register(User, UserAdmin) diff --git a/app/migrations/0037_profile.py b/app/migrations/0037_profile.py new file mode 100644 index 00000000..ab7a1fa0 --- /dev/null +++ b/app/migrations/0037_profile.py @@ -0,0 +1,35 @@ +# Generated by Django 2.2.27 on 2023-08-24 16:35 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +def create_profiles(apps, schema_editor): + User = apps.get_model('auth', 'User') + Profile = apps.get_model('app', 'Profile') + + for u in User.objects.all(): + p = Profile.objects.create(user=u) + p.save() + print("Created user profile for %s" % u.username) + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('app', '0036_task_size'), + ] + + operations = [ + migrations.CreateModel( + name='Profile', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quota', models.FloatField(blank=True, default=-1, help_text='Maximum disk quota in megabytes', verbose_name='Quota')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + + migrations.RunPython(create_profiles), + ] diff --git a/app/models/__init__.py b/app/models/__init__.py index b7434b5d..a9d64a24 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -5,6 +5,7 @@ from .theme import Theme from .setting import Setting from .plugin_datum import PluginDatum from .plugin import Plugin +from .profile import Profile # deprecated def image_directory_path(image_upload, filename): diff --git a/app/models/profile.py b/app/models/profile.py new file mode 100644 index 00000000..11adefee --- /dev/null +++ b/app/models/profile.py @@ -0,0 +1,37 @@ +from django.contrib.auth.models import User +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django.db.models.signals import post_save +from django.dispatch import receiver +from app.models import Task +from django.db.models import Sum +from django.core.cache import cache + +class Profile(models.Model): + user = models.OneToOneField(User, on_delete=models.CASCADE) + quota = models.FloatField(default=-1, blank=True, help_text=_("Maximum disk quota in megabytes"), verbose_name=_("Quota")) + + def has_quota(self): + return self.quota != -1 + + def used_quota(self): + return Task.objects.filter(project__owner=self.user).aggregate(total=Sum('size'))['total'] + + def used_quota_cached(self): + k = f'used_quota_{self.user.id}' + cached = cache.get(k) + if cached is not None: + return cached + + v = self.used_quota() + cache.set(k, v, 300) # 2 minutes + return v + +@receiver(post_save, sender=User) +def create_user_profile(sender, instance, created, **kwargs): + if created: + Profile.objects.create(user=instance) + +@receiver(post_save, sender=User) +def save_user_profile(sender, instance, **kwargs): + instance.profile.save() \ No newline at end of file diff --git a/app/templates/app/logged_in_base.html b/app/templates/app/logged_in_base.html index c4d18f5c..2f8fe229 100644 --- a/app/templates/app/logged_in_base.html +++ b/app/templates/app/logged_in_base.html @@ -15,6 +15,17 @@ {% blocktrans with user=user.username %}Hello, {{ user }}!{% endblocktrans %}
+ {% if user.profile.has_quota %} +
  • + {% with tot_quota=user.profile.quota %} + {% with used_quota=user.profile.used_quota_cached %} + {% percentage 0 tot_quota as perc_quota %} + + Tot: {{ tot_quota }} Used: {{ used_quota }} + Perc: {{ perc_quota|floatformat:0 }} + + {% endwith %}{% endwith %} + {% endif %}
  • {% trans 'Logout' %}
  • diff --git a/app/templatetags/settings.py b/app/templatetags/settings.py index 7bda2e18..f12ebf88 100644 --- a/app/templatetags/settings.py +++ b/app/templatetags/settings.py @@ -7,6 +7,15 @@ from webodm import settings register = template.Library() logger = logging.getLogger('app.logger') +@register.simple_tag +def percentage(num, den, maximum=None): + if den == 0: + return 0 + perc = max(0, num / den * 100) + if maximum is not None: + perc = min(perc, maximum) + return perc + @register.simple_tag def is_single_user_mode(): return settings.SINGLE_USER_MODE diff --git a/requirements.txt b/requirements.txt index 61aa2130..07a92e66 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,6 +14,7 @@ django-filter==2.4.0 django-guardian==1.4.9 django-imagekit==4.0.1 django-libsass==0.7 +django-redis==4.12.1 django-webpack-loader==0.6.0 djangorestframework==3.13.1 djangorestframework-jwt==1.9.0 diff --git a/webodm/settings.py b/webodm/settings.py index 943a4705..66bf8561 100644 --- a/webodm/settings.py +++ b/webodm/settings.py @@ -377,6 +377,16 @@ CELERY_INCLUDE=['worker.tasks', 'app.plugins.worker'] CELERY_WORKER_REDIRECT_STDOUTS = False CELERY_WORKER_HIJACK_ROOT_LOGGER = False +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": os.environ.get('WO_BROKER', 'redis://localhost'), + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + } + } +} + # Number of minutes a processing node hasn't been seen # before it should be considered offline NODE_OFFLINE_MINUTES = 5 From cd7f7790198c374701f8f53365707a4a0b7ad4cb Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Sat, 26 Aug 2023 06:07:05 -0400 Subject: [PATCH 05/20] Update locale --- locale | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/locale b/locale index 31a7b8fc..04c6bb88 160000 --- a/locale +++ b/locale @@ -1 +1 @@ -Subproject commit 31a7b8fc6d955e8bd6c13d2de84501bc43895190 +Subproject commit 04c6bb88e48e5dad3c0686dafa59ae8852d72273 From a0dbd681221b6ad5d0262b0bafbf7e7795bfef68 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Sat, 26 Aug 2023 08:12:16 -0400 Subject: [PATCH 06/20] Pretty quota status bar --- app/static/app/css/sb-admin-2.css | 17 +++++++++++++++- app/templates/app/logged_in_base.html | 28 ++++++++++++++++++++------- app/templatetags/settings.py | 14 +++++++++++++- 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/app/static/app/css/sb-admin-2.css b/app/static/app/css/sb-admin-2.css index 396503db..8103ba6b 100644 --- a/app/static/app/css/sb-admin-2.css +++ b/app/static/app/css/sb-admin-2.css @@ -50,11 +50,26 @@ body { margin-right: 0; } -.navbar-top-links .dropdown-menu li a { +.navbar-top-links .dropdown-menu li a{ padding: 3px 20px; min-height: 0; } +.navbar-top-links .dropdown-menu li div.info-item{ + padding: 3px 8px; + min-height: 0; +} + +.navbar-top-links .dropdown-menu li div.info-item.quotas{ + min-width: 190px; +} + +.navbar-top-links .dropdown-menu li .progress{ + margin-bottom: 0; + margin-top: 6px; +} + + .navbar-top-links .dropdown-menu li a div { white-space: normal; } diff --git a/app/templates/app/logged_in_base.html b/app/templates/app/logged_in_base.html index 2f8fe229..2920cdfb 100644 --- a/app/templates/app/logged_in_base.html +++ b/app/templates/app/logged_in_base.html @@ -12,17 +12,31 @@
    - {% endwith %}{% endwith %} + {% endwith %} {% endif %}
  • {% trans 'Logout' %} diff --git a/app/templatetags/settings.py b/app/templatetags/settings.py index ae6e2c7c..3ab491da 100644 --- a/app/templatetags/settings.py +++ b/app/templatetags/settings.py @@ -28,6 +28,10 @@ def percentage(num, den, maximum=None): perc = min(perc, maximum) return perc +@register.simple_tag +def quota_exceeded_grace_period(): + return settings.QUOTA_EXCEEDED_GRACE_PERIOD + @register.simple_tag def is_single_user_mode(): return settings.SINGLE_USER_MODE diff --git a/app/views/app.py b/app/views/app.py index 58dbc907..f37266e2 100644 --- a/app/views/app.py +++ b/app/views/app.py @@ -38,9 +38,10 @@ def dashboard(request): return redirect(settings.PROCESSING_NODES_ONBOARDING) no_tasks = Task.objects.filter(project__owner=request.user).count() == 0 - + no_projects = Project.objects.filter(owner=request.user).count() == 0 + # Create first project automatically - if Project.objects.count() == 0: + if no_projects and request.user.has_perm('app.add_project'): Project.objects.create(owner=request.user, name=_("First Project")) return render(request, 'app/dashboard.html', {'title': _('Dashboard'), diff --git a/webodm/settings.py b/webodm/settings.py index 66bf8561..896b27e9 100644 --- a/webodm/settings.py +++ b/webodm/settings.py @@ -395,6 +395,11 @@ USE_EXTERNAL_AUTH = True # TODO: change EXTERNAL_AUTH_ENDPOINT = "http://192.168.2.253:5000/r/auth/login" # TODO: make these env vars? +# Number of hours before tasks are automatically deleted +# from an account that is exceeding a disk quota +QUOTA_EXCEEDED_GRACE_PERIOD = 8 + + if TESTING or FLUSHING: CELERY_TASK_ALWAYS_EAGER = True From b4e54e6406b1b87a01831cf753a4287a71f68252 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Sat, 26 Aug 2023 09:53:42 -0400 Subject: [PATCH 08/20] Tweaks --- app/static/app/js/components/TaskListItem.jsx | 2 +- app/templates/app/dashboard.html | 4 ++-- app/templates/app/logged_in_base.html | 2 +- app/templatetags/settings.py | 2 +- app/tests/test_api_task.py | 3 +++ 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/static/app/js/components/TaskListItem.jsx b/app/static/app/js/components/TaskListItem.jsx index c995e49f..a8f58114 100644 --- a/app/static/app/js/components/TaskListItem.jsx +++ b/app/static/app/js/components/TaskListItem.jsx @@ -575,7 +575,7 @@ class TaskListItem extends React.Component { } {task.size > 0 && - {_("Size:")} + {_("Disk Usage:")} {Utils.bytesToSize(task.size * 1024 * 1024)} } diff --git a/app/templates/app/dashboard.html b/app/templates/app/dashboard.html index b6b28015..8fa7a548 100644 --- a/app/templates/app/dashboard.html +++ b/app/templates/app/dashboard.html @@ -41,10 +41,10 @@ {% endif %} {% if user.profile.has_exceeded_quota_cached %} - {% with total=user.profile.quota|storage_size used=user.profile.used_quota_cached|storage_size %} + {% with total=user.profile.quota|disk_size used=user.profile.used_quota_cached|disk_size %} {% quota_exceeded_grace_period as hours %}
    - {% blocktrans %}The current storage quota is being exceeded ({{ used }} of {{ total }} used). The most recent tasks will be automatically deleted within {{ hours }} hours, until usage falls below {{ total }}.{% endblocktrans %} + {% blocktrans %}The disk quota is being exceeded ({{ used }} of {{ total }} used). The most recent tasks will be automatically deleted within {{ hours }} hours, until usage falls below {{ total }}.{% endblocktrans %}
    {% endwith %} {% endif %} diff --git a/app/templates/app/logged_in_base.html b/app/templates/app/logged_in_base.html index 3abe707c..8ba8d36f 100644 --- a/app/templates/app/logged_in_base.html +++ b/app/templates/app/logged_in_base.html @@ -26,7 +26,7 @@
  • - {% with usage=perc_quota|floatformat:0 used=used_quota|storage_size total=tot_quota|storage_size %} + {% with usage=perc_quota|floatformat:0 used=used_quota|disk_size total=tot_quota|disk_size %} {% blocktrans %}{{used}} of {{total}} used{% endblocktrans %}
    diff --git a/app/templatetags/settings.py b/app/templatetags/settings.py index 3ab491da..2cb0d723 100644 --- a/app/templatetags/settings.py +++ b/app/templatetags/settings.py @@ -8,7 +8,7 @@ register = template.Library() logger = logging.getLogger('app.logger') @register.filter -def storage_size(megabytes): +def disk_size(megabytes): k = 1000 k2 = k ** 2 k3 = k ** 3 diff --git a/app/tests/test_api_task.py b/app/tests/test_api_task.py index 2cd2dbcb..1abbff4e 100644 --- a/app/tests/test_api_task.py +++ b/app/tests/test_api_task.py @@ -390,6 +390,9 @@ class TestApiTask(BootTransactionTestCase): # Size should be updated self.assertTrue(task.size > 0) + # The owner's used quota should have increased + self.assertTrue(task.project.owner.profile.used_quota_cached() > 0) + # Can export orthophoto (when formula and bands are specified) res = client.post("/api/projects/{}/tasks/{}/orthophoto/export".format(project.id, task.id), { 'formula': 'NDVI' From aa737da1a1dd16948af143bb93dbef1aec23ed04 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Fri, 1 Sep 2023 16:16:13 -0400 Subject: [PATCH 09/20] Expose profiles API, quota update endpoint --- app/api/admin.py | 39 +++++++++++++++++++++++++++++++- app/api/urls.py | 5 ++-- app/models/profile.py | 26 ++++++++++++++++++++- app/templates/app/dashboard.html | 4 ++-- app/templatetags/settings.py | 21 ++++++++++++++--- worker/celery.py | 8 +++++++ worker/tasks.py | 16 ++++++++++++- 7 files changed, 109 insertions(+), 10 deletions(-) diff --git a/app/api/admin.py b/app/api/admin.py index 329e00ef..2de55e4d 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -1,7 +1,10 @@ from django.contrib.auth.models import User, Group -from rest_framework import serializers, viewsets, generics, status +from app.models import Profile +from rest_framework import serializers, viewsets, generics, status, exceptions +from rest_framework.decorators import action from rest_framework.permissions import IsAdminUser from rest_framework.response import Response +from django.core.exceptions import ObjectDoesNotExist from django.contrib.auth.hashers import make_password from app import models @@ -20,6 +23,7 @@ class AdminUserViewSet(viewsets.ModelViewSet): if email is not None: queryset = queryset.filter(email=email) return queryset + def create(self, request): data = request.data.copy() password = data.get('password') @@ -44,3 +48,36 @@ class AdminGroupViewSet(viewsets.ModelViewSet): if name is not None: queryset = queryset.filter(name=name) return queryset + + +class ProfileSerializer(serializers.ModelSerializer): + class Meta: + model = Profile + exclude = ('id', ) + + read_only_fields = ('user', ) + +class AdminProfileViewSet(viewsets.ModelViewSet): + serializer_class = ProfileSerializer + permission_classes = [IsAdminUser] + lookup_field = 'user' + + def get_queryset(self): + return Profile.objects.all() + + + @action(detail=True, methods=['post']) + def update_quota_deadline(self, request, user=None): + try: + hours = float(request.data.get('hours', '')) + if hours < 0: + raise ValueError("hours must be >= 0") + except ValueError as e: + raise exceptions.ValidationError(str(e)) + + try: + p = Profile.objects.get(user=user) + except ObjectDoesNotExist: + raise exceptions.NotFound() + + return Response({'deadline': p.set_quota_deadline(hours)}, status=status.HTTP_200_OK) diff --git a/app/api/urls.py b/app/api/urls.py index bebfccd4..a29f0e0a 100644 --- a/app/api/urls.py +++ b/app/api/urls.py @@ -6,7 +6,7 @@ from .projects import ProjectViewSet from .tasks import TaskViewSet, TaskDownloads, TaskAssets, TaskAssetsImport from .imageuploads import Thumbnail, ImageDownload from .processingnodes import ProcessingNodeViewSet, ProcessingNodeOptionsView -from .admin import AdminUserViewSet, AdminGroupViewSet +from .admin import AdminUserViewSet, AdminGroupViewSet, AdminProfileViewSet from rest_framework_nested import routers from rest_framework_jwt.views import obtain_jwt_token from .tiler import TileJson, Bounds, Metadata, Tiles, Export @@ -26,6 +26,7 @@ tasks_router.register(r'tasks', TaskViewSet, basename='projects-tasks') admin_router = routers.DefaultRouter() admin_router.register(r'admin/users', AdminUserViewSet, basename='admin-users') admin_router.register(r'admin/groups', AdminGroupViewSet, basename='admin-groups') +admin_router.register(r'admin/profiles', AdminProfileViewSet, basename='admin-groups') urlpatterns = [ url(r'processingnodes/options/$', ProcessingNodeOptionsView.as_view()), @@ -56,7 +57,7 @@ urlpatterns = [ url(r'^auth/', include('rest_framework.urls')), url(r'^token-auth/', obtain_jwt_token), - url(r'^plugins/(?P[^/.]+)/(.*)$', api_view_handler) + url(r'^plugins/(?P[^/.]+)/(.*)$', api_view_handler), ] if settings.ENABLE_USERS_API: diff --git a/app/models/profile.py b/app/models/profile.py index d77d5932..1a1cf971 100644 --- a/app/models/profile.py +++ b/app/models/profile.py @@ -1,3 +1,4 @@ +import time from django.contrib.auth.models import User from django.db import models from django.utils.translation import gettext_lazy as _ @@ -6,6 +7,8 @@ from django.dispatch import receiver from app.models import Task from django.db.models import Sum from django.core.cache import cache +from webodm import settings + class Profile(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE) @@ -17,6 +20,13 @@ class Profile(models.Model): def used_quota(self): return Task.objects.filter(project__owner=self.user).aggregate(total=Sum('size'))['total'] + def has_exceeded_quota(self): + if not self.has_quota(): + return False + + q = self.used_quota() + return q > self.quota + def used_quota_cached(self): k = f'used_quota_{self.user.id}' cached = cache.get(k) @@ -36,6 +46,20 @@ class Profile(models.Model): def clear_used_quota_cache(self): cache.delete(f'used_quota_{self.user.id}') + + def get_quota_deadline(self): + return cache.get(f'quota_deadline_{self.user.id}') + + def set_quota_deadline(self, hours): + k = f'quota_deadline_{self.user.id}' + seconds = (hours * 60 * 60) + v = time.time() + seconds + cache.set(k, v, int(max(seconds * 10, settings.QUOTA_EXCEEDED_GRACE_PERIOD * 60 * 60))) + return v + + def clear_quota_deadline(self): + cache.delete(f'quota_deadline_{self.user.id}') + @receiver(post_save, sender=User) def create_user_profile(sender, instance, created, **kwargs): @@ -44,4 +68,4 @@ def create_user_profile(sender, instance, created, **kwargs): @receiver(post_save, sender=User) def save_user_profile(sender, instance, **kwargs): - instance.profile.save() \ No newline at end of file + instance.profile.save() diff --git a/app/templates/app/dashboard.html b/app/templates/app/dashboard.html index 8fa7a548..668af63e 100644 --- a/app/templates/app/dashboard.html +++ b/app/templates/app/dashboard.html @@ -42,9 +42,9 @@ {% if user.profile.has_exceeded_quota_cached %} {% with total=user.profile.quota|disk_size used=user.profile.used_quota_cached|disk_size %} - {% quota_exceeded_grace_period as hours %} + {% quota_exceeded_grace_period as when %}
    - {% blocktrans %}The disk quota is being exceeded ({{ used }} of {{ total }} used). The most recent tasks will be automatically deleted within {{ hours }} hours, until usage falls below {{ total }}.{% endblocktrans %} + {% blocktrans %}The disk quota is being exceeded ({{ used }} of {{ total }} used). The most recent tasks will be automatically deleted {{ when }}, until usage falls below {{ total }}.{% endblocktrans %}
    {% endwith %} {% endif %} diff --git a/app/templatetags/settings.py b/app/templatetags/settings.py index 2cb0d723..8ed581fa 100644 --- a/app/templatetags/settings.py +++ b/app/templatetags/settings.py @@ -1,8 +1,10 @@ import datetime import math import logging +import time from django import template from webodm import settings +from django.utils.translation import gettext as _ register = template.Library() logger = logging.getLogger('app.logger') @@ -28,9 +30,22 @@ def percentage(num, den, maximum=None): perc = min(perc, maximum) return perc -@register.simple_tag -def quota_exceeded_grace_period(): - return settings.QUOTA_EXCEEDED_GRACE_PERIOD +@register.simple_tag(takes_context=True) +def quota_exceeded_grace_period(context): + deadline = context.request.user.profile.get_quota_deadline() + now = time.time() + if deadline is None: + deadline = now + settings.QUOTA_EXCEEDED_GRACE_PERIOD * 60 * 60 + diff = max(0, deadline - now) + if diff >= 60*60*24*2: + return _("within %(num)s days") % {"num": math.ceil(diff / (60*60*24))} + elif diff >= 60*60: + return _("within %(num)s hours") % {"num": math.ceil(diff / (60*60))} + elif diff > 0: + return _("within %(num)s minutes") % {"num": math.ceil(diff / 60)} + else: + return _("very soon") + @register.simple_tag def is_single_user_mode(): diff --git a/worker/celery.py b/worker/celery.py index 083edd81..cb9209e1 100644 --- a/worker/celery.py +++ b/worker/celery.py @@ -44,6 +44,14 @@ app.conf.beat_schedule = { 'retry': False } }, + 'check-quotas': { + 'task': 'worker.tasks.check_quotas', + 'schedule': 3600, + 'options': { + 'expires': 1799, + 'retry': False + } + }, } # Mock class for handling async results during testing diff --git a/worker/tasks.py b/worker/tasks.py index b220b3e3..7b8ef84e 100644 --- a/worker/tasks.py +++ b/worker/tasks.py @@ -11,6 +11,7 @@ from celery.utils.log import get_task_logger from django.core.exceptions import ObjectDoesNotExist from django.db.models import Count from django.db.models import Q +from app.models import Profile from app.models import Project from app.models import Task @@ -202,4 +203,17 @@ def export_pointcloud(self, input, **opts): return result except Exception as e: logger.error(str(e)) - return {'error': str(e)} \ No newline at end of file + return {'error': str(e)} + +@app.task +def check_quotas(): + profiles = Profile.objects.filter(quota__gt=-1) + # for p in profiles: + # deadline_key = "%s_quota_exceeded_deadline" % p.user.id + + # if p.has_exceeded_quota(): + # now = time.time() + # deadline = redis_client.getset(deadline_key, now + (settings.QUOTA_EXCEEDED_GRACE_PERIOD * 60 * 60)) + # # if deadline < now: TODO.. + # else: + # redis_client.delete(deadline_key) From 1b92ee1f19ea2f9e69cbddca46f92fd6ee0b8302 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Mon, 4 Sep 2023 13:34:54 -0400 Subject: [PATCH 10/20] Quota deletion working --- app/static/app/css/sb-admin-2.css | 2 +- app/templatetags/settings.py | 2 +- worker/tasks.py | 30 ++++++++++++++++++++++-------- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/app/static/app/css/sb-admin-2.css b/app/static/app/css/sb-admin-2.css index 8103ba6b..2d19dd0c 100644 --- a/app/static/app/css/sb-admin-2.css +++ b/app/static/app/css/sb-admin-2.css @@ -61,7 +61,7 @@ body { } .navbar-top-links .dropdown-menu li div.info-item.quotas{ - min-width: 190px; + min-width: 232px; } .navbar-top-links .dropdown-menu li .progress{ diff --git a/app/templatetags/settings.py b/app/templatetags/settings.py index 8ed581fa..a540ae5e 100644 --- a/app/templatetags/settings.py +++ b/app/templatetags/settings.py @@ -41,7 +41,7 @@ def quota_exceeded_grace_period(context): return _("within %(num)s days") % {"num": math.ceil(diff / (60*60*24))} elif diff >= 60*60: return _("within %(num)s hours") % {"num": math.ceil(diff / (60*60))} - elif diff > 0: + elif diff > 1: return _("within %(num)s minutes") % {"num": math.ceil(diff / 60)} else: return _("very soon") diff --git a/worker/tasks.py b/worker/tasks.py index 7b8ef84e..3cafcdbd 100644 --- a/worker/tasks.py +++ b/worker/tasks.py @@ -208,12 +208,26 @@ def export_pointcloud(self, input, **opts): @app.task def check_quotas(): profiles = Profile.objects.filter(quota__gt=-1) - # for p in profiles: - # deadline_key = "%s_quota_exceeded_deadline" % p.user.id + for p in profiles: + if p.has_exceeded_quota(): + deadline = p.get_quota_deadline() + if deadline is None: + deadline = p.set_quota_deadline(settings.QUOTA_EXCEEDED_GRACE_PERIOD) + now = time.time() + if now > deadline: + # deadline passed, delete tasks until quota is met + logger.info("Quota deadline expired for %s, deleting tasks" % str(p.user.username)) + + while p.has_exceeded_quota(): + try: + last_task = Task.objects.filter(project__owner=p.user).order_by("-created_at").first() + if last_task is None: + break + logger.info("Deleting %s" % last_task) + last_task.delete() + except Exception as e: + logger.warn("Cannot delete %s for %s: %s" % (str(last_task), str(p.user.username), str(e))) + break + else: + p.clear_quota_deadline() - # if p.has_exceeded_quota(): - # now = time.time() - # deadline = redis_client.getset(deadline_key, now + (settings.QUOTA_EXCEEDED_GRACE_PERIOD * 60 * 60)) - # # if deadline < now: TODO.. - # else: - # redis_client.delete(deadline_key) From 4cd5a01023c1c1d4b80e1adeeeaa1d630ae12bbd Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Wed, 6 Sep 2023 11:09:49 -0400 Subject: [PATCH 11/20] Add --external-auth-endpoint --- .env | 1 + app/auth/backends.py | 21 ++++++++++++++++----- app/models/profile.py | 5 ++++- docker-compose.yml | 2 ++ webodm.sh | 8 ++++++++ webodm/settings.py | 4 +--- 6 files changed, 32 insertions(+), 9 deletions(-) diff --git a/.env b/.env index 5ff6f7a0..516bcd21 100644 --- a/.env +++ b/.env @@ -10,3 +10,4 @@ WO_DEBUG=NO WO_DEV=NO WO_BROKER=redis://broker WO_DEFAULT_NODES=1 +WO_EXTERNAL_AUTH_ENDPOINT= diff --git a/app/auth/backends.py b/app/auth/backends.py index 95fcb2c1..c25f2be3 100644 --- a/app/auth/backends.py +++ b/app/auth/backends.py @@ -2,7 +2,7 @@ import requests from django.contrib.auth.backends import ModelBackend from django.contrib.auth.models import User from nodeodm.models import ProcessingNode -from webodm.settings import EXTERNAL_AUTH_ENDPOINT, USE_EXTERNAL_AUTH +from webodm.settings import EXTERNAL_AUTH_ENDPOINT from guardian.shortcuts import assign_perm import logging @@ -10,7 +10,7 @@ logger = logging.getLogger('app.logger') class ExternalBackend(ModelBackend): def authenticate(self, request, username=None, password=None): - if not USE_EXTERNAL_AUTH: + if EXTERNAL_AUTH_ENDPOINT == "": return None try: @@ -20,10 +20,10 @@ class ExternalBackend(ModelBackend): }, headers={'Accept': 'application/json'}) res = r.json() + # logger.info(res) + if 'message' in res or 'error' in res: return None - - logger.info(res) if 'user_id' in res: try: @@ -33,6 +33,17 @@ class ExternalBackend(ModelBackend): if user.username != username: user.username = username user.save() + + # Update quotas + maxQuota = -1 + if 'maxQuota' in res: + maxQuota = res['maxQuota'] + if 'node' in res and 'limits' in res['node'] and 'maxQuota' in res['node']['limits']: + maxQuota = res['node']['limits']['maxQuota'] + + if user.profile.quota != maxQuota: + user.profile.quota = maxQuota + user.save() except User.DoesNotExist: user = User(pk=res['user_id'], username=username) user.save() @@ -64,7 +75,7 @@ class ExternalBackend(ModelBackend): return None def get_user(self, user_id): - if not USE_EXTERNAL_AUTH: + if EXTERNAL_AUTH_ENDPOINT == "": return None try: diff --git a/app/models/profile.py b/app/models/profile.py index 1a1cf971..54e227d1 100644 --- a/app/models/profile.py +++ b/app/models/profile.py @@ -18,7 +18,10 @@ class Profile(models.Model): return self.quota != -1 def used_quota(self): - return Task.objects.filter(project__owner=self.user).aggregate(total=Sum('size'))['total'] + q = Task.objects.filter(project__owner=self.user).aggregate(total=Sum('size'))['total'] + if q is None: + q = 0 + return q def has_exceeded_quota(self): if not self.has_quota(): diff --git a/docker-compose.yml b/docker-compose.yml index 04daa1c4..2b79fa64 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,6 +33,7 @@ services: - WO_BROKER - WO_DEV - WO_DEV_WATCH_PLUGINS + - WO_EXTERNAL_AUTH_ENDPOINT restart: unless-stopped oom_score_adj: 0 broker: @@ -52,5 +53,6 @@ services: environment: - WO_BROKER - WO_DEBUG + - WO_EXTERNAL_AUTH_ENDPOINT restart: unless-stopped oom_score_adj: 250 diff --git a/webodm.sh b/webodm.sh index b979a09d..4a044ba0 100755 --- a/webodm.sh +++ b/webodm.sh @@ -130,6 +130,12 @@ case $key in shift # past argument shift # past value ;; + --external-auth-endpoint) + WO_EXTERNAL_AUTH_ENDPOINT="$2" + export WO_EXTERNAL_AUTH_ENDPOINT + shift # past argument + shift # past value + ;; *) # unknown option POSITIONAL+=("$1") # save it in an array for later shift # past argument @@ -170,6 +176,7 @@ usage(){ echo " --broker Set the URL used to connect to the celery broker (default: $DEFAULT_BROKER)" echo " --detached Run WebODM in detached mode. This means WebODM will run in the background, without blocking the terminal (default: disabled)" echo " --gpu Use GPU NodeODM nodes (Linux only) (default: disabled)" + echo " --external-auth-endpoint External authentication endpoint (default: disabled)" exit } @@ -339,6 +346,7 @@ start(){ echo "SSL insecure port redirect: $WO_SSL_INSECURE_PORT_REDIRECT" echo "Celery Broker: $WO_BROKER" echo "Default Nodes: $WO_DEFAULT_NODES" + echo "External auth endpoint: $WO_EXTERNAL_AUTH_ENDPOINT" echo "================================" echo "Make sure to issue a $0 down if you decide to change the environment." echo "" diff --git a/webodm/settings.py b/webodm/settings.py index 896b27e9..9774338f 100644 --- a/webodm/settings.py +++ b/webodm/settings.py @@ -391,9 +391,7 @@ CACHES = { # before it should be considered offline NODE_OFFLINE_MINUTES = 5 -USE_EXTERNAL_AUTH = True # TODO: change -EXTERNAL_AUTH_ENDPOINT = "http://192.168.2.253:5000/r/auth/login" -# TODO: make these env vars? +EXTERNAL_AUTH_ENDPOINT = os.environ.get('WO_EXTERNAL_AUTH_ENDPOINT', '') # Number of hours before tasks are automatically deleted # from an account that is exceeding a disk quota From bd70b4b7ec28c1f16c161e9c99a65b56b8465318 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Thu, 7 Sep 2023 10:50:44 -0400 Subject: [PATCH 12/20] Increase point budget, auto-login logic PoC --- app/static/app/js/ModelView.jsx | 2 +- app/templates/app/registration/login.html | 37 ++++++++++++++++++++++- app/templatetags/settings.py | 4 +++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/app/static/app/js/ModelView.jsx b/app/static/app/js/ModelView.jsx index b149743e..3a0478ce 100644 --- a/app/static/app/js/ModelView.jsx +++ b/app/static/app/js/ModelView.jsx @@ -298,7 +298,7 @@ class ModelView extends React.Component { window.viewer = new Potree.Viewer(container); viewer.setEDLEnabled(true); viewer.setFOV(60); - viewer.setPointBudget(1*1000*1000); + viewer.setPointBudget(10*1000*1000); viewer.setEDLEnabled(true); viewer.loadSettingsFromURL(); diff --git a/app/templates/app/registration/login.html b/app/templates/app/registration/login.html index 164a46e3..9945dd42 100644 --- a/app/templates/app/registration/login.html +++ b/app/templates/app/registration/login.html @@ -10,11 +10,12 @@ {% endif %} {% is_single_user_mode as autologin %} + {% external_auth_endpoint as ext_auth_ep %} {% if autologin %} {% else %} -
    {% csrf_token %} + {% csrf_token %} {% for field in form %} {% include 'registration/form_field.html' %} {% endfor %} @@ -34,5 +35,39 @@
    + + {% if ext_auth_ep != '' %} +
    + +
    + + {% endif %} + {% endif %} {% endblock %} \ No newline at end of file diff --git a/app/templatetags/settings.py b/app/templatetags/settings.py index a540ae5e..e439e161 100644 --- a/app/templatetags/settings.py +++ b/app/templatetags/settings.py @@ -9,6 +9,10 @@ from django.utils.translation import gettext as _ register = template.Library() logger = logging.getLogger('app.logger') +@register.simple_tag +def external_auth_endpoint(): + return settings.EXTERNAL_AUTH_ENDPOINT + @register.filter def disk_size(megabytes): k = 1000 From 73052fb2ec60d6b9dd535107a4509478d5425bac Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Fri, 8 Sep 2023 12:28:13 -0400 Subject: [PATCH 13/20] External auto auth working --- app/api/externalauth.py | 38 ++++++++ app/api/urls.py | 4 + app/auth/backends.py | 103 +++++++++++----------- app/templates/app/registration/login.html | 43 +++++---- app/templatetags/settings.py | 4 +- 5 files changed, 124 insertions(+), 68 deletions(-) create mode 100644 app/api/externalauth.py diff --git a/app/api/externalauth.py b/app/api/externalauth.py new file mode 100644 index 00000000..dcc96a60 --- /dev/null +++ b/app/api/externalauth.py @@ -0,0 +1,38 @@ +from django.contrib.auth.models import User +from django.contrib.auth import login +from rest_framework.views import APIView +from rest_framework import exceptions, permissions, parsers +from rest_framework.response import Response +from app.auth.backends import get_user_from_external_auth_response +import requests +from webodm import settings + +class ExternalTokenAuth(APIView): + permission_classes = (permissions.AllowAny,) + parser_classes = (parsers.JSONParser, parsers.FormParser,) + + def post(self, request): + # This should never happen + if settings.EXTERNAL_AUTH_ENDPOINT == '': + return Response({'error': 'EXTERNAL_AUTH_ENDPOINT not set'}) + + token = request.COOKIES.get('external_access_token', '') + if token == '': + return Response({'error': 'external_access_token cookie not set'}) + + try: + r = requests.post(settings.EXTERNAL_AUTH_ENDPOINT, headers={ + 'Authorization': "Bearer %s" % token + }) + res = r.json() + if res.get('user_id') is not None: + user = get_user_from_external_auth_response(res) + if user is not None: + login(request, user, backend='django.contrib.auth.backends.ModelBackend') + return Response({'redirect': '/'}) + else: + return Response({'error': 'Invalid credentials'}) + else: + return Response({'error': res.get('message', 'Invalid external server response')}) + except Exception as e: + return Response({'error': str(e)}) diff --git a/app/api/urls.py b/app/api/urls.py index a29f0e0a..3de13590 100644 --- a/app/api/urls.py +++ b/app/api/urls.py @@ -13,6 +13,7 @@ from .tiler import TileJson, Bounds, Metadata, Tiles, Export from .potree import Scene, CameraView from .workers import CheckTask, GetTaskResult from .users import UsersList +from .externalauth import ExternalTokenAuth from webodm import settings router = routers.DefaultRouter() @@ -63,3 +64,6 @@ urlpatterns = [ if settings.ENABLE_USERS_API: urlpatterns.append(url(r'users', UsersList.as_view())) +if settings.EXTERNAL_AUTH_ENDPOINT != '': + urlpatterns.append(url(r'^external-token-auth/', ExternalTokenAuth.as_view())) + diff --git a/app/auth/backends.py b/app/auth/backends.py index c25f2be3..22c7a65f 100644 --- a/app/auth/backends.py +++ b/app/auth/backends.py @@ -8,6 +8,57 @@ import logging logger = logging.getLogger('app.logger') +def get_user_from_external_auth_response(res): + if 'message' in res or 'error' in res: + return None + + if 'user_id' in res and 'username' in res: + try: + user = User.objects.get(pk=res['user_id']) + + # Update user info + if user.username != res['username']: + user.username = res['username'] + user.save() + + # Update quotas + maxQuota = -1 + if 'maxQuota' in res: + maxQuota = res['maxQuota'] + if 'node' in res and 'limits' in res['node'] and 'maxQuota' in res['node']['limits']: + maxQuota = res['node']['limits']['maxQuota'] + + if user.profile.quota != maxQuota: + user.profile.quota = maxQuota + user.save() + except User.DoesNotExist: + user = User(pk=res['user_id'], username=username) + user.save() + + # Setup/update processing node + if ('api_key' in res or 'token' in res) and 'node' in res: + hostname = res['node']['hostname'] + port = res['node']['port'] + token = res['api_key'] if 'api_key' in res else res['token'] + + try: + node = ProcessingNode.objects.get(token=token) + if node.hostname != hostname or node.port != port: + node.hostname = hostname + node.port = port + node.save() + + except ProcessingNode.DoesNotExist: + node = ProcessingNode(hostname=hostname, port=port, token=token) + node.save() + + if not user.has_perm('view_processingnode', node): + assign_perm('view_processingnode', user, node) + + return user + else: + return None + class ExternalBackend(ModelBackend): def authenticate(self, request, username=None, password=None): if EXTERNAL_AUTH_ENDPOINT == "": @@ -20,57 +71,7 @@ class ExternalBackend(ModelBackend): }, headers={'Accept': 'application/json'}) res = r.json() - # logger.info(res) - - if 'message' in res or 'error' in res: - return None - - if 'user_id' in res: - try: - user = User.objects.get(pk=res['user_id']) - - # Update user info - if user.username != username: - user.username = username - user.save() - - # Update quotas - maxQuota = -1 - if 'maxQuota' in res: - maxQuota = res['maxQuota'] - if 'node' in res and 'limits' in res['node'] and 'maxQuota' in res['node']['limits']: - maxQuota = res['node']['limits']['maxQuota'] - - if user.profile.quota != maxQuota: - user.profile.quota = maxQuota - user.save() - except User.DoesNotExist: - user = User(pk=res['user_id'], username=username) - user.save() - - # Setup/update processing node - if ('api_key' in res or 'token' in res) and 'node' in res: - hostname = res['node']['hostname'] - port = res['node']['port'] - token = res['api_key'] if 'api_key' in res else res['token'] - - try: - node = ProcessingNode.objects.get(token=token) - if node.hostname != hostname or node.port != port: - node.hostname = hostname - node.port = port - node.save() - - except ProcessingNode.DoesNotExist: - node = ProcessingNode(hostname=hostname, port=port, token=token) - node.save() - - if not user.has_perm('view_processingnode', node): - assign_perm('view_processingnode', user, node) - - return user - else: - return None + return get_user_from_external_auth_response(res) except: return None diff --git a/app/templates/app/registration/login.html b/app/templates/app/registration/login.html index 9945dd42..a1b9b8d4 100644 --- a/app/templates/app/registration/login.html +++ b/app/templates/app/registration/login.html @@ -10,12 +10,12 @@ {% endif %} {% is_single_user_mode as autologin %} - {% external_auth_endpoint as ext_auth_ep %} + {% has_external_auth as ext_auth %} {% if autologin %} {% else %} -
    - {% if ext_auth_ep != '' %} + {% if ext_auth %}
    diff --git a/app/templatetags/settings.py b/app/templatetags/settings.py index e439e161..d904dd1e 100644 --- a/app/templatetags/settings.py +++ b/app/templatetags/settings.py @@ -10,8 +10,8 @@ register = template.Library() logger = logging.getLogger('app.logger') @register.simple_tag -def external_auth_endpoint(): - return settings.EXTERNAL_AUTH_ENDPOINT +def has_external_auth(): + return settings.EXTERNAL_AUTH_ENDPOINT != "" @register.filter def disk_size(megabytes): From 83419a7dab2bb05591e5fbbb610652e8354bd9af Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Fri, 8 Sep 2023 15:55:42 -0400 Subject: [PATCH 14/20] Add --settings, drop --external-auth-endpoint --- docker-compose.yml | 2 -- webodm.sh | 18 +++++++++++++----- webodm/settings.py | 8 +++++++- webodm/settings_override.py | 2 ++ 4 files changed, 22 insertions(+), 8 deletions(-) create mode 100644 webodm/settings_override.py diff --git a/docker-compose.yml b/docker-compose.yml index 8fb61b51..23b8922f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,7 +33,6 @@ services: - WO_BROKER - WO_DEV - WO_DEV_WATCH_PLUGINS - - WO_EXTERNAL_AUTH_ENDPOINT restart: unless-stopped oom_score_adj: 0 broker: @@ -53,6 +52,5 @@ services: environment: - WO_BROKER - WO_DEBUG - - WO_EXTERNAL_AUTH_ENDPOINT restart: unless-stopped oom_score_adj: 250 diff --git a/webodm.sh b/webodm.sh index 4a044ba0..5146101c 100755 --- a/webodm.sh +++ b/webodm.sh @@ -130,9 +130,9 @@ case $key in shift # past argument shift # past value ;; - --external-auth-endpoint) - WO_EXTERNAL_AUTH_ENDPOINT="$2" - export WO_EXTERNAL_AUTH_ENDPOINT + --settings) + WO_SETTINGS=$(realpath "$2") + export WO_SETTINGS shift # past argument shift # past value ;; @@ -176,7 +176,7 @@ usage(){ echo " --broker Set the URL used to connect to the celery broker (default: $DEFAULT_BROKER)" echo " --detached Run WebODM in detached mode. This means WebODM will run in the background, without blocking the terminal (default: disabled)" echo " --gpu Use GPU NodeODM nodes (Linux only) (default: disabled)" - echo " --external-auth-endpoint External authentication endpoint (default: disabled)" + echo " --settings Path to a settings.py file to enable modifications of system settings (default: None)" exit } @@ -346,7 +346,7 @@ start(){ echo "SSL insecure port redirect: $WO_SSL_INSECURE_PORT_REDIRECT" echo "Celery Broker: $WO_BROKER" echo "Default Nodes: $WO_DEFAULT_NODES" - echo "External auth endpoint: $WO_EXTERNAL_AUTH_ENDPOINT" + echo "Settings: $WO_SETTINGS" echo "================================" echo "Make sure to issue a $0 down if you decide to change the environment." echo "" @@ -409,6 +409,14 @@ start(){ echo "Will enable SSL ($method)" fi + if [ ! -z "$WO_SETTINGS" ]; then + if [ ! -e "$WO_SETTINGS" ]; then + echo -e "\033[91mSettings file does not exist: $WO_SETTINGS\033[39m" + exit 1 + fi + command+=" -f docker-compose.settings.yml" + fi + command="$command up" if [[ $detached = true ]]; then diff --git a/webodm/settings.py b/webodm/settings.py index 9774338f..a09aba70 100644 --- a/webodm/settings.py +++ b/webodm/settings.py @@ -391,7 +391,8 @@ CACHES = { # before it should be considered offline NODE_OFFLINE_MINUTES = 5 -EXTERNAL_AUTH_ENDPOINT = os.environ.get('WO_EXTERNAL_AUTH_ENDPOINT', '') +EXTERNAL_AUTH_ENDPOINT = '' +RESET_PASSWORD_LINK = '' # Number of hours before tasks are automatically deleted # from an account that is exceeding a disk quota @@ -405,3 +406,8 @@ try: from .local_settings import * except ImportError: pass + +try: + from .settings_override import * +except ImportError: + pass \ No newline at end of file diff --git a/webodm/settings_override.py b/webodm/settings_override.py new file mode 100644 index 00000000..79c0d64e --- /dev/null +++ b/webodm/settings_override.py @@ -0,0 +1,2 @@ +# Do not touch. This file can be bind-mount replaced +# by docker-compose for customized settings \ No newline at end of file From e7d57b4cd58099dd567005308bf7ba057020a4e2 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Fri, 8 Sep 2023 15:56:05 -0400 Subject: [PATCH 15/20] Add docker-compose file --- docker-compose.settings.yml | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 docker-compose.settings.yml diff --git a/docker-compose.settings.yml b/docker-compose.settings.yml new file mode 100644 index 00000000..16182fb1 --- /dev/null +++ b/docker-compose.settings.yml @@ -0,0 +1,8 @@ +version: '2.1' +services: + webapp: + volumes: + - ${WO_SETTINGS}:/webodm/webodm/settings_override.py + worker: + volumes: + - ${WO_SETTINGS}:/webodm/webodm/settings_override.py \ No newline at end of file From 54296bd7a4ee441a91b224cf04804df3d3b16330 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Fri, 8 Sep 2023 16:02:45 -0400 Subject: [PATCH 16/20] Read pwd reset link from settings --- app/templates/app/registration/login.html | 8 ++++++-- app/templatetags/settings.py | 4 ++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/templates/app/registration/login.html b/app/templates/app/registration/login.html index a1b9b8d4..c19260f2 100644 --- a/app/templates/app/registration/login.html +++ b/app/templates/app/registration/login.html @@ -11,6 +11,7 @@ {% is_single_user_mode as autologin %} {% has_external_auth as ext_auth %} + {% reset_password_link as reset_pwd_link %} {% if autologin %} @@ -24,14 +25,17 @@
    - -

    Forgot your password?

    + {% if reset_pwd_link != '' %} +

    {% trans "Forgot your password?" %}

    + {% else %} +

    {% trans "Forgot your password?" %}

    + {% endif %}
    diff --git a/app/templatetags/settings.py b/app/templatetags/settings.py index d904dd1e..96efd0e2 100644 --- a/app/templatetags/settings.py +++ b/app/templatetags/settings.py @@ -9,6 +9,10 @@ from django.utils.translation import gettext as _ register = template.Library() logger = logging.getLogger('app.logger') +@register.simple_tag +def reset_password_link(): + return settings.RESET_PASSWORD_LINK + @register.simple_tag def has_external_auth(): return settings.EXTERNAL_AUTH_ENDPOINT != "" From 039df51cc6985e4e77a397d572fdc6b8e2c76dd3 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Fri, 8 Sep 2023 17:38:05 -0400 Subject: [PATCH 17/20] Add some unit tests --- .env | 2 +- app/api/admin.py | 1 + app/api/externalauth.py | 29 ++++++++++++++ app/tests/test_api_admin.py | 55 ++++++++++++++++++++++++++ app/tests/test_external_auth.py | 68 +++++++++++++++++++++++++++++++++ webodm/settings.py | 1 + 6 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 app/tests/test_external_auth.py diff --git a/.env b/.env index 516bcd21..b417a490 100644 --- a/.env +++ b/.env @@ -10,4 +10,4 @@ WO_DEBUG=NO WO_DEV=NO WO_BROKER=redis://broker WO_DEFAULT_NODES=1 -WO_EXTERNAL_AUTH_ENDPOINT= +WO_SETTINGS= diff --git a/app/api/admin.py b/app/api/admin.py index 2de55e4d..15e136a2 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -58,6 +58,7 @@ class ProfileSerializer(serializers.ModelSerializer): read_only_fields = ('user', ) class AdminProfileViewSet(viewsets.ModelViewSet): + pagination_class = None serializer_class = ProfileSerializer permission_classes = [IsAdminUser] lookup_field = 'user' diff --git a/app/api/externalauth.py b/app/api/externalauth.py index dcc96a60..8a77c71a 100644 --- a/app/api/externalauth.py +++ b/app/api/externalauth.py @@ -36,3 +36,32 @@ class ExternalTokenAuth(APIView): return Response({'error': res.get('message', 'Invalid external server response')}) except Exception as e: return Response({'error': str(e)}) + +# TODO: move to simple http server +# class TestExternalAuth(APIView): +# permission_classes = (permissions.AllowAny,) +# parser_classes = (parsers.JSONParser, parsers.FormParser,) + +# def post(self, request): +# print("YO!!!") +# if settings.EXTERNAL_AUTH_ENDPOINT == '': +# return Response({'message': 'Disabled'}) + +# username = request.data.get("username") +# password = request.data.get("password") + +# print("HERE", username) + +# if username == "extuser1" and password == "test1234": +# return Response({ +# 'user_id': 100, +# 'username': 'extuser1', +# 'maxQuota': 500, +# 'token': 'test', +# 'node': { +# 'hostname': 'localhost', +# 'port': 4444 +# } +# }) +# else: +# return Response({'message': "Invalid credentials"}) \ No newline at end of file diff --git a/app/tests/test_api_admin.py b/app/tests/test_api_admin.py index 7ba0fa28..7a0d1f7f 100644 --- a/app/tests/test_api_admin.py +++ b/app/tests/test_api_admin.py @@ -1,3 +1,4 @@ +import time from django.contrib.auth.models import User, Group from rest_framework import status from rest_framework.test import APIClient @@ -202,3 +203,57 @@ class TestApi(BootTestCase): res = client.delete('/api/admin/groups/{}/'.format(group.id)) self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN) + def test_profile(self): + client = APIClient() + client.login(username="testuser", password="test1234") + + user = User.objects.get(username="testuser") + + # Cannot list profiles (not admin) + res = client.get('/api/admin/profiles/') + self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN) + + res = client.get('/api/admin/profiles/%s/' % user.id) + self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN) + + # Cannot update quota deadlines + res = client.post('/api/admin/profiles/%s/update_quota_deadline/' % user.id, data={'hours': 1}) + self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN) + + # Admin can + client.login(username="testsuperuser", password="test1234") + + res = client.get('/api/admin/profiles/') + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertTrue(len(res.data) > 0) + + res = client.get('/api/admin/profiles/%s/' % user.id) + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertTrue('quota' in res.data) + self.assertTrue('user' in res.data) + + # User is the primary key (not profile id) + self.assertEqual(res.data['user'], user.id) + + # There should be no quota by default + self.assertEqual(res.data['quota'], -1) + + # Try updating + user.profile.quota = 10 + user.save() + res = client.get('/api/admin/profiles/%s/' % user.id) + self.assertEqual(res.data['quota'], 10) + + # Update quota deadlines + + # Miss parameters + res = client.post('/api/admin/profiles/%s/update_quota_deadline/' % user.id) + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + res = client.post('/api/admin/profiles/%s/update_quota_deadline/' % user.id, data={'hours': 48}) + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertTrue('deadline' in res.data and res.data['deadline'] > time.time() + 47*60*60) + + res = client.post('/api/admin/profiles/%s/update_quota_deadline/' % user.id, data={'hours': 0}) + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertTrue(abs(user.profile.get_quota_deadline() - time.time()) < 10) diff --git a/app/tests/test_external_auth.py b/app/tests/test_external_auth.py new file mode 100644 index 00000000..90dcda3f --- /dev/null +++ b/app/tests/test_external_auth.py @@ -0,0 +1,68 @@ +from django.contrib.auth.models import User, Group +from rest_framework import status +from rest_framework.test import APIClient + +from .classes import BootTestCase +from webodm import settings + +class TestAuth(BootTestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_ext_auth(self): + client = APIClient() + + # Disable + settings.EXTERNAL_AUTH_ENDPOINT = '' + + # Try to log-in + user = client.login(username='extuser1', password='test1234') + self.assertFalse(user) + + # Enable + settings.EXTERNAL_AUTH_ENDPOINT = 'http://0.0.0.0:5555' + + # TODO: start simplehttp auth server + + user = client.login(username='extuser1', password='test1234') + # self.assertEqual(user.username, 'extuser1') + # self.assertEqual(user.id, 100) + + + # client.login(username="testuser", password="test1234") + + # user = User.objects.get(username="testuser") + + # # Cannot list profiles (not admin) + # res = client.get('/api/admin/profiles/') + # self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN) + + # res = client.get('/api/admin/profiles/%s/' % user.id) + # self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN) + + # # Cannot update quota deadlines + # res = client.post('/api/admin/profiles/%s/update_quota_deadline/' % user.id, data={'hours': 1}) + # self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN) + + # # Admin can + # client.login(username="testsuperuser", password="test1234") + + # res = client.get('/api/admin/profiles/') + # self.assertEqual(res.status_code, status.HTTP_200_OK) + # self.assertTrue(len(res.data) > 0) + + # res = client.get('/api/admin/profiles/%s/' % user.id) + # self.assertEqual(res.status_code, status.HTTP_200_OK) + # self.assertTrue('quota' in res.data) + # self.assertTrue('user' in res.data) + + # # User is the primary key (not profile id) + # self.assertEqual(res.data['user'], user.id) + + # # There should be no quota by default + # self.assertEqual(res.data['quota'], -1) + + \ No newline at end of file diff --git a/webodm/settings.py b/webodm/settings.py index a09aba70..74f79cf0 100644 --- a/webodm/settings.py +++ b/webodm/settings.py @@ -401,6 +401,7 @@ QUOTA_EXCEEDED_GRACE_PERIOD = 8 if TESTING or FLUSHING: CELERY_TASK_ALWAYS_EAGER = True + EXTERNAL_AUTH_ENDPOINT = '/_test-external-auth' try: from .local_settings import * From a709c8fdf6e7519618605eed7567e4c8bc04b72b Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Mon, 11 Sep 2023 11:53:10 -0400 Subject: [PATCH 18/20] Moar tests --- app/api/externalauth.py | 28 ------- app/auth/backends.py | 71 +++++++++--------- app/tests/scripts/simple_auth_server.py | 97 +++++++++++++++++++++++++ app/tests/test_external_auth.py | 68 ++++++----------- app/tests/test_quota.py | 58 +++++++++++++++ app/tests/utils.py | 10 +++ nodeodm/models.py | 4 + webodm/settings.py | 2 +- 8 files changed, 231 insertions(+), 107 deletions(-) create mode 100644 app/tests/scripts/simple_auth_server.py create mode 100644 app/tests/test_quota.py diff --git a/app/api/externalauth.py b/app/api/externalauth.py index 8a77c71a..5f9c564c 100644 --- a/app/api/externalauth.py +++ b/app/api/externalauth.py @@ -37,31 +37,3 @@ class ExternalTokenAuth(APIView): except Exception as e: return Response({'error': str(e)}) -# TODO: move to simple http server -# class TestExternalAuth(APIView): -# permission_classes = (permissions.AllowAny,) -# parser_classes = (parsers.JSONParser, parsers.FormParser,) - -# def post(self, request): -# print("YO!!!") -# if settings.EXTERNAL_AUTH_ENDPOINT == '': -# return Response({'message': 'Disabled'}) - -# username = request.data.get("username") -# password = request.data.get("password") - -# print("HERE", username) - -# if username == "extuser1" and password == "test1234": -# return Response({ -# 'user_id': 100, -# 'username': 'extuser1', -# 'maxQuota': 500, -# 'token': 'test', -# 'node': { -# 'hostname': 'localhost', -# 'port': 4444 -# } -# }) -# else: -# return Response({'message': "Invalid credentials"}) \ No newline at end of file diff --git a/app/auth/backends.py b/app/auth/backends.py index 22c7a65f..c3c13ea1 100644 --- a/app/auth/backends.py +++ b/app/auth/backends.py @@ -2,7 +2,7 @@ import requests from django.contrib.auth.backends import ModelBackend from django.contrib.auth.models import User from nodeodm.models import ProcessingNode -from webodm.settings import EXTERNAL_AUTH_ENDPOINT +from webodm import settings from guardian.shortcuts import assign_perm import logging @@ -15,45 +15,48 @@ def get_user_from_external_auth_response(res): if 'user_id' in res and 'username' in res: try: user = User.objects.get(pk=res['user_id']) - - # Update user info - if user.username != res['username']: - user.username = res['username'] - user.save() - - # Update quotas - maxQuota = -1 - if 'maxQuota' in res: - maxQuota = res['maxQuota'] - if 'node' in res and 'limits' in res['node'] and 'maxQuota' in res['node']['limits']: - maxQuota = res['node']['limits']['maxQuota'] - - if user.profile.quota != maxQuota: - user.profile.quota = maxQuota - user.save() except User.DoesNotExist: - user = User(pk=res['user_id'], username=username) + user = User(pk=res['user_id'], username=res['username']) + user.save() + + # Update user info + if user.username != res['username']: + user.username = res['username'] + user.save() + + maxQuota = -1 + if 'maxQuota' in res: + maxQuota = res['maxQuota'] + if 'node' in res and 'limits' in res['node'] and 'maxQuota' in res['node']['limits']: + maxQuota = res['node']['limits']['maxQuota'] + + # Update quotas + if user.profile.quota != maxQuota: + user.profile.quota = maxQuota user.save() # Setup/update processing node - if ('api_key' in res or 'token' in res) and 'node' in res: + if 'node' in res and 'hostname' in res['node'] and 'port' in res['node']: hostname = res['node']['hostname'] port = res['node']['port'] - token = res['api_key'] if 'api_key' in res else res['token'] + token = res['node'].get('token', '') - try: - node = ProcessingNode.objects.get(token=token) - if node.hostname != hostname or node.port != port: - node.hostname = hostname - node.port = port + # Only add/update if a token is provided, since we use + # tokens as unique identifiers for hostname/port updates + if token != "": + try: + node = ProcessingNode.objects.get(token=token) + if node.hostname != hostname or node.port != port: + node.hostname = hostname + node.port = port + node.save() + + except ProcessingNode.DoesNotExist: + node = ProcessingNode(hostname=hostname, port=port, token=token) node.save() - except ProcessingNode.DoesNotExist: - node = ProcessingNode(hostname=hostname, port=port, token=token) - node.save() - - if not user.has_perm('view_processingnode', node): - assign_perm('view_processingnode', user, node) + if not user.has_perm('view_processingnode', node): + assign_perm('view_processingnode', user, node) return user else: @@ -61,11 +64,11 @@ def get_user_from_external_auth_response(res): class ExternalBackend(ModelBackend): def authenticate(self, request, username=None, password=None): - if EXTERNAL_AUTH_ENDPOINT == "": + if settings.EXTERNAL_AUTH_ENDPOINT == "": return None try: - r = requests.post(EXTERNAL_AUTH_ENDPOINT, { + r = requests.post(settings.EXTERNAL_AUTH_ENDPOINT, { 'username': username, 'password': password }, headers={'Accept': 'application/json'}) @@ -76,7 +79,7 @@ class ExternalBackend(ModelBackend): return None def get_user(self, user_id): - if EXTERNAL_AUTH_ENDPOINT == "": + if settings.EXTERNAL_AUTH_ENDPOINT == "": return None try: diff --git a/app/tests/scripts/simple_auth_server.py b/app/tests/scripts/simple_auth_server.py new file mode 100644 index 00000000..690b8636 --- /dev/null +++ b/app/tests/scripts/simple_auth_server.py @@ -0,0 +1,97 @@ +import http.server +from http.server import SimpleHTTPRequestHandler +import socketserver +import sys +import threading +from time import sleep +import json + +class MyHandler(SimpleHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header('Content-type','text/html') + self.end_headers() + self.wfile.write(bytes("Simple auth server is running", encoding="utf-8")) + + + def send_error(self, code, error): + self.send_json(code, {"error": error}) + + def send_json(self, code, data): + response = bytes(json.dumps(data), encoding="utf-8") + + self.send_response(200) + self.send_header('Content-type','application/json') + self.send_header('Content-length', len(response)) + self.end_headers() + self.wfile.write(response) + + def do_POST(self): + if self.path == '/auth': + if not 'Content-Length' in self.headers: + self.send_error(403, "Missing form data") + return + + content_length = int(self.headers['Content-Length']) + post_data_str = self.rfile.read(content_length).decode("utf-8") + post_data = {} + for item in post_data_str.split('&'): + k,v = item.split('=') + post_data[k] = v + + username = post_data.get("username") + password = post_data.get("password") + + print("Login request for " + username) + + if username == "extuser1" and password == "test1234": + print("Granted") + self.send_json(200, { + 'user_id': 100, + 'username': 'extuser1', + 'maxQuota': 500, + 'node': { + 'hostname': 'localhost', + 'port': 4444, + 'token': 'test' + } + }) + else: + print("Unauthorized") + return self.send_error(401, "unauthorized") + else: + self.send_error(404, "not found") + +class WebServer(threading.Thread): + def __init__(self): + super().__init__() + self.host = "0.0.0.0" + self.port = int(sys.argv[1]) if len(sys.argv) >= 2 else 8080 + self.ws = socketserver.TCPServer((self.host, self.port), MyHandler) + + def run(self): + print("WebServer started at Port:", self.port) + self.ws.serve_forever() + + def shutdown(self): + # set the two flags needed to shutdown the HTTP server manually + # self.ws._BaseServer__is_shut_down.set() + # self.ws.__shutdown_request = True + + print('Shutting down server.') + # call it anyway, for good measure... + self.ws.shutdown() + print('Closing server.') + self.ws.server_close() + self.join() + +if __name__=='__main__': + webServer = WebServer() + webServer.start() + while True: + try: + sleep(0.5) + except KeyboardInterrupt: + print('Keyboard Interrupt sent.') + webServer.shutdown() + exit(0) \ No newline at end of file diff --git a/app/tests/test_external_auth.py b/app/tests/test_external_auth.py index 90dcda3f..2928bebd 100644 --- a/app/tests/test_external_auth.py +++ b/app/tests/test_external_auth.py @@ -1,8 +1,10 @@ from django.contrib.auth.models import User, Group +from nodeodm.models import ProcessingNode from rest_framework import status from rest_framework.test import APIClient from .classes import BootTestCase +from .utils import start_simple_auth_server from webodm import settings class TestAuth(BootTestCase): @@ -19,50 +21,28 @@ class TestAuth(BootTestCase): settings.EXTERNAL_AUTH_ENDPOINT = '' # Try to log-in - user = client.login(username='extuser1', password='test1234') - self.assertFalse(user) + ok = client.login(username='extuser1', password='test1234') + self.assertFalse(ok) # Enable - settings.EXTERNAL_AUTH_ENDPOINT = 'http://0.0.0.0:5555' + settings.EXTERNAL_AUTH_ENDPOINT = 'http://0.0.0.0:5555/auth' - # TODO: start simplehttp auth server - - user = client.login(username='extuser1', password='test1234') - # self.assertEqual(user.username, 'extuser1') - # self.assertEqual(user.id, 100) - - - # client.login(username="testuser", password="test1234") - - # user = User.objects.get(username="testuser") - - # # Cannot list profiles (not admin) - # res = client.get('/api/admin/profiles/') - # self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN) - - # res = client.get('/api/admin/profiles/%s/' % user.id) - # self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN) - - # # Cannot update quota deadlines - # res = client.post('/api/admin/profiles/%s/update_quota_deadline/' % user.id, data={'hours': 1}) - # self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN) - - # # Admin can - # client.login(username="testsuperuser", password="test1234") - - # res = client.get('/api/admin/profiles/') - # self.assertEqual(res.status_code, status.HTTP_200_OK) - # self.assertTrue(len(res.data) > 0) - - # res = client.get('/api/admin/profiles/%s/' % user.id) - # self.assertEqual(res.status_code, status.HTTP_200_OK) - # self.assertTrue('quota' in res.data) - # self.assertTrue('user' in res.data) - - # # User is the primary key (not profile id) - # self.assertEqual(res.data['user'], user.id) - - # # There should be no quota by default - # self.assertEqual(res.data['quota'], -1) - - \ No newline at end of file + with start_simple_auth_server(["5555"]): + ok = client.login(username='extuser1', password='invalid') + self.assertFalse(ok) + self.assertFalse(User.objects.filter(username="extuser1").exists()) + ok = client.login(username='extuser1', password='test1234') + self.assertTrue(ok) + user = User.objects.get(username="extuser1") + self.assertEqual(user.id, 100) + self.assertEqual(user.profile.quota, 500) + pnode = ProcessingNode.objects.get(token='test') + self.assertEqual(pnode.hostname, 'localhost') + self.assertEqual(pnode.port, 4444) + self.assertTrue(user.has_perm('view_processingnode', pnode)) + self.assertFalse(user.has_perm('delete_processingnode', pnode)) + self.assertFalse(user.has_perm('change_processingnode', pnode)) + + # Re-test login + ok = client.login(username='extuser1', password='test1234') + self.assertTrue(ok) diff --git a/app/tests/test_quota.py b/app/tests/test_quota.py new file mode 100644 index 00000000..0e43b33a --- /dev/null +++ b/app/tests/test_quota.py @@ -0,0 +1,58 @@ +from django.contrib.auth.models import User, Group +from rest_framework import status +from rest_framework.test import APIClient +from app.models import Task, Project +from .classes import BootTestCase + +class TestQuota(BootTestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_quota(self): + c = APIClient() + c.login(username="testuser", password="test1234") + + user = User.objects.get(username="testuser") + self.assertEqual(user.profile.quota, -1) + + # There should be no quota panel + res = c.get('/dashboard/', follow=True) + body = res.content.decode("utf-8") + + # There should be no quota panel + self.assertFalse('
    ' in body) + + user.profile.quota = 2000 + user.save() + + res = c.get('/dashboard/', follow=True) + body = res.content.decode("utf-8") + + # There should be a quota panel + self.assertTrue('
    ' in body) + + # There should be no warning + self.assertFalse("disk quota is being exceeded" in body) + + self.assertEqual(user.profile.used_quota(), 0) + self.assertEqual(user.profile.used_quota_cached(), 0) + + # Create a task with size + p = Project.objects.create(owner=user, name='Test') + p.save() + t = Task.objects.create(project=p, name='Test', size=2005) + t.save() + + # Simulate call to task.update_size which calls clear_used_quota_cache + user.profile.clear_used_quota_cache() + + self.assertTrue(user.profile.has_exceeded_quota()) + self.assertTrue(user.profile.has_exceeded_quota_cached()) + + res = c.get('/dashboard/', follow=True) + body = res.content.decode("utf-8") + + # self.assertTrue("disk quota is being exceeded" in body) diff --git a/app/tests/utils.py b/app/tests/utils.py index f5ca1456..763546c7 100644 --- a/app/tests/utils.py +++ b/app/tests/utils.py @@ -25,6 +25,16 @@ def start_processing_node(args = []): node_odm.terminate() time.sleep(1) # Wait for the server to stop +@contextmanager +def start_simple_auth_server(args = []): + current_dir = os.path.dirname(os.path.realpath(__file__)) + s = subprocess.Popen(['python', 'simple_auth_server.py'] + args, shell=False, + cwd=os.path.join(current_dir, "scripts")) + time.sleep(2) # Wait for the server to launch + yield s + s.terminate() + time.sleep(1) # Wait for the server to stop + # We need to clear previous media_root content # This points to the test directory, but just in case # we double check that the directory is indeed a test directory diff --git a/nodeodm/models.py b/nodeodm/models.py index 39f47af8..a2f8e81e 100644 --- a/nodeodm/models.py +++ b/nodeodm/models.py @@ -15,7 +15,9 @@ from pyodm import Node from pyodm import exceptions from django.db.models import signals from datetime import timedelta +import logging +logger = logging.getLogger('app.logger') class ProcessingNode(models.Model): hostname = models.CharField(verbose_name=_("Hostname"), max_length=255, help_text=_("Hostname or IP address where the node is located (can be an internal hostname as well). If you are using Docker, this is never 127.0.0.1 or localhost. Find the IP address of your host machine by running ifconfig on Linux or by checking your network settings.")) @@ -197,6 +199,8 @@ def auto_update_node_info(sender, instance, created, **kwargs): instance.update_node_info() except exceptions.OdmError: pass + except Exception as e: + logger.warning("auto_update_node_info: " + str(e)) class ProcessingNodeUserObjectPermission(UserObjectPermissionBase): content_object = models.ForeignKey(ProcessingNode, on_delete=models.CASCADE) diff --git a/webodm/settings.py b/webodm/settings.py index 74f79cf0..04389728 100644 --- a/webodm/settings.py +++ b/webodm/settings.py @@ -401,7 +401,7 @@ QUOTA_EXCEEDED_GRACE_PERIOD = 8 if TESTING or FLUSHING: CELERY_TASK_ALWAYS_EAGER = True - EXTERNAL_AUTH_ENDPOINT = '/_test-external-auth' + EXTERNAL_AUTH_ENDPOINT = 'http://0.0.0.0:5555/auth' try: from .local_settings import * From c7ff74a5266a84dfdd61d22309ef0ad5c9eb4abb Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Mon, 11 Sep 2023 13:02:46 -0400 Subject: [PATCH 19/20] Moar unit tests --- app/api/tasks.py | 1 + app/templates/app/dashboard.html | 13 ++++------ app/templates/app/logged_in_base.html | 2 +- app/templates/app/quota.html | 11 +++++++++ app/templatetags/settings.py | 8 +++---- app/tests/test_api_admin.py | 2 ++ app/tests/test_login.py | 34 +++++++++++++++++++++++++++ app/tests/test_quota.py | 34 +++++++++++++++++++++++++-- 8 files changed, 89 insertions(+), 16 deletions(-) create mode 100644 app/templates/app/quota.html create mode 100644 app/tests/test_login.py diff --git a/app/api/tasks.py b/app/api/tasks.py index 4e4da2da..bb2d4a7c 100644 --- a/app/api/tasks.py +++ b/app/api/tasks.py @@ -184,6 +184,7 @@ class TaskViewSet(viewsets.ViewSet): if task.images_count < 1: raise exceptions.ValidationError(detail=_("You need to upload at least 1 file before commit")) + task.update_size() task.save() worker_tasks.process_task.delay(task.id) diff --git a/app/templates/app/dashboard.html b/app/templates/app/dashboard.html index 668af63e..344981ad 100644 --- a/app/templates/app/dashboard.html +++ b/app/templates/app/dashboard.html @@ -13,6 +13,8 @@ {% if no_processingnodes %} + {% include "quota.html" %} +

    {% trans 'Welcome!' %} ☺

    {% trans 'Add a Processing Node' as add_processing_node %} {% with nodeodm_link='NodeODM' api_link='API' %} @@ -39,15 +41,8 @@

    {% endif %} - - {% if user.profile.has_exceeded_quota_cached %} - {% with total=user.profile.quota|disk_size used=user.profile.used_quota_cached|disk_size %} - {% quota_exceeded_grace_period as when %} -
    - {% blocktrans %}The disk quota is being exceeded ({{ used }} of {{ total }} used). The most recent tasks will be automatically deleted {{ when }}, until usage falls below {{ total }}.{% endblocktrans %} -
    - {% endwith %} - {% endif %} + + {% include "quota.html" %}
    diff --git a/app/templates/app/logged_in_base.html b/app/templates/app/logged_in_base.html index 8ba8d36f..914cb4b0 100644 --- a/app/templates/app/logged_in_base.html +++ b/app/templates/app/logged_in_base.html @@ -19,7 +19,7 @@
  • {% if user.profile.has_quota %}
  • - + {% with tot_quota=user.profile.quota used_quota=user.profile.used_quota_cached %} {% percentage used_quota tot_quota as perc_quota %} {% percentage used_quota tot_quota 100 as bar_width %} diff --git a/app/templates/app/quota.html b/app/templates/app/quota.html new file mode 100644 index 00000000..c52df023 --- /dev/null +++ b/app/templates/app/quota.html @@ -0,0 +1,11 @@ +{% load i18n %} +{% load settings %} + +{% if user.profile.has_exceeded_quota_cached %} + {% with total=user.profile.quota|disk_size used=user.profile.used_quota_cached|disk_size %} + {% quota_exceeded_grace_period as when %} +
    + {% blocktrans %}The disk quota is being exceeded ({{ used }} of {{ total }} used). The most recent tasks will be automatically deleted {{ when }}, until usage falls below {{ total }}.{% endblocktrans %} +
    + {% endwith %} +{% endif %} \ No newline at end of file diff --git a/app/templatetags/settings.py b/app/templatetags/settings.py index 96efd0e2..b0962cbd 100644 --- a/app/templatetags/settings.py +++ b/app/templatetags/settings.py @@ -46,11 +46,11 @@ def quota_exceeded_grace_period(context): deadline = now + settings.QUOTA_EXCEEDED_GRACE_PERIOD * 60 * 60 diff = max(0, deadline - now) if diff >= 60*60*24*2: - return _("within %(num)s days") % {"num": math.ceil(diff / (60*60*24))} - elif diff >= 60*60: - return _("within %(num)s hours") % {"num": math.ceil(diff / (60*60))} + return _("in %(num)s days") % {"num": math.floor(diff / (60*60*24))} + elif diff >= 60*60*2: + return _("in %(num)s hours") % {"num": math.floor(diff / (60*60))} elif diff > 1: - return _("within %(num)s minutes") % {"num": math.ceil(diff / 60)} + return _("in %(num)s minutes") % {"num": math.floor(diff / 60)} else: return _("very soon") diff --git a/app/tests/test_api_admin.py b/app/tests/test_api_admin.py index 7a0d1f7f..a2c46b38 100644 --- a/app/tests/test_api_admin.py +++ b/app/tests/test_api_admin.py @@ -246,6 +246,8 @@ class TestApi(BootTestCase): # Update quota deadlines + self.assertTrue(user.profile.get_quota_deadline() is None) + # Miss parameters res = client.post('/api/admin/profiles/%s/update_quota_deadline/' % user.id) self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/app/tests/test_login.py b/app/tests/test_login.py new file mode 100644 index 00000000..57f25a15 --- /dev/null +++ b/app/tests/test_login.py @@ -0,0 +1,34 @@ +import os +from django.test import Client +from webodm import settings +from .classes import BootTestCase + +class TestLogin(BootTestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + def test_reset_password_render(self): + c = Client() + c.login(username="testuser", password="test1234") + + settings.RESET_PASSWORD_LINK = '' + + res = c.get('/login/', follow=True) + body = res.content.decode("utf-8") + + # The reset password link should show instructions + self.assertTrue("You can reset the administrator password" in body) + + settings.RESET_PASSWORD_LINK = 'http://0.0.0.0/reset_test' + + res = c.get('/login/', follow=True) + body = res.content.decode("utf-8") + + # The reset password link should show instructions + self.assertTrue(' Date: Mon, 11 Sep 2023 13:48:29 -0400 Subject: [PATCH 20/20] Update locales --- .../app/js/translations/odm_autogenerated.js | 170 +++++++++--------- app/tests/test_login.py | 2 +- .../plugin_manifest_autogenerated.py | 9 +- locale | 2 +- 4 files changed, 92 insertions(+), 91 deletions(-) diff --git a/app/static/app/js/translations/odm_autogenerated.js b/app/static/app/js/translations/odm_autogenerated.js index bc6021da..d4f81208 100644 --- a/app/static/app/js/translations/odm_autogenerated.js +++ b/app/static/app/js/translations/odm_autogenerated.js @@ -1,94 +1,94 @@ // Auto-generated with extract_odm_strings.py, do not edit! -_("Set a value in meters for the GPS Dilution of Precision (DOP) information for all images. If your images are tagged with high precision GPS information (RTK), this value will be automatically set accordingly. You can use this option to manually set it in case the reconstruction fails. Lowering this option can sometimes help control bowling-effects over large areas. Default: %(default)s"); -_("GeoJSON polygon limiting the area of the reconstruction. Can be specified either as path to a GeoJSON file or as a JSON string representing the contents of a GeoJSON file. Default: %(default)s"); -_("Maximum number of frames to extract from video files for processing. Set to 0 for no limit. Default: %(default)s"); -_("Export the georeferenced point cloud in LAS format. Default: %(default)s"); -_("Skip normalization of colors across all images. Useful when processing radiometric data. Default: %(default)s"); -_("Path to the file containing the ground control points used for georeferencing. The file needs to use the following format: EPSG: or <+proj definition>geo_x geo_y geo_z im_x im_y image_name [gcp_name] [extra1] [extra2]Default: %(default)s"); -_("Use this tag if you have a GCP File but want to use the EXIF information for georeferencing instead. Default: %(default)s"); -_("Path to the image geolocation file containing the camera center coordinates used for georeferencing. If you don't have values for yaw/pitch/roll you can set them to 0. The file needs to use the following format: EPSG: or <+proj definition>image_name geo_x geo_y geo_z [yaw (degrees)] [pitch (degrees)] [roll (degrees)] [horz accuracy (meters)] [vert accuracy (meters)]Default: %(default)s"); -_("Automatically compute image masks using AI to remove the background. Experimental. Default: %(default)s"); -_("Build orthophoto overviews for faster display in programs such as QGIS. Default: %(default)s"); -_("Classify the point cloud outputs. You can control the behavior of this option by tweaking the --dem-* parameters. Default: %(default)s"); -_("Use the camera parameters computed from another dataset instead of calculating them. Can be specified either as path to a cameras.json file or as a JSON string representing the contents of a cameras.json file. Default: %(default)s"); -_("Run local bundle adjustment for every image added to the reconstruction and a global adjustment every 100 images. Speeds up reconstruction for very large datasets. Default: %(default)s"); -_("Save the georeferenced point cloud in Cloud Optimized Point Cloud (COPC) format. Default: %(default)s"); -_("The maximum output resolution of extracted video frames in pixels. Default: %(default)s"); -_("Use this tag to build a DTM (Digital Terrain Model, ground only) using a simple morphological filter. Check the --dem* and --smrf* parameters for finer tuning. Default: %(default)s"); -_("Delete heavy intermediate files to optimize disk space usage. This affects the ability to restart the pipeline from an intermediate stage, but allows datasets to be processed on machines that don't have sufficient disk space available. Default: %(default)s"); -_("Automatically crop image outputs by creating a smooth buffer around the dataset boundaries, shrunk by N meters. Use 0 to disable cropping. Default: %(default)s"); -_("Geometric estimates improve the accuracy of the point cloud by computing geometrically consistent depthmaps but may not be usable in larger datasets. This flag disables geometric estimates. Default: %(default)s"); -_("Automatically compute image masks using AI to remove the sky. Experimental. Default: %(default)s"); -_("Set the compression to use for orthophotos. Can be one of: %(choices)s. Default: %(default)s"); -_("Skip generation of a full 3D model. This can save time if you only need 2D results such as orthophotos and DEMs. Default: %(default)s"); -_("Skips dense reconstruction and 3D model generation. It generates an orthophoto directly from the sparse reconstruction. If you just need an orthophoto and do not need a full 3D model, turn on this option. Default: %(default)s"); -_("show this help message and exit"); -_("Turn off camera parameter optimization during bundle adjustment. This can be sometimes useful for improving results that exhibit doming/bowling or when images are taken with a rolling shutter camera. Default: %(default)s"); -_("Keep faces in the mesh that are not seen in any camera. Default: %(default)s"); -_("Perform ground rectification on the point cloud. This means that wrongly classified ground points will be re-classified and gaps will be filled. Useful for generating DTMs. Default: %(default)s"); -_("Computes an euclidean raster map for each DEM. The map reports the distance from each cell to the nearest NODATA value (before any hole filling takes place). This can be useful to isolate the areas that have been filled. Default: %(default)s"); -_("When processing multispectral datasets, you can specify the name of the primary band that will be used for reconstruction. It's recommended to choose a band which has sharp details and is in focus. Default: %(default)s"); -_("Do not attempt to merge partial reconstructions. This can happen when images do not have sufficient overlap or are isolated. Default: %(default)s"); -_("Set point cloud quality. Higher quality generates better, denser point clouds, but requires more memory and takes longer. Each step up in quality increases processing time roughly by a factor of 4x.Can be one of: %(choices)s. Default: %(default)s"); -_("Number of steps used to fill areas with gaps. Set to 0 to disable gap filling. Starting with a radius equal to the output resolution, N different DEMs are generated with progressively bigger radius using the inverse distance weighted (IDW) algorithm and merged together. Remaining gaps are then merged using nearest neighbor interpolation. Default: %(default)s"); -_("Export the georeferenced point cloud in CSV format. Default: %(default)s"); -_("URL to a ClusterODM instance for distributing a split-merge workflow on multiple nodes in parallel. Default: %(default)s"); -_("Path to a GeoTIFF DEM or a LAS/LAZ point cloud that the reconstruction outputs should be automatically aligned to. Experimental. Default: %(default)s"); -_("Create Cloud-Optimized GeoTIFFs instead of normal GeoTIFFs. Default: %(default)s"); -_("Simple Morphological Filter slope parameter (rise over run). Default: %(default)s"); -_("Export the georeferenced point cloud in Entwine Point Tile (EPT) format. Default: %(default)s"); -_("Turn on rolling shutter correction. If the camera has a rolling shutter and the images were taken in motion, you can turn on this option to improve the accuracy of the results. See also --rolling-shutter-readout. Default: %(default)s"); -_("Choose the structure from motion algorithm. For aerial datasets, if camera GPS positions and angles are available, triangulation can generate better results. For planar scenes captured at fixed altitude with nadir-only images, planar can be much faster. Can be one of: %(choices)s. Default: %(default)s"); -_("Choose what to merge in the merge step in a split dataset. By default all available outputs are merged. Options: %(choices)s. Default: %(default)s"); -_("Perform image matching with the nearest images based on GPS exif data. Set to 0 to match by triangulation. Default: %(default)s"); -_("Minimum number of features to extract per image. More features can be useful for finding more matches between images, potentially allowing the reconstruction of areas with little overlap or insufficient features. More features also slow down processing. Default: %(default)s"); -_("Simple Morphological Filter elevation threshold parameter (meters). Default: %(default)s"); -_("Override the rolling shutter readout time for your camera sensor (in milliseconds), instead of using the rolling shutter readout database. Note that not all cameras are present in the database. Set to 0 to use the database value. Default: %(default)s"); -_("Simple Morphological Filter elevation scalar parameter. Default: %(default)s"); -_("Permanently delete all previous results and rerun the processing pipeline."); -_("Generates a polygon around the cropping area that cuts the orthophoto around the edges of features. This polygon can be useful for stitching seamless mosaics with multiple overlapping orthophotos. Default: %(default)s"); -_("Set feature extraction quality. Higher quality generates better features, but requires more memory and takes longer. Can be one of: %(choices)s. Default: %(default)s"); -_("DSM/DTM resolution in cm / pixel. Note that this value is capped to 2x the ground sampling distance (GSD) estimate. To remove the cap, check --ignore-gsd also. Default: %(default)s"); -_("Displays version number and exits. "); -_("Set this parameter if you want to generate a PNG rendering of the orthophoto. Default: %(default)s"); -_("The maximum number of processes to use in various processes. Peak memory requirement is ~1GB per thread and 2 megapixel image resolution. Default: %(default)s"); -_("Skip the blending of colors near seams. Default: %(default)s"); -_("Radius of the overlap between submodels. After grouping images into clusters, images that are closer than this radius to a cluster are added to the cluster. This is done to ensure that neighboring submodels overlap. Default: %(default)s"); -_("Do not use GPU acceleration, even if it's available. Default: %(default)s"); -_("When processing multispectral datasets, ODM will automatically align the images for each band. If the images have been postprocessed and are already aligned, use this option. Default: %(default)s"); -_("Filters the point cloud by removing points that deviate more than N standard deviations from the local mean. Set to 0 to disable filtering. Default: %(default)s"); -_("Orthophoto resolution in cm / pixel. Note that this value is capped by a ground sampling distance (GSD) estimate. To remove the cap, check --ignore-gsd also. Default: %(default)s"); -_("Automatically set a boundary using camera shot locations to limit the area of the reconstruction. This can help remove far away background artifacts (sky, background landscapes, etc.). See also --boundary. Default: %(default)s"); -_("Filters the point cloud by keeping only a single point around a radius N (in meters). This can be useful to limit the output resolution of the point cloud and remove duplicate points. Set to 0 to disable sampling. Default: %(default)s"); -_("Skip alignment of submodels in split-merge. Useful if GPS is good enough on very large datasets. Default: %(default)s"); -_("Set this parameter if you want to generate a Google Earth (KMZ) rendering of the orthophoto. Default: %(default)s"); -_("Path to the image groups file that controls how images should be split into groups. The file needs to use the following format: image_name group_nameDefault: %(default)s"); -_("Choose the algorithm for extracting keypoints and computing descriptors. Can be one of: %(choices)s. Default: %(default)s"); -_("Set this parameter if you want a striped GeoTIFF. Default: %(default)s"); _("Specify the distance between camera shot locations and the outer edge of the boundary when computing the boundary with --auto-boundary. Set to 0 to automatically choose a value. Default: %(default)s"); -_("Average number of images per submodel. When splitting a large dataset into smaller submodels, images are grouped into clusters. This value regulates the number of images that each cluster should have on average. Default: %(default)s"); -_("Decimate the points before generating the DEM. 1 is no decimation (full quality). 100 decimates ~99%% of the points. Useful for speeding up generation of DEM results in very large datasets. Default: %(default)s"); -_("Copy output results to this folder after processing."); -_("Generate static tiles for orthophotos and DEMs that are suitable for viewers like Leaflet or OpenLayers. Default: %(default)s"); -_("Generate OBJs that have a single material and a single texture file instead of multiple ones. Default: %(default)s"); -_("Rerun this stage only and stop. Can be one of: %(choices)s. Default: %(default)s"); -_("Set a camera projection type. Manually setting a value can help improve geometric undistortion. By default the application tries to determine a lens type from the images metadata. Can be one of: %(choices)s. Default: %(default)s"); -_("End processing at this stage. Can be one of: %(choices)s. Default: %(default)s"); -_("Use images' GPS exif data for reconstruction, even if there are GCPs present.This flag is useful if you have high precision GPS measurements. If there are no GCPs, this flag does nothing. Default: %(default)s"); -_("Path to the project folder. Your project folder should contain subfolders for each dataset. Each dataset should have an \"images\" folder."); -_("Octree depth used in the mesh reconstruction, increase to get more vertices, recommended values are 8-12. Default: %(default)s"); -_("Generate OGC 3D Tiles outputs. Default: %(default)s"); -_("Use a full 3D mesh to compute the orthophoto instead of a 2.5D mesh. This option is a bit faster and provides similar results in planar areas. Default: %(default)s"); -_("Matcher algorithm, Fast Library for Approximate Nearest Neighbors or Bag of Words. FLANN is slower, but more stable. BOW is faster, but can sometimes miss valid matches. BRUTEFORCE is very slow but robust.Can be one of: %(choices)s. Default: %(default)s"); -_("Generate single file Binary glTF (GLB) textured models. Default: %(default)s"); -_("Set the radiometric calibration to perform on images. When processing multispectral and thermal images you should set this option to obtain reflectance/temperature values (otherwise you will get digital number values). [camera] applies black level, vignetting, row gradient gain/exposure compensation (if appropriate EXIF tags are found) and computes absolute temperature values. [camera+sun] is experimental, applies all the corrections of [camera], plus compensates for spectral radiance registered via a downwelling light sensor (DLS) taking in consideration the angle of the sun. Can be one of: %(choices)s. Default: %(default)s"); _("Skip generation of PDF report. This can save time if you don't need a report. Default: %(default)s"); -_("Ignore Ground Sampling Distance (GSD). GSD caps the maximum resolution of image outputs and resizes images when necessary, resulting in faster processing and lower memory usage. Since GSD is an estimate, sometimes ignoring it can result in slightly better image output quality. Default: %(default)s"); -_("Perform image matching with the nearest N images based on image filename order. Can speed up processing of sequential images, such as those extracted from video. Set to 0 to disable. Default: %(default)s"); +_("Choose what to merge in the merge step in a split dataset. By default all available outputs are merged. Options: %(choices)s. Default: %(default)s"); +_("End processing at this stage. Can be one of: %(choices)s. Default: %(default)s"); +_("Choose the algorithm for extracting keypoints and computing descriptors. Can be one of: %(choices)s. Default: %(default)s"); +_("Choose the structure from motion algorithm. For aerial datasets, if camera GPS positions and angles are available, triangulation can generate better results. For planar scenes captured at fixed altitude with nadir-only images, planar can be much faster. Can be one of: %(choices)s. Default: %(default)s"); +_("Generate static tiles for orthophotos and DEMs that are suitable for viewers like Leaflet or OpenLayers. Default: %(default)s"); +_("Perform ground rectification on the point cloud. This means that wrongly classified ground points will be re-classified and gaps will be filled. Useful for generating DTMs. Default: %(default)s"); +_("Octree depth used in the mesh reconstruction, increase to get more vertices, recommended values are 8-12. Default: %(default)s"); +_("Use this tag if you have a GCP File but want to use the EXIF information for georeferencing instead. Default: %(default)s"); +_("Export the georeferenced point cloud in Entwine Point Tile (EPT) format. Default: %(default)s"); +_("Set this parameter if you want to generate a Google Earth (KMZ) rendering of the orthophoto. Default: %(default)s"); +_("Override the rolling shutter readout time for your camera sensor (in milliseconds), instead of using the rolling shutter readout database. Note that not all cameras are present in the database. Set to 0 to use the database value. Default: %(default)s"); +_("DSM/DTM resolution in cm / pixel. Note that this value is capped by a ground sampling distance (GSD) estimate. Default: %(default)s"); +_("Path to a GeoTIFF DEM or a LAS/LAZ point cloud that the reconstruction outputs should be automatically aligned to. Experimental. Default: %(default)s"); +_("Filters the point cloud by keeping only a single point around a radius N (in meters). This can be useful to limit the output resolution of the point cloud and remove duplicate points. Set to 0 to disable sampling. Default: %(default)s"); +_("Simple Morphological Filter slope parameter (rise over run). Default: %(default)s"); +_("Automatically set a boundary using camera shot locations to limit the area of the reconstruction. This can help remove far away background artifacts (sky, background landscapes, etc.). See also --boundary. Default: %(default)s"); _("Name of dataset (i.e subfolder name within project folder). Default: %(default)s"); -_("The maximum vertex count of the output mesh. Default: %(default)s"); +_("Automatically compute image masks using AI to remove the background. Experimental. Default: %(default)s"); +_("Generate OGC 3D Tiles outputs. Default: %(default)s"); _("Skip generation of the orthophoto. This can save time if you only need 3D results or DEMs. Default: %(default)s"); +_("Ignore Ground Sampling Distance (GSD).A memory and processor hungry change relative to the default behavior if set to true. Ordinarily, GSD estimates are used to cap the maximum resolution of image outputs and resizes images when necessary, resulting in faster processing and lower memory usage. Since GSD is an estimate, sometimes ignoring it can result in slightly better image output quality. Never set --ignore-gsd to true unless you are positive you need it, and even then: do not use it. Default: %(default)s"); +_("Generate single file Binary glTF (GLB) textured models. Default: %(default)s"); +_("When processing multispectral datasets, ODM will automatically align the images for each band. If the images have been postprocessed and are already aligned, use this option. Default: %(default)s"); +_("Classify the point cloud outputs. You can control the behavior of this option by tweaking the --dem-* parameters. Default: %(default)s"); +_("Matcher algorithm, Fast Library for Approximate Nearest Neighbors or Bag of Words. FLANN is slower, but more stable. BOW is faster, but can sometimes miss valid matches. BRUTEFORCE is very slow but robust.Can be one of: %(choices)s. Default: %(default)s"); +_("Skips dense reconstruction and 3D model generation. It generates an orthophoto directly from the sparse reconstruction. If you just need an orthophoto and do not need a full 3D model, turn on this option. Default: %(default)s"); +_("Generate OBJs that have a single material and a single texture file instead of multiple ones. Default: %(default)s"); +_("Create Cloud-Optimized GeoTIFFs instead of normal GeoTIFFs. Default: %(default)s"); +_("Set feature extraction quality. Higher quality generates better features, but requires more memory and takes longer. Can be one of: %(choices)s. Default: %(default)s"); +_("Computes an euclidean raster map for each DEM. The map reports the distance from each cell to the nearest NODATA value (before any hole filling takes place). This can be useful to isolate the areas that have been filled. Default: %(default)s"); +_("Minimum number of features to extract per image. More features can be useful for finding more matches between images, potentially allowing the reconstruction of areas with little overlap or insufficient features. More features also slow down processing. Default: %(default)s"); +_("Automatically crop image outputs by creating a smooth buffer around the dataset boundaries, shrunk by N meters. Use 0 to disable cropping. Default: %(default)s"); +_("Set a camera projection type. Manually setting a value can help improve geometric undistortion. By default the application tries to determine a lens type from the images metadata. Can be one of: %(choices)s. Default: %(default)s"); +_("Turn on rolling shutter correction. If the camera has a rolling shutter and the images were taken in motion, you can turn on this option to improve the accuracy of the results. See also --rolling-shutter-readout. Default: %(default)s"); +_("Number of steps used to fill areas with gaps. Set to 0 to disable gap filling. Starting with a radius equal to the output resolution, N different DEMs are generated with progressively bigger radius using the inverse distance weighted (IDW) algorithm and merged together. Remaining gaps are then merged using nearest neighbor interpolation. Default: %(default)s"); +_("Keep faces in the mesh that are not seen in any camera. Default: %(default)s"); +_("Filters the point cloud by removing points that deviate more than N standard deviations from the local mean. Set to 0 to disable filtering. Default: %(default)s"); +_("The maximum vertex count of the output mesh. Default: %(default)s"); +_("Delete heavy intermediate files to optimize disk space usage. This affects the ability to restart the pipeline from an intermediate stage, but allows datasets to be processed on machines that don't have sufficient disk space available. Default: %(default)s"); +_("Save the georeferenced point cloud in Cloud Optimized Point Cloud (COPC) format. Default: %(default)s"); +_("Path to the image geolocation file containing the camera center coordinates used for georeferencing. If you don't have values for yaw/pitch/roll you can set them to 0. The file needs to use the following format: EPSG: or <+proj definition>image_name geo_x geo_y geo_z [yaw (degrees)] [pitch (degrees)] [roll (degrees)] [horz accuracy (meters)] [vert accuracy (meters)]Default: %(default)s"); +_("Average number of images per submodel. When splitting a large dataset into smaller submodels, images are grouped into clusters. This value regulates the number of images that each cluster should have on average. Default: %(default)s"); +_("The maximum number of processes to use in various processes. Peak memory requirement is ~1GB per thread and 2 megapixel image resolution. Default: %(default)s"); +_("Build orthophoto overviews for faster display in programs such as QGIS. Default: %(default)s"); +_("Decimate the points before generating the DEM. 1 is no decimation (full quality). 100 decimates ~99%% of the points. Useful for speeding up generation of DEM results in very large datasets. Default: %(default)s"); +_("Set this parameter if you want a striped GeoTIFF. Default: %(default)s"); +_("Use this tag to build a DTM (Digital Terrain Model, ground only) using a simple morphological filter. Check the --dem* and --smrf* parameters for finer tuning. Default: %(default)s"); _("Use this tag to build a DSM (Digital Surface Model, ground + objects) using a progressive morphological filter. Check the --dem* parameters for finer tuning. Default: %(default)s"); +_("Turn off camera parameter optimization during bundle adjustment. This can be sometimes useful for improving results that exhibit doming/bowling or when images are taken with a rolling shutter camera. Default: %(default)s"); +_("show this help message and exit"); +_("Rerun this stage only and stop. Can be one of: %(choices)s. Default: %(default)s"); +_("Automatically compute image masks using AI to remove the sky. Experimental. Default: %(default)s"); +_("Orthophoto resolution in cm / pixel. Note that this value is capped by a ground sampling distance (GSD) estimate.Default: %(default)s"); +_("When processing multispectral datasets, you can specify the name of the primary band that will be used for reconstruction. It's recommended to choose a band which has sharp details and is in focus. Default: %(default)s"); +_("Use images' GPS exif data for reconstruction, even if there are GCPs present.This flag is useful if you have high precision GPS measurements. If there are no GCPs, this flag does nothing. Default: %(default)s"); _("Simple Morphological Filter window radius parameter (meters). Default: %(default)s"); +_("Do not use GPU acceleration, even if it's available. Default: %(default)s"); +_("Export the georeferenced point cloud in LAS format. Default: %(default)s"); +_("Use the camera parameters computed from another dataset instead of calculating them. Can be specified either as path to a cameras.json file or as a JSON string representing the contents of a cameras.json file. Default: %(default)s"); +_("Maximum number of frames to extract from video files for processing. Set to 0 for no limit. Default: %(default)s"); +_("URL to a ClusterODM instance for distributing a split-merge workflow on multiple nodes in parallel. Default: %(default)s"); +_("Set a value in meters for the GPS Dilution of Precision (DOP) information for all images. If your images are tagged with high precision GPS information (RTK), this value will be automatically set accordingly. You can use this option to manually set it in case the reconstruction fails. Lowering this option can sometimes help control bowling-effects over large areas. Default: %(default)s"); +_("Do not attempt to merge partial reconstructions. This can happen when images do not have sufficient overlap or are isolated. Default: %(default)s"); +_("Set the compression to use for orthophotos. Can be one of: %(choices)s. Default: %(default)s"); +_("Path to the project folder. Your project folder should contain subfolders for each dataset. Each dataset should have an \"images\" folder."); +_("Generates a polygon around the cropping area that cuts the orthophoto around the edges of features. This polygon can be useful for stitching seamless mosaics with multiple overlapping orthophotos. Default: %(default)s"); +_("Perform image matching with the nearest images based on GPS exif data. Set to 0 to match by triangulation. Default: %(default)s"); +_("Skip generation of a full 3D model. This can save time if you only need 2D results such as orthophotos and DEMs. Default: %(default)s"); +_("The maximum output resolution of extracted video frames in pixels. Default: %(default)s"); +_("Set the radiometric calibration to perform on images. When processing multispectral and thermal images you should set this option to obtain reflectance/temperature values (otherwise you will get digital number values). [camera] applies black level, vignetting, row gradient gain/exposure compensation (if appropriate EXIF tags are found) and computes absolute temperature values. [camera+sun] is experimental, applies all the corrections of [camera], plus compensates for spectral radiance registered via a downwelling light sensor (DLS) taking in consideration the angle of the sun. Can be one of: %(choices)s. Default: %(default)s"); +_("Simple Morphological Filter elevation threshold parameter (meters). Default: %(default)s"); +_("Geometric estimates improve the accuracy of the point cloud by computing geometrically consistent depthmaps but may not be usable in larger datasets. This flag disables geometric estimates. Default: %(default)s"); +_("Skip normalization of colors across all images. Useful when processing radiometric data. Default: %(default)s"); +_("GeoJSON polygon limiting the area of the reconstruction. Can be specified either as path to a GeoJSON file or as a JSON string representing the contents of a GeoJSON file. Default: %(default)s"); +_("Set point cloud quality. Higher quality generates better, denser point clouds, but requires more memory and takes longer. Each step up in quality increases processing time roughly by a factor of 4x.Can be one of: %(choices)s. Default: %(default)s"); +_("Skip alignment of submodels in split-merge. Useful if GPS is good enough on very large datasets. Default: %(default)s"); +_("Use a full 3D mesh to compute the orthophoto instead of a 2.5D mesh. This option is a bit faster and provides similar results in planar areas. Default: %(default)s"); +_("Radius of the overlap between submodels. After grouping images into clusters, images that are closer than this radius to a cluster are added to the cluster. This is done to ensure that neighboring submodels overlap. Default: %(default)s"); +_("Set this parameter if you want to generate a PNG rendering of the orthophoto. Default: %(default)s"); +_("Displays version number and exits. "); +_("Path to the image groups file that controls how images should be split into groups. The file needs to use the following format: image_name group_nameDefault: %(default)s"); _("Rerun processing from this stage. Can be one of: %(choices)s. Default: %(default)s"); +_("Path to the file containing the ground control points used for georeferencing. The file needs to use the following format: EPSG: or <+proj definition>geo_x geo_y geo_z im_x im_y image_name [gcp_name] [extra1] [extra2]Default: %(default)s"); +_("Permanently delete all previous results and rerun the processing pipeline."); +_("Simple Morphological Filter elevation scalar parameter. Default: %(default)s"); +_("Perform image matching with the nearest N images based on image filename order. Can speed up processing of sequential images, such as those extracted from video. It is applied only on non-georeferenced datasets. Set to 0 to disable. Default: %(default)s"); +_("Copy output results to this folder after processing."); +_("Run local bundle adjustment for every image added to the reconstruction and a global adjustment every 100 images. Speeds up reconstruction for very large datasets. Default: %(default)s"); +_("Export the georeferenced point cloud in CSV format. Default: %(default)s"); +_("Skip the blending of colors near seams. Default: %(default)s"); diff --git a/app/tests/test_login.py b/app/tests/test_login.py index 57f25a15..371e3bcf 100644 --- a/app/tests/test_login.py +++ b/app/tests/test_login.py @@ -28,7 +28,7 @@ class TestLogin(BootTestCase): res = c.get('/login/', follow=True) body = res.content.decode("utf-8") - # The reset password link should show instructions + # The reset password link is a link self.assertTrue('