diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index fb998be5..9dd76ed1 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -489,6 +489,26 @@ class DocumentViewSet( {"id": str(document.id)}, status=status.HTTP_201_CREATED ) + @drf.decorators.action( + detail=True, + methods=["get", "post"], + serializer_class=serializers.ListDocumentSerializer, + ) + # pylint: disable=unused-argument + def children(self, request, pk, *args, **kwargs): + """Custom action to retrieve children of a document""" + document = self.get_object() + queryset = document.get_children() + queryset = self.annotate_queryset(queryset) + + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True) + return drf.response.Response(serializer.data) + @drf.decorators.action(detail=True, methods=["get"], url_path="versions") def versions_list(self, request, *args, **kwargs): """ diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 2994b804..b39914f9 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -584,6 +584,7 @@ class Document(MP_Node, BaseModel): "ai_transform": can_update, "ai_translate": can_update, "attachment_upload": can_update, + "children": can_get, "collaboration_auth": can_get, "destroy": RoleChoices.OWNER in roles, "favorite": can_get and user.is_authenticated, diff --git a/src/backend/core/tests/documents/test_api_documents_children_list.py b/src/backend/core/tests/documents/test_api_documents_children_list.py new file mode 100644 index 00000000..1f25cf7b --- /dev/null +++ b/src/backend/core/tests/documents/test_api_documents_children_list.py @@ -0,0 +1,526 @@ +""" +Tests for Documents API endpoint in impress's core app: retrieve +""" + +import random + +from django.contrib.auth.models import AnonymousUser + +import pytest +from rest_framework.test import APIClient + +from core import factories + +pytestmark = pytest.mark.django_db + + +def test_api_documents_children_list_anonymous_public_standalone(): + """Anonymous users should be allowed to retrieve the children of a public documents.""" + document = factories.DocumentFactory(link_reach="public") + child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + factories.UserDocumentAccessFactory(document=child1) + + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/") + + assert response.status_code == 200 + assert response.json() == { + "count": 2, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(AnonymousUser()), + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "depth": 2, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 0, + "nb_accesses": 1, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + }, + { + "abilities": child2.get_abilities(AnonymousUser()), + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "depth": 2, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + }, + ], + } + + +def test_api_documents_children_list_anonymous_public_parent(): + """ + Anonymous users should be allowed to retrieve the children of a document who + has a public ancestor. + """ + grand_parent = factories.DocumentFactory(link_reach="public") + parent = factories.DocumentFactory( + parent=grand_parent, link_reach=random.choice(["authenticated", "restricted"]) + ) + document = factories.DocumentFactory( + link_reach=random.choice(["authenticated", "restricted"]), parent=parent + ) + child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + factories.UserDocumentAccessFactory(document=child1) + + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/") + + assert response.status_code == 200 + assert response.json() == { + "count": 2, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(AnonymousUser()), + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "depth": 4, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 0, + "nb_accesses": 1, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + }, + { + "abilities": child2.get_abilities(AnonymousUser()), + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "depth": 4, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + }, + ], + } + + +@pytest.mark.parametrize("reach", ["restricted", "authenticated"]) +def test_api_documents_children_list_anonymous_restricted_or_authenticated(reach): + """ + Anonymous users should not be able to retrieve children of a document that is not public. + """ + document = factories.DocumentFactory(link_reach=reach) + factories.DocumentFactory.create_batch(2, parent=document) + + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/") + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +@pytest.mark.parametrize("reach", ["public", "authenticated"]) +def test_api_documents_children_list_authenticated_unrelated_public_or_authenticated( + reach, +): + """ + Authenticated users should be able to retrieve the children of a public/authenticated + document to which they are not related. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach=reach) + child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + factories.UserDocumentAccessFactory(document=child1) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/children/", + ) + assert response.status_code == 200 + assert response.json() == { + "count": 2, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(user), + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "depth": 2, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 0, + "nb_accesses": 1, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + }, + { + "abilities": child2.get_abilities(user), + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "depth": 2, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + }, + ], + } + + +@pytest.mark.parametrize("reach", ["public", "authenticated"]) +def test_api_documents_children_list_authenticated_public_or_authenticated_parent( + reach, +): + """ + Authenticated users should be allowed to retrieve the children of a document who + has a public or authenticated ancestor. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + grand_parent = factories.DocumentFactory(link_reach=reach) + parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted") + document = factories.DocumentFactory(link_reach="restricted", parent=parent) + child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + factories.UserDocumentAccessFactory(document=child1) + + response = client.get(f"/api/v1.0/documents/{document.id!s}/children/") + + assert response.status_code == 200 + assert response.json() == { + "count": 2, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(user), + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "depth": 4, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 0, + "nb_accesses": 1, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + }, + { + "abilities": child2.get_abilities(user), + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "depth": 4, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + }, + ], + } + + +def test_api_documents_children_list_authenticated_unrelated_restricted(): + """ + Authenticated users should not be allowed to retrieve the children of a document that is + restricted and to which they are not related. + """ + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="restricted") + child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document) + factories.UserDocumentAccessFactory(document=child1) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/children/", + ) + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +def test_api_documents_children_list_authenticated_related_direct(): + """ + Authenticated users should be allowed to retrieve the children of a document + to which they are directly related whatever the role. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + factories.UserDocumentAccessFactory(document=document, user=user) + factories.UserDocumentAccessFactory(document=document) + + child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + factories.UserDocumentAccessFactory(document=child1) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/children/", + ) + assert response.status_code == 200 + assert response.json() == { + "count": 2, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(user), + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "depth": 2, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 0, + "nb_accesses": 3, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + }, + { + "abilities": child2.get_abilities(user), + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "depth": 2, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses": 2, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + }, + ], + } + + +def test_api_documents_children_list_authenticated_related_parent(): + """ + Authenticated users should be allowed to retrieve the children of a document if they + are related to one of its ancestors whatever the role. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + grand_parent = factories.DocumentFactory(link_reach="restricted") + parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted") + document = factories.DocumentFactory(parent=parent, link_reach="restricted") + + child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + factories.UserDocumentAccessFactory(document=child1) + + factories.UserDocumentAccessFactory(document=grand_parent, user=user) + factories.UserDocumentAccessFactory(document=grand_parent) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/children/", + ) + assert response.status_code == 200 + assert response.json() == { + "count": 2, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(user), + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "depth": 4, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 0, + "nb_accesses": 3, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + }, + { + "abilities": child2.get_abilities(user), + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "depth": 4, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses": 2, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + }, + ], + } + + +def test_api_documents_children_list_authenticated_related_child(): + """ + Authenticated users should not be allowed to retrieve all the children of a document + as a result of being related to one of its children. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="restricted") + child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document) + + factories.UserDocumentAccessFactory(document=child1, user=user) + factories.UserDocumentAccessFactory(document=document) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/children/", + ) + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +def test_api_documents_children_list_authenticated_related_team_none(mock_user_teams): + """ + Authenticated users should not be able to retrieve the children of a restricted document + related to teams in which the user is not. + """ + mock_user_teams.return_value = [] + + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="restricted") + factories.DocumentFactory.create_batch(2, parent=document) + + factories.TeamDocumentAccessFactory(document=document, team="myteam") + + response = client.get(f"/api/v1.0/documents/{document.id!s}/children/") + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +def test_api_documents_children_list_authenticated_related_team_members( + mock_user_teams, +): + """ + Authenticated users should be allowed to retrieve the children of a document to which they + are related via a team whatever the role. + """ + mock_user_teams.return_value = ["myteam"] + + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="restricted") + child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + + factories.TeamDocumentAccessFactory(document=document, team="myteam") + + response = client.get(f"/api/v1.0/documents/{document.id!s}/children/") + + # pylint: disable=R0801 + assert response.status_code == 200 + assert response.json() == { + "count": 2, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(user), + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "depth": 2, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 0, + "nb_accesses": 1, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + }, + { + "abilities": child2.get_abilities(user), + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "depth": 2, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses": 1, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + }, + ], + } 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 82200991..6d96432d 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve.py +++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -27,6 +27,7 @@ def test_api_documents_retrieve_anonymous_public_standalone(): "ai_transform": document.link_role == "editor", "ai_translate": document.link_role == "editor", "attachment_upload": document.link_role == "editor", + "children": True, "collaboration_auth": True, "destroy": False, # Anonymous user can't favorite a document even with read access @@ -77,6 +78,7 @@ def test_api_documents_retrieve_anonymous_public_parent(): "ai_transform": grand_parent.link_role == "editor", "ai_translate": grand_parent.link_role == "editor", "attachment_upload": grand_parent.link_role == "editor", + "children": True, "collaboration_auth": True, "destroy": False, # Anonymous user can't favorite a document even with read access @@ -161,6 +163,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated( "ai_transform": document.link_role == "editor", "ai_translate": document.link_role == "editor", "attachment_upload": document.link_role == "editor", + "children": True, "collaboration_auth": True, "destroy": False, "favorite": True, @@ -218,6 +221,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea "ai_transform": grand_parent.link_role == "editor", "ai_translate": grand_parent.link_role == "editor", "attachment_upload": grand_parent.link_role == "editor", + "children": True, "collaboration_auth": True, "destroy": False, "favorite": True, @@ -382,6 +386,7 @@ def test_api_documents_retrieve_authenticated_related_parent(): "ai_transform": access.role != "reader", "ai_translate": access.role != "reader", "attachment_upload": access.role != "reader", + "children": True, "collaboration_auth": True, "destroy": access.role == "owner", "favorite": True, diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index 598e5d3f..ae1443a0 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -109,6 +109,7 @@ def test_models_documents_get_abilities_forbidden(is_authenticated, reach, role) "ai_transform": False, "ai_translate": False, "attachment_upload": False, + "children": False, "collaboration_auth": False, "destroy": False, "favorite": False, @@ -146,6 +147,7 @@ def test_models_documents_get_abilities_reader(is_authenticated, reach): "ai_transform": False, "ai_translate": False, "attachment_upload": False, + "children": True, "collaboration_auth": True, "destroy": False, "favorite": is_authenticated, @@ -183,6 +185,7 @@ def test_models_documents_get_abilities_editor(is_authenticated, reach): "ai_transform": True, "ai_translate": True, "attachment_upload": True, + "children": True, "collaboration_auth": True, "destroy": False, "favorite": is_authenticated, @@ -209,6 +212,7 @@ def test_models_documents_get_abilities_owner(): "ai_transform": True, "ai_translate": True, "attachment_upload": True, + "children": True, "collaboration_auth": True, "destroy": True, "favorite": True, @@ -234,6 +238,7 @@ def test_models_documents_get_abilities_administrator(): "ai_transform": True, "ai_translate": True, "attachment_upload": True, + "children": True, "collaboration_auth": True, "destroy": False, "favorite": True, @@ -262,6 +267,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries): "ai_transform": True, "ai_translate": True, "attachment_upload": True, + "children": True, "collaboration_auth": True, "destroy": False, "favorite": True, @@ -292,6 +298,7 @@ def test_models_documents_get_abilities_reader_user(django_assert_num_queries): "ai_transform": False, "ai_translate": False, "attachment_upload": False, + "children": True, "collaboration_auth": True, "destroy": False, "favorite": True, @@ -323,6 +330,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries): "ai_transform": False, "ai_translate": False, "attachment_upload": False, + "children": True, "collaboration_auth": True, "destroy": False, "favorite": True,