From 731448a8f999c349222584cda65b8ddb7f268f47 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:59:26 -0800 Subject: [PATCH] Fixhancement: support version-specific edits (#12233) --- .../pdf-editor/pdf-editor.component.spec.ts | 13 ++ .../common/pdf-editor/pdf-editor.component.ts | 7 +- .../document-detail.component.spec.ts | 9 +- .../document-detail.component.ts | 14 +- .../src/app/services/rest/document.service.ts | 5 + src/documents/bulk_edit.py | 162 +++++++-------- src/documents/serialisers.py | 12 ++ src/documents/tests/test_api_bulk_edit.py | 24 ++- src/documents/tests/test_bulk_edit.py | 195 +++++++++++++++++- 9 files changed, 334 insertions(+), 107 deletions(-) diff --git a/src-ui/src/app/components/common/pdf-editor/pdf-editor.component.spec.ts b/src-ui/src/app/components/common/pdf-editor/pdf-editor.component.spec.ts index 434deb11d..0e9abafbb 100644 --- a/src-ui/src/app/components/common/pdf-editor/pdf-editor.component.spec.ts +++ b/src-ui/src/app/components/common/pdf-editor/pdf-editor.component.spec.ts @@ -3,6 +3,7 @@ import { provideHttpClientTesting } from '@angular/common/http/testing' import { ComponentFixture, TestBed } from '@angular/core/testing' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' +import { DocumentService } from 'src/app/services/rest/document.service' import { PDFEditorComponent } from './pdf-editor.component' describe('PDFEditorComponent', () => { @@ -139,4 +140,16 @@ describe('PDFEditorComponent', () => { expect(component.pages[1].page).toBe(2) expect(component.pages[2].page).toBe(3) }) + + it('should include selected version in preview source when provided', () => { + const documentService = TestBed.inject(DocumentService) + const previewSpy = jest + .spyOn(documentService, 'getPreviewUrl') + .mockReturnValue('preview-version') + component.documentID = 3 + component.versionID = 10 + + expect(component.pdfSrc).toBe('preview-version') + expect(previewSpy).toHaveBeenCalledWith(3, false, 10) + }) }) diff --git a/src-ui/src/app/components/common/pdf-editor/pdf-editor.component.ts b/src-ui/src/app/components/common/pdf-editor/pdf-editor.component.ts index c294516e0..5ea624aef 100644 --- a/src-ui/src/app/components/common/pdf-editor/pdf-editor.component.ts +++ b/src-ui/src/app/components/common/pdf-editor/pdf-editor.component.ts @@ -46,6 +46,7 @@ export class PDFEditorComponent extends ConfirmDialogComponent { activeModal: NgbActiveModal = inject(NgbActiveModal) documentID: number + versionID?: number pages: PageOperation[] = [] totalPages = 0 editMode: PdfEditorEditMode = this.settingsService.get( @@ -55,7 +56,11 @@ export class PDFEditorComponent extends ConfirmDialogComponent { includeMetadata: boolean = true get pdfSrc(): string { - return this.documentService.getPreviewUrl(this.documentID) + return this.documentService.getPreviewUrl( + this.documentID, + false, + this.versionID + ) } pdfLoaded(pdf: PngxPdfDocumentProxy) { diff --git a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts index e4cd1941f..990c6b4d0 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts @@ -1661,22 +1661,25 @@ describe('DocumentDetailComponent', () => { const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument') const errorSpy = jest.spyOn(toastService, 'showError') initNormally() + component.selectedVersionId = 10 component.editPdf() expect(modal).not.toBeUndefined() modal.componentInstance.documentID = doc.id + expect(modal.componentInstance.versionID).toBe(10) modal.componentInstance.pages = [{ page: 1, rotate: 0, splitAfter: false }] modal.componentInstance.confirm() let req = httpTestingController.expectOne( `${environment.apiBaseUrl}documents/bulk_edit/` ) expect(req.request.body).toEqual({ - documents: [doc.id], + documents: [10], method: 'edit_pdf', parameters: { operations: [{ page: 1, rotate: 0, doc: 0 }], delete_original: false, update_document: false, include_metadata: true, + source_mode: 'explicit_selection', }, }) req.error(new ErrorEvent('failed')) @@ -1698,6 +1701,7 @@ describe('DocumentDetailComponent', () => { let modal: NgbModalRef modalService.activeInstances.subscribe((m) => (modal = m[0])) initNormally() + component.selectedVersionId = 10 component.password = 'secret' component.removePassword() const dialog = @@ -1710,13 +1714,14 @@ describe('DocumentDetailComponent', () => { `${environment.apiBaseUrl}documents/bulk_edit/` ) expect(req.request.body).toEqual({ - documents: [doc.id], + documents: [10], method: 'remove_password', parameters: { password: 'secret', update_document: false, include_metadata: false, delete_original: true, + source_mode: 'explicit_selection', }, }) req.flush(true) diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts index 942ed4742..a2c193770 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.ts @@ -74,7 +74,10 @@ import { import { CorrespondentService } from 'src/app/services/rest/correspondent.service' import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' import { DocumentTypeService } from 'src/app/services/rest/document-type.service' -import { DocumentService } from 'src/app/services/rest/document.service' +import { + BulkEditSourceMode, + DocumentService, +} from 'src/app/services/rest/document.service' import { SavedViewService } from 'src/app/services/rest/saved-view.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { TagService } from 'src/app/services/rest/tag.service' @@ -1753,20 +1756,23 @@ export class DocumentDetailComponent size: 'xl', scrollable: true, }) + const sourceDocumentId = this.selectedVersionId ?? this.document.id modal.componentInstance.title = $localize`PDF Editor` modal.componentInstance.btnCaption = $localize`Proceed` modal.componentInstance.documentID = this.document.id + modal.componentInstance.versionID = sourceDocumentId modal.componentInstance.confirmClicked .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe(() => { modal.componentInstance.buttonsEnabled = false this.documentsService - .bulkEdit([this.document.id], 'edit_pdf', { + .bulkEdit([sourceDocumentId], 'edit_pdf', { operations: modal.componentInstance.getOperations(), delete_original: modal.componentInstance.deleteOriginal, update_document: modal.componentInstance.editMode == PdfEditorEditMode.Update, include_metadata: modal.componentInstance.includeMetadata, + source_mode: BulkEditSourceMode.EXPLICIT_SELECTION, }) .pipe(first(), takeUntil(this.unsubscribeNotifier)) .subscribe({ @@ -1812,16 +1818,18 @@ export class DocumentDetailComponent modal.componentInstance.confirmClicked .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe(() => { + const sourceDocumentId = this.selectedVersionId ?? this.document.id const dialog = modal.componentInstance as PasswordRemovalConfirmDialogComponent dialog.buttonsEnabled = false this.networkActive = true this.documentsService - .bulkEdit([this.document.id], 'remove_password', { + .bulkEdit([sourceDocumentId], 'remove_password', { password: this.password, update_document: dialog.updateDocument, include_metadata: dialog.includeMetadata, delete_original: dialog.deleteOriginal, + source_mode: BulkEditSourceMode.EXPLICIT_SELECTION, }) .pipe(first(), takeUntil(this.unsubscribeNotifier)) .subscribe({ diff --git a/src-ui/src/app/services/rest/document.service.ts b/src-ui/src/app/services/rest/document.service.ts index 4b4ed7072..bc06d571c 100644 --- a/src-ui/src/app/services/rest/document.service.ts +++ b/src-ui/src/app/services/rest/document.service.ts @@ -37,6 +37,11 @@ export interface SelectionData { selected_custom_fields: SelectionDataItem[] } +export enum BulkEditSourceMode { + LATEST_VERSION = 'latest_version', + EXPLICIT_SELECTION = 'explicit_selection', +} + @Injectable({ providedIn: 'root', }) diff --git a/src/documents/bulk_edit.py b/src/documents/bulk_edit.py index 0a7803944..490dfa629 100644 --- a/src/documents/bulk_edit.py +++ b/src/documents/bulk_edit.py @@ -29,12 +29,21 @@ from documents.plugins.helpers import DocumentsStatusManager from documents.tasks import bulk_update_documents from documents.tasks import consume_file 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 if TYPE_CHECKING: from django.contrib.auth.models import User logger: logging.Logger = logging.getLogger("paperless.bulk_edit") +SourceMode = Literal["latest_version", "explicit_selection"] + + +class SourceModeChoices: + LATEST_VERSION: SourceMode = "latest_version" + EXPLICIT_SELECTION: SourceMode = "explicit_selection" + @shared_task(bind=True) def restore_archive_serial_numbers_task( @@ -72,46 +81,21 @@ def restore_archive_serial_numbers(backup: dict[int, int | None]) -> None: logger.info(f"Restored archive serial numbers for documents {list(backup.keys())}") -def _get_root_ids_by_doc_id(doc_ids: list[int]) -> dict[int, int]: - """ - Resolve each provided document id to its root document id. +def _resolve_root_and_source_doc( + doc: Document, + *, + source_mode: SourceMode = SourceModeChoices.LATEST_VERSION, +) -> tuple[Document, Document]: + root_doc = get_root_document(doc) - - If the id is already a root document: root id is itself. - - If the id is a version document: root id is its `root_document_id`. - """ - qs = Document.objects.filter(id__in=doc_ids).only("id", "root_document_id") - return {doc.id: doc.root_document_id or doc.id for doc in qs} + if source_mode == SourceModeChoices.EXPLICIT_SELECTION: + return root_doc, doc + # Version IDs are explicit by default, only a selected root resolves to latest + if doc.root_document_id is not None: + return root_doc, doc -def _get_root_and_current_docs_by_root_id( - root_ids: set[int], -) -> tuple[dict[int, Document], dict[int, Document]]: - """ - Returns: - - root_docs: root_id -> root Document - - current_docs: root_id -> newest version Document (or root if none) - """ - root_docs = { - doc.id: doc - for doc in Document.objects.filter(id__in=root_ids).select_related( - "owner", - ) - } - latest_versions_by_root_id: dict[int, Document] = {} - for version_doc in Document.objects.filter(root_document_id__in=root_ids).order_by( - "root_document_id", - "-id", - ): - root_id = version_doc.root_document_id - if root_id is None: - continue - latest_versions_by_root_id.setdefault(root_id, version_doc) - - current_docs: dict[int, Document] = { - root_id: latest_versions_by_root_id.get(root_id, root_docs[root_id]) - for root_id in root_docs - } - return root_docs, current_docs + return root_doc, get_latest_version_for_root(root_doc) def set_correspondent( @@ -421,21 +405,32 @@ def rotate( doc_ids: list[int], degrees: int, *, + source_mode: SourceMode = SourceModeChoices.LATEST_VERSION, user: User | None = None, ) -> Literal["OK"]: logger.info( f"Attempting to rotate {len(doc_ids)} documents by {degrees} degrees.", ) - doc_to_root_id = _get_root_ids_by_doc_id(doc_ids) - root_ids = set(doc_to_root_id.values()) - root_docs_by_id, current_docs_by_root_id = _get_root_and_current_docs_by_root_id( - root_ids, - ) + docs_by_id = { + doc.id: doc + for doc in Document.objects.select_related("root_document").filter( + id__in=doc_ids, + ) + } + docs_by_root_id: dict[int, tuple[Document, Document]] = {} + for doc_id in doc_ids: + doc = docs_by_id.get(doc_id) + if doc is None: + continue + root_doc, source_doc = _resolve_root_and_source_doc( + doc, + source_mode=source_mode, + ) + docs_by_root_id.setdefault(root_doc.id, (root_doc, source_doc)) + import pikepdf - for root_id in root_ids: - root_doc = root_docs_by_id[root_id] - source_doc = current_docs_by_root_id[root_id] + for root_doc, source_doc in docs_by_root_id.values(): if source_doc.mime_type != "application/pdf": logger.warning( f"Document {root_doc.id} is not a PDF, skipping rotation.", @@ -481,12 +476,14 @@ def merge( metadata_document_id: int | None = None, delete_originals: bool = False, archive_fallback: bool = False, + source_mode: SourceMode = SourceModeChoices.LATEST_VERSION, user: User | None = None, ) -> Literal["OK"]: logger.info( f"Attempting to merge {len(doc_ids)} documents into a single document.", ) - qs = Document.objects.filter(id__in=doc_ids) + qs = Document.objects.select_related("root_document").filter(id__in=doc_ids) + docs_by_id = {doc.id: doc for doc in qs} affected_docs: list[int] = [] import pikepdf @@ -495,14 +492,20 @@ def merge( handoff_asn: int | None = None # use doc_ids to preserve order for doc_id in doc_ids: - doc = qs.get(id=doc_id) + doc = docs_by_id.get(doc_id) + if doc is None: + continue + _, source_doc = _resolve_root_and_source_doc( + doc, + source_mode=source_mode, + ) try: doc_path = ( - doc.archive_path + source_doc.archive_path if archive_fallback - and doc.mime_type != "application/pdf" - and doc.has_archive_version - else doc.source_path + and source_doc.mime_type != "application/pdf" + and source_doc.has_archive_version + else source_doc.source_path ) with pikepdf.open(str(doc_path)) as pdf: version = max(version, pdf.pdf_version) @@ -584,18 +587,23 @@ def split( pages: list[list[int]], *, delete_originals: bool = False, + source_mode: SourceMode = SourceModeChoices.LATEST_VERSION, user: User | None = None, ) -> Literal["OK"]: logger.info( f"Attempting to split document {doc_ids[0]} into {len(pages)} documents", ) - doc = Document.objects.get(id=doc_ids[0]) + doc = Document.objects.select_related("root_document").get(id=doc_ids[0]) + _, source_doc = _resolve_root_and_source_doc( + doc, + source_mode=source_mode, + ) import pikepdf consume_tasks = [] try: - with pikepdf.open(doc.source_path) as pdf: + with pikepdf.open(source_doc.source_path) as pdf: for idx, split_doc in enumerate(pages): dst: pikepdf.Pdf = pikepdf.new() for page in split_doc: @@ -659,25 +667,17 @@ def delete_pages( doc_ids: list[int], pages: list[int], *, + source_mode: SourceMode = SourceModeChoices.LATEST_VERSION, user: User | None = None, ) -> Literal["OK"]: logger.info( f"Attempting to delete pages {pages} from {len(doc_ids)} documents", ) doc = Document.objects.select_related("root_document").get(id=doc_ids[0]) - root_doc: Document - if doc.root_document_id is None or doc.root_document is None: - root_doc = doc - else: - root_doc = doc.root_document - - source_doc = ( - Document.objects.filter(Q(id=root_doc.id) | Q(root_document=root_doc)) - .order_by("-id") - .first() + root_doc, source_doc = _resolve_root_and_source_doc( + doc, + source_mode=source_mode, ) - if source_doc is None: - source_doc = root_doc pages = sorted(pages) # sort pages to avoid index issues import pikepdf @@ -722,6 +722,7 @@ def edit_pdf( delete_original: bool = False, update_document: bool = False, include_metadata: bool = True, + source_mode: SourceMode = SourceModeChoices.LATEST_VERSION, user: User | None = None, ) -> Literal["OK"]: """ @@ -736,19 +737,10 @@ def edit_pdf( f"Editing PDF of document {doc_ids[0]} with {len(operations)} operations", ) doc = Document.objects.select_related("root_document").get(id=doc_ids[0]) - root_doc: Document - if doc.root_document_id is None or doc.root_document is None: - root_doc = doc - else: - root_doc = doc.root_document - - source_doc = ( - Document.objects.filter(Q(id=root_doc.id) | Q(root_document=root_doc)) - .order_by("-id") - .first() + root_doc, source_doc = _resolve_root_and_source_doc( + doc, + source_mode=source_mode, ) - if source_doc is None: - source_doc = root_doc import pikepdf pdf_docs: list[pikepdf.Pdf] = [] @@ -859,6 +851,7 @@ def remove_password( update_document: bool = False, delete_original: bool = False, include_metadata: bool = True, + source_mode: SourceMode = SourceModeChoices.LATEST_VERSION, user: User | None = None, ) -> Literal["OK"]: """ @@ -868,19 +861,10 @@ def remove_password( for doc_id in doc_ids: doc = Document.objects.select_related("root_document").get(id=doc_id) - root_doc: Document - if doc.root_document_id is None or doc.root_document is None: - root_doc = doc - else: - root_doc = doc.root_document - - source_doc = ( - Document.objects.filter(Q(id=root_doc.id) | Q(root_document=root_doc)) - .order_by("-id") - .first() + root_doc, source_doc = _resolve_root_and_source_doc( + doc, + source_mode=source_mode, ) - if source_doc is None: - source_doc = root_doc try: logger.info( f"Attempting password removal from document {doc_ids[0]}", diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 457fb00ab..c6ae846be 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -1723,6 +1723,15 @@ class BulkEditSerializer( except ValueError: raise serializers.ValidationError("invalid rotation degrees") + def _validate_source_mode(self, parameters) -> None: + source_mode = parameters.get( + "source_mode", + bulk_edit.SourceModeChoices.LATEST_VERSION, + ) + if source_mode not in bulk_edit.SourceModeChoices.__dict__.values(): + raise serializers.ValidationError("Invalid source_mode") + parameters["source_mode"] = source_mode + def _validate_parameters_split(self, parameters) -> None: if "pages" not in parameters: raise serializers.ValidationError("pages not specified") @@ -1823,6 +1832,9 @@ class BulkEditSerializer( method = attrs["method"] parameters = attrs["parameters"] + if "source_mode" in parameters: + self._validate_source_mode(parameters) + if method == bulk_edit.set_correspondent: self._validate_parameters_correspondent(parameters) elif method == bulk_edit.set_document_type: diff --git a/src/documents/tests/test_api_bulk_edit.py b/src/documents/tests/test_api_bulk_edit.py index 81d972bd4..6a465e19f 100644 --- a/src/documents/tests/test_api_bulk_edit.py +++ b/src/documents/tests/test_api_bulk_edit.py @@ -1395,7 +1395,10 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): { "documents": [self.doc2.id], "method": "edit_pdf", - "parameters": {"operations": [{"page": 1}]}, + "parameters": { + "operations": [{"page": 1}], + "source_mode": "explicit_selection", + }, }, ), content_type="application/json", @@ -1407,6 +1410,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): args, kwargs = m.call_args self.assertCountEqual(args[0], [self.doc2.id]) self.assertEqual(kwargs["operations"], [{"page": 1}]) + self.assertEqual(kwargs["source_mode"], "explicit_selection") self.assertEqual(kwargs["user"], self.user) def test_edit_pdf_invalid_params(self) -> None: @@ -1572,6 +1576,24 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): response.content, ) + # invalid source mode + response = self.client.post( + "/api/documents/bulk_edit/", + json.dumps( + { + "documents": [self.doc2.id], + "method": "edit_pdf", + "parameters": { + "operations": [{"page": 1}], + "source_mode": "not_a_mode", + }, + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn(b"Invalid source_mode", response.content) + @mock.patch("documents.serialisers.bulk_edit.edit_pdf") def test_edit_pdf_page_out_of_bounds(self, m) -> None: """ diff --git a/src/documents/tests/test_bulk_edit.py b/src/documents/tests/test_bulk_edit.py index 40e247412..9b6c3c468 100644 --- a/src/documents/tests/test_bulk_edit.py +++ b/src/documents/tests/test_bulk_edit.py @@ -405,7 +405,9 @@ class TestBulkEdit(DirectoriesMixin, TestCase): self.assertTrue(Document.objects.filter(id=self.doc1.id).exists()) self.assertFalse(Document.objects.filter(id=version.id).exists()) - def test_get_root_and_current_doc_mapping(self) -> None: + def test_resolve_root_and_source_doc_latest_version_prefers_newest_version( + self, + ) -> None: version1 = Document.objects.create( checksum="B-v1", title="B version 1", @@ -417,18 +419,14 @@ class TestBulkEdit(DirectoriesMixin, TestCase): root_document=self.doc2, ) - root_ids_by_doc_id = bulk_edit._get_root_ids_by_doc_id( - [self.doc2.id, version1.id, version2.id], + root_doc, source_doc = bulk_edit._resolve_root_and_source_doc( + self.doc2, + source_mode="latest_version", ) - self.assertEqual(root_ids_by_doc_id[self.doc2.id], self.doc2.id) - self.assertEqual(root_ids_by_doc_id[version1.id], self.doc2.id) - self.assertEqual(root_ids_by_doc_id[version2.id], self.doc2.id) - root_docs, current_docs = bulk_edit._get_root_and_current_docs_by_root_id( - {self.doc2.id}, - ) - self.assertEqual(root_docs[self.doc2.id].id, self.doc2.id) - self.assertEqual(current_docs[self.doc2.id].id, version2.id) + self.assertEqual(root_doc.id, self.doc2.id) + self.assertEqual(source_doc.id, version2.id) + self.assertNotEqual(source_doc.id, version1.id) @mock.patch("documents.tasks.bulk_update_documents.delay") def test_set_permissions(self, m) -> None: @@ -662,6 +660,33 @@ class TestPDFActions(DirectoriesMixin, TestCase): self.assertEqual(result, "OK") + @mock.patch("pikepdf.open") + @mock.patch("documents.tasks.consume_file.s") + def test_merge_uses_latest_version_source_for_root_selection( + self, + mock_consume_file, + mock_open_pdf, + ) -> None: + version_file = self.dirs.scratch_dir / "sample2_version_merge.pdf" + shutil.copy(self.doc2.source_path, version_file) + version = Document.objects.create( + checksum="B-v1", + title="B version 1", + root_document=self.doc2, + filename=version_file, + mime_type="application/pdf", + ) + fake_pdf = mock.MagicMock() + fake_pdf.pdf_version = "1.7" + fake_pdf.pages = [mock.Mock()] + mock_open_pdf.return_value.__enter__.return_value = fake_pdf + + result = bulk_edit.merge([self.doc2.id]) + + self.assertEqual(result, "OK") + mock_open_pdf.assert_called_once_with(str(version.source_path)) + mock_consume_file.assert_not_called() + @mock.patch("documents.bulk_edit.delete.si") @mock.patch("documents.tasks.consume_file.s") def test_merge_and_delete_originals( @@ -870,6 +895,36 @@ class TestPDFActions(DirectoriesMixin, TestCase): self.assertEqual(result, "OK") + @mock.patch("documents.bulk_edit.group") + @mock.patch("pikepdf.open") + @mock.patch("documents.tasks.consume_file.s") + def test_split_uses_latest_version_source_for_root_selection( + self, + mock_consume_file, + mock_open_pdf, + mock_group, + ) -> None: + version_file = self.dirs.scratch_dir / "sample2_version_split.pdf" + shutil.copy(self.doc2.source_path, version_file) + version = Document.objects.create( + checksum="B-v1", + title="B version 1", + root_document=self.doc2, + filename=version_file, + mime_type="application/pdf", + ) + fake_pdf = mock.MagicMock() + fake_pdf.pages = [mock.Mock(), mock.Mock()] + mock_open_pdf.return_value.__enter__.return_value = fake_pdf + mock_group.return_value.delay.return_value = None + + result = bulk_edit.split([self.doc2.id], [[1], [2]]) + + self.assertEqual(result, "OK") + mock_open_pdf.assert_called_once_with(version.source_path) + mock_consume_file.assert_not_called() + mock_group.return_value.delay.assert_not_called() + @mock.patch("documents.bulk_edit.delete.si") @mock.patch("documents.tasks.consume_file.s") @mock.patch("documents.bulk_edit.chord") @@ -1041,6 +1096,34 @@ class TestPDFActions(DirectoriesMixin, TestCase): self.assertIsNotNone(overrides) self.assertEqual(result, "OK") + @mock.patch("documents.data_models.magic.from_file", return_value="application/pdf") + @mock.patch("documents.tasks.consume_file.delay") + @mock.patch("pikepdf.open") + def test_rotate_explicit_selection_uses_root_source_when_root_selected( + self, + mock_open, + mock_consume_delay, + mock_magic, + ): + Document.objects.create( + checksum="B-v1", + title="B version 1", + root_document=self.doc2, + ) + fake_pdf = mock.MagicMock() + fake_pdf.pages = [mock.Mock()] + mock_open.return_value.__enter__.return_value = fake_pdf + + result = bulk_edit.rotate( + [self.doc2.id], + 90, + source_mode="explicit_selection", + ) + + self.assertEqual(result, "OK") + mock_open.assert_called_once_with(self.doc2.source_path) + mock_consume_delay.assert_called_once() + @mock.patch("documents.tasks.consume_file.delay") @mock.patch("pikepdf.Pdf.save") @mock.patch("documents.data_models.magic.from_file", return_value="application/pdf") @@ -1065,6 +1148,34 @@ class TestPDFActions(DirectoriesMixin, TestCase): self.assertIsNotNone(overrides) self.assertEqual(result, "OK") + @mock.patch("documents.data_models.magic.from_file", return_value="application/pdf") + @mock.patch("documents.tasks.consume_file.delay") + @mock.patch("pikepdf.open") + def test_delete_pages_explicit_selection_uses_root_source_when_root_selected( + self, + mock_open, + mock_consume_delay, + mock_magic, + ): + Document.objects.create( + checksum="B-v1", + title="B version 1", + root_document=self.doc2, + ) + fake_pdf = mock.MagicMock() + fake_pdf.pages = [mock.Mock(), mock.Mock()] + mock_open.return_value.__enter__.return_value = fake_pdf + + result = bulk_edit.delete_pages( + [self.doc2.id], + [1], + source_mode="explicit_selection", + ) + + self.assertEqual(result, "OK") + mock_open.assert_called_once_with(self.doc2.source_path) + mock_consume_delay.assert_called_once() + @mock.patch("documents.tasks.consume_file.delay") @mock.patch("pikepdf.Pdf.save") def test_delete_pages_with_error(self, mock_pdf_save, mock_consume_delay): @@ -1213,6 +1324,40 @@ class TestPDFActions(DirectoriesMixin, TestCase): self.assertTrue(str(consumable.original_file).endswith("_edited.pdf")) self.assertIsNotNone(overrides) + @mock.patch("documents.data_models.magic.from_file", return_value="application/pdf") + @mock.patch("documents.tasks.consume_file.delay") + @mock.patch("pikepdf.new") + @mock.patch("pikepdf.open") + def test_edit_pdf_explicit_selection_uses_root_source_when_root_selected( + self, + mock_open, + mock_new, + mock_consume_delay, + mock_magic, + ): + Document.objects.create( + checksum="B-v1", + title="B version 1", + root_document=self.doc2, + ) + fake_pdf = mock.MagicMock() + fake_pdf.pages = [mock.Mock()] + mock_open.return_value.__enter__.return_value = fake_pdf + output_pdf = mock.MagicMock() + output_pdf.pages = [] + mock_new.return_value = output_pdf + + result = bulk_edit.edit_pdf( + [self.doc2.id], + operations=[{"page": 1}], + update_document=True, + source_mode="explicit_selection", + ) + + self.assertEqual(result, "OK") + mock_open.assert_called_once_with(self.doc2.source_path) + mock_consume_delay.assert_called_once() + @mock.patch("documents.bulk_edit.group") @mock.patch("documents.tasks.consume_file.s") def test_edit_pdf_without_metadata( @@ -1333,6 +1478,34 @@ class TestPDFActions(DirectoriesMixin, TestCase): self.assertEqual(consumable.root_document_id, doc.id) self.assertIsNotNone(overrides) + @mock.patch("documents.data_models.magic.from_file", return_value="application/pdf") + @mock.patch("documents.tasks.consume_file.delay") + @mock.patch("pikepdf.open") + def test_remove_password_explicit_selection_uses_root_source_when_root_selected( + self, + mock_open, + mock_consume_delay, + mock_magic, + ) -> None: + Document.objects.create( + checksum="A-v1", + title="A version 1", + root_document=self.doc1, + ) + fake_pdf = mock.MagicMock() + mock_open.return_value.__enter__.return_value = fake_pdf + + result = bulk_edit.remove_password( + [self.doc1.id], + password="secret", + update_document=True, + source_mode="explicit_selection", + ) + + self.assertEqual(result, "OK") + mock_open.assert_called_once_with(self.doc1.source_path, password="secret") + mock_consume_delay.assert_called_once() + @mock.patch("documents.bulk_edit.chord") @mock.patch("documents.bulk_edit.group") @mock.patch("documents.tasks.consume_file.s")