From 867583a52af729e96eb021024f11fd830332870a Mon Sep 17 00:00:00 2001 From: Mohamed El Amine BOUKERFA Date: Wed, 11 Mar 2026 22:50:11 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20add=20limit=20on=20distinc?= =?UTF-8?q?t=20reactions=20per=20comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement a configurable limit (default: 15) on the number of distinct emoji reactions per comment. - Backend validation ensures the limit cannot be exceeded via API Signed-off-by: Mohamed El Amine BOUKERFA --- CHANGELOG.md | 1 + src/backend/core/api/viewsets.py | 27 +++++++++- src/backend/core/factories.py | 5 ++ .../documents/test_api_documents_comments.py | 53 +++++++++++++++++++ src/backend/core/tests/test_api_config.py | 1 + src/backend/impress/settings.py | 6 +++ .../e2e/__tests__/app-impress/utils-common.ts | 1 + 7 files changed, 92 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1968d8267..06e06f8c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to ### Added +- ✨(backend) add limit on distinct reactions per comment #1978 - ✨(frontend) leave a document #2410 - ✨(frontend) add top parent on sub docs search #1952 - ✨(frontend) unauthenticated users can search #2407 diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 1476831d3..b3a923438 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -3088,6 +3088,7 @@ class ConfigView(drf.views.APIView): "POSTHOG_HOST", "LANGUAGES", "LANGUAGE_CODE", + "REACTIONS_MAX_PER_COMMENT", "SENTRY_DSN", "TRASHBIN_CUTOFF_DAYS", ] @@ -3233,7 +3234,9 @@ class CommentViewSet( permission_classes = [permissions.CommentPermission] pagination_class = Pagination serializer_class = serializers.CommentSerializer - queryset = models.Comment.objects.select_related("user").all() + queryset = models.Comment.objects.select_related("user").prefetch_related( + "reactions__users" + ).all() def get_queryset(self): """Override to filter on related resource.""" @@ -3279,9 +3282,29 @@ class CommentViewSet( serializer.is_valid(raise_exception=True) if request.method == "POST": + emoji = serializer.validated_data["emoji"] + + if ( + not models.Reaction.objects.filter( + comment=comment, emoji=emoji + ).exists() + and comment.reactions.count() >= settings.REACTIONS_MAX_PER_COMMENT + ): + return drf.response.Response( + { + "emoji": [ + _( + "A comment can have a maximum of %(max)d distinct reactions." + ) + % {"max": settings.REACTIONS_MAX_PER_COMMENT} + ] + }, + status=status.HTTP_400_BAD_REQUEST, + ) + reaction, created = models.Reaction.objects.get_or_create( comment=comment, - emoji=serializer.validated_data["emoji"], + emoji=emoji, ) if not created and reaction.users.filter(id=request.user.id).exists(): return drf.response.Response( diff --git a/src/backend/core/factories.py b/src/backend/core/factories.py index bf7ee0428..eeefa8f4b 100644 --- a/src/backend/core/factories.py +++ b/src/backend/core/factories.py @@ -227,6 +227,11 @@ class ReactionFactory(factory.django.DjangoModelFactory): comment = factory.SubFactory(CommentFactory) emoji = factory.Faker("emoji") + @classmethod + def generate_emojis(cls, n=10): + """Generate a list of n unique emojis.""" + return [fake.unique.emoji() for _ in range(n)] + @factory.post_generation def users(self, create, extracted, **kwargs): """Add users to reaction from a given list of users or create one if not provided.""" diff --git a/src/backend/core/tests/documents/test_api_documents_comments.py b/src/backend/core/tests/documents/test_api_documents_comments.py index ec681f1ed..e9a3b2eeb 100644 --- a/src/backend/core/tests/documents/test_api_documents_comments.py +++ b/src/backend/core/tests/documents/test_api_documents_comments.py @@ -957,3 +957,56 @@ def test_delete_reaction_owned_by_the_current_user(): reaction.refresh_from_db() assert reaction.users.exists() + + +def test_create_reaction_exceeds_maximum(settings): + """ + Users should not be able to add more than REACTIONS_MAX_PER_COMMENT + (here we set it to 10) distinct emoji reactions to a comment. + They should, however, be able to add themselves to an existing reaction. + """ + user1 = factories.UserFactory() + user2 = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + users=[(user1, models.RoleChoices.ADMIN), (user2, models.RoleChoices.ADMIN)], + ) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) + + client = APIClient() + client.force_login(user1) + + # Add max distinct reactions + max_reactions = settings.REACTIONS_MAX_PER_COMMENT + emojis = factories.ReactionFactory.generate_emojis(max_reactions + 1) + for emoji in emojis[:max_reactions]: + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/" + f"comments/{comment.id!s}/reactions/", + {"emoji": emoji}, + ) + assert response.status_code == 201 + + # Attempt to add another distinct reaction + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/" + f"comments/{comment.id!s}/reactions/", + {"emoji": emojis[max_reactions]}, + ) + assert response.status_code == 400 + expected_message = ( + f"A comment can have a maximum of {max_reactions} distinct reactions." + ) + assert response.json() == {"emoji": [expected_message]} + + # Attempt to add user2 to one of the existing reactions (should succeed) + client.force_login(user2) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/" + f"comments/{comment.id!s}/reactions/", + {"emoji": emojis[0]}, + ) + assert response.status_code == 201 + reaction = models.Reaction.objects.get(comment=comment, emoji=emojis[0]) + assert reaction.users.count() == 2 diff --git a/src/backend/core/tests/test_api_config.py b/src/backend/core/tests/test_api_config.py index 571af85ab..3b5f70855 100644 --- a/src/backend/core/tests/test_api_config.py +++ b/src/backend/core/tests/test_api_config.py @@ -80,6 +80,7 @@ def test_api_config(is_authenticated): "MEDIA_BASE_URL": "http://testserver/", "POSTHOG_KEY": "132456", "POSTHOG_HOST": "https://eu.i.posthog-test.com", + "REACTIONS_MAX_PER_COMMENT": 15, "RELEASE_VERSION": "1.0.0", "SENTRY_DSN": "https://sentry.test/123", "TRASHBIN_CUTOFF_DAYS": 30, diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 991dfae06..8ba5b1149 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -197,6 +197,12 @@ class Base(Configuration): environ_prefix=None, ) + REACTIONS_MAX_PER_COMMENT = values.IntegerValue( + 15, + environ_name="REACTIONS_MAX_PER_COMMENT", + environ_prefix=None, + ) + DOCUMENT_UNSAFE_MIME_TYPES = [ # Executable Files "application/x-msdownload", diff --git a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts index 581cf56ed..39812b915 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts @@ -42,6 +42,7 @@ export const CONFIG = { LANGUAGE_CODE: 'en-us', POSTHOG_HOST: 'https://eu.i.posthog.com', POSTHOG_KEY: null, + REACTIONS_MAX_PER_COMMENT: 15, RELEASE_VERSION: packageJsonVersion, SENTRY_DSN: null, TRASHBIN_CUTOFF_DAYS: 30,