diff --git a/src/openforms/forms/api/permissions.py b/src/openforms/forms/api/permissions.py new file mode 100644 index 0000000000..523e876768 --- /dev/null +++ b/src/openforms/forms/api/permissions.py @@ -0,0 +1,12 @@ +from rest_framework.permissions import SAFE_METHODS, BasePermission + + +class IsStaffOrReadOnly(BasePermission): + """ + The request is a staff user, or is a read-only request. + """ + + def has_permission(self, request, view): + return request.method in SAFE_METHODS or ( + request.user and request.user.is_staff + ) diff --git a/src/openforms/forms/api/serializers.py b/src/openforms/forms/api/serializers.py index 5e590197a9..0e2c163e05 100644 --- a/src/openforms/forms/api/serializers.py +++ b/src/openforms/forms/api/serializers.py @@ -1,4 +1,4 @@ -import json +from django.shortcuts import get_object_or_404 from rest_framework import serializers from rest_framework_nested.relations import NestedHyperlinkedRelatedField @@ -83,9 +83,11 @@ class Meta: } -class FormStepSerializer(serializers.ModelSerializer): - index = serializers.IntegerField(source="order") - configuration = serializers.JSONField(source="form_definition.configuration") +class FormStepSerializer(serializers.HyperlinkedModelSerializer): + index = serializers.IntegerField(source="order", read_only=True) + configuration = serializers.JSONField( + source="form_definition.configuration", read_only=True + ) parent_lookup_kwargs = { "form_uuid": "form__uuid", @@ -93,4 +95,17 @@ class FormStepSerializer(serializers.ModelSerializer): class Meta: model = FormStep - fields = ("index", "configuration") + fields = ("index", "configuration", "form_definition") + + extra_kwargs = { + "form_definition": { + "view_name": "api:formdefinition-detail", + "lookup_field": "uuid", + }, + } + + def create(self, validated_data): + validated_data["form"] = get_object_or_404( + Form, uuid=self.context["view"].kwargs["form_uuid"] + ) + return super().create(validated_data) diff --git a/src/openforms/forms/api/viewsets.py b/src/openforms/forms/api/viewsets.py index 0298011550..84863373ac 100644 --- a/src/openforms/forms/api/viewsets.py +++ b/src/openforms/forms/api/viewsets.py @@ -2,7 +2,7 @@ from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view -from rest_framework import permissions, status, viewsets +from rest_framework import mixins, permissions, status, viewsets from rest_framework.decorators import action from rest_framework.request import Request from rest_framework.response import Response @@ -10,6 +10,7 @@ from openforms.api.pagination import PageNumberPagination +from ..api.permissions import IsStaffOrReadOnly from ..api.serializers import ( FormDefinitionSerializer, FormSerializer, @@ -18,13 +19,6 @@ from ..models import Form, FormDefinition, FormStep -class BaseFormsViewSet(viewsets.ReadOnlyModelViewSet): - lookup_field = "uuid" - # anonymous clients must be able to get the form definitions in the browser - # The DRF settings apply some default throttling to mitigate abuse - permission_classes = [permissions.AllowAny] - - @extend_schema( parameters=[ OpenApiParameter("form_uuid", OpenApiTypes.UUID, location=OpenApiParameter.PATH) @@ -33,10 +27,19 @@ class BaseFormsViewSet(viewsets.ReadOnlyModelViewSet): @extend_schema_view( list=extend_schema(summary=_("List form steps")), retrieve=extend_schema(summary=_("Retrieve form step details")), + create=extend_schema(summary=_("Create a form step")), + update=extend_schema(summary=_("Update all details of a form step")), + partial_update=extend_schema(summary=_("Update some details of a form step")), + destroy=extend_schema(summary=_("Delete a form step")), ) -class FormStepViewSet(NestedViewSetMixin, BaseFormsViewSet): +class FormStepViewSet( + NestedViewSetMixin, + viewsets.ModelViewSet, +): serializer_class = FormStepSerializer queryset = FormStep.objects.all() + permission_classes = [IsStaffOrReadOnly] + lookup_field = "uuid" @extend_schema_view( @@ -49,10 +52,14 @@ class FormStepViewSet(NestedViewSetMixin, BaseFormsViewSet): tags=["forms"], ), ) -class FormDefinitionViewSet(BaseFormsViewSet): +class FormDefinitionViewSet(viewsets.ReadOnlyModelViewSet): queryset = FormDefinition.objects.order_by("slug") serializer_class = FormDefinitionSerializer pagination_class = PageNumberPagination + lookup_field = "uuid" + # anonymous clients must be able to get the form definitions in the browser + # The DRF settings apply some default throttling to mitigate abuse + permission_classes = [permissions.AllowAny] def get_serializer_context(self) -> dict: context = super().get_serializer_context() @@ -79,7 +86,14 @@ def configuration(self, request: Request, *args, **kwargs): @extend_schema_view( list=extend_schema(summary=_("List forms")), retrieve=extend_schema(summary=_("Retrieve form details")), + create=extend_schema(summary=_("Create form")), + update=extend_schema(summary=_("Update all details of a form")), + partial_update=extend_schema(summary=_("Update given details of a form")), ) -class FormViewSet(BaseFormsViewSet): +class FormViewSet( + mixins.CreateModelMixin, mixins.UpdateModelMixin, viewsets.ReadOnlyModelViewSet +): queryset = Form.objects.filter(active=True) + lookup_field = "uuid" serializer_class = FormSerializer + permission_classes = [IsStaffOrReadOnly] diff --git a/src/openforms/forms/tests/test_api.py b/src/openforms/forms/tests/test_api.py index 6e7d874757..868d348bb3 100644 --- a/src/openforms/forms/tests/test_api.py +++ b/src/openforms/forms/tests/test_api.py @@ -1,3 +1,4 @@ +import uuid from unittest import expectedFailure from django.contrib.auth import get_user_model @@ -7,6 +8,7 @@ from rest_framework import status from rest_framework.test import APITestCase +from ..models import Form, FormStep from .factories import FormDefinitionFactory, FormFactory, FormStepFactory @@ -14,13 +16,13 @@ class FormsAPITests(APITestCase): def setUp(self): # TODO: Replace with API-token User = get_user_model() - user = User.objects.create_user( + self.user = User.objects.create_user( username="john", password="secret", email="john@example.com" ) # TODO: Axes requires HttpRequest, should we have that in the API at all? assert self.client.login( - request=HttpRequest(), username=user.username, password="secret" + request=HttpRequest(), username=self.user.username, password="secret" ) @expectedFailure @@ -42,15 +44,445 @@ def test_list(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.json()), 2) - def test_steps_list(self): - step = FormStepFactory.create() + def test_create_form_successful(self): + self.user.is_staff = True + self.user.save() + url = reverse("api:form-list") + data = { + "name": "Test Post Form", + "slug": "test-post-form", + } + response = self.client.post(url, data=data) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Form.objects.count(), 1) + form = Form.objects.get() + self.assertEqual(form.name, "Test Post Form") + self.assertEqual(form.slug, "test-post-form") + + def test_create_form_unsuccessful_with_bad_data(self): + self.user.is_staff = True + self.user.save() + url = reverse("api:form-list") + data = { + "bad": "data", + } + response = self.client.post(url, data=data) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(Form.objects.exists()) + self.assertEqual( + response.json(), + {"name": ["Dit veld is vereist."], "slug": ["Dit veld is vereist."]}, + ) + + def test_create_form_unsuccessful_without_authorization(self): + url = reverse("api:form-list") + data = { + "name": "Test Post Form", + "slug": "test-post-form", + } + response = self.client.post(url, data=data) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertFalse(Form.objects.exists()) + + def test_partial_update_of_form(self): + form = FormFactory.create() + self.user.is_staff = True + self.user.save() + + url = reverse("api:form-detail", kwargs={"uuid": form.uuid}) + data = { + "name": "Test Patch Form", + } + response = self.client.patch(url, data=data) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + form.refresh_from_db() + self.assertEqual(form.name, "Test Patch Form") + + def test_partial_update_of_form_unsuccessful_without_authorization(self): + form = FormFactory.create() + url = reverse("api:form-detail", kwargs={"uuid": form.uuid}) + data = { + "name": "Test Patch Form", + } + response = self.client.patch(url, data=data) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + form.refresh_from_db() + self.assertNotEqual(form.name, "Test Patch Form") + + def test_partial_update_of_form_unsuccessful_when_form_cannot_be_found(self): + self.user.is_staff = True + self.user.save() + + url = reverse("api:form-detail", kwargs={"uuid": uuid.uuid4()}) + data = { + "name": "Test Patch Form", + } + response = self.client.patch(url, data=data) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_complete_update_of_form_successful(self): + form = FormFactory.create() + self.user.is_staff = True + self.user.save() + + url = reverse("api:form-detail", kwargs={"uuid": form.uuid}) + data = { + "name": "Test Put Form", + "slug": "test-put-form", + } + response = self.client.put(url, data=data) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + form.refresh_from_db() + self.assertEqual(form.name, "Test Put Form") + self.assertEqual(form.slug, "test-put-form") + + def test_complete_update_of_form_with_incomplete_data_unsuccessful(self): + form = FormFactory.create() + self.user.is_staff = True + self.user.save() + + url = reverse("api:form-detail", kwargs={"uuid": form.uuid}) + data = { + "name": "Test Put Form", + } + response = self.client.put(url, data=data) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.json(), {"slug": ["Dit veld is vereist."]}) + + def test_complete_update_of_form_unsuccessful_without_authorization(self): + form = FormFactory.create() + url = reverse("api:form-detail", kwargs={"uuid": form.uuid}) + data = { + "name": "Test Post Form", + "slug": "test-post-form", + } + response = self.client.put(url, data=data) - url = reverse("api:form-steps-list", args=(step.form.uuid,)) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + form.refresh_from_db() + self.assertNotEqual(form.name, "Test Put Form") + self.assertNotEqual(form.slug, "test-put-form") + + def test_complete_update_of_form_unsuccessful_with_bad_data(self): + form = FormFactory.create() + self.user.is_staff = True + self.user.save() + url = reverse("api:form-detail", kwargs={"uuid": form.uuid}) + data = { + "bad": "data", + } + response = self.client.put(url, data=data) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + {"name": ["Dit veld is vereist."], "slug": ["Dit veld is vereist."]}, + ) + + def test_complete_update_of_form_when_form_cannot_be_found(self): + self.user.is_staff = True + self.user.save() + + url = reverse("api:form-detail", kwargs={"uuid": uuid.uuid4()}) + data = { + "name": "Test Post Form", + "slug": "test-post-form", + } + response = self.client.put(url, data=data) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + +class FormsStepsAPITests(APITestCase): + def setUp(self): + # TODO: Replace with API-token + User = get_user_model() + self.user = User.objects.create_user( + username="john", password="secret", email="john@example.com" + ) + self.step = FormStepFactory.create() + self.other_form_definition = FormDefinitionFactory.create() + + # TODO: Axes requires HttpRequest, should we have that in the API at all? + assert self.client.login( + request=HttpRequest(), username=self.user.username, password="secret" + ) + + def test_steps_list(self): + url = reverse("api:form-steps-list", kwargs={"form_uuid": self.step.form.uuid}) response = self.client.get(url, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.json()), 1) + def test_create_form_step_successful(self): + self.user.is_staff = True + self.user.save() + url = reverse("api:form-steps-list", kwargs={"form_uuid": self.step.form.uuid}) + form_detail_url = reverse( + "api:formdefinition-detail", + kwargs={"uuid": self.other_form_definition.uuid}, + ) + data = {"formDefinition": f"http://testserver{form_detail_url}"} + response = self.client.post(url, data=data) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual( + FormStep.objects.filter(form_definition=self.other_form_definition).count(), + 1, + ) + + def test_create_form_step_unsuccessful_with_bad_data(self): + self.user.is_staff = True + self.user.save() + url = reverse("api:form-steps-list", kwargs={"form_uuid": self.step.form.uuid}) + data = { + "bad": "data", + } + response = self.client.post(url, data=data) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(FormStep.objects.count(), 1) + self.assertEqual(response.json(), {"formDefinition": ["Dit veld is vereist."]}) + + def test_create_form_step_unsuccessful_when_form_is_not_found(self): + self.user.is_staff = True + self.user.save() + url = reverse("api:form-steps-list", kwargs={"form_uuid": uuid.uuid4()}) + form_detail_url = reverse( + "api:formdefinition-detail", kwargs={"uuid": self.step.form_definition.uuid} + ) + data = {"formDefinition": f"http://testserver{form_detail_url}"} + response = self.client.post(url, data=data) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(FormStep.objects.count(), 1) + + def test_create_form_step_unsuccessful_without_authorization(self): + url = reverse("api:form-steps-list", kwargs={"form_uuid": self.step.form.uuid}) + form_detail_url = reverse( + "api:formdefinition-detail", kwargs={"uuid": self.step.form_definition.uuid} + ) + data = {"formDefinition": f"http://testserver{form_detail_url}"} + response = self.client.post(url, data=data) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(FormStep.objects.count(), 1) + + def test_complete_form_step_update_successful(self): + self.user.is_staff = True + self.user.save() + url = reverse( + "api:form-steps-detail", + kwargs={"form_uuid": self.step.form.uuid, "uuid": self.step.uuid}, + ) + form_detail_url = reverse( + "api:formdefinition-detail", + kwargs={"uuid": self.other_form_definition.uuid}, + ) + data = {"formDefinition": f"http://testserver{form_detail_url}"} + response = self.client.put(url, data=data) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + FormStep.objects.filter(form_definition=self.other_form_definition).count(), + 1, + ) + + def test_complete_form_step_update_unsuccessful_when_form_step_not_found(self): + self.user.is_staff = True + self.user.save() + url = reverse( + "api:form-steps-detail", + kwargs={"form_uuid": self.step.form.uuid, "uuid": uuid.uuid4()}, + ) + form_detail_url = reverse( + "api:formdefinition-detail", + kwargs={"uuid": self.other_form_definition.uuid}, + ) + data = {"formDefinition": f"http://testserver{form_detail_url}"} + response = self.client.put(url, data=data) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertFalse( + FormStep.objects.filter(form_definition=self.other_form_definition).exists() + ) + + def test_complete_form_step_update_unsuccessful_with_non_existant_form_definition( + self, + ): + self.user.is_staff = True + self.user.save() + url = reverse( + "api:form-steps-detail", + kwargs={"form_uuid": self.step.form.uuid, "uuid": self.step.uuid}, + ) + form_detail_url = reverse( + "api:formdefinition-detail", + kwargs={"uuid": uuid.uuid4()}, + ) + data = {"formDefinition": f"http://testserver{form_detail_url}"} + response = self.client.put(url, data=data) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse( + FormStep.objects.filter(form_definition=self.other_form_definition).exists() + ) + self.assertEqual( + response.json(), + {"formDefinition": ["Ongeldige hyperlink - Object bestaat niet."]}, + ) + + def test_complete_form_step_update_unsuccessful_with_bad_data(self): + self.user.is_staff = True + self.user.save() + url = reverse( + "api:form-steps-detail", + kwargs={"form_uuid": self.step.form.uuid, "uuid": self.step.uuid}, + ) + data = { + "bad": "data", + } + response = self.client.put(url, data=data) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(FormStep.objects.count(), 1) + self.assertEqual(response.json(), {"formDefinition": ["Dit veld is vereist."]}) + + def test_complete_form_step_update_unsuccessful_without_authorization(self): + url = reverse( + "api:form-steps-detail", + kwargs={"form_uuid": self.step.form.uuid, "uuid": self.step.uuid}, + ) + form_detail_url = reverse( + "api:formdefinition-detail", kwargs={"uuid": self.step.form_definition.uuid} + ) + data = {"formDefinition": f"http://testserver{form_detail_url}"} + response = self.client.put(url, data=data) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertFalse( + FormStep.objects.filter(form_definition=self.other_form_definition).exists() + ) + + def test_partial_form_step_update_successful(self): + self.user.is_staff = True + self.user.save() + url = reverse( + "api:form-steps-detail", + kwargs={"form_uuid": self.step.form.uuid, "uuid": self.step.uuid}, + ) + form_detail_url = reverse( + "api:formdefinition-detail", + kwargs={"uuid": self.other_form_definition.uuid}, + ) + data = {"formDefinition": f"http://testserver{form_detail_url}"} + response = self.client.patch(url, data=data) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + FormStep.objects.filter(form_definition=self.other_form_definition).count(), + 1, + ) + + def test_partial_form_step_update_unsuccessful_when_form_step_not_found(self): + self.user.is_staff = True + self.user.save() + url = reverse( + "api:form-steps-detail", + kwargs={"form_uuid": self.step.form.uuid, "uuid": uuid.uuid4()}, + ) + form_detail_url = reverse( + "api:formdefinition-detail", kwargs={"uuid": uuid.uuid4()} + ) + data = {"formDefinition": f"http://testserver{form_detail_url}"} + response = self.client.patch(url, data=data) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertFalse( + FormStep.objects.filter(form_definition=self.other_form_definition).exists() + ) + + def test_partial_form_step_update_unsuccessful_when_form_definition_not_found(self): + self.user.is_staff = True + self.user.save() + url = reverse( + "api:form-steps-detail", + kwargs={"form_uuid": self.step.form.uuid, "uuid": self.step.uuid}, + ) + form_detail_url = reverse( + "api:formdefinition-detail", kwargs={"uuid": uuid.uuid4()} + ) + data = {"formDefinition": f"http://testserver{form_detail_url}"} + response = self.client.patch(url, data=data) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse( + FormStep.objects.filter(form_definition=self.other_form_definition).exists() + ) + self.assertEqual( + response.json(), + {"formDefinition": ["Ongeldige hyperlink - Object bestaat niet."]}, + ) + + def test_partial_form_step_update_unsuccessful_without_authorization(self): + url = reverse( + "api:form-steps-detail", + kwargs={"form_uuid": self.step.form.uuid, "uuid": self.step.uuid}, + ) + form_detail_url = reverse( + "api:formdefinition-detail", kwargs={"uuid": self.step.form_definition.uuid} + ) + data = {"formDefinition": f"http://testserver{form_detail_url}"} + response = self.client.patch(url, data=data) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertFalse( + FormStep.objects.filter(form_definition=self.other_form_definition).exists() + ) + + def test_delete_form_step_successful(self): + self.user.is_staff = True + self.user.save() + url = reverse( + "api:form-steps-detail", + kwargs={"form_uuid": self.step.form.uuid, "uuid": self.step.uuid}, + ) + response = self.client.delete(url) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(FormStep.objects.exists()) + + def test_delete_form_step_unsuccessful_when_form_not_found(self): + self.user.is_staff = True + self.user.save() + url = reverse( + "api:form-steps-detail", + kwargs={"form_uuid": self.step.form.uuid, "uuid": uuid.uuid4()}, + ) + response = self.client.delete(url) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(FormStep.objects.count(), 1) + + def test_delete_form_step_unsuccessful_when_not_authorized(self): + url = reverse( + "api:form-steps-detail", + kwargs={"form_uuid": self.step.form.uuid, "uuid": self.step.uuid}, + ) + response = self.client.delete(url) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(FormStep.objects.count(), 1) + class FormDefinitionsAPITests(APITestCase): def setUp(self):