Skip to content

Commit c48c9c5

Browse files
committed
✨(backend) implement thread and reactions API
In order to use comment we also have to implement a thread and reactions API. A thread has multiple comments and comments can have multiple reactions.
1 parent abc449f commit c48c9c5

16 files changed

+2415
-433
lines changed

src/backend/core/api/serializers.py

Lines changed: 89 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -891,45 +891,122 @@ class MoveDocumentSerializer(serializers.Serializer):
891891
)
892892

893893

894+
class ReactionSerializer(serializers.ModelSerializer):
895+
"""Serialize reactions."""
896+
897+
users = UserLightSerializer(many=True, read_only=True)
898+
899+
class Meta:
900+
model = models.Reaction
901+
fields = [
902+
"id",
903+
"emoji",
904+
"created_at",
905+
"users",
906+
]
907+
read_only_fields = ["id", "created_at", "users"]
908+
909+
894910
class CommentSerializer(serializers.ModelSerializer):
895-
"""Serialize comments."""
911+
"""Serialize comments (nested under a thread) with reactions and abilities."""
896912

897913
user = UserLightSerializer(read_only=True)
898-
abilities = serializers.SerializerMethodField(read_only=True)
914+
abilities = serializers.SerializerMethodField()
915+
reactions = ReactionSerializer(many=True, read_only=True)
899916

900917
class Meta:
901918
model = models.Comment
902919
fields = [
903920
"id",
904-
"content",
921+
"user",
922+
"body",
905923
"created_at",
906924
"updated_at",
907-
"user",
908-
"document",
925+
"reactions",
909926
"abilities",
910927
]
911928
read_only_fields = [
912929
"id",
930+
"user",
913931
"created_at",
914932
"updated_at",
915-
"user",
916-
"document",
933+
"reactions",
917934
"abilities",
918935
]
919936

920-
def get_abilities(self, comment) -> dict:
921-
"""Return abilities of the logged-in user on the instance."""
937+
def validate(self, attrs):
938+
"""Validate comment data."""
939+
940+
request = self.context.get("request")
941+
user = getattr(request, "user", None)
942+
943+
attrs["thread_id"] = self.context["thread_id"]
944+
attrs["user_id"] = user.id if user else None
945+
return attrs
946+
947+
def get_abilities(self, obj):
948+
"""Return comment's abilities."""
922949
request = self.context.get("request")
923950
if request:
924-
return comment.get_abilities(request.user)
951+
return obj.get_abilities(request.user)
925952
return {}
926953

954+
955+
class ThreadSerializer(serializers.ModelSerializer):
956+
"""Serialize threads in a backward compatible shape for current frontend.
957+
958+
We expose a flatten representation where ``content`` maps to the first
959+
comment's body. Creating a thread requires a ``content`` field which is
960+
stored as the first comment.
961+
"""
962+
963+
creator = UserLightSerializer(read_only=True)
964+
abilities = serializers.SerializerMethodField(read_only=True)
965+
body = serializers.JSONField(write_only=True, required=True)
966+
comments = serializers.SerializerMethodField(read_only=True)
967+
comments = CommentSerializer(many=True, read_only=True)
968+
969+
class Meta:
970+
model = models.Thread
971+
fields = [
972+
"id",
973+
"body",
974+
"created_at",
975+
"updated_at",
976+
"creator",
977+
"abilities",
978+
"comments",
979+
"resolved",
980+
"resolved_at",
981+
"resolved_by",
982+
"metadata",
983+
]
984+
read_only_fields = [
985+
"id",
986+
"created_at",
987+
"updated_at",
988+
"creator",
989+
"abilities",
990+
"comments",
991+
"resolved",
992+
"resolved_at",
993+
"resolved_by",
994+
"metadata",
995+
]
996+
927997
def validate(self, attrs):
928-
"""Validate invitation data."""
998+
"""Validate thread data."""
929999
request = self.context.get("request")
9301000
user = getattr(request, "user", None)
9311001

9321002
attrs["document_id"] = self.context["resource_id"]
933-
attrs["user_id"] = user.id if user else None
1003+
attrs["creator_id"] = user.id if user else None
9341004

9351005
return attrs
1006+
1007+
def get_abilities(self, thread):
1008+
"""Return thread's abilities."""
1009+
request = self.context.get("request")
1010+
if request:
1011+
return thread.get_abilities(request.user)
1012+
return {}

