From b13571c6df77dee7c9605d7841b8ee9b14aa908c Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Fri, 12 Sep 2025 15:28:25 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20implement=20thread=20and?= =?UTF-8?q?=20reactions=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/backend/core/api/serializers.py | 110 +- src/backend/core/api/viewsets.py | 121 +- src/backend/core/choices.py | 4 +- src/backend/core/factories.py | 41 +- ...role_alter_documentaccess_role_and_more.py | 146 -- src/backend/core/migrations/0026_comments.py | 275 ++++ src/backend/core/models.py | 141 +- .../documents/test_api_document_accesses.py | 42 +- .../test_api_documents_ask_for_access.py | 2 + .../documents/test_api_documents_comments.py | 516 +++++-- .../documents/test_api_documents_retrieve.py | 30 +- .../documents/test_api_documents_threads.py | 1226 +++++++++++++++++ .../documents/test_api_documents_trashbin.py | 4 +- src/backend/core/tests/test_models_comment.py | 80 +- .../tests/test_models_document_accesses.py | 18 +- .../core/tests/test_models_documents.py | 103 +- src/backend/core/urls.py | 17 +- 17 files changed, 2431 insertions(+), 445 deletions(-) delete mode 100644 src/backend/core/migrations/0025_alter_document_link_role_alter_documentaccess_role_and_more.py create mode 100644 src/backend/core/migrations/0026_comments.py create mode 100644 src/backend/core/tests/documents/test_api_documents_threads.py diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index dbd010f3..e9d49aac 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -1,4 +1,5 @@ """Client serializers for the impress core app.""" +# pylint: disable=too-many-lines import binascii import mimetypes @@ -893,45 +894,122 @@ class MoveDocumentSerializer(serializers.Serializer): ) +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.""" + """Serialize comments (nested under a thread) with reactions and abilities.""" user = UserLightSerializer(read_only=True) - abilities = serializers.SerializerMethodField(read_only=True) + abilities = serializers.SerializerMethodField() + reactions = ReactionSerializer(many=True, read_only=True) class Meta: model = models.Comment fields = [ "id", - "content", + "user", + "body", "created_at", "updated_at", - "user", - "document", + "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", - "user", - "document", + "creator", "abilities", + "comments", + "resolved", + "resolved_at", + "resolved_by", + "metadata", ] - 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.""" + """Validate thread 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 + 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 {} diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 1d79b53d..1c1b9ef5 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -21,6 +21,7 @@ from django.db.models.expressions import RawSQL 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 _ @@ -2152,15 +2153,9 @@ class ConfigView(drf.views.APIView): return theme_customization -class CommentViewSet( - viewsets.ModelViewSet, -): - """API ViewSet for comments.""" +class CommentViewSetMixin: + """Comment ViewSet Mixin.""" - 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): @@ -2174,12 +2169,114 @@ class CommentViewSet( 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["resource_id"] = self.kwargs["resource_id"] + context["document_id"] = self.kwargs["resource_id"] + context["thread_id"] = self.kwargs["thread_id"] return context - def get_queryset(self): - """Return the queryset according to the action.""" - return super().get_queryset().filter(document=self.kwargs["resource_id"]) + @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) diff --git a/src/backend/core/choices.py b/src/backend/core/choices.py index 8505ebab..e1851531 100644 --- a/src/backend/core/choices.py +++ b/src/backend/core/choices.py @@ -33,7 +33,7 @@ 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 + COMMENTER = "commenter", _("Commenter") # Can read and comment EDITOR = "editor", _("Editor") # Can read and edit @@ -41,7 +41,7 @@ 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 + 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") diff --git a/src/backend/core/factories.py b/src/backend/core/factories.py index 24bdd317..c0737cdc 100644 --- a/src/backend/core/factories.py +++ b/src/backend/core/factories.py @@ -258,12 +258,47 @@ class InvitationFactory(factory.django.DjangoModelFactory): 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 document""" + """A factory to create comments for a thread""" class Meta: model = models.Comment - document = factory.SubFactory(DocumentFactory) + thread = factory.SubFactory(ThreadFactory) user = factory.SubFactory(UserFactory) - content = factory.Faker("text") + 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) diff --git a/src/backend/core/migrations/0025_alter_document_link_role_alter_documentaccess_role_and_more.py b/src/backend/core/migrations/0025_alter_document_link_role_alter_documentaccess_role_and_more.py deleted file mode 100644 index a34ad05b..00000000 --- a/src/backend/core/migrations/0025_alter_document_link_role_alter_documentaccess_role_and_more.py +++ /dev/null @@ -1,146 +0,0 @@ -# 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",), - }, - ), - ] diff --git a/src/backend/core/migrations/0026_comments.py b/src/backend/core/migrations/0026_comments.py new file mode 100644 index 00000000..f1b122f3 --- /dev/null +++ b/src/backend/core/migrations/0026_comments.py @@ -0,0 +1,275 @@ +# Generated by Django 5.2.6 on 2025-09-16 08:59 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0025_alter_user_short_name"), + ] + + operations = [ + migrations.AlterField( + model_name="document", + name="link_role", + field=models.CharField( + choices=[ + ("reader", "Reader"), + ("commenter", "Commenter"), + ("editor", "Editor"), + ], + default="reader", + max_length=20, + ), + ), + migrations.AlterField( + model_name="documentaccess", + name="role", + field=models.CharField( + choices=[ + ("reader", "Reader"), + ("commenter", "Commenter"), + ("editor", "Editor"), + ("administrator", "Administrator"), + ("owner", "Owner"), + ], + default="reader", + max_length=20, + ), + ), + migrations.AlterField( + model_name="documentaskforaccess", + name="role", + field=models.CharField( + choices=[ + ("reader", "Reader"), + ("commenter", "Commenter"), + ("editor", "Editor"), + ("administrator", "Administrator"), + ("owner", "Owner"), + ], + default="reader", + max_length=20, + ), + ), + migrations.AlterField( + model_name="invitation", + name="role", + field=models.CharField( + choices=[ + ("reader", "Reader"), + ("commenter", "Commenter"), + ("editor", "Editor"), + ("administrator", "Administrator"), + ("owner", "Owner"), + ], + default="reader", + max_length=20, + ), + ), + migrations.AlterField( + model_name="templateaccess", + name="role", + field=models.CharField( + choices=[ + ("reader", "Reader"), + ("commenter", "Commenter"), + ("editor", "Editor"), + ("administrator", "Administrator"), + ("owner", "Owner"), + ], + default="reader", + max_length=20, + ), + ), + migrations.CreateModel( + name="Thread", + 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", + ), + ), + ("resolved", models.BooleanField(default=False)), + ("resolved_at", models.DateTimeField(blank=True, null=True)), + ("metadata", models.JSONField(blank=True, default=dict)), + ( + "creator", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="threads", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "document", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="threads", + to="core.document", + ), + ), + ( + "resolved_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="resolved_threads", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Thread", + "verbose_name_plural": "Threads", + "db_table": "impress_thread", + "ordering": ("-created_at",), + }, + ), + 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", + ), + ), + ("body", models.JSONField()), + ("metadata", models.JSONField(blank=True, default=dict)), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="thread_comment", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "thread", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="comments", + to="core.thread", + ), + ), + ], + options={ + "verbose_name": "Comment", + "verbose_name_plural": "Comments", + "db_table": "impress_comment", + "ordering": ("created_at",), + }, + ), + migrations.CreateModel( + name="Reaction", + 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", + ), + ), + ("emoji", models.CharField(max_length=32)), + ( + "comment", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="reactions", + to="core.comment", + ), + ), + ( + "users", + models.ManyToManyField( + related_name="reactions", to=settings.AUTH_USER_MODEL + ), + ), + ], + options={ + "verbose_name": "Reaction", + "verbose_name_plural": "Reactions", + "db_table": "impress_comment_reaction", + "constraints": [ + models.UniqueConstraint( + fields=("comment", "emoji"), + name="unique_comment_emoji", + violation_error_message="This emoji has already been reacted to this comment.", + ) + ], + }, + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index c4e06f87..c17d3ec4 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -756,7 +756,7 @@ class Document(MP_Node, BaseModel): 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 + can_comment = (can_update or role == RoleChoices.COMMENTER) and not is_deleted can_create_children = can_update and user.is_authenticated can_destroy = ( is_owner @@ -1150,7 +1150,7 @@ class DocumentAccess(BaseAccess): set_role_to.extend( [ RoleChoices.READER, - RoleChoices.COMMENTATOR, + RoleChoices.COMMENTER, RoleChoices.EDITOR, RoleChoices.ADMIN, ] @@ -1277,48 +1277,153 @@ class DocumentAskForAccess(BaseModel): self.document.send_email(subject, [email], context, language) -class Comment(BaseModel): - """User comment on a document.""" +class Thread(BaseModel): + """Discussion thread attached to a document. + + A thread groups one or many comments. For backward compatibility with the + existing frontend (useComments hook) we still expose a flattened serializer + that returns a "content" field representing the first comment's body. + """ document = models.ForeignKey( Document, on_delete=models.CASCADE, + related_name="threads", + ) + creator = models.ForeignKey( + User, + on_delete=models.SET_NULL, + related_name="threads", + null=True, + blank=True, + ) + resolved = models.BooleanField(default=False) + resolved_at = models.DateTimeField(null=True, blank=True) + resolved_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + related_name="resolved_threads", + null=True, + blank=True, + ) + metadata = models.JSONField(default=dict, blank=True) + + class Meta: + db_table = "impress_thread" + ordering = ("-created_at",) + verbose_name = _("Thread") + verbose_name_plural = _("Threads") + + def __str__(self): + author = self.creator or _("Anonymous") + return f"Thread by {author!s} on {self.document!s}" + + def get_abilities(self, user): + """Compute and return abilities for a given user (mirrors comment logic).""" + role = self.document.get_role(user) + doc_abilities = self.document.get_abilities(user) + read_access = doc_abilities.get("comment", False) + write_access = self.creator == user or role in [ + RoleChoices.OWNER, + RoleChoices.ADMIN, + ] + return { + "destroy": write_access, + "update": write_access, + "partial_update": write_access, + "resolve": write_access, + "retrieve": read_access, + } + + @property + def first_comment(self): + """Return the first createdcomment of the thread.""" + return self.comments.order_by("created_at").first() + + +class Comment(BaseModel): + """A comment belonging to a thread.""" + + thread = models.ForeignKey( + Thread, + on_delete=models.CASCADE, related_name="comments", ) user = models.ForeignKey( User, on_delete=models.SET_NULL, - related_name="comments", + related_name="thread_comment", null=True, blank=True, ) - content = models.TextField() + body = models.JSONField() + metadata = models.JSONField(default=dict, blank=True) class Meta: db_table = "impress_comment" - ordering = ("-created_at",) + ordering = ("created_at",) verbose_name = _("Comment") verbose_name_plural = _("Comments") def __str__(self): + """Return the string representation of the comment.""" author = self.user or _("Anonymous") - return f"{author!s} on {self.document!s}" + return f"Comment by {author!s} on thread {self.thread_id}" 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 the abilities of the comment.""" + role = self.thread.document.get_role(user) + doc_abilities = self.thread.document.get_abilities(user) + read_access = doc_abilities.get("comment", False) + can_react = read_access and user.is_authenticated + write_access = self.user == user or role in [ + RoleChoices.OWNER, + RoleChoices.ADMIN, + ] 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, + "destroy": write_access, + "update": write_access, + "partial_update": write_access, + "reactions": can_react, + "retrieve": read_access, } +class Reaction(BaseModel): + """Aggregated reactions for a given emoji on a comment. + + We store one row per (comment, emoji) and maintain the list of user IDs who + reacted with that emoji. This matches the frontend interface where a + reaction exposes: emoji, createdAt (first reaction date) and userIds. + """ + + comment = models.ForeignKey( + Comment, + on_delete=models.CASCADE, + related_name="reactions", + ) + emoji = models.CharField(max_length=32) + users = models.ManyToManyField(User, related_name="reactions") + + class Meta: + db_table = "impress_comment_reaction" + constraints = [ + models.UniqueConstraint( + fields=["comment", "emoji"], + name="unique_comment_emoji", + violation_error_message=_( + "This emoji has already been reacted to this comment." + ), + ), + ] + verbose_name = _("Reaction") + verbose_name_plural = _("Reactions") + + def __str__(self): + """Return the string representation of the reaction.""" + return f"Reaction {self.emoji} on comment {self.comment.id}" + + class Template(BaseModel): """HTML and CSS code used for formatting the print around the MarkDown body.""" diff --git a/src/backend/core/tests/documents/test_api_document_accesses.py b/src/backend/core/tests/documents/test_api_document_accesses.py index cdb3d4d6..aa21544c 100644 --- a/src/backend/core/tests/documents/test_api_document_accesses.py +++ b/src/backend/core/tests/documents/test_api_document_accesses.py @@ -293,7 +293,7 @@ def test_api_document_accesses_retrieve_set_role_to_child(): } assert result_dict[str(document_access_other_user.id)] == [ "reader", - "commentator", + "commenter", "editor", "administrator", "owner", @@ -302,7 +302,7 @@ def test_api_document_accesses_retrieve_set_role_to_child(): # Add an access for the other user on the parent parent_access_other_user = factories.UserDocumentAccessFactory( - document=parent, user=other_user, role="commentator" + document=parent, user=other_user, role="commenter" ) response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/") @@ -315,7 +315,7 @@ def test_api_document_accesses_retrieve_set_role_to_child(): result["id"]: result["abilities"]["set_role_to"] for result in content } assert result_dict[str(document_access_other_user.id)] == [ - "commentator", + "commenter", "editor", "administrator", "owner", @@ -323,7 +323,7 @@ def test_api_document_accesses_retrieve_set_role_to_child(): assert result_dict[str(parent_access.id)] == [] assert result_dict[str(parent_access_other_user.id)] == [ "reader", - "commentator", + "commenter", "editor", "administrator", "owner", @@ -336,28 +336,28 @@ def test_api_document_accesses_retrieve_set_role_to_child(): [ ["administrator", "reader", "reader", "reader"], [ - ["reader", "commentator", "editor", "administrator"], + ["reader", "commenter", "editor", "administrator"], [], [], - ["reader", "commentator", "editor", "administrator"], + ["reader", "commenter", "editor", "administrator"], ], ], [ ["owner", "reader", "reader", "reader"], [ - ["reader", "commentator", "editor", "administrator", "owner"], + ["reader", "commenter", "editor", "administrator", "owner"], [], [], - ["reader", "commentator", "editor", "administrator", "owner"], + ["reader", "commenter", "editor", "administrator", "owner"], ], ], [ ["owner", "reader", "reader", "owner"], [ - ["reader", "commentator", "editor", "administrator", "owner"], + ["reader", "commenter", "editor", "administrator", "owner"], [], [], - ["reader", "commentator", "editor", "administrator", "owner"], + ["reader", "commenter", "editor", "administrator", "owner"], ], ], ], @@ -418,44 +418,44 @@ def test_api_document_accesses_list_authenticated_related_same_user(roles, resul [ ["administrator", "reader", "reader", "reader"], [ - ["reader", "commentator", "editor", "administrator"], + ["reader", "commenter", "editor", "administrator"], [], [], - ["reader", "commentator", "editor", "administrator"], + ["reader", "commenter", "editor", "administrator"], ], ], [ ["owner", "reader", "reader", "reader"], [ - ["reader", "commentator", "editor", "administrator", "owner"], + ["reader", "commenter", "editor", "administrator", "owner"], [], [], - ["reader", "commentator", "editor", "administrator", "owner"], + ["reader", "commenter", "editor", "administrator", "owner"], ], ], [ ["owner", "reader", "reader", "owner"], [ - ["reader", "commentator", "editor", "administrator", "owner"], + ["reader", "commenter", "editor", "administrator", "owner"], [], [], - ["reader", "commentator", "editor", "administrator", "owner"], + ["reader", "commenter", "editor", "administrator", "owner"], ], ], [ ["reader", "reader", "reader", "owner"], [ - ["reader", "commentator", "editor", "administrator", "owner"], + ["reader", "commenter", "editor", "administrator", "owner"], [], [], - ["reader", "commentator", "editor", "administrator", "owner"], + ["reader", "commenter", "editor", "administrator", "owner"], ], ], [ ["reader", "administrator", "reader", "editor"], [ - ["reader", "commentator", "editor", "administrator"], - ["reader", "commentator", "editor", "administrator"], + ["reader", "commenter", "editor", "administrator"], + ["reader", "commenter", "editor", "administrator"], [], [], ], @@ -463,7 +463,7 @@ def test_api_document_accesses_list_authenticated_related_same_user(roles, resul [ ["editor", "editor", "administrator", "editor"], [ - ["reader", "commentator", "editor", "administrator"], + ["reader", "commenter", "editor", "administrator"], [], ["editor", "administrator"], [], diff --git a/src/backend/core/tests/documents/test_api_documents_ask_for_access.py b/src/backend/core/tests/documents/test_api_documents_ask_for_access.py index 2d93b753..c5aad35f 100644 --- a/src/backend/core/tests/documents/test_api_documents_ask_for_access.py +++ b/src/backend/core/tests/documents/test_api_documents_ask_for_access.py @@ -360,6 +360,7 @@ def test_api_documents_ask_for_access_list_owner_or_admin(role): expected_set_role_to = [ RoleChoices.READER, + RoleChoices.COMMENTER, RoleChoices.EDITOR, RoleChoices.ADMIN, ] @@ -480,6 +481,7 @@ def test_api_documents_ask_for_access_retrieve_owner_or_admin(role): assert response.status_code == 200 expected_set_role_to = [ RoleChoices.READER, + RoleChoices.COMMENTER, RoleChoices.EDITOR, RoleChoices.ADMIN, ] diff --git a/src/backend/core/tests/documents/test_api_documents_comments.py b/src/backend/core/tests/documents/test_api_documents_comments.py index 2a0cb7ce..98cbc0ef 100644 --- a/src/backend/core/tests/documents/test_api_documents_comments.py +++ b/src/backend/core/tests/documents/test_api_documents_comments.py @@ -17,42 +17,45 @@ pytestmark = pytest.mark.django_db def test_list_comments_anonymous_user_public_document(): """Anonymous users should be allowed to list comments on a public document.""" document = factories.DocumentFactory( - link_reach="public", link_role=models.LinkRoleChoices.COMMENTATOR + link_reach="public", link_role=models.LinkRoleChoices.COMMENTER ) - comment1, comment2 = factories.CommentFactory.create_batch(2, document=document) + thread = factories.ThreadFactory(document=document) + comment1, comment2 = factories.CommentFactory.create_batch(2, thread=thread) # other comments not linked to the document factories.CommentFactory.create_batch(2) - response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/comments/") + response = APIClient().get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/" + ) assert response.status_code == 200 assert response.json() == { "count": 2, "next": None, "previous": None, "results": [ - { - "id": str(comment2.id), - "content": comment2.content, - "created_at": comment2.created_at.isoformat().replace("+00:00", "Z"), - "updated_at": comment2.updated_at.isoformat().replace("+00:00", "Z"), - "user": { - "full_name": comment2.user.full_name, - "short_name": comment2.user.short_name, - }, - "document": str(comment2.document.id), - "abilities": comment2.get_abilities(AnonymousUser()), - }, { "id": str(comment1.id), - "content": comment1.content, + "body": comment1.body, "created_at": comment1.created_at.isoformat().replace("+00:00", "Z"), "updated_at": comment1.updated_at.isoformat().replace("+00:00", "Z"), "user": { "full_name": comment1.user.full_name, "short_name": comment1.user.short_name, }, - "document": str(comment1.document.id), "abilities": comment1.get_abilities(AnonymousUser()), + "reactions": [], + }, + { + "id": str(comment2.id), + "body": comment2.body, + "created_at": comment2.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": comment2.updated_at.isoformat().replace("+00:00", "Z"), + "user": { + "full_name": comment2.user.full_name, + "short_name": comment2.user.short_name, + }, + "abilities": comment2.get_abilities(AnonymousUser()), + "reactions": [], }, ], } @@ -62,13 +65,16 @@ def test_list_comments_anonymous_user_public_document(): def test_list_comments_anonymous_user_non_public_document(link_reach): """Anonymous users should not be allowed to list comments on a non-public document.""" document = factories.DocumentFactory( - link_reach=link_reach, link_role=models.LinkRoleChoices.COMMENTATOR + link_reach=link_reach, link_role=models.LinkRoleChoices.COMMENTER ) - factories.CommentFactory(document=document) + thread = factories.ThreadFactory(document=document) + factories.CommentFactory(thread=thread) # other comments not linked to the document factories.CommentFactory.create_batch(2) - response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/comments/") + response = APIClient().get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/" + ) assert response.status_code == 401 @@ -76,46 +82,49 @@ def test_list_comments_authenticated_user_accessible_document(): """Authenticated users should be allowed to list comments on an accessible document.""" user = factories.UserFactory() document = factories.DocumentFactory( - link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTATOR)] + link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)] ) - comment1 = factories.CommentFactory(document=document) - comment2 = factories.CommentFactory(document=document, user=user) + thread = factories.ThreadFactory(document=document) + comment1 = factories.CommentFactory(thread=thread) + comment2 = factories.CommentFactory(thread=thread, user=user) # other comments not linked to the document factories.CommentFactory.create_batch(2) client = APIClient() client.force_login(user) - response = client.get(f"/api/v1.0/documents/{document.id!s}/comments/") + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/" + ) assert response.status_code == 200 assert response.json() == { "count": 2, "next": None, "previous": None, "results": [ - { - "id": str(comment2.id), - "content": comment2.content, - "created_at": comment2.created_at.isoformat().replace("+00:00", "Z"), - "updated_at": comment2.updated_at.isoformat().replace("+00:00", "Z"), - "user": { - "full_name": comment2.user.full_name, - "short_name": comment2.user.short_name, - }, - "document": str(comment2.document.id), - "abilities": comment2.get_abilities(user), - }, { "id": str(comment1.id), - "content": comment1.content, + "body": comment1.body, "created_at": comment1.created_at.isoformat().replace("+00:00", "Z"), "updated_at": comment1.updated_at.isoformat().replace("+00:00", "Z"), "user": { "full_name": comment1.user.full_name, "short_name": comment1.user.short_name, }, - "document": str(comment1.document.id), "abilities": comment1.get_abilities(user), + "reactions": [], + }, + { + "id": str(comment2.id), + "body": comment2.body, + "created_at": comment2.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": comment2.updated_at.isoformat().replace("+00:00", "Z"), + "user": { + "full_name": comment2.user.full_name, + "short_name": comment2.user.short_name, + }, + "abilities": comment2.get_abilities(user), + "reactions": [], }, ], } @@ -125,14 +134,17 @@ def test_list_comments_authenticated_user_non_accessible_document(): """Authenticated users should not be allowed to list comments on a non-accessible document.""" user = factories.UserFactory() document = factories.DocumentFactory(link_reach="restricted") - factories.CommentFactory(document=document) + thread = factories.ThreadFactory(document=document) + factories.CommentFactory(thread=thread) # other comments not linked to the document factories.CommentFactory.create_batch(2) client = APIClient() client.force_login(user) - response = client.get(f"/api/v1.0/documents/{document.id!s}/comments/") + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/" + ) assert response.status_code == 403 @@ -145,14 +157,17 @@ def test_list_comments_authenticated_user_not_enough_access(): document = factories.DocumentFactory( link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)] ) - factories.CommentFactory(document=document) + thread = factories.ThreadFactory(document=document) + factories.CommentFactory(thread=thread) # other comments not linked to the document factories.CommentFactory.create_batch(2) client = APIClient() client.force_login(user) - response = client.get(f"/api/v1.0/documents/{document.id!s}/comments/") + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/" + ) assert response.status_code == 403 @@ -160,30 +175,35 @@ def test_list_comments_authenticated_user_not_enough_access(): def test_create_comment_anonymous_user_public_document(): - """Anonymous users should not be allowed to create comments on a public document.""" + """ + Anonymous users should be allowed to create comments on a public document + with commenter link_role. + """ document = factories.DocumentFactory( - link_reach="public", link_role=models.LinkRoleChoices.COMMENTATOR + link_reach="public", link_role=models.LinkRoleChoices.COMMENTER ) + thread = factories.ThreadFactory(document=document) client = APIClient() response = client.post( - f"/api/v1.0/documents/{document.id!s}/comments/", {"content": "test"} + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/", + {"body": "test"}, ) - assert response.status_code == 201 assert response.json() == { "id": str(response.json()["id"]), - "content": "test", + "body": "test", "created_at": response.json()["created_at"], "updated_at": response.json()["updated_at"], "user": None, - "document": str(document.id), "abilities": { "destroy": False, "update": False, "partial_update": False, + "reactions": False, "retrieve": True, }, + "reactions": [], } @@ -192,9 +212,11 @@ def test_create_comment_anonymous_user_non_accessible_document(): document = factories.DocumentFactory( link_reach="public", link_role=models.LinkRoleChoices.READER ) + thread = factories.ThreadFactory(document=document) client = APIClient() response = client.post( - f"/api/v1.0/documents/{document.id!s}/comments/", {"content": "test"} + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/", + {"body": "test"}, ) assert response.status_code == 401 @@ -204,31 +226,34 @@ def test_create_comment_authenticated_user_accessible_document(): """Authenticated users should be allowed to create comments on an accessible document.""" user = factories.UserFactory() document = factories.DocumentFactory( - link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTATOR)] + link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)] ) + thread = factories.ThreadFactory(document=document) client = APIClient() client.force_login(user) response = client.post( - f"/api/v1.0/documents/{document.id!s}/comments/", {"content": "test"} + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/", + {"body": "test"}, ) assert response.status_code == 201 assert response.json() == { "id": str(response.json()["id"]), - "content": "test", + "body": "test", "created_at": response.json()["created_at"], "updated_at": response.json()["updated_at"], "user": { "full_name": user.full_name, "short_name": user.short_name, }, - "document": str(document.id), "abilities": { "destroy": True, "update": True, "partial_update": True, + "reactions": True, "retrieve": True, }, + "reactions": [], } @@ -241,10 +266,12 @@ def test_create_comment_authenticated_user_not_enough_access(): document = factories.DocumentFactory( link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)] ) + thread = factories.ThreadFactory(document=document) client = APIClient() client.force_login(user) response = client.post( - f"/api/v1.0/documents/{document.id!s}/comments/", {"content": "test"} + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/", + {"body": "test"}, ) assert response.status_code == 403 @@ -255,24 +282,25 @@ def test_create_comment_authenticated_user_not_enough_access(): def test_retrieve_comment_anonymous_user_public_document(): """Anonymous users should be allowed to retrieve comments on a public document.""" document = factories.DocumentFactory( - link_reach="public", link_role=models.LinkRoleChoices.COMMENTATOR + link_reach="public", link_role=models.LinkRoleChoices.COMMENTER ) - comment = factories.CommentFactory(document=document) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) client = APIClient() response = client.get( - f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/" + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" ) assert response.status_code == 200 assert response.json() == { "id": str(comment.id), - "content": comment.content, + "body": comment.body, "created_at": comment.created_at.isoformat().replace("+00:00", "Z"), "updated_at": comment.updated_at.isoformat().replace("+00:00", "Z"), "user": { "full_name": comment.user.full_name, "short_name": comment.user.short_name, }, - "document": str(comment.document.id), + "reactions": [], "abilities": comment.get_abilities(AnonymousUser()), } @@ -282,10 +310,11 @@ def test_retrieve_comment_anonymous_user_non_accessible_document(): document = factories.DocumentFactory( link_reach="public", link_role=models.LinkRoleChoices.READER ) - comment = factories.CommentFactory(document=document) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) client = APIClient() response = client.get( - f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/" + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" ) assert response.status_code == 401 @@ -294,13 +323,14 @@ def test_retrieve_comment_authenticated_user_accessible_document(): """Authenticated users should be allowed to retrieve comments on an accessible document.""" user = factories.UserFactory() document = factories.DocumentFactory( - link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTATOR)] + link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)] ) - comment = factories.CommentFactory(document=document) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) client = APIClient() client.force_login(user) response = client.get( - f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/" + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" ) assert response.status_code == 200 @@ -314,11 +344,12 @@ def test_retrieve_comment_authenticated_user_not_enough_access(): document = factories.DocumentFactory( link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)] ) - comment = factories.CommentFactory(document=document) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) client = APIClient() client.force_login(user) response = client.get( - f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/" + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" ) assert response.status_code == 403 @@ -329,13 +360,14 @@ def test_retrieve_comment_authenticated_user_not_enough_access(): def test_update_comment_anonymous_user_public_document(): """Anonymous users should not be allowed to update comments on a public document.""" document = factories.DocumentFactory( - link_reach="public", link_role=models.LinkRoleChoices.COMMENTATOR + link_reach="public", link_role=models.LinkRoleChoices.COMMENTER ) - comment = factories.CommentFactory(document=document, content="test") + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread, body="test") client = APIClient() response = client.put( - f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/", - {"content": "other content"}, + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/", + {"body": "other content"}, ) assert response.status_code == 401 @@ -345,11 +377,12 @@ def test_update_comment_anonymous_user_non_accessible_document(): document = factories.DocumentFactory( link_reach="public", link_role=models.LinkRoleChoices.READER ) - comment = factories.CommentFactory(document=document, content="test") + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread, body="test") client = APIClient() response = client.put( - f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/", - {"content": "other content"}, + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/", + {"body": "other content"}, ) assert response.status_code == 401 @@ -363,17 +396,18 @@ def test_update_comment_authenticated_user_accessible_document(): ( user, random.choice( - [models.LinkRoleChoices.COMMENTATOR, models.LinkRoleChoices.EDITOR] + [models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR] ), ) ], ) - comment = factories.CommentFactory(document=document, content="test") + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread, body="test") client = APIClient() client.force_login(user) response = client.put( - f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/", - {"content": "other content"}, + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/", + {"body": "other content"}, ) assert response.status_code == 403 @@ -387,22 +421,23 @@ def test_update_comment_authenticated_user_own_comment(): ( user, random.choice( - [models.LinkRoleChoices.COMMENTATOR, models.LinkRoleChoices.EDITOR] + [models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR] ), ) ], ) - comment = factories.CommentFactory(document=document, content="test", user=user) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread, body="test", user=user) client = APIClient() client.force_login(user) response = client.put( - f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/", - {"content": "other content"}, + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/", + {"body": "other content"}, ) assert response.status_code == 200 comment.refresh_from_db() - assert comment.content == "other content" + assert comment.body == "other content" def test_update_comment_authenticated_user_not_enough_access(): @@ -414,12 +449,13 @@ def test_update_comment_authenticated_user_not_enough_access(): document = factories.DocumentFactory( link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)] ) - comment = factories.CommentFactory(document=document, content="test") + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread, body="test") client = APIClient() client.force_login(user) response = client.put( - f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/", - {"content": "other content"}, + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/", + {"body": "other content"}, ) assert response.status_code == 403 @@ -431,12 +467,13 @@ def test_update_comment_authenticated_no_access(): """ user = factories.UserFactory() document = factories.DocumentFactory(link_reach="restricted") - comment = factories.CommentFactory(document=document, content="test") + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread, body="test") client = APIClient() client.force_login(user) response = client.put( - f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/", - {"content": "other content"}, + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/", + {"body": "other content"}, ) assert response.status_code == 403 @@ -448,18 +485,19 @@ def test_update_comment_authenticated_admin_or_owner_can_update_any_comment(role """ user = factories.UserFactory() document = factories.DocumentFactory(users=[(user, role)]) - comment = factories.CommentFactory(document=document, content="test") + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread, body="test") client = APIClient() client.force_login(user) response = client.put( - f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/", - {"content": "other content"}, + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/", + {"body": "other content"}, ) assert response.status_code == 200 comment.refresh_from_db() - assert comment.content == "other content" + assert comment.body == "other content" @pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER]) @@ -469,18 +507,19 @@ def test_update_comment_authenticated_admin_or_owner_can_update_own_comment(role """ user = factories.UserFactory() document = factories.DocumentFactory(users=[(user, role)]) - comment = factories.CommentFactory(document=document, content="test", user=user) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread, body="test", user=user) client = APIClient() client.force_login(user) response = client.put( - f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/", - {"content": "other content"}, + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/", + {"body": "other content"}, ) assert response.status_code == 200 comment.refresh_from_db() - assert comment.content == "other content" + assert comment.body == "other content" # Delete comment @@ -489,12 +528,13 @@ def test_update_comment_authenticated_admin_or_owner_can_update_own_comment(role def test_delete_comment_anonymous_user_public_document(): """Anonymous users should not be allowed to delete comments on a public document.""" document = factories.DocumentFactory( - link_reach="public", link_role=models.LinkRoleChoices.COMMENTATOR + link_reach="public", link_role=models.LinkRoleChoices.COMMENTER ) - comment = factories.CommentFactory(document=document) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) client = APIClient() response = client.delete( - f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/" + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" ) assert response.status_code == 401 @@ -504,10 +544,11 @@ def test_delete_comment_anonymous_user_non_accessible_document(): document = factories.DocumentFactory( link_reach="public", link_role=models.LinkRoleChoices.READER ) - comment = factories.CommentFactory(document=document) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) client = APIClient() response = client.delete( - f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/" + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" ) assert response.status_code == 401 @@ -516,13 +557,14 @@ def test_delete_comment_authenticated_user_accessible_document_own_comment(): """Authenticated users should be able to delete comments on an accessible document.""" user = factories.UserFactory() document = factories.DocumentFactory( - link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTATOR)] + link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)] ) - comment = factories.CommentFactory(document=document, user=user) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread, user=user) client = APIClient() client.force_login(user) response = client.delete( - f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/" + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" ) assert response.status_code == 204 @@ -531,13 +573,14 @@ def test_delete_comment_authenticated_user_accessible_document_not_own_comment() """Authenticated users should not be able to delete comments on an accessible document.""" user = factories.UserFactory() document = factories.DocumentFactory( - link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTATOR)] + link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)] ) - comment = factories.CommentFactory(document=document) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) client = APIClient() client.force_login(user) response = client.delete( - f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/" + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" ) assert response.status_code == 403 @@ -547,11 +590,12 @@ def test_delete_comment_authenticated_user_admin_or_owner_can_delete_any_comment """Authenticated users should be able to delete comments on a document they have access to.""" user = factories.UserFactory() document = factories.DocumentFactory(users=[(user, role)]) - comment = factories.CommentFactory(document=document) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) client = APIClient() client.force_login(user) response = client.delete( - f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/" + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" ) assert response.status_code == 204 @@ -561,11 +605,12 @@ def test_delete_comment_authenticated_user_admin_or_owner_can_delete_own_comment """Authenticated users should be able to delete comments on a document they have access to.""" user = factories.UserFactory() document = factories.DocumentFactory(users=[(user, role)]) - comment = factories.CommentFactory(document=document, user=user) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread, user=user) client = APIClient() client.force_login(user) response = client.delete( - f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/" + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" ) assert response.status_code == 204 @@ -579,10 +624,255 @@ def test_delete_comment_authenticated_user_not_enough_access(): document = factories.DocumentFactory( link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)] ) - comment = factories.CommentFactory(document=document) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) client = APIClient() client.force_login(user) response = client.delete( - f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/" + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" ) assert response.status_code == 403 + + +# Create reaction + + +@pytest.mark.parametrize("link_role", models.LinkRoleChoices.values) +def test_create_reaction_anonymous_user_public_document(link_role): + """No matter the link_role, an anonymous user can not react to a comment.""" + + document = factories.DocumentFactory(link_reach="public", link_role=link_role) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) + client = APIClient() + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/" + f"comments/{comment.id!s}/reactions/", + {"emoji": "test"}, + ) + assert response.status_code == 401 + + +def test_create_reaction_authenticated_user_public_document(): + """ + Authenticated users should not be able to reaction to a comment on a public document with + link_role reader. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="public", link_role=models.LinkRoleChoices.READER + ) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/" + f"comments/{comment.id!s}/reactions/", + {"emoji": "test"}, + ) + assert response.status_code == 403 + + +def test_create_reaction_authenticated_user_accessible_public_document(): + """ + Authenticated users should be able to react to a comment on a public document. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="public", link_role=models.LinkRoleChoices.COMMENTER + ) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/" + f"comments/{comment.id!s}/reactions/", + {"emoji": "test"}, + ) + assert response.status_code == 201 + + assert models.Reaction.objects.filter( + comment=comment, emoji="test", users__in=[user] + ).exists() + + +def test_create_reaction_authenticated_user_connected_document_link_role_reader(): + """ + Authenticated users should not be able to react to a comment on a connected document + with link_role reader. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="authenticated", link_role=models.LinkRoleChoices.READER + ) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/" + f"comments/{comment.id!s}/reactions/", + {"emoji": "test"}, + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "link_role", + [ + role + for role in models.LinkRoleChoices.values + if role != models.LinkRoleChoices.READER + ], +) +def test_create_reaction_authenticated_user_connected_document(link_role): + """ + Authenticated users should be able to react to a comment on a connected document. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="authenticated", link_role=link_role + ) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/" + f"comments/{comment.id!s}/reactions/", + {"emoji": "test"}, + ) + assert response.status_code == 201 + + assert models.Reaction.objects.filter( + comment=comment, emoji="test", users__in=[user] + ).exists() + + +def test_create_reaction_authenticated_user_restricted_accessible_document(): + """ + Authenticated users should not be able to react to a comment on a restricted accessible document + they don't have access to. + """ + user = factories.UserFactory() + document = factories.DocumentFactory(link_reach="restricted") + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/" + f"comments/{comment.id!s}/reactions/", + {"emoji": "test"}, + ) + assert response.status_code == 403 + + +def test_create_reaction_authenticated_user_restricted_accessible_document_role_reader(): + """ + Authenticated users should not be able to react to a comment on a restricted accessible + document with role reader. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", link_role=models.LinkRoleChoices.READER + ) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/" + f"comments/{comment.id!s}/reactions/", + {"emoji": "test"}, + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "role", + [role for role in models.RoleChoices.values if role != models.RoleChoices.READER], +) +def test_create_reaction_authenticated_user_restricted_accessible_document_role_commenter( + role, +): + """ + Authenticated users should be able to react to a comment on a restricted accessible document + with role commenter. + """ + user = factories.UserFactory() + document = factories.DocumentFactory(link_reach="restricted", users=[(user, role)]) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/" + f"comments/{comment.id!s}/reactions/", + {"emoji": "test"}, + ) + assert response.status_code == 201 + + assert models.Reaction.objects.filter( + comment=comment, emoji="test", users__in=[user] + ).exists() + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/" + f"comments/{comment.id!s}/reactions/", + {"emoji": "test"}, + ) + assert response.status_code == 400 + assert response.json() == {"user_already_reacted": True} + + +# Delete reaction + + +def test_delete_reaction_not_owned_by_the_current_user(): + """ + Users should not be able to delete reactions not owned by the current user. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", users=[(user, models.RoleChoices.ADMIN)] + ) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) + reaction = factories.ReactionFactory(comment=comment) + + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/" + f"comments/{comment.id!s}/reactions/", + {"emoji": reaction.emoji}, + ) + assert response.status_code == 404 + + +def test_delete_reaction_owned_by_the_current_user(): + """ + Users should not be able to delete reactions not owned by the current user. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", users=[(user, models.RoleChoices.ADMIN)] + ) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) + reaction = factories.ReactionFactory(comment=comment) + + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/" + f"comments/{comment.id!s}/reactions/", + {"emoji": reaction.emoji}, + ) + assert response.status_code == 404 + + reaction.refresh_from_db() + assert reaction.users.exists() diff --git a/src/backend/core/tests/documents/test_api_documents_retrieve.py b/src/backend/core/tests/documents/test_api_documents_retrieve.py index de158fba..59c5e029 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve.py +++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -36,7 +36,7 @@ def test_api_documents_retrieve_anonymous_public_standalone(): "children_create": False, "children_list": True, "collaboration_auth": True, - "comment": document.link_role in ["commentator", "editor"], + "comment": document.link_role in ["commenter", "editor"], "cors_proxy": True, "content": True, "descendants": True, @@ -47,8 +47,8 @@ def test_api_documents_retrieve_anonymous_public_standalone(): "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "commentator", "editor"], - "public": ["reader", "commentator", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": False, @@ -114,7 +114,7 @@ def test_api_documents_retrieve_anonymous_public_parent(): "children_create": False, "children_list": True, "collaboration_auth": True, - "comment": grand_parent.link_role in ["commentator", "editor"], + "comment": grand_parent.link_role in ["commenter", "editor"], "descendants": True, "cors_proxy": True, "content": True, @@ -222,7 +222,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated( "children_create": document.link_role == "editor", "children_list": True, "collaboration_auth": True, - "comment": document.link_role in ["commentator", "editor"], + "comment": document.link_role in ["commenter", "editor"], "descendants": True, "cors_proxy": True, "content": True, @@ -232,8 +232,8 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated( "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "commentator", "editor"], - "public": ["reader", "commentator", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": True, @@ -307,7 +307,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea "children_create": grand_parent.link_role == "editor", "children_list": True, "collaboration_auth": True, - "comment": grand_parent.link_role in ["commentator", "editor"], + "comment": grand_parent.link_role in ["commenter", "editor"], "descendants": True, "cors_proxy": True, "content": True, @@ -498,11 +498,11 @@ def test_api_documents_retrieve_authenticated_related_parent(): "abilities": { "accesses_manage": access.role in ["administrator", "owner"], "accesses_view": True, - "ai_transform": access.role != "reader", - "ai_translate": access.role != "reader", - "attachment_upload": access.role != "reader", - "can_edit": access.role not in ["reader", "commentator"], - "children_create": access.role != "reader", + "ai_transform": access.role not in ["reader", "commenter"], + "ai_translate": access.role not in ["reader", "commenter"], + "attachment_upload": access.role not in ["reader", "commenter"], + "can_edit": access.role not in ["reader", "commenter"], + "children_create": access.role not in ["reader", "commenter"], "children_list": True, "collaboration_auth": True, "comment": access.role != "reader", @@ -521,11 +521,11 @@ def test_api_documents_retrieve_authenticated_related_parent(): "media_auth": True, "media_check": True, "move": access.role in ["administrator", "owner"], - "partial_update": access.role != "reader", + "partial_update": access.role not in ["reader", "commenter"], "restore": access.role == "owner", "retrieve": True, "tree": True, - "update": access.role != "reader", + "update": access.role not in ["reader", "commenter"], "versions_destroy": access.role in ["administrator", "owner"], "versions_list": True, "versions_retrieve": True, diff --git a/src/backend/core/tests/documents/test_api_documents_threads.py b/src/backend/core/tests/documents/test_api_documents_threads.py new file mode 100644 index 00000000..cea0ae96 --- /dev/null +++ b/src/backend/core/tests/documents/test_api_documents_threads.py @@ -0,0 +1,1226 @@ +"""Test Thread viewset.""" + +import pytest +from rest_framework.test import APIClient + +from core import factories, models + +pytestmark = pytest.mark.django_db + +# pylint: disable=too-many-lines + + +# Create + + +def test_api_documents_threads_public_document_link_role_reader(): + """ + Anonymous users should not be allowed to create threads on public documents with reader + link_role. + """ + document = factories.DocumentFactory( + link_reach="public", + link_role=models.LinkRoleChoices.READER, + ) + + client = APIClient() + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/", + { + "body": "test", + }, + ) + assert response.status_code == 401 + + +@pytest.mark.parametrize( + "link_role", [models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR] +) +def test_api_documents_threads_public_document(link_role): + """ + Anonymous users should be allowed to create threads on public documents with commenter + link_role. + """ + document = factories.DocumentFactory( + link_reach="public", + link_role=link_role, + ) + + client = APIClient() + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/", + { + "body": "test", + }, + ) + + assert response.status_code == 201 + thread = models.Thread.objects.first() + comment = thread.comments.first() + content = response.json() + assert content == { + "id": str(thread.id), + "created_at": thread.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": thread.updated_at.isoformat().replace("+00:00", "Z"), + "creator": None, + "comments": [ + { + "id": str(comment.id), + "body": "test", + "created_at": comment.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": comment.updated_at.isoformat().replace("+00:00", "Z"), + "user": None, + "reactions": [], + "abilities": { + "destroy": False, + "update": False, + "partial_update": False, + "reactions": False, + "retrieve": True, + }, + } + ], + "abilities": { + "destroy": False, + "update": False, + "partial_update": False, + "resolve": False, + "retrieve": True, + }, + "metadata": {}, + "resolved": False, + "resolved_at": None, + "resolved_by": None, + } + + +def test_api_documents_threads_restricted_document(): + """ + Authenticated users should not be allowed to create threads on restricted + documents with reader roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.READER, + users=[(user, models.LinkRoleChoices.READER)], + ) + + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/", + { + "body": "test", + }, + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "role", + [role for role in models.RoleChoices.values if role != models.RoleChoices.READER], +) +def test_api_documents_threads_restricted_document_editor(role): + """ + Authenticated users should be allowed to create threads on restricted + documents with editor roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.EDITOR, + users=[(user, role)], + ) + + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/", + { + "body": "test", + }, + ) + + assert response.status_code == 201 + thread = models.Thread.objects.first() + comment = thread.comments.first() + content = response.json() + assert content == { + "id": str(thread.id), + "created_at": thread.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": thread.updated_at.isoformat().replace("+00:00", "Z"), + "creator": { + "full_name": user.full_name, + "short_name": user.short_name, + }, + "comments": [ + { + "id": str(comment.id), + "body": "test", + "created_at": comment.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": comment.updated_at.isoformat().replace("+00:00", "Z"), + "user": { + "full_name": user.full_name, + "short_name": user.short_name, + }, + "reactions": [], + "abilities": { + "destroy": True, + "update": True, + "partial_update": True, + "reactions": True, + "retrieve": True, + }, + } + ], + "abilities": { + "destroy": True, + "update": True, + "partial_update": True, + "resolve": True, + "retrieve": True, + }, + "metadata": {}, + "resolved": False, + "resolved_at": None, + "resolved_by": None, + } + + +def test_api_documents_threads_authenticated_document_anonymous_user(): + """ + Anonymous users should not be allowed to create threads on authenticated documents. + """ + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + client = APIClient() + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/", + { + "body": "test", + }, + ) + assert response.status_code == 401 + + +def test_api_documents_threads_authenticated_document_reader_role(): + """ + Authenticated users should not be allowed to create threads on authenticated + documents with reader link_role. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=models.LinkRoleChoices.READER, + ) + + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/", + { + "body": "test", + }, + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "link_role", [models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR] +) +def test_api_documents_threads_authenticated_document(link_role): + """ + Authenticated users should be allowed to create threads on authenticated + documents with commenter or editor link_role. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=link_role, + ) + + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/", + { + "body": "test", + }, + ) + + assert response.status_code == 201 + thread = models.Thread.objects.first() + comment = thread.comments.first() + content = response.json() + assert content == { + "id": str(thread.id), + "created_at": thread.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": thread.updated_at.isoformat().replace("+00:00", "Z"), + "creator": { + "full_name": user.full_name, + "short_name": user.short_name, + }, + "comments": [ + { + "id": str(comment.id), + "body": "test", + "created_at": comment.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": comment.updated_at.isoformat().replace("+00:00", "Z"), + "user": { + "full_name": user.full_name, + "short_name": user.short_name, + }, + "reactions": [], + "abilities": { + "destroy": True, + "update": True, + "partial_update": True, + "reactions": True, + "retrieve": True, + }, + } + ], + "abilities": { + "destroy": True, + "update": True, + "partial_update": True, + "resolve": True, + "retrieve": True, + }, + "metadata": {}, + "resolved": False, + "resolved_at": None, + "resolved_by": None, + } + + +# List + + +def test_api_documents_threads_list_public_document_link_role_reader(): + """ + Anonymous users should not be allowed to retrieve threads on public documents with reader + link_role. + """ + document = factories.DocumentFactory( + link_reach="public", + link_role=models.LinkRoleChoices.READER, + ) + + factories.ThreadFactory.create_batch(3, document=document) + + client = APIClient() + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/", + ) + assert response.status_code == 401 + + +@pytest.mark.parametrize( + "link_role", [models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR] +) +def test_api_documents_threads_list_public_document_link_role_higher_than_reader( + link_role, +): + """ + Anonymous users should be allowed to retrieve threads on public documents with commenter or + editor link_role. + """ + document = factories.DocumentFactory( + link_reach="public", + link_role=link_role, + ) + + factories.ThreadFactory.create_batch(3, document=document) + + client = APIClient() + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/", + ) + assert response.status_code == 200 + assert response.json()["count"] == 3 + + +def test_api_documents_threads_list_authenticated_document_anonymous_user(): + """ + Anonymous users should not be allowed to retrieve threads on authenticated documents. + """ + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + factories.ThreadFactory.create_batch(3, document=document) + + client = APIClient() + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/", + ) + assert response.status_code == 401 + + +def test_api_documents_threads_list_authenticated_document_reader_role(): + """ + Authenticated users should not be allowed to retrieve threads on authenticated + documents with reader link_role. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=models.LinkRoleChoices.READER, + ) + + factories.ThreadFactory.create_batch(3, document=document) + + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/", + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "link_role", [models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR] +) +def test_api_documents_threads_list_authenticated_document(link_role): + """ + Authenticated users should be allowed to retrieve threads on authenticated + documents with commenter or editor link_role. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=link_role, + ) + + factories.ThreadFactory.create_batch(3, document=document) + + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/", + ) + assert response.status_code == 200 + assert response.json()["count"] == 3 + + +def test_api_documents_threads_list_restricted_document_anonymous_user(): + """ + Anonymous users should not be allowed to retrieve threads on restricted documents. + """ + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + factories.ThreadFactory.create_batch(3, document=document) + + client = APIClient() + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/", + ) + assert response.status_code == 401 + + +def test_api_documents_threads_list_restricted_document_reader_role(): + """ + Authenticated users should not be allowed to retrieve threads on restricted + documents with reader roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.READER, + users=[(user, models.LinkRoleChoices.READER)], + ) + + factories.ThreadFactory.create_batch(3, document=document) + + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/", + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "role", + [role for role in models.RoleChoices.values if role != models.RoleChoices.READER], +) +def test_api_documents_threads_list_restricted_document_editor(role): + """ + Authenticated users should be allowed to retrieve threads on restricted + documents with editor roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.EDITOR, + users=[(user, role)], + ) + + factories.ThreadFactory.create_batch(3, document=document) + + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/", + ) + assert response.status_code == 200 + assert response.json()["count"] == 3 + + +# Retrieve + + +def test_api_documents_threads_retrieve_public_document_link_role_reader(): + """ + Anonymous users should not be allowed to retrieve threads on public documents with reader + link_role. + """ + document = factories.DocumentFactory( + link_reach="public", + link_role=models.LinkRoleChoices.READER, + ) + + thread = factories.ThreadFactory(document=document) + + client = APIClient() + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 401 + + +@pytest.mark.parametrize( + "link_role", [models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR] +) +def test_api_documents_threads_retrieve_public_document_link_role_higher_than_reader( + link_role, +): + """ + Anonymous users should be allowed to retrieve threads on public documents with commenter or + editor link_role. + """ + document = factories.DocumentFactory( + link_reach="public", + link_role=link_role, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + comment = factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 200 + content = response.json() + assert content == { + "id": str(thread.id), + "created_at": thread.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": thread.updated_at.isoformat().replace("+00:00", "Z"), + "creator": None, + "comments": [ + { + "id": str(comment.id), + "body": comment.body, + "created_at": comment.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": comment.updated_at.isoformat().replace("+00:00", "Z"), + "user": None, + "reactions": [], + "abilities": { + "destroy": False, + "update": False, + "partial_update": False, + "reactions": False, + "retrieve": True, + }, + } + ], + "abilities": { + "destroy": False, + "update": False, + "partial_update": False, + "resolve": False, + "retrieve": True, + }, + "metadata": {}, + "resolved": False, + "resolved_at": None, + "resolved_by": None, + } + + +def test_api_documents_threads_retrieve_authenticated_document_anonymous_user(): + """ + Anonymous users should not be allowed to retrieve threads on authenticated documents. + """ + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + thread = factories.ThreadFactory(document=document) + + client = APIClient() + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 401 + + +def test_api_documents_threads_retrieve_authenticated_document_reader_role(): + """ + Authenticated users should not be allowed to retrieve threads on authenticated + documents with reader link_role. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=models.LinkRoleChoices.READER, + ) + + thread = factories.ThreadFactory(document=document) + + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "link_role", [models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR] +) +def test_api_documents_threads_retrieve_authenticated_document(link_role): + """ + Authenticated users should be allowed to retrieve threads on authenticated + documents with commenter or editor link_role. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=link_role, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + comment = factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 200 + content = response.json() + assert content == { + "id": str(thread.id), + "created_at": thread.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": thread.updated_at.isoformat().replace("+00:00", "Z"), + "creator": None, + "metadata": {}, + "resolved": False, + "resolved_at": None, + "resolved_by": None, + "comments": [ + { + "id": str(comment.id), + "body": comment.body, + "created_at": comment.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": comment.updated_at.isoformat().replace("+00:00", "Z"), + "user": None, + "reactions": [], + "abilities": { + "destroy": False, + "update": False, + "partial_update": False, + "reactions": True, + "retrieve": True, + }, + } + ], + "abilities": { + "destroy": False, + "update": False, + "partial_update": False, + "resolve": False, + "retrieve": True, + }, + } + + +def test_api_documents_threads_retrieve_restricted_document_anonymous_user(): + """ + Anonymous users should not be allowed to retrieve threads on restricted documents. + """ + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + thread = factories.ThreadFactory(document=document) + + client = APIClient() + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 401 + + +def test_api_documents_threads_retrieve_restricted_document_reader_role(): + """ + Authenticated users should not be allowed to retrieve threads on restricted + documents with reader roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.READER, + users=[(user, models.LinkRoleChoices.READER)], + ) + + thread = factories.ThreadFactory(document=document) + + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "role", [models.RoleChoices.COMMENTER, models.RoleChoices.EDITOR] +) +def test_api_documents_threads_retrieve_restricted_document_editor(role): + """ + Authenticated users should be allowed to retrieve threads on restricted + documents with editor roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.EDITOR, + users=[(user, role)], + ) + + thread = factories.ThreadFactory(document=document, creator=None) + comment = factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 200 + content = response.json() + assert content == { + "id": str(thread.id), + "created_at": thread.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": thread.updated_at.isoformat().replace("+00:00", "Z"), + "creator": None, + "comments": [ + { + "id": str(comment.id), + "body": comment.body, + "created_at": comment.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": comment.updated_at.isoformat().replace("+00:00", "Z"), + "user": None, + "reactions": [], + "abilities": { + "destroy": False, + "update": False, + "partial_update": False, + "reactions": True, + "retrieve": True, + }, + } + ], + "abilities": { + "destroy": False, + "update": False, + "partial_update": False, + "resolve": False, + "retrieve": True, + }, + "metadata": {}, + "resolved": False, + "resolved_at": None, + "resolved_by": None, + } + + +@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER]) +def test_api_documents_threads_retrieve_restricted_document_privileged_roles(role): + """ + Authenticated users with privileged roles should be allowed to retrieve + threads on restricted documents with editor roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.EDITOR, + users=[(user, role)], + ) + + thread = factories.ThreadFactory(document=document, creator=None) + comment = factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 200 + content = response.json() + assert content == { + "id": str(thread.id), + "created_at": thread.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": thread.updated_at.isoformat().replace("+00:00", "Z"), + "creator": None, + "comments": [ + { + "id": str(comment.id), + "body": comment.body, + "created_at": comment.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": comment.updated_at.isoformat().replace("+00:00", "Z"), + "user": None, + "reactions": [], + "abilities": { + "destroy": True, + "update": True, + "partial_update": True, + "reactions": True, + "retrieve": True, + }, + } + ], + "abilities": { + "destroy": True, + "update": True, + "partial_update": True, + "resolve": True, + "retrieve": True, + }, + "metadata": {}, + "resolved": False, + "resolved_at": None, + "resolved_by": None, + } + + +# Destroy + + +def test_api_documents_threads_destroy_public_document_anonymous_user(): + """ + Anonymous users should not be allowed to destroy threads on public documents. + """ + document = factories.DocumentFactory( + link_reach="public", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 401 + + +def test_api_documents_threads_destroy_public_document_authenticated_user(): + """ + Authenticated users should not be allowed to destroy threads on public documents. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="public", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 403 + + +def test_api_documents_threads_destroy_authenticated_document_anonymous_user(): + """ + Anonymous users should not be allowed to destroy threads on authenticated documents. + """ + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 401 + + +def test_api_documents_threads_destroy_authenticated_document_reader_role(): + """ + Authenticated users should not be allowed to destroy threads on authenticated + documents with reader link_role. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=models.LinkRoleChoices.READER, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "link_role", [models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR] +) +def test_api_documents_threads_destroy_authenticated_document(link_role): + """ + Authenticated users should not be allowed to destroy threads on authenticated + documents with commenter or editor link_role. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=link_role, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 403 + + +def test_api_documents_threads_destroy_restricted_document_anonymous_user(): + """ + Anonymous users should not be allowed to destroy threads on restricted documents. + """ + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 401 + + +def test_api_documents_threads_destroy_restricted_document_reader_role(): + """ + Authenticated users should not be allowed to destroy threads on restricted + documents with reader roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.READER, + users=[(user, models.LinkRoleChoices.READER)], + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "role", [models.RoleChoices.COMMENTER, models.RoleChoices.EDITOR] +) +def test_api_documents_threads_destroy_restricted_document_editor(role): + """ + Authenticated users should not be allowed to destroy threads on restricted + documents with editor roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.EDITOR, + users=[(user, role)], + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER]) +def test_api_documents_threads_destroy_restricted_document_privileged_roles(role): + """ + Authenticated users with privileged roles should be allowed to destroy + threads on restricted documents with editor roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.EDITOR, + users=[(user, role)], + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 204 + assert not models.Thread.objects.filter(id=thread.id).exists() + + +# Resolve + + +def test_api_documents_threads_resolve_public_document_anonymous_user(): + """ + Anonymous users should not be allowed to resolve threads on public documents. + """ + document = factories.DocumentFactory( + link_reach="public", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/resolve/", + ) + assert response.status_code == 401 + + +def test_api_documents_threads_resolve_public_document_authenticated_user(): + """ + Authenticated users should not be allowed to resolve threads on public documents. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="public", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/resolve/", + ) + assert response.status_code == 403 + + +def test_api_documents_threads_resolve_authenticated_document_anonymous_user(): + """ + Anonymous users should not be allowed to resolve threads on authenticated documents. + """ + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/resolve/", + ) + assert response.status_code == 401 + + +def test_api_documents_threads_resolve_authenticated_document_reader_role(): + """ + Authenticated users should not be allowed to resolve threads on authenticated + documents with reader link_role. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=models.LinkRoleChoices.READER, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/resolve/", + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "link_role", [models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR] +) +def test_api_documents_threads_resolve_authenticated_document(link_role): + """ + Authenticated users should not be allowed to resolve threads on authenticated documents with + commenter or editor link_role. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=link_role, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/resolve/", + ) + assert response.status_code == 403 + + +def test_api_documents_threads_resolve_restricted_document_anonymous_user(): + """ + Anonymous users should not be allowed to resolve threads on restricted documents. + """ + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/resolve/", + ) + assert response.status_code == 401 + + +def test_api_documents_threads_resolve_restricted_document_reader_role(): + """ + Authenticated users should not be allowed to resolve threads on restricted documents with + reader roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.READER, + users=[(user, models.LinkRoleChoices.READER)], + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/resolve/", + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "role", [models.RoleChoices.COMMENTER, models.RoleChoices.EDITOR] +) +def test_api_documents_threads_resolve_restricted_document_editor(role): + """ + Authenticated users should not be allowed to resolve threads on restricted documents with + editor roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.EDITOR, + users=[(user, role)], + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/resolve/", + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER]) +def test_api_documents_threads_resolve_restricted_document_privileged_roles(role): + """ + Authenticated users with privileged roles should be allowed to resolve threads on + restricted documents with editor roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.EDITOR, + users=[(user, role)], + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/resolve/", + ) + assert response.status_code == 204 + + # Verify thread is resolved + thread.refresh_from_db() + assert thread.resolved is True + assert thread.resolved_at is not None + assert thread.resolved_by == user diff --git a/src/backend/core/tests/documents/test_api_documents_trashbin.py b/src/backend/core/tests/documents/test_api_documents_trashbin.py index 28ea6e8b..cc32c09f 100644 --- a/src/backend/core/tests/documents/test_api_documents_trashbin.py +++ b/src/backend/core/tests/documents/test_api_documents_trashbin.py @@ -89,8 +89,8 @@ def test_api_documents_trashbin_format(): "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "commentator", "editor"], - "public": ["reader", "commentator", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": False, diff --git a/src/backend/core/tests/test_models_comment.py b/src/backend/core/tests/test_models_comment.py index dac0b36c..7ff8cc87 100644 --- a/src/backend/core/tests/test_models_comment.py +++ b/src/backend/core/tests/test_models_comment.py @@ -16,7 +16,7 @@ pytestmark = pytest.mark.django_db "role,can_comment", [ (LinkRoleChoices.READER, False), - (LinkRoleChoices.COMMENTATOR, True), + (LinkRoleChoices.COMMENTER, True), (LinkRoleChoices.EDITOR, True), ], ) @@ -25,13 +25,14 @@ def test_comment_get_abilities_anonymous_user_public_document(role, can_comment) document = factories.DocumentFactory( link_role=role, link_reach=LinkReachChoices.PUBLIC ) - comment = factories.CommentFactory(document=document) + comment = factories.CommentFactory(thread__document=document) user = AnonymousUser() assert comment.get_abilities(user) == { "destroy": False, "update": False, "partial_update": False, + "reactions": False, "retrieve": can_comment, } @@ -42,13 +43,14 @@ def test_comment_get_abilities_anonymous_user_public_document(role, can_comment) def test_comment_get_abilities_anonymous_user_restricted_document(link_reach): """Anonymous users cannot comment on a restricted document.""" document = factories.DocumentFactory(link_reach=link_reach) - comment = factories.CommentFactory(document=document) + comment = factories.CommentFactory(thread__document=document) user = AnonymousUser() assert comment.get_abilities(user) == { "destroy": False, "update": False, "partial_update": False, + "reactions": False, "retrieve": False, } @@ -57,13 +59,13 @@ def test_comment_get_abilities_anonymous_user_restricted_document(link_reach): "link_role,link_reach,can_comment", [ (LinkRoleChoices.READER, LinkReachChoices.PUBLIC, False), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC, True), + (LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC, True), (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC, True), (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED, False), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED, False), + (LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED, False), (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED, False), (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED, False), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.AUTHENTICATED, True), + (LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED, True), (LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED, True), ], ) @@ -73,12 +75,13 @@ def test_comment_get_abilities_user_reader(link_role, link_reach, can_comment): document = factories.DocumentFactory( link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.READER)] ) - comment = factories.CommentFactory(document=document) + comment = factories.CommentFactory(thread__document=document) assert comment.get_abilities(user) == { "destroy": False, "update": False, "partial_update": False, + "reactions": can_comment, "retrieve": can_comment, } @@ -87,13 +90,13 @@ def test_comment_get_abilities_user_reader(link_role, link_reach, can_comment): "link_role,link_reach,can_comment", [ (LinkRoleChoices.READER, LinkReachChoices.PUBLIC, False), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC, True), + (LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC, True), (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC, True), (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED, False), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED, False), + (LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED, False), (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED, False), (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED, False), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.AUTHENTICATED, True), + (LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED, True), (LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED, True), ], ) @@ -106,13 +109,14 @@ def test_comment_get_abilities_user_reader_own_comment( link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.READER)] ) comment = factories.CommentFactory( - document=document, user=user if can_comment else None + thread__document=document, user=user if can_comment else None ) assert comment.get_abilities(user) == { "destroy": can_comment, "update": can_comment, "partial_update": can_comment, + "reactions": can_comment, "retrieve": can_comment, } @@ -121,30 +125,31 @@ def test_comment_get_abilities_user_reader_own_comment( "link_role,link_reach", [ (LinkRoleChoices.READER, LinkReachChoices.PUBLIC), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC), (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC), (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED), (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED), (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED), (LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED), ], ) -def test_comment_get_abilities_user_commentator(link_role, link_reach): - """Commentators can comment on a document.""" +def test_comment_get_abilities_user_commenter(link_role, link_reach): + """Commenters can comment on a document.""" user = factories.UserFactory() document = factories.DocumentFactory( link_role=link_role, link_reach=link_reach, - users=[(user, RoleChoices.COMMENTATOR)], + users=[(user, RoleChoices.COMMENTER)], ) - comment = factories.CommentFactory(document=document) + comment = factories.CommentFactory(thread__document=document) assert comment.get_abilities(user) == { "destroy": False, "update": False, "partial_update": False, + "reactions": True, "retrieve": True, } @@ -153,30 +158,31 @@ def test_comment_get_abilities_user_commentator(link_role, link_reach): "link_role,link_reach", [ (LinkRoleChoices.READER, LinkReachChoices.PUBLIC), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC), (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC), (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED), (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED), (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED), (LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED), ], ) -def test_comment_get_abilities_user_commentator_own_comment(link_role, link_reach): - """Commentators have all accesses to its own comment.""" +def test_comment_get_abilities_user_commenter_own_comment(link_role, link_reach): + """Commenters have all accesses to its own comment.""" user = factories.UserFactory() document = factories.DocumentFactory( link_role=link_role, link_reach=link_reach, - users=[(user, RoleChoices.COMMENTATOR)], + users=[(user, RoleChoices.COMMENTER)], ) - comment = factories.CommentFactory(document=document, user=user) + comment = factories.CommentFactory(thread__document=document, user=user) assert comment.get_abilities(user) == { "destroy": True, "update": True, "partial_update": True, + "reactions": True, "retrieve": True, } @@ -185,13 +191,13 @@ def test_comment_get_abilities_user_commentator_own_comment(link_role, link_reac "link_role,link_reach", [ (LinkRoleChoices.READER, LinkReachChoices.PUBLIC), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC), (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC), (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED), (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED), (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED), (LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED), ], ) @@ -201,12 +207,13 @@ def test_comment_get_abilities_user_editor(link_role, link_reach): document = factories.DocumentFactory( link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.EDITOR)] ) - comment = factories.CommentFactory(document=document) + comment = factories.CommentFactory(thread__document=document) assert comment.get_abilities(user) == { "destroy": False, "update": False, "partial_update": False, + "reactions": True, "retrieve": True, } @@ -215,13 +222,13 @@ def test_comment_get_abilities_user_editor(link_role, link_reach): "link_role,link_reach", [ (LinkRoleChoices.READER, LinkReachChoices.PUBLIC), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC), (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC), (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED), (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED), (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED), - (LinkRoleChoices.COMMENTATOR, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED), (LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED), ], ) @@ -231,12 +238,13 @@ def test_comment_get_abilities_user_editor_own_comment(link_role, link_reach): document = factories.DocumentFactory( link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.EDITOR)] ) - comment = factories.CommentFactory(document=document, user=user) + comment = factories.CommentFactory(thread__document=document, user=user) assert comment.get_abilities(user) == { "destroy": True, "update": True, "partial_update": True, + "reactions": True, "retrieve": True, } @@ -246,13 +254,14 @@ def test_comment_get_abilities_user_admin(): user = factories.UserFactory() document = factories.DocumentFactory(users=[(user, RoleChoices.ADMIN)]) comment = factories.CommentFactory( - document=document, user=random.choice([user, None]) + thread__document=document, user=random.choice([user, None]) ) assert comment.get_abilities(user) == { "destroy": True, "update": True, "partial_update": True, + "reactions": True, "retrieve": True, } @@ -262,12 +271,13 @@ def test_comment_get_abilities_user_owner(): user = factories.UserFactory() document = factories.DocumentFactory(users=[(user, RoleChoices.OWNER)]) comment = factories.CommentFactory( - document=document, user=random.choice([user, None]) + thread__document=document, user=random.choice([user, None]) ) assert comment.get_abilities(user) == { "destroy": True, "update": True, "partial_update": True, + "reactions": True, "retrieve": True, } diff --git a/src/backend/core/tests/test_models_document_accesses.py b/src/backend/core/tests/test_models_document_accesses.py index eb7675c0..b8c3e93d 100644 --- a/src/backend/core/tests/test_models_document_accesses.py +++ b/src/backend/core/tests/test_models_document_accesses.py @@ -123,7 +123,7 @@ def test_models_document_access_get_abilities_for_owner_of_self_allowed(): "retrieve": True, "update": True, "partial_update": True, - "set_role_to": ["reader", "commentator", "editor", "administrator", "owner"], + "set_role_to": ["reader", "commenter", "editor", "administrator", "owner"], } @@ -166,7 +166,7 @@ def test_models_document_access_get_abilities_for_owner_of_self_last_on_child( "retrieve": True, "update": True, "partial_update": True, - "set_role_to": ["reader", "commentator", "editor", "administrator", "owner"], + "set_role_to": ["reader", "commenter", "editor", "administrator", "owner"], } @@ -183,7 +183,7 @@ def test_models_document_access_get_abilities_for_owner_of_owner(): "retrieve": True, "update": True, "partial_update": True, - "set_role_to": ["reader", "commentator", "editor", "administrator", "owner"], + "set_role_to": ["reader", "commenter", "editor", "administrator", "owner"], } @@ -200,7 +200,7 @@ def test_models_document_access_get_abilities_for_owner_of_administrator(): "retrieve": True, "update": True, "partial_update": True, - "set_role_to": ["reader", "commentator", "editor", "administrator", "owner"], + "set_role_to": ["reader", "commenter", "editor", "administrator", "owner"], } @@ -217,7 +217,7 @@ def test_models_document_access_get_abilities_for_owner_of_editor(): "retrieve": True, "update": True, "partial_update": True, - "set_role_to": ["reader", "commentator", "editor", "administrator", "owner"], + "set_role_to": ["reader", "commenter", "editor", "administrator", "owner"], } @@ -234,7 +234,7 @@ def test_models_document_access_get_abilities_for_owner_of_reader(): "retrieve": True, "update": True, "partial_update": True, - "set_role_to": ["reader", "commentator", "editor", "administrator", "owner"], + "set_role_to": ["reader", "commenter", "editor", "administrator", "owner"], } @@ -271,7 +271,7 @@ def test_models_document_access_get_abilities_for_administrator_of_administrator "retrieve": True, "update": True, "partial_update": True, - "set_role_to": ["reader", "commentator", "editor", "administrator"], + "set_role_to": ["reader", "commenter", "editor", "administrator"], } @@ -288,7 +288,7 @@ def test_models_document_access_get_abilities_for_administrator_of_editor(): "retrieve": True, "update": True, "partial_update": True, - "set_role_to": ["reader", "commentator", "editor", "administrator"], + "set_role_to": ["reader", "commenter", "editor", "administrator"], } @@ -305,7 +305,7 @@ def test_models_document_access_get_abilities_for_administrator_of_reader(): "retrieve": True, "update": True, "partial_update": True, - "set_role_to": ["reader", "commentator", "editor", "administrator"], + "set_role_to": ["reader", "commenter", "editor", "administrator"], } diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index 49fb3070..91b8abf9 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -134,13 +134,13 @@ def test_models_documents_soft_delete(depth): [ (True, "restricted", "reader"), (True, "restricted", "editor"), - (True, "restricted", "commentator"), + (True, "restricted", "commenter"), (False, "restricted", "reader"), (False, "restricted", "editor"), - (False, "restricted", "commentator"), + (False, "restricted", "commenter"), (False, "authenticated", "reader"), (False, "authenticated", "editor"), - (False, "authenticated", "commentator"), + (False, "authenticated", "commenter"), ], ) def test_models_documents_get_abilities_forbidden( @@ -176,8 +176,8 @@ def test_models_documents_get_abilities_forbidden( "move": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "commentator", "editor"], - "public": ["reader", "commentator", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "partial_update": False, @@ -237,8 +237,8 @@ def test_models_documents_get_abilities_reader( "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "commentator", "editor"], - "public": ["reader", "commentator", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": is_authenticated, @@ -278,14 +278,14 @@ def test_models_documents_get_abilities_reader( (True, "authenticated"), ], ) -def test_models_documents_get_abilities_commentator( +def test_models_documents_get_abilities_commenter( is_authenticated, reach, django_assert_num_queries ): """ - Check abilities returned for a document giving commentator role to link holders + Check abilities returned for a document giving commenter role to link holders i.e anonymous users or authenticated users who have no specific role on the document. """ - document = factories.DocumentFactory(link_reach=reach, link_role="commentator") + document = factories.DocumentFactory(link_reach=reach, link_role="commenter") user = factories.UserFactory() if is_authenticated else AnonymousUser() expected_abilities = { "accesses_manage": False, @@ -298,6 +298,7 @@ def test_models_documents_get_abilities_commentator( "children_list": True, "collaboration_auth": True, "comment": True, + "content": True, "descendants": True, "cors_proxy": True, "destroy": False, @@ -306,8 +307,8 @@ def test_models_documents_get_abilities_commentator( "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "commentator", "editor"], - "public": ["reader", "commentator", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": is_authenticated, @@ -373,8 +374,8 @@ def test_models_documents_get_abilities_editor( "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "commentator", "editor"], - "public": ["reader", "commentator", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": is_authenticated, @@ -429,8 +430,8 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries): "invite_owner": True, "link_configuration": True, "link_select_options": { - "authenticated": ["reader", "commentator", "editor"], - "public": ["reader", "commentator", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": True, @@ -461,6 +462,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries): "children_create": False, "children_list": False, "collaboration_auth": False, + "comment": False, "descendants": False, "cors_proxy": False, "content": False, @@ -470,8 +472,8 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries): "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "editor"], - "public": ["reader", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": False, @@ -516,8 +518,8 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries) "invite_owner": False, "link_configuration": True, "link_select_options": { - "authenticated": ["reader", "commentator", "editor"], - "public": ["reader", "commentator", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": True, @@ -572,8 +574,8 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries): "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "commentator", "editor"], - "public": ["reader", "commentator", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": True, @@ -626,7 +628,7 @@ def test_models_documents_get_abilities_reader_user( "children_list": True, "collaboration_auth": True, "comment": document.link_reach != "restricted" - and document.link_role in ["commentator", "editor"], + and document.link_role in ["commenter", "editor"], "descendants": True, "cors_proxy": True, "content": True, @@ -636,8 +638,8 @@ def test_models_documents_get_abilities_reader_user( "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "commentator", "editor"], - "public": ["reader", "commentator", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": True, @@ -668,12 +670,12 @@ def test_models_documents_get_abilities_reader_user( @pytest.mark.parametrize("ai_access_setting", ["public", "authenticated", "restricted"]) -def test_models_documents_get_abilities_commentator_user( +def test_models_documents_get_abilities_commenter_user( ai_access_setting, django_assert_num_queries ): - """Check abilities returned for the commentator of a document.""" + """Check abilities returned for the commenter of a document.""" user = factories.UserFactory() - document = factories.DocumentFactory(users=[(user, "commentator")]) + document = factories.DocumentFactory(users=[(user, "commenter")]) access_from_link = ( document.link_reach != "restricted" and document.link_role == "editor" @@ -692,6 +694,7 @@ def test_models_documents_get_abilities_commentator_user( "children_list": True, "collaboration_auth": True, "comment": True, + "content": True, "descendants": True, "cors_proxy": True, "destroy": False, @@ -700,8 +703,8 @@ def test_models_documents_get_abilities_commentator_user( "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "commentator", "editor"], - "public": ["reader", "commentator", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": True, @@ -761,8 +764,8 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries): "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "commentator", "editor"], - "public": ["reader", "commentator", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": True, @@ -1465,14 +1468,14 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): "public", "reader", { - "public": ["reader", "commentator", "editor"], + "public": ["reader", "commenter", "editor"], }, ), ( "public", - "commentator", + "commenter", { - "public": ["commentator", "editor"], + "public": ["commenter", "editor"], }, ), ("public", "editor", {"public": ["editor"]}), @@ -1480,16 +1483,16 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): "authenticated", "reader", { - "authenticated": ["reader", "commentator", "editor"], - "public": ["reader", "commentator", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], }, ), ( "authenticated", - "commentator", + "commenter", { - "authenticated": ["commentator", "editor"], - "public": ["commentator", "editor"], + "authenticated": ["commenter", "editor"], + "public": ["commenter", "editor"], }, ), ( @@ -1502,17 +1505,17 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): "reader", { "restricted": None, - "authenticated": ["reader", "commentator", "editor"], - "public": ["reader", "commentator", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], }, ), ( "restricted", - "commentator", + "commenter", { "restricted": None, - "authenticated": ["commentator", "editor"], - "public": ["commentator", "editor"], + "authenticated": ["commenter", "editor"], + "public": ["commenter", "editor"], }, ), ( @@ -1529,15 +1532,15 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): "public", None, { - "public": ["reader", "commentator", "editor"], + "public": ["reader", "commenter", "editor"], }, ), ( None, "reader", { - "public": ["reader", "commentator", "editor"], - "authenticated": ["reader", "commentator", "editor"], + "public": ["reader", "commenter", "editor"], + "authenticated": ["reader", "commenter", "editor"], "restricted": None, }, ), @@ -1545,8 +1548,8 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): None, None, { - "public": ["reader", "commentator", "editor"], - "authenticated": ["reader", "commentator", "editor"], + "public": ["reader", "commenter", "editor"], + "authenticated": ["reader", "commenter", "editor"], "restricted": None, }, ), diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index 84e13789..a24ebc99 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -27,9 +27,9 @@ document_related_router.register( basename="invitations", ) document_related_router.register( - "comments", - viewsets.CommentViewSet, - basename="comments", + "threads", + viewsets.ThreadViewSet, + basename="threads", ) document_related_router.register( "ask-for-access", @@ -37,6 +37,13 @@ document_related_router.register( basename="ask_for_access", ) +thread_related_router = DefaultRouter() +thread_related_router.register( + "comments", + viewsets.CommentViewSet, + basename="comments", +) + urlpatterns = [ path( @@ -49,6 +56,10 @@ urlpatterns = [ r"^documents/(?P[0-9a-z-]*)/", include(document_related_router.urls), ), + re_path( + r"^documents/(?P[0-9a-z-]*)/threads/(?P[0-9a-z-]*)/", + include(thread_related_router.urls), + ), ] ), ),