diff --git a/CHANGELOG.md b/CHANGELOG.md index b7fc30cbb..ab6b83a57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to ## [Unreleased] +### Added + +- ✨(backend) Comments on text editor #1309 + ### Changed - ⚡️(frontend) improve accessibility: diff --git a/src/backend/core/api/permissions.py b/src/backend/core/api/permissions.py index 09007847b..29df311c8 100644 --- a/src/backend/core/api/permissions.py +++ b/src/backend/core/api/permissions.py @@ -171,3 +171,19 @@ def has_object_permission(self, request, view, obj): action = view.action return abilities.get(action, False) + + +class CommentPermission(permissions.BasePermission): + """Permission class for comments.""" + + def has_permission(self, request, view): + """Check permission for a given object.""" + if view.action in ["create", "list"]: + document_abilities = view.get_document_or_404().get_abilities(request.user) + return document_abilities["comment"] + + return True + + def has_object_permission(self, request, view, obj): + """Check permission for a given object.""" + return obj.get_abilities(request.user).get(view.action, False) diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 83afc260d..65d1c0828 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -801,3 +801,47 @@ class MoveDocumentSerializer(serializers.Serializer): choices=enums.MoveNodePositionChoices.choices, default=enums.MoveNodePositionChoices.LAST_CHILD, ) + + +class CommentSerializer(serializers.ModelSerializer): + """Serialize comments.""" + + user = UserLightSerializer(read_only=True) + abilities = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = models.Comment + fields = [ + "id", + "content", + "created_at", + "updated_at", + "user", + "document", + "abilities", + ] + read_only_fields = [ + "id", + "created_at", + "updated_at", + "user", + "document", + "abilities", + ] + + def get_abilities(self, comment) -> dict: + """Return abilities of the logged-in user on the instance.""" + request = self.context.get("request") + if request: + return comment.get_abilities(request.user) + return {} + + def validate(self, attrs): + """Validate invitation data.""" + request = self.context.get("request") + user = getattr(request, "user", None) + + attrs["document_id"] = self.context["resource_id"] + attrs["user_id"] = user.id if user else None + + return attrs diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index ee0c594eb..196531380 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -2072,3 +2072,36 @@ def _load_theme_customization(self): ) return theme_customization + + +class CommentViewSet( + viewsets.ModelViewSet, +): + """API ViewSet for comments.""" + + permission_classes = [permissions.CommentPermission] + queryset = models.Comment.objects.select_related("user", "document").all() + serializer_class = serializers.CommentSerializer + pagination_class = Pagination + _document = None + + def get_document_or_404(self): + """Get the document related to the viewset or raise a 404 error.""" + if self._document is None: + try: + self._document = models.Document.objects.get( + pk=self.kwargs["resource_id"], + ) + except models.Document.DoesNotExist as e: + raise drf.exceptions.NotFound("Document not found.") from e + return self._document + + def get_serializer_context(self): + """Extra context provided to the serializer class.""" + context = super().get_serializer_context() + context["resource_id"] = self.kwargs["resource_id"] + return context + + def get_queryset(self): + """Return the queryset according to the action.""" + return super().get_queryset().filter(document=self.kwargs["resource_id"]) diff --git a/src/backend/core/choices.py b/src/backend/core/choices.py index e6b975111..8505ebab8 100644 --- a/src/backend/core/choices.py +++ b/src/backend/core/choices.py @@ -33,6 +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 EDITOR = "editor", _("Editor") # Can read and edit @@ -40,6 +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 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 1b3715e74..24bdd317e 100644 --- a/src/backend/core/factories.py +++ b/src/backend/core/factories.py @@ -256,3 +256,14 @@ class Meta: document = factory.SubFactory(DocumentFactory) role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices]) issuer = factory.SubFactory(UserFactory) + + +class CommentFactory(factory.django.DjangoModelFactory): + """A factory to create comments for a document""" + + class Meta: + model = models.Comment + + document = factory.SubFactory(DocumentFactory) + user = factory.SubFactory(UserFactory) + content = factory.Faker("text") 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 new file mode 100644 index 000000000..a34ad05b8 --- /dev/null +++ b/src/backend/core/migrations/0025_alter_document_link_role_alter_documentaccess_role_and_more.py @@ -0,0 +1,146 @@ +# Generated by Django 5.2.4 on 2025-08-26 08:11 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0024_add_is_masked_field_to_link_trace"), + ] + + operations = [ + migrations.AlterField( + model_name="document", + name="link_role", + field=models.CharField( + choices=[ + ("reader", "Reader"), + ("commentator", "Commentator"), + ("editor", "Editor"), + ], + default="reader", + max_length=20, + ), + ), + migrations.AlterField( + model_name="documentaccess", + name="role", + field=models.CharField( + choices=[ + ("reader", "Reader"), + ("commentator", "Commentator"), + ("editor", "Editor"), + ("administrator", "Administrator"), + ("owner", "Owner"), + ], + default="reader", + max_length=20, + ), + ), + migrations.AlterField( + model_name="documentaskforaccess", + name="role", + field=models.CharField( + choices=[ + ("reader", "Reader"), + ("commentator", "Commentator"), + ("editor", "Editor"), + ("administrator", "Administrator"), + ("owner", "Owner"), + ], + default="reader", + max_length=20, + ), + ), + migrations.AlterField( + model_name="invitation", + name="role", + field=models.CharField( + choices=[ + ("reader", "Reader"), + ("commentator", "Commentator"), + ("editor", "Editor"), + ("administrator", "Administrator"), + ("owner", "Owner"), + ], + default="reader", + max_length=20, + ), + ), + migrations.AlterField( + model_name="templateaccess", + name="role", + field=models.CharField( + choices=[ + ("reader", "Reader"), + ("commentator", "Commentator"), + ("editor", "Editor"), + ("administrator", "Administrator"), + ("owner", "Owner"), + ], + default="reader", + max_length=20, + ), + ), + migrations.CreateModel( + name="Comment", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + help_text="primary key for the record as UUID", + primary_key=True, + serialize=False, + verbose_name="id", + ), + ), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, + help_text="date and time at which a record was created", + verbose_name="created on", + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, + help_text="date and time at which a record was last updated", + verbose_name="updated on", + ), + ), + ("content", models.TextField()), + ( + "document", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="comments", + to="core.document", + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="comments", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Comment", + "verbose_name_plural": "Comments", + "db_table": "impress_comment", + "ordering": ("-created_at",), + }, + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index a1182964d..bcf7d4a20 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -762,6 +762,7 @@ def get_abilities(self, user): can_update = ( is_owner_or_admin or role == RoleChoices.EDITOR ) and not is_deleted + can_comment = (can_update or role == RoleChoices.COMMENTATOR) and not is_deleted ai_allow_reach_from = settings.AI_ALLOW_REACH_FROM ai_access = any( @@ -786,6 +787,7 @@ def get_abilities(self, user): "children_list": can_get, "children_create": can_update and user.is_authenticated, "collaboration_auth": can_get, + "comment": can_comment, "cors_proxy": can_get, "descendants": can_get, "destroy": is_owner, @@ -1145,7 +1147,12 @@ def get_abilities(self, user): set_role_to = [] if is_owner_or_admin: set_role_to.extend( - [RoleChoices.READER, RoleChoices.EDITOR, RoleChoices.ADMIN] + [ + RoleChoices.READER, + RoleChoices.COMMENTATOR, + RoleChoices.EDITOR, + RoleChoices.ADMIN, + ] ) if role == RoleChoices.OWNER: set_role_to.append(RoleChoices.OWNER) @@ -1277,6 +1284,48 @@ def send_ask_for_access_email(self, email, language=None): self.document.send_email(subject, [email], context, language) +class Comment(BaseModel): + """User comment on a document.""" + + document = models.ForeignKey( + Document, + on_delete=models.CASCADE, + related_name="comments", + ) + user = models.ForeignKey( + User, + on_delete=models.SET_NULL, + related_name="comments", + null=True, + blank=True, + ) + content = models.TextField() + + class Meta: + db_table = "impress_comment" + ordering = ("-created_at",) + verbose_name = _("Comment") + verbose_name_plural = _("Comments") + + def __str__(self): + author = self.user or _("Anonymous") + return f"{author!s} on {self.document!s}" + + def get_abilities(self, user): + """Compute and return abilities for a given user.""" + role = self.document.get_role(user) + can_comment = self.document.get_abilities(user)["comment"] + return { + "destroy": self.user == user + or role in [RoleChoices.OWNER, RoleChoices.ADMIN], + "update": self.user == user + or role in [RoleChoices.OWNER, RoleChoices.ADMIN], + "partial_update": self.user == user + or role in [RoleChoices.OWNER, RoleChoices.ADMIN], + "retrieve": can_comment, + } + + class Template(BaseModel): """HTML and CSS code used for formatting the print around the MarkDown body.""" 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 eb6fa92b7..02675e08b 100644 --- a/src/backend/core/tests/documents/test_api_document_accesses.py +++ b/src/backend/core/tests/documents/test_api_document_accesses.py @@ -292,6 +292,7 @@ def test_api_document_accesses_retrieve_set_role_to_child(): } assert result_dict[str(document_access_other_user.id)] == [ "reader", + "commentator", "editor", "administrator", "owner", @@ -300,7 +301,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="editor" + document=parent, user=other_user, role="commentator" ) response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/") @@ -313,6 +314,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", "editor", "administrator", "owner", @@ -320,6 +322,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", "editor", "administrator", "owner", @@ -332,28 +335,28 @@ def test_api_document_accesses_retrieve_set_role_to_child(): [ ["administrator", "reader", "reader", "reader"], [ - ["reader", "editor", "administrator"], + ["reader", "commentator", "editor", "administrator"], [], [], - ["reader", "editor", "administrator"], + ["reader", "commentator", "editor", "administrator"], ], ], [ ["owner", "reader", "reader", "reader"], [ - ["reader", "editor", "administrator", "owner"], + ["reader", "commentator", "editor", "administrator", "owner"], [], [], - ["reader", "editor", "administrator", "owner"], + ["reader", "commentator", "editor", "administrator", "owner"], ], ], [ ["owner", "reader", "reader", "owner"], [ - ["reader", "editor", "administrator", "owner"], + ["reader", "commentator", "editor", "administrator", "owner"], [], [], - ["reader", "editor", "administrator", "owner"], + ["reader", "commentator", "editor", "administrator", "owner"], ], ], ], @@ -414,44 +417,44 @@ def test_api_document_accesses_list_authenticated_related_same_user(roles, resul [ ["administrator", "reader", "reader", "reader"], [ - ["reader", "editor", "administrator"], + ["reader", "commentator", "editor", "administrator"], [], [], - ["reader", "editor", "administrator"], + ["reader", "commentator", "editor", "administrator"], ], ], [ ["owner", "reader", "reader", "reader"], [ - ["reader", "editor", "administrator", "owner"], + ["reader", "commentator", "editor", "administrator", "owner"], [], [], - ["reader", "editor", "administrator", "owner"], + ["reader", "commentator", "editor", "administrator", "owner"], ], ], [ ["owner", "reader", "reader", "owner"], [ - ["reader", "editor", "administrator", "owner"], + ["reader", "commentator", "editor", "administrator", "owner"], [], [], - ["reader", "editor", "administrator", "owner"], + ["reader", "commentator", "editor", "administrator", "owner"], ], ], [ ["reader", "reader", "reader", "owner"], [ - ["reader", "editor", "administrator", "owner"], + ["reader", "commentator", "editor", "administrator", "owner"], [], [], - ["reader", "editor", "administrator", "owner"], + ["reader", "commentator", "editor", "administrator", "owner"], ], ], [ ["reader", "administrator", "reader", "editor"], [ - ["reader", "editor", "administrator"], - ["reader", "editor", "administrator"], + ["reader", "commentator", "editor", "administrator"], + ["reader", "commentator", "editor", "administrator"], [], [], ], @@ -459,7 +462,7 @@ def test_api_document_accesses_list_authenticated_related_same_user(roles, resul [ ["editor", "editor", "administrator", "editor"], [ - ["reader", "editor", "administrator"], + ["reader", "commentator", "editor", "administrator"], [], ["editor", "administrator"], [], diff --git a/src/backend/core/tests/documents/test_api_documents_comments.py b/src/backend/core/tests/documents/test_api_documents_comments.py new file mode 100644 index 000000000..2a0cb7ced --- /dev/null +++ b/src/backend/core/tests/documents/test_api_documents_comments.py @@ -0,0 +1,588 @@ +"""Test API for comments on documents.""" + +import random + +from django.contrib.auth.models import AnonymousUser + +import pytest +from rest_framework.test import APIClient + +from core import factories, models + +pytestmark = pytest.mark.django_db + +# List comments + + +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 + ) + comment1, comment2 = factories.CommentFactory.create_batch(2, document=document) + # other comments not linked to the document + factories.CommentFactory.create_batch(2) + + response = APIClient().get(f"/api/v1.0/documents/{document.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, + "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()), + }, + ], + } + + +@pytest.mark.parametrize("link_reach", ["restricted", "authenticated"]) +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 + ) + factories.CommentFactory(document=document) + # other comments not linked to the document + factories.CommentFactory.create_batch(2) + + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/comments/") + assert response.status_code == 401 + + +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)] + ) + comment1 = factories.CommentFactory(document=document) + comment2 = factories.CommentFactory(document=document, 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/") + 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, + "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), + }, + ], + } + + +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) + # 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/") + assert response.status_code == 403 + + +def test_list_comments_authenticated_user_not_enough_access(): + """ + Authenticated users should not be allowed to list comments on a document they don't have + comment access to. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)] + ) + factories.CommentFactory(document=document) + # 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/") + assert response.status_code == 403 + + +# Create comment + + +def test_create_comment_anonymous_user_public_document(): + """Anonymous users should not be allowed to create comments on a public document.""" + document = factories.DocumentFactory( + link_reach="public", link_role=models.LinkRoleChoices.COMMENTATOR + ) + client = APIClient() + response = client.post( + f"/api/v1.0/documents/{document.id!s}/comments/", {"content": "test"} + ) + + assert response.status_code == 201 + + assert response.json() == { + "id": str(response.json()["id"]), + "content": "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, + "retrieve": True, + }, + } + + +def test_create_comment_anonymous_user_non_accessible_document(): + """Anonymous users should not be allowed to create comments on a non-accessible document.""" + document = factories.DocumentFactory( + link_reach="public", link_role=models.LinkRoleChoices.READER + ) + client = APIClient() + response = client.post( + f"/api/v1.0/documents/{document.id!s}/comments/", {"content": "test"} + ) + + assert response.status_code == 401 + + +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)] + ) + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/comments/", {"content": "test"} + ) + assert response.status_code == 201 + + assert response.json() == { + "id": str(response.json()["id"]), + "content": "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, + "retrieve": True, + }, + } + + +def test_create_comment_authenticated_user_not_enough_access(): + """ + Authenticated users should not be allowed to create comments on a document they don't have + comment access to. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)] + ) + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/comments/", {"content": "test"} + ) + assert response.status_code == 403 + + +# Retrieve comment + + +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 + ) + comment = factories.CommentFactory(document=document) + client = APIClient() + response = client.get( + f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/" + ) + assert response.status_code == 200 + assert response.json() == { + "id": str(comment.id), + "content": comment.content, + "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), + "abilities": comment.get_abilities(AnonymousUser()), + } + + +def test_retrieve_comment_anonymous_user_non_accessible_document(): + """Anonymous users should not be allowed to retrieve comments on a non-accessible document.""" + document = factories.DocumentFactory( + link_reach="public", link_role=models.LinkRoleChoices.READER + ) + comment = factories.CommentFactory(document=document) + client = APIClient() + response = client.get( + f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/" + ) + assert response.status_code == 401 + + +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)] + ) + comment = factories.CommentFactory(document=document) + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/" + ) + assert response.status_code == 200 + + +def test_retrieve_comment_authenticated_user_not_enough_access(): + """ + Authenticated users should not be allowed to retrieve comments on a document they don't have + comment access to. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)] + ) + comment = factories.CommentFactory(document=document) + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/" + ) + assert response.status_code == 403 + + +# Update comment + + +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 + ) + comment = factories.CommentFactory(document=document, content="test") + client = APIClient() + response = client.put( + f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/", + {"content": "other content"}, + ) + assert response.status_code == 401 + + +def test_update_comment_anonymous_user_non_accessible_document(): + """Anonymous users should not be allowed to update comments on a non-accessible document.""" + document = factories.DocumentFactory( + link_reach="public", link_role=models.LinkRoleChoices.READER + ) + comment = factories.CommentFactory(document=document, content="test") + client = APIClient() + response = client.put( + f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/", + {"content": "other content"}, + ) + assert response.status_code == 401 + + +def test_update_comment_authenticated_user_accessible_document(): + """Authenticated users should not be able to update comments not their own.""" + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + users=[ + ( + user, + random.choice( + [models.LinkRoleChoices.COMMENTATOR, models.LinkRoleChoices.EDITOR] + ), + ) + ], + ) + comment = factories.CommentFactory(document=document, content="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"}, + ) + assert response.status_code == 403 + + +def test_update_comment_authenticated_user_own_comment(): + """Authenticated users should be able to update comments not their own.""" + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + users=[ + ( + user, + random.choice( + [models.LinkRoleChoices.COMMENTATOR, models.LinkRoleChoices.EDITOR] + ), + ) + ], + ) + comment = factories.CommentFactory(document=document, content="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"}, + ) + assert response.status_code == 200 + + comment.refresh_from_db() + assert comment.content == "other content" + + +def test_update_comment_authenticated_user_not_enough_access(): + """ + Authenticated users should not be allowed to update comments on a document they don't + have comment access to. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)] + ) + comment = factories.CommentFactory(document=document, content="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"}, + ) + assert response.status_code == 403 + + +def test_update_comment_authenticated_no_access(): + """ + Authenticated users should not be allowed to update comments on a document they don't + have access to. + """ + user = factories.UserFactory() + document = factories.DocumentFactory(link_reach="restricted") + comment = factories.CommentFactory(document=document, content="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"}, + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER]) +def test_update_comment_authenticated_admin_or_owner_can_update_any_comment(role): + """ + Authenticated users should be able to update comments on a document they don't have access to. + """ + user = factories.UserFactory() + document = factories.DocumentFactory(users=[(user, role)]) + comment = factories.CommentFactory(document=document, content="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"}, + ) + assert response.status_code == 200 + + comment.refresh_from_db() + assert comment.content == "other content" + + +@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER]) +def test_update_comment_authenticated_admin_or_owner_can_update_own_comment(role): + """ + Authenticated users should be able to update comments on a document they don't have access to. + """ + user = factories.UserFactory() + document = factories.DocumentFactory(users=[(user, role)]) + comment = factories.CommentFactory(document=document, content="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"}, + ) + assert response.status_code == 200 + + comment.refresh_from_db() + assert comment.content == "other content" + + +# Delete comment + + +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 + ) + comment = factories.CommentFactory(document=document) + client = APIClient() + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/" + ) + assert response.status_code == 401 + + +def test_delete_comment_anonymous_user_non_accessible_document(): + """Anonymous users should not be allowed to delete comments on a non-accessible document.""" + document = factories.DocumentFactory( + link_reach="public", link_role=models.LinkRoleChoices.READER + ) + comment = factories.CommentFactory(document=document) + client = APIClient() + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/" + ) + assert response.status_code == 401 + + +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)] + ) + comment = factories.CommentFactory(document=document, user=user) + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/" + ) + assert response.status_code == 204 + + +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)] + ) + comment = factories.CommentFactory(document=document) + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/" + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER]) +def test_delete_comment_authenticated_user_admin_or_owner_can_delete_any_comment(role): + """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) + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/" + ) + assert response.status_code == 204 + + +@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER]) +def test_delete_comment_authenticated_user_admin_or_owner_can_delete_own_comment(role): + """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) + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/" + ) + assert response.status_code == 204 + + +def test_delete_comment_authenticated_user_not_enough_access(): + """ + Authenticated users should not be able to delete comments on a document they don't + have access to. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)] + ) + comment = factories.CommentFactory(document=document) + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/" + ) + assert response.status_code == 403 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 63fa8f090..eb2358428 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve.py +++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -36,6 +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"], "cors_proxy": True, "descendants": True, "destroy": False, @@ -45,8 +46,8 @@ def test_api_documents_retrieve_anonymous_public_standalone(): "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "editor"], - "public": ["reader", "editor"], + "authenticated": ["reader", "commentator", "editor"], + "public": ["reader", "commentator", "editor"], "restricted": None, }, "mask": False, @@ -111,6 +112,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"], "descendants": True, "cors_proxy": True, "destroy": False, @@ -216,6 +218,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"], "descendants": True, "cors_proxy": True, "destroy": False, @@ -224,8 +227,8 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated( "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "editor"], - "public": ["reader", "editor"], + "authenticated": ["reader", "commentator", "editor"], + "public": ["reader", "commentator", "editor"], "restricted": None, }, "mask": True, @@ -298,6 +301,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"], "descendants": True, "cors_proxy": True, "destroy": False, @@ -488,10 +492,11 @@ def test_api_documents_retrieve_authenticated_related_parent(): "ai_transform": access.role != "reader", "ai_translate": access.role != "reader", "attachment_upload": access.role != "reader", - "can_edit": access.role != "reader", + "can_edit": access.role not in ["reader", "commentator"], "children_create": access.role != "reader", "children_list": True, "collaboration_auth": True, + "comment": access.role != "reader", "descendants": True, "cors_proxy": True, "destroy": access.role == "owner", 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 25bc1cda9..b3c5c35b6 100644 --- a/src/backend/core/tests/documents/test_api_documents_trashbin.py +++ b/src/backend/core/tests/documents/test_api_documents_trashbin.py @@ -79,16 +79,17 @@ def test_api_documents_trashbin_format(): "children_create": True, "children_list": True, "collaboration_auth": True, - "descendants": True, + "comment": True, "cors_proxy": True, + "descendants": True, "destroy": True, "duplicate": True, "favorite": True, "invite_owner": True, "link_configuration": True, "link_select_options": { - "authenticated": ["reader", "editor"], - "public": ["reader", "editor"], + "authenticated": ["reader", "commentator", "editor"], + "public": ["reader", "commentator", "editor"], "restricted": None, }, "mask": True, diff --git a/src/backend/core/tests/test_models_comment.py b/src/backend/core/tests/test_models_comment.py new file mode 100644 index 000000000..dac0b36c2 --- /dev/null +++ b/src/backend/core/tests/test_models_comment.py @@ -0,0 +1,273 @@ +"""Test the comment model.""" + +import random + +from django.contrib.auth.models import AnonymousUser + +import pytest + +from core import factories +from core.models import LinkReachChoices, LinkRoleChoices, RoleChoices + +pytestmark = pytest.mark.django_db + + +@pytest.mark.parametrize( + "role,can_comment", + [ + (LinkRoleChoices.READER, False), + (LinkRoleChoices.COMMENTATOR, True), + (LinkRoleChoices.EDITOR, True), + ], +) +def test_comment_get_abilities_anonymous_user_public_document(role, can_comment): + """Anonymous users cannot comment on a document.""" + document = factories.DocumentFactory( + link_role=role, link_reach=LinkReachChoices.PUBLIC + ) + comment = factories.CommentFactory(document=document) + user = AnonymousUser() + + assert comment.get_abilities(user) == { + "destroy": False, + "update": False, + "partial_update": False, + "retrieve": can_comment, + } + + +@pytest.mark.parametrize( + "link_reach", [LinkReachChoices.RESTRICTED, LinkReachChoices.AUTHENTICATED] +) +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) + user = AnonymousUser() + + assert comment.get_abilities(user) == { + "destroy": False, + "update": False, + "partial_update": False, + "retrieve": False, + } + + +@pytest.mark.parametrize( + "link_role,link_reach,can_comment", + [ + (LinkRoleChoices.READER, LinkReachChoices.PUBLIC, False), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC, True), + (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC, True), + (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED, False), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED, False), + (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED, False), + (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED, False), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.AUTHENTICATED, True), + (LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED, True), + ], +) +def test_comment_get_abilities_user_reader(link_role, link_reach, can_comment): + """Readers cannot comment on a document.""" + user = factories.UserFactory() + document = factories.DocumentFactory( + link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.READER)] + ) + comment = factories.CommentFactory(document=document) + + assert comment.get_abilities(user) == { + "destroy": False, + "update": False, + "partial_update": False, + "retrieve": can_comment, + } + + +@pytest.mark.parametrize( + "link_role,link_reach,can_comment", + [ + (LinkRoleChoices.READER, LinkReachChoices.PUBLIC, False), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC, True), + (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC, True), + (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED, False), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED, False), + (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED, False), + (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED, False), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.AUTHENTICATED, True), + (LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED, True), + ], +) +def test_comment_get_abilities_user_reader_own_comment( + link_role, link_reach, can_comment +): + """User with reader role on a document has all accesses to its own comment.""" + user = factories.UserFactory() + document = factories.DocumentFactory( + link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.READER)] + ) + comment = factories.CommentFactory( + document=document, user=user if can_comment else None + ) + + assert comment.get_abilities(user) == { + "destroy": can_comment, + "update": can_comment, + "partial_update": can_comment, + "retrieve": can_comment, + } + + +@pytest.mark.parametrize( + "link_role,link_reach", + [ + (LinkRoleChoices.READER, LinkReachChoices.PUBLIC), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED), + ], +) +def test_comment_get_abilities_user_commentator(link_role, link_reach): + """Commentators can comment on a document.""" + user = factories.UserFactory() + document = factories.DocumentFactory( + link_role=link_role, + link_reach=link_reach, + users=[(user, RoleChoices.COMMENTATOR)], + ) + comment = factories.CommentFactory(document=document) + + assert comment.get_abilities(user) == { + "destroy": False, + "update": False, + "partial_update": False, + "retrieve": True, + } + + +@pytest.mark.parametrize( + "link_role,link_reach", + [ + (LinkRoleChoices.READER, LinkReachChoices.PUBLIC), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.COMMENTATOR, 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.""" + user = factories.UserFactory() + document = factories.DocumentFactory( + link_role=link_role, + link_reach=link_reach, + users=[(user, RoleChoices.COMMENTATOR)], + ) + comment = factories.CommentFactory(document=document, user=user) + + assert comment.get_abilities(user) == { + "destroy": True, + "update": True, + "partial_update": True, + "retrieve": True, + } + + +@pytest.mark.parametrize( + "link_role,link_reach", + [ + (LinkRoleChoices.READER, LinkReachChoices.PUBLIC), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED), + ], +) +def test_comment_get_abilities_user_editor(link_role, link_reach): + """Editors can comment on a document.""" + user = factories.UserFactory() + document = factories.DocumentFactory( + link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.EDITOR)] + ) + comment = factories.CommentFactory(document=document) + + assert comment.get_abilities(user) == { + "destroy": False, + "update": False, + "partial_update": False, + "retrieve": True, + } + + +@pytest.mark.parametrize( + "link_role,link_reach", + [ + (LinkRoleChoices.READER, LinkReachChoices.PUBLIC), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED), + ], +) +def test_comment_get_abilities_user_editor_own_comment(link_role, link_reach): + """Editors have all accesses to its own comment.""" + user = factories.UserFactory() + document = factories.DocumentFactory( + link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.EDITOR)] + ) + comment = factories.CommentFactory(document=document, user=user) + + assert comment.get_abilities(user) == { + "destroy": True, + "update": True, + "partial_update": True, + "retrieve": True, + } + + +def test_comment_get_abilities_user_admin(): + """Admins have all accesses to a comment.""" + user = factories.UserFactory() + document = factories.DocumentFactory(users=[(user, RoleChoices.ADMIN)]) + comment = factories.CommentFactory( + document=document, user=random.choice([user, None]) + ) + + assert comment.get_abilities(user) == { + "destroy": True, + "update": True, + "partial_update": True, + "retrieve": True, + } + + +def test_comment_get_abilities_user_owner(): + """Owners have all accesses to a comment.""" + user = factories.UserFactory() + document = factories.DocumentFactory(users=[(user, RoleChoices.OWNER)]) + comment = factories.CommentFactory( + document=document, user=random.choice([user, None]) + ) + + assert comment.get_abilities(user) == { + "destroy": True, + "update": True, + "partial_update": 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 2fa88cf1f..eb7675c00 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", "editor", "administrator", "owner"], + "set_role_to": ["reader", "commentator", "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", "editor", "administrator", "owner"], + "set_role_to": ["reader", "commentator", "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", "editor", "administrator", "owner"], + "set_role_to": ["reader", "commentator", "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", "editor", "administrator", "owner"], + "set_role_to": ["reader", "commentator", "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", "editor", "administrator", "owner"], + "set_role_to": ["reader", "commentator", "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", "editor", "administrator", "owner"], + "set_role_to": ["reader", "commentator", "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", "editor", "administrator"], + "set_role_to": ["reader", "commentator", "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", "editor", "administrator"], + "set_role_to": ["reader", "commentator", "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", "editor", "administrator"], + "set_role_to": ["reader", "commentator", "editor", "administrator"], } diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index 6874009c9..0bedd1605 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -134,10 +134,13 @@ def test_models_documents_soft_delete(depth): [ (True, "restricted", "reader"), (True, "restricted", "editor"), + (True, "restricted", "commentator"), (False, "restricted", "reader"), (False, "restricted", "editor"), + (False, "restricted", "commentator"), (False, "authenticated", "reader"), (False, "authenticated", "editor"), + (False, "authenticated", "commentator"), ], ) def test_models_documents_get_abilities_forbidden( @@ -164,6 +167,7 @@ def test_models_documents_get_abilities_forbidden( "destroy": False, "duplicate": False, "favorite": False, + "comment": False, "invite_owner": False, "mask": False, "media_auth": False, @@ -171,8 +175,8 @@ def test_models_documents_get_abilities_forbidden( "move": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "editor"], - "public": ["reader", "editor"], + "authenticated": ["reader", "commentator", "editor"], + "public": ["reader", "commentator", "editor"], "restricted": None, }, "partial_update": False, @@ -222,6 +226,7 @@ def test_models_documents_get_abilities_reader( "children_create": False, "children_list": True, "collaboration_auth": True, + "comment": False, "descendants": True, "cors_proxy": True, "destroy": False, @@ -230,8 +235,77 @@ def test_models_documents_get_abilities_reader( "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "editor"], - "public": ["reader", "editor"], + "authenticated": ["reader", "commentator", "editor"], + "public": ["reader", "commentator", "editor"], + "restricted": None, + }, + "mask": is_authenticated, + "media_auth": True, + "media_check": True, + "move": False, + "partial_update": False, + "restore": False, + "retrieve": True, + "tree": True, + "update": False, + "versions_destroy": False, + "versions_list": False, + "versions_retrieve": False, + } + nb_queries = 1 if is_authenticated else 0 + with django_assert_num_queries(nb_queries): + assert document.get_abilities(user) == expected_abilities + + document.soft_delete() + document.refresh_from_db() + assert all( + value is False + for key, value in document.get_abilities(user).items() + if key not in ["link_select_options", "ancestors_links_definition"] + ) + + +@override_settings( + AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"]) +) +@pytest.mark.parametrize( + "is_authenticated,reach", + [ + (True, "public"), + (False, "public"), + (True, "authenticated"), + ], +) +def test_models_documents_get_abilities_commentator( + is_authenticated, reach, django_assert_num_queries +): + """ + Check abilities returned for a document giving commentator 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") + user = factories.UserFactory() if is_authenticated else AnonymousUser() + expected_abilities = { + "accesses_manage": False, + "accesses_view": False, + "ai_transform": False, + "ai_translate": False, + "attachment_upload": False, + "can_edit": False, + "children_create": False, + "children_list": True, + "collaboration_auth": True, + "comment": True, + "descendants": True, + "cors_proxy": True, + "destroy": False, + "duplicate": is_authenticated, + "favorite": is_authenticated, + "invite_owner": False, + "link_configuration": False, + "link_select_options": { + "authenticated": ["reader", "commentator", "editor"], + "public": ["reader", "commentator", "editor"], "restricted": None, }, "mask": is_authenticated, @@ -287,6 +361,7 @@ def test_models_documents_get_abilities_editor( "children_create": is_authenticated, "children_list": True, "collaboration_auth": True, + "comment": True, "descendants": True, "cors_proxy": True, "destroy": False, @@ -295,8 +370,8 @@ def test_models_documents_get_abilities_editor( "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "editor"], - "public": ["reader", "editor"], + "authenticated": ["reader", "commentator", "editor"], + "public": ["reader", "commentator", "editor"], "restricted": None, }, "mask": is_authenticated, @@ -341,6 +416,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries): "children_create": True, "children_list": True, "collaboration_auth": True, + "comment": True, "descendants": True, "cors_proxy": True, "destroy": True, @@ -349,8 +425,8 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries): "invite_owner": True, "link_configuration": True, "link_select_options": { - "authenticated": ["reader", "editor"], - "public": ["reader", "editor"], + "authenticated": ["reader", "commentator", "editor"], + "public": ["reader", "commentator", "editor"], "restricted": None, }, "mask": True, @@ -392,6 +468,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries) "children_create": True, "children_list": True, "collaboration_auth": True, + "comment": True, "descendants": True, "cors_proxy": True, "destroy": False, @@ -400,8 +477,8 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries) "invite_owner": False, "link_configuration": True, "link_select_options": { - "authenticated": ["reader", "editor"], - "public": ["reader", "editor"], + "authenticated": ["reader", "commentator", "editor"], + "public": ["reader", "commentator", "editor"], "restricted": None, }, "mask": True, @@ -446,6 +523,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries): "children_create": True, "children_list": True, "collaboration_auth": True, + "comment": True, "descendants": True, "cors_proxy": True, "destroy": False, @@ -454,8 +532,8 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries): "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "editor"], - "public": ["reader", "editor"], + "authenticated": ["reader", "commentator", "editor"], + "public": ["reader", "commentator", "editor"], "restricted": None, }, "mask": True, @@ -507,6 +585,8 @@ def test_models_documents_get_abilities_reader_user( "children_create": access_from_link, "children_list": True, "collaboration_auth": True, + "comment": document.link_reach != "restricted" + and document.link_role in ["commentator", "editor"], "descendants": True, "cors_proxy": True, "destroy": False, @@ -515,8 +595,72 @@ def test_models_documents_get_abilities_reader_user( "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "editor"], - "public": ["reader", "editor"], + "authenticated": ["reader", "commentator", "editor"], + "public": ["reader", "commentator", "editor"], + "restricted": None, + }, + "mask": True, + "media_auth": True, + "media_check": True, + "move": False, + "partial_update": access_from_link, + "restore": False, + "retrieve": True, + "tree": True, + "update": access_from_link, + "versions_destroy": False, + "versions_list": True, + "versions_retrieve": True, + } + + with override_settings(AI_ALLOW_REACH_FROM=ai_access_setting): + with django_assert_num_queries(1): + assert document.get_abilities(user) == expected_abilities + + document.soft_delete() + document.refresh_from_db() + assert all( + value is False + for key, value in document.get_abilities(user).items() + if key not in ["link_select_options", "ancestors_links_definition"] + ) + + +@pytest.mark.parametrize("ai_access_setting", ["public", "authenticated", "restricted"]) +def test_models_documents_get_abilities_commentator_user( + ai_access_setting, django_assert_num_queries +): + """Check abilities returned for the commentator of a document.""" + user = factories.UserFactory() + document = factories.DocumentFactory(users=[(user, "commentator")]) + + access_from_link = ( + document.link_reach != "restricted" and document.link_role == "editor" + ) + + expected_abilities = { + "accesses_manage": False, + "accesses_view": True, + # If you get your editor rights from the link role and not your access role + # You should not access AI if it's restricted to users with specific access + "ai_transform": access_from_link and ai_access_setting != "restricted", + "ai_translate": access_from_link and ai_access_setting != "restricted", + "attachment_upload": access_from_link, + "can_edit": access_from_link, + "children_create": access_from_link, + "children_list": True, + "collaboration_auth": True, + "comment": True, + "descendants": True, + "cors_proxy": True, + "destroy": False, + "duplicate": True, + "favorite": True, + "invite_owner": False, + "link_configuration": False, + "link_select_options": { + "authenticated": ["reader", "commentator", "editor"], + "public": ["reader", "commentator", "editor"], "restricted": None, }, "mask": True, @@ -566,6 +710,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries): "children_create": False, "children_list": True, "collaboration_auth": True, + "comment": False, "descendants": True, "cors_proxy": True, "destroy": False, @@ -574,8 +719,8 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries): "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "editor"], - "public": ["reader", "editor"], + "authenticated": ["reader", "commentator", "editor"], + "public": ["reader", "commentator", "editor"], "restricted": None, }, "mask": True, @@ -1198,7 +1343,14 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): "public", "reader", { - "public": ["reader", "editor"], + "public": ["reader", "commentator", "editor"], + }, + ), + ( + "public", + "commentator", + { + "public": ["commentator", "editor"], }, ), ("public", "editor", {"public": ["editor"]}), @@ -1206,8 +1358,16 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): "authenticated", "reader", { - "authenticated": ["reader", "editor"], - "public": ["reader", "editor"], + "authenticated": ["reader", "commentator", "editor"], + "public": ["reader", "commentator", "editor"], + }, + ), + ( + "authenticated", + "commentator", + { + "authenticated": ["commentator", "editor"], + "public": ["commentator", "editor"], }, ), ( @@ -1220,8 +1380,17 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): "reader", { "restricted": None, - "authenticated": ["reader", "editor"], - "public": ["reader", "editor"], + "authenticated": ["reader", "commentator", "editor"], + "public": ["reader", "commentator", "editor"], + }, + ), + ( + "restricted", + "commentator", + { + "restricted": None, + "authenticated": ["commentator", "editor"], + "public": ["commentator", "editor"], }, ), ( @@ -1238,15 +1407,15 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): "public", None, { - "public": ["reader", "editor"], + "public": ["reader", "commentator", "editor"], }, ), ( None, "reader", { - "public": ["reader", "editor"], - "authenticated": ["reader", "editor"], + "public": ["reader", "commentator", "editor"], + "authenticated": ["reader", "commentator", "editor"], "restricted": None, }, ), @@ -1254,8 +1423,8 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): None, None, { - "public": ["reader", "editor"], - "authenticated": ["reader", "editor"], + "public": ["reader", "commentator", "editor"], + "authenticated": ["reader", "commentator", "editor"], "restricted": None, }, ), diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index 2ad8b0039..2df79fcc4 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -26,7 +26,11 @@ viewsets.InvitationViewset, basename="invitations", ) - +document_related_router.register( + "comments", + viewsets.CommentViewSet, + basename="comments", +) document_related_router.register( "ask-for-access", viewsets.DocumentAskForAccessViewSet,