src/backend/core/api/viewsets.py

Lines changed: 109 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from django.db.models.functions import Left, Length
2222
from django.http import Http404, StreamingHttpResponse
2323
from django.urls import reverse
24+
from django.utils import timezone
2425
from django.utils.functional import cached_property
2526
from django.utils.text import capfirst, slugify
2627
from django.utils.translation import gettext_lazy as _
@@ -2238,15 +2239,9 @@ def _load_theme_customization(self):
22382239
return theme_customization
22392240

22402241

2241-
class CommentViewSet(
2242-
viewsets.ModelViewSet,
2243-
):
2244-
"""API ViewSet for comments."""
2242+
class CommentViewSetMixin:
2243+
"""Comment ViewSet Mixin."""
22452244

2246-
permission_classes = [permissions.CommentPermission]
2247-
queryset = models.Comment.objects.select_related("user", "document").all()
2248-
serializer_class = serializers.CommentSerializer
2249-
pagination_class = Pagination
22502245
_document = None
22512246

22522247
def get_document_or_404(self):
@@ -2260,12 +2255,114 @@ def get_document_or_404(self):
22602255
raise drf.exceptions.NotFound("Document not found.") from e
22612256
return self._document
22622257

2258+
2259+
class ThreadViewSet(
2260+
ResourceAccessViewsetMixin,
2261+
CommentViewSetMixin,
2262+
drf.mixins.CreateModelMixin,
2263+
drf.mixins.ListModelMixin,
2264+
drf.mixins.RetrieveModelMixin,
2265+
drf.mixins.DestroyModelMixin,
2266+
viewsets.GenericViewSet,
2267+
):
2268+
"""Thread API: list/create threads and nested comment operations."""
2269+
2270+
permission_classes = [permissions.CommentPermission]
2271+
pagination_class = Pagination
2272+
serializer_class = serializers.ThreadSerializer
2273+
queryset = models.Thread.objects.select_related("creator", "document").filter(
2274+
resolved=False
2275+
)
2276+
resource_field_name = "document"
2277+
2278+
def perform_create(self, serializer):
2279+
"""Create the first comment of the thread."""
2280+
body = serializer.validated_data["body"]
2281+
del serializer.validated_data["body"]
2282+
thread = serializer.save()
2283+
2284+
models.Comment.objects.create(
2285+
thread=thread,
2286+
user=self.request.user if self.request.user.is_authenticated else None,
2287+
body=body,
2288+
)
2289+
2290+
@drf.decorators.action(detail=True, methods=["post"], url_path="resolve")
2291+
def resolve(self, request, *args, **kwargs):
2292+
"""Resolve a thread."""
2293+
thread = self.get_object()
2294+
if not thread.resolved:
2295+
thread.resolved = True
2296+
thread.resolved_at = timezone.now()
2297+
thread.resolved_by = request.user
2298+
thread.save(update_fields=["resolved", "resolved_at", "resolved_by"])
2299+
return drf.response.Response(status=status.HTTP_204_NO_CONTENT)
2300+
2301+
2302+
class CommentViewSet(
2303+
CommentViewSetMixin,
2304+
viewsets.ModelViewSet,
2305+
):
2306+
"""Comment API: list/create comments and nested reaction operations."""
2307+
2308+
permission_classes = [permissions.CommentPermission]
2309+
pagination_class = Pagination
2310+
serializer_class = serializers.CommentSerializer
2311+
queryset = models.Comment.objects.select_related("user").all()
2312+
2313+
def get_queryset(self):
2314+
"""Override to filter on related resource."""
2315+
return (
2316+
super()
2317+
.get_queryset()
2318+
.filter(
2319+
thread=self.kwargs["thread_id"],
2320+
thread__document=self.kwargs["resource_id"],
2321+
)
2322+
)
2323+
22632324
def get_serializer_context(self):
22642325
"""Extra context provided to the serializer class."""
22652326
context = super().get_serializer_context()
2266-
context["resource_id"] = self.kwargs["resource_id"]
2327+
context["document_id"] = self.kwargs["resource_id"]
2328+
context["thread_id"] = self.kwargs["thread_id"]
22672329
return context
22682330

