diff --git a/CHANGELOG.md b/CHANGELOG.md index 94902f5a..ea46e8e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,9 +28,10 @@ and this project adheres to ## Added -- 🌐(backend) add german translation #259 -- 🌐(frontend) Add German translation #255 -- ✨(frontend) Add a broadcast store #387 +- ✨(backend) allow users to mark/unmark documents as favorite #411 +- 🌐(backend) add German translation #259 +- 🌐(frontend) add German translation #255 +- ✨(frontend) add a broadcast store #387 - ✨(backend) whitelist pod's IP address #443 - ✨(backend) config endpoint #425 - ✨(frontend) config endpoint #424 diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 46016350..9298ae5a 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -140,6 +140,7 @@ class BaseResourceSerializer(serializers.ModelSerializer): class ListDocumentSerializer(BaseResourceSerializer): """Serialize documents with limited fields for display in lists.""" + is_favorite = serializers.BooleanField(read_only=True) class Meta: model = models.Document @@ -148,6 +149,7 @@ class ListDocumentSerializer(BaseResourceSerializer): "abilities", "content", "created_at", + "is_favorite", "link_role", "link_reach", "title", @@ -157,6 +159,7 @@ class ListDocumentSerializer(BaseResourceSerializer): "id", "abilities", "created_at", + "is_favorite", "link_role", "link_reach", "updated_at", @@ -175,6 +178,7 @@ class DocumentSerializer(ListDocumentSerializer): "abilities", "content", "created_at", + "is_favorite", "link_role", "link_reach", "title", @@ -184,6 +188,7 @@ class DocumentSerializer(ListDocumentSerializer): "id", "abilities", "created_at", + "is_avorite", "link_role", "link_reach", "updated_at", diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 29bead7e..dfe41be6 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -10,10 +10,12 @@ from django.contrib.postgres.search import TrigramSimilarity from django.core.exceptions import ValidationError from django.core.files.storage import default_storage from django.db.models import ( + Exists, Min, OuterRef, Q, Subquery, + Value, ) from django.http import Http404 @@ -193,42 +195,6 @@ class UserViewSet( ) -class ResourceViewsetMixin: - """Mixin with methods common to all resource viewsets that are managed with accesses.""" - - filter_backends = [filters.OrderingFilter] - ordering_fields = ["created_at", "updated_at", "title"] - ordering = ["-created_at"] - - def get_queryset(self): - """Custom queryset to get user related resources.""" - queryset = super().get_queryset() - user = self.request.user - - if not user.is_authenticated: - return queryset - - user_roles_query = ( - self.access_model_class.objects.filter( - Q(user=user) | Q(team__in=user.teams), - **{self.resource_field_name: OuterRef("pk")}, - ) - .values(self.resource_field_name) - .annotate(roles_array=ArrayAgg("role")) - .values("roles_array") - ) - return queryset.annotate(user_roles=Subquery(user_roles_query)).distinct() - - def perform_create(self, serializer): - """Set the current user as owner of the newly created object.""" - obj = serializer.save() - self.access_model_class.objects.create( - user=self.request.user, - role=models.RoleChoices.OWNER, - **{self.resource_field_name: obj}, - ) - - class ResourceAccessViewsetMixin: """Mixin with methods common to all access viewsets.""" @@ -338,7 +304,6 @@ class DocumentMetadata(metadata.SimpleMetadata): class DocumentViewSet( - ResourceViewsetMixin, mixins.CreateModelMixin, mixins.DestroyModelMixin, mixins.UpdateModelMixin, @@ -346,14 +311,14 @@ class DocumentViewSet( ): """Document ViewSet""" - access_model_class = models.DocumentAccess + filter_backends = [filters.OrderingFilter] metadata_class = DocumentMetadata ordering = ["-updated_at"] + ordering_fields = ["created_at", "is_favorite", "updated_at", "title"] permission_classes = [ permissions.AccessPermission, ] queryset = models.Document.objects.all() - resource_field_name = "document" serializer_class = serializers.DocumentSerializer def get_serializer_class(self): @@ -364,6 +329,33 @@ class DocumentViewSet( return serializers.ListDocumentSerializer return self.serializer_class + def get_queryset(self): + """Optimize queryset to include favorite status for the current user.""" + queryset = super().get_queryset() + user = self.request.user + + if not user.is_authenticated: + # If the user is not authenticated, annotate `is_favorite` as False + return queryset.annotate(is_favorite=Value(False)) + + # Annotate the queryset to indicate if the document is favorited by the current user + favorite_exists = models.DocumentFavorite.objects.filter( + document_id=OuterRef("pk"), user=user + ) + queryset = queryset.annotate(is_favorite=Exists(favorite_exists)) + + # Annotate the queryset with the logged-in user roles + user_roles_query = ( + models.DocumentAccess.objects.filter( + Q(user=user) | Q(team__in=user.teams), + document_id=OuterRef("pk"), + ) + .values("document") + .annotate(roles_array=ArrayAgg("role")) + .values("roles_array") + ) + return queryset.annotate(user_roles=Subquery(user_roles_query)).distinct() + def list(self, request, *args, **kwargs): """Restrict resources returned by the list endpoint""" queryset = self.filter_queryset(self.get_queryset()) @@ -411,6 +403,15 @@ class DocumentViewSet( return drf_response.Response(serializer.data) + def perform_create(self, serializer): + """Set the current user as owner of the newly created object.""" + obj = serializer.save() + models.DocumentAccess.objects.create( + document=obj, + user=self.request.user, + role=models.RoleChoices.OWNER, + ) + @decorators.action(detail=True, methods=["get"], url_path="versions") def versions_list(self, request, *args, **kwargs): """ @@ -504,6 +505,43 @@ class DocumentViewSet( serializer.save() return drf_response.Response(serializer.data, status=status.HTTP_200_OK) + @decorators.action(detail=True, methods=["post", "delete"], url_path="favorite") + def favorite(self, request, *args, **kwargs): + """ + Mark or unmark the document as a favorite for the logged-in user based on the HTTP method. + """ + # Check permissions first + document = self.get_object() + user = request.user + + if request.method == "POST": + # Try to mark as favorite + try: + models.DocumentFavorite.objects.create(document=document, user=user) + except ValidationError: + return drf_response.Response( + {"detail": "Document already marked as favorite"}, + status=status.HTTP_200_OK, + ) + return drf_response.Response( + {"detail": "Document marked as favorite"}, + status=status.HTTP_201_CREATED, + ) + + # Handle DELETE method to unmark as favorite + deleted, _ = models.DocumentFavorite.objects.filter( + document=document, user=user + ).delete() + if deleted: + return drf_response.Response( + {"detail": "Document unmarked as favorite"}, + status=status.HTTP_204_NO_CONTENT, + ) + return drf_response.Response( + {"detail": "Document was already not marked as favorite"}, + status=status.HTTP_200_OK, + ) + @decorators.action(detail=True, methods=["post"], url_path="attachment-upload") def attachment_upload(self, request, *args, **kwargs): """Upload a file related to a given document""" @@ -687,7 +725,6 @@ class DocumentAccessViewSet( class TemplateViewSet( - ResourceViewsetMixin, mixins.CreateModelMixin, mixins.DestroyModelMixin, mixins.RetrieveModelMixin, @@ -696,15 +733,35 @@ class TemplateViewSet( ): """Template ViewSet""" + filter_backends = [filters.OrderingFilter] permission_classes = [ permissions.IsAuthenticatedOrSafe, permissions.AccessPermission, ] + ordering = ["-created_at"] + ordering_fields = ["created_at", "updated_at", "title"] serializer_class = serializers.TemplateSerializer - access_model_class = models.TemplateAccess - resource_field_name = "template" queryset = models.Template.objects.all() + def get_queryset(self): + """Custom queryset to get user related templates.""" + queryset = super().get_queryset() + user = self.request.user + + if not user.is_authenticated: + return queryset + + user_roles_query = ( + models.TemplateAccess.objects.filter( + Q(user=user) | Q(team__in=user.teams), + template_id=OuterRef("pk"), + ) + .values("template") + .annotate(roles_array=ArrayAgg("role")) + .values("roles_array") + ) + return queryset.annotate(user_roles=Subquery(user_roles_query)).distinct() + def list(self, request, *args, **kwargs): """Restrict templates returned by the list endpoint""" queryset = self.filter_queryset(self.get_queryset()) @@ -726,6 +783,15 @@ class TemplateViewSet( serializer = self.get_serializer(queryset, many=True) return drf_response.Response(serializer.data) + def perform_create(self, serializer): + """Set the current user as owner of the newly created object.""" + obj = serializer.save() + models.TemplateAccess.objects.create( + template=obj, + user=self.request.user, + role=models.RoleChoices.OWNER, + ) + @decorators.action( detail=True, methods=["post"], diff --git a/src/backend/core/factories.py b/src/backend/core/factories.py index 2ca7d17a..a825e598 100644 --- a/src/backend/core/factories.py +++ b/src/backend/core/factories.py @@ -80,6 +80,13 @@ class DocumentFactory(factory.django.DjangoModelFactory): for item in extracted: models.LinkTrace.objects.create(document=self, user=item) + @factory.post_generation + def favorited_by(self, create, extracted, **kwargs): + """Mark document as favorited by a list of users.""" + if create and extracted: + for item in extracted: + models.DocumentFavorite.objects.create(document=self, user=item) + class UserDocumentAccessFactory(factory.django.DjangoModelFactory): """Create fake document user accesses for testing.""" diff --git a/src/backend/core/migrations/0009_add_document_favorite.py b/src/backend/core/migrations/0009_add_document_favorite.py new file mode 100644 index 00000000..9254f9fb --- /dev/null +++ b/src/backend/core/migrations/0009_add_document_favorite.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.2 on 2024-11-08 07:59 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0008_alter_document_link_reach'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='language', + field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'), + ), + migrations.CreateModel( + name='DocumentFavorite', + 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')), + ('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorited_by_users', to='core.document')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorite_documents', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Document favorite', + 'verbose_name_plural': 'Document favorites', + 'db_table': 'impress_document_favorite', + 'constraints': [models.UniqueConstraint(fields=('user', 'document'), name='unique_document_favorite_user', violation_error_message='This document is already targeted by a favorite relation instance for the same user.')], + }, + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 73246705..70fedb7f 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -518,6 +518,7 @@ class Document(BaseModel): "ai_translate": is_owner_or_admin or is_editor, "attachment_upload": is_owner_or_admin or is_editor, "destroy": RoleChoices.OWNER in roles, + "favorite": can_get and user.is_authenticated, "link_configuration": is_owner_or_admin, "invite_owner": RoleChoices.OWNER in roles, "partial_update": is_owner_or_admin or is_editor, @@ -600,6 +601,37 @@ class LinkTrace(BaseModel): return f"{self.user!s} trace on document {self.document!s}" +class DocumentFavorite(BaseModel): + """Relation model to store a user's favorite documents.""" + + document = models.ForeignKey( + Document, + on_delete=models.CASCADE, + related_name="favorited_by_users", + ) + user = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="favorite_documents" + ) + + class Meta: + db_table = "impress_document_favorite" + verbose_name = _("Document favorite") + verbose_name_plural = _("Document favorites") + constraints = [ + models.UniqueConstraint( + fields=["user", "document"], + name="unique_document_favorite_user", + violation_error_message=_( + "This document is already targeted by a favorite relation instance " + "for the same user." + ), + ), + ] + + def __str__(self): + return f"{self.user!s} favorite on document {self.document!s}" + + class DocumentAccess(BaseAccess): """Relation model to give access to a document for a user or a team with a role.""" diff --git a/src/backend/core/tests/documents/test_api_documents_favorite.py b/src/backend/core/tests/documents/test_api_documents_favorite.py new file mode 100644 index 00000000..e0359b3d --- /dev/null +++ b/src/backend/core/tests/documents/test_api_documents_favorite.py @@ -0,0 +1,308 @@ +"""Test favorite document API endpoint for users in impress's core app.""" + +import pytest +from rest_framework.test import APIClient + +from core import factories, models + +pytestmark = pytest.mark.django_db + + +@pytest.mark.parametrize( + "reach", + [ + "restricted", + "authenticated", + "public", + ], +) +@pytest.mark.parametrize("method", ["post", "delete"]) +def test_api_document_favorite_anonymous_user(method, reach): + """Anonymous users should not be able to mark/unmark documents as favorites.""" + document = factories.DocumentFactory(link_reach=reach) + + response = getattr(APIClient(), method)( + f"/api/v1.0/documents/{document.id!s}/favorite/" + ) + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + # Verify in database + assert models.DocumentFavorite.objects.exists() is False + + +@pytest.mark.parametrize( + "reach, has_role", + [ + ["restricted", True], + ["authenticated", False], + ["authenticated", True], + ["public", False], + ["public", True], + ], +) +def test_api_document_favorite_authenticated_post_allowed(reach, has_role): + """Authenticated users should be able to mark a document as favorite using POST.""" + user = factories.UserFactory() + document = factories.DocumentFactory(link_reach=reach) + client = APIClient() + client.force_login(user) + + if has_role: + models.DocumentAccess.objects.create(document=document, user=user) + + # Mark as favorite + response = client.post(f"/api/v1.0/documents/{document.id!s}/favorite/") + + assert response.status_code == 201 + assert response.json() == {"detail": "Document marked as favorite"} + + # Verify in database + assert models.DocumentFavorite.objects.filter(document=document, user=user).exists() + + # Verify document format + response = client.get(f"/api/v1.0/documents/{document.id!s}/") + assert response.json()["is_favorite"] is True + + +def test_api_document_favorite_authenticated_post_forbidden(): + """Authenticated users should be able to mark a document as favorite using POST.""" + user = factories.UserFactory() + document = factories.DocumentFactory(link_reach="restricted") + client = APIClient() + client.force_login(user) + + # Try marking as favorite + response = client.post(f"/api/v1.0/documents/{document.id!s}/favorite/") + + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + # Verify in database + assert ( + models.DocumentFavorite.objects.filter(document=document, user=user).exists() + is False + ) + + +@pytest.mark.parametrize( + "reach, has_role", + [ + ["restricted", True], + ["authenticated", False], + ["authenticated", True], + ["public", False], + ["public", True], + ], +) +def test_api_document_favorite_authenticated_post_already_favorited_allowed( + reach, has_role +): + """POST should not create duplicate favorites if already marked.""" + user = factories.UserFactory() + document = factories.DocumentFactory(link_reach=reach, favorited_by=[user]) + client = APIClient() + client.force_login(user) + + if has_role: + models.DocumentAccess.objects.create(document=document, user=user) + + # Try to mark as favorite again + response = client.post(f"/api/v1.0/documents/{document.id!s}/favorite/") + + assert response.status_code == 200 + assert response.json() == {"detail": "Document already marked as favorite"} + + # Verify in database + assert models.DocumentFavorite.objects.filter(document=document, user=user).exists() + + # Verify document format + response = client.get(f"/api/v1.0/documents/{document.id!s}/") + assert response.json()["is_favorite"] is True + + +def test_api_document_favorite_authenticated_post_already_favorited_forbidden(): + """POST should not create duplicate favorites if already marked.""" + user = factories.UserFactory() + document = factories.DocumentFactory(link_reach="restricted", favorited_by=[user]) + client = APIClient() + client.force_login(user) + + # Try to mark as favorite again + response = client.post(f"/api/v1.0/documents/{document.id!s}/favorite/") + + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + # Verify in database + assert models.DocumentFavorite.objects.filter(document=document, user=user).exists() + + +@pytest.mark.parametrize( + "reach, has_role", + [ + ["restricted", True], + ["authenticated", False], + ["authenticated", True], + ["public", False], + ["public", True], + ], +) +def test_api_document_favorite_authenticated_delete_allowed(reach, has_role): + """Authenticated users should be able to unmark a document as favorite using DELETE.""" + user = factories.UserFactory() + document = factories.DocumentFactory(link_reach=reach, favorited_by=[user]) + client = APIClient() + client.force_login(user) + + if has_role: + models.DocumentAccess.objects.create(document=document, user=user) + + # Unmark as favorite + response = client.delete(f"/api/v1.0/documents/{document.id!s}/favorite/") + assert response.status_code == 204 + + # Verify in database + assert ( + models.DocumentFavorite.objects.filter(document=document, user=user).exists() + is False + ) + + # Verify document format + response = client.get(f"/api/v1.0/documents/{document.id!s}/") + assert response.json()["is_favorite"] is False + + +def test_api_document_favorite_authenticated_delete_forbidden(): + """Authenticated users should be able to unmark a document as favorite using DELETE.""" + user = factories.UserFactory() + document = factories.DocumentFactory(link_reach="restricted", favorited_by=[user]) + client = APIClient() + client.force_login(user) + + # Unmark as favorite + response = client.delete(f"/api/v1.0/documents/{document.id!s}/favorite/") + + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + # Verify in database + assert ( + models.DocumentFavorite.objects.filter(document=document, user=user).exists() + is True + ) + + +@pytest.mark.parametrize( + "reach, has_role", + [ + ["restricted", True], + ["authenticated", False], + ["authenticated", True], + ["public", False], + ["public", True], + ], +) +def test_api_document_favorite_authenticated_delete_not_favorited_allowed( + reach, has_role +): + """DELETE should be idempotent if the document is not marked as favorite.""" + user = factories.UserFactory() + document = factories.DocumentFactory(link_reach=reach) + client = APIClient() + client.force_login(user) + + if has_role: + models.DocumentAccess.objects.create(document=document, user=user) + + # Try to unmark as favorite when no favorite entry exists + response = client.delete(f"/api/v1.0/documents/{document.id!s}/favorite/") + + assert response.status_code == 200 + assert response.json() == {"detail": "Document was already not marked as favorite"} + + # Verify in database + assert ( + models.DocumentFavorite.objects.filter(document=document, user=user).exists() + is False + ) + + # Verify document format + response = client.get(f"/api/v1.0/documents/{document.id!s}/") + assert response.json()["is_favorite"] is False + + +def test_api_document_favorite_authenticated_delete_not_favorited_forbidden(): + """DELETE should be idempotent if the document is not marked as favorite.""" + user = factories.UserFactory() + document = factories.DocumentFactory(link_reach="restricted") + client = APIClient() + client.force_login(user) + + # Try to unmark as favorite when no favorite entry exists + response = client.delete(f"/api/v1.0/documents/{document.id!s}/favorite/") + + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + # Verify in database + assert ( + models.DocumentFavorite.objects.filter(document=document, user=user).exists() + is False + ) + + +@pytest.mark.parametrize( + "reach, has_role", + [ + ["restricted", True], + ["authenticated", False], + ["authenticated", True], + ["public", False], + ["public", True], + ], +) +def test_api_document_favorite_authenticated_post_unmark_then_mark_again_allowed( + reach, has_role +): + """A user should be able to mark, unmark, and mark a document again as favorite.""" + user = factories.UserFactory() + document = factories.DocumentFactory(link_reach=reach) + client = APIClient() + client.force_login(user) + + if has_role: + models.DocumentAccess.objects.create(document=document, user=user) + + url = f"/api/v1.0/documents/{document.id!s}/favorite/" + + # Mark as favorite + response = client.post(url) + assert response.status_code == 201 + + # Unmark as favorite + response = client.delete(url) + assert response.status_code == 204 + + # Mark as favorite again + response = client.post(url) + assert response.status_code == 201 + assert response.json() == {"detail": "Document marked as favorite"} + + # Verify in database + assert models.DocumentFavorite.objects.filter(document=document, user=user).exists() + + # Verify document format + response = client.get(f"/api/v1.0/documents/{document.id!s}/") + assert response.json()["is_favorite"] is True diff --git a/src/backend/core/tests/documents/test_api_documents_list.py b/src/backend/core/tests/documents/test_api_documents_list.py index 2c30b77c..9e71ef0a 100644 --- a/src/backend/core/tests/documents/test_api_documents_list.py +++ b/src/backend/core/tests/documents/test_api_documents_list.py @@ -32,6 +32,42 @@ def test_api_documents_list_anonymous(reach, role): assert len(results) == 0 +def test_api_documents_list_format(): + """Validate the format of documents as returned by the list view.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory( + users=[user, *factories.UserFactory.create_batch(2)], + favorited_by=[user, *factories.UserFactory.create_batch(2)], + ) + + response = client.get("/api/v1.0/documents/") + + assert response.status_code == 200 + content = response.json() + results = content.pop("results") + assert content == { + "count": 1, + "next": None, + "previous": None, + } + assert len(results) == 1 + assert results[0] == { + "id": str(document.id), + "abilities": document.get_abilities(user), + "content": document.content, + "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "is_favorite": True, + "link_reach": document.link_reach, + "link_role": document.link_role, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), + } + + def test_api_documents_list_authenticated_direct(django_assert_num_queries): """ Authenticated users should be able to list documents they are a direct @@ -264,6 +300,8 @@ def test_api_documents_list_ordering_by_fields(): for parameter in [ "created_at", "-created_at", + "is_favorite", + "-is_favorite", "updated_at", "-updated_at", "title", @@ -282,3 +320,45 @@ def test_api_documents_list_ordering_by_fields(): compare = operator.ge if is_descending else operator.le for i in range(4): assert compare(results[i][field], results[i + 1][field]) + + +def test_api_documents_list_favorites_no_extra_queries(django_assert_num_queries): + """ + Ensure that marking documents as favorite does not generate additional queries + when fetching the document list. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + special_documents = factories.DocumentFactory.create_batch(3, users=[user]) + factories.DocumentFactory.create_batch(2, users=[user]) + + url = "/api/v1.0/documents/" + with django_assert_num_queries(3): + response = client.get(url) + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 5 + + assert all(result["is_favorite"] is False for result in results) + + # Mark documents as favorite and check results again + for document in special_documents: + models.DocumentFavorite.objects.create(document=document, user=user) + + with django_assert_num_queries(3): + response = client.get(url) + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 5 + + # Check if the "is_favorite" annotation is correctly set for the favorited documents + favorited_ids = {str(doc.id) for doc in special_documents} + for result in results: + if result["id"] in favorited_ids: + assert result["is_favorite"] is True + else: + assert result["is_favorite"] is False diff --git a/src/backend/core/tests/documents/test_api_documents_retrieve.py b/src/backend/core/tests/documents/test_api_documents_retrieve.py index ad46611d..adad3780 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve.py +++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -27,6 +27,8 @@ def test_api_documents_retrieve_anonymous_public(): "ai_translate": document.link_role == "editor", "attachment_upload": document.link_role == "editor", "destroy": False, + # Anonymous user can't favorite a document even with read access + "favorite": False, "invite_owner": False, "link_configuration": False, "partial_update": document.link_role == "editor", @@ -38,7 +40,7 @@ def test_api_documents_retrieve_anonymous_public(): }, "content": document.content, "created_at": document.created_at.isoformat().replace("+00:00", "Z"), - "is_user_favorite": False, + "is_favorite": False, "link_reach": "public", "link_role": document.link_role, "title": document.title, @@ -84,9 +86,10 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated( "ai_transform": document.link_role == "editor", "ai_translate": document.link_role == "editor", "attachment_upload": document.link_role == "editor", - "link_configuration": False, "destroy": False, + "favorite": True, "invite_owner": False, + "link_configuration": False, "partial_update": document.link_role == "editor", "retrieve": True, "update": document.link_role == "editor", @@ -94,12 +97,12 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated( "versions_list": False, "versions_retrieve": False, }, - "is_user_favorite": False, + "content": document.content, + "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "is_favorite": False, "link_reach": reach, "link_role": document.link_role, "title": document.title, - "content": document.content, - "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), } assert ( @@ -179,12 +182,13 @@ def test_api_documents_retrieve_authenticated_related_direct(): assert response.status_code == 200 assert response.json() == { "id": str(document.id), - "title": document.title, - "content": document.content, "abilities": document.get_abilities(user), + "content": document.content, + "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "is_favorite": False, "link_reach": document.link_reach, "link_role": document.link_role, - "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "title": document.title, "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), } @@ -267,12 +271,13 @@ def test_api_documents_retrieve_authenticated_related_team_members( assert response.status_code == 200 assert response.json() == { "id": str(document.id), - "title": document.title, - "content": document.content, "abilities": document.get_abilities(user), + "content": document.content, + "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "is_favorite": False, "link_reach": "restricted", "link_role": document.link_role, - "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "title": document.title, "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), } @@ -320,12 +325,13 @@ def test_api_documents_retrieve_authenticated_related_team_administrators( assert response.status_code == 200 assert response.json() == { "id": str(document.id), - "title": document.title, - "content": document.content, "abilities": document.get_abilities(user), + "content": document.content, + "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "is_favorite": False, "link_reach": "restricted", "link_role": document.link_role, - "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "title": document.title, "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), } @@ -374,11 +380,12 @@ def test_api_documents_retrieve_authenticated_related_team_owners( assert response.status_code == 200 assert response.json() == { "id": str(document.id), - "title": document.title, - "content": document.content, "abilities": document.get_abilities(user), + "content": document.content, + "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "is_favorite": False, "link_reach": "restricted", "link_role": document.link_role, - "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "title": document.title, "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), } diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index 0859db69..a06c6cc5 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -88,9 +88,10 @@ def test_models_documents_get_abilities_forbidden(is_authenticated, reach, role) "ai_transform": False, "ai_translate": False, "attachment_upload": False, - "link_configuration": False, "destroy": False, + "favorite": False, "invite_owner": False, + "link_configuration": False, "partial_update": False, "retrieve": False, "update": False, @@ -123,8 +124,9 @@ def test_models_documents_get_abilities_reader(is_authenticated, reach): "ai_translate": False, "attachment_upload": False, "destroy": False, - "link_configuration": False, + "favorite": is_authenticated, "invite_owner": False, + "link_configuration": False, "partial_update": False, "retrieve": True, "update": False, @@ -157,8 +159,9 @@ def test_models_documents_get_abilities_editor(is_authenticated, reach): "ai_translate": True, "attachment_upload": True, "destroy": False, - "link_configuration": False, + "favorite": is_authenticated, "invite_owner": False, + "link_configuration": False, "partial_update": True, "retrieve": True, "update": True, @@ -180,8 +183,9 @@ def test_models_documents_get_abilities_owner(): "ai_translate": True, "attachment_upload": True, "destroy": True, - "link_configuration": True, + "favorite": True, "invite_owner": True, + "link_configuration": True, "partial_update": True, "retrieve": True, "update": True, @@ -202,8 +206,9 @@ def test_models_documents_get_abilities_administrator(): "ai_translate": True, "attachment_upload": True, "destroy": False, - "link_configuration": True, + "favorite": True, "invite_owner": False, + "link_configuration": True, "partial_update": True, "retrieve": True, "update": True, @@ -227,8 +232,9 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries): "ai_translate": True, "attachment_upload": True, "destroy": False, - "link_configuration": False, + "favorite": True, "invite_owner": False, + "link_configuration": False, "partial_update": True, "retrieve": True, "update": True, @@ -254,8 +260,9 @@ def test_models_documents_get_abilities_reader_user(django_assert_num_queries): "ai_translate": False, "attachment_upload": False, "destroy": False, - "link_configuration": False, + "favorite": True, "invite_owner": False, + "link_configuration": False, "partial_update": False, "retrieve": True, "update": False, @@ -282,8 +289,9 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries): "ai_translate": False, "attachment_upload": False, "destroy": False, - "link_configuration": False, + "favorite": True, "invite_owner": False, + "link_configuration": False, "partial_update": False, "retrieve": True, "update": False,