(backend) add limit on distinct reactions per comment

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 <boukerfa.ma@gmail.com>
This commit is contained in:
Mohamed El Amine BOUKERFA
2026-06-15 08:56:45 +02:00
committed by Manuel Raynaud
parent 0f984d6832
commit 867583a52a
7 changed files with 92 additions and 2 deletions
+1
View File
@@ -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
+25 -2
View File
@@ -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(
+5
View File
@@ -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."""
@@ -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
@@ -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,
+6
View File
@@ -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",
@@ -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,