2269-
def get_queryset(self):
2270-
"""Return the queryset according to the action."""
2271-
return super().get_queryset().filter(document=self.kwargs["resource_id"])
2331+
@drf.decorators.action(
2332+
detail=True,
2333+
methods=["post", "delete"],
2334+
)
2335+
def reactions(self, request, *args, **kwargs):
2336+
"""POST: add reaction; DELETE: remove reaction.
2337+
2338+
Emoji is expected in request.data['emoji'] for both operations.
2339+
"""
2340+
comment = self.get_object()
2341+
serializer = serializers.ReactionSerializer(data=request.data)
2342+
serializer.is_valid(raise_exception=True)
2343+
2344+
if request.method == "POST":
2345+
reaction, created = models.Reaction.objects.get_or_create(
2346+
comment=comment,
2347+
emoji=serializer.validated_data["emoji"],
2348+
)
2349+
if not created and reaction.users.filter(id=request.user.id).exists():
2350+
return drf.response.Response(
2351+
{"user_already_reacted": True}, status=status.HTTP_400_BAD_REQUEST
2352+
)
2353+
reaction.users.add(request.user)
2354+
return drf.response.Response(status=status.HTTP_201_CREATED)
2355+
2356+
# DELETE
2357+
try:
2358+
reaction = models.Reaction.objects.get(
2359+
comment=comment,
2360+
emoji=serializer.validated_data["emoji"],
2361+
users__in=[request.user],
2362+
)
2363+
except models.Reaction.DoesNotExist as e:
2364+
raise drf.exceptions.NotFound("Reaction not found.") from e
2365+
reaction.users.remove(request.user)
2366+
if not reaction.users.exists():
2367+
reaction.delete()
2368+
return drf.response.Response(status=status.HTTP_204_NO_CONTENT)

src/backend/core/choices.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,15 @@ class LinkRoleChoices(PriorityTextChoices):
3333
"""Defines the possible roles a link can offer on a document."""
3434

3535
READER = "reader", _("Reader") # Can read
36-
COMMENTATOR = "commentator", _("Commentator") # Can read and comment
36+
COMMENTER = "commenter", _("Commenter") # Can read and comment
3737
EDITOR = "editor", _("Editor") # Can read and edit
3838

3939

4040
class RoleChoices(PriorityTextChoices):
4141
"""Defines the possible roles a user can have in a resource."""
4242

4343
READER = "reader", _("Reader") # Can read
44-
COMMENTATOR = "commentator", _("Commentator") # Can read and comment
44+
COMMENTER = "commenter", _("Commenter") # Can read and comment
4545
EDITOR = "editor", _("Editor") # Can read and edit
4646
ADMIN = "administrator", _("Administrator") # Can read, edit, delete and share
4747
OWNER = "owner", _("Owner")

src/backend/core/factories.py

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -258,12 +258,47 @@ class Meta:
258258
issuer = factory.SubFactory(UserFactory)
259259

260260

261+
class ThreadFactory(factory.django.DjangoModelFactory):
262+
"""A factory to create threads for a document"""
263+
264+
class Meta:
265+
model = models.Thread
266+
267+
document = factory.SubFactory(DocumentFactory)
268+
creator = factory.SubFactory(UserFactory)
269+
270+
261271
class CommentFactory(factory.django.DjangoModelFactory):
262-
"""A factory to create comments for a document"""
272+
"""A factory to create comments for a thread"""
263273

264274
class Meta:
265275
model = models.Comment
266276

267-
document = factory.SubFactory(DocumentFactory)
277+
thread = factory.SubFactory(ThreadFactory)
268278
user = factory.SubFactory(UserFactory)
269-
content = factory.Faker("text")
279+
body = factory.Faker("text")
280+
281+
282+
class ReactionFactory(factory.django.DjangoModelFactory):
283+
"""A factory to create reactions for a comment"""
284+
285+
class Meta:
286+
model = models.Reaction
287+
288+
comment = factory.SubFactory(CommentFactory)
289+
emoji = "test"
290+
291+
@factory.post_generation
292+
def users(self, create, extracted, **kwargs):
293+
"""Add users to reaction from a given list of users or create one if not provided."""
294+
if not create:
295+
return
296+
297+
if not extracted:
298+
# the factory is being created, but no users were provided
299+
user = UserFactory()
300+
self.users.add(user)
301+
return
302+
303+
# Add the iterable of groups using bulk addition
304+
self.users.add(*extracted)

0 commit comments

Comments
 (0)