diff --git a/src/backend/core/api/permissions.py b/src/backend/core/api/permissions.py index 445a2c16..f2080192 100644 --- a/src/backend/core/api/permissions.py +++ b/src/backend/core/api/permissions.py @@ -8,7 +8,8 @@ from rest_framework import permissions from core.models import DocumentAccess, RoleChoices ACTION_FOR_METHOD_TO_PERMISSION = { - "versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"} + "versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}, + "children": {"GET": "children_list", "POST": "children_create"}, } diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 9dd76ed1..9087c95d 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -12,6 +12,7 @@ from django.contrib.postgres.search import TrigramSimilarity from django.core.exceptions import ValidationError from django.core.files.storage import default_storage from django.db import models as db +from django.db import transaction from django.db.models import ( Exists, F, @@ -493,11 +494,38 @@ class DocumentViewSet( detail=True, methods=["get", "post"], serializer_class=serializers.ListDocumentSerializer, + url_path="children", ) - # pylint: disable=unused-argument - def children(self, request, pk, *args, **kwargs): - """Custom action to retrieve children of a document""" + def children(self, request, *args, **kwargs): + """Handle listing and creating children of a document""" document = self.get_object() + + if request.method == "POST": + # Create a child document + serializer = serializers.DocumentSerializer( + data=request.data, context=self.get_serializer_context() + ) + serializer.is_valid(raise_exception=True) + + with transaction.atomic(): + child_document = document.add_child( + creator=request.user, + **serializer.validated_data, + ) + models.DocumentAccess.objects.create( + document=child_document, + user=request.user, + role=models.RoleChoices.OWNER, + ) + # Set the created instance to the serializer + serializer.instance = child_document + + headers = self.get_success_headers(serializer.data) + return drf.response.Response( + serializer.data, status=status.HTTP_201_CREATED, headers=headers + ) + + # GET: List children queryset = document.get_children() queryset = self.annotate_queryset(queryset) diff --git a/src/backend/core/models.py b/src/backend/core/models.py index b39914f9..e4449596 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -584,7 +584,8 @@ class Document(MP_Node, BaseModel): "ai_transform": can_update, "ai_translate": can_update, "attachment_upload": can_update, - "children": can_get, + "children_list": can_get, + "children_create": can_update and user.is_authenticated, "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_create.py b/src/backend/core/tests/documents/test_api_documents_children_create.py new file mode 100644 index 00000000..ffac8f2b --- /dev/null +++ b/src/backend/core/tests/documents/test_api_documents_children_create.py @@ -0,0 +1,249 @@ +""" +Tests for Documents API endpoint in impress's core app: create +""" + +import random +from uuid import uuid4 + +import pytest +from rest_framework.test import APIClient + +from core import factories +from core.models import Document, LinkReachChoices, LinkRoleChoices + +pytestmark = pytest.mark.django_db + + +@pytest.mark.parametrize("depth", [1, 2, 3]) +@pytest.mark.parametrize("role", LinkRoleChoices.values) +@pytest.mark.parametrize("reach", LinkReachChoices.values) +def test_api_documents_children_create_anonymous(reach, role, depth): + """Anonymous users should not be allowed to create children documents.""" + for i in range(depth): + if i == 0: + document = factories.DocumentFactory(link_reach=reach, link_role=role) + else: + document = factories.DocumentFactory(parent=document) + + response = APIClient().post( + f"/api/v1.0/documents/{document.id!s}/children/", + { + "title": "my document", + }, + ) + + assert response.status_code == 401 + assert Document.objects.count() == depth + + +@pytest.mark.parametrize("depth", [1, 2, 3]) +@pytest.mark.parametrize( + "reach,role", + [ + ["restricted", "editor"], + ["restricted", "reader"], + ["public", "reader"], + ["authenticated", "reader"], + ], +) +def test_api_documents_children_create_authenticated_forbidden(reach, role, depth): + """ + Authenticated users with no write access on a document should not be allowed + to create a nested document. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + for i in range(depth): + if i == 0: + document = factories.DocumentFactory(link_reach=reach, link_role=role) + else: + document = factories.DocumentFactory(parent=document, link_role="reader") + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/children/", + { + "title": "my document", + }, + ) + + assert response.status_code == 403 + assert Document.objects.count() == depth + + +@pytest.mark.parametrize("depth", [1, 2, 3]) +@pytest.mark.parametrize( + "reach,role", + [ + ["public", "editor"], + ["authenticated", "editor"], + ], +) +def test_api_documents_children_create_authenticated_success(reach, role, depth): + """ + Authenticated users with write access on a document should be able + to create a nested document. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + for i in range(depth): + if i == 0: + document = factories.DocumentFactory(link_reach=reach, link_role=role) + else: + document = factories.DocumentFactory(parent=document, link_role="reader") + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/children/", + { + "title": "my child", + }, + ) + + assert response.status_code == 201 + + child = Document.objects.get(id=response.json()["id"]) + assert child.title == "my child" + assert child.link_reach == "restricted" + assert child.accesses.filter(role="owner", user=user).exists() + + +@pytest.mark.parametrize("depth", [1, 2, 3]) +def test_api_documents_children_create_related_forbidden(depth): + """ + Authenticated users with a specific read access on a document should not be allowed + to create a nested document. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + for i in range(depth): + if i == 0: + document = factories.DocumentFactory(link_reach="restricted") + factories.UserDocumentAccessFactory( + user=user, document=document, role="reader" + ) + else: + document = factories.DocumentFactory( + parent=document, link_reach="restricted" + ) + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/children/", + { + "title": "my document", + }, + ) + + assert response.status_code == 403 + assert Document.objects.count() == depth + + +@pytest.mark.parametrize("depth", [1, 2, 3]) +@pytest.mark.parametrize("role", ["editor", "administrator", "owner"]) +def test_api_documents_children_create_related_success(role, depth): + """ + Authenticated users with a specific write access on a document should be + able to create a nested document. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + for i in range(depth): + if i == 0: + document = factories.DocumentFactory(link_reach="restricted") + factories.UserDocumentAccessFactory(user=user, document=document, role=role) + else: + document = factories.DocumentFactory( + parent=document, link_reach="restricted" + ) + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/children/", + { + "title": "my child", + }, + ) + + assert response.status_code == 201 + child = Document.objects.get(id=response.json()["id"]) + assert child.title == "my child" + assert child.link_reach == "restricted" + assert child.accesses.filter(role="owner", user=user).exists() + + +def test_api_documents_children_create_authenticated_title_null(): + """It should be possible to create several nested documents with a null title.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + parent = factories.DocumentFactory( + title=None, link_reach="authenticated", link_role="editor" + ) + factories.DocumentFactory(title=None, parent=parent) + + response = client.post( + f"/api/v1.0/documents/{parent.id!s}/children/", {}, format="json" + ) + + assert response.status_code == 201 + assert Document.objects.filter(title__isnull=True).count() == 3 + + +def test_api_documents_children_create_force_id_success(): + """It should be possible to force the document ID when creating a nested document.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + access = factories.UserDocumentAccessFactory(user=user, role="editor") + forced_id = uuid4() + + response = client.post( + f"/api/v1.0/documents/{access.document.id!s}/children/", + { + "id": str(forced_id), + "title": "my document", + }, + format="json", + ) + + assert response.status_code == 201 + assert Document.objects.count() == 2 + assert response.json()["id"] == str(forced_id) + + +def test_api_documents_children_create_force_id_existing(): + """ + It should not be possible to use the ID of an existing document when forcing ID on creation. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + access = factories.UserDocumentAccessFactory(user=user, role="editor") + document = factories.DocumentFactory() + + response = client.post( + f"/api/v1.0/documents/{access.document.id!s}/children/", + { + "id": str(document.id), + "title": "my document", + }, + format="json", + ) + + assert response.status_code == 400 + assert response.json() == { + "id": ["A document with this ID already exists. You cannot override it."] + } 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 6d96432d..f1315e93 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve.py +++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -27,7 +27,8 @@ 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, + "children_create": False, + "children_list": True, "collaboration_auth": True, "destroy": False, # Anonymous user can't favorite a document even with read access @@ -78,7 +79,8 @@ 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, + "children_create": False, + "children_list": True, "collaboration_auth": True, "destroy": False, # Anonymous user can't favorite a document even with read access @@ -163,7 +165,8 @@ 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, + "children_create": document.link_role == "editor", + "children_list": True, "collaboration_auth": True, "destroy": False, "favorite": True, @@ -221,7 +224,8 @@ 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, + "children_create": grand_parent.link_role == "editor", + "children_list": True, "collaboration_auth": True, "destroy": False, "favorite": True, @@ -386,7 +390,8 @@ 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, + "children_create": access.role != "reader", + "children_list": 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 ae1443a0..b961499a 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -109,7 +109,8 @@ def test_models_documents_get_abilities_forbidden(is_authenticated, reach, role) "ai_transform": False, "ai_translate": False, "attachment_upload": False, - "children": False, + "children_create": False, + "children_list": False, "collaboration_auth": False, "destroy": False, "favorite": False, @@ -147,7 +148,8 @@ def test_models_documents_get_abilities_reader(is_authenticated, reach): "ai_transform": False, "ai_translate": False, "attachment_upload": False, - "children": True, + "children_create": False, + "children_list": True, "collaboration_auth": True, "destroy": False, "favorite": is_authenticated, @@ -185,7 +187,8 @@ def test_models_documents_get_abilities_editor(is_authenticated, reach): "ai_transform": True, "ai_translate": True, "attachment_upload": True, - "children": True, + "children_create": is_authenticated, + "children_list": True, "collaboration_auth": True, "destroy": False, "favorite": is_authenticated, @@ -212,7 +215,8 @@ def test_models_documents_get_abilities_owner(): "ai_transform": True, "ai_translate": True, "attachment_upload": True, - "children": True, + "children_create": True, + "children_list": True, "collaboration_auth": True, "destroy": True, "favorite": True, @@ -238,7 +242,8 @@ def test_models_documents_get_abilities_administrator(): "ai_transform": True, "ai_translate": True, "attachment_upload": True, - "children": True, + "children_create": True, + "children_list": True, "collaboration_auth": True, "destroy": False, "favorite": True, @@ -267,7 +272,8 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries): "ai_transform": True, "ai_translate": True, "attachment_upload": True, - "children": True, + "children_create": True, + "children_list": True, "collaboration_auth": True, "destroy": False, "favorite": True, @@ -298,7 +304,8 @@ def test_models_documents_get_abilities_reader_user(django_assert_num_queries): "ai_transform": False, "ai_translate": False, "attachment_upload": False, - "children": True, + "children_create": False, + "children_list": True, "collaboration_auth": True, "destroy": False, "favorite": True, @@ -330,7 +337,8 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries): "ai_transform": False, "ai_translate": False, "attachment_upload": False, - "children": True, + "children_create": False, + "children_list": True, "collaboration_auth": True, "destroy": False, "favorite": True,