diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 90d28ec1..b44c8239 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -8,6 +8,11 @@ services: # Log printing via Rich is enhanced by a TTY tty: true env_file: ./dev/.env.docker-compose + environment: + # ensure these match the web container + - DJANGO_HOMEPAGE_REDIRECT_URL=http://localhost:8080/ + - VUE_APP_BASE_URL=http://localhost:8080/ + - VUE_APP_OAUTH_CLIENT_ID=cBmD6D6F2YAmMWHNQZFPUr4OpaXVpW5w4Thod6Kj volumes: - .:/opt/uvdat-server ports: @@ -33,6 +38,8 @@ services: ] # Docker Compose does not set the TTY width, which causes Celery errors tty: false + environment: + - DJANGO_HOMEPAGE_REDIRECT_URL=http://localhost:8080/ env_file: ./dev/.env.docker-compose volumes: - .:/opt/uvdat-server diff --git a/docs/setup.md b/docs/setup.md index 08bdff22..407c9fd8 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -8,7 +8,10 @@ b. Run `docker-compose run --rm django ./manage.py createsuperuser` and follow the prompts to create your own user. - c. Run `docker-compose run --rm django ./manage.py populate` to use sample data. + c. Run `docker-compose run --rm django ./manage.py makeclient` to create a client Application object for authentication. + + d. Run `docker-compose run --rm django ./manage.py populate` to use sample data. + ### Run Application 1. Run `docker-compose up`. diff --git a/sample_data/ingest_use_case.py b/sample_data/ingest_use_case.py index 8d4af224..8968054a 100644 --- a/sample_data/ingest_use_case.py +++ b/sample_data/ingest_use_case.py @@ -4,11 +4,12 @@ import os from pathlib import Path +from django.contrib.auth.models import User from django.contrib.gis.geos import Point from django.core.files.base import ContentFile import requests -from uvdat.core.models import Chart, Context, Dataset, FileItem +from uvdat.core.models import Chart, Project, Dataset, FileItem from .use_cases.boston_floods import ingest as boston_floods_ingest from .use_cases.new_york_energy import ingest as new_york_energy_ingest @@ -54,26 +55,28 @@ def ingest_file(file_info, index=0, dataset=None, chart=None): new_file_item.file.save(file_path, ContentFile(f.read())) -def ingest_contexts(use_case): - context_file_path = USE_CASE_FOLDER / use_case / 'contexts.json' - if context_file_path.exists(): - print('Creating Context objects...') - with open(context_file_path) as contexts_json: - data = json.load(contexts_json) - for context in data: - print('\t- ', context['name']) - existing = Context.objects.filter(name=context['name']) - if existing.count(): - context_for_setting = existing.first() - else: - context_for_setting = Context.objects.create( - name=context['name'], - default_map_center=Point(*context['default_map_center']), - default_map_zoom=context['default_map_zoom'], - ) - print('\t', f'Context {context_for_setting.name} created.') +def ingest_projects(use_case): + project_file_path = USE_CASE_FOLDER / use_case / 'projects.json' + if not project_file_path.exists(): + return + + print('Creating Project objects...') + with open(project_file_path) as projects_json: + data = json.load(projects_json) + for project in data: + print('\t- ', project['name']) + project_for_setting, created = Project.objects.get_or_create( + name=project['name'], + defaults={ + 'default_map_center': Point(*project['default_map_center']), + 'default_map_zoom': project['default_map_zoom'], + }, + ) + if created: + print('\t', f'Project {project_for_setting.name} created.') - context_for_setting.datasets.set(Dataset.objects.filter(name__in=context['datasets'])) + project_for_setting.datasets.set(Dataset.objects.filter(name__in=project['datasets'])) + project_for_setting.set_permissions(owner=User.objects.filter(is_superuser=True).first()) def ingest_charts(use_case): @@ -91,7 +94,7 @@ def ingest_charts(use_case): new_chart = Chart.objects.create( name=chart['name'], description=chart['description'], - context=Context.objects.get(name=chart['context']), + project=Project.objects.get(name=chart['project']), chart_options=chart.get('chart_options'), metadata=chart.get('metadata'), editable=chart.get('editable', False), @@ -162,5 +165,5 @@ def ingest_use_case(use_case_name, include_large=False, dataset_indexes=None): include_large=include_large, dataset_indexes=dataset_indexes, ) - ingest_contexts(use_case=use_case_name) + ingest_projects(use_case=use_case_name) ingest_charts(use_case=use_case_name) diff --git a/sample_data/use_cases/boston_floods/charts.json b/sample_data/use_cases/boston_floods/charts.json index 9de31636..88bbe219 100644 --- a/sample_data/use_cases/boston_floods/charts.json +++ b/sample_data/use_cases/boston_floods/charts.json @@ -2,7 +2,7 @@ { "name": "Boston Harbor Daily Tide Levels", "description": "Raw data was obtained using the NOAA CO-OPS API for Data Retrieval and reformatted in tabular form", - "context": "Boston Transportation", + "project": "Boston Transportation", "files": [ { "url": "https://data.kitware.com/api/v1/item/64beb508b4d956782eee8cb1/download", diff --git a/sample_data/use_cases/boston_floods/contexts.json b/sample_data/use_cases/boston_floods/projects.json similarity index 100% rename from sample_data/use_cases/boston_floods/contexts.json rename to sample_data/use_cases/boston_floods/projects.json diff --git a/sample_data/use_cases/new_york_energy/contexts.json b/sample_data/use_cases/new_york_energy/projects.json similarity index 100% rename from sample_data/use_cases/new_york_energy/contexts.json rename to sample_data/use_cases/new_york_energy/projects.json diff --git a/setup.py b/setup.py index 7236eae0..5d7bd986 100644 --- a/setup.py +++ b/setup.py @@ -42,6 +42,7 @@ 'django-configurations[database,email]==2.5.1', 'django-extensions==3.2.3', 'django-filter==24.3', + 'django-guardian==2.4.0', 'django-oauth-toolkit==2.4.0', 'djangorestframework==3.15.2', 'django-large-image==0.10.0', diff --git a/tox.ini b/tox.ini index 7bdf7b68..2bb7c68b 100644 --- a/tox.ini +++ b/tox.ini @@ -45,6 +45,7 @@ passenv = DJANGO_MINIO_STORAGE_ACCESS_KEY DJANGO_MINIO_STORAGE_ENDPOINT DJANGO_MINIO_STORAGE_SECRET_KEY + DJANGO_HOMEPAGE_REDIRECT_URL extras = dev deps = @@ -66,6 +67,7 @@ passenv = DJANGO_MINIO_STORAGE_ACCESS_KEY DJANGO_MINIO_STORAGE_ENDPOINT DJANGO_MINIO_STORAGE_SECRET_KEY + DJANGO_HOMEPAGE_REDIRECT_URL extras = dev commands = diff --git a/uvdat/core/admin.py b/uvdat/core/admin.py index 346aab6a..95eb8595 100644 --- a/uvdat/core/admin.py +++ b/uvdat/core/admin.py @@ -2,13 +2,13 @@ from uvdat.core.models import ( Chart, - Context, Dataset, DerivedRegion, FileItem, Network, NetworkEdge, NetworkNode, + Project, RasterMapLayer, SimulationResult, SourceRegion, @@ -17,7 +17,7 @@ ) -class ContextAdmin(admin.ModelAdmin): +class ProjectAdmin(admin.ModelAdmin): list_display = ['id', 'name'] @@ -72,10 +72,10 @@ def get_dataset_name(self, obj): class DerivedRegionAdmin(admin.ModelAdmin): - list_display = ['id', 'name', 'get_context_name', 'operation', 'get_source_region_names'] + list_display = ['id', 'name', 'get_project_name', 'operation', 'get_source_region_names'] - def get_context_name(self, obj): - return obj.context.name + def get_project_name(self, obj): + return obj.project.name def get_source_region_names(self, obj): return ', '.join(r.name for r in obj.source_regions.all()) @@ -115,7 +115,7 @@ class SimulationResultAdmin(admin.ModelAdmin): list_display = ['id', 'simulation_type', 'input_args'] -admin.site.register(Context, ContextAdmin) +admin.site.register(Project, ProjectAdmin) admin.site.register(Dataset, DatasetAdmin) admin.site.register(FileItem, FileItemAdmin) admin.site.register(Chart, ChartAdmin) diff --git a/uvdat/core/management/commands/load_roads.py b/uvdat/core/management/commands/load_roads.py index 2b755770..c54674e7 100644 --- a/uvdat/core/management/commands/load_roads.py +++ b/uvdat/core/management/commands/load_roads.py @@ -13,10 +13,10 @@ def add_arguments(self, parser): type=str, help='Target city to fetch roads from (e.g. "Boston, MA")', ) - parser.add_argument('--context_id', nargs='?', type=int, const=1) + parser.add_argument('--project_id', nargs='?', type=int, const=1) def handle(self, *args, **kwargs): city = kwargs['city'] - context_id = kwargs['context_id'] - print(f'Populating context {context_id} with roads for {city}...') - load_roads(context_id, city) + project_id = kwargs['project_id'] + print(f'Populating project {project_id} with roads for {city}...') + load_roads(project_id, city) diff --git a/uvdat/core/management/commands/makeclient.py b/uvdat/core/management/commands/makeclient.py new file mode 100644 index 00000000..4a554a9f --- /dev/null +++ b/uvdat/core/management/commands/makeclient.py @@ -0,0 +1,38 @@ +import os + +from django.contrib.sites.models import Site +from django.core.management.base import BaseCommand, CommandError +from oauth2_provider.models import Application + + +class Command(BaseCommand): + help = 'Creates a client Application object for authentication purposes.' + + def handle(self, **kwargs): + uri = os.environ.get('VUE_APP_BASE_URL') + client_id = os.environ.get('VUE_APP_OAUTH_CLIENT_ID') + if uri is None: + raise CommandError('Environment variable VUE_APP_BASE_URL is not set.') + if client_id is None: + raise CommandError('Environment variable VUE_APP_OAUTH_CLIENT_ID is not set.') + + site = Site.objects.get_current() # type: ignore + site.domain = 'uvdat.demo' + site.name = 'UVDAT' + site.save() + + _, created = Application.objects.get_or_create( + name='client-app', + defaults={ + 'redirect_uris': uri, + 'client_id': client_id, + 'client_type': 'public', + 'authorization_grant_type': 'authorization-code', + 'skip_authorization': True, + }, + ) + if not created: + raise CommandError( + 'The client already exists. You can administer it from the admin console.' + ) + self.stdout.write(self.style.SUCCESS('Client Application created.')) diff --git a/uvdat/core/migrations/0005_projects.py b/uvdat/core/migrations/0005_projects.py new file mode 100644 index 00000000..20dccb87 --- /dev/null +++ b/uvdat/core/migrations/0005_projects.py @@ -0,0 +1,80 @@ +# Generated by Django 5.0.7 on 2024-09-29 16:44 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0004_files_and_networks'), + ] + + operations = [ + migrations.RenameModel( + old_name='Context', + new_name='Project', + ), + migrations.AlterModelOptions( + name='project', + options={ + 'permissions': [ + ('owner', 'Can read, write, and delete'), + ('collaborator', 'Can read and write'), + ('follower', 'Can read'), + ] + }, + ), + migrations.RemoveConstraint( + model_name='derivedregion', + name='unique-derived-region-name', + ), + migrations.RemoveField( + model_name='chart', + name='context', + ), + migrations.RemoveField( + model_name='derivedregion', + name='context', + ), + migrations.RemoveField( + model_name='simulationresult', + name='context', + ), + migrations.AddField( + model_name='chart', + name='project', + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='charts', + to='core.project', + ), + ), + migrations.AddField( + model_name='derivedregion', + name='project', + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='derived_regions', + to='core.project', + ), + ), + migrations.AddField( + model_name='simulationresult', + name='project', + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='simulation_results', + to='core.project', + ), + ), + migrations.AddConstraint( + model_name='derivedregion', + constraint=models.UniqueConstraint( + fields=('project', 'name'), name='unique-derived-region-name' + ), + ), + ] diff --git a/uvdat/core/models/__init__.py b/uvdat/core/models/__init__.py index 88a0b109..fd2baf5c 100644 --- a/uvdat/core/models/__init__.py +++ b/uvdat/core/models/__init__.py @@ -1,15 +1,15 @@ from .chart import Chart -from .context import Context from .dataset import Dataset from .file_item import FileItem from .map_layers import RasterMapLayer, VectorFeature, VectorMapLayer from .networks import Network, NetworkEdge, NetworkNode +from .project import Project from .regions import DerivedRegion, SourceRegion from .simulations import SimulationResult __all__ = [ Chart, - Context, + Project, Dataset, FileItem, RasterMapLayer, diff --git a/uvdat/core/models/chart.py b/uvdat/core/models/chart.py index 48540428..d85ccfa8 100644 --- a/uvdat/core/models/chart.py +++ b/uvdat/core/models/chart.py @@ -1,21 +1,18 @@ from django.db import models -from .context import Context +from .project import Project class Chart(models.Model): name = models.CharField(max_length=255, unique=True) description = models.TextField(null=True, blank=True) - context = models.ForeignKey(Context, on_delete=models.CASCADE, related_name='charts') + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='charts', null=True) metadata = models.JSONField(blank=True, null=True) chart_data = models.JSONField(blank=True, null=True) chart_options = models.JSONField(blank=True, null=True) editable = models.BooleanField(default=False) - def is_in_context(self, context_id): - return self.context.id == context_id - def spawn_conversion_task( self, conversion_options=None, diff --git a/uvdat/core/models/context.py b/uvdat/core/models/context.py deleted file mode 100644 index 46e0e1ee..00000000 --- a/uvdat/core/models/context.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.contrib.gis.db import models as geo_models -from django.db import models - -from .dataset import Dataset - - -class Context(models.Model): - name = models.CharField(max_length=255, unique=True) - default_map_center = geo_models.PointField() - default_map_zoom = models.IntegerField(default=10) - datasets = models.ManyToManyField(Dataset, blank=True) diff --git a/uvdat/core/models/dataset.py b/uvdat/core/models/dataset.py index 19819c6b..9e656b64 100644 --- a/uvdat/core/models/dataset.py +++ b/uvdat/core/models/dataset.py @@ -16,12 +16,6 @@ class DatasetType(models.TextChoices): choices=DatasetType.choices, ) - def is_in_context(self, context_id): - from uvdat.core.models import Context - - context = Context.objects.get(id=context_id) - return context.datasets.filter(id=self.id).exists() - def spawn_conversion_task( self, style_options=None, diff --git a/uvdat/core/models/file_item.py b/uvdat/core/models/file_item.py index 9a129a94..3a4dcc4e 100644 --- a/uvdat/core/models/file_item.py +++ b/uvdat/core/models/file_item.py @@ -18,9 +18,6 @@ class FileItem(TimeStampedModel): metadata = models.JSONField(blank=True, null=True) index = models.IntegerField(null=True) - def is_in_context(self, context_id): - return self.dataset.is_in_context(context_id) - def download(self): # TODO: download pass diff --git a/uvdat/core/models/map_layers.py b/uvdat/core/models/map_layers.py index 1b9e2e90..43870315 100644 --- a/uvdat/core/models/map_layers.py +++ b/uvdat/core/models/map_layers.py @@ -18,9 +18,6 @@ class AbstractMapLayer(TimeStampedModel): default_style = models.JSONField(blank=True, null=True) index = models.IntegerField(null=True) - def is_in_context(self, context_id): - return self.dataset.is_in_context(context_id) - class Meta: abstract = True diff --git a/uvdat/core/models/networks.py b/uvdat/core/models/networks.py index 5b9cb83d..235156d2 100644 --- a/uvdat/core/models/networks.py +++ b/uvdat/core/models/networks.py @@ -10,9 +10,6 @@ class Network(models.Model): category = models.CharField(max_length=25) metadata = models.JSONField(blank=True, null=True) - def is_in_context(self, context_id): - return self.dataset.is_in_context(context_id) - def get_graph(self): from uvdat.core.tasks.networks import get_network_graph @@ -34,9 +31,6 @@ class NetworkNode(models.Model): capacity = models.IntegerField(null=True) location = geo_models.PointField() - def is_in_context(self, context_id): - return self.network.is_in_context(context_id) - def get_adjacent_nodes(self) -> models.QuerySet: entering_node_ids = ( NetworkEdge.objects.filter(to_node=self.id) @@ -62,6 +56,3 @@ class NetworkEdge(models.Model): directed = models.BooleanField(default=False) from_node = models.ForeignKey(NetworkNode, related_name='+', on_delete=models.CASCADE) to_node = models.ForeignKey(NetworkNode, related_name='+', on_delete=models.CASCADE) - - def is_in_context(self, context_id): - return self.network.is_in_context(context_id) diff --git a/uvdat/core/models/project.py b/uvdat/core/models/project.py new file mode 100644 index 00000000..706f87b4 --- /dev/null +++ b/uvdat/core/models/project.py @@ -0,0 +1,42 @@ +from django.contrib.auth.models import User +from django.contrib.gis.db import models as geo_models +from django.db import models, transaction +from guardian.models import UserObjectPermission +from guardian.shortcuts import assign_perm + +from .dataset import Dataset + + +class Project(models.Model): + name = models.CharField(max_length=255, unique=True) + default_map_center = geo_models.PointField() + default_map_zoom = models.IntegerField(default=10) + datasets = models.ManyToManyField(Dataset, blank=True) + + @transaction.atomic() + def set_permissions( + self, + owner: User, + collaborator: list[User] | None = None, + follower: list[User] | None = None, + ): + # Delete all existing first + UserObjectPermission.objects.filter( + content_type__app_label=self._meta.app_label, + content_type__model=self._meta.model_name, + object_pk=self.pk, + ).delete() + + # Assign new perms + assign_perm('owner', owner, self) + for user in collaborator or []: + assign_perm('collaborator', user, self) + for user in follower or []: + assign_perm('follower', user, self) + + class Meta: + permissions = [ + ('owner', 'Can read, write, and delete'), + ('collaborator', 'Can read and write'), + ('follower', 'Can read'), + ] diff --git a/uvdat/core/models/regions.py b/uvdat/core/models/regions.py index 3d79ea43..e4a70d55 100644 --- a/uvdat/core/models/regions.py +++ b/uvdat/core/models/regions.py @@ -1,9 +1,9 @@ from django.contrib.gis.db import models as geo_models from django.db import models -from .context import Context from .dataset import Dataset from .map_layers import VectorMapLayer +from .project import Project class SourceRegion(models.Model): @@ -12,9 +12,6 @@ class SourceRegion(models.Model): metadata = models.JSONField(blank=True, null=True) boundary = geo_models.MultiPolygonField() - def is_in_context(self, context_id): - return self.dataset.is_in_context(context_id) - class Meta: constraints = [ # We enforce name uniqueness across datasets @@ -28,7 +25,9 @@ class VectorOperation(models.TextChoices): INTERSECTION = 'INTERSECTION', 'Intersection' name = models.CharField(max_length=255) - context = models.ForeignKey(Context, on_delete=models.CASCADE, related_name='derived_regions') + project = models.ForeignKey( + Project, on_delete=models.CASCADE, related_name='derived_regions', null=True + ) metadata = models.JSONField(blank=True, null=True) boundary = geo_models.MultiPolygonField() @@ -43,9 +42,6 @@ class VectorOperation(models.TextChoices): # They need their own reference to a map representation map_layer = models.ForeignKey(VectorMapLayer, on_delete=models.PROTECT) - def is_in_context(self, context_id): - return self.context.id == int(context_id) - def get_map_layers(self): return [ { @@ -57,6 +53,6 @@ def get_map_layers(self): class Meta: constraints = [ - # We enforce name uniqueness across contexts - models.UniqueConstraint(name='unique-derived-region-name', fields=['context', 'name']) + # We enforce name uniqueness across projects + models.UniqueConstraint(name='unique-derived-region-name', fields=['project', 'name']) ] diff --git a/uvdat/core/models/simulations.py b/uvdat/core/models/simulations.py index e6bf4e12..00bd9bdc 100644 --- a/uvdat/core/models/simulations.py +++ b/uvdat/core/models/simulations.py @@ -3,9 +3,9 @@ from uvdat.core.tasks import simulations as uvdat_simulations -from .context import Context from .map_layers import RasterMapLayer, VectorMapLayer from .networks import Network +from .project import Project class SimulationResult(TimeStampedModel): @@ -17,16 +17,13 @@ class SimulationType(models.TextChoices): max_length=max(len(choice[0]) for choice in SimulationType.choices), choices=SimulationType.choices, ) - context = models.ForeignKey( - Context, on_delete=models.CASCADE, related_name='simulation_results' + project = models.ForeignKey( + Project, on_delete=models.CASCADE, related_name='simulation_results', null=True ) input_args = models.JSONField(blank=True, null=True) output_data = models.JSONField(blank=True, null=True) error_message = models.TextField(null=True, blank=True) - def is_in_context(self, context_id): - return self.context.id == int(context_id) - def get_simulation_type(self): if not self.simulation_type or self.simulation_type not in AVAILABLE_SIMULATIONS: raise ValueError(f'Simulation type not found: {self.simulation_type}') diff --git a/uvdat/core/rest/__init__.py b/uvdat/core/rest/__init__.py index 6bc2c50e..2bcd5dfd 100644 --- a/uvdat/core/rest/__init__.py +++ b/uvdat/core/rest/__init__.py @@ -1,14 +1,15 @@ from .chart import ChartViewSet -from .context import ContextViewSet from .dataset import DatasetViewSet from .file_item import FileItemViewSet from .map_layers import RasterMapLayerViewSet, VectorMapLayerViewSet from .network import NetworkEdgeViewSet, NetworkNodeViewSet, NetworkViewSet +from .project import ProjectViewSet from .regions import DerivedRegionViewSet, SourceRegionViewSet from .simulations import SimulationViewSet +from .user import UserViewSet __all__ = [ - ContextViewSet, + ProjectViewSet, ChartViewSet, FileItemViewSet, RasterMapLayerViewSet, @@ -20,4 +21,5 @@ SourceRegionViewSet, DerivedRegionViewSet, SimulationViewSet, + UserViewSet, ] diff --git a/uvdat/core/rest/access_control.py b/uvdat/core/rest/access_control.py new file mode 100644 index 00000000..684d2394 --- /dev/null +++ b/uvdat/core/rest/access_control.py @@ -0,0 +1,70 @@ +from django.db.models import Model +from django.db.models.query import QuerySet +from guardian.shortcuts import get_objects_for_user +from rest_framework.filters import BaseFilterBackend +from rest_framework.permissions import SAFE_METHODS, IsAuthenticated + +from uvdat.core import models +from uvdat.core.models.project import Project + + +# TODO: Dataset permissions should be separated from Project permissions +def filter_queryset_by_projects(queryset: QuerySet[Model], projects: QuerySet[models.Project]): + model = queryset.model + if model == models.Project: + return queryset.filter(id__in=projects.values_list('id', flat=True)) + if model in [models.Dataset, models.Chart, models.DerivedRegion, models.SimulationResult]: + return queryset.filter(project__in=projects) + if model in [ + models.FileItem, + models.RasterMapLayer, + models.VectorMapLayer, + models.Network, + models.SourceRegion, + ]: + return queryset.filter(dataset__project__in=projects) + if model in [models.NetworkNode, models.NetworkEdge]: + return queryset.filter(network__dataset__project__in=projects) + + # If any models are un-caught, raise an exception + raise NotImplementedError + + +class GuardianPermission(IsAuthenticated): + def has_object_permission(self, request, view, obj): + if request.user.is_superuser: + return True + perms = ['follower', 'collaborator', 'owner'] + if request.method not in SAFE_METHODS: + perms = ['collaborator', 'owner'] + if request.method == 'DELETE': + perms = ['owner'] + if not isinstance(obj, Model): + raise Exception('Only Django models may be used in permission check') + + # Create queryset out of single object, so it can be passed to the filter function + queryset = obj.__class__.objects.filter(pk=obj.pk) + + # Get all projects user has access to + user_projects = get_objects_for_user( + klass=models.Project, user=request.user, perms=perms, any_perm=True + ) + + # If the object remains in the queryset after this function filters it, then the user has + # the required permission on at least one associated project + return filter_queryset_by_projects(queryset=queryset, projects=user_projects).exists() + + +class GuardianFilter(BaseFilterBackend): + def filter_queryset(self, request, queryset, view): + if request.user.is_superuser: + return queryset + + # Allow user to have any level of permission + all_perms = [x for x, _ in Project._meta.permissions] + user_projects = get_objects_for_user( + klass=models.Project, user=request.user, perms=all_perms, any_perm=True + ) + + # Return queryset filtered by objects that are within these projects + return filter_queryset_by_projects(queryset=queryset, projects=user_projects) diff --git a/uvdat/core/rest/accounts.py b/uvdat/core/rest/accounts.py new file mode 100644 index 00000000..06755401 --- /dev/null +++ b/uvdat/core/rest/accounts.py @@ -0,0 +1,15 @@ +from allauth.account.forms import SignupForm +from django import forms + + +class AccountSignupForm(SignupForm): + first_name = forms.CharField( + label=('First Name'), + min_length=1, + widget=forms.TextInput(attrs={'placeholder': ('First name')}), + ) + last_name = forms.CharField( + label=('Last Name'), + min_length=1, + widget=forms.TextInput(attrs={'placeholder': ('Last name')}), + ) diff --git a/uvdat/core/rest/chart.py b/uvdat/core/rest/chart.py index 55371d6a..12822e28 100644 --- a/uvdat/core/rest/chart.py +++ b/uvdat/core/rest/chart.py @@ -1,21 +1,26 @@ from django.http import HttpResponse from rest_framework.decorators import action -from rest_framework.viewsets import GenericViewSet, mixins +from rest_framework.viewsets import ModelViewSet from uvdat.core.models import Chart +from uvdat.core.rest.access_control import GuardianFilter, GuardianPermission +from uvdat.core.rest.serializers import ChartSerializer -from .serializers import ChartSerializer - -class ChartViewSet(GenericViewSet, mixins.ListModelMixin): +class ChartViewSet(ModelViewSet): queryset = Chart.objects.all() serializer_class = ChartSerializer + permission_classes = [GuardianPermission] + filter_backends = [GuardianFilter] + lookup_field = 'id' + + def get_queryset(self): + qs = super().get_queryset() + project_id: str = self.request.query_params.get('project') + if project_id is None or not project_id.isdigit(): + return qs - def get_queryset(self, **kwargs): - context_id = self.request.query_params.get('context') - if context_id: - return Chart.objects.filter(context__id=context_id) - return Chart.objects.all() + return qs.filter(project=int(project_id)) def validate_editable(self, chart, func, *args, **kwargs): if chart.editable: diff --git a/uvdat/core/rest/context.py b/uvdat/core/rest/context.py deleted file mode 100644 index 072ee269..00000000 --- a/uvdat/core/rest/context.py +++ /dev/null @@ -1,34 +0,0 @@ -from django.http import HttpResponse -from rest_framework.decorators import action -from rest_framework.viewsets import ModelViewSet - -from uvdat.core.models import Context -from uvdat.core.rest.serializers import ContextSerializer -from uvdat.core.tasks.osmnx import load_roads - - -class ContextViewSet(ModelViewSet): - queryset = Context.objects.all() - serializer_class = ContextSerializer - - @action(detail=True, methods=['get']) - def regions(self, request, **kwargs): - context = self.get_object() - regions = context.derived_regions.all() - return HttpResponse(regions, status=200) - - @action(detail=True, methods=['get']) - def simulation_results(self, request, **kwargs): - context = self.get_object() - simulation_results = context.simulation_results.all() - return HttpResponse(simulation_results, status=200) - - @action( - detail=True, - methods=['get'], - url_path=r'load_roads/(?P.+)', - ) - def load_roads(self, request, location, **kwargs): - context = self.get_object() - load_roads.delay(context.id, location) - return HttpResponse('Task spawned successfully.', status=200) diff --git a/uvdat/core/rest/dataset.py b/uvdat/core/rest/dataset.py index 0fa19caa..1ef7eaf9 100644 --- a/uvdat/core/rest/dataset.py +++ b/uvdat/core/rest/dataset.py @@ -6,19 +6,31 @@ from rest_framework.viewsets import ModelViewSet from uvdat.core.models import Dataset, NetworkEdge, NetworkNode -from uvdat.core.rest import serializers as uvdat_serializers +from uvdat.core.rest.access_control import GuardianFilter, GuardianPermission +from uvdat.core.rest.serializers import ( + DatasetSerializer, + NetworkEdgeSerializer, + NetworkNodeSerializer, + RasterMapLayerSerializer, + VectorMapLayerSerializer, +) from uvdat.core.tasks.chart import add_gcc_chart_datum class DatasetViewSet(ModelViewSet): - serializer_class = uvdat_serializers.DatasetSerializer + queryset = Dataset.objects.all() + serializer_class = DatasetSerializer + permission_classes = [GuardianPermission] + filter_backends = [GuardianFilter] + lookup_field = 'id' def get_queryset(self): - context_id = self.request.query_params.get('context') - if context_id: - return Dataset.objects.filter(context__id=context_id) - else: - return Dataset.objects.all() + qs = super().get_queryset() + project_id: str = self.request.query_params.get('project') + if project_id is None or not project_id.isdigit(): + return qs + + return qs.filter(project=int(project_id)) @action(detail=True, methods=['get']) def map_layers(self, request, **kwargs): @@ -27,10 +39,10 @@ def map_layers(self, request, **kwargs): # Set serializer based on dataset type if dataset.dataset_type == Dataset.DatasetType.RASTER: - serializer = uvdat_serializers.RasterMapLayerSerializer(map_layers, many=True) + serializer = RasterMapLayerSerializer(map_layers, many=True) elif dataset.dataset_type == Dataset.DatasetType.VECTOR: # Set serializer - serializer = uvdat_serializers.VectorMapLayerSerializer(map_layers, many=True) + serializer = VectorMapLayerSerializer(map_layers, many=True) else: raise NotImplementedError(f'Dataset Type {dataset.dataset_type}') @@ -51,11 +63,11 @@ def network(self, request, **kwargs): networks.append( { 'nodes': [ - uvdat_serializers.NetworkNodeSerializer(n).data + NetworkNodeSerializer(n).data for n in NetworkNode.objects.filter(network=network) ], 'edges': [ - uvdat_serializers.NetworkEdgeSerializer(e).data + NetworkEdgeSerializer(e).data for e in NetworkEdge.objects.filter(network=network) ], } @@ -65,7 +77,7 @@ def network(self, request, **kwargs): @action(detail=True, methods=['get']) def gcc(self, request, **kwargs): dataset = self.get_object() - context_id = request.query_params.get('context') + project_id = request.query_params.get('project') exclude_nodes = request.query_params.get('exclude_nodes', []) exclude_nodes = exclude_nodes.split(',') exclude_nodes = [int(n) for n in exclude_nodes if len(n)] @@ -81,5 +93,5 @@ def gcc(self, request, **kwargs): results.sort(key=lambda r: len(r.get('excluded')), reverse=True) gcc = results[0].get('gcc') excluded = results[0].get('excluded') - add_gcc_chart_datum(dataset, context_id, excluded, len(gcc)) + add_gcc_chart_datum(dataset, project_id, excluded, len(gcc)) return HttpResponse(json.dumps(gcc), status=200) diff --git a/uvdat/core/rest/file_item.py b/uvdat/core/rest/file_item.py index 1e8e8154..51a2054e 100644 --- a/uvdat/core/rest/file_item.py +++ b/uvdat/core/rest/file_item.py @@ -1,9 +1,13 @@ from rest_framework.viewsets import ModelViewSet from uvdat.core.models import FileItem +from uvdat.core.rest.access_control import GuardianFilter, GuardianPermission from uvdat.core.rest.serializers import FileItemSerializer class FileItemViewSet(ModelViewSet): queryset = FileItem.objects.all() serializer_class = FileItemSerializer + permission_classes = [GuardianPermission] + filter_backends = [GuardianFilter] + lookup_field = 'id' diff --git a/uvdat/core/rest/map_layers.py b/uvdat/core/rest/map_layers.py index fb7e944f..39a53ce0 100644 --- a/uvdat/core/rest/map_layers.py +++ b/uvdat/core/rest/map_layers.py @@ -8,6 +8,7 @@ from rest_framework.viewsets import ModelViewSet from uvdat.core.models import RasterMapLayer, VectorMapLayer +from uvdat.core.rest.access_control import GuardianFilter, GuardianPermission from uvdat.core.rest.serializers import ( RasterMapLayerSerializer, VectorMapLayerDetailSerializer, @@ -72,6 +73,9 @@ class RasterMapLayerViewSet(ModelViewSet, LargeImageFileDetailMixin): queryset = RasterMapLayer.objects.select_related('dataset').all() serializer_class = RasterMapLayerSerializer + permission_classes = [GuardianPermission] + filter_backends = [GuardianFilter] + lookup_field = 'id' FILE_FIELD_NAME = 'cloud_optimized_geotiff' @action( @@ -89,6 +93,9 @@ def get_raster_data(self, request, resolution: str = '1', **kwargs): class VectorMapLayerViewSet(ModelViewSet): queryset = VectorMapLayer.objects.select_related('dataset').all() serializer_class = VectorMapLayerSerializer + permission_classes = [GuardianPermission] + filter_backends = [GuardianFilter] + lookup_field = 'id' def retrieve(self, request, *args, **kwargs): instance = self.get_object() @@ -101,7 +108,7 @@ def retrieve(self, request, *args, **kwargs): url_path=r'tiles/(?P\d+)/(?P\d+)/(?P\d+)', url_name='tiles', ) - def get_vector_tile(self, request, x: str, y: str, z: str, pk: str): + def get_vector_tile(self, request, id: str, x: str, y: str, z: str): with connection.cursor() as cursor: cursor.execute( VECTOR_TILE_SQL, @@ -110,7 +117,7 @@ def get_vector_tile(self, request, x: str, y: str, z: str, pk: str): 'x': x, 'y': y, 'srid': 3857, - 'map_layer_id': pk, + 'map_layer_id': id, }, ) row = cursor.fetchone() diff --git a/uvdat/core/rest/network.py b/uvdat/core/rest/network.py index 3e42cbae..a3ca45dc 100644 --- a/uvdat/core/rest/network.py +++ b/uvdat/core/rest/network.py @@ -1,6 +1,7 @@ from rest_framework.viewsets import ModelViewSet from uvdat.core.models import Network, NetworkEdge, NetworkNode +from uvdat.core.rest.access_control import GuardianFilter, GuardianPermission from uvdat.core.rest.serializers import ( NetworkEdgeSerializer, NetworkNodeSerializer, @@ -11,13 +12,22 @@ class NetworkViewSet(ModelViewSet): queryset = Network.objects.all() serializer_class = NetworkSerializer + permission_classes = [GuardianPermission] + filter_backends = [GuardianFilter] + lookup_field = 'id' class NetworkNodeViewSet(ModelViewSet): queryset = NetworkNode.objects.all() serializer_class = NetworkNodeSerializer + permission_classes = [GuardianPermission] + filter_backends = [GuardianFilter] + lookup_field = 'id' class NetworkEdgeViewSet(ModelViewSet): queryset = NetworkEdge.objects.all() serializer_class = NetworkEdgeSerializer + permission_classes = [GuardianPermission] + filter_backends = [GuardianFilter] + lookup_field = 'id' diff --git a/uvdat/core/rest/project.py b/uvdat/core/rest/project.py new file mode 100644 index 00000000..448f943c --- /dev/null +++ b/uvdat/core/rest/project.py @@ -0,0 +1,44 @@ +from django.contrib.auth.models import User +from django.http import HttpResponse +from rest_framework.decorators import action +from rest_framework.viewsets import ModelViewSet + +from uvdat.core.models import Project +from uvdat.core.rest.access_control import GuardianFilter, GuardianPermission +from uvdat.core.rest.serializers import ProjectSerializer +from uvdat.core.tasks.osmnx import load_roads + + +class ProjectViewSet(ModelViewSet): + queryset = Project.objects.all() + serializer_class = ProjectSerializer + permission_classes = [GuardianPermission] + filter_backends = [GuardianFilter] + lookup_field = 'id' + + def perform_create(self, serializer): + project: Project = serializer.save() + user: User = self.request.user + project.set_permissions(owner=user) + + @action(detail=True, methods=['get']) + def regions(self, request, **kwargs): + project = self.get_object() + regions = project.derived_regions.all() + return HttpResponse(regions, status=200) + + @action(detail=True, methods=['get']) + def simulation_results(self, request, **kwargs): + project = self.get_object() + simulation_results = project.simulation_results.all() + return HttpResponse(simulation_results, status=200) + + @action( + detail=True, + methods=['get'], + url_path=r'load_roads/(?P.+)', + ) + def load_roads(self, request, location, **kwargs): + project = self.get_object() + load_roads.delay(project.id, location) + return HttpResponse('Task spawned successfully.', status=200) diff --git a/uvdat/core/rest/regions.py b/uvdat/core/rest/regions.py index d752e8db..d3a6496c 100644 --- a/uvdat/core/rest/regions.py +++ b/uvdat/core/rest/regions.py @@ -6,6 +6,7 @@ from rest_framework.viewsets import GenericViewSet, mixins from uvdat.core.models import DerivedRegion, SourceRegion +from uvdat.core.rest.access_control import GuardianFilter, GuardianPermission from uvdat.core.tasks.regions import DerivedRegionCreationError, create_derived_region from .serializers import ( @@ -19,11 +20,25 @@ class SourceRegionViewSet(mixins.RetrieveModelMixin, mixins.ListModelMixin, GenericViewSet): queryset = SourceRegion.objects.all() serializer_class = SourceRegionSerializer + permission_classes = [GuardianPermission] + filter_backends = [GuardianFilter] + lookup_field = 'id' class DerivedRegionViewSet(mixins.RetrieveModelMixin, mixins.ListModelMixin, GenericViewSet): queryset = DerivedRegion.objects.all() serializer_class = DerivedRegionListSerializer + permission_classes = [GuardianPermission] + filter_backends = [GuardianFilter] + lookup_field = 'id' + + def get_queryset(self): + qs = super().get_queryset() + project_id: str = self.request.query_params.get('project') + if project_id is None or not project_id.isdigit(): + return qs + + return qs.filter(project=int(project_id)) def get_serializer_class(self): if self.detail: @@ -31,13 +46,6 @@ def get_serializer_class(self): return super().get_serializer_class() - def get_queryset(self): - context_id = self.request.query_params.get('context') - if context_id: - return DerivedRegion.objects.filter(context__id=context_id) - else: - return DerivedRegion.objects.all() - @action(detail=True, methods=['GET']) def as_feature(self, request, *args, **kwargs): obj: DerivedRegion = self.get_object() @@ -58,7 +66,7 @@ def create(self, request, *args, **kwargs): data = serializer.validated_data derived_region = create_derived_region( name=data['name'], - context=data['context'], + project=data['project'], region_ids=data['regions'], operation=data['operation'], ) diff --git a/uvdat/core/rest/serializers.py b/uvdat/core/rest/serializers.py index 5f4db738..9d1d0aea 100644 --- a/uvdat/core/rest/serializers.py +++ b/uvdat/core/rest/serializers.py @@ -1,17 +1,20 @@ import json +from django.contrib.auth.models import User +from django.contrib.gis.geos import Point from django.contrib.gis.serializers import geojson +from guardian.shortcuts import get_users_with_perms from rest_framework import serializers from uvdat.core.models import ( Chart, - Context, Dataset, DerivedRegion, FileItem, Network, NetworkEdge, NetworkNode, + Project, RasterMapLayer, SimulationResult, SourceRegion, @@ -19,16 +22,47 @@ ) -class ContextSerializer(serializers.ModelSerializer): +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ['id', 'username', 'email', 'first_name', 'last_name', 'is_superuser'] + + +class ProjectSerializer(serializers.ModelSerializer): default_map_center = serializers.SerializerMethodField('get_center') + owner = serializers.SerializerMethodField('get_owner') + collaborators = serializers.SerializerMethodField('get_collaborators') + followers = serializers.SerializerMethodField('get_followers') def get_center(self, obj): # Web client expects Lon, Lat if obj.default_map_center: return [obj.default_map_center.y, obj.default_map_center.x] + def get_owner(self, obj): + users = list(get_users_with_perms(obj, only_with_perms_in=['owner'])) + if len(users) != 1: + raise Exception('Project must have exactly one owner') + + return UserSerializer(users[0]).data + + def get_collaborators(self, obj): + users = get_users_with_perms(obj, only_with_perms_in=['collaborator']) + return [UserSerializer(user).data for user in users.all()] + + def get_followers(self, obj): + users = get_users_with_perms(obj, only_with_perms_in=['follower']) + return [UserSerializer(user).data for user in users.all()] + + def to_internal_value(self, data): + center = data.get('default_map_center') + data = super().to_internal_value(data) + if isinstance(center, list): + data['default_map_center'] = Point(center[1], center[0]) + return data + class Meta: - model = Context + model = Project fields = '__all__' @@ -145,7 +179,7 @@ class Meta: fields = [ 'id', 'name', - 'context', + 'project', 'metadata', 'source_regions', 'operation', @@ -173,7 +207,7 @@ class Meta: model = DerivedRegion fields = [ 'name', - 'context', + 'project', 'regions', 'operation', ] diff --git a/uvdat/core/rest/simulations.py b/uvdat/core/rest/simulations.py index 2372092e..643da989 100644 --- a/uvdat/core/rest/simulations.py +++ b/uvdat/core/rest/simulations.py @@ -5,15 +5,20 @@ from django.http import HttpResponse from rest_framework.decorators import action from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import GenericViewSet +from rest_framework.viewsets import ModelViewSet -from uvdat.core.models import Context +from uvdat.core.models import Project from uvdat.core.models.simulations import AVAILABLE_SIMULATIONS, SimulationResult +from uvdat.core.rest.access_control import ( + GuardianFilter, + GuardianPermission, + filter_queryset_by_projects, +) import uvdat.core.rest.serializers as uvdat_serializers # TODO: Refactor -def get_available_simulations(context_id: int): +def get_available_simulations(project_id: int): sims = [] for index, (name, details) in enumerate(AVAILABLE_SIMULATIONS.items()): details = details.copy() @@ -39,13 +44,16 @@ def get_available_simulations(context_id: int): option_objects = options_type.objects if options_annotations: option_objects = option_objects.annotate(**options_annotations) - options = list( + + queryset = option_objects.filter( + **options_query, + ).all() + options = [ option_serializer(d).data - for d in option_objects.filter( - **options_query, - ).all() - if d.is_in_context(context_id) - ) + for d in filter_queryset_by_projects( + queryset=queryset, projects=Project.objects.filter(id=project_id) + ) + ] args.append( { 'name': a['name'], @@ -60,16 +68,20 @@ def get_available_simulations(context_id: int): return sims -class SimulationViewSet(GenericViewSet): +class SimulationViewSet(ModelViewSet): + queryset = SimulationResult.objects.all() serializer_class = uvdat_serializers.SimulationResultSerializer + permission_classes = [GuardianPermission] + filter_backends = [GuardianFilter] + lookup_field = 'id' @action( detail=False, methods=['get'], - url_path=r'available/context/(?P[\d*]+)', + url_path=r'available/project/(?P[\d*]+)', ) - def list_available(self, request, context_id: int, **kwargs): - sims = get_available_simulations(context_id) + def list_available(self, request, project_id: int, **kwargs): + sims = get_available_simulations(project_id) return HttpResponse( json.dumps(sims), status=200, @@ -78,16 +90,16 @@ def list_available(self, request, context_id: int, **kwargs): @action( detail=False, methods=['get'], - url_path=r'(?P[\d*]+)/context/(?P[\d*]+)/results', + url_path=r'(?P[\d*]+)/project/(?P[\d*]+)/results', ) - def list_results(self, request, simulation_index: int, context_id: int, **kwargs): + def list_results(self, request, simulation_index: int, project_id: int, **kwargs): simulation_type = list(AVAILABLE_SIMULATIONS.keys())[int(simulation_index)] return HttpResponse( json.dumps( list( uvdat_serializers.SimulationResultSerializer(s).data for s in SimulationResult.objects.filter( - simulation_type=simulation_type, context__id=context_id + simulation_type=simulation_type, project__id=project_id ).all() ) ), @@ -97,16 +109,16 @@ def list_results(self, request, simulation_index: int, context_id: int, **kwargs @action( detail=False, methods=['post'], - url_path=r'run/(?P[\d*]+)/context/(?P[\d*]+)', + url_path=r'run/(?P[\d*]+)/project/(?P[\d*]+)', ) - def run(self, request, simulation_index: int, context_id: int, **kwargs): + def run(self, request, simulation_index: int, project_id: int, **kwargs): simulation_type = list(AVAILABLE_SIMULATIONS.keys())[int(simulation_index)] - context = Context.objects.get(id=context_id) + project = Project.objects.get(id=project_id) input_args = request.data sim_result = SimulationResult.objects.create( simulation_type=simulation_type, input_args=input_args, - context=context, + project=project, ) sim_result.run(**input_args) return HttpResponse( diff --git a/uvdat/core/rest/user.py b/uvdat/core/rest/user.py new file mode 100644 index 00000000..879e31cf --- /dev/null +++ b/uvdat/core/rest/user.py @@ -0,0 +1,20 @@ +import json + +from django.contrib.auth.models import User +from django.http import HttpResponse +from rest_framework.decorators import action +from rest_framework.viewsets import ReadOnlyModelViewSet + +from .serializers import UserSerializer + + +class UserViewSet(ReadOnlyModelViewSet): + queryset = User.objects.all() + serializer_class = UserSerializer + + @action(detail=False, pagination_class=None) + def me(self, request): + """Return the currently logged in user's information.""" + if request.user.is_anonymous: + return HttpResponse(status=204) + return HttpResponse(json.dumps(UserSerializer(request.user).data), status=200) diff --git a/uvdat/core/tasks/chart.py b/uvdat/core/tasks/chart.py index 62a5f511..638432a1 100644 --- a/uvdat/core/tasks/chart.py +++ b/uvdat/core/tasks/chart.py @@ -4,7 +4,7 @@ import pandas from webcolors import name_to_hex -from uvdat.core.models import Chart, Context, NetworkNode +from uvdat.core.models import Chart, NetworkNode, Project @shared_task @@ -43,7 +43,7 @@ def convert_chart(chart_id, conversion_options): print(f'\t Saved converted data for chart {chart.name}.') -def get_gcc_chart(dataset, context_id): +def get_gcc_chart(dataset, project_id): chart_name = f'{dataset.name} Greatest Connected Component Sizes' try: return Chart.objects.get(name=chart_name) @@ -55,7 +55,7 @@ def get_gcc_chart(dataset, context_id): for the network's greatest connected component (GCC), showing GCC size by number of excluded nodes """, - context=Context.objects.get(id=context_id), + project=Project.objects.get(id=project_id), editable=True, chart_data={}, metadata=[], @@ -71,8 +71,8 @@ def get_gcc_chart(dataset, context_id): return chart -def add_gcc_chart_datum(dataset, context_id, excluded_node_names, gcc_size): - chart = get_gcc_chart(dataset, context_id) +def add_gcc_chart_datum(dataset, project_id, excluded_node_names, gcc_size): + chart = get_gcc_chart(dataset, project_id) if len(chart.metadata) == 0: # no data exists, need to initialize data structures chart.metadata = [] diff --git a/uvdat/core/tasks/osmnx.py b/uvdat/core/tasks/osmnx.py index ce35daaa..02f72a9b 100644 --- a/uvdat/core/tasks/osmnx.py +++ b/uvdat/core/tasks/osmnx.py @@ -2,12 +2,12 @@ from django.contrib.gis.geos import LineString, Point import osmnx -from uvdat.core.models import Context, Dataset, Network, NetworkEdge, NetworkNode, VectorMapLayer +from uvdat.core.models import Dataset, Network, NetworkEdge, NetworkNode, Project, VectorMapLayer from uvdat.core.tasks.map_layers import save_vector_features from uvdat.core.tasks.networks import geojson_from_network -def get_or_create_road_dataset(context, location): +def get_or_create_road_dataset(project, location): dataset, created = Dataset.objects.get_or_create( name=f'{location} Road Network', description='Roads and intersections retrieved from OpenStreetMap via OSMnx', @@ -15,7 +15,7 @@ def get_or_create_road_dataset(context, location): category='transportation', ) if created: - context.datasets.add(dataset) + project.datasets.add(dataset) print('Clearing previous results...') Network.objects.filter(dataset=dataset).delete() @@ -31,9 +31,9 @@ def metadata_for_row(row): @shared_task -def load_roads(context_id, location): - context = Context.objects.get(id=context_id) - dataset = get_or_create_road_dataset(context, location) +def load_roads(project_id, location): + project = Project.objects.get(id=project_id) + dataset = get_or_create_road_dataset(project, location) network = Network.objects.create( dataset=dataset, category='roads', metadata={'source': 'Fetched with OSMnx.'} ) diff --git a/uvdat/core/tasks/regions.py b/uvdat/core/tasks/regions.py index 26031109..80b8f7c4 100644 --- a/uvdat/core/tasks/regions.py +++ b/uvdat/core/tasks/regions.py @@ -7,7 +7,8 @@ from django.db import transaction import geopandas -from uvdat.core.models import Context, DerivedRegion, SourceRegion, VectorMapLayer +from uvdat.core.models import DerivedRegion, Project, SourceRegion, VectorMapLayer +from uvdat.core.rest.access_control import filter_queryset_by_projects from uvdat.core.tasks.map_layers import save_vector_features @@ -15,16 +16,17 @@ class DerivedRegionCreationError(Exception): pass -def create_derived_region(name: str, context: Context, region_ids: List[int], operation: str): +def create_derived_region(name: str, project: Project, region_ids: List[int], operation: str): # Ensure at least two regions provided source_regions = SourceRegion.objects.filter(pk__in=region_ids) if source_regions.count() < 2: raise DerivedRegionCreationError('Derived Regions must consist of multiple regions') - # Ensure all regions are from one context - if any(not sr.is_in_context(context.id) for sr in source_regions): + # Ensure all regions are from one project + filtered = filter_queryset_by_projects(source_regions, Project.objects.filter(id=project.id)) + if filtered.count() < source_regions.count(): raise DerivedRegionCreationError( - f'Source Regions must exist in the same context with id {context.id}.' + f'Source Regions must exist in the same project with id {project.id}.' ) # Only handle union operations for now @@ -44,7 +46,7 @@ def create_derived_region(name: str, context: Context, region_ids: List[int], op # Check for duplicate derived regions existing = list( DerivedRegion.objects.filter( - context=context, boundary=GEOSGeometry(new_boundary) + project=project, boundary=GEOSGeometry(new_boundary) ).values_list('id', flat=True) ) if existing: @@ -65,7 +67,7 @@ def create_derived_region(name: str, context: Context, region_ids: List[int], op derived_region = DerivedRegion.objects.create( name=name, - context=context, + project=project, metadata={}, boundary=new_boundary, operation=operation, diff --git a/uvdat/core/tests/conftest.py b/uvdat/core/tests/conftest.py index b5fc2dea..8d92203d 100644 --- a/uvdat/core/tests/conftest.py +++ b/uvdat/core/tests/conftest.py @@ -1,9 +1,66 @@ +from django.contrib.auth.models import User +from django.contrib.gis.geos import Point import pytest from pytest_factoryboy import register from rest_framework.test import APIClient +from uvdat.core.models import Project + from .factories import UserFactory +USER_INFOS = [ + dict( + id='superuser', + username='userA', + password='testmepassA', + email='a@fakeemail.com', + is_superuser=True, + perm=None, + ), + dict( + id='owner', + username='userB', + password='testmepassB', + email='b@fakeemail.com', + is_superuser=False, + perm='owner', + ), + dict( + id='collaborator', + username='userC', + password='testmepassC', + email='c@fakeemail.com', + is_superuser=False, + perm='collaborator', + ), + dict( + id='follower', + username='userD', + password='testmepassD', + email='d@fakeemail.com', + is_superuser=False, + perm='follower', + ), + dict( + id='no_perms', + username='userE', + password='testmepassE', + email='E@fakeemail.com', + is_superuser=False, + perm=None, + ), +] + + +@pytest.fixture +def test_project() -> Project: + project = Project.objects.create( + name='Test Project', default_map_zoom=10, default_map_center=Point(42, -71) + ) + original_owner = User.objects.create(username='testowner') + project.set_permissions(owner=original_owner) + return project + @pytest.fixture def api_client() -> APIClient: @@ -11,10 +68,13 @@ def api_client() -> APIClient: @pytest.fixture -def authenticated_api_client(user) -> APIClient: +def permissions_client(user_info, test_project) -> APIClient: + user_info.pop('perm', None) + user_info.pop('id', None) + user = User.objects.create(**user_info) client = APIClient() client.force_authenticate(user=user) - return client + return (client, user) register(UserFactory) diff --git a/uvdat/core/tests/test_api.py b/uvdat/core/tests/test_api.py new file mode 100644 index 00000000..7218f0f9 --- /dev/null +++ b/uvdat/core/tests/test_api.py @@ -0,0 +1,317 @@ +from django.contrib.gis.geos import LineString, MultiPolygon, Point, Polygon +from guardian.shortcuts import get_perms +import pytest + +from uvdat.core.models import ( + Chart, + Dataset, + FileItem, + Network, + NetworkEdge, + NetworkNode, + RasterMapLayer, + SourceRegion, + VectorMapLayer, +) + +from .conftest import USER_INFOS + + +def list_endpoint(server, client, viewset_name, **kwargs): + read_allowed = kwargs.get('read_allowed', False) + api_root = f'{server.url}/api/v1' + response = client.get(f'{api_root}/{viewset_name}/', format='json') + assert response.status_code == 200 + if not read_allowed: + assert response.json().get('count') == 0 + else: + assert response.json().get('count') == 1 + + +def fetch_endpoint(server, client, viewset_name, obj_id, **kwargs): + read_allowed = kwargs.get('read_allowed', False) + api_root = f'{server.url}/api/v1' + response = client.get(f'{api_root}/{viewset_name}/{obj_id}/', format='json') + if not read_allowed: + assert response.status_code == 404 + else: + assert response.status_code == 200 + assert response.json().get('id') == obj_id + + +def create_endpoint(server, client, viewset_name, post_data, **kwargs): + if post_data is not None: + api_root = f'{server.url}/api/v1' + response = client.post(f'{api_root}/{viewset_name}/', post_data, format='json') + assert response.status_code == 201 + for key, value in post_data.items(): + assert response.json().get(key) == value + + +def overwrite_endpoint(server, client, viewset_name, obj_id, put_data, **kwargs): + if put_data is not None: + read_allowed = kwargs.get('read_allowed', False) + write_allowed = kwargs.get('write_allowed', False) + api_root = f'{server.url}/api/v1' + response = client.put(f'{api_root}/{viewset_name}/{obj_id}/', put_data, format='json') + if not read_allowed: + assert response.status_code == 404 + elif not write_allowed: + assert response.status_code == 403 + else: + assert response.status_code == 200 + for key, value in put_data.items(): + assert response.json().get(key) == value + + +def update_endpoint(server, client, viewset_name, obj_id, patch_data, **kwargs): + if patch_data is not None: + read_allowed = kwargs.get('read_allowed', False) + write_allowed = kwargs.get('write_allowed', False) + api_root = f'{server.url}/api/v1' + response = client.patch(f'{api_root}/{viewset_name}/{obj_id}/', patch_data, format='json') + if not read_allowed: + assert response.status_code == 404 + elif not write_allowed: + assert response.status_code == 403 + else: + assert response.status_code == 200 + for key, value in patch_data.items(): + assert response.json().get(key) == value + + +def delete_endpoint(server, client, viewset_name, obj_id, **kwargs): + perform_delete = kwargs.get('perform_delete', True) + read_allowed = kwargs.get('read_allowed', False) + delete_allowed = kwargs.get('write_allowed', False) + api_root = f'{server.url}/api/v1' + if perform_delete: + response = client.delete(f'{api_root}/{viewset_name}/{obj_id}/', format='json') + if not read_allowed: + assert response.status_code == 404 + elif not delete_allowed: + assert response.status_code == 403 + else: + assert response.status_code == 204 + + +def viewset_test( + live_server, + permissions_client, + user_info, + test_project, + viewset_name, + obj, + post_data=None, + put_data=None, + patch_data=None, + perform_delete=True, +): + client, user = permissions_client + perm = user_info.get('perm') + superuser = user_info.get('is_superuser') + args = [live_server, client, viewset_name] + kwargs = dict( + read_allowed=perm is not None or superuser, + write_allowed=perm in ['collaborator', 'owner'] or superuser, + delete_allowed=perm == 'owner' or superuser, + perform_delete=perform_delete, + ) + + # Update project permissions + if perm is not None: + test_project.set_permissions(**{perm: user if perm == 'owner' else [user]}) + assert get_perms(user, test_project) == [perm] + + # Test endpoints + list_endpoint(*args, **kwargs) + fetch_endpoint(*args, obj.id, **kwargs) + create_endpoint(*args, post_data, **kwargs) + overwrite_endpoint(*args, obj.id, put_data, **kwargs) + update_endpoint(*args, obj.id, patch_data, **kwargs) + delete_endpoint(*args, obj.id, **kwargs) + + +@pytest.mark.parametrize('user_info', USER_INFOS, ids=[u.get('id') for u in USER_INFOS]) +@pytest.mark.django_db +def test_project_viewset(live_server, permissions_client, user_info, test_project): + viewset_test( + live_server, + permissions_client, + user_info, + test_project, + 'projects', + test_project, + post_data=dict(name='New Project', default_map_center=[42, -71], default_map_zoom=10), + put_data=dict( + name='Overwritten Test Project', default_map_center=[42, -71], default_map_zoom=10 + ), + patch_data=dict(name='Updated Test Project'), + ) + + +@pytest.mark.parametrize('user_info', USER_INFOS, ids=[u.get('id') for u in USER_INFOS]) +@pytest.mark.django_db +def test_chart_viewset(live_server, permissions_client, user_info, test_project): + chart = Chart.objects.create(name='Test Chart', project=test_project) + viewset_test( + live_server, + permissions_client, + user_info, + test_project, + 'charts', + chart, + post_data=dict(name='New Chart'), + put_data=dict(name='Overwritten Test Chart'), + patch_data=dict(name='Updated Test Chart'), + ) + + +@pytest.mark.parametrize('user_info', USER_INFOS, ids=[u.get('id') for u in USER_INFOS]) +@pytest.mark.django_db +def test_dataset_viewset(live_server, permissions_client, user_info, test_project): + dataset = Dataset.objects.create(name='Test Dataset') + test_project.datasets.add(dataset) + viewset_test( + live_server, + permissions_client, + user_info, + test_project, + 'datasets', + dataset, + post_data=dict(name='New Dataset', dataset_type='VECTOR', category='test'), + put_data=dict(name='Overwritten Test Dataset', dataset_type='VECTOR', category='test'), + patch_data=dict(name='Updated Test Dataset'), + ) + + +@pytest.mark.parametrize('user_info', USER_INFOS, ids=[u.get('id') for u in USER_INFOS]) +@pytest.mark.django_db +def test_files_viewset(live_server, permissions_client, user_info, test_project): + dataset = Dataset.objects.create(name='Test Dataset') + test_project.datasets.add(dataset) + file_item = FileItem.objects.create(name='Test File', dataset=dataset) + viewset_test( + live_server, + permissions_client, + user_info, + test_project, + 'files', + file_item, + patch_data=dict(name='Updated Test File'), + ) + + +@pytest.mark.parametrize('user_info', USER_INFOS, ids=[u.get('id') for u in USER_INFOS]) +@pytest.mark.django_db +def test_raster_viewset(live_server, permissions_client, user_info, test_project): + dataset = Dataset.objects.create(name='Test Dataset') + test_project.datasets.add(dataset) + raster = RasterMapLayer.objects.create(dataset=dataset) + viewset_test( + live_server, + permissions_client, + user_info, + test_project, + 'rasters', + raster, + patch_data=dict(index=1), + ) + + +@pytest.mark.parametrize('user_info', USER_INFOS, ids=[u.get('id') for u in USER_INFOS]) +@pytest.mark.django_db +def test_vector_viewset(live_server, permissions_client, user_info, test_project): + dataset = Dataset.objects.create(name='Test Dataset') + test_project.datasets.add(dataset) + vector = VectorMapLayer.objects.create(dataset=dataset) + viewset_test( + live_server, + permissions_client, + user_info, + test_project, + 'vectors', + vector, + patch_data=dict(index=1), + ) + + +@pytest.mark.parametrize('user_info', USER_INFOS, ids=[u.get('id') for u in USER_INFOS]) +@pytest.mark.django_db +def test_network_viewset(live_server, permissions_client, user_info, test_project): + dataset = Dataset.objects.create(name='Test Dataset') + test_project.datasets.add(dataset) + network = Network.objects.create(dataset=dataset) + viewset_test( + live_server, + permissions_client, + user_info, + test_project, + 'networks', + network, + patch_data=dict(category='foo'), + ) + + +@pytest.mark.parametrize('user_info', USER_INFOS, ids=[u.get('id') for u in USER_INFOS]) +@pytest.mark.django_db +def test_node_viewset(live_server, permissions_client, user_info, test_project): + dataset = Dataset.objects.create(name='Test Dataset') + test_project.datasets.add(dataset) + network = Network.objects.create(dataset=dataset) + node = NetworkNode.objects.create(name='Test Node', network=network, location=Point(42, -71)) + viewset_test( + live_server, + permissions_client, + user_info, + test_project, + 'nodes', + node, + patch_data=dict(name='Updated Test Node'), + ) + + +@pytest.mark.parametrize('user_info', USER_INFOS, ids=[u.get('id') for u in USER_INFOS]) +@pytest.mark.django_db +def test_edge_viewset(live_server, permissions_client, user_info, test_project): + dataset = Dataset.objects.create(name='Test Dataset') + test_project.datasets.add(dataset) + network = Network.objects.create(dataset=dataset) + node_1 = NetworkNode.objects.create(name='Test Node', network=network, location=Point(42, -71)) + node_2 = NetworkNode.objects.create(name='Test Node', network=network, location=Point(41, -70)) + edge = NetworkEdge.objects.create( + name='Test Edge', + network=network, + line_geometry=LineString(Point(42, -71), Point(41, -70)), + from_node=node_1, + to_node=node_2, + ) + viewset_test( + live_server, + permissions_client, + user_info, + test_project, + 'edges', + edge, + patch_data=dict(name='Updated Test Edge'), + ) + + +@pytest.mark.parametrize('user_info', USER_INFOS, ids=[u.get('id') for u in USER_INFOS]) +@pytest.mark.django_db +def test_region_viewset(live_server, permissions_client, user_info, test_project): + dataset = Dataset.objects.create(name='Test Dataset') + test_project.datasets.add(dataset) + geo_points = (Point(42, -71), Point(41, -70), Point(41.5, -70.5), Point(42, -71)) + region = SourceRegion.objects.create( + name='Test Region', dataset=dataset, boundary=MultiPolygon(Polygon(geo_points)) + ) + viewset_test( + live_server, + permissions_client, + user_info, + test_project, + 'source-regions', + region, + perform_delete=False, + ) diff --git a/uvdat/core/tests/test_load_roads.py b/uvdat/core/tests/test_load_roads.py index 2fbaa6b4..0a1b9264 100644 --- a/uvdat/core/tests/test_load_roads.py +++ b/uvdat/core/tests/test_load_roads.py @@ -2,19 +2,19 @@ from django.core.management import call_command import pytest -from uvdat.core.models import Context, Dataset +from uvdat.core.models import Dataset, Project @pytest.mark.django_db def test_load_roads(): - context = Context.objects.create( + project = Project.objects.create( name='Road Test', default_map_zoom=10, default_map_center=Point(42, -71) ) call_command( 'load_roads', 'Boston', - context_id=context.id, + project_id=project.id, ) dataset = Dataset.objects.get(name='Boston Road Network') diff --git a/uvdat/core/tests/test_populate.py b/uvdat/core/tests/test_populate.py index 22021ea1..075c2f56 100644 --- a/uvdat/core/tests/test_populate.py +++ b/uvdat/core/tests/test_populate.py @@ -1,15 +1,16 @@ +from django.contrib.auth.models import User from django.core.management import call_command import pytest from uvdat.core.models import ( Chart, - Context, Dataset, DerivedRegion, FileItem, Network, NetworkEdge, NetworkNode, + Project, RasterMapLayer, SimulationResult, SourceRegion, @@ -20,6 +21,9 @@ @pytest.mark.django_db def test_populate(): + # ensure a superuser exists + User.objects.create_superuser('testsuper') + # smaller subset for faster evaluation # 0 is MBTA Rapid Transit, tests network eval # 4 is Massachusetts Elevation Data, tests raster eval @@ -35,7 +39,7 @@ def test_populate(): ) assert Chart.objects.all().count() == 1 - assert Context.objects.all().count() == 2 + assert Project.objects.all().count() == 2 assert Dataset.objects.all().count() == 4 assert DerivedRegion.objects.all().count() == 0 assert FileItem.objects.all().count() == 7 diff --git a/uvdat/settings.py b/uvdat/settings.py index 358f348c..60c38343 100644 --- a/uvdat/settings.py +++ b/uvdat/settings.py @@ -12,6 +12,7 @@ ProductionBaseConfiguration, TestingBaseConfiguration, ) +from configurations import values class UvdatMixin(ConfigMixin): @@ -20,12 +21,24 @@ class UvdatMixin(ConfigMixin): BASE_DIR = Path(__file__).resolve(strict=True).parent.parent + # Override default signup sheet to ask new users for first and last name + ACCOUNT_FORMS = {'signup': 'uvdat.core.rest.accounts.AccountSignupForm'} + + HOMEPAGE_REDIRECT_URL = values.URLValue(environ_required=True) + + # django-guardian; disable anonymous user permissions + ANONYMOUS_USER_NAME = None + + # django-guardian; raise PermissionDenied exception instead of redirecting to login page + GUARDIAN_RAISE_403 = True + @staticmethod def mutate_configuration(configuration: ComposedConfiguration) -> None: # Install local apps first, to ensure any overridden resources are found first configuration.INSTALLED_APPS = [ 'django.contrib.gis', 'django_large_image', + 'guardian', 'uvdat.core.apps.CoreConfig', ] + configuration.INSTALLED_APPS @@ -34,10 +47,13 @@ def mutate_configuration(configuration: ComposedConfiguration) -> None: 's3_file_field', ] - # Disable authentication requirements for REST - # TODO: configure authentication and remove this workaround - configuration.REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'] = [] - configuration.REST_FRAMEWORK['DEFAULT_PERMISSION_CLASSES'] = [] + configuration.AUTHENTICATION_BACKENDS += ['guardian.backends.ObjectPermissionBackend'] + configuration.REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'] += [ + 'oauth2_provider.contrib.rest_framework.OAuth2Authentication', + ] + configuration.REST_FRAMEWORK['DEFAULT_PERMISSION_CLASSES'] = [ + 'rest_framework.permissions.IsAuthenticated' + ] # Re-configure the database for PostGIS db_parts = urlparse(os.environ['DJANGO_DATABASE_URL']) diff --git a/uvdat/urls.py b/uvdat/urls.py index 06f204ea..b97b53a2 100644 --- a/uvdat/urls.py +++ b/uvdat/urls.py @@ -1,22 +1,24 @@ from django.conf import settings from django.contrib import admin from django.urls import include, path +from django.views.generic.base import RedirectView from drf_yasg import openapi from drf_yasg.views import get_schema_view from rest_framework import permissions, routers from uvdat.core.rest import ( ChartViewSet, - ContextViewSet, DatasetViewSet, DerivedRegionViewSet, FileItemViewSet, NetworkEdgeViewSet, NetworkNodeViewSet, NetworkViewSet, + ProjectViewSet, RasterMapLayerViewSet, SimulationViewSet, SourceRegionViewSet, + UserViewSet, VectorMapLayerViewSet, ) @@ -28,7 +30,8 @@ permission_classes=(permissions.AllowAny,), ) -router.register(r'contexts', ContextViewSet, basename='contexts') +router.register(r'users', UserViewSet, basename='users') +router.register(r'projects', ProjectViewSet, basename='projects') router.register(r'datasets', DatasetViewSet, basename='datasets') router.register(r'files', FileItemViewSet, basename='files') router.register(r'charts', ChartViewSet, basename='charts') @@ -49,6 +52,8 @@ path('api/v1/', include(router.urls)), path('api/docs/redoc/', schema_view.with_ui('redoc'), name='docs-redoc'), path('api/docs/swagger/', schema_view.with_ui('swagger'), name='docs-swagger'), + # Redirect all other server requests to Vue client + path('', RedirectView.as_view(url=settings.HOMEPAGE_REDIRECT_URL)), # type: ignore ] if settings.DEBUG: diff --git a/web/package-lock.json b/web/package-lock.json index 48e496f9..23a60466 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,8 +8,8 @@ "name": "uvdat", "version": "0.1.0", "dependencies": { - "@girder/oauth-client": "^0.8.0", "@mdi/font": "^7.2.96", + "@resonant/oauth-client": "^0.9.0", "@turf/turf": "^7.1.0", "axios": "^1.4.0", "buffer": "^6.0.3", @@ -1822,14 +1822,6 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/@girder/oauth-client": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@girder/oauth-client/-/oauth-client-0.8.0.tgz", - "integrity": "sha512-uOEKyerB3BArN/t4UJxA2+L35ykidk60RNN+XwFXlVUVjZMuajaLFB6/r53Tr977/k8D1RJnBjosAfgl7oLTzg==", - "dependencies": { - "@openid/appauth": "^1.3.0" - } - }, "node_modules/@hapi/hoek": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", @@ -2126,14 +2118,14 @@ } }, "node_modules/@openid/appauth": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@openid/appauth/-/appauth-1.3.1.tgz", - "integrity": "sha512-e54kpi219wES2ijPzeHe1kMnT8VKH8YeTd1GAn9BzVBmutz3tBgcG1y8a4pziNr4vNjFnuD4W446Ua7ELnNDiA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@openid/appauth/-/appauth-1.3.2.tgz", + "integrity": "sha512-NoOejniaqzOEbHg3RcBZtTriYqhqpQFgTC4lDNaRbgRCnpz6n8PlxWlCbh2N1K5qKawfxRP29/Wiho3FrXQ3Qw==", "dependencies": { - "@types/base64-js": "^1.3.0", - "@types/jquery": "^3.5.5", + "@types/base64-js": "^1.3.2", + "@types/jquery": "^3.5.29", "base64-js": "^1.5.1", - "follow-redirects": "^1.13.3", + "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "opener": "^1.5.2" } @@ -2144,6 +2136,14 @@ "integrity": "sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==", "dev": true }, + "node_modules/@resonant/oauth-client": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@resonant/oauth-client/-/oauth-client-0.9.0.tgz", + "integrity": "sha512-7WaDZeukUw11AlmMD9xJn3fuzWn6YdldfcOFL/IwNNDCbnj2l2LP+0r3Q0PfsAKSAdMiF8Eir7v9Kw3PFJ0buQ==", + "dependencies": { + "@openid/appauth": "^1.3.2" + } + }, "node_modules/@sideway/address": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz", @@ -4161,9 +4161,9 @@ } }, "node_modules/@types/base64-js": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@types/base64-js/-/base64-js-1.3.0.tgz", - "integrity": "sha512-ZmI0sZGAUNXUfMWboWwi4LcfpoVUYldyN6Oe0oJ5cCsHDU/LlRq8nQKPXhYLOx36QYSW9bNIb1vvRrD6K7Llgw==" + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@types/base64-js/-/base64-js-1.3.2.tgz", + "integrity": "sha512-Q2Xn2/vQHRGLRXhQ5+BSLwhHkR3JVflxVKywH0Q6fVoAiUE8fFYL2pE5/l2ZiOiBDfA8qUqRnSxln4G/NFz1Sg==" }, "node_modules/@types/body-parser": { "version": "1.19.2", @@ -4287,9 +4287,9 @@ } }, "node_modules/@types/jquery": { - "version": "3.5.16", - "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.16.tgz", - "integrity": "sha512-bsI7y4ZgeMkmpG9OM710RRzDFp+w4P1RGiIt30C1mSBT+ExCleeh4HObwgArnDFELmRrOpXgSYN9VF1hj+f1lw==", + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.30.tgz", + "integrity": "sha512-nbWKkkyb919DOUxjmRVk8vwtDb0/k8FKncmUKFi+NY+QXqWltooxTrswvz4LspQwxvLdvzBN1TImr6cw3aQx2A==", "dependencies": { "@types/sizzle": "*" } @@ -4404,9 +4404,9 @@ } }, "node_modules/@types/sizzle": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.3.tgz", - "integrity": "sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==" + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.8.tgz", + "integrity": "sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg==" }, "node_modules/@types/sockjs": { "version": "0.3.33", @@ -8935,9 +8935,9 @@ "dev": true }, "node_modules/follow-redirects": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", - "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", "funding": [ { "type": "individual", @@ -16457,14 +16457,6 @@ "integrity": "sha512-mjZVbpaeMZludF2fsWLD0Z9gCref1Tk4i9+wddjRvpUNqqcndPkBD09N/Mapey0b3jaXbLm2kICwFv2E64QinA==", "dev": true }, - "@girder/oauth-client": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@girder/oauth-client/-/oauth-client-0.8.0.tgz", - "integrity": "sha512-uOEKyerB3BArN/t4UJxA2+L35ykidk60RNN+XwFXlVUVjZMuajaLFB6/r53Tr977/k8D1RJnBjosAfgl7oLTzg==", - "requires": { - "@openid/appauth": "^1.3.0" - } - }, "@hapi/hoek": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", @@ -16707,14 +16699,14 @@ } }, "@openid/appauth": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@openid/appauth/-/appauth-1.3.1.tgz", - "integrity": "sha512-e54kpi219wES2ijPzeHe1kMnT8VKH8YeTd1GAn9BzVBmutz3tBgcG1y8a4pziNr4vNjFnuD4W446Ua7ELnNDiA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@openid/appauth/-/appauth-1.3.2.tgz", + "integrity": "sha512-NoOejniaqzOEbHg3RcBZtTriYqhqpQFgTC4lDNaRbgRCnpz6n8PlxWlCbh2N1K5qKawfxRP29/Wiho3FrXQ3Qw==", "requires": { - "@types/base64-js": "^1.3.0", - "@types/jquery": "^3.5.5", + "@types/base64-js": "^1.3.2", + "@types/jquery": "^3.5.29", "base64-js": "^1.5.1", - "follow-redirects": "^1.13.3", + "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "opener": "^1.5.2" } @@ -16725,6 +16717,14 @@ "integrity": "sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==", "dev": true }, + "@resonant/oauth-client": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@resonant/oauth-client/-/oauth-client-0.9.0.tgz", + "integrity": "sha512-7WaDZeukUw11AlmMD9xJn3fuzWn6YdldfcOFL/IwNNDCbnj2l2LP+0r3Q0PfsAKSAdMiF8Eir7v9Kw3PFJ0buQ==", + "requires": { + "@openid/appauth": "^1.3.2" + } + }, "@sideway/address": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz", @@ -18378,9 +18378,9 @@ } }, "@types/base64-js": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@types/base64-js/-/base64-js-1.3.0.tgz", - "integrity": "sha512-ZmI0sZGAUNXUfMWboWwi4LcfpoVUYldyN6Oe0oJ5cCsHDU/LlRq8nQKPXhYLOx36QYSW9bNIb1vvRrD6K7Llgw==" + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@types/base64-js/-/base64-js-1.3.2.tgz", + "integrity": "sha512-Q2Xn2/vQHRGLRXhQ5+BSLwhHkR3JVflxVKywH0Q6fVoAiUE8fFYL2pE5/l2ZiOiBDfA8qUqRnSxln4G/NFz1Sg==" }, "@types/body-parser": { "version": "1.19.2", @@ -18504,9 +18504,9 @@ } }, "@types/jquery": { - "version": "3.5.16", - "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.16.tgz", - "integrity": "sha512-bsI7y4ZgeMkmpG9OM710RRzDFp+w4P1RGiIt30C1mSBT+ExCleeh4HObwgArnDFELmRrOpXgSYN9VF1hj+f1lw==", + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.30.tgz", + "integrity": "sha512-nbWKkkyb919DOUxjmRVk8vwtDb0/k8FKncmUKFi+NY+QXqWltooxTrswvz4LspQwxvLdvzBN1TImr6cw3aQx2A==", "requires": { "@types/sizzle": "*" } @@ -18621,9 +18621,9 @@ } }, "@types/sizzle": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.3.tgz", - "integrity": "sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==" + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.8.tgz", + "integrity": "sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg==" }, "@types/sockjs": { "version": "0.3.33", @@ -21977,9 +21977,9 @@ "dev": true }, "follow-redirects": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", - "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==" + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==" }, "fork-ts-checker-webpack-plugin": { "version": "6.5.3", diff --git a/web/package.json b/web/package.json index f6f7ed07..2aae0298 100644 --- a/web/package.json +++ b/web/package.json @@ -9,9 +9,9 @@ "format": "vue-cli-service lint --fix" }, "dependencies": { - "@girder/oauth-client": "^0.8.0", "@mdi/font": "^7.2.96", "@turf/turf": "^7.1.0", + "@resonant/oauth-client": "^0.9.0", "axios": "^1.4.0", "buffer": "^6.0.3", "core-js": "^3.8.3", diff --git a/web/src/App.vue b/web/src/App.vue index 4990c8fd..2dd46d92 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -1,17 +1,16 @@