Compare commits

..

5 Commits

Author SHA1 Message Date
shamoon
01e2b41bd6 Update bulk_edit.py 2026-03-04 15:59:13 -08:00
shamoon
118ecf950a This is all unused now 2026-03-04 15:59:12 -08:00
shamoon
f8e91cc20e Oh this needs to be here 2026-03-04 15:59:12 -08:00
shamoon
8ecbc7035b Use source mode to determine which docs bulk edit use 2026-03-04 15:59:12 -08:00
shamoon
384980e0c5 Add a source mode param, pass from frontend detail 2026-03-04 15:59:12 -08:00
9 changed files with 256 additions and 99 deletions

View File

@@ -3,6 +3,7 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { DocumentService } from 'src/app/services/rest/document.service'
import { PDFEditorComponent } from './pdf-editor.component' import { PDFEditorComponent } from './pdf-editor.component'
describe('PDFEditorComponent', () => { describe('PDFEditorComponent', () => {
@@ -139,4 +140,16 @@ describe('PDFEditorComponent', () => {
expect(component.pages[1].page).toBe(2) expect(component.pages[1].page).toBe(2)
expect(component.pages[2].page).toBe(3) 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)
})
}) })

View File

@@ -46,6 +46,7 @@ export class PDFEditorComponent extends ConfirmDialogComponent {
activeModal: NgbActiveModal = inject(NgbActiveModal) activeModal: NgbActiveModal = inject(NgbActiveModal)
documentID: number documentID: number
versionID?: number
pages: PageOperation[] = [] pages: PageOperation[] = []
totalPages = 0 totalPages = 0
editMode: PdfEditorEditMode = this.settingsService.get( editMode: PdfEditorEditMode = this.settingsService.get(
@@ -55,7 +56,11 @@ export class PDFEditorComponent extends ConfirmDialogComponent {
includeMetadata: boolean = true includeMetadata: boolean = true
get pdfSrc(): string { get pdfSrc(): string {
return this.documentService.getPreviewUrl(this.documentID) return this.documentService.getPreviewUrl(
this.documentID,
false,
this.versionID
)
} }
pdfLoaded(pdf: PngxPdfDocumentProxy) { pdfLoaded(pdf: PngxPdfDocumentProxy) {

View File

@@ -1661,22 +1661,25 @@ describe('DocumentDetailComponent', () => {
const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument') const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument')
const errorSpy = jest.spyOn(toastService, 'showError') const errorSpy = jest.spyOn(toastService, 'showError')
initNormally() initNormally()
component.selectedVersionId = 10
component.editPdf() component.editPdf()
expect(modal).not.toBeUndefined() expect(modal).not.toBeUndefined()
modal.componentInstance.documentID = doc.id modal.componentInstance.documentID = doc.id
expect(modal.componentInstance.versionID).toBe(10)
modal.componentInstance.pages = [{ page: 1, rotate: 0, splitAfter: false }] modal.componentInstance.pages = [{ page: 1, rotate: 0, splitAfter: false }]
modal.componentInstance.confirm() modal.componentInstance.confirm()
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/` `${environment.apiBaseUrl}documents/bulk_edit/`
) )
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: [doc.id], documents: [10],
method: 'edit_pdf', method: 'edit_pdf',
parameters: { parameters: {
operations: [{ page: 1, rotate: 0, doc: 0 }], operations: [{ page: 1, rotate: 0, doc: 0 }],
delete_original: false, delete_original: false,
update_document: false, update_document: false,
include_metadata: true, include_metadata: true,
source_mode: 'explicit_selection',
}, },
}) })
req.error(new ErrorEvent('failed')) req.error(new ErrorEvent('failed'))
@@ -1698,6 +1701,7 @@ describe('DocumentDetailComponent', () => {
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0])) modalService.activeInstances.subscribe((m) => (modal = m[0]))
initNormally() initNormally()
component.selectedVersionId = 10
component.password = 'secret' component.password = 'secret'
component.removePassword() component.removePassword()
const dialog = const dialog =
@@ -1710,13 +1714,14 @@ describe('DocumentDetailComponent', () => {
`${environment.apiBaseUrl}documents/bulk_edit/` `${environment.apiBaseUrl}documents/bulk_edit/`
) )
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: [doc.id], documents: [10],
method: 'remove_password', method: 'remove_password',
parameters: { parameters: {
password: 'secret', password: 'secret',
update_document: false, update_document: false,
include_metadata: false, include_metadata: false,
delete_original: true, delete_original: true,
source_mode: 'explicit_selection',
}, },
}) })
req.flush(true) req.flush(true)

View File

@@ -74,7 +74,10 @@ import {
import { CorrespondentService } from 'src/app/services/rest/correspondent.service' import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { DocumentTypeService } from 'src/app/services/rest/document-type.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 { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { TagService } from 'src/app/services/rest/tag.service' import { TagService } from 'src/app/services/rest/tag.service'
@@ -1753,20 +1756,23 @@ export class DocumentDetailComponent
size: 'xl', size: 'xl',
scrollable: true, scrollable: true,
}) })
const sourceDocumentId = this.selectedVersionId ?? this.document.id
modal.componentInstance.title = $localize`PDF Editor` modal.componentInstance.title = $localize`PDF Editor`
modal.componentInstance.btnCaption = $localize`Proceed` modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.documentID = this.document.id modal.componentInstance.documentID = this.document.id
modal.componentInstance.versionID = sourceDocumentId
modal.componentInstance.confirmClicked modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => { .subscribe(() => {
modal.componentInstance.buttonsEnabled = false modal.componentInstance.buttonsEnabled = false
this.documentsService this.documentsService
.bulkEdit([this.document.id], 'edit_pdf', { .bulkEdit([sourceDocumentId], 'edit_pdf', {
operations: modal.componentInstance.getOperations(), operations: modal.componentInstance.getOperations(),
delete_original: modal.componentInstance.deleteOriginal, delete_original: modal.componentInstance.deleteOriginal,
update_document: update_document:
modal.componentInstance.editMode == PdfEditorEditMode.Update, modal.componentInstance.editMode == PdfEditorEditMode.Update,
include_metadata: modal.componentInstance.includeMetadata, include_metadata: modal.componentInstance.includeMetadata,
source_mode: BulkEditSourceMode.EXPLICIT_SELECTION,
}) })
.pipe(first(), takeUntil(this.unsubscribeNotifier)) .pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
@@ -1812,16 +1818,18 @@ export class DocumentDetailComponent
modal.componentInstance.confirmClicked modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => { .subscribe(() => {
const sourceDocumentId = this.selectedVersionId ?? this.document.id
const dialog = const dialog =
modal.componentInstance as PasswordRemovalConfirmDialogComponent modal.componentInstance as PasswordRemovalConfirmDialogComponent
dialog.buttonsEnabled = false dialog.buttonsEnabled = false
this.networkActive = true this.networkActive = true
this.documentsService this.documentsService
.bulkEdit([this.document.id], 'remove_password', { .bulkEdit([sourceDocumentId], 'remove_password', {
password: this.password, password: this.password,
update_document: dialog.updateDocument, update_document: dialog.updateDocument,
include_metadata: dialog.includeMetadata, include_metadata: dialog.includeMetadata,
delete_original: dialog.deleteOriginal, delete_original: dialog.deleteOriginal,
source_mode: BulkEditSourceMode.EXPLICIT_SELECTION,
}) })
.pipe(first(), takeUntil(this.unsubscribeNotifier)) .pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({

View File

@@ -37,6 +37,11 @@ export interface SelectionData {
selected_custom_fields: SelectionDataItem[] selected_custom_fields: SelectionDataItem[]
} }
export enum BulkEditSourceMode {
LATEST_VERSION = 'latest_version',
EXPLICIT_SELECTION = 'explicit_selection',
}
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })

View File

@@ -29,12 +29,21 @@ from documents.plugins.helpers import DocumentsStatusManager
from documents.tasks import bulk_update_documents from documents.tasks import bulk_update_documents
from documents.tasks import consume_file from documents.tasks import consume_file
from documents.tasks import update_document_content_maybe_archive_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: if TYPE_CHECKING:
from django.contrib.auth.models import User from django.contrib.auth.models import User
logger: logging.Logger = logging.getLogger("paperless.bulk_edit") 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) @shared_task(bind=True)
def restore_archive_serial_numbers_task( 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())}") 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]: def _resolve_root_and_source_doc(
""" doc: Document,
Resolve each provided document id to its root document id. *,
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 source_mode == SourceModeChoices.EXPLICIT_SELECTION:
- If the id is a version document: root id is its `root_document_id`. return root_doc, doc
"""
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}
# 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( return root_doc, get_latest_version_for_root(root_doc)
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
def set_correspondent( def set_correspondent(
@@ -421,21 +405,32 @@ def rotate(
doc_ids: list[int], doc_ids: list[int],
degrees: int, degrees: int,
*, *,
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
user: User | None = None, user: User | None = None,
) -> Literal["OK"]: ) -> Literal["OK"]:
logger.info( logger.info(
f"Attempting to rotate {len(doc_ids)} documents by {degrees} degrees.", f"Attempting to rotate {len(doc_ids)} documents by {degrees} degrees.",
) )
doc_to_root_id = _get_root_ids_by_doc_id(doc_ids) docs_by_id = {
root_ids = set(doc_to_root_id.values()) doc.id: doc
root_docs_by_id, current_docs_by_root_id = _get_root_and_current_docs_by_root_id( for doc in Document.objects.select_related("root_document").filter(
root_ids, 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 import pikepdf
for root_id in root_ids: for root_doc, source_doc in docs_by_root_id.values():
root_doc = root_docs_by_id[root_id]
source_doc = current_docs_by_root_id[root_id]
if source_doc.mime_type != "application/pdf": if source_doc.mime_type != "application/pdf":
logger.warning( logger.warning(
f"Document {root_doc.id} is not a PDF, skipping rotation.", f"Document {root_doc.id} is not a PDF, skipping rotation.",
@@ -659,25 +654,17 @@ def delete_pages(
doc_ids: list[int], doc_ids: list[int],
pages: list[int], pages: list[int],
*, *,
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
user: User | None = None, user: User | None = None,
) -> Literal["OK"]: ) -> Literal["OK"]:
logger.info( logger.info(
f"Attempting to delete pages {pages} from {len(doc_ids)} documents", f"Attempting to delete pages {pages} from {len(doc_ids)} documents",
) )
doc = Document.objects.select_related("root_document").get(id=doc_ids[0]) doc = Document.objects.select_related("root_document").get(id=doc_ids[0])
root_doc: Document root_doc, source_doc = _resolve_root_and_source_doc(
if doc.root_document_id is None or doc.root_document is None: doc,
root_doc = doc source_mode=source_mode,
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()
) )
if source_doc is None:
source_doc = root_doc
pages = sorted(pages) # sort pages to avoid index issues pages = sorted(pages) # sort pages to avoid index issues
import pikepdf import pikepdf
@@ -722,6 +709,7 @@ def edit_pdf(
delete_original: bool = False, delete_original: bool = False,
update_document: bool = False, update_document: bool = False,
include_metadata: bool = True, include_metadata: bool = True,
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
user: User | None = None, user: User | None = None,
) -> Literal["OK"]: ) -> Literal["OK"]:
""" """
@@ -736,19 +724,10 @@ def edit_pdf(
f"Editing PDF of document {doc_ids[0]} with {len(operations)} operations", f"Editing PDF of document {doc_ids[0]} with {len(operations)} operations",
) )
doc = Document.objects.select_related("root_document").get(id=doc_ids[0]) doc = Document.objects.select_related("root_document").get(id=doc_ids[0])
root_doc: Document root_doc, source_doc = _resolve_root_and_source_doc(
if doc.root_document_id is None or doc.root_document is None: doc,
root_doc = doc source_mode=source_mode,
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()
) )
if source_doc is None:
source_doc = root_doc
import pikepdf import pikepdf
pdf_docs: list[pikepdf.Pdf] = [] pdf_docs: list[pikepdf.Pdf] = []
@@ -859,6 +838,7 @@ def remove_password(
update_document: bool = False, update_document: bool = False,
delete_original: bool = False, delete_original: bool = False,
include_metadata: bool = True, include_metadata: bool = True,
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
user: User | None = None, user: User | None = None,
) -> Literal["OK"]: ) -> Literal["OK"]:
""" """
@@ -868,19 +848,10 @@ def remove_password(
for doc_id in doc_ids: for doc_id in doc_ids:
doc = Document.objects.select_related("root_document").get(id=doc_id) doc = Document.objects.select_related("root_document").get(id=doc_id)
root_doc: Document root_doc, source_doc = _resolve_root_and_source_doc(
if doc.root_document_id is None or doc.root_document is None: doc,
root_doc = doc source_mode=source_mode,
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()
) )
if source_doc is None:
source_doc = root_doc
try: try:
logger.info( logger.info(
f"Attempting password removal from document {doc_ids[0]}", f"Attempting password removal from document {doc_ids[0]}",

View File

@@ -1723,6 +1723,15 @@ class BulkEditSerializer(
except ValueError: except ValueError:
raise serializers.ValidationError("invalid rotation degrees") 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: def _validate_parameters_split(self, parameters) -> None:
if "pages" not in parameters: if "pages" not in parameters:
raise serializers.ValidationError("pages not specified") raise serializers.ValidationError("pages not specified")
@@ -1823,6 +1832,9 @@ class BulkEditSerializer(
method = attrs["method"] method = attrs["method"]
parameters = attrs["parameters"] parameters = attrs["parameters"]
if "source_mode" in parameters:
self._validate_source_mode(parameters)
if method == bulk_edit.set_correspondent: if method == bulk_edit.set_correspondent:
self._validate_parameters_correspondent(parameters) self._validate_parameters_correspondent(parameters)
elif method == bulk_edit.set_document_type: elif method == bulk_edit.set_document_type:

View File

@@ -1395,7 +1395,10 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
{ {
"documents": [self.doc2.id], "documents": [self.doc2.id],
"method": "edit_pdf", "method": "edit_pdf",
"parameters": {"operations": [{"page": 1}]}, "parameters": {
"operations": [{"page": 1}],
"source_mode": "explicit_selection",
},
}, },
), ),
content_type="application/json", content_type="application/json",
@@ -1407,6 +1410,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
args, kwargs = m.call_args args, kwargs = m.call_args
self.assertCountEqual(args[0], [self.doc2.id]) self.assertCountEqual(args[0], [self.doc2.id])
self.assertEqual(kwargs["operations"], [{"page": 1}]) self.assertEqual(kwargs["operations"], [{"page": 1}])
self.assertEqual(kwargs["source_mode"], "explicit_selection")
self.assertEqual(kwargs["user"], self.user) self.assertEqual(kwargs["user"], self.user)
def test_edit_pdf_invalid_params(self) -> None: def test_edit_pdf_invalid_params(self) -> None:
@@ -1572,6 +1576,24 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
response.content, 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") @mock.patch("documents.serialisers.bulk_edit.edit_pdf")
def test_edit_pdf_page_out_of_bounds(self, m) -> None: def test_edit_pdf_page_out_of_bounds(self, m) -> None:
""" """

View File

@@ -405,7 +405,9 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
self.assertTrue(Document.objects.filter(id=self.doc1.id).exists()) self.assertTrue(Document.objects.filter(id=self.doc1.id).exists())
self.assertFalse(Document.objects.filter(id=version.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( version1 = Document.objects.create(
checksum="B-v1", checksum="B-v1",
title="B version 1", title="B version 1",
@@ -417,18 +419,14 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
root_document=self.doc2, root_document=self.doc2,
) )
root_ids_by_doc_id = bulk_edit._get_root_ids_by_doc_id( root_doc, source_doc = bulk_edit._resolve_root_and_source_doc(
[self.doc2.id, version1.id, version2.id], 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.assertEqual(root_doc.id, self.doc2.id)
{self.doc2.id}, self.assertEqual(source_doc.id, version2.id)
) self.assertNotEqual(source_doc.id, version1.id)
self.assertEqual(root_docs[self.doc2.id].id, self.doc2.id)
self.assertEqual(current_docs[self.doc2.id].id, version2.id)
@mock.patch("documents.tasks.bulk_update_documents.delay") @mock.patch("documents.tasks.bulk_update_documents.delay")
def test_set_permissions(self, m) -> None: def test_set_permissions(self, m) -> None:
@@ -1041,6 +1039,34 @@ class TestPDFActions(DirectoriesMixin, TestCase):
self.assertIsNotNone(overrides) self.assertIsNotNone(overrides)
self.assertEqual(result, "OK") 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("documents.tasks.consume_file.delay")
@mock.patch("pikepdf.Pdf.save") @mock.patch("pikepdf.Pdf.save")
@mock.patch("documents.data_models.magic.from_file", return_value="application/pdf") @mock.patch("documents.data_models.magic.from_file", return_value="application/pdf")
@@ -1065,6 +1091,34 @@ class TestPDFActions(DirectoriesMixin, TestCase):
self.assertIsNotNone(overrides) self.assertIsNotNone(overrides)
self.assertEqual(result, "OK") 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("documents.tasks.consume_file.delay")
@mock.patch("pikepdf.Pdf.save") @mock.patch("pikepdf.Pdf.save")
def test_delete_pages_with_error(self, mock_pdf_save, mock_consume_delay): def test_delete_pages_with_error(self, mock_pdf_save, mock_consume_delay):
@@ -1213,6 +1267,40 @@ class TestPDFActions(DirectoriesMixin, TestCase):
self.assertTrue(str(consumable.original_file).endswith("_edited.pdf")) self.assertTrue(str(consumable.original_file).endswith("_edited.pdf"))
self.assertIsNotNone(overrides) 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.bulk_edit.group")
@mock.patch("documents.tasks.consume_file.s") @mock.patch("documents.tasks.consume_file.s")
def test_edit_pdf_without_metadata( def test_edit_pdf_without_metadata(
@@ -1333,6 +1421,34 @@ class TestPDFActions(DirectoriesMixin, TestCase):
self.assertEqual(consumable.root_document_id, doc.id) self.assertEqual(consumable.root_document_id, doc.id)
self.assertIsNotNone(overrides) 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.chord")
@mock.patch("documents.bulk_edit.group") @mock.patch("documents.bulk_edit.group")
@mock.patch("documents.tasks.consume_file.s") @mock.patch("documents.tasks.consume_file.s")