From df24c24da11d943846bd644f659d71a2ed4b65d9 Mon Sep 17 00:00:00 2001 From: Marie PUPO JEAMMET Date: Wed, 17 Apr 2024 11:19:22 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(api)=20add=20CRUD=20for=20mailbox=20m?= =?UTF-8?q?anager=20MailDomain=20models?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add create,list,retrieve and delete actions for MailDomain model. --- .../teams/test_core_api_teams_retrieve.py | 8 +- .../mailbox_manager/api/permissions.py | 12 ++ .../mailbox_manager/api/serializers.py | 24 +++- src/backend/mailbox_manager/api/viewsets.py | 65 ++++++++++- src/backend/mailbox_manager/factories.py | 15 +++ .../0002_alter_maildomainaccess_domain.py | 19 ++++ src/backend/mailbox_manager/models.py | 25 +++- .../test_api_mail_domains_create.py | 73 ++++++++++++ .../test_api_mail_domains_delete.py | 107 ++++++++++++++++++ .../mail_domain/test_api_mail_domains_list.py | 56 +++++++++ .../test_api_mail_domains_retrieve.py | 71 ++++++++++++ src/backend/mailbox_manager/urls.py | 41 +++++++ src/backend/people/api_urls.py | 20 +--- 13 files changed, 505 insertions(+), 31 deletions(-) create mode 100644 src/backend/mailbox_manager/api/permissions.py create mode 100644 src/backend/mailbox_manager/migrations/0002_alter_maildomainaccess_domain.py create mode 100644 src/backend/mailbox_manager/tests/api/mail_domain/test_api_mail_domains_create.py create mode 100644 src/backend/mailbox_manager/tests/api/mail_domain/test_api_mail_domains_delete.py create mode 100644 src/backend/mailbox_manager/tests/api/mail_domain/test_api_mail_domains_list.py create mode 100644 src/backend/mailbox_manager/tests/api/mail_domain/test_api_mail_domains_retrieve.py create mode 100644 src/backend/mailbox_manager/urls.py diff --git a/src/backend/core/tests/teams/test_core_api_teams_retrieve.py b/src/backend/core/tests/teams/test_core_api_teams_retrieve.py index 1fdb62c..c88711b 100644 --- a/src/backend/core/tests/teams/test_core_api_teams_retrieve.py +++ b/src/backend/core/tests/teams/test_core_api_teams_retrieve.py @@ -3,7 +3,7 @@ Tests for Teams API endpoint in People's core app: retrieve """ import pytest -from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_404_NOT_FOUND +from rest_framework import status from rest_framework.test import APIClient from core import factories @@ -16,7 +16,7 @@ def test_api_teams_retrieve_anonymous(): team = factories.TeamFactory() response = APIClient().get(f"/api/v1.0/teams/{team.id}/") - assert response.status_code == HTTP_401_UNAUTHORIZED + assert response.status_code == status.HTTP_401_UNAUTHORIZED assert response.json() == { "detail": "Authentication credentials were not provided." } @@ -37,7 +37,7 @@ def test_api_teams_retrieve_authenticated_unrelated(): response = client.get( f"/api/v1.0/teams/{team.id!s}/", ) - assert response.status_code == HTTP_404_NOT_FOUND + assert response.status_code == status.HTTP_404_NOT_FOUND assert response.json() == {"detail": "No Team matches the given query."} @@ -60,7 +60,7 @@ def test_api_teams_retrieve_authenticated_related(): f"/api/v1.0/teams/{team.id!s}/", ) - assert response.status_code == HTTP_200_OK + assert response.status_code == status.HTTP_200_OK assert sorted(response.json().pop("accesses")) == sorted( [ str(access1.id), diff --git a/src/backend/mailbox_manager/api/permissions.py b/src/backend/mailbox_manager/api/permissions.py new file mode 100644 index 0000000..bc534ac --- /dev/null +++ b/src/backend/mailbox_manager/api/permissions.py @@ -0,0 +1,12 @@ +"""Permission handlers for the People mailbox manager app.""" + +from core.api import permissions as core_permissions + + +class AccessPermission(core_permissions.IsAuthenticated): + """Permission class for access objects.""" + + def has_object_permission(self, request, view, obj): + """Check permission for a given object.""" + abilities = obj.get_abilities(request.user) + return abilities.get(request.method.lower(), False) diff --git a/src/backend/mailbox_manager/api/serializers.py b/src/backend/mailbox_manager/api/serializers.py index 150ae5e..08b2d5e 100644 --- a/src/backend/mailbox_manager/api/serializers.py +++ b/src/backend/mailbox_manager/api/serializers.py @@ -1,4 +1,4 @@ -"""Client serializers for the People mailbox_manager app.""" +"""Client serializers for People's mailbox manager app.""" from rest_framework import serializers @@ -18,4 +18,24 @@ class MailDomainSerializer(serializers.ModelSerializer): class Meta: model = models.MailDomain - fields = ["id", "name"] + fields = [ + "id", + "name", + "created_at", + "updated_at", + ] + + +class MailDomainAccessSerializer(serializers.ModelSerializer): + """Serialize mail domain accesses.""" + + class Meta: + model = models.MailDomainAccess + fields = [ + "id", + "user", + "role", + "created_at", + "updated_at", + ] + read_only_fields = ["id"] diff --git a/src/backend/mailbox_manager/api/viewsets.py b/src/backend/mailbox_manager/api/viewsets.py index e11234f..9c3b653 100644 --- a/src/backend/mailbox_manager/api/viewsets.py +++ b/src/backend/mailbox_manager/api/viewsets.py @@ -1,22 +1,75 @@ """API endpoints""" -from rest_framework import mixins, viewsets +from rest_framework import filters, mixins, viewsets from rest_framework import permissions as drf_permissions +from core import models as core_models + from mailbox_manager import models - -from . import serializers +from mailbox_manager.api import permissions, serializers +# pylint: disable=too-many-ancestors class MailDomainViewSet( + mixins.CreateModelMixin, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + """ + MailDomain viewset. + + GET /api//mail-domains/ + Return a list of mail domains user has access to. + + GET /api//mail-domains// + Return details for a mail domain user has access to. + + POST /api//mail-domains/ with expected data: + - name: str + Return newly created domain + + DELETE /api//mail-domains// + Delete targeted team access + """ + + permission_classes = [permissions.AccessPermission] + serializer_class = serializers.MailDomainSerializer + filter_backends = [filters.OrderingFilter] + ordering_fields = ["created_at", "name"] + ordering = ["-created_at"] + queryset = models.MailDomain.objects.all() + + def get_queryset(self): + return self.queryset.filter(accesses__user=self.request.user) + + def perform_create(self, serializer): + """Set the current user as owner of the newly created mail domain.""" + + domain = serializer.save() + models.MailDomainAccess.objects.create( + user=self.request.user, + domain=domain, + role=core_models.RoleChoices.OWNER, + ) + + +# pylint: disable=too-many-ancestors +class MailDomainAccessViewSet( mixins.ListModelMixin, viewsets.GenericViewSet, ): - """MailDomain ViewSet""" + """ + MailDomainAccess viewset. + """ permission_classes = [drf_permissions.IsAuthenticated] - serializer_class = serializers.MailDomainSerializer - queryset = models.MailDomain.objects.all() + serializer_class = serializers.MailDomainAccessSerializer + filter_backends = [filters.OrderingFilter] + ordering_fields = ["created_at", "user", "domain", "role"] + ordering = ["-created_at"] + queryset = models.MailDomainAccess.objects.all() class MailBoxViewSet( diff --git a/src/backend/mailbox_manager/factories.py b/src/backend/mailbox_manager/factories.py index 43cb8a5..e3012b0 100644 --- a/src/backend/mailbox_manager/factories.py +++ b/src/backend/mailbox_manager/factories.py @@ -18,9 +18,24 @@ class MailDomainFactory(factory.django.DjangoModelFactory): class Meta: model = models.MailDomain + django_get_or_create = ("name",) + skip_postgeneration_save = True name = factory.Faker("domain_name") + @factory.post_generation + def users(self, create, extracted, **kwargs): + """Add users to domain from a given list of users with or without roles.""" + if not create or not extracted: + return + for user_entry in extracted: + if isinstance(user_entry, core_models.User): + MailDomainAccessFactory(domain=self, user=user_entry) + else: + MailDomainAccessFactory( + domain=self, user=user_entry[0], role=user_entry[1] + ) + class MailDomainAccessFactory(factory.django.DjangoModelFactory): """A factory to create mail domain accesses.""" diff --git a/src/backend/mailbox_manager/migrations/0002_alter_maildomainaccess_domain.py b/src/backend/mailbox_manager/migrations/0002_alter_maildomainaccess_domain.py new file mode 100644 index 0000000..020c13e --- /dev/null +++ b/src/backend/mailbox_manager/migrations/0002_alter_maildomainaccess_domain.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0.3 on 2024-04-17 11:58 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mailbox_manager', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='maildomainaccess', + name='domain', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='mailbox_manager.maildomain'), + ), + ] diff --git a/src/backend/mailbox_manager/models.py b/src/backend/mailbox_manager/models.py index 934d1fb..ffc1551 100644 --- a/src/backend/mailbox_manager/models.py +++ b/src/backend/mailbox_manager/models.py @@ -25,6 +25,29 @@ class MailDomain(BaseModel): def __str__(self): return self.name + def get_abilities(self, user): + """ + Compute and return abilities for a given user on the domain. + """ + is_owner_or_admin = False + role = None + + if user.is_authenticated: + try: + role = self.accesses.filter(user=user).values("role")[0]["role"] + except (MailDomainAccess.DoesNotExist, IndexError): + role = None + + is_owner_or_admin = role in [RoleChoices.OWNER, RoleChoices.ADMIN] + + return { + "get": bool(role), + "patch": is_owner_or_admin, + "put": is_owner_or_admin, + "delete": role == RoleChoices.OWNER, + "manage_accesses": is_owner_or_admin, + } + class MailDomainAccess(BaseModel): """Allow to manage users' accesses to mail domains.""" @@ -39,7 +62,7 @@ class MailDomainAccess(BaseModel): domain = models.ForeignKey( MailDomain, on_delete=models.CASCADE, - related_name="mail_domain_accesses", + related_name="accesses", null=False, blank=False, ) diff --git a/src/backend/mailbox_manager/tests/api/mail_domain/test_api_mail_domains_create.py b/src/backend/mailbox_manager/tests/api/mail_domain/test_api_mail_domains_create.py new file mode 100644 index 0000000..5c70b13 --- /dev/null +++ b/src/backend/mailbox_manager/tests/api/mail_domain/test_api_mail_domains_create.py @@ -0,0 +1,73 @@ +""" +Tests for MailDomains API endpoint in People's app mailbox_manager. Focus on "create" action. +""" + +import pytest +from rest_framework import status +from rest_framework.test import APIClient + +from core import factories as core_factories + +from mailbox_manager import factories, models + +pytestmark = pytest.mark.django_db + + +def test_api_mail_domains__create_anonymous(): + """Anonymous users should not be allowed to create mail domains.""" + + response = APIClient().post( + "/api/v1.0/mail-domains/", + { + "name": "mydomain.com", + }, + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert not models.MailDomain.objects.exists() + + +def test_api_mail_domains__create_name_unique(): + """ + Creating domain should raise an error if already existing name. + """ + factories.MailDomainFactory(name="existing_domain.com") + identity = core_factories.IdentityFactory() + + client = APIClient() + client.force_login(identity.user) + + response = client.post( + "/api/v1.0/mail-domains/", + { + "name": "existing_domain.com", + }, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["name"] == ["Mail domain with this name already exists."] + + +def test_api_mail_domains__create_authenticated(): + """ + Authenticated users should be able to create mail domains + and should automatically be added as owner of the newly created domain. + """ + + identity = core_factories.IdentityFactory() + user = identity.user + + client = APIClient() + client.force_login(identity.user) + + response = client.post( + "/api/v1.0/mail-domains/", + { + "name": "mydomain.com", + }, + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED + domain = models.MailDomain.objects.get() + assert domain.name == "mydomain.com" + assert domain.accesses.filter(role="owner", user=user).exists() diff --git a/src/backend/mailbox_manager/tests/api/mail_domain/test_api_mail_domains_delete.py b/src/backend/mailbox_manager/tests/api/mail_domain/test_api_mail_domains_delete.py new file mode 100644 index 0000000..9e16f4b --- /dev/null +++ b/src/backend/mailbox_manager/tests/api/mail_domain/test_api_mail_domains_delete.py @@ -0,0 +1,107 @@ +""" +Tests for MailDomains API endpoint, in People's mailbox manager app. Focus on "delete" action. +""" + +import pytest +from rest_framework import status +from rest_framework.test import APIClient + +from core import factories as core_factories + +from mailbox_manager import factories, models + +pytestmark = pytest.mark.django_db + + +def test_api_mail_domains__delete_anonymous(): + """Anonymous users should not be allowed to destroy a team.""" + domain = factories.MailDomainFactory() + + response = APIClient().delete( + f"/api/v1.0/mail-domains/{domain.id!s}/", + ) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert models.MailDomain.objects.count() == 1 + + +def test_api_mail_domains__delete_authenticated_unrelated(): + """ + Authenticated users should not be allowed to delete a domain to which they are not + related. + """ + identity = core_factories.IdentityFactory() + domain = factories.MailDomainFactory() + + client = APIClient() + client.force_login(identity.user) + response = client.delete( + f"/api/v1.0/mail-domains/{domain.id!s}/", + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json() == {"detail": "No MailDomain matches the given query."} + assert models.MailDomain.objects.count() == 1 + + +def test_api_mail_domains__delete_authenticated_member(): + """ + Authenticated users should not be allowed to delete a domain + to which they are only a member. + """ + identity = core_factories.IdentityFactory() + user = identity.user + domain = factories.MailDomainFactory(users=[(user, "member")]) + + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/mail-domains/{domain.id}/", + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + assert models.MailDomain.objects.count() == 1 + + +def test_api_mail_domains__delete_authenticated_administrator(): + """ + Authenticated users should not be allowed to delete a domain + for which they are administrator. + """ + identity = core_factories.IdentityFactory() + user = identity.user + domain = factories.MailDomainFactory(users=[(user, "administrator")]) + + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/mail-domains/{domain.id}/", + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + assert models.MailDomain.objects.count() == 1 + + +def test_api_mail_domains__delete_authenticated_owner(): + """ + Authenticated users should be able to delete a domain + for which they are directly owner. + """ + identity = core_factories.IdentityFactory() + user = identity.user + domain = factories.MailDomainFactory(users=[(user, "owner")]) + + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/mail-domains/{domain.id}/", + ) + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert models.MailDomain.objects.exists() is False diff --git a/src/backend/mailbox_manager/tests/api/mail_domain/test_api_mail_domains_list.py b/src/backend/mailbox_manager/tests/api/mail_domain/test_api_mail_domains_list.py new file mode 100644 index 0000000..b7086f4 --- /dev/null +++ b/src/backend/mailbox_manager/tests/api/mail_domain/test_api_mail_domains_list.py @@ -0,0 +1,56 @@ +""" +Tests for MailDomains API endpoint in People's mailbox manager app. Focus on "list" action. +""" + +import pytest +from rest_framework import status +from rest_framework.test import APIClient + +from core import factories as core_factories + +from mailbox_manager import factories + +pytestmark = pytest.mark.django_db + + +def test_api_mail_domains__list_anonymous(): + """Anonymous users should not be allowed to list mail domains.""" + + factories.MailDomainFactory.create_batch(3) + + response = APIClient().get("/api/v1.0/mail-domains/") + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +def test_api_mail_domains__list_authenticated(): + """ + Authenticated users should be able to list domains + to which they have access. + """ + + identity = core_factories.IdentityFactory() + user = identity.user + + client = APIClient() + client.force_login(user) + + expected_ids = { + str(access.domain.id) + for access in factories.MailDomainAccessFactory.create_batch(5, user=user) + } + factories.MailDomainFactory.create_batch(2) # Other teams + factories.MailDomainAccessFactory.create_batch(2) # Other teams and accesses + + response = client.get( + "/api/v1.0/mail-domains/", + ) + + assert response.status_code == status.HTTP_200_OK + results = response.json()["results"] + assert len(results) == 5 + results_id = {result["id"] for result in results} + assert expected_ids == results_id diff --git a/src/backend/mailbox_manager/tests/api/mail_domain/test_api_mail_domains_retrieve.py b/src/backend/mailbox_manager/tests/api/mail_domain/test_api_mail_domains_retrieve.py new file mode 100644 index 0000000..d768cc1 --- /dev/null +++ b/src/backend/mailbox_manager/tests/api/mail_domain/test_api_mail_domains_retrieve.py @@ -0,0 +1,71 @@ +""" +Tests for MailDomains API endpoint in People's mailbox manager app. Focus on "retrieve" action. +""" + +import pytest +from rest_framework import status +from rest_framework.test import APIClient + +from core import factories as core_factories + +from mailbox_manager import factories + +pytestmark = pytest.mark.django_db + + +def test_api_mail_domains__retrieve_anonymous(): + """Anonymous users should not be allowed to retrieve a domain.""" + + domain = factories.MailDomainFactory() + response = APIClient().get(f"/api/v1.0/mail-domains/{domain.id}/") + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +def test_api_mail_domains__retrieve_authenticated_unrelated(): + """ + Authenticated users should not be allowed to retrieve a domain + to which they have access. + """ + identity = core_factories.IdentityFactory() + + client = APIClient() + client.force_login(identity.user) + + domain = factories.MailDomainFactory() + + response = client.get( + f"/api/v1.0/mail-domains/{domain.id!s}/", + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json() == {"detail": "No MailDomain matches the given query."} + + +def test_api_mail_domains__retrieve_authenticated_related(): + """ + Authenticated users should be allowed to retrieve a domain + to which they have access. + """ + identity = core_factories.IdentityFactory() + user = identity.user + + client = APIClient() + client.force_login(user) + + domain = factories.MailDomainFactory() + factories.MailDomainAccessFactory(domain=domain, user=user) + + response = client.get( + f"/api/v1.0/mail-domains/{domain.id!s}/", + ) + + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "id": str(domain.id), + "name": domain.name, + "created_at": domain.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": domain.updated_at.isoformat().replace("+00:00", "Z"), + } diff --git a/src/backend/mailbox_manager/urls.py b/src/backend/mailbox_manager/urls.py new file mode 100644 index 0000000..d2bd53b --- /dev/null +++ b/src/backend/mailbox_manager/urls.py @@ -0,0 +1,41 @@ +"""API URL Configuration""" + +from django.urls import include, path, re_path + +from rest_framework.routers import DefaultRouter + +from mailbox_manager.api import viewsets + +maildomain_router = DefaultRouter() +maildomain_router.register( + "mail-domains", viewsets.MailDomainViewSet, basename="mail-domains" +) + +# - Routes nested under a mail domain +maildomain_related_router = DefaultRouter() +maildomain_related_router.register( + "accesses", + viewsets.MailDomainAccessViewSet, + basename="accesses", +) +maildomain_related_router.register( + "mailboxes", + viewsets.MailBoxViewSet, + basename="mailboxes", +) + + +urlpatterns = [ + path( + "", + include( + [ + *maildomain_router.urls, + re_path( + r"^mail-domains/(?P[0-9a-z-]*)/", + include(maildomain_related_router.urls), + ), + ] + ), + ), +] diff --git a/src/backend/people/api_urls.py b/src/backend/people/api_urls.py index 094faf8..7ce675e 100644 --- a/src/backend/people/api_urls.py +++ b/src/backend/people/api_urls.py @@ -8,16 +8,11 @@ from rest_framework.routers import DefaultRouter from core.api import viewsets -from mailbox_manager.api import viewsets as mail_viewsets - # - Main endpoints router = DefaultRouter() router.register("contacts", viewsets.ContactViewSet, basename="contacts") router.register("teams", viewsets.TeamViewSet, basename="teams") router.register("users", viewsets.UserViewSet, basename="users") -router.register( - "mail-domains", mail_viewsets.MailDomainViewSet, basename="mail-domains" -) # - Routes nested under a team team_related_router = DefaultRouter() @@ -33,14 +28,6 @@ team_related_router.register( basename="invitations", ) -# - Routes nested under a mail domain -maildomain_related_router = DefaultRouter() -maildomain_related_router.register( - "mailboxes", - mail_viewsets.MailBoxViewSet, - basename="mailboxes", -) - urlpatterns = [ path( @@ -53,11 +40,8 @@ urlpatterns = [ r"^teams/(?P[0-9a-z-]*)/", include(team_related_router.urls), ), - re_path( - r"^mail-domains/(?P[0-9a-z-]*)/", - include(maildomain_related_router.urls), - ), ] ), - ) + ), + path(f"api/{settings.API_VERSION}/", include("mailbox_manager.urls")), ]