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
1 change: 1 addition & 0 deletions .github/workflows/docker-hub.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ on:
push:
branches:
- 'main'
- 'feat/comments-frontend'
tags:
- 'v*'
pull_request:
Expand Down
9 changes: 4 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to
### Added

- ✨(frontend) add pdf block to the editor #1293
- ✨ Add comments feature to the editor #1330

### Changed

Expand All @@ -25,8 +26,10 @@ and this project adheres to
- ♿ remove redundant aria-label on hidden icons and update tests #1432
- ♿ improve semantic structure and aria roles of leftpanel #1431
- ♿ add default background to left panel for better accessibility #1423
- ♿improve NVDA navigation in DocShareModal #1396
- ♿ restyle checked checkboxes: removing strikethrough #1439
- ♿ add h1 for SR on 40X pages and remove alt texts #1438

### Fixed

- 🐛(backend) duplicate sub docs as root for reader users
Expand All @@ -35,11 +38,6 @@ and this project adheres to
- 🐛(frontend) fix legacy role computation #1376
- 🐛(frontend) scroll back to top when navigate to a document #1406

### Changed

- ♿(frontend) improve accessibility:
- ♿improve NVDA navigation in DocShareModal #1396

## [3.7.0] - 2025-09-12

### Added
Expand Down Expand Up @@ -71,6 +69,7 @@ and this project adheres to

### Added

- ✨(backend) Comments on text editor #1309
- 👷(CI) add bundle size check job #1268
- ✨(frontend) use title first emoji as doc icon in tree #1289

Expand Down
16 changes: 16 additions & 0 deletions src/backend/core/api/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,19 @@ def has_object_permission(self, request, view, obj):

action = view.action
return abilities.get(action, False)


class CommentPermission(permissions.BasePermission):
"""Permission class for comments."""

def has_permission(self, request, view):
"""Check permission for a given object."""
if view.action in ["create", "list"]:
document_abilities = view.get_document_or_404().get_abilities(request.user)
return document_abilities["comment"]

return True

def has_object_permission(self, request, view, obj):
"""Check permission for a given object."""
return obj.get_abilities(request.user).get(view.action, False)
121 changes: 121 additions & 0 deletions src/backend/core/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -877,3 +877,124 @@ class MoveDocumentSerializer(serializers.Serializer):
choices=enums.MoveNodePositionChoices.choices,
default=enums.MoveNodePositionChoices.LAST_CHILD,
)


class ReactionSerializer(serializers.ModelSerializer):
"""Serialize reactions."""

users = UserLightSerializer(many=True, read_only=True)

class Meta:
model = models.Reaction
fields = [
"id",
"emoji",
"created_at",
"users",
]
read_only_fields = ["id", "created_at", "users"]


class CommentSerializer(serializers.ModelSerializer):
"""Serialize comments (nested under a thread) with reactions and abilities."""

user = UserLightSerializer(read_only=True)
abilities = serializers.SerializerMethodField()
reactions = ReactionSerializer(many=True, read_only=True)

class Meta:
model = models.Comment
fields = [
"id",
"user",
"body",
"created_at",
"updated_at",
"reactions",
"abilities",
]
read_only_fields = [
"id",
"user",
"created_at",
"updated_at",
"reactions",
"abilities",
]

def validate(self, attrs):
"""Validate comment data."""

request = self.context.get("request")
user = getattr(request, "user", None)

attrs["thread_id"] = self.context["thread_id"]
attrs["user_id"] = user.id if user else None
return attrs

def get_abilities(self, obj):
"""Return comment's abilities."""
request = self.context.get("request")
if request:
return obj.get_abilities(request.user)
return {}


class ThreadSerializer(serializers.ModelSerializer):
"""Serialize threads in a backward compatible shape for current frontend.

We expose a flatten representation where ``content`` maps to the first
comment's body. Creating a thread requires a ``content`` field which is
stored as the first comment.
"""

creator = UserLightSerializer(read_only=True)
abilities = serializers.SerializerMethodField(read_only=True)
body = serializers.JSONField(write_only=True, required=True)
comments = serializers.SerializerMethodField(read_only=True)
comments = CommentSerializer(many=True, read_only=True)

class Meta:
model = models.Thread
fields = [
"id",
"body",
"created_at",
"updated_at",
"creator",
"abilities",
"comments",
"resolved",
"resolved_at",
"resolved_by",
"metadata",
]
read_only_fields = [
"id",
"created_at",
"updated_at",
"creator",
"abilities",
"comments",
"resolved",
"resolved_at",
"resolved_by",
"metadata",
]

def validate(self, attrs):
"""Validate thread data."""
request = self.context.get("request")
user = getattr(request, "user", None)

attrs["document_id"] = self.context["resource_id"]
attrs["creator_id"] = user.id if user else None

return attrs

def get_abilities(self, thread):
"""Return thread's abilities."""
request = self.context.get("request")
if request:
return thread.get_abilities(request.user)
return {}
130 changes: 130 additions & 0 deletions src/backend/core/api/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from django.db.models.functions import Left, Length
from django.http import Http404, StreamingHttpResponse
from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.text import capfirst, slugify
from django.utils.translation import gettext_lazy as _
Expand Down Expand Up @@ -2201,3 +2202,132 @@ def _load_theme_customization(self):
)

return theme_customization


class CommentViewSetMixin:
"""Comment ViewSet Mixin."""

