mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-03-05 08:46:26 +00:00
Compare commits
5 Commits
dev
...
fix-versio
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01e2b41bd6 | ||
|
|
118ecf950a | ||
|
|
f8e91cc20e | ||
|
|
8ecbc7035b | ||
|
|
384980e0c5 |
@@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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]}",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user