Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ and this project adheres to
## [Unreleased]

### Added

- ✨(backend) add limit on distinct reactions per comment #1978
- ✨(backend) create a dedicated endpoint to update document content
- ⚡️(backend) stream s3 file content with a dedicated endpoint

Expand Down
29 changes: 27 additions & 2 deletions src/backend/core/api/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -2838,6 +2838,7 @@ def get(self, request):
"POSTHOG_KEY",
"LANGUAGES",
"LANGUAGE_CODE",
"REACTIONS_MAX_PER_COMMENT",
"SENTRY_DSN",
"TRASHBIN_CUTOFF_DAYS",
]
Expand Down Expand Up @@ -2955,7 +2956,11 @@ 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."""
Expand Down Expand Up @@ -2989,9 +2994,29 @@ def reactions(self, request, *args, **kwargs):
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(
Expand Down
5 changes: 5 additions & 0 deletions src/backend/core/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,11 @@ class Meta:
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."""
Expand Down
53 changes: 53 additions & 0 deletions src/backend/core/tests/documents/test_api_documents_comments.py
Original file line number Diff line number Diff line change
Expand Up @@ -934,3 +934,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
1 change: 1 addition & 0 deletions src/backend/core/tests/test_api_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ def test_api_config(is_authenticated):
"LANGUAGE_CODE": "en-us",
"MEDIA_BASE_URL": "http://testserver/",
"POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"},
"REACTIONS_MAX_PER_COMMENT": 15,
"SENTRY_DSN": "https://sentry.test/123",
"TRASHBIN_CUTOFF_DAYS": 30,
"theme_customization": {},
Expand Down
6 changes: 6 additions & 0 deletions src/backend/impress/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export interface ConfigResponse {
MEDIA_BASE_URL?: string;
POSTHOG_KEY?: PostHogConf;
SENTRY_DSN?: string;
REACTIONS_MAX_PER_COMMENT: number;
TRASHBIN_CUTOFF_DAYS?: number;
theme_customization?: ThemeCustomization;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export class DocsThreadStoreAuth extends ThreadStoreAuth {
constructor(
private readonly userId: string,
public canSee: boolean,
private readonly maxReactions: number,
) {
super();
}
Expand Down Expand Up @@ -68,13 +69,27 @@ export class DocsThreadStoreAuth extends ThreadStoreAuth {
}

if (!emoji) {
return true;
return comment.reactions.length < this.maxReactions;
}

return !comment.reactions.some(
const hasReactedWithEmoji = comment.reactions.some(
(reaction) =>
reaction.emoji === emoji && reaction.userIds.includes(this.userId),
);

if (hasReactedWithEmoji) {
return false;
}

const reactionExists = comment.reactions.some(
(reaction) => reaction.emoji === emoji,
);

if (reactionExists) {
return true;
}

return comment.reactions.length < this.maxReactions;
}

canDeleteReaction(comment: ClientCommentData, emoji?: string): boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useCunninghamTheme } from '@/cunningham';
import { User, avatarUrlFromName } from '@/features/auth';
import { useEditorStore } from '@/features/docs/doc-editor/stores';
import { Doc, useProviderStore } from '@/features/docs/doc-management';
import { useConfig } from '@/core';

import { DocsThreadStore } from './DocsThreadStore';
import { DocsThreadStoreAuth } from './DocsThreadStoreAuth';
Expand All @@ -18,6 +19,7 @@ export function useComments(
const { t } = useTranslation();
const { themeTokens } = useCunninghamTheme();
const { setThreadStore } = useEditorStore();
const { data: config } = useConfig();

const threadStore = useMemo(() => {
return new DocsThreadStore(
Expand All @@ -26,9 +28,16 @@ export function useComments(
new DocsThreadStoreAuth(
encodeURIComponent(user?.full_name || ''),
canComment,
config?.REACTIONS_MAX_PER_COMMENT ?? 0,
),
);
}, [docId, canComment, provider?.awareness, user?.full_name]);
}, [
docId,
canComment,
provider?.awareness,
user?.full_name,
config?.REACTIONS_MAX_PER_COMMENT,
]);

useEffect(() => {
if (canComment) {
Expand Down