Skip to content
Closed
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ and this project adheres to

## [Unreleased]

### Added

- ✨(backend) Comments on text editor #1309

### Changed

- ⚡️(frontend) improve accessibility:
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)
44 changes: 44 additions & 0 deletions src/backend/core/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -801,3 +801,47 @@ class MoveDocumentSerializer(serializers.Serializer):
choices=enums.MoveNodePositionChoices.choices,
default=enums.MoveNodePositionChoices.LAST_CHILD,
)


class CommentSerializer(serializers.ModelSerializer):
"""Serialize comments."""

user = UserLightSerializer(read_only=True)
abilities = serializers.SerializerMethodField(read_only=True)

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

def get_abilities(self, comment) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return comment.get_abilities(request.user)
return {}

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

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

return attrs
33 changes: 33 additions & 0 deletions src/backend/core/api/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -2072,3 +2072,36 @@ def _load_theme_customization(self):
)

return theme_customization


class CommentViewSet(
viewsets.ModelViewSet,
):
"""API ViewSet for comments."""

permission_classes = [permissions.CommentPermission]
queryset = models.Comment.objects.select_related("user", "document").all()
serializer_class = serializers.CommentSerializer
pagination_class = Pagination
_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

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

def get_queryset(self):
"""Return the queryset according to the action."""
return super().get_queryset().filter(document=self.kwargs["resource_id"])
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
COMMENTATOR = "commentator", _("Commentator") # 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
COMMENTATOR = "commentator", _("Commentator") # 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
11 changes: 11 additions & 0 deletions src/backend/core/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,3 +256,14 @@ class Meta:
document = factory.SubFactory(DocumentFactory)
role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices])
issuer = factory.SubFactory(UserFactory)


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

class Meta:
model = models.Comment

document = factory.SubFactory(DocumentFactory)
user = factory.SubFactory(UserFactory)
content = factory.Faker("text")
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# Generated by Django 5.2.4 on 2025-08-26 08:11

import uuid

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("core", "0024_add_is_masked_field_to_link_trace"),
]

operations = [
migrations.AlterField(
model_name="document",
name="link_role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commentator", "Commentator"),
("editor", "Editor"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="documentaccess",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commentator", "Commentator"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="documentaskforaccess",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commentator", "Commentator"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="invitation",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commentator", "Commentator"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="templateaccess",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commentator", "Commentator"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
migrations.CreateModel(
name="Comment",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="primary key for the record as UUID",
primary_key=True,
serialize=False,
verbose_name="id",
),
),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="date and time at which a record was created",
verbose_name="created on",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="date and time at which a record was last updated",
verbose_name="updated on",
),
),
("content", models.TextField()),
(
"document",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="comments",
to="core.document",
),
),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="comments",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Comment",
"verbose_name_plural": "Comments",
"db_table": "impress_comment",
"ordering": ("-created_at",),
},
),
]
51 changes: 50 additions & 1 deletion src/backend/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -762,6 +762,7 @@ def get_abilities(self, user):
can_update = (
is_owner_or_admin or role == RoleChoices.EDITOR
) and not is_deleted
can_comment = (can_update or role == RoleChoices.COMMENTATOR) and not is_deleted

ai_allow_reach_from = settings.AI_ALLOW_REACH_FROM
ai_access = any(
Expand All @@ -786,6 +787,7 @@ def get_abilities(self, user):
"children_list": can_get,
"children_create": can_update and user.is_authenticated,
"collaboration_auth": can_get,
"comment": can_comment,
"cors_proxy": can_get,
"descendants": can_get,
"destroy": is_owner,
Expand Down Expand Up @@ -1145,7 +1147,12 @@ def get_abilities(self, user):
set_role_to = []
if is_owner_or_admin:
set_role_to.extend(
[RoleChoices.READER, RoleChoices.EDITOR, RoleChoices.ADMIN]
[
RoleChoices.READER,
RoleChoices.COMMENTATOR,
RoleChoices.EDITOR,
RoleChoices.ADMIN,
]
)
if role == RoleChoices.OWNER:
set_role_to.append(RoleChoices.OWNER)
Expand Down Expand Up @@ -1277,6 +1284,48 @@ def send_ask_for_access_email(self, email, language=None):
self.document.send_email(subject, [email], context, language)


class Comment(BaseModel):
"""User comment on a document."""

document = models.ForeignKey(
Document,
on_delete=models.CASCADE,
related_name="comments",
)
user = models.ForeignKey(
User,
on_delete=models.SET_NULL,
related_name="comments",
null=True,
blank=True,
)
content = models.TextField()

class Meta:
db_table = "impress_comment"
ordering = ("-created_at",)
verbose_name = _("Comment")
verbose_name_plural = _("Comments")

def __str__(self):
author = self.user or _("Anonymous")
return f"{author!s} on {self.document!s}"

def get_abilities(self, user):
"""Compute and return abilities for a given user."""
role = self.document.get_role(user)
can_comment = self.document.get_abilities(user)["comment"]
return {
"destroy": self.user == user
or role in [RoleChoices.OWNER, RoleChoices.ADMIN],
"update": self.user == user
or role in [RoleChoices.OWNER, RoleChoices.ADMIN],
"partial_update": self.user == user
or role in [RoleChoices.OWNER, RoleChoices.ADMIN],
"retrieve": can_comment,
}


class Template(BaseModel):
"""HTML and CSS code used for formatting the print around the MarkDown body."""

Expand Down
Loading
Loading