From 5d5ac0c1c8c75bf984bf250a13068ed47b275ef0 Mon Sep 17 00:00:00 2001 From: Sylvain Boissel Date: Wed, 18 Feb 2026 12:31:08 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20allow=20the=20duplication?= =?UTF-8?q?=20of=20subpages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new with_descendants parameter to the doc duplication API. The logic of the duplicate() method has been moved to a new internal _duplicate_document() method to allow for recursion. Adds unit tests for the new feature. --- CHANGELOG.md | 1 + src/backend/core/api/serializers.py | 5 +- src/backend/core/api/viewsets.py | 165 +++++-- .../documents/test_api_documents_duplicate.py | 421 ++++++++++++++++++ 4 files changed, 543 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b814fdb..79676723 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to - ✨(frontend) Can print a doc #1832 - ✨(backend) manage reconciliation requests for user accounts #1878 - 👷(CI) add GHCR workflow for forked repo testing #1851 +- ✨(backend) allow the duplication of subpages #1893 ### Changed diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 349e0191..57ac5bae 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -591,10 +591,13 @@ class LinkDocumentSerializer(serializers.ModelSerializer): class DocumentDuplicationSerializer(serializers.Serializer): """ Serializer for duplicating a document. - Allows specifying whether to keep access permissions. + Allows specifying whether to keep access permissions, + and whether to duplicate descendant documents as well + (deep copy) or not (shallow copy). """ with_accesses = serializers.BooleanField(default=False) + with_descendants = serializers.BooleanField(default=False) def create(self, validated_data): """ diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index c74bb94c..514e56c9 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1208,11 +1208,7 @@ class DocumentViewSet( @transaction.atomic def duplicate(self, request, *args, **kwargs): """ - Duplicate a document and store the links to attached files in the duplicated - document to allow cross-access. - - Optionally duplicates accesses if `with_accesses` is set to true - in the payload. + Duplicate a document, alongside its descendants if requested. """ # Get document while checking permissions document_to_duplicate = self.get_object() @@ -1221,8 +1217,43 @@ class DocumentViewSet( data=request.data, partial=True ) serializer.is_valid(raise_exception=True) + user = request.user + + duplicated_document = self._duplicate_document( + document_to_duplicate=document_to_duplicate, + serializer=serializer, + user=user, + ) + + return drf_response.Response( + {"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED + ) + + def _duplicate_document( + self, + document_to_duplicate, + serializer, + user, + new_parent=None, + ): + """ + Duplicate a document and store the links to attached files in the duplicated + document to allow cross-access. + + Optionally duplicates accesses if `with_accesses` is set to true + in the payload. + + Optionally duplicates sub-documents if `with_descendants` is set to true in + the payload. In this case, the whole subtree of the document will be duplicated, + and the links to attached files will be stored in all duplicated documents. + + The `with_accesses` option will also be applied to all duplicated documents + if `with_descendants` is set to true. + """ with_accesses = serializer.validated_data.get("with_accesses", False) - user_role = document_to_duplicate.get_role(request.user) + with_descendants = serializer.validated_data.get("with_descendants", False) + + user_role = document_to_duplicate.get_role(user) is_owner_or_admin = user_role in models.PRIVILEGED_ROLES base64_yjs_content = document_to_duplicate.content @@ -1241,11 +1272,41 @@ class DocumentViewSet( extracted_attachments & set(document_to_duplicate.attachments) ) title = capfirst(_("copy of {title}").format(title=document_to_duplicate.title)) - if not document_to_duplicate.is_root() and choices.RoleChoices.get_priority( + # If parent_duplicate is provided we must add the duplicated document as a child + if new_parent is not None: + duplicated_document = new_parent.add_child( + title=title, + content=base64_yjs_content, + attachments=attachments, + duplicated_from=document_to_duplicate, + creator=user, + **link_kwargs, + ) + + # Handle access duplication for this child + if with_accesses and is_owner_or_admin: + original_accesses = models.DocumentAccess.objects.filter( + document=document_to_duplicate + ).exclude(user=user) + + accesses_to_create = [ + models.DocumentAccess( + document=duplicated_document, + user_id=access.user_id, + team=access.team, + role=access.role, + ) + for access in original_accesses + ] + + if accesses_to_create: + models.DocumentAccess.objects.bulk_create(accesses_to_create) + + elif not document_to_duplicate.is_root() and choices.RoleChoices.get_priority( user_role ) < choices.RoleChoices.get_priority(models.RoleChoices.EDITOR): duplicated_document = models.Document.add_root( - creator=self.request.user, + creator=user, title=title, content=base64_yjs_content, attachments=attachments, @@ -1254,55 +1315,63 @@ class DocumentViewSet( ) models.DocumentAccess.objects.create( document=duplicated_document, - user=self.request.user, + user=user, role=models.RoleChoices.OWNER, ) - return drf_response.Response( - {"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED + else: + duplicated_document = document_to_duplicate.add_sibling( + "right", + title=title, + content=base64_yjs_content, + attachments=attachments, + duplicated_from=document_to_duplicate, + creator=user, + **link_kwargs, ) - duplicated_document = document_to_duplicate.add_sibling( - "right", - title=title, - content=base64_yjs_content, - attachments=attachments, - duplicated_from=document_to_duplicate, - creator=request.user, - **link_kwargs, - ) - - # Always add the logged-in user as OWNER for root documents - if document_to_duplicate.is_root(): - accesses_to_create = [ - models.DocumentAccess( - document=duplicated_document, - user=request.user, - role=models.RoleChoices.OWNER, - ) - ] - - # If accesses should be duplicated, add other users' accesses as per original document - if with_accesses and is_owner_or_admin: - original_accesses = models.DocumentAccess.objects.filter( - document=document_to_duplicate - ).exclude(user=request.user) - - accesses_to_create.extend( + # Always add the logged-in user as OWNER for root documents + if document_to_duplicate.is_root(): + accesses_to_create = [ models.DocumentAccess( document=duplicated_document, - user_id=access.user_id, - team=access.team, - role=access.role, + user=user, + role=models.RoleChoices.OWNER, ) - for access in original_accesses + ] + + # If accesses should be duplicated, + # add other users' accesses as per original document + if with_accesses and is_owner_or_admin: + original_accesses = models.DocumentAccess.objects.filter( + document=document_to_duplicate + ).exclude(user=user) + + accesses_to_create.extend( + models.DocumentAccess( + document=duplicated_document, + user_id=access.user_id, + team=access.team, + role=access.role, + ) + for access in original_accesses + ) + + # Bulk create all the duplicated accesses + models.DocumentAccess.objects.bulk_create(accesses_to_create) + + if with_descendants: + for child in document_to_duplicate.get_children().filter( + ancestors_deleted_at__isnull=True + ): + # When duplicating descendants, attach duplicates under the duplicated_document + self._duplicate_document( + document_to_duplicate=child, + serializer=serializer, + user=user, + new_parent=duplicated_document, ) - # Bulk create all the duplicated accesses - models.DocumentAccess.objects.bulk_create(accesses_to_create) - - return drf_response.Response( - {"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED - ) + return duplicated_document def _search_simple(self, request, text): """ diff --git a/src/backend/core/tests/documents/test_api_documents_duplicate.py b/src/backend/core/tests/documents/test_api_documents_duplicate.py index 3882c428..40760539 100644 --- a/src/backend/core/tests/documents/test_api_documents_duplicate.py +++ b/src/backend/core/tests/documents/test_api_documents_duplicate.py @@ -318,3 +318,424 @@ def test_api_documents_duplicate_reader_non_root_document(): assert duplicated_document.is_root() assert duplicated_document.accesses.count() == 1 assert duplicated_document.accesses.get(user=user).role == "owner" + + +def test_api_documents_duplicate_with_descendants_simple(): + """ + Duplicating a document with descendants flag should recursively duplicate all children. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + # Create document tree + root = factories.DocumentFactory( + users=[(user, "owner")], + title="Root Document", + ) + child1 = factories.DocumentFactory( + parent=root, + title="Child 1", + ) + child2 = factories.DocumentFactory( + parent=root, + title="Child 2", + ) + + initial_count = models.Document.objects.count() + assert initial_count == 3 + + # Duplicate with descendants + response = client.post( + f"/api/v1.0/documents/{root.id!s}/duplicate/", + {"with_descendants": True}, + format="json", + ) + + assert response.status_code == 201 + duplicated_root = models.Document.objects.get(id=response.json()["id"]) + + # Check that all documents were duplicated (6 total: 3 original + 3 duplicated) + assert models.Document.objects.count() == 6 + + # Check root duplication + assert duplicated_root.title == "Copy of Root Document" + assert duplicated_root.creator == user + assert duplicated_root.duplicated_from == root + assert duplicated_root.get_children().count() == 2 + + # Check children duplication + duplicated_children = duplicated_root.get_children().order_by("title") + assert duplicated_children.count() == 2 + + duplicated_child1 = duplicated_children.first() + assert duplicated_child1.title == "Copy of Child 1" + assert duplicated_child1.creator == user + assert duplicated_child1.duplicated_from == child1 + assert duplicated_child1.get_parent() == duplicated_root + + duplicated_child2 = duplicated_children.last() + assert duplicated_child2.title == "Copy of Child 2" + assert duplicated_child2.creator == user + assert duplicated_child2.duplicated_from == child2 + assert duplicated_child2.get_parent() == duplicated_root + + +def test_api_documents_duplicate_with_descendants_multi_level(): + """ + Duplicating should recursively handle multiple levels of nesting. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + root = factories.DocumentFactory( + users=[(user, "owner")], + title="Level 0", + ) + child = factories.DocumentFactory( + parent=root, + title="Level 1", + ) + grandchild = factories.DocumentFactory( + parent=child, + title="Level 2", + ) + great_grandchild = factories.DocumentFactory( + parent=grandchild, + title="Level 3", + ) + + initial_count = models.Document.objects.count() + assert initial_count == 4 + + # Duplicate with descendants + response = client.post( + f"/api/v1.0/documents/{root.id!s}/duplicate/", + {"with_descendants": True}, + format="json", + ) + + assert response.status_code == 201 + duplicated_root = models.Document.objects.get(id=response.json()["id"]) + + # Check that all documents were duplicated + assert models.Document.objects.count() == 8 + + # Verify the tree structure + assert duplicated_root.depth == root.depth + dup_children = duplicated_root.get_children() + assert dup_children.count() == 1 + + dup_child = dup_children.first() + assert dup_child.title == "Copy of Level 1" + assert dup_child.duplicated_from == child + dup_grandchildren = dup_child.get_children() + assert dup_grandchildren.count() == 1 + + dup_grandchild = dup_grandchildren.first() + assert dup_grandchild.title == "Copy of Level 2" + assert dup_grandchild.duplicated_from == grandchild + dup_great_grandchildren = dup_grandchild.get_children() + assert dup_great_grandchildren.count() == 1 + + dup_great_grandchild = dup_great_grandchildren.first() + assert dup_great_grandchild.title == "Copy of Level 3" + assert dup_great_grandchild.duplicated_from == great_grandchild + + +def test_api_documents_duplicate_with_descendants_and_attachments(): + """ + Duplicating with descendants should properly handle attachments in all children. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + # Create documents with attachments + root_id = uuid.uuid4() + child_id = uuid.uuid4() + image_key_root, image_url_root = get_image_refs(root_id) + image_key_child, image_url_child = get_image_refs(child_id) + + # Create root document with attachment + ydoc = pycrdt.Doc() + fragment = pycrdt.XmlFragment( + [ + pycrdt.XmlElement("img", {"src": image_url_root}), + ] + ) + ydoc["document-store"] = fragment + update = ydoc.get_update() + root_content = base64.b64encode(update).decode("utf-8") + + root = factories.DocumentFactory( + id=root_id, + users=[(user, "owner")], + title="Root with Image", + content=root_content, + attachments=[image_key_root], + ) + + # Create child with different attachment + ydoc_child = pycrdt.Doc() + fragment_child = pycrdt.XmlFragment( + [ + pycrdt.XmlElement("img", {"src": image_url_child}), + ] + ) + ydoc_child["document-store"] = fragment_child + update_child = ydoc_child.get_update() + child_content = base64.b64encode(update_child).decode("utf-8") + + child = factories.DocumentFactory( + id=child_id, + parent=root, + title="Child with Image", + content=child_content, + attachments=[image_key_child], + ) + + # Duplicate with descendants + response = client.post( + f"/api/v1.0/documents/{root.id!s}/duplicate/", + {"with_descendants": True}, + format="json", + ) + + assert response.status_code == 201 + duplicated_root = models.Document.objects.get(id=response.json()["id"]) + + # Check root attachments + assert duplicated_root.attachments == [image_key_root] + assert duplicated_root.content == root_content + + # Check child attachments + dup_children = duplicated_root.get_children() + assert dup_children.count() == 1 + dup_child = dup_children.first() + assert dup_child.attachments == [image_key_child] + assert dup_child.content == child_content + + +def test_api_documents_duplicate_with_descendants_and_accesses(): + """ + Duplicating with descendants and accesses should propagate accesses to all children. + """ + user = factories.UserFactory() + other_user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + # Create document tree with accesses + root = factories.DocumentFactory( + users=[(user, "owner"), (other_user, "editor")], + title="Root", + ) + child = factories.DocumentFactory( + parent=root, + title="Child", + ) + factories.UserDocumentAccessFactory(document=child, user=other_user, role="reader") + + # Duplicate with descendants and accesses + response = client.post( + f"/api/v1.0/documents/{root.id!s}/duplicate/", + {"with_descendants": True, "with_accesses": True}, + format="json", + ) + + assert response.status_code == 201 + duplicated_root = models.Document.objects.get(id=response.json()["id"]) + + # Check root accesses (should be duplicated) + root_accesses = duplicated_root.accesses.order_by("user_id") + assert root_accesses.count() == 2 + assert root_accesses.get(user=user).role == "owner" + assert root_accesses.get(user=other_user).role == "editor" + + # Check child accesses (should be duplicated) + dup_children = duplicated_root.get_children() + dup_child = dup_children.first() + child_accesses = dup_child.accesses.order_by("user_id") + assert child_accesses.count() == 1 + assert child_accesses.get(user=other_user).role == "reader" + + +@pytest.mark.parametrize("role", ["editor", "reader"]) +def test_api_documents_duplicate_with_descendants_non_root_document_becomes_root(role): + """ + When duplicating a non-root document with descendants as a reader/editor, + it should become a root document and still duplicate its children. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + parent = factories.DocumentFactory(users=[(user, "owner")]) + child = factories.DocumentFactory( + parent=parent, + users=[(user, role)], + title="Sub Document", + ) + grandchild = factories.DocumentFactory( + parent=child, + title="Grandchild", + ) + + assert child.is_child_of(parent) + + # Duplicate the child (non-root) with descendants + response = client.post( + f"/api/v1.0/documents/{child.id!s}/duplicate/", + {"with_descendants": True}, + format="json", + ) + + assert response.status_code == 201 + duplicated_child = models.Document.objects.get(id=response.json()["id"]) + + assert duplicated_child.title == "Copy of Sub Document" + + dup_grandchildren = duplicated_child.get_children() + assert dup_grandchildren.count() == 1 + dup_grandchild = dup_grandchildren.first() + assert dup_grandchild.title == "Copy of Grandchild" + assert dup_grandchild.duplicated_from == grandchild + + +def test_api_documents_duplicate_without_descendants_should_not_duplicate_children(): + """ + When with_descendants is not set or False, children should not be duplicated. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + # Create document tree + root = factories.DocumentFactory( + users=[(user, "owner")], + title="Root", + ) + child = factories.DocumentFactory( + parent=root, + title="Child", + ) + + initial_count = models.Document.objects.count() + assert initial_count == 2 + + # Duplicate without descendants (default behavior) + response = client.post( + f"/api/v1.0/documents/{root.id!s}/duplicate/", + format="json", + ) + + assert response.status_code == 201 + duplicated_root = models.Document.objects.get(id=response.json()["id"]) + + # Only root should be duplicated, not children + assert models.Document.objects.count() == 3 + assert duplicated_root.get_children().count() == 0 + + +def test_api_documents_duplicate_with_descendants_preserves_link_configuration(): + """ + Duplicating with descendants should preserve link configuration (link_reach, link_role) + for all children when with_accesses is True. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + # Create document tree with specific link configurations + root = factories.DocumentFactory( + users=[(user, "owner")], + title="Root", + link_reach="public", + link_role="reader", + ) + child = factories.DocumentFactory( + parent=root, + title="Child", + link_reach="restricted", + link_role="editor", + ) + + # Duplicate with descendants and accesses + response = client.post( + f"/api/v1.0/documents/{root.id!s}/duplicate/", + {"with_descendants": True, "with_accesses": True}, + format="json", + ) + + assert response.status_code == 201 + duplicated_root = models.Document.objects.get(id=response.json()["id"]) + + # Check root link configuration + assert duplicated_root.link_reach == root.link_reach + assert duplicated_root.link_role == root.link_role + + # Check child link configuration + dup_children = duplicated_root.get_children() + dup_child = dup_children.first() + assert dup_child.link_reach == child.link_reach + assert dup_child.link_role == child.link_role + + +def test_api_documents_duplicate_with_descendants_complex_tree(): + """ + Test duplication of a complex tree structure with multiple branches. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + # Create a complex tree: + # root + # / \ + # c1 c2 + # / \ \ + # gc1 gc2 gc3 + root = factories.DocumentFactory( + users=[(user, "owner")], + title="Root", + ) + child1 = factories.DocumentFactory(parent=root, title="Child 1") + child2 = factories.DocumentFactory(parent=root, title="Child 2") + _grandchild1 = factories.DocumentFactory(parent=child1, title="GrandChild 1") + _grandchild2 = factories.DocumentFactory(parent=child1, title="GrandChild 2") + _grandchild3 = factories.DocumentFactory(parent=child2, title="GrandChild 3") + + initial_count = models.Document.objects.count() + assert initial_count == 6 + + # Duplicate with descendants + response = client.post( + f"/api/v1.0/documents/{root.id!s}/duplicate/", + {"with_descendants": True}, + format="json", + ) + + assert response.status_code == 201 + duplicated_root = models.Document.objects.get(id=response.json()["id"]) + + # All documents should be duplicated + assert models.Document.objects.count() == 12 + + # Check structure is preserved + dup_children = duplicated_root.get_children().order_by("title") + assert dup_children.count() == 2 + + dup_child1 = dup_children.first() + assert dup_child1.title == "Copy of Child 1" + dup_grandchildren1 = dup_child1.get_children().order_by("title") + assert dup_grandchildren1.count() == 2 + assert dup_grandchildren1.first().title == "Copy of GrandChild 1" + assert dup_grandchildren1.last().title == "Copy of GrandChild 2" + + dup_child2 = dup_children.last() + assert dup_child2.title == "Copy of Child 2" + dup_grandchildren2 = dup_child2.get_children() + assert dup_grandchildren2.count() == 1 + assert dup_grandchildren2.first().title == "Copy of GrandChild 3"