Skip to content

Commit c5cb886

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 c5cb886

16 files changed

+2419
-435
lines changed

src/backend/core/api/serializers.py

Lines changed: 90 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Client serializers for the impress core app."""
2+
# pylint: disable=too-many-lines
23

34
import binascii
45
import mimetypes
@@ -891,45 +892,122 @@ class MoveDocumentSerializer(serializers.Serializer):
891892
)
892893

893894

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

897914
user = UserLightSerializer(read_only=True)
898-
abilities = serializers.SerializerMethodField(read_only=True)
915+
abilities = serializers.SerializerMethodField()
916+
reactions = ReactionSerializer(many=True, read_only=True)
899917

900918
class Meta:
901919
model = models.Comment
902920
fields = [
903921
"id",
904-
"content",
922+
"user",
923+
"body",
905924
"created_at",
906925
"updated_at",
907-
"user",
908-
"document",
926+
"reactions",
909927
"abilities",
910928
]
911929
read_only_fields = [
912930
"id",
931+
"user",
913932
"created_at",
914933
"updated_at",
915-
"user",
916-
"document",
934+
"reactions",
917935
"abilities",
918936
]
919937

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

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

9321003
attrs["document_id"] = self.context["resource_id"]
933-
attrs["user_id"] = user.id if user else None
1004+
attrs["creator_id"] = user.id if user else None
9341005

9351006
return attrs
1007+
1008+
def get_abilities(self, thread):
1009+
"""Return thread's abilities."""
1010+
request = self.context.get("request")
1011+
if request:
1012+
return thread.get_abilities(request.user)
1013+
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)