Skip to content

Commit 0f47174

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 f2b993c commit 0f47174

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
@@ -879,45 +879,122 @@ class MoveDocumentSerializer(serializers.Serializer):
879879
)
880880

881881

882+
class ReactionSerializer(serializers.ModelSerializer):
883+
"""Serialize reactions."""
884+
885+
users = UserLightSerializer(many=True, read_only=True)
886+
887+
class Meta:
888+
model = models.Reaction
889+
fields = [
890+
"id",
891+
"emoji",
892+
"created_at",
893+
"users",
894+
]
895+
read_only_fields = ["id", "created_at", "users"]
896+
897+
882898
class CommentSerializer(serializers.ModelSerializer):
883-
"""Serialize comments."""
899+
"""Serialize comments (nested under a thread) with reactions and abilities."""
884900

885901
user = UserLightSerializer(read_only=True)
886-
abilities = serializers.SerializerMethodField(read_only=True)
902+
abilities = serializers.SerializerMethodField()
903+
reactions = ReactionSerializer(many=True, read_only=True)
887904

888905
class Meta:
889906
model = models.Comment
890907
fields = [
891908
"id",
892-
"content",
909+
"user",
910+
"body",
893911
"created_at",
894912
"updated_at",
895-
"user",
896-
"document",
913+
"reactions",
897914
"abilities",
898915
]
899916
read_only_fields = [
900917
"id",
918+
"user",
901919
"created_at",
902920
"updated_at",
903-
"user",
904-
"document",
921+
"reactions",
905922
"abilities",
906923
]
907924

908-
def get_abilities(self, comment) -> dict:
909-
"""Return abilities of the logged-in user on the instance."""
925+
def validate(self, attrs):
926+
"""Validate comment data."""
927+
928+
request = self.context.get("request")
929+
user = getattr(request, "user", None)
930+
931+
attrs["thread_id"] = self.context["thread_id"]
932+
attrs["user_id"] = user.id if user else None
933+
return attrs
934+
935+
def get_abilities(self, obj):
936+
"""Return comment's abilities."""
910937
request = self.context.get("request")
911938
if request:
912-
return comment.get_abilities(request.user)
939+
return obj.get_abilities(request.user)
913940
return {}
914941

942+
943+
class ThreadSerializer(serializers.ModelSerializer):
944+
"""Serialize threads in a backward compatible shape for current frontend.
945+
946+
We expose a flatten representation where ``content`` maps to the first
947+
comment's body. Creating a thread requires a ``content`` field which is
948+
stored as the first comment.
949+
"""
950+
951+
creator = UserLightSerializer(read_only=True)
952+
abilities = serializers.SerializerMethodField(read_only=True)
953+
body = serializers.JSONField(write_only=True, required=True)
954+
comments = serializers.SerializerMethodField(read_only=True)
955+
comments = CommentSerializer(many=True, read_only=True)
956+
957+
class Meta:
958+
model = models.Thread
959+
fields = [
960+
"id",
961+
"body",
962+
"created_at",
963+
"updated_at",
964+
"creator",
965+
"abilities",
966+
"comments",
967+
"resolved",
968+
"resolved_at",
969+
"resolved_by",
970+
"metadata",
971+
]
972+
read_only_fields = [
973+
"id",
974+
"created_at",
975+
"updated_at",
976+
"creator",
977+
"abilities",
978+
"comments",
979+
"resolved",
980+
"resolved_at",
981+
"resolved_by",
982+
"metadata",
983+
]
984+
915985
def validate(self, attrs):
916-
"""Validate invitation data."""
986+
"""Validate thread data."""
917987
request = self.context.get("request")
918988
user = getattr(request, "user", None)
919989

920990
attrs["document_id"] = self.context["resource_id"]
921-
attrs["user_id"] = user.id if user else None
991+
attrs["creator_id"] = user.id if user else None
922992

923993
return attrs
994+
995+
def get_abilities(self, thread):
996+
"""Return thread's abilities."""
997+
request = self.context.get("request")
998+
if request:
999+
return thread.get_abilities(request.user)
1000+
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 _
@@ -2203,15 +2204,9 @@ def _load_theme_customization(self):
22032204
return theme_customization
22042205

22052206

2206-
class CommentViewSet(
2207-
viewsets.ModelViewSet,
2208-
):
2209-
"""API ViewSet for comments."""
2207+
class CommentViewSetMixin:
2208+
"""Comment ViewSet Mixin."""
22102209

2211-
permission_classes = [permissions.CommentPermission]
2212-
queryset = models.Comment.objects.select_related("user", "document").all()
2213-
serializer_class = serializers.CommentSerializer
2214-
pagination_class = Pagination
22152210
_document = None
22162211