_document = None

def get_document_or_404(self):
"""Get the document related to the viewset or raise a 404 error."""
if self._document is None:
try:
self._document = models.Document.objects.get(
pk=self.kwargs["resource_id"],
)
except models.Document.DoesNotExist as e:
raise drf.exceptions.NotFound("Document not found.") from e
return self._document


class ThreadViewSet(
ResourceAccessViewsetMixin,
CommentViewSetMixin,
drf.mixins.CreateModelMixin,
drf.mixins.ListModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""Thread API: list/create threads and nested comment operations."""

permission_classes = [permissions.CommentPermission]
pagination_class = Pagination
serializer_class = serializers.ThreadSerializer
queryset = models.Thread.objects.select_related("creator", "document").filter(
resolved=False
)
resource_field_name = "document"

def perform_create(self, serializer):
"""Create the first comment of the thread."""
body = serializer.validated_data["body"]
del serializer.validated_data["body"]
thread = serializer.save()

models.Comment.objects.create(
thread=thread,
user=self.request.user if self.request.user.is_authenticated else None,
body=body,
)

@drf.decorators.action(detail=True, methods=["post"], url_path="resolve")
def resolve(self, request, *args, **kwargs):
"""Resolve a thread."""
thread = self.get_object()
if not thread.resolved:
thread.resolved = True
thread.resolved_at = timezone.now()
thread.resolved_by = request.user
thread.save(update_fields=["resolved", "resolved_at", "resolved_by"])
return drf.response.Response(status=status.HTTP_204_NO_CONTENT)


class CommentViewSet(
CommentViewSetMixin,
viewsets.ModelViewSet,
):
"""Comment API: list/create comments and nested reaction operations."""

permission_classes = [permissions.CommentPermission]
pagination_class = Pagination
serializer_class = serializers.CommentSerializer
queryset = models.Comment.objects.select_related("user").all()

def get_queryset(self):
"""Override to filter on related resource."""
return (
super()
.get_queryset()
.filter(
thread=self.kwargs["thread_id"],
thread__document=self.kwargs["resource_id"],
)
)

def get_serializer_context(self):
"""Extra context provided to the serializer class."""
context = super().get_serializer_context()
context["document_id"] = self.kwargs["resource_id"]
context["thread_id"] = self.kwargs["thread_id"]
return context

@drf.decorators.action(
detail=True,
methods=["post", "delete"],
)
def reactions(self, request, *args, **kwargs):
"""POST: add reaction; DELETE: remove reaction.

Emoji is expected in request.data['emoji'] for both operations.
"""
comment = self.get_object()
serializer = serializers.ReactionSerializer(data=request.data)
serializer.is_valid(raise_exception=True)

if request.method == "POST":
reaction, created = models.Reaction.objects.get_or_create(
comment=comment,
emoji=serializer.validated_data["emoji"],
)
if not created and reaction.users.filter(id=request.user.id).exists():
return drf.response.Response(
{"user_already_reacted": True}, status=status.HTTP_400_BAD_REQUEST
)
reaction.users.add(request.user)
return drf.response.Response(status=status.HTTP_201_CREATED)

# DELETE
try:
reaction = models.Reaction.objects.get(
comment=comment,
emoji=serializer.validated_data["emoji"],
users__in=[request.user],
)
except models.Reaction.DoesNotExist as e:
raise drf.exceptions.NotFound("Reaction not found.") from e
reaction.users.remove(request.user)
if not reaction.users.exists():
reaction.delete()
return drf.response.Response(status=status.HTTP_204_NO_CONTENT)
2 changes: 2 additions & 0 deletions src/backend/core/choices.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,15 @@ class LinkRoleChoices(PriorityTextChoices):
"""Defines the possible roles a link can offer on a document."""

READER = "reader", _("Reader") # Can read
COMMENTER = "commenter", _("Commenter") # Can read and comment
EDITOR = "editor", _("Editor") # Can read and edit


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

READER = "reader", _("Reader") # Can read
COMMENTER = "commenter", _("Commenter") # Can read and comment
EDITOR = "editor", _("Editor") # Can read and edit
ADMIN = "administrator", _("Administrator") # Can read, edit, delete and share
OWNER = "owner", _("Owner")
Expand Down
46 changes: 46 additions & 0 deletions src/backend/core/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,3 +256,49 @@ class Meta:
document = factory.SubFactory(DocumentFactory)
role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices])
issuer = factory.SubFactory(UserFactory)


class ThreadFactory(factory.django.DjangoModelFactory):
"""A factory to create threads for a document"""

class Meta:
model = models.Thread

document = factory.SubFactory(DocumentFactory)
creator = factory.SubFactory(UserFactory)


class CommentFactory(factory.django.DjangoModelFactory):
"""A factory to create comments for a thread"""

class Meta:
model = models.Comment

thread = factory.SubFactory(ThreadFactory)
user = factory.SubFactory(UserFactory)
body = factory.Faker("text")


class ReactionFactory(factory.django.DjangoModelFactory):
"""A factory to create reactions for a comment"""

class Meta:
model = models.Reaction

comment = factory.SubFactory(CommentFactory)
emoji = "test"

@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."""
if not create:
return

if not extracted:
# the factory is being created, but no users were provided
user = UserFactory()
self.users.add(user)
return

# Add the iterable of groups using bulk addition
self.users.add(*extracted)
Loading
Loading