From c2e46fa9e207e416dd5848663a7aef62d67a6688 Mon Sep 17 00:00:00 2001 From: Manuel Raynaud Date: Wed, 18 Jun 2025 15:13:48 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(back)=20document=20as=20for=20access?= =?UTF-8?q?=20CRUD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We introduce a new model for user wanted to access a document or upgrade their role if they already have access. The viewsets does not implement PUT and PATCH, we don't need it for now. --- src/backend/core/api/serializers.py | 31 ++ src/backend/core/api/viewsets.py | 64 +++ src/backend/core/factories.py | 11 + ...lter_user_language_documentaskforaccess.py | 89 ++++ src/backend/core/models.py | 59 +++ .../test_api_documents_ask_for_access.py | 445 ++++++++++++++++++ src/backend/core/urls.py | 6 + 7 files changed, 705 insertions(+) create mode 100644 src/backend/core/migrations/0022_alter_user_language_documentaskforaccess.py create mode 100644 src/backend/core/tests/documents/test_api_documents_ask_for_access.py diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 07542ef3..e08bae1c 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -665,6 +665,37 @@ class InvitationSerializer(serializers.ModelSerializer): return role +class DocumentAskForAccessCreateSerializer(serializers.Serializer): + """Serializer for creating a document ask for access.""" + + role = serializers.ChoiceField(choices=models.RoleChoices.choices, required=False, default=models.RoleChoices.READER) + + +class DocumentAskForAccessSerializer(serializers.ModelSerializer): + """Serializer for document ask for access model""" + + abilities = serializers.SerializerMethodField(read_only=True) + user = UserSerializer(read_only=True) + + class Meta: + model = models.DocumentAskForAccess + fields = [ + "id", + "document", + "user", + "role", + "created_at", + "abilities", + ] + read_only_fields = ["id", "document", "user", "role", "created_at", "abilities"] + + def get_abilities(self, invitation) -> dict: + """Return abilities of the logged-in user on the instance.""" + request = self.context.get("request") + if request: + return invitation.get_abilities(request.user) + return {} + class VersionFilterSerializer(serializers.Serializer): """Validate version filters applied to the list endpoint.""" diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 83904387..5ba51e4d 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1774,6 +1774,70 @@ class InvitationViewset( ) +class DocumentAskForAccessViewSet( + drf.mixins.ListModelMixin, + drf.mixins.RetrieveModelMixin, + drf.mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + """API ViewSet for asking for access to a document.""" + + lookup_field = "id" + pagination_class = Pagination + permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission] + queryset = models.DocumentAskForAccess.objects.all() + serializer_class = serializers.DocumentAskForAccessSerializer + _document = None + + def get_document_or_404(self): + """Get the document related to the viewset or raise a 404 error.""" + if self._document is None: + try: + self._document = models.Document.objects.get( + pk=self.kwargs["resource_id"] + ) + except models.Document.DoesNotExist as e: + raise drf.exceptions.NotFound("Document not found.") from e + return self._document + + def get_queryset(self): + """Return the queryset according to the action.""" + document = self.get_document_or_404() + + queryset = super().get_queryset() + queryset = queryset.filter(document=document) + + roles = set(document.get_roles(self.request.user)) + is_owner_or_admin = bool(roles.intersection(set(models.PRIVILEGED_ROLES))) + if not is_owner_or_admin: + queryset = queryset.filter(user=self.request.user) + + return queryset + + def create(self, request, *args, **kwargs): + """Create a document ask for access resource.""" + document = self.get_document_or_404() + + serializer = serializers.DocumentAskForAccessCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + queryset = self.get_queryset() + + if queryset.filter(user=request.user).exists(): + return drf.response.Response( + {"detail": "You already ask to access to this document."}, + status=drf.status.HTTP_400_BAD_REQUEST, + ) + + models.DocumentAskForAccess.objects.create( + document=document, + user=request.user, + role=serializer.validated_data["role"], + ) + + return drf.response.Response(status=drf.status.HTTP_201_CREATED) + + class ConfigView(drf.views.APIView): """API ViewSet for sharing some public settings.""" diff --git a/src/backend/core/factories.py b/src/backend/core/factories.py index c2acf607..9d08c51c 100644 --- a/src/backend/core/factories.py +++ b/src/backend/core/factories.py @@ -182,6 +182,17 @@ class TeamDocumentAccessFactory(factory.django.DjangoModelFactory): role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices]) +class DocumentAskForAccessFactory(factory.django.DjangoModelFactory): + """Create fake document ask for access for testing.""" + + class Meta: + model = models.DocumentAskForAccess + + document = factory.SubFactory(DocumentFactory) + user = factory.SubFactory(UserFactory) + role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices]) + + class TemplateFactory(factory.django.DjangoModelFactory): """A factory to create templates""" diff --git a/src/backend/core/migrations/0022_alter_user_language_documentaskforaccess.py b/src/backend/core/migrations/0022_alter_user_language_documentaskforaccess.py new file mode 100644 index 00000000..62e314eb --- /dev/null +++ b/src/backend/core/migrations/0022_alter_user_language_documentaskforaccess.py @@ -0,0 +1,89 @@ +# Generated by Django 5.2.3 on 2025-06-18 10:02 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0021_activate_unaccent_extension"), + ] + + operations = [ + migrations.CreateModel( + name="DocumentAskForAccess", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + help_text="primary key for the record as UUID", + primary_key=True, + serialize=False, + verbose_name="id", + ), + ), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, + help_text="date and time at which a record was created", + verbose_name="created on", + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, + help_text="date and time at which a record was last updated", + verbose_name="updated on", + ), + ), + ( + "role", + models.CharField( + choices=[ + ("reader", "Reader"), + ("editor", "Editor"), + ("administrator", "Administrator"), + ("owner", "Owner"), + ], + default="reader", + max_length=20, + ), + ), + ( + "document", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="ask_for_accesses", + to="core.document", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="ask_for_accesses", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Document ask for access", + "verbose_name_plural": "Document ask for accesses", + "db_table": "impress_document_ask_for_access", + "constraints": [ + models.UniqueConstraint( + fields=("user", "document"), + name="unique_document_ask_for_access_user", + violation_error_message="This user has already asked for access to this document.", + ) + ], + }, + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index a2599edf..ca9f4b19 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1149,6 +1149,65 @@ class DocumentAccess(BaseAccess): } +class DocumentAskForAccess(BaseModel): + """Relation model to ask for access to a document.""" + + document = models.ForeignKey( + Document, on_delete=models.CASCADE, related_name="ask_for_accesses" + ) + user = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="ask_for_accesses" + ) + + role = models.CharField( + max_length=20, choices=RoleChoices.choices, default=RoleChoices.READER + ) + + class Meta: + db_table = "impress_document_ask_for_access" + verbose_name = _("Document ask for access") + verbose_name_plural = _("Document ask for accesses") + constraints = [ + models.UniqueConstraint( + fields=["user", "document"], + name="unique_document_ask_for_access_user", + violation_error_message=_( + "This user has already asked for access to this document." + ), + ), + ] + + def __str__(self): + return f"{self.user!s} asked for access to document {self.document!s}" + + def get_abilities(self, user): + """Compute and return abilities for a given user.""" + roles = [] + + if user.is_authenticated: + teams = user.teams + try: + roles = self.user_roles or [] + except AttributeError: + try: + roles = self.document.accesses.filter( + models.Q(user=user) | models.Q(team__in=teams), + ).values_list("role", flat=True) + except (self._meta.model.DoesNotExist, IndexError): + roles = [] + + is_admin_or_owner = bool( + set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN}) + ) + + return { + "destroy": is_admin_or_owner, + "update": is_admin_or_owner, + "partial_update": is_admin_or_owner, + "retrieve": is_admin_or_owner, + } + + class Template(BaseModel): """HTML and CSS code used for formatting the print around the MarkDown body.""" diff --git a/src/backend/core/tests/documents/test_api_documents_ask_for_access.py b/src/backend/core/tests/documents/test_api_documents_ask_for_access.py new file mode 100644 index 00000000..facaab2e --- /dev/null +++ b/src/backend/core/tests/documents/test_api_documents_ask_for_access.py @@ -0,0 +1,445 @@ +"""Test API for document ask for access.""" + +import uuid + +import pytest +from rest_framework.test import APIClient + +from core.api.serializers import UserSerializer +from core.factories import ( + DocumentAskForAccessFactory, + DocumentFactory, + UserDocumentAccessFactory, + UserFactory, +) +from core.models import DocumentAccess, DocumentAskForAccess, RoleChoices + +pytestmark = pytest.mark.django_db + +## Create + + +def test_api_documents_ask_for_access_create_anonymous(): + """Anonymous users should not be able to create a document ask for access.""" + document = DocumentFactory() + + client = APIClient() + response = client.post(f"/api/v1.0/documents/{document.id}/ask-for-access/") + + assert response.status_code == 401 + + +def test_api_documents_ask_for_access_create_invalid_document_id(): + """Invalid document ID should return a 404 error.""" + user = UserFactory() + + client = APIClient() + client.force_login(user) + response = client.post(f"/api/v1.0/documents/{uuid.uuid4()}/ask-for-access/") + + assert response.status_code == 404 + + +def test_api_documents_ask_for_access_create_authenticated(): + """Authenticated users should be able to create a document ask for access.""" + document = DocumentFactory() + user = UserFactory() + + client = APIClient() + client.force_login(user) + + response = client.post(f"/api/v1.0/documents/{document.id}/ask-for-access/") + assert response.status_code == 201 + + assert DocumentAskForAccess.objects.filter( + document=document, + user=user, + role=RoleChoices.READER, + ).exists() + + +def test_api_documents_ask_for_access_create_authenticated_specific_role(): + """ + Authenticated users should be able to create a document ask for access with a specific role. + """ + document = DocumentFactory() + user = UserFactory() + + client = APIClient() + client.force_login(user) + + response = client.post( + f"/api/v1.0/documents/{document.id}/ask-for-access/", + data={"role": RoleChoices.EDITOR}, + ) + assert response.status_code == 201 + + assert DocumentAskForAccess.objects.filter( + document=document, + user=user, + role=RoleChoices.EDITOR, + ).exists() + + +def test_api_documents_ask_for_access_create_authenticated_already_has_access(): + """Authenticated users with existing access can ask for access with a different role.""" + user = UserFactory() + document = DocumentFactory(users=[(user, RoleChoices.READER)]) + + client = APIClient() + client.force_login(user) + + response = client.post( + f"/api/v1.0/documents/{document.id}/ask-for-access/", + data={"role": RoleChoices.EDITOR}, + ) + assert response.status_code == 201 + + assert DocumentAskForAccess.objects.filter( + document=document, + user=user, + role=RoleChoices.EDITOR, + ).exists() + + +def test_api_documents_ask_for_access_create_authenticated_already_has_ask_for_access(): + """ + Authenticated users with existing ask for access can not ask for a new access on this document. + """ + user = UserFactory() + document = DocumentFactory(users=[(user, RoleChoices.READER)]) + DocumentAskForAccessFactory(document=document, user=user, role=RoleChoices.READER) + + client = APIClient() + client.force_login(user) + + response = client.post( + f"/api/v1.0/documents/{document.id}/ask-for-access/", + data={"role": RoleChoices.EDITOR}, + ) + assert response.status_code == 400 + assert response.json() == {"detail": "You already ask to access to this document."} + + +## List + + +def test_api_documents_ask_for_access_list_anonymous(): + """Anonymous users should not be able to list document ask for access.""" + document = DocumentFactory() + DocumentAskForAccessFactory.create_batch( + 3, document=document, role=RoleChoices.READER + ) + + client = APIClient() + response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/") + + assert response.status_code == 401 + + +def test_api_documents_ask_for_access_list_authenticated(): + """Authenticated users should be able to list document ask for access.""" + document = DocumentFactory() + DocumentAskForAccessFactory.create_batch( + 3, document=document, role=RoleChoices.READER + ) + + client = APIClient() + client.force_login(UserFactory()) + + response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/") + assert response.status_code == 200 + assert response.json() == { + "count": 0, + "next": None, + "previous": None, + "results": [], + } + + +def test_api_documents_ask_for_access_list_authenticated_own_request(): + """Authenticated users should be able to list their own document ask for access.""" + document = DocumentFactory() + DocumentAskForAccessFactory.create_batch( + 3, document=document, role=RoleChoices.READER + ) + + user = UserFactory() + user_data = UserSerializer(instance=user).data + + document_ask_for_access = DocumentAskForAccessFactory( + document=document, user=user, role=RoleChoices.READER + ) + + client = APIClient() + client.force_login(user) + + response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/") + assert response.status_code == 200 + assert response.json() == { + "count": 1, + "next": None, + "previous": None, + "results": [ + { + "id": str(document_ask_for_access.id), + "document": str(document.id), + "user": user_data, + "role": RoleChoices.READER, + "created_at": document_ask_for_access.created_at.isoformat().replace( + "+00:00", "Z" + ), + "abilities": { + "destroy": False, + "update": False, + "partial_update": False, + "retrieve": False, + }, + } + ], + } + + +def test_api_documents_ask_for_access_list_authenticated_other_document(): + """Authenticated users should not be able to list document ask for access of other documents.""" + document = DocumentFactory() + DocumentAskForAccessFactory.create_batch( + 3, document=document, role=RoleChoices.READER + ) + + client = APIClient() + client.force_login(UserFactory()) + + other_document = DocumentFactory() + DocumentAskForAccessFactory.create_batch( + 3, document=other_document, role=RoleChoices.READER + ) + + response = client.get(f"/api/v1.0/documents/{other_document.id}/ask-for-access/") + assert response.status_code == 200 + assert response.json() == { + "count": 0, + "next": None, + "previous": None, + "results": [], + } + + +@pytest.mark.parametrize("role", [RoleChoices.READER, RoleChoices.EDITOR]) +def test_api_documents_ask_for_access_list_non_owner_or_admin(role): + """Non owner or admin users should not be able to list document ask for access.""" + + user = UserFactory() + + document = DocumentFactory(users=[(user, role)]) + DocumentAskForAccessFactory.create_batch( + 3, document=document, role=RoleChoices.READER + ) + + client = APIClient() + client.force_login(user) + + response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/") + assert response.status_code == 200 + assert response.json() == { + "count": 0, + "next": None, + "previous": None, + "results": [], + } + + +@pytest.mark.parametrize("role", [RoleChoices.OWNER]) +def test_api_documents_ask_for_access_list_owner_or_admin(role): + """Owner or admin users should be able to list document ask for access.""" + user = UserFactory() + document = DocumentFactory(users=[(user, role)]) + document_ask_for_accesses = DocumentAskForAccessFactory.create_batch( + 3, document=document, role=RoleChoices.READER + ) + + client = APIClient() + client.force_login(user) + + response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/") + assert response.status_code == 200 + assert response.json() == { + "count": 3, + "next": None, + "previous": None, + "results": [ + { + "id": str(document_ask_for_access.id), + "document": str(document.id), + "user": UserSerializer(instance=document_ask_for_access.user).data, + "role": RoleChoices.READER, + "created_at": document_ask_for_access.created_at.isoformat().replace( + "+00:00", "Z" + ), + "abilities": { + "destroy": True, + "update": True, + "partial_update": True, + "retrieve": True, + }, + } + for document_ask_for_access in document_ask_for_accesses + ], + } + + +## Retrieve + + +def test_api_documents_ask_for_access_retrieve_anonymous(): + """Anonymous users should not be able to retrieve document ask for access.""" + document = DocumentFactory() + document_ask_for_access = DocumentAskForAccessFactory( + document=document, role=RoleChoices.READER + ) + + client = APIClient() + response = client.get( + f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/" + ) + assert response.status_code == 401 + + +def test_api_documents_ask_for_access_retrieve_authenticated(): + """Authenticated users should not be able to retrieve document ask for access.""" + document = DocumentFactory() + document_ask_for_access = DocumentAskForAccessFactory( + document=document, role=RoleChoices.READER + ) + + client = APIClient() + client.force_login(UserFactory()) + + response = client.get( + f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/" + ) + assert response.status_code == 404 + + +@pytest.mark.parametrize("role", [RoleChoices.READER, RoleChoices.EDITOR]) +def test_api_documents_ask_for_access_retrieve_authenticated_non_owner_or_admin(role): + """Non owner or admin users should not be able to retrieve document ask for access.""" + user = UserFactory() + document = DocumentFactory(users=[(user, role)]) + document_ask_for_access = DocumentAskForAccessFactory( + document=document, role=RoleChoices.READER + ) + + client = APIClient() + client.force_login(user) + + response = client.get( + f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/" + ) + assert response.status_code == 404 + + +@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN]) +def test_api_documents_ask_for_access_retrieve_owner_or_admin(role): + """Owner or admin users should be able to retrieve document ask for access.""" + user = UserFactory() + document = DocumentFactory(users=[(user, role)]) + document_ask_for_access = DocumentAskForAccessFactory( + document=document, role=RoleChoices.READER + ) + user_data = UserSerializer(instance=document_ask_for_access.user).data + + client = APIClient() + client.force_login(user) + + response = client.get( + f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/" + ) + assert response.status_code == 200 + assert response.json() == { + "id": str(document_ask_for_access.id), + "document": str(document.id), + "user": user_data, + "role": RoleChoices.READER, + "created_at": document_ask_for_access.created_at.isoformat().replace( + "+00:00", "Z" + ), + "abilities": { + "destroy": True, + "update": True, + "partial_update": True, + "retrieve": True, + }, + } + + +## Delete + + +def test_api_documents_ask_for_access_delete_anonymous(): + """Anonymous users should not be able to delete document ask for access.""" + document = DocumentFactory() + document_ask_for_access = DocumentAskForAccessFactory( + document=document, role=RoleChoices.READER + ) + + client = APIClient() + response = client.delete( + f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/" + ) + assert response.status_code == 401 + + +def test_api_documents_ask_for_access_delete_authenticated(): + """Authenticated users should not be able to delete document ask for access.""" + document = DocumentFactory() + document_ask_for_access = DocumentAskForAccessFactory( + document=document, role=RoleChoices.READER + ) + + client = APIClient() + client.force_login(UserFactory()) + + response = client.delete( + f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/" + ) + assert response.status_code == 404 + + +@pytest.mark.parametrize("role", [RoleChoices.READER, RoleChoices.EDITOR]) +def test_api_documents_ask_for_access_delete_authenticated_non_owner_or_admin(role): + """Non owner or admin users should not be able to delete document ask for access.""" + user = UserFactory() + document = DocumentFactory(users=[(user, role)]) + document_ask_for_access = DocumentAskForAccessFactory( + document=document, role=RoleChoices.READER + ) + + client = APIClient() + client.force_login(user) + + response = client.delete( + f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/" + ) + assert response.status_code == 404 + + +@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN]) +def test_api_documents_ask_for_access_delete_owner_or_admin(role): + """Owner or admin users should be able to delete document ask for access.""" + user = UserFactory() + document = DocumentFactory(users=[(user, role)]) + document_ask_for_access = DocumentAskForAccessFactory( + document=document, role=RoleChoices.READER + ) + + client = APIClient() + client.force_login(user) + + response = client.delete( + f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/" + ) + assert response.status_code == 204 + assert not DocumentAskForAccess.objects.filter( + id=document_ask_for_access.id + ).exists() diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index 05441895..2ad8b003 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -27,6 +27,12 @@ document_related_router.register( basename="invitations", ) +document_related_router.register( + "ask-for-access", + viewsets.DocumentAskForAccessViewSet, + basename="ask_for_access", +) + # - Routes nested under a template template_related_router = DefaultRouter()