diff --git a/docs/usage.md b/docs/usage.md index 49b18964a..8cc921b05 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -101,6 +101,8 @@ Think of versions as **file history** for a document. - Deleting a non-root version keeps metadata and falls back to the latest remaining version. - From the document list, select two or more documents and choose **Merge as versions** to combine them under one entry. Select the root document whose metadata and permissions should be retained; the other selected documents become file versions. The root may already have versions, but documents being added as versions must not have version histories of their own. - From a document's **Versions** menu, choose **Existing** to search for another document and add it as a version of the current document. +- Documents merged as versions give up their archive serial number. If the root has no ASN of its own it takes the first one, otherwise the ASNs are released and the removal is logged. +- Merging as versions cannot be undone from the UI, and deleting the root document moves its versions to the trash as well. ### Management Lists diff --git a/src/documents/bulk_edit.py b/src/documents/bulk_edit.py index 696f15397..09596fb74 100644 --- a/src/documents/bulk_edit.py +++ b/src/documents/bulk_edit.py @@ -651,11 +651,19 @@ def merge_as_versions( 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 + ] + 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 update_fields = [ "root_document", "version_index", @@ -664,11 +672,25 @@ def merge_as_versions( if version_label is not None: source_document.version_label = version_label update_fields.append("version_label") - source_document.archive_serial_number = None source_document.save(update_fields=update_fields) + root_update_fields = ["modified"] + 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_document.archive_serial_number = source_asns.pop(0) + root_update_fields.append("archive_serial_number") + logger.info( + f"Document {root_document.id} took archive serial number " + f"{root_document.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}", + ) + root_document.modified = timezone.now() - root_document.save(update_fields=["modified"]) + root_document.save(update_fields=root_update_fields) for source_id in source_ids: remove_document_from_index.apply_async(args=[source_id]) diff --git a/src/documents/tests/test_merge_documents_as_versions.py b/src/documents/tests/test_merge_documents_as_versions.py index bb9fa18dc..57e11c87e 100644 --- a/src/documents/tests/test_merge_documents_as_versions.py +++ b/src/documents/tests/test_merge_documents_as_versions.py @@ -223,6 +223,8 @@ class TestMergeDocumentsAsVersions(TestCase): self.assertEqual(source1.version_index, 4) 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, 1) self.assertGreater(root.modified, original_modified) self.assertEqual(existing_version.root_document_id, root.id) @@ -238,6 +240,52 @@ class TestMergeDocumentsAsVersions(TestCase): [source1.id, source2.id], ) + @mock.patch("documents.bulk_edit.DocumentsStatusManager") + @mock.patch("documents.bulk_edit.bulk_update_documents.apply_async") + @mock.patch("documents.bulk_edit.remove_document_from_index.apply_async") + 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.bulk_edit.remove_document_from_index.apply_async") + 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.bulk_edit.remove_document_from_index.apply_async")