diff --git a/docs/api.md b/docs/api.md index a298803cf..e2ceec208 100644 --- a/docs/api.md +++ b/docs/api.md @@ -227,7 +227,7 @@ Version-aware endpoints: - `PATCH /api/documents/{id}/`: content updates target the selected version (`?version={version_id}`) or latest version by default; non-content metadata updates target the root document. - `GET /api/documents/{id}/download/`, `GET /api/documents/{id}/preview/`, `GET /api/documents/{id}/thumb/`, `GET /api/documents/{id}/metadata/`: accept `?version={version_id}`. - `POST /api/documents/{id}/update_version/`: uploads a new version using multipart form field `document` and optional `version_label`. -- `POST /api/documents/merge_as_versions/`: merges existing top-level documents as versions of a selected root. The JSON body must contain `documents` (at least two document IDs) and `root_document_id` (one of those IDs). The root retains its metadata and permissions; source documents with existing versions are rejected. +- `POST /api/documents/merge_as_versions/`: merges existing top-level documents as versions of a selected root. The JSON body must contain `documents` (at least two document IDs) and `root_document_id` (one of those IDs). When merging one source document, an optional `version_label` may be provided. The root retains its metadata and permissions; source documents with existing versions are rejected. - `PATCH /api/documents/{id}/versions/{version_id}/`: updates the `version_label` of a specific version. - `DELETE /api/documents/{root_id}/versions/{version_id}/`: deletes a non-root version. diff --git a/src-ui/src/app/services/rest/document.service.spec.ts b/src-ui/src/app/services/rest/document.service.spec.ts index d02d7fcf6..e491e0b45 100644 --- a/src-ui/src/app/services/rest/document.service.spec.ts +++ b/src-ui/src/app/services/rest/document.service.spec.ts @@ -329,6 +329,21 @@ describe(`DocumentService`, () => { }) }) + it('should include an optional label when merging one document as a version', () => { + const ids = [1, 2] + subscription = service + .mergeDocumentsAsVersions(ids, 2, 'Imported') + .subscribe() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}${endpoint}/merge_as_versions/` + ) + expect(req.request.body).toEqual({ + documents: ids, + root_document_id: 2, + version_label: 'Imported', + }) + }) + it('should call appropriate api endpoint for edit pdf', () => { const ids = [1] const args = { operations: [{ page: 1, rotate: 90, doc: 0 }] } diff --git a/src-ui/src/app/services/rest/document.service.ts b/src-ui/src/app/services/rest/document.service.ts index 193088fd3..d201a546a 100644 --- a/src-ui/src/app/services/rest/document.service.ts +++ b/src-ui/src/app/services/rest/document.service.ts @@ -374,10 +374,15 @@ export class DocumentService extends AbstractPaperlessService { }) } - mergeDocumentsAsVersions(ids: number[], rootDocumentId: number) { + mergeDocumentsAsVersions( + ids: number[], + rootDocumentId: number, + versionLabel?: string + ) { return this.http.post(this.getResourceUrl(null, 'merge_as_versions'), { documents: ids, root_document_id: rootDocumentId, + ...(versionLabel ? { version_label: versionLabel } : {}), }) } diff --git a/src/documents/bulk_edit.py b/src/documents/bulk_edit.py index a1395ef0b..6b5f6ae11 100644 --- a/src/documents/bulk_edit.py +++ b/src/documents/bulk_edit.py @@ -617,6 +617,7 @@ def merge_as_versions( doc_ids: list[int], *, root_document_id: int, + version_label: str | None = None, ) -> Literal["OK"]: with transaction.atomic(): documents = list( @@ -632,6 +633,10 @@ def merge_as_versions( raise ValueError("Only top-level documents can be merged as versions.") source_ids = sorted(doc_id for doc_id in doc_ids if doc_id != root_document_id) + if version_label is not None and len(source_ids) != 1: + raise ValueError( + "A version label can only be set when merging one source document.", + ) if Document.objects.filter(root_document_id__in=source_ids).exists(): raise ValueError( "Documents with existing versions cannot be merged into another document.", @@ -650,14 +655,16 @@ def merge_as_versions( next_version_index += 1 source_document.root_document = root_document source_document.version_index = next_version_index + update_fields = [ + "root_document", + "version_index", + "archive_serial_number", + ] + 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=[ - "root_document", - "version_index", - "archive_serial_number", - ], - ) + source_document.save(update_fields=update_fields) root_document.modified = timezone.now() root_document.save(update_fields=["modified"]) diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 10661db1f..2628e0789 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -1677,6 +1677,18 @@ class MergeDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin 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"] @@ -1684,6 +1696,10 @@ class MergeDocumentsAsVersionsSerializer(DocumentListSerializer): raise serializers.ValidationError( "At least two documents are required.", ) + if "version_label" in attrs 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.", diff --git a/src/documents/tests/test_merge_documents_as_versions.py b/src/documents/tests/test_merge_documents_as_versions.py index 5ba595dfd..808926dd0 100644 --- a/src/documents/tests/test_merge_documents_as_versions.py +++ b/src/documents/tests/test_merge_documents_as_versions.py @@ -49,6 +49,33 @@ class TestMergeDocumentsAsVersionsSerializer(TestCase): "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={ @@ -191,6 +218,27 @@ 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.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") + @mock.patch("documents.bulk_edit.DocumentsStatusManager") @mock.patch("documents.bulk_edit.bulk_update_documents.apply_async") @mock.patch("documents.search.get_backend") @@ -252,6 +300,7 @@ class TestMergeDocumentsAsVersionsAPI(APITestCase): { "documents": [self.doc1.id, self.doc2.id], "root_document_id": self.doc2.id, + "version_label": "Imported", }, ), content_type="application/json", @@ -262,6 +311,7 @@ class TestMergeDocumentsAsVersionsAPI(APITestCase): merge_mock.assert_called_once_with( [self.doc1.id, self.doc2.id], root_document_id=self.doc2.id, + version_label="Imported", ) @mock.patch("documents.views.bulk_edit.merge_as_versions") @@ -320,6 +370,7 @@ class TestMergeDocumentsAsVersionsAPI(APITestCase): { "documents": [self.doc1.id, self.doc2.id], "root_document_id": self.doc2.id, + "version_label": "Imported", }, format="json", ) @@ -327,6 +378,7 @@ class TestMergeDocumentsAsVersionsAPI(APITestCase): 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",