diff --git a/app/admin.py b/app/admin.py index ff412b5c..ef34ef52 100644 --- a/app/admin.py +++ b/app/admin.py @@ -1,5 +1,7 @@ from django.contrib import admin from guardian.admin import GuardedModelAdmin + +from app.models import Preset from .models import Project, Task, ImageUpload admin.site.register(Project, GuardedModelAdmin) @@ -12,3 +14,6 @@ admin.site.register(Task, TaskAdmin) class ImageUploadAdmin(admin.ModelAdmin): readonly_fields = ('image',) admin.site.register(ImageUpload, ImageUploadAdmin) + +admin.site.register(Preset, admin.ModelAdmin) + diff --git a/app/api/presets.py b/app/api/presets.py new file mode 100644 index 00000000..60a65cdb --- /dev/null +++ b/app/api/presets.py @@ -0,0 +1,59 @@ +from django.db import transaction +from rest_framework import permissions +from rest_framework import serializers, viewsets +from django.db.models import Q +from rest_framework import status, exceptions +from rest_framework.filters import DjangoFilterBackend, OrderingFilter +from rest_framework.response import Response + +from app.models import Preset + + +class PresetSerializer(serializers.ModelSerializer): + class Meta: + model = Preset + + exclude = ('owner', ) + read_only_fields = ('owner', 'created_at', 'system', ) + + +class PresetViewSet(viewsets.ModelViewSet): + """ + Preset get/add/delete/update + Presets represent a set of options that a user + can save/customize for use in processing a task. + """ + + pagination_class = None + serializer_class = PresetSerializer + + # We don't use object level permissions on presets + permission_classes = (permissions.DjangoModelPermissions, ) + filter_backends = (DjangoFilterBackend, OrderingFilter, ) + + def get_queryset(self): + return Preset.objects.filter(Q(owner=self.request.user.id) | Q(system=True)) + + def create(self, request): + with transaction.atomic(): + preset = Preset.objects.create(owner=self.request.user) + + # Update other parameters + serializer = PresetSerializer(preset, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def destroy(self, request, pk=None): + preset = Preset.objects.get(pk=pk) + + # Only owners can delete their own presets (except superusers) + if preset.owner != request.user and not request.user.is_superuser: + raise exceptions.NotFound() + + # Even superusers cannot delete global presets via the API (must use admin backend) + if preset.system: + raise exceptions.PermissionDenied() + + return super().destroy(request, pk) diff --git a/app/api/urls.py b/app/api/urls.py index df438e34..4b57ec80 100644 --- a/app/api/urls.py +++ b/app/api/urls.py @@ -1,4 +1,6 @@ from django.conf.urls import url, include + +from app.api.presets import PresetViewSet from .projects import ProjectViewSet from .tasks import TaskViewSet, TaskTiles, TaskTilesJson, TaskDownloads, TaskAssets from .processingnodes import ProcessingNodeViewSet, ProcessingNodeOptionsView @@ -8,6 +10,8 @@ from rest_framework_jwt.views import obtain_jwt_token router = routers.DefaultRouter() router.register(r'projects', ProjectViewSet) router.register(r'processingnodes', ProcessingNodeViewSet) +router.register(r'presets', PresetViewSet, base_name='presets') + tasks_router = routers.NestedSimpleRouter(router, r'projects', lookup='project') tasks_router.register(r'tasks', TaskViewSet, base_name='projects-tasks') diff --git a/app/boot.py b/app/boot.py index 5ee436dd..6daf412b 100644 --- a/app/boot.py +++ b/app/boot.py @@ -42,7 +42,7 @@ def boot(): # Add default permissions (view_project, change_project, delete_project, etc.) - for permission in ('_project', '_task'): + for permission in ('_project', '_task', '_preset'): default_group.permissions.add( *list(Permission.objects.filter(codename__endswith=permission)) ) diff --git a/app/migrations/0009_auto_20170721_1332.py b/app/migrations/0009_auto_20170721_1332.py new file mode 100644 index 00000000..9076765b --- /dev/null +++ b/app/migrations/0009_auto_20170721_1332.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.1 on 2017-07-21 17:32 +from __future__ import unicode_literals + +import app.models.task +import django.contrib.postgres.fields.jsonb +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('app', '0008_preset'), + ] + + operations = [ + migrations.AlterField( + model_name='preset', + name='options', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=[], help_text="Options that define this preset (same format as in a Task's options).", validators=[app.models.task.validate_task_options]), + ), + ] diff --git a/app/models/preset.py b/app/models/preset.py index 2a9d4358..170f6e70 100644 --- a/app/models/preset.py +++ b/app/models/preset.py @@ -11,14 +11,11 @@ logger = logging.getLogger('app.logger') class Preset(models.Model): owner = models.ForeignKey(User, on_delete=models.CASCADE, help_text="The person who owns this preset") - name = models.CharField(max_length=255, help_text="A label used to describe the preset") - options = JSONField(default=dict(), blank=True, help_text="Options that define this preset (same format as in a Task's options).", + name = models.CharField(max_length=255, blank=False, null=False, help_text="A label used to describe the preset") + options = JSONField(default=list(), blank=True, help_text="Options that define this preset (same format as in a Task's options).", validators=[validate_task_options]) created_at = models.DateTimeField(default=timezone.now, help_text="Creation date") system = models.BooleanField(db_index=True, default=False, help_text="Whether this preset is available to every user in the system or just to its owner.") def __str__(self): return self.name - - - diff --git a/app/static/app/js/components/EditTaskForm.jsx b/app/static/app/js/components/EditTaskForm.jsx index 6677e9fd..727b2092 100644 --- a/app/static/app/js/components/EditTaskForm.jsx +++ b/app/static/app/js/components/EditTaskForm.jsx @@ -34,7 +34,9 @@ class EditTaskForm extends React.Component { advancedOptions: props.task !== null ? props.task.options.length > 0 : false, loadedProcessingNodes: false, selectedNode: null, - processingNodes: [] + processingNodes: [], + selectedPreset: {key: 1, label: "TODO REMOVE"}, + presets: [] }; // Refs to ProcessingNodeOption components @@ -261,10 +263,11 @@ class EditTaskForm extends React.Component {
-
- - -
+
@@ -278,6 +281,10 @@ class EditTaskForm extends React.Component {
); + /*
+ + +
*/ }else{ processingNodesOptions = (
Loading processing nodes...
diff --git a/app/tests/test_api_preset.py b/app/tests/test_api_preset.py new file mode 100644 index 00000000..509080c0 --- /dev/null +++ b/app/tests/test_api_preset.py @@ -0,0 +1,134 @@ +import logging + +import json +from django.contrib.auth.models import User +from rest_framework import status +from rest_framework.test import APIClient + +from app.models import Preset +from app.tests.classes import BootTestCase + +logger = logging.getLogger('app.logger') + +class TestApiPreset(BootTestCase): + def setUp(self): + super().setUp() + + superuser = User.objects.get(username='testsuperuser') + + Preset.objects.create(owner=superuser, name='Global Preset #1', system=True, options=[{'test': True}]) + Preset.objects.create(owner=superuser, name='Global Preset #2', system=True, options=[{'test2': True}]) + Preset.objects.create(owner=superuser, name='Local Preset #1', system=False, options=[{'test3': True}]) + + def test_preset(self): + client = APIClient() + + # Cannot list presets without authentication + res = client.get("/api/presets/") + self.assertTrue(res.status_code == status.HTTP_403_FORBIDDEN) + + # Cannot create presets without authentication + res = client.post("/api/presets/", { + 'name': 'test', + }) + self.assertTrue(res.status_code == status.HTTP_403_FORBIDDEN) + + user = User.objects.get(username="testuser") + self.assertFalse(user.is_superuser) + + other_user = User.objects.get(username="testuser2") + + client.login(username="testuser", password="test1234") + + # Create local preset + Preset.objects.create(owner=user, name='My Local Preset') + + # Can list presets + res = client.get("/api/presets/") + self.assertTrue(res.status_code == status.HTTP_200_OK) + + # Only ours and global presets are available + self.assertTrue(len(res.data) == 3) + self.assertTrue('My Local Preset' in [preset['name'] for preset in res.data]) + self.assertTrue('Global Preset #1' in [preset['name'] for preset in res.data]) + self.assertTrue('Global Preset #2' in [preset['name'] for preset in res.data]) + self.assertFalse('Local Preset #1' in [preset['name'] for preset in res.data]) + + # Owner field does not exist + self.assertFalse('owner' in res.data[0]) + + # Can create preset when authenticated + res = client.post("/api/presets/", { + 'name': 'test', + 'system': True + }) + self.assertTrue(res.status_code == status.HTTP_201_CREATED) + + # Result is not a system preset even though we tried to set it as such + self.assertFalse(res.data['system']) + + # Cannot create a preset and set it as somebody else's preset + res = client.post("/api/presets/", { + 'name': 'test', + 'owner': other_user.id + }) + self.assertTrue(res.status_code == status.HTTP_201_CREATED) + + preset = Preset.objects.get(pk=res.data['id']) + + # Still ours + self.assertTrue(preset.owner == user) + + # Cannot update one of our existing preset with a different user, or set it to system + res = client.patch("/api/presets/{}/".format(preset.id),{ + 'owner': other_user.id, + 'system': True + }) + self.assertTrue(res.status_code == status.HTTP_200_OK) + self.assertFalse(res.data['system']) + preset.refresh_from_db() + self.assertTrue(preset.owner == user) + + # Can update name and options fields + res = client.patch("/api/presets/{}/".format(preset.id), { + 'name': 'changed', + 'options': json.dumps([{'name': 'optname', 'value': 'optvalue'}]) + }) + self.assertTrue(res.status_code == status.HTTP_200_OK) + self.assertTrue(res.data['name'] == 'changed') + self.assertTrue('name' in res.data['options'][0]) + + # Cannot set an invalid options value + res = client.patch("/api/presets/{}/".format(preset.id), { + 'options': json.dumps([{'invalid': 'value'}]) + }) + self.assertTrue(res.status_code == status.HTTP_400_BAD_REQUEST) + + # Can delete our own preset + res = client.delete("/api/presets/{}/".format(preset.id)) + self.assertTrue(res.status_code == status.HTTP_204_NO_CONTENT) + self.assertFalse(Preset.objects.filter(pk=preset.id).exists()) + + # Cannot delete somebody else's preset + other_preset = Preset.objects.get(name="Local Preset #1") + self.assertTrue(other_preset.owner != user) + res = client.delete("/api/presets/{}/".format(other_preset.id)) + self.assertTrue(res.status_code == status.HTTP_404_NOT_FOUND) + + # Cannot update somebody else's preset + res = client.patch("/api/presets/{}/".format(other_preset.id), { + 'name': 'test' + }) + self.assertTrue(res.status_code == status.HTTP_404_NOT_FOUND) + + # Cannot delete a system preset (even as a superuser) + system_preset = Preset.objects.get(name="Global Preset #1") + res = client.delete("/api/presets/{}/".format(system_preset.id)) + self.assertTrue(res.status_code == status.HTTP_404_NOT_FOUND) + + client.login(username="testsuperuser", password="test1234") + res = client.delete("/api/presets/{}/".format(system_preset.id)) + self.assertTrue(res.status_code == status.HTTP_403_FORBIDDEN) + + +