22172212
def get_document_or_404(self):
@@ -2225,12 +2220,114 @@ def get_document_or_404(self):
22252220
raise drf.exceptions.NotFound("Document not found.") from e
22262221
return self._document
22272222

2223+
2224+
class ThreadViewSet(
2225+
ResourceAccessViewsetMixin,
2226+
CommentViewSetMixin,
2227+
drf.mixins.CreateModelMixin,
2228+
drf.mixins.ListModelMixin,
2229+
drf.mixins.RetrieveModelMixin,
2230+
drf.mixins.DestroyModelMixin,
2231+
viewsets.GenericViewSet,
2232+
):
2233+
"""Thread API: list/create threads and nested comment operations."""
2234+
2235+
permission_classes = [permissions.CommentPermission]
2236+
pagination_class = Pagination
2237+
serializer_class = serializers.ThreadSerializer
2238+
queryset = models.Thread.objects.select_related("creator", "document").filter(
2239+
resolved=False
2240+
)
2241+
resource_field_name = "document"
2242+
2243+
def perform_create(self, serializer):
2244+
"""Create the first comment of the thread."""
2245+
body = serializer.validated_data["body"]
2246+
del serializer.validated_data["body"]
2247+
thread = serializer.save()
2248+
2249+
models.Comment.objects.create(
2250+
thread=thread,
2251+
user=self.request.user if self.request.user.is_authenticated else None,
2252+
body=body,
2253+
)
2254+
2255+
@drf.decorators.action(detail=True, methods=["post"], url_path="resolve")
2256+
def resolve(self, request, *args, **kwargs):
2257+
"""Resolve a thread."""
2258+
thread = self.get_object()
2259+
if not thread.resolved:
2260+
thread.resolved = True
2261+
thread.resolved_at = timezone.now()
2262+
thread.resolved_by = request.user
2263+
thread.save(update_fields=["resolved", "resolved_at", "resolved_by"])
2264+
return drf.response.Response(status=status.HTTP_204_NO_CONTENT)
2265+
2266+
2267+
class CommentViewSet(
2268+
CommentViewSetMixin,
2269+
viewsets.ModelViewSet,
2270+
):
2271+
"""Comment API: list/create comments and nested reaction operations."""
2272+
2273+
permission_classes = [permissions.CommentPermission]
2274+
pagination_class = Pagination
2275+
serializer_class = serializers.CommentSerializer
2276+
queryset = models.Comment.objects.select_related("user").all()
2277+
2278+
def get_queryset(self):
2279+
"""Override to filter on related resource."""
2280+
return (
2281+
super()
2282+
.get_queryset()
2283+
.filter(
2284+
thread=self.kwargs["thread_id"],
2285+
thread__document=self.kwargs["resource_id"],
2286+
)
2287+
)
2288+
22282289
def get_serializer_context(self):
22292290
"""Extra context provided to the serializer class."""
22302291
context = super().get_serializer_context()
2231-
context["resource_id"] = self.kwargs["resource_id"]
2292+
context["document_id"] = self.kwargs["resource_id"]
2293+
context["thread_id"] = self.kwargs["thread_id"]
22322294
return context
22332295

2234-
def get_queryset(self):
2235-
"""Return the queryset according to the action."""
2236-
return super().get_queryset().filter(document=self.kwargs["resource_id"])
2296+
@drf.decorators.action(
2297+
detail=True,
2298+
methods=["post", "delete"],
2299+
)
2300+
def reactions(self, request, *args, **kwargs):
2301+
"""POST: add reaction; DELETE: remove reaction.
2302+
2303+
Emoji is expected in request.data['emoji'] for both operations.
2304+
"""
2305+
comment = self.get_object()
2306+
serializer = serializers.ReactionSerializer(data=request.data)
2307+
serializer.is_valid(raise_exception=True)
2308+
2309+
if request.method == "POST":
2310+
reaction, created = models.Reaction.objects.get_or_create(
2311+
comment=comment,
2312+
emoji=serializer.validated_data["emoji"],
2313+
)
2314+
if not created and reaction.users.filter(id=request.user.id).exists():
2315+
return drf.response.Response(
2316+
{"user_already_reacted": True}, status=status.HTTP_400_BAD_REQUEST
2317+
)
2318+
reaction.users.add(request.user)
2319+
return drf.response.Response(status=status.HTTP_201_CREATED)
2320+
2321+
# DELETE
2322+
try:
2323+
reaction = models.Reaction.objects.get(
2324+
comment=comment,
2325+
emoji=serializer.validated_data["emoji"],
2326+
users__in=[request.user],
2327+
)
2328+
except models.Reaction.DoesNotExist as e:
2329+
raise drf.exceptions.NotFound("Reaction not found.") from e
2330+
reaction.users.remove(request.user)
2331+
if not reaction.users.exists():
2332+
reaction.delete()
2333+
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)