Backend operation, also delete from search and notify the frontend

This commit is contained in:
shamoon
2026-08-07 15:14:57 -07:00
parent ead3a46d3b
commit aa015423ac
3 changed files with 233 additions and 0 deletions
+69
View File
@@ -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]],
+14
View File
@@ -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
@@ -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()