From 604e5e0eb2f2e36fda3869ddf7ddc74cce2cd39d Mon Sep 17 00:00:00 2001 From: Manuel Raynaud Date: Tue, 26 Aug 2025 17:55:53 +0200 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8(backend)=20add=20commentator=20ro?= =?UTF-8?q?le?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit To allow a user to comment a document we added a new role: commentator. Commentator is higher than reader but lower than editor. --- src/backend/core/choices.py | 2 + src/backend/core/models.py | 9 +- .../documents/test_api_document_accesses.py | 39 ++-- .../documents/test_api_documents_retrieve.py | 15 +- .../documents/test_api_documents_trashbin.py | 7 +- .../tests/test_models_document_accesses.py | 18 +- .../core/tests/test_models_documents.py | 221 +++++++++++++++--- 7 files changed, 249 insertions(+), 62 deletions(-) diff --git a/src/backend/core/choices.py b/src/backend/core/choices.py index e6b975111a..8505ebab87 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/models.py b/src/backend/core/models.py index a1182964da..bacef75c6d 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) 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 eb6fa92b7c..02675e08bd 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_retrieve.py b/src/backend/core/tests/documents/test_api_documents_retrieve.py index 63fa8f0902..eb23584283 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 25bc1cda96..b3c5c35b6e 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_document_accesses.py b/src/backend/core/tests/test_models_document_accesses.py index 2fa88cf1fb..eb7675c00c 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 6874009c97..0bedd1605c 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, }, ), From a11a1911bc2c2499ecdf37d1f949211723c63939 Mon Sep 17 00:00:00 2001 From: Manuel Raynaud Date: Wed, 27 Aug 2025 16:38:42 +0200 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9C=A8(backend)=20add=20Comment=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In order to store the comments on a document, we created a new model Comment. User is nullable because anonymous users can comment a Document is this one is public with a link_role commentator. --- src/backend/core/factories.py | 11 + ...role_alter_documentaccess_role_and_more.py | 146 ++++++++++ src/backend/core/models.py | 42 +++ src/backend/core/tests/test_models_comment.py | 273 ++++++++++++++++++ 4 files changed, 472 insertions(+) create mode 100644 src/backend/core/migrations/0025_alter_document_link_role_alter_documentaccess_role_and_more.py create mode 100644 src/backend/core/tests/test_models_comment.py diff --git a/src/backend/core/factories.py b/src/backend/core/factories.py index 1b3715e749..24bdd317e9 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 0000000000..a34ad05b88 --- /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 bacef75c6d..bcf7d4a208 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1284,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/test_models_comment.py b/src/backend/core/tests/test_models_comment.py new file mode 100644 index 0000000000..dac0b36c22 --- /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, + } From f51246b39229064d3ab226a53de8f6ff6c969080 Mon Sep 17 00:00:00 2001 From: Manuel Raynaud Date: Thu, 28 Aug 2025 08:21:35 +0200 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9C=A8(backend)=20add=20comment=20viewse?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit add the CRUD part to manage comment lifeycle. Permissions are relying on the Document and Comment abilities. Comment viewset depends on the Document route and is added to the document_related_router. Dedicated serializer and permission are created. --- CHANGELOG.md | 4 + src/backend/core/api/permissions.py | 16 + src/backend/core/api/serializers.py | 44 ++ src/backend/core/api/viewsets.py | 33 + .../documents/test_api_documents_comments.py | 588 ++++++++++++++++++ src/backend/core/urls.py | 6 +- 6 files changed, 690 insertions(+), 1 deletion(-) create mode 100644 src/backend/core/tests/documents/test_api_documents_comments.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b7fc30cbb0..ab6b83a57d 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 09007847bf..29df311c82 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 83afc260d9..65d1c0828a 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 ee0c594eb1..1965313804 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/tests/documents/test_api_documents_comments.py b/src/backend/core/tests/documents/test_api_documents_comments.py new file mode 100644 index 0000000000..2a0cb7ced7 --- /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/urls.py b/src/backend/core/urls.py index 2ad8b00395..2df79fcc4a 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,