Skip to content

Commit b13571c

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 a2a63cd commit b13571c

17 files changed

+2427
-441
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
@@ -893,45 +894,122 @@ class MoveDocumentSerializer(serializers.Serializer):
893894
)
894895

895896

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

899916
user = UserLightSerializer(read_only=True)
900-
abilities = serializers.SerializerMethodField(read_only=True)
917+
abilities = serializers.SerializerMethodField()
918+
reactions = ReactionSerializer(many=True, read_only=True)
901919

902920
class Meta:
903921
model = models.Comment
904922
fields = [
905923
"id",
906-
"content",
924+
"user",
925+
"body",
907926
"created_at",
908927
"updated_at",
909-
"user",
910-
"document",
928+
"reactions",
911929
"abilities",
912930
]
913931
read_only_fields = [
914932
"id",
933+
"user",
915934
"created_at",
916935
"updated_at",
917-
"user",
918-
"document",
936+
"reactions",
919937
"abilities",
920938
]
921939

922-
def get_abilities(self, comment) -> dict:
923-
"""Return abilities of the logged-in user on the instance."""
940+
def validate(self, attrs):
941+
"""Validate comment data."""
942+
943+
request = self.context.get("request")
944+
user = getattr(request, "user", None)
945+
946+
attrs["thread_id"] = self.context["thread_id"]
947+
attrs["user_id"] = user.id if user else None
948+
return attrs
949+
950+
def get_abilities(self, obj):
951+
"""Return comment's abilities."""
924952
request = self.context.get("request")
925953
if request:
926-
return comment.get_abilities(request.user)
954+
return obj.get_abilities(request.user)
927955
return {}
928956

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

9341005
attrs["document_id"] = self.context["resource_id"]
935-
attrs["user_id"] = user.id if user else None
1006+
attrs["creator_id"] = user.id if user else None
9361007

9371008
return attrs
1009+
1010+
def get_abilities(self, thread):
1011+
"""Return thread's abilities."""
1012+
request = self.context.get("request")
1013+
if request:
1014+
return thread.get_abilities(request.user)
1015+
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 _
@@ -2152,15 +2153,9 @@ def _load_theme_customization(self):
21522153
return theme_customization
21532154

21542155

2155-
class CommentViewSet(
2156-
viewsets.ModelViewSet,
2157-
):
2158-
"""API ViewSet for comments."""
2156+
class CommentViewSetMixin:
2157+
"""Comment ViewSet Mixin."""
21592158

2160-
permission_classes = [permissions.CommentPermission]
2161-
queryset = models.Comment.objects.select_related("user", "document").all()
2162-
serializer_class = serializers.CommentSerializer
2163-
pagination_class = Pagination
21642159
_document = None
21652160

21662161
def get_document_or_404(self):
@@ -2174,12 +2169,114 @@ def get_document_or_404(self):
21742169
raise drf.exceptions.NotFound("Document not found.") from e
21752170
return self._document
21762171

2172+
2173+
class ThreadViewSet(
2174+
ResourceAccessViewsetMixin,
2175+
CommentViewSetMixin,
2176+
drf.mixins.CreateModelMixin,
2177+
drf.mixins.ListModelMixin,
2178+
drf.mixins.RetrieveModelMixin,
2179+
drf.mixins.DestroyModelMixin,
2180+
viewsets.GenericViewSet,
2181+
):
2182+
"""Thread API: list/create threads and nested comment operations."""
2183+
2184+
permission_classes = [permissions.CommentPermission]
2185+
pagination_class = Pagination
2186+
serializer_class = serializers.ThreadSerializer
2187+
queryset = models.Thread.objects.select_related("creator", "document").filter(
2188+
resolved=False
2189+
)
2190+
resource_field_name = "document"
2191+
2192+
def perform_create(self, serializer):
2193+
"""Create the first comment of the thread."""
2194+
body = serializer.validated_data["body"]
2195+
del serializer.validated_data["body"]
2196+
thread = serializer.save()
2197+
2198+
models.Comment.objects.create(
2199+
thread=thread,
2200+
user=self.request.user if self.request.user.is_authenticated else None,
2201+
body=body,
2202+
)
2203+
2204+
@drf.decorators.action(detail=True, methods=["post"], url_path="resolve")
2205+
def resolve(self, request, *args, **kwargs):
2206+
"""Resolve a thread."""
2207+
thread = self.get_object()
2208+
if not thread.resolved:
2209+
thread.resolved = True
2210+
thread.resolved_at = timezone.now()
2211+
thread.resolved_by = request.user
2212+
thread.save(update_fields=["resolved", "resolved_at", "resolved_by"])
2213+
return drf.response.Response(status=status.HTTP_204_NO_CONTENT)
2214+
2215+
2216+
class CommentViewSet(
2217+
CommentViewSetMixin,
2218+
viewsets.ModelViewSet,
2219+
):
2220+
"""Comment API: list/create comments and nested reaction operations."""
2221+
2222+
permission_classes = [permissions.CommentPermission]
2223+
pagination_class = Pagination
2224+
serializer_class = serializers.CommentSerializer
2225+
queryset = models.Comment.objects.select_related("user").all()
2226+
2227+
def get_queryset(self):
2228+
"""Override to filter on related resource."""
2229+
return (
2230+
super()
2231+
.get_queryset()
2232+
.filter(
2233+
thread=self.kwargs["thread_id"],
2234+
thread__document=self.kwargs["resource_id"],
2235+
)
2236+
)
2237+
21772238
def get_serializer_context(self):
21782239
"""Extra context provided to the serializer class."""
21792240
context = super().get_serializer_context()
2180-
context["resource_id"] = self.kwargs["resource_id"]
2241+
context["document_id"] = self.kwargs["resource_id"]
2242+
context["thread_id"] = self.kwargs["thread_id"]
21812243
return context
21822244

2183-
def get_queryset(self):
2184-
"""Return the queryset according to the action."""
2185-
return super().get_queryset().filter(document=self.kwargs["resource_id"])
2245+
@drf.decorators.action(
2246+
detail=True,
2247+
methods=["post", "delete"],
2248+
)
2249+
def reactions(self, request, *args, **kwargs):
2250+
"""POST: add reaction; DELETE: remove reaction.
2251+
2252+
Emoji is expected in request.data['emoji'] for both operations.
2253+
"""
2254+
comment = self.get_object()
2255+
serializer = serializers.ReactionSerializer(data=request.data)
2256+
serializer.is_valid(raise_exception=True)
2257+
2258+
if request.method == "POST":
2259+
reaction, created = models.Reaction.objects.get_or_create(
2260+
comment=comment,
2261+
emoji=serializer.validated_data["emoji"],
2262+
)
2263+
if not created and reaction.users.filter(id=request.user.id).exists():
2264+
return drf.response.Response(
2265+
{"user_already_reacted": True}, status=status.HTTP_400_BAD_REQUEST
2266+
)
2267+
reaction.users.add(request.user)
2268+
return drf.response.Response(status=status.HTTP_201_CREATED)
2269+
2270+
# DELETE
2271+
try:
2272+
reaction = models.Reaction.objects.get(
2273+
comment=comment,
2274+
emoji=serializer.validated_data["emoji"],
2275+
users__in=[request.user],
2276+
)
2277+
except models.Reaction.DoesNotExist as e:
2278+
raise drf.exceptions.NotFound("Reaction not found.") from e
2279+
reaction.users.remove(request.user)
2280+
if not reaction.users.exists():
2281+
reaction.delete()
2282+
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)