From effdae0793658aa104520c6a13e3b1ac8451bef1 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:17:45 -0700 Subject: [PATCH] Backend operation, also delete from search and notify the frontend --- src/documents/bulk_edit.py | 69 ++++++++ src/documents/serialisers.py | 14 ++ .../tests/test_merge_documents_as_versions.py | 150 ++++++++++++++++++ 3 files changed, 233 insertions(+) diff --git a/src/documents/bulk_edit.py b/src/documents/bulk_edit.py index 0cea9a3a1..632d4f566 100644 --- a/src/documents/bulk_edit.py +++ b/src/documents/bulk_edit.py @@ -12,6 +12,7 @@ from celery import group from celery import shared_task from django.conf import settings from django.db import transaction +from django.db.models import Max from django.db.models import Q from django.utils import timezone @@ -612,6 +613,74 @@ def merge( return "OK" +def merge_as_versions( + doc_ids: list[int], + *, + root_document_id: int, +) -> Literal["OK"]: + with transaction.atomic(): + documents = list( + Document.objects.select_for_update().filter(id__in=doc_ids), + ) + documents_by_id = {document.id: document for document in documents} + + if len(documents) != len(doc_ids): + raise ValueError("Some documents do not exist or were specified twice.") + if root_document_id not in documents_by_id: + raise ValueError("The root document must be selected.") + if any(document.root_document_id is not None for document in documents): + raise ValueError("Only top-level documents can be merged as versions.") + + source_ids = [doc_id for doc_id in doc_ids if doc_id != root_document_id] + if Document.objects.filter(root_document_id__in=source_ids).exists(): + raise ValueError( + "Documents with existing versions cannot be merged into another document.", + ) + + root_document = documents_by_id[root_document_id] + next_version_index = ( + Document.global_objects.filter( + root_document_id=root_document_id, + ).aggregate(max_index=Max("version_index"))["max_index"] + or 0 + ) + + for source_id in source_ids: + source_document = documents_by_id[source_id] + next_version_index += 1 + source_document.root_document = root_document + source_document.version_index = next_version_index + source_document.archive_serial_number = None + source_document.save( + update_fields=[ + "root_document", + "version_index", + "archive_serial_number", + ], + ) + + root_document.modified = timezone.now() + root_document.save(update_fields=["modified"]) + + # We need to remove these explicitly from search + from documents.search import get_backend + + with get_backend().batch_update() as batch: + for source_id in source_ids: + batch.remove(source_id) + + bulk_update_documents.apply_async( + kwargs={"document_ids": [root_document_id]}, + headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM}, + ) + + # And as far as the frontend is concerned, they're deleted + status_mgr = DocumentsStatusManager() + status_mgr.send_documents_deleted(source_ids) + + return "OK" + + def split( doc_ids: list[int], pages: list[list[int]], diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 7da0277b9..10661db1f 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -1688,6 +1688,20 @@ class MergeDocumentsAsVersionsSerializer(DocumentListSerializer): raise serializers.ValidationError( "root_document_id must be one of the selected documents.", ) + + selected_documents = Document.objects.filter(id__in=documents) + if selected_documents.filter(root_document__isnull=False).exists(): + raise serializers.ValidationError( + "Only top-level documents can be merged as versions.", + ) + + source_document_ids = set(documents) - {attrs["root_document_id"]} + if Document.objects.filter( + root_document_id__in=source_document_ids, + ).exists(): + raise serializers.ValidationError( + "Documents with existing versions cannot be merged into another document.", + ) return attrs diff --git a/src/documents/tests/test_merge_documents_as_versions.py b/src/documents/tests/test_merge_documents_as_versions.py index 12ff16ae3..2b9f75334 100644 --- a/src/documents/tests/test_merge_documents_as_versions.py +++ b/src/documents/tests/test_merge_documents_as_versions.py @@ -1,5 +1,8 @@ +from unittest import mock + from django.test import TestCase +from documents.bulk_edit import merge_as_versions from documents.models import Document from documents.serialisers import MergeDocumentsAsVersionsSerializer @@ -65,3 +68,150 @@ class TestMergeDocumentsAsVersionsSerializer(TestCase): self.assertFalse(serializer.is_valid()) self.assertIn("documents", serializer.errors) + + def test_rejects_selected_version(self) -> None: + version = Document.objects.create( + checksum="D", + title="D", + root_document=self.doc1, + version_index=1, + ) + serializer = MergeDocumentsAsVersionsSerializer( + data={ + "documents": [version.id, self.doc2.id], + "root_document_id": self.doc2.id, + }, + ) + + self.assertFalse(serializer.is_valid()) + self.assertEqual( + serializer.errors["non_field_errors"][0], + "Only top-level documents can be merged as versions.", + ) + + def test_rejects_source_document_with_versions(self) -> None: + Document.objects.create( + checksum="D", + title="D", + root_document=self.doc1, + version_index=1, + ) + serializer = MergeDocumentsAsVersionsSerializer( + data={ + "documents": [self.doc1.id, self.doc2.id], + "root_document_id": self.doc2.id, + }, + ) + + self.assertFalse(serializer.is_valid()) + self.assertEqual( + serializer.errors["non_field_errors"][0], + "Documents with existing versions cannot be merged into another document.", + ) + + def test_allows_root_document_with_versions(self) -> None: + Document.objects.create( + checksum="D", + title="D", + root_document=self.doc1, + version_index=1, + ) + serializer = MergeDocumentsAsVersionsSerializer( + data={ + "documents": [self.doc1.id, self.doc2.id], + "root_document_id": self.doc1.id, + }, + ) + + self.assertTrue(serializer.is_valid(), serializer.errors) + + +class TestMergeDocumentsAsVersions(TestCase): + @mock.patch("documents.bulk_edit.DocumentsStatusManager") + @mock.patch("documents.bulk_edit.bulk_update_documents.apply_async") + @mock.patch("documents.search.get_backend") + def test_merges_documents_in_selection_order( + self, + get_backend_mock, + bulk_update_mock, + status_manager_mock, + ) -> None: + root = Document.objects.create(checksum="A", title="Root") + existing_version = Document.objects.create( + checksum="B", + title="Existing version", + root_document=root, + version_index=3, + ) + source1 = Document.objects.create( + checksum="C", + title="Source 1", + archive_serial_number=1, + ) + source2 = Document.objects.create( + checksum="D", + title="Source 2", + archive_serial_number=2, + ) + original_modified = root.modified + + result = merge_as_versions( + [source2.id, root.id, source1.id], + root_document_id=root.id, + ) + + self.assertEqual(result, "OK") + source1.refresh_from_db() + source2.refresh_from_db() + root.refresh_from_db() + self.assertEqual(source2.root_document_id, root.id) + self.assertEqual(source2.version_index, 4) + self.assertEqual(source1.root_document_id, root.id) + self.assertEqual(source1.version_index, 5) + self.assertIsNone(source1.archive_serial_number) + self.assertIsNone(source2.archive_serial_number) + self.assertGreater(root.modified, original_modified) + self.assertEqual(existing_version.root_document_id, root.id) + + batch = get_backend_mock.return_value.batch_update.return_value.__enter__.return_value + self.assertEqual( + [call.args[0] for call in batch.remove.call_args_list], + [source2.id, source1.id], + ) + bulk_update_mock.assert_called_once_with( + kwargs={"document_ids": [root.id]}, + headers={"trigger_source": "system"}, + ) + status_manager_mock.return_value.send_documents_deleted.assert_called_once_with( + [source2.id, source1.id], + ) + + @mock.patch("documents.bulk_edit.DocumentsStatusManager") + @mock.patch("documents.bulk_edit.bulk_update_documents.apply_async") + @mock.patch("documents.search.get_backend") + def test_rejects_source_document_with_versions( + self, + get_backend_mock, + bulk_update_mock, + status_manager_mock, + ) -> None: + source = Document.objects.create(checksum="A", title="Source") + Document.objects.create( + checksum="B", + title="Source version", + root_document=source, + version_index=1, + ) + root = Document.objects.create(checksum="C", title="Root") + + with self.assertRaisesRegex(ValueError, "existing versions"): + merge_as_versions( + [source.id, root.id], + root_document_id=root.id, + ) + + source.refresh_from_db() + self.assertIsNone(source.root_document_id) + get_backend_mock.assert_not_called() + bulk_update_mock.assert_not_called() + status_manager_mock.assert_not_called()