From 86573fc1a05a1ab7b1813c6af9529dff3c3ec584 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:55:36 -0700 Subject: [PATCH] Chore: separate actions from bulk edit endpoint (#12286) --- docs/api.md | 51 +- .../document-detail.component.spec.ts | 48 +- .../document-detail.component.ts | 44 +- .../bulk-editor/bulk-editor.component.spec.ts | 33 +- .../bulk-editor/bulk-editor.component.ts | 140 ++-- .../services/rest/document.service.spec.ts | 82 +++ .../src/app/services/rest/document.service.ts | 89 ++- src/documents/serialisers.py | 129 +++- src/documents/tests/test_api_bulk_edit.py | 695 ++++++++---------- src/documents/tests/test_api_schema.py | 36 + src/documents/views.py | 389 ++++++++-- src/paperless/urls.py | 36 + 12 files changed, 1119 insertions(+), 653 deletions(-) diff --git a/docs/api.md b/docs/api.md index 9703e8eb7..334b1a75a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -305,52 +305,16 @@ The following methods are supported: - `"merge": true or false` (defaults to false) - The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including removing them) or be merged with existing permissions. -- `edit_pdf` - - Requires `parameters`: - - `"doc_ids": [DOCUMENT_ID]` A list of a single document ID to edit. - - `"operations": [OPERATION, ...]` A list of operations to perform on the documents. Each operation is a dictionary - with the following keys: - - `"page": PAGE_NUMBER` The page number to edit (1-based). - - `"rotate": DEGREES` Optional rotation in degrees (90, 180, 270). - - `"doc": OUTPUT_DOCUMENT_INDEX` Optional index of the output document for split operations. - - Optional `parameters`: - - `"delete_original": true` to delete the original documents after editing. - - `"update_document": true` to add the edited PDF as a new version of the root document. - - `"include_metadata": true` to copy metadata from the original document to the edited document. -- `remove_password` - - Requires `parameters`: - - `"password": "PASSWORD_STRING"` The password to remove from the PDF documents. - - Optional `parameters`: - - `"update_document": true` to add the password-less PDF as a new version of the root document. - - `"delete_original": true` to delete the original document after editing. - - `"include_metadata": true` to copy metadata from the original document to the new password-less document. -- `merge` - - No additional `parameters` required. - - The ordering of the merged document is determined by the list of IDs. - - Optional `parameters`: - - `"metadata_document_id": DOC_ID` apply metadata (tags, correspondent, etc.) from this document to the merged document. - - `"delete_originals": true` to delete the original documents. This requires the calling user being the owner of - all documents that are merged. -- `split` - - Requires `parameters`: - - `"pages": [..]` The list should be a list of pages and/or a ranges, separated by commas e.g. `"[1,2-3,4,5-7]"` - - Optional `parameters`: - - `"delete_originals": true` to delete the original document after consumption. This requires the calling user being the owner of - the document. - - The split operation only accepts a single document. -- `rotate` - - Requires `parameters`: - - `"degrees": DEGREES`. Must be an integer i.e. 90, 180, 270 -- `delete_pages` - - Requires `parameters`: - - `"pages": [..]` The list should be a list of integers e.g. `"[2,3,4]"` - - The delete_pages operation only accepts a single document. - `modify_custom_fields` - Requires `parameters`: - `"add_custom_fields": { CUSTOM_FIELD_ID: VALUE }`: JSON object consisting of custom field id:value pairs to add to the document, can also be a list of custom field IDs to add with empty values. - `"remove_custom_fields": [CUSTOM_FIELD_ID]`: custom field ids to remove from the document. +#### Document-editing operations + +Beginning with version 10+, the API supports individual endpoints for document-editing operations (`merge`, `rotate`, `edit_pdf`, etc), thus their documentation can be found in the API spec / viewer. Legacy document-editing methods via `/api/documents/bulk_edit/` are still supported for compatibility, are deprecated and clients should migrate to the individual endpoints before they are removed in a future version. + ### Objects Bulk editing for objects (tags, document types etc.) currently supports set permissions or delete @@ -467,4 +431,9 @@ Initial API version. #### Version 10 - The `show_on_dashboard` and `show_in_sidebar` fields of saved views have been - removed. Relevant settings are now stored in the UISettings model. + removed. Relevant settings are now stored in the UISettings model. Compatibility is maintained + for versions < 10 until support for API v9 is dropped. +- Document-editing operations such as `merge`, `rotate`, and `edit_pdf` have been + moved from the bulk edit endpoint to their own individual endpoints. Using these methods via + the bulk edit endpoint is still supported for compatibility with versions < 10 until support + for API v9 is dropped. 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 990c6b4d0..753dc7ca8 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 @@ -950,8 +950,8 @@ describe('DocumentDetailComponent', () => { it('should support reprocess, confirm and close modal after started', () => { initNormally() - const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit') - bulkEditSpy.mockReturnValue(of(true)) + const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments') + reprocessSpy.mockReturnValue(of(true)) let openModal: NgbModalRef modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) const modalSpy = jest.spyOn(modalService, 'open') @@ -959,7 +959,7 @@ describe('DocumentDetailComponent', () => { component.reprocess() const modalCloseSpy = jest.spyOn(openModal, 'close') openModal.componentInstance.confirmClicked.next() - expect(bulkEditSpy).toHaveBeenCalledWith([doc.id], 'reprocess', {}) + expect(reprocessSpy).toHaveBeenCalledWith([doc.id]) expect(modalSpy).toHaveBeenCalled() expect(toastSpy).toHaveBeenCalled() expect(modalCloseSpy).toHaveBeenCalled() @@ -967,13 +967,13 @@ describe('DocumentDetailComponent', () => { it('should show error if redo ocr call fails', () => { initNormally() - const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit') + const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments') let openModal: NgbModalRef modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) const toastSpy = jest.spyOn(toastService, 'showError') component.reprocess() const modalCloseSpy = jest.spyOn(openModal, 'close') - bulkEditSpy.mockReturnValue(throwError(() => new Error('error occurred'))) + reprocessSpy.mockReturnValue(throwError(() => new Error('error occurred'))) openModal.componentInstance.confirmClicked.next() expect(toastSpy).toHaveBeenCalled() expect(modalCloseSpy).not.toHaveBeenCalled() @@ -1669,18 +1669,15 @@ describe('DocumentDetailComponent', () => { modal.componentInstance.pages = [{ page: 1, rotate: 0, splitAfter: false }] modal.componentInstance.confirm() let req = httpTestingController.expectOne( - `${environment.apiBaseUrl}documents/bulk_edit/` + `${environment.apiBaseUrl}documents/edit_pdf/` ) expect(req.request.body).toEqual({ 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', - }, + 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')) expect(errorSpy).toHaveBeenCalled() @@ -1691,7 +1688,7 @@ describe('DocumentDetailComponent', () => { modal.componentInstance.deleteOriginal = true modal.componentInstance.confirm() req = httpTestingController.expectOne( - `${environment.apiBaseUrl}documents/bulk_edit/` + `${environment.apiBaseUrl}documents/edit_pdf/` ) req.flush(true) expect(closeSpy).toHaveBeenCalled() @@ -1711,18 +1708,15 @@ describe('DocumentDetailComponent', () => { dialog.deleteOriginal = true dialog.confirm() const req = httpTestingController.expectOne( - `${environment.apiBaseUrl}documents/bulk_edit/` + `${environment.apiBaseUrl}documents/remove_password/` ) expect(req.request.body).toEqual({ documents: [10], - method: 'remove_password', - parameters: { - password: 'secret', - update_document: false, - include_metadata: false, - delete_original: true, - source_mode: 'explicit_selection', - }, + password: 'secret', + update_document: false, + include_metadata: false, + delete_original: true, + source_mode: 'explicit_selection', }) req.flush(true) }) @@ -1737,7 +1731,7 @@ describe('DocumentDetailComponent', () => { expect(errorSpy).toHaveBeenCalled() httpTestingController.expectNone( - `${environment.apiBaseUrl}documents/bulk_edit/` + `${environment.apiBaseUrl}documents/remove_password/` ) }) @@ -1753,7 +1747,7 @@ describe('DocumentDetailComponent', () => { modal.componentInstance as PasswordRemovalConfirmDialogComponent dialog.confirm() const req = httpTestingController.expectOne( - `${environment.apiBaseUrl}documents/bulk_edit/` + `${environment.apiBaseUrl}documents/remove_password/` ) req.error(new ErrorEvent('failed')) @@ -1774,7 +1768,7 @@ describe('DocumentDetailComponent', () => { modal.componentInstance as PasswordRemovalConfirmDialogComponent dialog.confirm() const req = httpTestingController.expectOne( - `${environment.apiBaseUrl}documents/bulk_edit/` + `${environment.apiBaseUrl}documents/remove_password/` ) 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 a2c193770..25c854e53 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 @@ -1379,27 +1379,25 @@ export class DocumentDetailComponent modal.componentInstance.btnCaption = $localize`Proceed` modal.componentInstance.confirmClicked.subscribe(() => { modal.componentInstance.buttonsEnabled = false - this.documentsService - .bulkEdit([this.document.id], 'reprocess', {}) - .subscribe({ - next: () => { - this.toastService.showInfo( - $localize`Reprocess operation for "${this.document.title}" will begin in the background.` - ) - if (modal) { - modal.close() - } - }, - error: (error) => { - if (modal) { - modal.componentInstance.buttonsEnabled = true - } - this.toastService.showError( - $localize`Error executing operation`, - error - ) - }, - }) + this.documentsService.reprocessDocuments([this.document.id]).subscribe({ + next: () => { + this.toastService.showInfo( + $localize`Reprocess operation for "${this.document.title}" will begin in the background.` + ) + if (modal) { + modal.close() + } + }, + error: (error) => { + if (modal) { + modal.componentInstance.buttonsEnabled = true + } + this.toastService.showError( + $localize`Error executing operation`, + error + ) + }, + }) }) } @@ -1766,7 +1764,7 @@ export class DocumentDetailComponent .subscribe(() => { modal.componentInstance.buttonsEnabled = false this.documentsService - .bulkEdit([sourceDocumentId], 'edit_pdf', { + .editPdfDocuments([sourceDocumentId], { operations: modal.componentInstance.getOperations(), delete_original: modal.componentInstance.deleteOriginal, update_document: @@ -1824,7 +1822,7 @@ export class DocumentDetailComponent dialog.buttonsEnabled = false this.networkActive = true this.documentsService - .bulkEdit([sourceDocumentId], 'remove_password', { + .removePasswordDocuments([sourceDocumentId], { password: this.password, update_document: dialog.updateDocument, include_metadata: dialog.includeMetadata, diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts index 694899d3d..893dae97f 100644 --- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts @@ -849,13 +849,11 @@ describe('BulkEditorComponent', () => { expect(modal).not.toBeUndefined() modal.componentInstance.confirm() let req = httpTestingController.expectOne( - `${environment.apiBaseUrl}documents/bulk_edit/` + `${environment.apiBaseUrl}documents/delete/` ) req.flush(true) expect(req.request.body).toEqual({ documents: [3, 4], - method: 'delete', - parameters: {}, }) httpTestingController.match( `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` @@ -868,7 +866,7 @@ describe('BulkEditorComponent', () => { fixture.detectChanges() component.applyDelete() req = httpTestingController.expectOne( - `${environment.apiBaseUrl}documents/bulk_edit/` + `${environment.apiBaseUrl}documents/delete/` ) }) @@ -944,13 +942,11 @@ describe('BulkEditorComponent', () => { expect(modal).not.toBeUndefined() modal.componentInstance.confirm() let req = httpTestingController.expectOne( - `${environment.apiBaseUrl}documents/bulk_edit/` + `${environment.apiBaseUrl}documents/reprocess/` ) req.flush(true) expect(req.request.body).toEqual({ documents: [3, 4], - method: 'reprocess', - parameters: {}, }) httpTestingController.match( `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` @@ -979,13 +975,13 @@ describe('BulkEditorComponent', () => { modal.componentInstance.rotate() modal.componentInstance.confirm() let req = httpTestingController.expectOne( - `${environment.apiBaseUrl}documents/bulk_edit/` + `${environment.apiBaseUrl}documents/rotate/` ) req.flush(true) expect(req.request.body).toEqual({ documents: [3, 4], - method: 'rotate', - parameters: { degrees: 90 }, + degrees: 90, + source_mode: 'latest_version', }) httpTestingController.match( `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` @@ -1021,13 +1017,12 @@ describe('BulkEditorComponent', () => { modal.componentInstance.metadataDocumentID = 3 modal.componentInstance.confirm() let req = httpTestingController.expectOne( - `${environment.apiBaseUrl}documents/bulk_edit/` + `${environment.apiBaseUrl}documents/merge/` ) req.flush(true) expect(req.request.body).toEqual({ documents: [3, 4], - method: 'merge', - parameters: { metadata_document_id: 3 }, + metadata_document_id: 3, }) httpTestingController.match( `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` @@ -1040,13 +1035,13 @@ describe('BulkEditorComponent', () => { modal.componentInstance.deleteOriginals = true modal.componentInstance.confirm() req = httpTestingController.expectOne( - `${environment.apiBaseUrl}documents/bulk_edit/` + `${environment.apiBaseUrl}documents/merge/` ) req.flush(true) expect(req.request.body).toEqual({ documents: [3, 4], - method: 'merge', - parameters: { metadata_document_id: 3, delete_originals: true }, + metadata_document_id: 3, + delete_originals: true, }) httpTestingController.match( `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` @@ -1061,13 +1056,13 @@ describe('BulkEditorComponent', () => { modal.componentInstance.archiveFallback = true modal.componentInstance.confirm() req = httpTestingController.expectOne( - `${environment.apiBaseUrl}documents/bulk_edit/` + `${environment.apiBaseUrl}documents/merge/` ) req.flush(true) expect(req.request.body).toEqual({ documents: [3, 4], - method: 'merge', - parameters: { metadata_document_id: 3, archive_fallback: true }, + metadata_document_id: 3, + archive_fallback: true, }) httpTestingController.match( `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts index 8e97b5f9e..c23cd6c5f 100644 --- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -12,7 +12,7 @@ import { } from '@ng-bootstrap/ng-bootstrap' import { saveAs } from 'file-saver' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' -import { first, map, Subject, switchMap, takeUntil } from 'rxjs' +import { first, map, Observable, Subject, switchMap, takeUntil } from 'rxjs' import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component' import { CustomField } from 'src/app/data/custom-field' import { MatchingModel } from 'src/app/data/matching-model' @@ -29,7 +29,9 @@ import { CorrespondentService } from 'src/app/services/rest/correspondent.servic import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' import { DocumentTypeService } from 'src/app/services/rest/document-type.service' import { + DocumentBulkEditMethod, DocumentService, + MergeDocumentsRequest, SelectionDataItem, } from 'src/app/services/rest/document.service' import { SavedViewService } from 'src/app/services/rest/saved-view.service' @@ -255,9 +257,9 @@ export class BulkEditorComponent this.unsubscribeNotifier.complete() } - private executeBulkOperation( + private executeBulkEditMethod( modal: NgbModalRef, - method: string, + method: DocumentBulkEditMethod, args: any, overrideDocumentIDs?: number[] ) { @@ -272,32 +274,55 @@ export class BulkEditorComponent ) .pipe(first()) .subscribe({ - next: () => { - if (args['delete_originals']) { - this.list.selected.clear() - } - this.list.reload() - this.list.reduceSelectionToFilter() - this.list.selected.forEach((id) => { - this.openDocumentService.refreshDocument(id) - }) - this.savedViewService.maybeRefreshDocumentCounts() - if (modal) { - modal.close() - } - }, - error: (error) => { - if (modal) { - modal.componentInstance.buttonsEnabled = true - } - this.toastService.showError( - $localize`Error executing bulk operation`, - error - ) - }, + next: () => this.handleOperationSuccess(modal), + error: (error) => this.handleOperationError(modal, error), }) } + private executeDocumentAction( + modal: NgbModalRef, + request: Observable, + options: { deleteOriginals?: boolean } = {} + ) { + if (modal) { + modal.componentInstance.buttonsEnabled = false + } + request.pipe(first()).subscribe({ + next: () => { + this.handleOperationSuccess(modal, options.deleteOriginals ?? false) + }, + error: (error) => this.handleOperationError(modal, error), + }) + } + + private handleOperationSuccess( + modal: NgbModalRef, + clearSelection: boolean = false + ) { + if (clearSelection) { + this.list.selected.clear() + } + this.list.reload() + this.list.reduceSelectionToFilter() + this.list.selected.forEach((id) => { + this.openDocumentService.refreshDocument(id) + }) + this.savedViewService.maybeRefreshDocumentCounts() + if (modal) { + modal.close() + } + } + + private handleOperationError(modal: NgbModalRef, error: any) { + if (modal) { + modal.componentInstance.buttonsEnabled = true + } + this.toastService.showError( + $localize`Error executing bulk operation`, + error + ) + } + private applySelectionData( items: SelectionDataItem[], selectionModel: FilterableDropdownSelectionModel @@ -446,13 +471,13 @@ export class BulkEditorComponent modal.componentInstance.confirmClicked .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe(() => { - this.executeBulkOperation(modal, 'modify_tags', { + this.executeBulkEditMethod(modal, 'modify_tags', { add_tags: changedTags.itemsToAdd.map((t) => t.id), remove_tags: changedTags.itemsToRemove.map((t) => t.id), }) }) } else { - this.executeBulkOperation(null, 'modify_tags', { + this.executeBulkEditMethod(null, 'modify_tags', { add_tags: changedTags.itemsToAdd.map((t) => t.id), remove_tags: changedTags.itemsToRemove.map((t) => t.id), }) @@ -486,12 +511,12 @@ export class BulkEditorComponent modal.componentInstance.confirmClicked .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe(() => { - this.executeBulkOperation(modal, 'set_correspondent', { + this.executeBulkEditMethod(modal, 'set_correspondent', { correspondent: correspondent ? correspondent.id : null, }) }) } else { - this.executeBulkOperation(null, 'set_correspondent', { + this.executeBulkEditMethod(null, 'set_correspondent', { correspondent: correspondent ? correspondent.id : null, }) } @@ -524,12 +549,12 @@ export class BulkEditorComponent modal.componentInstance.confirmClicked .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe(() => { - this.executeBulkOperation(modal, 'set_document_type', { + this.executeBulkEditMethod(modal, 'set_document_type', { document_type: documentType ? documentType.id : null, }) }) } else { - this.executeBulkOperation(null, 'set_document_type', { + this.executeBulkEditMethod(null, 'set_document_type', { document_type: documentType ? documentType.id : null, }) } @@ -562,12 +587,12 @@ export class BulkEditorComponent modal.componentInstance.confirmClicked .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe(() => { - this.executeBulkOperation(modal, 'set_storage_path', { + this.executeBulkEditMethod(modal, 'set_storage_path', { storage_path: storagePath ? storagePath.id : null, }) }) } else { - this.executeBulkOperation(null, 'set_storage_path', { + this.executeBulkEditMethod(null, 'set_storage_path', { storage_path: storagePath ? storagePath.id : null, }) } @@ -624,7 +649,7 @@ export class BulkEditorComponent modal.componentInstance.confirmClicked .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe(() => { - this.executeBulkOperation(modal, 'modify_custom_fields', { + this.executeBulkEditMethod(modal, 'modify_custom_fields', { add_custom_fields: changedCustomFields.itemsToAdd.map((f) => f.id), remove_custom_fields: changedCustomFields.itemsToRemove.map( (f) => f.id @@ -632,7 +657,7 @@ export class BulkEditorComponent }) }) } else { - this.executeBulkOperation(null, 'modify_custom_fields', { + this.executeBulkEditMethod(null, 'modify_custom_fields', { add_custom_fields: changedCustomFields.itemsToAdd.map((f) => f.id), remove_custom_fields: changedCustomFields.itemsToRemove.map( (f) => f.id @@ -762,10 +787,16 @@ export class BulkEditorComponent .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe(() => { modal.componentInstance.buttonsEnabled = false - this.executeBulkOperation(modal, 'delete', {}) + this.executeDocumentAction( + modal, + this.documentService.deleteDocuments(Array.from(this.list.selected)) + ) }) } else { - this.executeBulkOperation(null, 'delete', {}) + this.executeDocumentAction( + null, + this.documentService.deleteDocuments(Array.from(this.list.selected)) + ) } } @@ -804,7 +835,12 @@ export class BulkEditorComponent .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe(() => { modal.componentInstance.buttonsEnabled = false - this.executeBulkOperation(modal, 'reprocess', {}) + this.executeDocumentAction( + modal, + this.documentService.reprocessDocuments( + Array.from(this.list.selected) + ) + ) }) } @@ -815,7 +851,7 @@ export class BulkEditorComponent modal.componentInstance.confirmClicked.subscribe( ({ permissions, merge }) => { modal.componentInstance.buttonsEnabled = false - this.executeBulkOperation(modal, 'set_permissions', { + this.executeBulkEditMethod(modal, 'set_permissions', { ...permissions, merge, }) @@ -838,9 +874,13 @@ export class BulkEditorComponent .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe(() => { rotateDialog.buttonsEnabled = false - this.executeBulkOperation(modal, 'rotate', { - degrees: rotateDialog.degrees, - }) + this.executeDocumentAction( + modal, + this.documentService.rotateDocuments( + Array.from(this.list.selected), + rotateDialog.degrees + ) + ) }) } @@ -856,18 +896,22 @@ export class BulkEditorComponent mergeDialog.confirmClicked .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe(() => { - const args = {} + const args: MergeDocumentsRequest = {} if (mergeDialog.metadataDocumentID > -1) { - args['metadata_document_id'] = mergeDialog.metadataDocumentID + args.metadata_document_id = mergeDialog.metadataDocumentID } if (mergeDialog.deleteOriginals) { - args['delete_originals'] = true + args.delete_originals = true } if (mergeDialog.archiveFallback) { - args['archive_fallback'] = true + args.archive_fallback = true } mergeDialog.buttonsEnabled = false - this.executeBulkOperation(modal, 'merge', args, mergeDialog.documentIDs) + this.executeDocumentAction( + modal, + this.documentService.mergeDocuments(mergeDialog.documentIDs, args), + { deleteOriginals: !!args.delete_originals } + ) this.toastService.showInfo( $localize`Merged document will be queued for consumption.` ) 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 78cef69cb..b3a9757ff 100644 --- a/src-ui/src/app/services/rest/document.service.spec.ts +++ b/src-ui/src/app/services/rest/document.service.spec.ts @@ -230,6 +230,88 @@ describe(`DocumentService`, () => { }) }) + it('should call appropriate api endpoint for delete documents', () => { + const ids = [1, 2, 3] + subscription = service.deleteDocuments(ids).subscribe() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}${endpoint}/delete/` + ) + expect(req.request.method).toEqual('POST') + expect(req.request.body).toEqual({ + documents: ids, + }) + }) + + it('should call appropriate api endpoint for reprocess documents', () => { + const ids = [1, 2, 3] + subscription = service.reprocessDocuments(ids).subscribe() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}${endpoint}/reprocess/` + ) + expect(req.request.method).toEqual('POST') + expect(req.request.body).toEqual({ + documents: ids, + }) + }) + + it('should call appropriate api endpoint for rotate documents', () => { + const ids = [1, 2, 3] + subscription = service.rotateDocuments(ids, 90).subscribe() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}${endpoint}/rotate/` + ) + expect(req.request.method).toEqual('POST') + expect(req.request.body).toEqual({ + documents: ids, + degrees: 90, + source_mode: 'latest_version', + }) + }) + + it('should call appropriate api endpoint for merge documents', () => { + const ids = [1, 2, 3] + const args = { metadata_document_id: 1, delete_originals: true } + subscription = service.mergeDocuments(ids, args).subscribe() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}${endpoint}/merge/` + ) + expect(req.request.method).toEqual('POST') + expect(req.request.body).toEqual({ + documents: ids, + metadata_document_id: 1, + delete_originals: true, + }) + }) + + it('should call appropriate api endpoint for edit pdf', () => { + const ids = [1] + const args = { operations: [{ page: 1, rotate: 90, doc: 0 }] } + subscription = service.editPdfDocuments(ids, args).subscribe() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}${endpoint}/edit_pdf/` + ) + expect(req.request.method).toEqual('POST') + expect(req.request.body).toEqual({ + documents: ids, + operations: [{ page: 1, rotate: 90, doc: 0 }], + }) + }) + + it('should call appropriate api endpoint for remove password', () => { + const ids = [1] + const args = { password: 'secret', update_document: true } + subscription = service.removePasswordDocuments(ids, args).subscribe() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}${endpoint}/remove_password/` + ) + expect(req.request.method).toEqual('POST') + expect(req.request.body).toEqual({ + documents: ids, + password: 'secret', + update_document: true, + }) + }) + it('should return the correct preview URL for a single document', () => { let url = service.getPreviewUrl(documents[0].id) expect(url).toEqual( diff --git a/src-ui/src/app/services/rest/document.service.ts b/src-ui/src/app/services/rest/document.service.ts index bc06d571c..971396bac 100644 --- a/src-ui/src/app/services/rest/document.service.ts +++ b/src-ui/src/app/services/rest/document.service.ts @@ -42,6 +42,45 @@ export enum BulkEditSourceMode { EXPLICIT_SELECTION = 'explicit_selection', } +export type DocumentBulkEditMethod = + | 'set_correspondent' + | 'set_document_type' + | 'set_storage_path' + | 'add_tag' + | 'remove_tag' + | 'modify_tags' + | 'modify_custom_fields' + | 'set_permissions' + +export interface MergeDocumentsRequest { + metadata_document_id?: number + delete_originals?: boolean + archive_fallback?: boolean + source_mode?: BulkEditSourceMode +} + +export interface EditPdfOperation { + page: number + rotate?: number + doc?: number +} + +export interface EditPdfDocumentsRequest { + operations: EditPdfOperation[] + delete_original?: boolean + update_document?: boolean + include_metadata?: boolean + source_mode?: BulkEditSourceMode +} + +export interface RemovePasswordDocumentsRequest { + password: string + update_document?: boolean + delete_original?: boolean + include_metadata?: boolean + source_mode?: BulkEditSourceMode +} + @Injectable({ providedIn: 'root', }) @@ -299,7 +338,7 @@ export class DocumentService extends AbstractPaperlessService { return this.http.get(url.toString()) } - bulkEdit(ids: number[], method: string, args: any) { + bulkEdit(ids: number[], method: DocumentBulkEditMethod, args: any) { return this.http.post(this.getResourceUrl(null, 'bulk_edit'), { documents: ids, method: method, @@ -307,6 +346,54 @@ export class DocumentService extends AbstractPaperlessService { }) } + deleteDocuments(ids: number[]) { + return this.http.post(this.getResourceUrl(null, 'delete'), { + documents: ids, + }) + } + + reprocessDocuments(ids: number[]) { + return this.http.post(this.getResourceUrl(null, 'reprocess'), { + documents: ids, + }) + } + + rotateDocuments( + ids: number[], + degrees: number, + sourceMode: BulkEditSourceMode = BulkEditSourceMode.LATEST_VERSION + ) { + return this.http.post(this.getResourceUrl(null, 'rotate'), { + documents: ids, + degrees, + source_mode: sourceMode, + }) + } + + mergeDocuments(ids: number[], request: MergeDocumentsRequest = {}) { + return this.http.post(this.getResourceUrl(null, 'merge'), { + documents: ids, + ...request, + }) + } + + editPdfDocuments(ids: number[], request: EditPdfDocumentsRequest) { + return this.http.post(this.getResourceUrl(null, 'edit_pdf'), { + documents: ids, + ...request, + }) + } + + removePasswordDocuments( + ids: number[], + request: RemovePasswordDocumentsRequest + ) { + return this.http.post(this.getResourceUrl(null, 'remove_password'), { + documents: ids, + ...request, + }) + } + getSelectionData(ids: number[]): Observable { return this.http.post( this.getResourceUrl(null, 'selection_data'), diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 1faa815e5..7361ca70b 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -1540,11 +1540,124 @@ class DocumentListSerializer(serializers.Serializer): return documents +class SourceModeValidationMixin: + def validate_source_mode(self, source_mode: str) -> str: + if source_mode not in bulk_edit.SourceModeChoices.__dict__.values(): + raise serializers.ValidationError("Invalid source_mode") + return source_mode + + +class RotateDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin): + degrees = serializers.IntegerField(required=True) + source_mode = serializers.CharField( + required=False, + default=bulk_edit.SourceModeChoices.LATEST_VERSION, + ) + + +class MergeDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin): + metadata_document_id = serializers.IntegerField( + required=False, + allow_null=True, + ) + delete_originals = serializers.BooleanField(required=False, default=False) + archive_fallback = serializers.BooleanField(required=False, default=False) + source_mode = serializers.CharField( + required=False, + default=bulk_edit.SourceModeChoices.LATEST_VERSION, + ) + + +class EditPdfDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin): + operations = serializers.ListField(required=True) + delete_original = serializers.BooleanField(required=False, default=False) + update_document = serializers.BooleanField(required=False, default=False) + include_metadata = serializers.BooleanField(required=False, default=True) + source_mode = serializers.CharField( + required=False, + default=bulk_edit.SourceModeChoices.LATEST_VERSION, + ) + + def validate(self, attrs): + documents = attrs["documents"] + if len(documents) > 1: + raise serializers.ValidationError( + "Edit PDF method only supports one document", + ) + + operations = attrs["operations"] + if not isinstance(operations, list): + raise serializers.ValidationError("operations must be a list") + + for op in operations: + if not isinstance(op, dict): + raise serializers.ValidationError("invalid operation entry") + if "page" not in op or not isinstance(op["page"], int): + raise serializers.ValidationError("page must be an integer") + if "rotate" in op and not isinstance(op["rotate"], int): + raise serializers.ValidationError("rotate must be an integer") + if "doc" in op and not isinstance(op["doc"], int): + raise serializers.ValidationError("doc must be an integer") + + if attrs["update_document"]: + max_idx = max(op.get("doc", 0) for op in operations) + if max_idx > 0: + raise serializers.ValidationError( + "update_document only allowed with a single output document", + ) + + doc = Document.objects.get(id=documents[0]) + if doc.page_count: + for op in operations: + if op["page"] < 1 or op["page"] > doc.page_count: + raise serializers.ValidationError( + f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.", + ) + return attrs + + +class RemovePasswordDocumentsSerializer( + DocumentListSerializer, + SourceModeValidationMixin, +): + password = serializers.CharField(required=True) + update_document = serializers.BooleanField(required=False, default=False) + delete_original = serializers.BooleanField(required=False, default=False) + include_metadata = serializers.BooleanField(required=False, default=True) + source_mode = serializers.CharField( + required=False, + default=bulk_edit.SourceModeChoices.LATEST_VERSION, + ) + + +class DeleteDocumentsSerializer(DocumentListSerializer): + pass + + +class ReprocessDocumentsSerializer(DocumentListSerializer): + pass + + class BulkEditSerializer( SerializerWithPerms, DocumentListSerializer, SetPermissionsMixin, + SourceModeValidationMixin, ): + # TODO: remove this and related backwards compatibility code when API v9 is dropped + # split, delete_pages can be removed entirely + MOVED_DOCUMENT_ACTION_ENDPOINTS = { + "delete": "/api/documents/delete/", + "reprocess": "/api/documents/reprocess/", + "rotate": "/api/documents/rotate/", + "merge": "/api/documents/merge/", + "edit_pdf": "/api/documents/edit_pdf/", + "remove_password": "/api/documents/remove_password/", + "split": "/api/documents/edit_pdf/", + "delete_pages": "/api/documents/edit_pdf/", + } + LEGACY_DOCUMENT_ACTION_METHODS = tuple(MOVED_DOCUMENT_ACTION_ENDPOINTS.keys()) + method = serializers.ChoiceField( choices=[ "set_correspondent", @@ -1554,15 +1667,8 @@ class BulkEditSerializer( "remove_tag", "modify_tags", "modify_custom_fields", - "delete", - "reprocess", "set_permissions", - "rotate", - "merge", - "split", - "delete_pages", - "edit_pdf", - "remove_password", + *LEGACY_DOCUMENT_ACTION_METHODS, ], label="Method", write_only=True, @@ -1640,8 +1746,7 @@ class BulkEditSerializer( return bulk_edit.edit_pdf elif method == "remove_password": return bulk_edit.remove_password - else: # pragma: no cover - # This will never happen as it is handled by the ChoiceField + else: raise serializers.ValidationError("Unsupported method.") def _validate_parameters_tags(self, parameters) -> None: @@ -1751,9 +1856,7 @@ class BulkEditSerializer( "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 + parameters["source_mode"] = self.validate_source_mode(source_mode) def _validate_parameters_split(self, parameters) -> None: if "pages" not in parameters: diff --git a/src/documents/tests/test_api_bulk_edit.py b/src/documents/tests/test_api_bulk_edit.py index 6a465e19f..04c3b5ff8 100644 --- a/src/documents/tests/test_api_bulk_edit.py +++ b/src/documents/tests/test_api_bulk_edit.py @@ -422,6 +422,34 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertEqual(args[0], [self.doc1.id]) self.assertEqual(len(kwargs), 0) + @mock.patch("documents.views.bulk_edit.delete") + def test_delete_documents_endpoint(self, m) -> None: + self.setup_mock(m, "delete") + response = self.client.post( + "/api/documents/delete/", + json.dumps({"documents": [self.doc1.id]}), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + m.assert_called_once() + args, kwargs = m.call_args + self.assertEqual(args[0], [self.doc1.id]) + self.assertEqual(len(kwargs), 0) + + @mock.patch("documents.views.bulk_edit.reprocess") + def test_reprocess_documents_endpoint(self, m) -> None: + self.setup_mock(m, "reprocess") + response = self.client.post( + "/api/documents/reprocess/", + json.dumps({"documents": [self.doc1.id]}), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + m.assert_called_once() + args, kwargs = m.call_args + self.assertEqual(args[0], [self.doc1.id]) + self.assertEqual(len(kwargs), 0) + @mock.patch("documents.serialisers.bulk_edit.set_storage_path") def test_api_set_storage_path(self, m) -> None: """ @@ -877,7 +905,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertEqual(kwargs["merge"], True) @mock.patch("documents.serialisers.bulk_edit.set_storage_path") - @mock.patch("documents.serialisers.bulk_edit.merge") + @mock.patch("documents.views.bulk_edit.merge") def test_insufficient_global_perms(self, mock_merge, mock_set_storage) -> None: """ GIVEN: @@ -912,12 +940,11 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): mock_set_storage.assert_not_called() response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/merge/", json.dumps( { "documents": [self.doc1.id], - "method": "merge", - "parameters": {"metadata_document_id": self.doc1.id}, + "metadata_document_id": self.doc1.id, }, ), content_type="application/json", @@ -927,15 +954,12 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): mock_merge.assert_not_called() response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/merge/", json.dumps( { "documents": [self.doc1.id], - "method": "merge", - "parameters": { - "metadata_document_id": self.doc1.id, - "delete_originals": True, - }, + "metadata_document_id": self.doc1.id, + "delete_originals": True, }, ), content_type="application/json", @@ -1052,85 +1076,57 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): m.assert_called_once() - @mock.patch("documents.serialisers.bulk_edit.rotate") + @mock.patch("documents.views.bulk_edit.rotate") def test_rotate(self, m) -> None: self.setup_mock(m, "rotate") response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/rotate/", json.dumps( { "documents": [self.doc2.id, self.doc3.id], - "method": "rotate", - "parameters": {"degrees": 90}, + "degrees": 90, }, ), content_type="application/json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) - m.assert_called_once() args, kwargs = m.call_args self.assertCountEqual(args[0], [self.doc2.id, self.doc3.id]) self.assertEqual(kwargs["degrees"], 90) - - @mock.patch("documents.serialisers.bulk_edit.rotate") - def test_rotate_invalid_params(self, m) -> None: - response = self.client.post( - "/api/documents/bulk_edit/", - json.dumps( - { - "documents": [self.doc2.id, self.doc3.id], - "method": "rotate", - "parameters": {"degrees": "foo"}, - }, - ), - content_type="application/json", - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - response = self.client.post( - "/api/documents/bulk_edit/", - json.dumps( - { - "documents": [self.doc2.id, self.doc3.id], - "method": "rotate", - "parameters": {"degrees": 90.5}, - }, - ), - content_type="application/json", - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - m.assert_not_called() - - @mock.patch("documents.serialisers.bulk_edit.merge") - def test_merge(self, m) -> None: - self.setup_mock(m, "merge") - response = self.client.post( - "/api/documents/bulk_edit/", - json.dumps( - { - "documents": [self.doc2.id, self.doc3.id], - "method": "merge", - "parameters": {"metadata_document_id": self.doc3.id}, - }, - ), - content_type="application/json", - ) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - - m.assert_called_once() - args, kwargs = m.call_args - self.assertCountEqual(args[0], [self.doc2.id, self.doc3.id]) - self.assertEqual(kwargs["metadata_document_id"], self.doc3.id) + self.assertEqual(kwargs["source_mode"], "latest_version") self.assertEqual(kwargs["user"], self.user) - @mock.patch("documents.serialisers.bulk_edit.merge") - def test_merge_and_delete_insufficient_permissions(self, m) -> None: + @mock.patch("documents.views.bulk_edit.rotate") + def test_rotate_invalid_params(self, m) -> None: + response = self.client.post( + "/api/documents/rotate/", + json.dumps( + { + "documents": [self.doc2.id, self.doc3.id], + "degrees": "foo", + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + response = self.client.post( + "/api/documents/rotate/", + json.dumps( + { + "documents": [self.doc2.id, self.doc3.id], + "degrees": 90.5, + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + m.assert_not_called() + + @mock.patch("documents.views.bulk_edit.rotate") + def test_rotate_insufficient_permissions(self, m) -> None: self.doc1.owner = User.objects.get(username="temp_admin") self.doc1.save() user1 = User.objects.create(username="user1") @@ -1138,17 +1134,13 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): user1.save() self.client.force_authenticate(user=user1) - self.setup_mock(m, "merge") + self.setup_mock(m, "rotate") response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/rotate/", json.dumps( { "documents": [self.doc1.id, self.doc2.id], - "method": "merge", - "parameters": { - "metadata_document_id": self.doc2.id, - "delete_originals": True, - }, + "degrees": 90, }, ), content_type="application/json", @@ -1159,15 +1151,11 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertEqual(response.content, b"Insufficient permissions") response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/rotate/", json.dumps( { "documents": [self.doc2.id, self.doc3.id], - "method": "merge", - "parameters": { - "metadata_document_id": self.doc2.id, - "delete_originals": True, - }, + "degrees": 90, }, ), content_type="application/json", @@ -1176,27 +1164,78 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) m.assert_called_once() - @mock.patch("documents.serialisers.bulk_edit.merge") - def test_merge_invalid_parameters(self, m) -> None: - """ - GIVEN: - - API data for merging documents is called - - The parameters are invalid - WHEN: - - API is called - THEN: - - The API fails with a correct error code - """ + @mock.patch("documents.views.bulk_edit.merge") + def test_merge(self, m) -> None: self.setup_mock(m, "merge") response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/merge/", + json.dumps( + { + "documents": [self.doc2.id, self.doc3.id], + "metadata_document_id": self.doc3.id, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + m.assert_called_once() + args, kwargs = m.call_args + self.assertCountEqual(args[0], [self.doc2.id, self.doc3.id]) + self.assertEqual(kwargs["metadata_document_id"], self.doc3.id) + self.assertEqual(kwargs["source_mode"], "latest_version") + self.assertEqual(kwargs["user"], self.user) + + @mock.patch("documents.views.bulk_edit.merge") + def test_merge_and_delete_insufficient_permissions(self, m) -> None: + self.doc1.owner = User.objects.get(username="temp_admin") + self.doc1.save() + user1 = User.objects.create(username="user1") + user1.user_permissions.add(*Permission.objects.all()) + user1.save() + self.client.force_authenticate(user=user1) + + self.setup_mock(m, "merge") + response = self.client.post( + "/api/documents/merge/", json.dumps( { "documents": [self.doc1.id, self.doc2.id], - "method": "merge", - "parameters": { - "delete_originals": "not_boolean", - }, + "metadata_document_id": self.doc2.id, + "delete_originals": True, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + m.assert_not_called() + self.assertEqual(response.content, b"Insufficient permissions") + + response = self.client.post( + "/api/documents/merge/", + json.dumps( + { + "documents": [self.doc2.id, self.doc3.id], + "metadata_document_id": self.doc2.id, + "delete_originals": True, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + m.assert_called_once() + + @mock.patch("documents.views.bulk_edit.merge") + def test_merge_invalid_parameters(self, m) -> None: + self.setup_mock(m, "merge") + response = self.client.post( + "/api/documents/merge/", + json.dumps( + { + "documents": [self.doc1.id, self.doc2.id], + "delete_originals": "not_boolean", }, ), content_type="application/json", @@ -1205,207 +1244,67 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) m.assert_not_called() - @mock.patch("documents.serialisers.bulk_edit.split") - def test_split(self, m) -> None: - self.setup_mock(m, "split") - response = self.client.post( - "/api/documents/bulk_edit/", - json.dumps( - { - "documents": [self.doc2.id], - "method": "split", - "parameters": {"pages": "1,2-4,5-6,7"}, - }, - ), - content_type="application/json", - ) + def test_bulk_edit_allows_legacy_file_methods_with_warning(self) -> None: + method_payloads = { + "delete": {}, + "reprocess": {}, + "rotate": {"degrees": 90}, + "merge": {"metadata_document_id": self.doc2.id}, + "edit_pdf": {"operations": [{"page": 1}]}, + "remove_password": {"password": "secret"}, + "split": {"pages": "1,2-4"}, + "delete_pages": {"pages": [1, 2]}, + } - self.assertEqual(response.status_code, status.HTTP_200_OK) + for version in (9, 10): + for method, parameters in method_payloads.items(): + with self.subTest(method=method, version=version): + with mock.patch( + f"documents.views.bulk_edit.{method}", + ) as mocked_method: + self.setup_mock(mocked_method, method) + with self.assertLogs("paperless.api", level="WARNING") as logs: + response = self.client.post( + "/api/documents/bulk_edit/", + json.dumps( + { + "documents": [self.doc2.id], + "method": method, + "parameters": parameters, + }, + ), + content_type="application/json", + headers={ + "Accept": f"application/json; version={version}", + }, + ) - m.assert_called_once() - args, kwargs = m.call_args - self.assertCountEqual(args[0], [self.doc2.id]) - self.assertEqual(kwargs["pages"], [[1], [2, 3, 4], [5, 6], [7]]) - self.assertEqual(kwargs["user"], self.user) + self.assertEqual(response.status_code, status.HTTP_200_OK) + mocked_method.assert_called_once() + self.assertTrue( + any( + "Deprecated bulk_edit method" in entry + and f"'{method}'" in entry + for entry in logs.output + ), + ) - def test_split_invalid_params(self) -> None: - response = self.client.post( - "/api/documents/bulk_edit/", - json.dumps( - { - "documents": [self.doc2.id], - "method": "split", - "parameters": {}, # pages not specified - }, - ), - content_type="application/json", - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn(b"pages not specified", response.content) - - response = self.client.post( - "/api/documents/bulk_edit/", - json.dumps( - { - "documents": [self.doc2.id], - "method": "split", - "parameters": {"pages": "1:7"}, # wrong format - }, - ), - content_type="application/json", - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn(b"invalid pages specified", response.content) - - response = self.client.post( - "/api/documents/bulk_edit/", - json.dumps( - { - "documents": [ - self.doc1.id, - self.doc2.id, - ], # only one document supported - "method": "split", - "parameters": {"pages": "1-2,3-7"}, # wrong format - }, - ), - content_type="application/json", - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn(b"Split method only supports one document", response.content) - - response = self.client.post( - "/api/documents/bulk_edit/", - json.dumps( - { - "documents": [self.doc2.id], - "method": "split", - "parameters": { - "pages": "1", - "delete_originals": "notabool", - }, # not a bool - }, - ), - content_type="application/json", - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn(b"delete_originals must be a boolean", response.content) - - @mock.patch("documents.serialisers.bulk_edit.delete_pages") - def test_delete_pages(self, m) -> None: - self.setup_mock(m, "delete_pages") - response = self.client.post( - "/api/documents/bulk_edit/", - json.dumps( - { - "documents": [self.doc2.id], - "method": "delete_pages", - "parameters": {"pages": [1, 2, 3, 4]}, - }, - ), - content_type="application/json", - ) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - - m.assert_called_once() - args, kwargs = m.call_args - self.assertCountEqual(args[0], [self.doc2.id]) - self.assertEqual(kwargs["pages"], [1, 2, 3, 4]) - - def test_delete_pages_invalid_params(self) -> None: - response = self.client.post( - "/api/documents/bulk_edit/", - json.dumps( - { - "documents": [ - self.doc1.id, - self.doc2.id, - ], # only one document supported - "method": "delete_pages", - "parameters": { - "pages": [1, 2, 3, 4], - }, - }, - ), - content_type="application/json", - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn( - b"Delete pages method only supports one document", - response.content, - ) - - response = self.client.post( - "/api/documents/bulk_edit/", - json.dumps( - { - "documents": [self.doc2.id], - "method": "delete_pages", - "parameters": {}, # pages not specified - }, - ), - content_type="application/json", - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn(b"pages not specified", response.content) - - response = self.client.post( - "/api/documents/bulk_edit/", - json.dumps( - { - "documents": [self.doc2.id], - "method": "delete_pages", - "parameters": {"pages": "1-3"}, # not a list - }, - ), - content_type="application/json", - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn(b"pages must be a list", response.content) - - response = self.client.post( - "/api/documents/bulk_edit/", - json.dumps( - { - "documents": [self.doc2.id], - "method": "delete_pages", - "parameters": {"pages": ["1-3"]}, # not ints - }, - ), - content_type="application/json", - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn(b"pages must be a list of integers", response.content) - - @mock.patch("documents.serialisers.bulk_edit.edit_pdf") + @mock.patch("documents.views.bulk_edit.edit_pdf") def test_edit_pdf(self, m) -> None: self.setup_mock(m, "edit_pdf") response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/edit_pdf/", json.dumps( { "documents": [self.doc2.id], - "method": "edit_pdf", - "parameters": { - "operations": [{"page": 1}], - "source_mode": "explicit_selection", - }, + "operations": [{"page": 1}], + "source_mode": "explicit_selection", }, ), content_type="application/json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) - m.assert_called_once() args, kwargs = m.call_args self.assertCountEqual(args[0], [self.doc2.id]) @@ -1414,14 +1313,12 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertEqual(kwargs["user"], self.user) def test_edit_pdf_invalid_params(self) -> None: - # multiple documents response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/edit_pdf/", json.dumps( { "documents": [self.doc2.id, self.doc3.id], - "method": "edit_pdf", - "parameters": {"operations": [{"page": 1}]}, + "operations": [{"page": 1}], }, ), content_type="application/json", @@ -1429,44 +1326,25 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn(b"Edit PDF method only supports one document", response.content) - # no operations specified response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/edit_pdf/", json.dumps( { "documents": [self.doc2.id], - "method": "edit_pdf", - "parameters": {}, + "operations": "not_a_list", }, ), content_type="application/json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn(b"operations not specified", response.content) + self.assertIn(b"Expected a list of items", response.content) - # operations not a list response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/edit_pdf/", json.dumps( { "documents": [self.doc2.id], - "method": "edit_pdf", - "parameters": {"operations": "not_a_list"}, - }, - ), - content_type="application/json", - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn(b"operations must be a list", response.content) - - # invalid operation - response = self.client.post( - "/api/documents/bulk_edit/", - json.dumps( - { - "documents": [self.doc2.id], - "method": "edit_pdf", - "parameters": {"operations": ["invalid_operation"]}, + "operations": ["invalid_operation"], }, ), content_type="application/json", @@ -1474,14 +1352,12 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn(b"invalid operation entry", response.content) - # page not an int response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/edit_pdf/", json.dumps( { "documents": [self.doc2.id], - "method": "edit_pdf", - "parameters": {"operations": [{"page": "not_an_int"}]}, + "operations": [{"page": "not_an_int"}], }, ), content_type="application/json", @@ -1489,14 +1365,12 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn(b"page must be an integer", response.content) - # rotate not an int response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/edit_pdf/", json.dumps( { "documents": [self.doc2.id], - "method": "edit_pdf", - "parameters": {"operations": [{"page": 1, "rotate": "not_an_int"}]}, + "operations": [{"page": 1, "rotate": "not_an_int"}], }, ), content_type="application/json", @@ -1504,14 +1378,12 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn(b"rotate must be an integer", response.content) - # doc not an int response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/edit_pdf/", json.dumps( { "documents": [self.doc2.id], - "method": "edit_pdf", - "parameters": {"operations": [{"page": 1, "doc": "not_an_int"}]}, + "operations": [{"page": 1, "doc": "not_an_int"}], }, ), content_type="application/json", @@ -1519,53 +1391,13 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn(b"doc must be an integer", response.content) - # update_document not a boolean response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/edit_pdf/", json.dumps( { "documents": [self.doc2.id], - "method": "edit_pdf", - "parameters": { - "update_document": "not_a_bool", - "operations": [{"page": 1}], - }, - }, - ), - content_type="application/json", - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn(b"update_document must be a boolean", response.content) - - # include_metadata not a boolean - response = self.client.post( - "/api/documents/bulk_edit/", - json.dumps( - { - "documents": [self.doc2.id], - "method": "edit_pdf", - "parameters": { - "include_metadata": "not_a_bool", - "operations": [{"page": 1}], - }, - }, - ), - content_type="application/json", - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn(b"include_metadata must be a boolean", response.content) - - # update_document True but output would be multiple documents - response = self.client.post( - "/api/documents/bulk_edit/", - json.dumps( - { - "documents": [self.doc2.id], - "method": "edit_pdf", - "parameters": { - "update_document": True, - "operations": [{"page": 1, "doc": 1}, {"page": 2, "doc": 2}], - }, + "update_document": True, + "operations": [{"page": 1, "doc": 1}, {"page": 2, "doc": 2}], }, ), content_type="application/json", @@ -1576,17 +1408,13 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): response.content, ) - # invalid source mode response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/edit_pdf/", json.dumps( { "documents": [self.doc2.id], - "method": "edit_pdf", - "parameters": { - "operations": [{"page": 1}], - "source_mode": "not_a_mode", - }, + "operations": [{"page": 1}], + "source_mode": "not_a_mode", }, ), content_type="application/json", @@ -1594,42 +1422,70 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): 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.views.bulk_edit.edit_pdf") def test_edit_pdf_page_out_of_bounds(self, m) -> None: - """ - GIVEN: - - API data for editing PDF is called - - The page number is out of bounds - WHEN: - - API is called - THEN: - - The API fails with a correct error code - """ self.setup_mock(m, "edit_pdf") response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/edit_pdf/", json.dumps( { "documents": [self.doc2.id], - "method": "edit_pdf", - "parameters": {"operations": [{"page": 99}]}, + "operations": [{"page": 99}], }, ), content_type="application/json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn(b"out of bounds", response.content) + m.assert_not_called() - @mock.patch("documents.serialisers.bulk_edit.remove_password") - def test_remove_password(self, m) -> None: - self.setup_mock(m, "remove_password") + @mock.patch("documents.views.bulk_edit.edit_pdf") + def test_edit_pdf_insufficient_permissions(self, m) -> None: + self.doc1.owner = User.objects.get(username="temp_admin") + self.doc1.save() + user1 = User.objects.create(username="user1") + user1.user_permissions.add(*Permission.objects.all()) + user1.save() + self.client.force_authenticate(user=user1) + + self.setup_mock(m, "edit_pdf") response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/edit_pdf/", + json.dumps( + { + "documents": [self.doc1.id], + "operations": [{"page": 1}], + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + m.assert_not_called() + self.assertEqual(response.content, b"Insufficient permissions") + + response = self.client.post( + "/api/documents/edit_pdf/", json.dumps( { "documents": [self.doc2.id], - "method": "remove_password", - "parameters": {"password": "secret", "update_document": True}, + "operations": [{"page": 1}], + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + m.assert_called_once() + + @mock.patch("documents.views.bulk_edit.remove_password") + def test_remove_password(self, m) -> None: + self.setup_mock(m, "remove_password") + response = self.client.post( + "/api/documents/remove_password/", + json.dumps( + { + "documents": [self.doc2.id], + "password": "secret", + "update_document": True, }, ), content_type="application/json", @@ -1641,36 +1497,69 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertCountEqual(args[0], [self.doc2.id]) self.assertEqual(kwargs["password"], "secret") self.assertTrue(kwargs["update_document"]) + self.assertEqual(kwargs["source_mode"], "latest_version") self.assertEqual(kwargs["user"], self.user) def test_remove_password_invalid_params(self) -> None: response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/remove_password/", json.dumps( { "documents": [self.doc2.id], - "method": "remove_password", - "parameters": {}, }, ), content_type="application/json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn(b"password not specified", response.content) response = self.client.post( - "/api/documents/bulk_edit/", + "/api/documents/remove_password/", json.dumps( { "documents": [self.doc2.id], - "method": "remove_password", - "parameters": {"password": 123}, + "password": 123, }, ), content_type="application/json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn(b"password must be a string", response.content) + + @mock.patch("documents.views.bulk_edit.remove_password") + def test_remove_password_insufficient_permissions(self, m) -> None: + self.doc1.owner = User.objects.get(username="temp_admin") + self.doc1.save() + user1 = User.objects.create(username="user1") + user1.user_permissions.add(*Permission.objects.all()) + user1.save() + self.client.force_authenticate(user=user1) + + self.setup_mock(m, "remove_password") + response = self.client.post( + "/api/documents/remove_password/", + json.dumps( + { + "documents": [self.doc1.id], + "password": "secret", + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + m.assert_not_called() + self.assertEqual(response.content, b"Insufficient permissions") + + response = self.client.post( + "/api/documents/remove_password/", + json.dumps( + { + "documents": [self.doc2.id], + "password": "secret", + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + m.assert_called_once() @override_settings(AUDIT_LOG_ENABLED=True) def test_bulk_edit_audit_log_enabled_simple_field(self) -> None: diff --git a/src/documents/tests/test_api_schema.py b/src/documents/tests/test_api_schema.py index 24d44fbc0..e14762f68 100644 --- a/src/documents/tests/test_api_schema.py +++ b/src/documents/tests/test_api_schema.py @@ -25,3 +25,39 @@ class TestApiSchema(APITestCase): ui_response = self.client.get(self.ENDPOINT + "view/") self.assertEqual(ui_response.status_code, status.HTTP_200_OK) + + def test_schema_includes_dedicated_document_edit_endpoints(self) -> None: + schema_response = self.client.get(self.ENDPOINT) + self.assertEqual(schema_response.status_code, status.HTTP_200_OK) + + paths = schema_response.data["paths"] + self.assertIn("/api/documents/delete/", paths) + self.assertIn("/api/documents/reprocess/", paths) + self.assertIn("/api/documents/rotate/", paths) + self.assertIn("/api/documents/merge/", paths) + self.assertIn("/api/documents/edit_pdf/", paths) + self.assertIn("/api/documents/remove_password/", paths) + + def test_schema_bulk_edit_advertises_legacy_document_action_methods(self) -> None: + schema_response = self.client.get(self.ENDPOINT) + self.assertEqual(schema_response.status_code, status.HTTP_200_OK) + + schema = schema_response.data["components"]["schemas"] + bulk_schema = schema["BulkEditRequest"] + method_schema = bulk_schema["properties"]["method"] + + # drf-spectacular emits the enum as a referenced schema for this field + enum_ref = method_schema["allOf"][0]["$ref"].split("/")[-1] + advertised_methods = schema[enum_ref]["enum"] + + for action_method in [ + "delete", + "reprocess", + "rotate", + "merge", + "edit_pdf", + "remove_password", + "split", + "delete_pages", + ]: + self.assertIn(action_method, advertised_methods) diff --git a/src/documents/views.py b/src/documents/views.py index 9b960a8b4..d51cde25f 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -176,14 +176,20 @@ from documents.serialisers import BulkEditObjectsSerializer from documents.serialisers import BulkEditSerializer from documents.serialisers import CorrespondentSerializer from documents.serialisers import CustomFieldSerializer +from documents.serialisers import DeleteDocumentsSerializer from documents.serialisers import DocumentListSerializer from documents.serialisers import DocumentSerializer from documents.serialisers import DocumentTypeSerializer from documents.serialisers import DocumentVersionLabelSerializer from documents.serialisers import DocumentVersionSerializer +from documents.serialisers import EditPdfDocumentsSerializer from documents.serialisers import EmailSerializer +from documents.serialisers import MergeDocumentsSerializer from documents.serialisers import NotesSerializer from documents.serialisers import PostDocumentSerializer +from documents.serialisers import RemovePasswordDocumentsSerializer +from documents.serialisers import ReprocessDocumentsSerializer +from documents.serialisers import RotateDocumentsSerializer from documents.serialisers import RunTaskViewSerializer from documents.serialisers import SavedViewSerializer from documents.serialisers import SearchResultSerializer @@ -2114,6 +2120,125 @@ class SavedViewViewSet(BulkPermissionMixin, PassUserMixin, ModelViewSet): ordering_fields = ("name",) +class DocumentOperationPermissionMixin(PassUserMixin): + permission_classes = (IsAuthenticated,) + parser_classes = (parsers.JSONParser,) + METHOD_NAMES_REQUIRING_USER = { + "split", + "merge", + "rotate", + "delete_pages", + "edit_pdf", + "remove_password", + } + + def _has_document_permissions( + self, + *, + user: User, + documents: list[int], + method, + parameters: dict[str, Any], + ) -> bool: + if user.is_superuser: + return True + + document_objs = Document.objects.select_related("owner").filter( + pk__in=documents, + ) + user_is_owner_of_all_documents = all( + (doc.owner == user or doc.owner is None) for doc in document_objs + ) + + # check global and object permissions for all documents + has_perms = user.has_perm("documents.change_document") and all( + has_perms_owner_aware(user, "change_document", doc) for doc in document_objs + ) + + # check ownership for methods that change original document + if ( + ( + has_perms + and method + in [ + bulk_edit.set_permissions, + bulk_edit.delete, + bulk_edit.rotate, + bulk_edit.delete_pages, + bulk_edit.edit_pdf, + bulk_edit.remove_password, + ] + ) + or ( + method in [bulk_edit.merge, bulk_edit.split] + and parameters.get("delete_originals") + ) + or (method == bulk_edit.edit_pdf and parameters.get("update_document")) + ): + has_perms = user_is_owner_of_all_documents + + # check global add permissions for methods that create documents + if ( + has_perms + and ( + method in [bulk_edit.split, bulk_edit.merge] + or ( + method in [bulk_edit.edit_pdf, bulk_edit.remove_password] + and not parameters.get("update_document") + ) + ) + and not user.has_perm("documents.add_document") + ): + has_perms = False + + # check global delete permissions for methods that delete documents + if ( + has_perms + and ( + method == bulk_edit.delete + or ( + method in [bulk_edit.merge, bulk_edit.split] + and parameters.get("delete_originals") + ) + ) + and not user.has_perm("documents.delete_document") + ): + has_perms = False + + return has_perms + + def _execute_document_action( + self, + *, + method, + validated_data: dict[str, Any], + operation_label: str, + ): + documents = validated_data["documents"] + parameters = {k: v for k, v in validated_data.items() if k != "documents"} + user = self.request.user + + if method.__name__ in self.METHOD_NAMES_REQUIRING_USER: + parameters["user"] = user + + if not self._has_document_permissions( + user=user, + documents=documents, + method=method, + parameters=parameters, + ): + return HttpResponseForbidden("Insufficient permissions") + + try: + result = method(documents, **parameters) + return Response({"result": result}) + except Exception as e: + logger.warning(f"An error occurred performing {operation_label}: {e!s}") + return HttpResponseBadRequest( + f"Error performing {operation_label}, check logs for more detail.", + ) + + @extend_schema_view( post=extend_schema( operation_id="bulk_edit", @@ -2132,7 +2257,7 @@ class SavedViewViewSet(BulkPermissionMixin, PassUserMixin, ModelViewSet): }, ), ) -class BulkEditView(PassUserMixin): +class BulkEditView(DocumentOperationPermissionMixin): MODIFIED_FIELD_BY_METHOD = { "set_correspondent": "correspondent", "set_document_type": "document_type", @@ -2154,11 +2279,24 @@ class BulkEditView(PassUserMixin): "remove_password": None, } - permission_classes = (IsAuthenticated,) serializer_class = BulkEditSerializer - parser_classes = (parsers.JSONParser,) def post(self, request, *args, **kwargs): + request_method = request.data.get("method") + api_version = int(request.version or settings.REST_FRAMEWORK["DEFAULT_VERSION"]) + # TODO: remove this and related backwards compatibility code when API v9 is dropped + if request_method in BulkEditSerializer.LEGACY_DOCUMENT_ACTION_METHODS: + endpoint = BulkEditSerializer.MOVED_DOCUMENT_ACTION_ENDPOINTS[ + request_method + ] + logger.warning( + "Deprecated bulk_edit method '%s' requested on API version %s. " + "Use '%s' instead.", + request_method, + api_version, + endpoint, + ) + serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -2166,82 +2304,15 @@ class BulkEditView(PassUserMixin): method = serializer.validated_data.get("method") parameters = serializer.validated_data.get("parameters") documents = serializer.validated_data.get("documents") - if method in [ - bulk_edit.split, - bulk_edit.merge, - bulk_edit.rotate, - bulk_edit.delete_pages, - bulk_edit.edit_pdf, - bulk_edit.remove_password, - ]: + if method.__name__ in self.METHOD_NAMES_REQUIRING_USER: parameters["user"] = user - - if not user.is_superuser: - document_objs = Document.objects.select_related("owner").filter( - pk__in=documents, - ) - user_is_owner_of_all_documents = all( - (doc.owner == user or doc.owner is None) for doc in document_objs - ) - - # check global and object permissions for all documents - has_perms = user.has_perm("documents.change_document") and all( - has_perms_owner_aware(user, "change_document", doc) - for doc in document_objs - ) - - # check ownership for methods that change original document - if ( - ( - has_perms - and method - in [ - bulk_edit.set_permissions, - bulk_edit.delete, - bulk_edit.rotate, - bulk_edit.delete_pages, - bulk_edit.edit_pdf, - bulk_edit.remove_password, - ] - ) - or ( - method in [bulk_edit.merge, bulk_edit.split] - and parameters["delete_originals"] - ) - or (method == bulk_edit.edit_pdf and parameters["update_document"]) - ): - has_perms = user_is_owner_of_all_documents - - # check global add permissions for methods that create documents - if ( - has_perms - and ( - method in [bulk_edit.split, bulk_edit.merge] - or ( - method in [bulk_edit.edit_pdf, bulk_edit.remove_password] - and not parameters["update_document"] - ) - ) - and not user.has_perm("documents.add_document") - ): - has_perms = False - - # check global delete permissions for methods that delete documents - if ( - has_perms - and ( - method == bulk_edit.delete - or ( - method in [bulk_edit.merge, bulk_edit.split] - and parameters["delete_originals"] - ) - ) - and not user.has_perm("documents.delete_document") - ): - has_perms = False - - if not has_perms: - return HttpResponseForbidden("Insufficient permissions") + if not self._has_document_permissions( + user=user, + documents=documents, + method=method, + parameters=parameters, + ): + return HttpResponseForbidden("Insufficient permissions") try: modified_field = self.MODIFIED_FIELD_BY_METHOD.get(method.__name__, None) @@ -2298,6 +2369,168 @@ class BulkEditView(PassUserMixin): ) +@extend_schema_view( + post=extend_schema( + operation_id="documents_rotate", + description="Rotate one or more documents", + responses={ + 200: inline_serializer( + name="RotateDocumentsResult", + fields={ + "result": serializers.CharField(), + }, + ), + }, + ), +) +class RotateDocumentsView(DocumentOperationPermissionMixin): + serializer_class = RotateDocumentsSerializer + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + return self._execute_document_action( + method=bulk_edit.rotate, + validated_data=serializer.validated_data, + operation_label="document rotate", + ) + + +@extend_schema_view( + post=extend_schema( + operation_id="documents_merge", + description="Merge selected documents into a new document", + responses={ + 200: inline_serializer( + name="MergeDocumentsResult", + fields={ + "result": serializers.CharField(), + }, + ), + }, + ), +) +class MergeDocumentsView(DocumentOperationPermissionMixin): + serializer_class = MergeDocumentsSerializer + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + return self._execute_document_action( + method=bulk_edit.merge, + validated_data=serializer.validated_data, + operation_label="document merge", + ) + + +@extend_schema_view( + post=extend_schema( + operation_id="documents_delete", + description="Move selected documents to trash", + responses={ + 200: inline_serializer( + name="DeleteDocumentsResult", + fields={ + "result": serializers.CharField(), + }, + ), + }, + ), +) +class DeleteDocumentsView(DocumentOperationPermissionMixin): + serializer_class = DeleteDocumentsSerializer + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + return self._execute_document_action( + method=bulk_edit.delete, + validated_data=serializer.validated_data, + operation_label="document delete", + ) + + +@extend_schema_view( + post=extend_schema( + operation_id="documents_reprocess", + description="Reprocess selected documents", + responses={ + 200: inline_serializer( + name="ReprocessDocumentsResult", + fields={ + "result": serializers.CharField(), + }, + ), + }, + ), +) +class ReprocessDocumentsView(DocumentOperationPermissionMixin): + serializer_class = ReprocessDocumentsSerializer + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + return self._execute_document_action( + method=bulk_edit.reprocess, + validated_data=serializer.validated_data, + operation_label="document reprocess", + ) + + +@extend_schema_view( + post=extend_schema( + operation_id="documents_edit_pdf", + description="Perform PDF edit operations on a selected document", + responses={ + 200: inline_serializer( + name="EditPdfDocumentsResult", + fields={ + "result": serializers.CharField(), + }, + ), + }, + ), +) +class EditPdfDocumentsView(DocumentOperationPermissionMixin): + serializer_class = EditPdfDocumentsSerializer + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + return self._execute_document_action( + method=bulk_edit.edit_pdf, + validated_data=serializer.validated_data, + operation_label="PDF edit", + ) + + +@extend_schema_view( + post=extend_schema( + operation_id="documents_remove_password", + description="Remove password protection from selected PDFs", + responses={ + 200: inline_serializer( + name="RemovePasswordDocumentsResult", + fields={ + "result": serializers.CharField(), + }, + ), + }, + ), +) +class RemovePasswordDocumentsView(DocumentOperationPermissionMixin): + serializer_class = RemovePasswordDocumentsSerializer + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + return self._execute_document_action( + method=bulk_edit.remove_password, + validated_data=serializer.validated_data, + operation_label="password removal", + ) + + @extend_schema_view( post=extend_schema( description="Upload a document via the API", diff --git a/src/paperless/urls.py b/src/paperless/urls.py index 04b0c2200..88e1a3178 100644 --- a/src/paperless/urls.py +++ b/src/paperless/urls.py @@ -21,12 +21,18 @@ from documents.views import BulkEditView from documents.views import ChatStreamingView from documents.views import CorrespondentViewSet from documents.views import CustomFieldViewSet +from documents.views import DeleteDocumentsView from documents.views import DocumentTypeViewSet +from documents.views import EditPdfDocumentsView from documents.views import GlobalSearchView from documents.views import IndexView from documents.views import LogViewSet +from documents.views import MergeDocumentsView from documents.views import PostDocumentView from documents.views import RemoteVersionView +from documents.views import RemovePasswordDocumentsView +from documents.views import ReprocessDocumentsView +from documents.views import RotateDocumentsView from documents.views import SavedViewViewSet from documents.views import SearchAutoCompleteView from documents.views import SelectionDataView @@ -132,6 +138,36 @@ urlpatterns = [ BulkEditView.as_view(), name="bulk_edit", ), + re_path( + "^delete/", + DeleteDocumentsView.as_view(), + name="delete_documents", + ), + re_path( + "^reprocess/", + ReprocessDocumentsView.as_view(), + name="reprocess_documents", + ), + re_path( + "^rotate/", + RotateDocumentsView.as_view(), + name="rotate_documents", + ), + re_path( + "^merge/", + MergeDocumentsView.as_view(), + name="merge_documents", + ), + re_path( + "^edit_pdf/", + EditPdfDocumentsView.as_view(), + name="edit_pdf_documents", + ), + re_path( + "^remove_password/", + RemovePasswordDocumentsView.as_view(), + name="remove_password_documents", + ), re_path( "^bulk_download/", BulkDownloadView.as_view(),