mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-20 09:43:29 +00:00
Enhancement: merge documents as versions (#13515)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -30,6 +31,7 @@ from documents.permissions import set_permissions_for_object
|
||||
from documents.plugins.helpers import DocumentsStatusManager
|
||||
from documents.tasks import bulk_update_documents
|
||||
from documents.tasks import consume_file
|
||||
from documents.tasks import remove_document_from_index
|
||||
from documents.tasks import update_document_content_maybe_archive_file
|
||||
from documents.versioning import get_latest_version_for_root
|
||||
from documents.versioning import get_root_document
|
||||
@@ -39,6 +41,9 @@ if TYPE_CHECKING:
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
if settings.AUDIT_LOG_ENABLED:
|
||||
from auditlog.models import LogEntry
|
||||
|
||||
logger: logging.Logger = logging.getLogger("paperless.bulk_edit")
|
||||
|
||||
SourceMode = Literal["latest_version", "explicit_selection"]
|
||||
@@ -612,6 +617,115 @@ def merge(
|
||||
return "OK"
|
||||
|
||||
|
||||
def merge_as_versions(
|
||||
doc_ids: list[int],
|
||||
*,
|
||||
root_document_id: int,
|
||||
version_label: str | None = None,
|
||||
user: User | None = None,
|
||||
) -> Literal["OK"]:
|
||||
with transaction.atomic():
|
||||
documents = list(
|
||||
# Ordered by pk so concurrent merges take the row locks in the same order
|
||||
Document.objects.select_for_update()
|
||||
.filter(id__in=doc_ids)
|
||||
.order_by("id")
|
||||
.defer("content"),
|
||||
)
|
||||
documents_by_id = {document.id: document for document in documents}
|
||||
|
||||
source_ids = [doc_id for doc_id in doc_ids if doc_id != root_document_id]
|
||||
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
|
||||
)
|
||||
|
||||
# A version gives up its ASN
|
||||
source_asns = [
|
||||
documents_by_id[source_id].archive_serial_number
|
||||
for source_id in source_ids
|
||||
if documents_by_id[source_id].archive_serial_number is not None
|
||||
]
|
||||
|
||||
updated_fields = ["root_document", "version_index", "archive_serial_number"]
|
||||
if version_label is not None:
|
||||
updated_fields.append("version_label")
|
||||
|
||||
for source_id in source_ids:
|
||||
next_version_index += 1
|
||||
source_document = documents_by_id[source_id]
|
||||
source_document.root_document_id = root_document.pk
|
||||
source_document.version_index = next_version_index
|
||||
source_document.archive_serial_number = None
|
||||
if version_label is not None:
|
||||
source_document.version_label = version_label
|
||||
|
||||
# bulk_update and not save() to avoid post_save now
|
||||
Document.objects.bulk_update(
|
||||
[documents_by_id[source_id] for source_id in source_ids],
|
||||
updated_fields,
|
||||
)
|
||||
|
||||
root_updates = {"modified": timezone.now()}
|
||||
if source_asns and root_document.archive_serial_number is None:
|
||||
# If a version had one, hand the ASN over, the same as merge() does
|
||||
root_updates["archive_serial_number"] = source_asns.pop(0)
|
||||
logger.info(
|
||||
f"Document {root_document.id} took archive serial number "
|
||||
f"{root_updates['archive_serial_number']} from a document merged into it",
|
||||
)
|
||||
if source_asns:
|
||||
logger.warning(
|
||||
f"Archive serial number(s) {source_asns} were removed by merging "
|
||||
f"those documents as versions of document {root_document.id}",
|
||||
)
|
||||
|
||||
Document.objects.filter(pk=root_document.pk).update(**root_updates)
|
||||
|
||||
if settings.AUDIT_LOG_ENABLED:
|
||||
# update() doesn't fire auditlog signals, so manual
|
||||
LogEntry.objects.log_create(
|
||||
instance=root_document,
|
||||
changes={"Merged As Versions": ["None", source_ids]},
|
||||
action=LogEntry.Action.UPDATE,
|
||||
actor=user,
|
||||
additional_data={
|
||||
"reason": "Merged as versions",
|
||||
"version_ids": source_ids,
|
||||
},
|
||||
)
|
||||
|
||||
# One batch rather than a task each
|
||||
from documents.search import SearchIndexLockError
|
||||
from documents.search import get_backend
|
||||
|
||||
try:
|
||||
with get_backend().batch_update() as batch:
|
||||
for source_id in source_ids:
|
||||
batch.remove(source_id)
|
||||
except SearchIndexLockError:
|
||||
logger.error(
|
||||
f"Search index lock exhausted removing {source_ids}, "
|
||||
f"scheduling deferred index removal",
|
||||
)
|
||||
for source_id in source_ids:
|
||||
remove_document_from_index.apply_async(args=[source_id], countdown=60)
|
||||
|
||||
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]],
|
||||
|
||||
@@ -372,6 +372,10 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
||||
For version documents, this is always the document's own content.
|
||||
If the queryset already annotated ``effective_content``, that value is used.
|
||||
"""
|
||||
# Here to avoid circular import
|
||||
from documents.versioning import sort_versions_newest_first
|
||||
from documents.versioning import versions_newest_first
|
||||
|
||||
if hasattr(self, "effective_content"):
|
||||
return getattr(self, "effective_content")
|
||||
|
||||
@@ -388,12 +392,10 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
||||
# Empty list means prefetch ran and found no versions — use own content.
|
||||
if not prefetched_versions:
|
||||
return self.content
|
||||
latest_prefetched = max(prefetched_versions, key=lambda doc: doc.id)
|
||||
return latest_prefetched.content
|
||||
return sort_versions_newest_first(prefetched_versions)[0].content
|
||||
|
||||
latest_version_content = (
|
||||
Document.objects.filter(root_document=self)
|
||||
.order_by("-id")
|
||||
versions_newest_first(Document.objects.filter(root_document=self))
|
||||
.values_list("content", flat=True)
|
||||
.first()
|
||||
)
|
||||
|
||||
@@ -88,6 +88,7 @@ from documents.templating.utils import convert_format_str_to_template_format
|
||||
from documents.templating.workflows import validate_workflow_template
|
||||
from documents.validators import uri_validator
|
||||
from documents.validators import url_validator
|
||||
from documents.versioning import sort_versions_newest_first
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
@@ -1116,9 +1117,13 @@ class DocumentSerializer(
|
||||
"added",
|
||||
"checksum",
|
||||
"version_label",
|
||||
"root_document_id",
|
||||
"version_index",
|
||||
)
|
||||
versions = [*versions_qs, root_doc]
|
||||
|
||||
versions = sort_versions_newest_first(versions)
|
||||
|
||||
def build_info(doc: Document) -> _DocumentVersionInfo:
|
||||
return {
|
||||
"id": doc.id,
|
||||
@@ -1128,9 +1133,7 @@ class DocumentSerializer(
|
||||
"is_root": doc.id == root_doc.id,
|
||||
}
|
||||
|
||||
info = [build_info(doc) for doc in versions]
|
||||
info.sort(key=lambda item: item["id"], reverse=True)
|
||||
return info
|
||||
return [build_info(doc) for doc in versions]
|
||||
|
||||
def get_original_file_name(self, obj) -> str | None:
|
||||
return obj.original_filename
|
||||
@@ -1677,6 +1680,52 @@ class MergeDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin
|
||||
from_webui = serializers.BooleanField(required=False, default=False)
|
||||
|
||||
|
||||
class MergeDocumentsAsVersionsSerializer(DocumentListSerializer):
|
||||
root_document_id = serializers.IntegerField(required=True)
|
||||
version_label = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
allow_null=True,
|
||||
max_length=64,
|
||||
)
|
||||
|
||||
def validate_version_label(self, value):
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.strip()
|
||||
return normalized or None
|
||||
|
||||
def validate(self, attrs):
|
||||
documents = attrs["documents"]
|
||||
if len(documents) < 2:
|
||||
raise serializers.ValidationError(
|
||||
"At least two documents are required.",
|
||||
)
|
||||
if attrs.get("version_label") is not None and len(documents) != 2:
|
||||
raise serializers.ValidationError(
|
||||
"version_label can only be used when merging one source document.",
|
||||
)
|
||||
if attrs["root_document_id"] not in documents:
|
||||
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.global_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
|
||||
|
||||
|
||||
class EditPdfDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin):
|
||||
operations = serializers.ListField(required=True)
|
||||
delete_original = serializers.BooleanField(required=False, default=False)
|
||||
|
||||
@@ -827,6 +827,67 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(resp.data["content"], "v1-content")
|
||||
|
||||
def _make_root_with_out_of_order_versions(self) -> tuple[Document, ...]:
|
||||
"""
|
||||
A root whose newest version has a *lower* id than an older one, which is
|
||||
what merging an existing document in as a version produces.
|
||||
"""
|
||||
root = Document.objects.create(
|
||||
title="root",
|
||||
checksum="root",
|
||||
mime_type="application/pdf",
|
||||
content="root-content",
|
||||
)
|
||||
newest = Document.objects.create(
|
||||
title="newest",
|
||||
checksum="newest",
|
||||
mime_type="application/pdf",
|
||||
content="newest-content",
|
||||
)
|
||||
older = Document.objects.create(
|
||||
title="older",
|
||||
checksum="older",
|
||||
mime_type="application/pdf",
|
||||
root_document=root,
|
||||
version_index=1,
|
||||
content="older-content",
|
||||
)
|
||||
# Assigned last, so `newest` has the lower id despite being the later version
|
||||
newest.root_document = root
|
||||
newest.version_index = 2
|
||||
newest.save()
|
||||
return root, newest, older
|
||||
|
||||
def test_retrieve_uses_version_index_not_id_for_latest(self) -> None:
|
||||
root, _, _ = self._make_root_with_out_of_order_versions()
|
||||
|
||||
resp = self.client.get(f"/api/documents/{root.id}/")
|
||||
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(resp.data["content"], "newest-content")
|
||||
|
||||
def test_list_uses_version_index_not_id_for_latest(self) -> None:
|
||||
self._make_root_with_out_of_order_versions()
|
||||
|
||||
resp = self.client.get("/api/documents/?fields=id,content")
|
||||
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(
|
||||
[doc["content"] for doc in resp.data["results"]],
|
||||
["newest-content"],
|
||||
)
|
||||
|
||||
def test_versions_are_listed_newest_first_with_root_last(self) -> None:
|
||||
root, newest, older = self._make_root_with_out_of_order_versions()
|
||||
|
||||
resp = self.client.get(f"/api/documents/{root.id}/")
|
||||
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(
|
||||
[(version["id"], version["is_root"]) for version in resp.data["versions"]],
|
||||
[(newest.id, False), (older.id, False), (root.id, True)],
|
||||
)
|
||||
|
||||
|
||||
class TestVersionAwareFilters(TestCase):
|
||||
def test_title_content_filter_falls_back_to_content(self) -> None:
|
||||
|
||||
@@ -48,6 +48,7 @@ class TestApiSchema(APITestCase):
|
||||
self.assertIn("/api/documents/reprocess/", paths)
|
||||
self.assertIn("/api/documents/rotate/", paths)
|
||||
self.assertIn("/api/documents/merge/", paths)
|
||||
self.assertIn("/api/documents/merge_as_versions/", paths)
|
||||
self.assertIn("/api/documents/edit_pdf/", paths)
|
||||
self.assertIn("/api/documents/remove_password/", paths)
|
||||
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
from auditlog.models import LogEntry
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import TestCase
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from documents.bulk_edit import merge_as_versions
|
||||
from documents.models import Document
|
||||
from documents.serialisers import MergeDocumentsAsVersionsSerializer
|
||||
|
||||
|
||||
class TestMergeDocumentsAsVersionsSerializer(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.doc1 = Document.objects.create(checksum="A", title="A")
|
||||
self.doc2 = Document.objects.create(checksum="B", title="B")
|
||||
self.doc3 = Document.objects.create(checksum="C", title="C")
|
||||
|
||||
def test_accepts_selected_root_document(self) -> None:
|
||||
serializer = MergeDocumentsAsVersionsSerializer(
|
||||
data={
|
||||
"documents": [self.doc1.id, self.doc2.id, self.doc3.id],
|
||||
"root_document_id": self.doc2.id,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||
self.assertEqual(
|
||||
serializer.validated_data,
|
||||
{
|
||||
"documents": [self.doc1.id, self.doc2.id, self.doc3.id],
|
||||
"root_document_id": self.doc2.id,
|
||||
},
|
||||
)
|
||||
|
||||
def test_requires_at_least_two_documents(self) -> None:
|
||||
serializer = MergeDocumentsAsVersionsSerializer(
|
||||
data={
|
||||
"documents": [self.doc1.id],
|
||||
"root_document_id": self.doc1.id,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertFalse(serializer.is_valid())
|
||||
self.assertEqual(
|
||||
serializer.errors["non_field_errors"][0],
|
||||
"At least two documents are required.",
|
||||
)
|
||||
|
||||
def test_accepts_version_label_for_one_source_document(self) -> None:
|
||||
serializer = MergeDocumentsAsVersionsSerializer(
|
||||
data={
|
||||
"documents": [self.doc1.id, self.doc2.id],
|
||||
"root_document_id": self.doc1.id,
|
||||
"version_label": " Imported ",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||
self.assertEqual(serializer.validated_data["version_label"], "Imported")
|
||||
|
||||
def test_rejects_version_label_for_multiple_source_documents(self) -> None:
|
||||
serializer = MergeDocumentsAsVersionsSerializer(
|
||||
data={
|
||||
"documents": [self.doc1.id, self.doc2.id, self.doc3.id],
|
||||
"root_document_id": self.doc1.id,
|
||||
"version_label": "Imported",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertFalse(serializer.is_valid())
|
||||
self.assertEqual(
|
||||
serializer.errors["non_field_errors"][0],
|
||||
"version_label can only be used when merging one source document.",
|
||||
)
|
||||
|
||||
def test_requires_root_document_to_be_selected(self) -> None:
|
||||
serializer = MergeDocumentsAsVersionsSerializer(
|
||||
data={
|
||||
"documents": [self.doc1.id, self.doc2.id],
|
||||
"root_document_id": self.doc3.id,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertFalse(serializer.is_valid())
|
||||
self.assertEqual(
|
||||
serializer.errors["non_field_errors"][0],
|
||||
"root_document_id must be one of the selected documents.",
|
||||
)
|
||||
|
||||
def test_rejects_duplicate_documents(self) -> None:
|
||||
serializer = MergeDocumentsAsVersionsSerializer(
|
||||
data={
|
||||
"documents": [self.doc1.id, self.doc1.id],
|
||||
"root_document_id": self.doc1.id,
|
||||
},
|
||||
)
|
||||
|
||||
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_rejects_source_document_with_trashed_versions(self) -> None:
|
||||
version = Document.objects.create(
|
||||
checksum="D",
|
||||
title="D",
|
||||
root_document=self.doc1,
|
||||
version_index=1,
|
||||
)
|
||||
version.delete() # trashed, but still points at doc1
|
||||
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()
|
||||
# source2 was selected first, so it becomes the older of the two versions
|
||||
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)
|
||||
# The root had no ASN of its own, so it takes the first one
|
||||
self.assertEqual(root.archive_serial_number, 2)
|
||||
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_root_keeps_its_own_archive_serial_number(self, *_mocks) -> None:
|
||||
root = Document.objects.create(
|
||||
checksum="A",
|
||||
title="Root",
|
||||
archive_serial_number=1,
|
||||
)
|
||||
source = Document.objects.create(
|
||||
checksum="B",
|
||||
title="Source",
|
||||
archive_serial_number=2,
|
||||
)
|
||||
|
||||
with self.assertLogs("paperless.bulk_edit", level="WARNING") as logs:
|
||||
merge_as_versions([root.id, source.id], root_document_id=root.id)
|
||||
|
||||
root.refresh_from_db()
|
||||
source.refresh_from_db()
|
||||
self.assertEqual(root.archive_serial_number, 1)
|
||||
self.assertIsNone(source.archive_serial_number)
|
||||
# Dropping an ASN is not silent
|
||||
self.assertIn("[2]", logs.output[0])
|
||||
|
||||
@mock.patch("documents.bulk_edit.DocumentsStatusManager")
|
||||
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
|
||||
@mock.patch("documents.search.get_backend")
|
||||
def test_root_without_asn_takes_the_source_archive_serial_number(
|
||||
self,
|
||||
*_mocks,
|
||||
) -> None:
|
||||
root = Document.objects.create(checksum="A", title="Root")
|
||||
source = Document.objects.create(
|
||||
checksum="B",
|
||||
title="Source",
|
||||
archive_serial_number=7,
|
||||
)
|
||||
|
||||
merge_as_versions([root.id, source.id], root_document_id=root.id)
|
||||
|
||||
root.refresh_from_db()
|
||||
source.refresh_from_db()
|
||||
self.assertEqual(root.archive_serial_number, 7)
|
||||
self.assertIsNone(source.archive_serial_number)
|
||||
|
||||
@mock.patch("documents.bulk_edit.DocumentsStatusManager")
|
||||
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
|
||||
@mock.patch("documents.search.get_backend")
|
||||
def test_writes_audit_log_entry(self, *_mocks) -> None:
|
||||
user = User.objects.create_user(username="merger")
|
||||
root = Document.objects.create(checksum="A", title="Root")
|
||||
source = Document.objects.create(checksum="B", title="Source")
|
||||
LogEntry.objects.all().delete()
|
||||
|
||||
merge_as_versions([root.id, source.id], root_document_id=root.id, user=user)
|
||||
|
||||
entry = LogEntry.objects.filter(
|
||||
content_type=ContentType.objects.get_for_model(Document),
|
||||
object_id=root.id,
|
||||
).first()
|
||||
self.assertIsNotNone(entry)
|
||||
self.assertEqual(entry.actor, user)
|
||||
self.assertEqual(entry.action, LogEntry.Action.UPDATE)
|
||||
self.assertEqual(entry.changes, {"Merged As Versions": ["None", [source.id]]})
|
||||
self.assertEqual(entry.additional_data["version_ids"], [source.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_sets_version_label_for_one_source_document(
|
||||
self,
|
||||
_get_backend_mock,
|
||||
_bulk_update_mock,
|
||||
_status_manager_mock,
|
||||
) -> None:
|
||||
root = Document.objects.create(checksum="A", title="Root")
|
||||
source = Document.objects.create(checksum="B", title="Source")
|
||||
|
||||
merge_as_versions(
|
||||
[root.id, source.id],
|
||||
root_document_id=root.id,
|
||||
version_label="Imported",
|
||||
)
|
||||
|
||||
source.refresh_from_db()
|
||||
self.assertEqual(source.version_label, "Imported")
|
||||
|
||||
|
||||
class TestMergeDocumentsAsVersionsAPI(APITestCase):
|
||||
def setUp(self) -> None:
|
||||
self.user = User.objects.create_user(username="user")
|
||||
self.user.user_permissions.add(
|
||||
Permission.objects.get(codename="change_document"),
|
||||
Permission.objects.get(codename="view_document"),
|
||||
Permission.objects.get(codename="delete_document"),
|
||||
)
|
||||
self.doc1 = Document.objects.create(
|
||||
checksum="A",
|
||||
title="A",
|
||||
owner=self.user,
|
||||
)
|
||||
self.doc2 = Document.objects.create(
|
||||
checksum="B",
|
||||
title="B",
|
||||
owner=self.user,
|
||||
)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
@mock.patch("documents.views.bulk_edit.merge_as_versions")
|
||||
def test_merges_documents_as_versions(self, merge_mock) -> None:
|
||||
merge_mock.return_value = "OK"
|
||||
merge_mock.__name__ = "merge_as_versions"
|
||||
|
||||
response = self.client.post(
|
||||
"/api/documents/merge_as_versions/",
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc1.id, self.doc2.id],
|
||||
"root_document_id": self.doc2.id,
|
||||
"version_label": "Imported",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data, {"result": "OK"})
|
||||
merge_mock.assert_called_once_with(
|
||||
[self.doc1.id, self.doc2.id],
|
||||
root_document_id=self.doc2.id,
|
||||
version_label="Imported",
|
||||
user=self.user,
|
||||
)
|
||||
|
||||
@mock.patch("documents.views.bulk_edit.merge_as_versions")
|
||||
def test_requires_change_permission(self, merge_mock) -> None:
|
||||
merge_mock.__name__ = "merge_as_versions"
|
||||
user = User.objects.create_user(username="no-change")
|
||||
self.doc1.owner = user
|
||||
self.doc1.save()
|
||||
self.doc2.owner = user
|
||||
self.doc2.save()
|
||||
self.client.force_authenticate(user=user)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/documents/merge_as_versions/",
|
||||
{
|
||||
"documents": [self.doc1.id, self.doc2.id],
|
||||
"root_document_id": self.doc1.id,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
merge_mock.assert_not_called()
|
||||
|
||||
@mock.patch("documents.views.bulk_edit.merge_as_versions")
|
||||
def test_requires_delete_permission(self, merge_mock) -> None:
|
||||
merge_mock.__name__ = "merge_as_versions"
|
||||
# Owns them and may change them, but may not make them stop being documents
|
||||
user = User.objects.create_user(username="no-delete")
|
||||
user.user_permissions.add(
|
||||
Permission.objects.get(codename="change_document"),
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
for doc in (self.doc1, self.doc2):
|
||||
doc.owner = user
|
||||
doc.save()
|
||||
self.client.force_authenticate(user=user)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/documents/merge_as_versions/",
|
||||
{
|
||||
"documents": [self.doc1.id, self.doc2.id],
|
||||
"root_document_id": self.doc1.id,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
merge_mock.assert_not_called()
|
||||
|
||||
@mock.patch("documents.views.bulk_edit.merge_as_versions")
|
||||
def test_rejects_unselected_root(self, merge_mock) -> None:
|
||||
doc3 = Document.objects.create(
|
||||
checksum="C",
|
||||
title="C",
|
||||
owner=self.user,
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/documents/merge_as_versions/",
|
||||
{
|
||||
"documents": [self.doc1.id, self.doc2.id],
|
||||
"root_document_id": doc3.id,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
merge_mock.assert_not_called()
|
||||
|
||||
@mock.patch("documents.views.bulk_edit.merge_as_versions")
|
||||
def test_rejects_source_document_with_versions(self, merge_mock) -> None:
|
||||
Document.objects.create(
|
||||
checksum="C",
|
||||
title="C",
|
||||
root_document=self.doc1,
|
||||
version_index=1,
|
||||
owner=self.user,
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/documents/merge_as_versions/",
|
||||
{
|
||||
"documents": [self.doc1.id, self.doc2.id],
|
||||
"root_document_id": self.doc2.id,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
merge_mock.assert_not_called()
|
||||
self.doc1.refresh_from_db()
|
||||
self.assertIsNone(self.doc1.root_document_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_merges_and_returns_documents_as_versions(
|
||||
self,
|
||||
get_backend_mock,
|
||||
bulk_update_mock,
|
||||
status_manager_mock,
|
||||
) -> None:
|
||||
response = self.client.post(
|
||||
"/api/documents/merge_as_versions/",
|
||||
{
|
||||
"documents": [self.doc1.id, self.doc2.id],
|
||||
"root_document_id": self.doc2.id,
|
||||
"version_label": "Imported",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.doc1.refresh_from_db()
|
||||
self.assertEqual(self.doc1.root_document_id, self.doc2.id)
|
||||
self.assertEqual(self.doc1.version_label, "Imported")
|
||||
|
||||
detail_response = self.client.get(
|
||||
f"/api/documents/{self.doc2.id}/?fields=id,versions",
|
||||
)
|
||||
self.assertEqual(detail_response.status_code, status.HTTP_200_OK)
|
||||
versions = detail_response.data["versions"]
|
||||
self.assertEqual(
|
||||
{version["id"] for version in versions},
|
||||
{self.doc1.id, self.doc2.id},
|
||||
)
|
||||
self.assertEqual(
|
||||
[version["id"] for version in versions if version["is_root"]],
|
||||
[self.doc2.id],
|
||||
)
|
||||
batch = get_backend_mock.return_value.batch_update.return_value.__enter__.return_value
|
||||
batch.remove.assert_called_once_with(self.doc1.id)
|
||||
bulk_update_mock.assert_called_once_with(
|
||||
kwargs={"document_ids": [self.doc2.id]},
|
||||
headers={"trigger_source": "system"},
|
||||
)
|
||||
status_manager_mock.return_value.send_documents_deleted.assert_called_once_with(
|
||||
[self.doc1.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_chosen_order_survives_to_the_versions_list(self, *_mocks) -> None:
|
||||
doc3 = Document.objects.create(checksum="C", title="C", owner=self.user)
|
||||
# Deliberately not in id order, as dragging the dialog rows produces
|
||||
ordered = [doc3.id, self.doc1.id]
|
||||
|
||||
response = self.client.post(
|
||||
"/api/documents/merge_as_versions/",
|
||||
{
|
||||
"documents": [*ordered, self.doc2.id],
|
||||
"root_document_id": self.doc2.id,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
detail_response = self.client.get(
|
||||
f"/api/documents/{self.doc2.id}/?fields=id,versions",
|
||||
)
|
||||
# Newest first, so the reverse of the order they were merged in
|
||||
self.assertEqual(
|
||||
[version["id"] for version in detail_response.data["versions"]],
|
||||
[self.doc1.id, doc3.id, self.doc2.id],
|
||||
)
|
||||
@@ -5,12 +5,34 @@ from enum import StrEnum
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
|
||||
from django.db.models import F
|
||||
from django.db.models import QuerySet
|
||||
|
||||
from documents.models import Document
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from rest_framework.request import Request
|
||||
|
||||
|
||||
def versions_newest_first(documents: QuerySet[Document]) -> QuerySet[Document]:
|
||||
"""
|
||||
Sorts versions so the newest one comes first using version_index and not on id,
|
||||
because an existing document can be merged in as a version
|
||||
"""
|
||||
return documents.order_by(F("version_index").desc(nulls_last=True), "-id")
|
||||
|
||||
|
||||
def sort_versions_newest_first(documents: list[Document]) -> list[Document]:
|
||||
"""
|
||||
Same sorting as versions_newest_first()
|
||||
"""
|
||||
return sorted(
|
||||
documents,
|
||||
key=lambda doc: (doc.version_index or 0, doc.id),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
|
||||
class VersionResolutionError(StrEnum):
|
||||
INVALID = "invalid"
|
||||
NOT_FOUND = "not_found"
|
||||
@@ -51,7 +73,7 @@ def get_latest_version_for_root(
|
||||
include_deleted: bool = False,
|
||||
) -> Document:
|
||||
manager = _document_manager(include_deleted=include_deleted)
|
||||
latest = manager.filter(root_document=root_doc).order_by("-id").first()
|
||||
latest = versions_newest_first(manager.filter(root_document=root_doc)).first()
|
||||
return latest or root_doc
|
||||
|
||||
|
||||
|
||||
+45
-9
@@ -196,6 +196,7 @@ from documents.serialisers import DocumentVersionLabelSerializer
|
||||
from documents.serialisers import DocumentVersionSerializer
|
||||
from documents.serialisers import EditPdfDocumentsSerializer
|
||||
from documents.serialisers import EmailSerializer
|
||||
from documents.serialisers import MergeDocumentsAsVersionsSerializer
|
||||
from documents.serialisers import MergeDocumentsSerializer
|
||||
from documents.serialisers import NotesSerializer
|
||||
from documents.serialisers import PostDocumentSerializer
|
||||
@@ -233,6 +234,7 @@ from documents.versioning import get_latest_version_for_root
|
||||
from documents.versioning import get_request_version_param
|
||||
from documents.versioning import get_root_document
|
||||
from documents.versioning import resolve_requested_version_for_root
|
||||
from documents.versioning import versions_newest_first
|
||||
from paperless import version
|
||||
from paperless.celery import app as celery_app
|
||||
from paperless.config import AIConfig
|
||||
@@ -1083,9 +1085,9 @@ class DocumentViewSet(
|
||||
|
||||
def get_queryset(self):
|
||||
latest_version_content = Subquery(
|
||||
Document.objects.filter(root_document=OuterRef("pk"))
|
||||
.order_by("-id")
|
||||
.values("content")[:1],
|
||||
versions_newest_first(
|
||||
Document.objects.filter(root_document=OuterRef("pk")),
|
||||
).values("content")[:1],
|
||||
)
|
||||
# A correlated subquery avoids the LEFT JOIN + Count() this used to
|
||||
# be, which forced a GROUP BY aggregate over every matching document
|
||||
@@ -1121,6 +1123,7 @@ class DocumentViewSet(
|
||||
"checksum",
|
||||
"version_label",
|
||||
"root_document_id",
|
||||
"version_index",
|
||||
),
|
||||
),
|
||||
"tags",
|
||||
@@ -2187,11 +2190,9 @@ class DocumentViewSet(
|
||||
},
|
||||
)
|
||||
|
||||
current = (
|
||||
Document.objects.filter(Q(id=root_doc.id) | Q(root_document=root_doc))
|
||||
.order_by("-id")
|
||||
.first()
|
||||
)
|
||||
current = versions_newest_first(
|
||||
Document.objects.filter(Q(id=root_doc.id) | Q(root_document=root_doc)),
|
||||
).first()
|
||||
|
||||
document_updated.send(
|
||||
sender=self.__class__,
|
||||
@@ -2819,8 +2820,12 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
|
||||
"delete_pages",
|
||||
"edit_pdf",
|
||||
"remove_password",
|
||||
"merge_as_versions",
|
||||
}
|
||||
# merge_as_versions doesn't queue any consume tasks
|
||||
METHOD_NAMES_REQUIRING_TRIGGER_SOURCE = METHOD_NAMES_REQUIRING_USER - {
|
||||
"merge_as_versions",
|
||||
}
|
||||
METHOD_NAMES_REQUIRING_TRIGGER_SOURCE = METHOD_NAMES_REQUIRING_USER
|
||||
|
||||
def _has_document_permissions(
|
||||
self,
|
||||
@@ -2861,6 +2866,7 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
|
||||
bulk_edit.rotate,
|
||||
bulk_edit.delete_pages,
|
||||
bulk_edit.edit_pdf,
|
||||
bulk_edit.merge_as_versions,
|
||||
bulk_edit.remove_password,
|
||||
]
|
||||
)
|
||||
@@ -2891,6 +2897,9 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
|
||||
has_perms
|
||||
and (
|
||||
method == bulk_edit.delete
|
||||
# Sources stop being documents of their own, and removing one
|
||||
# again afterwards needs delete_document
|
||||
or method == bulk_edit.merge_as_versions
|
||||
or (
|
||||
method in [bulk_edit.merge, bulk_edit.split]
|
||||
and parameters.get("delete_originals")
|
||||
@@ -3147,6 +3156,33 @@ class MergeDocumentsView(DocumentOperationPermissionMixin):
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
operation_id="documents_merge_as_versions",
|
||||
description="Merge selected documents as versions of a chosen root document",
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="MergeDocumentsAsVersionsResult",
|
||||
fields={
|
||||
"result": serializers.CharField(),
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
class MergeDocumentsAsVersionsView(DocumentOperationPermissionMixin):
|
||||
serializer_class = MergeDocumentsAsVersionsSerializer
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
return self._execute_document_action(
|
||||
method=bulk_edit.merge_as_versions,
|
||||
validated_data=serializer.validated_data,
|
||||
operation_label="document merge as versions",
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
operation_id="documents_delete",
|
||||
|
||||
Reference in New Issue
Block a user