diff --git a/greg/api/filters.py b/greg/api/filters.py index ef45de2a5eab93a75cabb53a2dfb046d827bd78a..b8384d7960ddb9398d564830e4b7d9b7e9e443e4 100644 --- a/greg/api/filters.py +++ b/greg/api/filters.py @@ -11,6 +11,7 @@ from greg.models import ( Person, Role, Identity, + Consent, ) @@ -60,3 +61,9 @@ class IdentityFilter(FilterSet): class Meta: model = Identity fields = ["type", "verified_by_id"] + + +class PersonConsentFilter(FilterSet): + class Meta: + model = Consent + fields = ["id"] diff --git a/greg/api/urls.py b/greg/api/urls.py index 9f0fb910eddf6ee1e7db3602fbd0dd5ae45f38dd..55fc742f2bc2ff41fa11d87f4f4251ecf58faee9 100644 --- a/greg/api/urls.py +++ b/greg/api/urls.py @@ -9,6 +9,7 @@ from greg.api.views.person import ( RoleViewSet, PersonViewSet, IdentityViewSet, + ConsentViewSet, ) from greg.api.views.role_type import RoleTypeViewSet from greg.api.views.sponsor import ( @@ -51,6 +52,18 @@ urlpatterns += [ ), name="person_identity-detail", ), + re_path( + r"^persons/(?P<person_id>[0-9]+)/consents$", + ConsentViewSet.as_view({"get": "list", "post": "create"}), + name="person_consent-list", + ), + re_path( + r"^persons/(?P<person_id>[0-9]+)/consents/(?P<id>[0-9]+)$", + ConsentViewSet.as_view( + {"get": "retrieve", "delete": "destroy", "patch": "partial_update"} + ), + name="person_consent-detail", + ), re_path( r"^sponsors/(?P<sponsor_id>[0-9]+)/guests$", SponsorGuestsViewSet.as_view({"get": "list"}), diff --git a/greg/api/views/person.py b/greg/api/views/person.py index 8d0000e167d217d934f6c70e09f0c9e006066281..12cff657d6719d5e77520ea7a541190d1ab87262 100644 --- a/greg/api/views/person.py +++ b/greg/api/views/person.py @@ -1,17 +1,24 @@ from django.core.exceptions import ValidationError from django_filters import rest_framework as filters from drf_spectacular.utils import extend_schema, OpenApiParameter +from rest_framework import serializers from rest_framework import viewsets, status, permissions from rest_framework.response import Response -from greg.api.filters import PersonFilter, RoleFilter, IdentityFilter +from greg.api.filters import ( + PersonFilter, + RoleFilter, + IdentityFilter, + PersonConsentFilter, +) from greg.api.pagination import PrimaryKeyCursorPagination +from greg.api.serializers.consent import ConsentSerializerBrief from greg.api.serializers.person import ( PersonSerializer, IdentitySerializer, ) from greg.api.serializers.role import RoleSerializer, RoleWriteSerializer -from greg.models import Person, Role, Identity +from greg.models import Person, Role, Identity, Consent, ConsentChoice, ConsentType class PersonViewSet(viewsets.ModelViewSet): @@ -129,3 +136,106 @@ class IdentityViewSet(viewsets.ModelViewSet): return Response( serializer.data, status=status.HTTP_201_CREATED, headers=headers ) + + +class ConsentViewSet(viewsets.ModelViewSet): + """ + Person consent API + """ + + queryset = Consent.objects.all().order_by("id") + pagination_class = PrimaryKeyCursorPagination + permission_classes = (permissions.IsAdminUser,) + filter_backends = (filters.DjangoFilterBackend,) + filterset_class = PersonConsentFilter + lookup_field = "id" + + def get_serializer_class(self): + """ + Fetch different serializer depending on http method. + + """ + if self.request.method in ("POST", "PATCH"): + return PersonConsentSerializer + + # Use the same format as in the person endpoint when using a get + return ConsentSerializerBrief + + def get_queryset(self): + qs = self.queryset + if not self.kwargs: + return qs.none() + + # The ID of the person consent mapping is actually unique globally, + # so the person ID is not really necessary, but we have the convention in + # the API that the person ID should be specified as well + person_id = self.kwargs["person_id"] + + consent_id = self.kwargs.get("id") + qs = qs.filter(person_id=person_id) + if consent_id: + qs = qs.filter(id=consent_id) + return qs + + def create(self, request, *args, **kwargs): + try: + serializer = self.get_serializer(data=self._transform_input(request)) + except (ValidationError, ConsentChoice.DoesNotExist): + return Response(status=status.HTTP_400_BAD_REQUEST) + + serializer.is_valid(raise_exception=True) + + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response( + serializer.data, status=status.HTTP_201_CREATED, headers=headers + ) + + def update(self, request, *args, **kwargs): + try: + transformed_input = self._transform_input(request) + except (ValidationError, ConsentChoice.DoesNotExist): + return Response(status=status.HTTP_400_BAD_REQUEST) + + partial = kwargs.pop("partial", False) + instance = self.get_object() + serializer = self.get_serializer( + instance, data=transformed_input, partial=partial + ) + serializer.is_valid(raise_exception=True) + self.perform_update(serializer) + + return Response(serializer.data) + + def _transform_input(self, request): + # Transform and augment in the request so that the serializer can handle it + + # Want to get the person id which is part of the API path and then + # include this with the data used to create the identity for the person + person_id = self.kwargs["person_id"] + + if person_id is None: + # Should not happen, the person ID is part of the API path + raise ValidationError("No person id") + + input_data = request.data.copy() + input_data["person"] = int(person_id) + + consent_type = ConsentType.objects.get(identifier=input_data["type"]) + consent_choice = input_data["choice"] + + choice = ConsentChoice.objects.get( + consent_type=consent_type, value=consent_choice + ) + + input_data["type"] = consent_type.id + input_data["choice"] = choice.id + + return input_data + + +class PersonConsentSerializer(serializers.ModelSerializer): + # Not much logic defined here, the validation is done implicitly in the view + class Meta: + model = Consent + fields = ["person", "type", "choice"] diff --git a/greg/tests/api/test_person.py b/greg/tests/api/test_person.py index 9be933358048289b9f7ee98d3856c417be9ec669..8787731449a157f73a4a4ff1654aa6015e3b1479 100644 --- a/greg/tests/api/test_person.py +++ b/greg/tests/api/test_person.py @@ -498,3 +498,99 @@ def test_filter_active_value_false( response = client.get(url, {"active": False}) results = response.json()["results"] assert len(results) == 1 + + +@pytest.mark.django_db +def test_person_consents_get( + client: APIClient, person: Person, consent_fixture_choice_yes: Consent +): + url = reverse("v1:person_consent-list", kwargs={"person_id": person.id}) + consents_for_person = client.get(url).json()["results"] + + assert consents_for_person[0]["type"]["identifier"] == "foo" + assert not consents_for_person[0]["type"]["mandatory"] + assert consents_for_person[0]["choice"] == "yes" + + +@pytest.mark.django_db +def test_person_consent_add( + client: APIClient, person_foo: Person, consent_fixture: Consent +): + url = reverse("v1:person_consent-list", kwargs={"person_id": person_foo.id}) + consents_for_person = client.get(url).json()["results"] + + assert len(consents_for_person) == 0 + + consent_data = {"type": "foo", "choice": "yes"} + response = client.post(url, consent_data) + + assert response.status_code == status.HTTP_201_CREATED + + consents_for_person = client.get(url).json()["results"] + + assert consents_for_person[0]["type"]["identifier"] == "foo" + assert not consents_for_person[0]["type"]["mandatory"] + assert consents_for_person[0]["choice"] == "yes" + + +@pytest.mark.django_db +def test_person_consent_patch( + client: APIClient, person: Person, consent_fixture_choice_yes: Consent +): + consent_id = person.consents.get().id + url = reverse( + "v1:person_consent-detail", kwargs={"person_id": person.id, "id": consent_id} + ) + + consent_data = {"type": "foo", "choice": "no"} + response = client.patch(url, consent_data) + + assert response.status_code == status.HTTP_200_OK + + get_url = reverse("v1:person_consent-list", kwargs={"person_id": person.id}) + consents_for_person = client.get(get_url).json()["results"] + + assert consents_for_person[0]["type"]["identifier"] == "foo" + assert not consents_for_person[0]["type"]["mandatory"] + assert consents_for_person[0]["choice"] == "no" + + +@pytest.mark.django_db +def test_person_consent_delete( + client: APIClient, person: Person, consent_fixture_choice_yes: Consent +): + consent_id = person.consents.get().id + get_url = reverse("v1:person_consent-list", kwargs={"person_id": person.id}) + + consents_for_person = client.get(get_url).json()["results"] + assert len(consents_for_person) == 1 + + delete_url = reverse( + "v1:person_consent-detail", kwargs={"person_id": person.id, "id": consent_id} + ) + response = client.delete(delete_url) + + assert response.status_code == status.HTTP_204_NO_CONTENT + + consents_for_person = client.get(get_url).json()["results"] + assert len(consents_for_person) == 0 + + +@pytest.mark.django_db +def test_person_consent_add_invalid_choice_fails( + client: APIClient, person_foo: Person, consent_fixture: Consent +): + url = reverse("v1:person_consent-list", kwargs={"person_id": person_foo.id}) + consents_for_person = client.get(url).json()["results"] + + assert len(consents_for_person) == 0 + + # Try with blue as a choice + consent_data = {"type": "foo", "choice": "blue"} + response = client.post(url, consent_data) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # No consent should have been added to the person + consents_for_person = client.get(url).json()["results"] + assert len(consents_for_person) == 0 diff --git a/greg/tests/conftest.py b/greg/tests/conftest.py index bd45a28353221005e5372e3d20cb990f0bb0f993..fd6e8b0954b34547729a6bc4ce7e61f569e06d58 100644 --- a/greg/tests/conftest.py +++ b/greg/tests/conftest.py @@ -230,6 +230,18 @@ def consent_fixture(person, consent_type_foo): return Consent.objects.get(id=1) +@pytest.fixture +def consent_fixture_choice_yes(person, consent_type_foo): + consent_given_date = "2021-06-20" + Consent.objects.create( + person=person, + type=consent_type_foo, + consent_given_at=consent_given_date, + choice=consent_type_foo.choices.get(value="yes"), + ) + return Consent.objects.get(id=1) + + @pytest.fixture def consent_type_foo() -> ConsentType: consent_foo = ConsentType.objects.create( diff --git a/gregui/api/views/invitation.py b/gregui/api/views/invitation.py index e35626b11823d8ea2345220cf3c41fd9975b527c..1d4fb1ee617e219e26e2e997609ebb97631f79e4 100644 --- a/gregui/api/views/invitation.py +++ b/gregui/api/views/invitation.py @@ -337,8 +337,7 @@ class InvitedGuestView(GenericAPIView): return optional_response with transaction.atomic(): - # Note this only serializes data for the person, it does not look at other sections - # in the response that happen to be there + # Serialize data for the person, this includes identity and consent information serializer = self.get_serializer( instance=person, data=request.data["person"] )