From 245514ad10c09c0b19b1fe5fe5d06d0602d83e6b Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Tue, 31 Mar 2026 07:55:59 -0700 Subject: [PATCH 1/4] Performance: deprecate and remove usage of `all` in API results (#12309) --- docs/api.md | 3 + .../document-attributes.component.html | 10 +- .../management-list.component.html | 4 +- .../management-list.component.spec.ts | 73 ++++++++++-- .../management-list.component.ts | 53 +++++++-- .../tag-list/tag-list.component.spec.ts | 1 - .../tag-list/tag-list.component.ts | 11 -- src-ui/src/app/data/results.ts | 4 +- .../rest/abstract-name-filter-service.spec.ts | 24 ++++ .../rest/abstract-name-filter-service.ts | 15 ++- src/documents/index.py | 20 ++++ src/documents/serialisers.py | 30 ++++- src/documents/tests/test_api_documents.py | 33 +++++- src/documents/tests/test_api_objects.py | 109 ++++++++++++++++++ src/documents/tests/test_api_search.py | 30 ++++- src/documents/views.py | 57 +++++++-- src/paperless/views.py | 42 ++++--- 17 files changed, 441 insertions(+), 78 deletions(-) diff --git a/docs/api.md b/docs/api.md index bd550c519..21c6b140f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -437,3 +437,6 @@ Initial API version. 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. +- The `all` parameter of list endpoints is now deprecated and will be removed in a future version. +- The bulk edit objects endpoint now supports `all` and `filters` parameters to avoid having to send + large lists of object IDs for operations affecting many objects. diff --git a/src-ui/src/app/components/manage/document-attributes/document-attributes.component.html b/src-ui/src/app/components/manage/document-attributes/document-attributes.component.html index bee9a29aa..118b61ce3 100644 --- a/src-ui/src/app/components/manage/document-attributes/document-attributes.component.html +++ b/src-ui/src/app/components/manage/document-attributes/document-attributes.component.html @@ -9,8 +9,8 @@
@@ -25,7 +25,7 @@ Select:
- @if (activeManagementList.selectedObjects.size > 0) { + @if (activeManagementList.hasSelection) { @@ -40,11 +40,11 @@
-
@@ -103,13 +103,13 @@ class="btn btn-sm btn-outline-primary" id="dropdownSend" ngbDropdownToggle - [disabled]="disabled || list.selected.size === 0" + [disabled]="disabled || !list.hasSelection || list.allSelected" >
Send
- @if (emailEnabled) { - } 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 da74da98a..f283a75f3 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 @@ -13,6 +13,7 @@ import { of, throwError } from 'rxjs' import { Correspondent } from 'src/app/data/correspondent' import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' import { DocumentType } from 'src/app/data/document-type' +import { FILTER_TITLE } from 'src/app/data/filter-rule-type' import { Results } from 'src/app/data/results' import { StoragePath } from 'src/app/data/storage-path' import { Tag } from 'src/app/data/tag' @@ -273,6 +274,92 @@ describe('BulkEditorComponent', () => { expect(component.customFieldsSelectionModel.selectionSize()).toEqual(1) }) + it('should apply list selection data to tags menu when all filtered documents are selected', () => { + jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) + fixture.detectChanges() + jest + .spyOn(documentListViewService, 'allSelected', 'get') + .mockReturnValue(true) + jest + .spyOn(documentListViewService, 'selectedCount', 'get') + .mockReturnValue(3) + documentListViewService.selectionData = selectionData + const getSelectionDataSpy = jest.spyOn(documentService, 'getSelectionData') + + component.openTagsDropdown() + + expect(getSelectionDataSpy).not.toHaveBeenCalled() + expect(component.tagSelectionModel.selectionSize()).toEqual(1) + }) + + it('should apply list selection data to document types menu when all filtered documents are selected', () => { + jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) + fixture.detectChanges() + jest + .spyOn(documentListViewService, 'allSelected', 'get') + .mockReturnValue(true) + documentListViewService.selectionData = selectionData + const getSelectionDataSpy = jest.spyOn(documentService, 'getSelectionData') + + component.openDocumentTypeDropdown() + + expect(getSelectionDataSpy).not.toHaveBeenCalled() + expect(component.documentTypeDocumentCounts).toEqual( + selectionData.selected_document_types + ) + }) + + it('should apply list selection data to correspondents menu when all filtered documents are selected', () => { + jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) + fixture.detectChanges() + jest + .spyOn(documentListViewService, 'allSelected', 'get') + .mockReturnValue(true) + documentListViewService.selectionData = selectionData + const getSelectionDataSpy = jest.spyOn(documentService, 'getSelectionData') + + component.openCorrespondentDropdown() + + expect(getSelectionDataSpy).not.toHaveBeenCalled() + expect(component.correspondentDocumentCounts).toEqual( + selectionData.selected_correspondents + ) + }) + + it('should apply list selection data to storage paths menu when all filtered documents are selected', () => { + jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) + fixture.detectChanges() + jest + .spyOn(documentListViewService, 'allSelected', 'get') + .mockReturnValue(true) + documentListViewService.selectionData = selectionData + const getSelectionDataSpy = jest.spyOn(documentService, 'getSelectionData') + + component.openStoragePathDropdown() + + expect(getSelectionDataSpy).not.toHaveBeenCalled() + expect(component.storagePathDocumentCounts).toEqual( + selectionData.selected_storage_paths + ) + }) + + it('should apply list selection data to custom fields menu when all filtered documents are selected', () => { + jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) + fixture.detectChanges() + jest + .spyOn(documentListViewService, 'allSelected', 'get') + .mockReturnValue(true) + documentListViewService.selectionData = selectionData + const getSelectionDataSpy = jest.spyOn(documentService, 'getSelectionData') + + component.openCustomFieldsDropdown() + + expect(getSelectionDataSpy).not.toHaveBeenCalled() + expect(component.customFieldDocumentCounts).toEqual( + selectionData.selected_custom_fields + ) + }) + it('should execute modify tags bulk operation', () => { jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) jest @@ -307,6 +394,49 @@ describe('BulkEditorComponent', () => { ) // listAllFilteredIds }) + it('should execute modify tags bulk operation for all filtered documents', () => { + jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) + jest + .spyOn(documentListViewService, 'documents', 'get') + .mockReturnValue([{ id: 3 }, { id: 4 }]) + jest + .spyOn(documentListViewService, 'selected', 'get') + .mockReturnValue(new Set([3, 4])) + jest + .spyOn(documentListViewService, 'allSelected', 'get') + .mockReturnValue(true) + jest + .spyOn(documentListViewService, 'filterRules', 'get') + .mockReturnValue([{ rule_type: FILTER_TITLE, value: 'apple' }]) + jest + .spyOn(documentListViewService, 'selectedCount', 'get') + .mockReturnValue(25) + jest + .spyOn(permissionsService, 'currentUserHasObjectPermissions') + .mockReturnValue(true) + component.showConfirmationDialogs = false + fixture.detectChanges() + + component.setTags({ + itemsToAdd: [{ id: 101 }], + itemsToRemove: [], + }) + + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}documents/bulk_edit/` + ) + req.flush(true) + expect(req.request.body).toEqual({ + all: true, + filters: { title__icontains: 'apple' }, + method: 'modify_tags', + parameters: { add_tags: [101], remove_tags: [] }, + }) + httpTestingController.match( + `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` + ) // list reload + }) + it('should execute modify tags bulk operation with confirmation dialog if enabled', () => { let modal: NgbModalRef modalService.activeInstances.subscribe((m) => (modal = m[0])) @@ -1089,22 +1219,39 @@ describe('BulkEditorComponent', () => { component.downloadForm.get('downloadFileTypeArchive').patchValue(true) fixture.detectChanges() let downloadSpy = jest.spyOn(documentService, 'bulkDownload') + downloadSpy.mockReturnValue(of(new Blob())) //archive component.downloadSelected() - expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'archive', false) + expect(downloadSpy).toHaveBeenCalledWith( + { documents: [3, 4] }, + 'archive', + false + ) //originals component.downloadForm.get('downloadFileTypeArchive').patchValue(false) component.downloadForm.get('downloadFileTypeOriginals').patchValue(true) component.downloadSelected() - expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'originals', false) + expect(downloadSpy).toHaveBeenCalledWith( + { documents: [3, 4] }, + 'originals', + false + ) //both component.downloadForm.get('downloadFileTypeArchive').patchValue(true) component.downloadSelected() - expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'both', false) + expect(downloadSpy).toHaveBeenCalledWith( + { documents: [3, 4] }, + 'both', + false + ) //formatting component.downloadForm.get('downloadUseFormatting').patchValue(true) component.downloadSelected() - expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'both', true) + expect(downloadSpy).toHaveBeenCalledWith( + { documents: [3, 4] }, + 'both', + true + ) httpTestingController.match( `${environment.apiBaseUrl}documents/bulk_download/` @@ -1450,6 +1597,7 @@ describe('BulkEditorComponent', () => { expect(modal.componentInstance.customFields.length).toEqual(2) expect(modal.componentInstance.fieldsToAddIds).toEqual([1, 2]) + expect(modal.componentInstance.selection).toEqual({ documents: [3, 4] }) expect(modal.componentInstance.documents).toEqual([3, 4]) modal.componentInstance.failed.emit() 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 b97cc76c4..a456ec2cb 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 @@ -31,6 +31,7 @@ import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service import { DocumentTypeService } from 'src/app/services/rest/document-type.service' import { DocumentBulkEditMethod, + DocumentSelectionQuery, DocumentService, MergeDocumentsRequest, } from 'src/app/services/rest/document.service' @@ -41,6 +42,7 @@ import { TagService } from 'src/app/services/rest/tag.service' import { SettingsService } from 'src/app/services/settings.service' import { ToastService } from 'src/app/services/toast.service' import { flattenTags } from 'src/app/utils/flatten-tags' +import { queryParamsFromFilterRules } from 'src/app/utils/query-params' import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component' import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component' import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component' @@ -261,17 +263,13 @@ export class BulkEditorComponent modal: NgbModalRef, method: DocumentBulkEditMethod, args: any, - overrideDocumentIDs?: number[] + overrideSelection?: DocumentSelectionQuery ) { if (modal) { modal.componentInstance.buttonsEnabled = false } this.documentService - .bulkEdit( - overrideDocumentIDs ?? Array.from(this.list.selected), - method, - args - ) + .bulkEdit(overrideSelection ?? this.getSelectionQuery(), method, args) .pipe(first()) .subscribe({ next: () => this.handleOperationSuccess(modal), @@ -329,7 +327,7 @@ export class BulkEditorComponent ) { let selectionData = new Map() items.forEach((i) => { - if (i.document_count == this.list.selected.size) { + if (i.document_count == this.list.selectedCount) { selectionData.set(i.id, ToggleableItemState.Selected) } else if (i.document_count > 0) { selectionData.set(i.id, ToggleableItemState.PartiallySelected) @@ -338,7 +336,31 @@ export class BulkEditorComponent selectionModel.init(selectionData) } + private getSelectionQuery(): DocumentSelectionQuery { + if (this.list.allSelected) { + return { + all: true, + filters: queryParamsFromFilterRules(this.list.filterRules), + } + } + + return { + documents: Array.from(this.list.selected), + } + } + + private getSelectionSize(): number { + return this.list.selectedCount + } + openTagsDropdown() { + if (this.list.allSelected) { + const selectionData = this.list.selectionData + this.tagDocumentCounts = selectionData?.selected_tags ?? [] + this.applySelectionData(this.tagDocumentCounts, this.tagSelectionModel) + return + } + this.documentService .getSelectionData(Array.from(this.list.selected)) .pipe(first()) @@ -349,6 +371,17 @@ export class BulkEditorComponent } openDocumentTypeDropdown() { + if (this.list.allSelected) { + const selectionData = this.list.selectionData + this.documentTypeDocumentCounts = + selectionData?.selected_document_types ?? [] + this.applySelectionData( + this.documentTypeDocumentCounts, + this.documentTypeSelectionModel + ) + return + } + this.documentService .getSelectionData(Array.from(this.list.selected)) .pipe(first()) @@ -362,6 +395,17 @@ export class BulkEditorComponent } openCorrespondentDropdown() { + if (this.list.allSelected) { + const selectionData = this.list.selectionData + this.correspondentDocumentCounts = + selectionData?.selected_correspondents ?? [] + this.applySelectionData( + this.correspondentDocumentCounts, + this.correspondentSelectionModel + ) + return + } + this.documentService .getSelectionData(Array.from(this.list.selected)) .pipe(first()) @@ -375,6 +419,17 @@ export class BulkEditorComponent } openStoragePathDropdown() { + if (this.list.allSelected) { + const selectionData = this.list.selectionData + this.storagePathDocumentCounts = + selectionData?.selected_storage_paths ?? [] + this.applySelectionData( + this.storagePathDocumentCounts, + this.storagePathsSelectionModel + ) + return + } + this.documentService .getSelectionData(Array.from(this.list.selected)) .pipe(first()) @@ -388,6 +443,17 @@ export class BulkEditorComponent } openCustomFieldsDropdown() { + if (this.list.allSelected) { + const selectionData = this.list.selectionData + this.customFieldDocumentCounts = + selectionData?.selected_custom_fields ?? [] + this.applySelectionData( + this.customFieldDocumentCounts, + this.customFieldsSelectionModel + ) + return + } + this.documentService .getSelectionData(Array.from(this.list.selected)) .pipe(first()) @@ -437,33 +503,33 @@ export class BulkEditorComponent changedTags.itemsToRemove.length == 0 ) { let tag = changedTags.itemsToAdd[0] - modal.componentInstance.message = $localize`This operation will add the tag "${tag.name}" to ${this.list.selected.size} selected document(s).` + modal.componentInstance.message = $localize`This operation will add the tag "${tag.name}" to ${this.getSelectionSize()} selected document(s).` } else if ( changedTags.itemsToAdd.length > 1 && changedTags.itemsToRemove.length == 0 ) { modal.componentInstance.message = $localize`This operation will add the tags ${this._localizeList( changedTags.itemsToAdd - )} to ${this.list.selected.size} selected document(s).` + )} to ${this.getSelectionSize()} selected document(s).` } else if ( changedTags.itemsToAdd.length == 0 && changedTags.itemsToRemove.length == 1 ) { let tag = changedTags.itemsToRemove[0] - modal.componentInstance.message = $localize`This operation will remove the tag "${tag.name}" from ${this.list.selected.size} selected document(s).` + modal.componentInstance.message = $localize`This operation will remove the tag "${tag.name}" from ${this.getSelectionSize()} selected document(s).` } else if ( changedTags.itemsToAdd.length == 0 && changedTags.itemsToRemove.length > 1 ) { modal.componentInstance.message = $localize`This operation will remove the tags ${this._localizeList( changedTags.itemsToRemove - )} from ${this.list.selected.size} selected document(s).` + )} from ${this.getSelectionSize()} selected document(s).` } else { modal.componentInstance.message = $localize`This operation will add the tags ${this._localizeList( changedTags.itemsToAdd )} and remove the tags ${this._localizeList( changedTags.itemsToRemove - )} on ${this.list.selected.size} selected document(s).` + )} on ${this.getSelectionSize()} selected document(s).` } modal.componentInstance.btnClass = 'btn-warning' @@ -502,9 +568,9 @@ export class BulkEditorComponent }) modal.componentInstance.title = $localize`Confirm correspondent assignment` if (correspondent) { - modal.componentInstance.message = $localize`This operation will assign the correspondent "${correspondent.name}" to ${this.list.selected.size} selected document(s).` + modal.componentInstance.message = $localize`This operation will assign the correspondent "${correspondent.name}" to ${this.getSelectionSize()} selected document(s).` } else { - modal.componentInstance.message = $localize`This operation will remove the correspondent from ${this.list.selected.size} selected document(s).` + modal.componentInstance.message = $localize`This operation will remove the correspondent from ${this.getSelectionSize()} selected document(s).` } modal.componentInstance.btnClass = 'btn-warning' modal.componentInstance.btnCaption = $localize`Confirm` @@ -540,9 +606,9 @@ export class BulkEditorComponent }) modal.componentInstance.title = $localize`Confirm document type assignment` if (documentType) { - modal.componentInstance.message = $localize`This operation will assign the document type "${documentType.name}" to ${this.list.selected.size} selected document(s).` + modal.componentInstance.message = $localize`This operation will assign the document type "${documentType.name}" to ${this.getSelectionSize()} selected document(s).` } else { - modal.componentInstance.message = $localize`This operation will remove the document type from ${this.list.selected.size} selected document(s).` + modal.componentInstance.message = $localize`This operation will remove the document type from ${this.getSelectionSize()} selected document(s).` } modal.componentInstance.btnClass = 'btn-warning' modal.componentInstance.btnCaption = $localize`Confirm` @@ -578,9 +644,9 @@ export class BulkEditorComponent }) modal.componentInstance.title = $localize`Confirm storage path assignment` if (storagePath) { - modal.componentInstance.message = $localize`This operation will assign the storage path "${storagePath.name}" to ${this.list.selected.size} selected document(s).` + modal.componentInstance.message = $localize`This operation will assign the storage path "${storagePath.name}" to ${this.getSelectionSize()} selected document(s).` } else { - modal.componentInstance.message = $localize`This operation will remove the storage path from ${this.list.selected.size} selected document(s).` + modal.componentInstance.message = $localize`This operation will remove the storage path from ${this.getSelectionSize()} selected document(s).` } modal.componentInstance.btnClass = 'btn-warning' modal.componentInstance.btnCaption = $localize`Confirm` @@ -615,33 +681,33 @@ export class BulkEditorComponent changedCustomFields.itemsToRemove.length == 0 ) { let customField = changedCustomFields.itemsToAdd[0] - modal.componentInstance.message = $localize`This operation will assign the custom field "${customField.name}" to ${this.list.selected.size} selected document(s).` + modal.componentInstance.message = $localize`This operation will assign the custom field "${customField.name}" to ${this.getSelectionSize()} selected document(s).` } else if ( changedCustomFields.itemsToAdd.length > 1 && changedCustomFields.itemsToRemove.length == 0 ) { modal.componentInstance.message = $localize`This operation will assign the custom fields ${this._localizeList( changedCustomFields.itemsToAdd - )} to ${this.list.selected.size} selected document(s).` + )} to ${this.getSelectionSize()} selected document(s).` } else if ( changedCustomFields.itemsToAdd.length == 0 && changedCustomFields.itemsToRemove.length == 1 ) { let customField = changedCustomFields.itemsToRemove[0] - modal.componentInstance.message = $localize`This operation will remove the custom field "${customField.name}" from ${this.list.selected.size} selected document(s).` + modal.componentInstance.message = $localize`This operation will remove the custom field "${customField.name}" from ${this.getSelectionSize()} selected document(s).` } else if ( changedCustomFields.itemsToAdd.length == 0 && changedCustomFields.itemsToRemove.length > 1 ) { modal.componentInstance.message = $localize`This operation will remove the custom fields ${this._localizeList( changedCustomFields.itemsToRemove - )} from ${this.list.selected.size} selected document(s).` + )} from ${this.getSelectionSize()} selected document(s).` } else { modal.componentInstance.message = $localize`This operation will assign the custom fields ${this._localizeList( changedCustomFields.itemsToAdd )} and remove the custom fields ${this._localizeList( changedCustomFields.itemsToRemove - )} on ${this.list.selected.size} selected document(s).` + )} on ${this.getSelectionSize()} selected document(s).` } modal.componentInstance.btnClass = 'btn-warning' @@ -779,7 +845,7 @@ export class BulkEditorComponent backdrop: 'static', }) modal.componentInstance.title = $localize`Confirm` - modal.componentInstance.messageBold = $localize`Move ${this.list.selected.size} selected document(s) to the trash?` + modal.componentInstance.messageBold = $localize`Move ${this.getSelectionSize()} selected document(s) to the trash?` modal.componentInstance.message = $localize`Documents can be restored prior to permanent deletion.` modal.componentInstance.btnClass = 'btn-danger' modal.componentInstance.btnCaption = $localize`Move to trash` @@ -789,13 +855,13 @@ export class BulkEditorComponent modal.componentInstance.buttonsEnabled = false this.executeDocumentAction( modal, - this.documentService.deleteDocuments(Array.from(this.list.selected)) + this.documentService.deleteDocuments(this.getSelectionQuery()) ) }) } else { this.executeDocumentAction( null, - this.documentService.deleteDocuments(Array.from(this.list.selected)) + this.documentService.deleteDocuments(this.getSelectionQuery()) ) } } @@ -811,7 +877,7 @@ export class BulkEditorComponent : 'originals' this.documentService .bulkDownload( - Array.from(this.list.selected), + this.getSelectionQuery(), downloadFileType, this.downloadForm.get('downloadUseFormatting').value ) @@ -827,7 +893,7 @@ export class BulkEditorComponent backdrop: 'static', }) modal.componentInstance.title = $localize`Reprocess confirm` - modal.componentInstance.messageBold = $localize`This operation will permanently recreate the archive files for ${this.list.selected.size} selected document(s).` + modal.componentInstance.messageBold = $localize`This operation will permanently recreate the archive files for ${this.getSelectionSize()} selected document(s).` modal.componentInstance.message = $localize`The archive files will be re-generated with the current settings.` modal.componentInstance.btnClass = 'btn-danger' modal.componentInstance.btnCaption = $localize`Proceed` @@ -837,9 +903,7 @@ export class BulkEditorComponent modal.componentInstance.buttonsEnabled = false this.executeDocumentAction( modal, - this.documentService.reprocessDocuments( - Array.from(this.list.selected) - ) + this.documentService.reprocessDocuments(this.getSelectionQuery()) ) }) } @@ -866,7 +930,7 @@ export class BulkEditorComponent }) const rotateDialog = modal.componentInstance as RotateConfirmDialogComponent rotateDialog.title = $localize`Rotate confirm` - rotateDialog.messageBold = $localize`This operation will add rotated versions of the ${this.list.selected.size} document(s).` + rotateDialog.messageBold = $localize`This operation will add rotated versions of the ${this.getSelectionSize()} document(s).` rotateDialog.btnClass = 'btn-danger' rotateDialog.btnCaption = $localize`Proceed` rotateDialog.documentID = Array.from(this.list.selected)[0] @@ -877,7 +941,7 @@ export class BulkEditorComponent this.executeDocumentAction( modal, this.documentService.rotateDocuments( - Array.from(this.list.selected), + this.getSelectionQuery(), rotateDialog.degrees ) ) @@ -890,7 +954,7 @@ export class BulkEditorComponent }) const mergeDialog = modal.componentInstance as MergeConfirmDialogComponent mergeDialog.title = $localize`Merge confirm` - mergeDialog.messageBold = $localize`This operation will merge ${this.list.selected.size} selected documents into a new document.` + mergeDialog.messageBold = $localize`This operation will merge ${this.getSelectionSize()} selected documents into a new document.` mergeDialog.btnCaption = $localize`Proceed` mergeDialog.documentIDs = Array.from(this.list.selected) mergeDialog.confirmClicked @@ -935,7 +999,7 @@ export class BulkEditorComponent (item) => item.id ) - dialog.documents = Array.from(this.list.selected) + dialog.selection = this.getSelectionQuery() dialog.succeeded.subscribe((result) => { this.toastService.showInfo($localize`Custom fields updated.`) this.list.reload() diff --git a/src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.spec.ts b/src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.spec.ts index 40ec327ba..1e31c0e05 100644 --- a/src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.spec.ts +++ b/src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.spec.ts @@ -48,7 +48,7 @@ describe('CustomFieldsBulkEditDialogComponent', () => { .mockReturnValue(of('Success')) const successSpy = jest.spyOn(component.succeeded, 'emit') - component.documents = [1, 2] + component.selection = [1, 2] component.fieldsToAddIds = [1] component.form.controls['1'].setValue('Value 1') component.save() @@ -63,7 +63,7 @@ describe('CustomFieldsBulkEditDialogComponent', () => { .mockReturnValue(throwError(new Error('Error'))) const failSpy = jest.spyOn(component.failed, 'emit') - component.documents = [1, 2] + component.selection = [1, 2] component.fieldsToAddIds = [1] component.form.controls['1'].setValue('Value 1') component.save() diff --git a/src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.ts b/src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.ts index 8452e5388..7d3878c59 100644 --- a/src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.ts +++ b/src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.ts @@ -17,7 +17,10 @@ import { SelectComponent } from 'src/app/components/common/input/select/select.c import { TextComponent } from 'src/app/components/common/input/text/text.component' import { UrlComponent } from 'src/app/components/common/input/url/url.component' import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' -import { DocumentService } from 'src/app/services/rest/document.service' +import { + DocumentSelectionQuery, + DocumentService, +} from 'src/app/services/rest/document.service' import { TextAreaComponent } from '../../../common/input/textarea/textarea.component' @Component({ @@ -76,7 +79,11 @@ export class CustomFieldsBulkEditDialogComponent { public form: FormGroup = new FormGroup({}) - public documents: number[] = [] + public selection: DocumentSelectionQuery = { documents: [] } + + public get documents(): number[] { + return this.selection.documents + } initForm() { Object.keys(this.form.controls).forEach((key) => { @@ -91,7 +98,7 @@ export class CustomFieldsBulkEditDialogComponent { public save() { this.documentService - .bulkEdit(this.documents, 'modify_custom_fields', { + .bulkEdit(this.selection, 'modify_custom_fields', { add_custom_fields: this.form.value, remove_custom_fields: this.fieldsToRemoveIds, }) diff --git a/src-ui/src/app/components/document-list/document-list.component.html b/src-ui/src/app/components/document-list/document-list.component.html index 176513663..aadec7d77 100644 --- a/src-ui/src/app/components/document-list/document-list.component.html +++ b/src-ui/src/app/components/document-list/document-list.component.html @@ -2,8 +2,8 @@
@@ -17,7 +17,7 @@ Select:
- @if (list.selected.size > 0) { + @if (list.hasSelection) { @@ -127,11 +127,11 @@
Loading... } - @if (list.selected.size > 0) { - {list.collectionSize, plural, =1 {Selected {{list.selected.size}} of one document} other {Selected {{list.selected.size}} of {{list.collectionSize || 0}} documents}} + @if (list.hasSelection) { + {list.collectionSize, plural, =1 {Selected {{list.selectedCount}} of one document} other {Selected {{list.selectedCount}} of {{list.collectionSize || 0}} documents}} } @if (!list.isReloading) { - @if (list.selected.size === 0) { + @if (!list.hasSelection) { {list.collectionSize, plural, =1 {One document} other {{{list.collectionSize || 0}} documents}} } @if (isFiltered) {  (filtered) @@ -142,7 +142,7 @@ Reset filters } - @if (!list.isReloading && list.selected.size > 0) { + @if (!list.isReloading && list.hasSelection) { diff --git a/src-ui/src/app/components/document-list/document-list.component.spec.ts b/src-ui/src/app/components/document-list/document-list.component.spec.ts index 3ea39ccb0..9ea7f27de 100644 --- a/src-ui/src/app/components/document-list/document-list.component.spec.ts +++ b/src-ui/src/app/components/document-list/document-list.component.spec.ts @@ -388,8 +388,8 @@ describe('DocumentListComponent', () => { it('should support select all, none, page & range', () => { jest.spyOn(documentListService, 'documents', 'get').mockReturnValue(docs) jest - .spyOn(documentService, 'listAllFilteredIds') - .mockReturnValue(of(docs.map((d) => d.id))) + .spyOn(documentListService, 'collectionSize', 'get') + .mockReturnValue(docs.length) fixture.detectChanges() expect(documentListService.selected.size).toEqual(0) const docCards = fixture.debugElement.queryAll( @@ -403,7 +403,8 @@ describe('DocumentListComponent', () => { displayModeButtons[2].triggerEventHandler('click') expect(selectAllSpy).toHaveBeenCalled() fixture.detectChanges() - expect(documentListService.selected.size).toEqual(3) + expect(documentListService.allSelected).toBeTruthy() + expect(documentListService.selectedCount).toEqual(3) docCards.forEach((card) => { expect(card.context.selected).toBeTruthy() }) diff --git a/src-ui/src/app/components/document-list/document-list.component.ts b/src-ui/src/app/components/document-list/document-list.component.ts index 2cd2ccaf3..eb453d4dc 100644 --- a/src-ui/src/app/components/document-list/document-list.component.ts +++ b/src-ui/src/app/components/document-list/document-list.component.ts @@ -240,7 +240,7 @@ export class DocumentListComponent } get isBulkEditing(): boolean { - return this.list.selected.size > 0 + return this.list.hasSelection } toggleDisplayField(field: DisplayField) { @@ -327,7 +327,7 @@ export class DocumentListComponent }) .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe(() => { - if (this.list.selected.size > 0) { + if (this.list.hasSelection) { this.list.selectNone() } else if (this.isFiltered) { this.resetFilters() @@ -356,7 +356,7 @@ export class DocumentListComponent .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe(() => { if (this.list.documents.length > 0) { - if (this.list.selected.size > 0) { + if (this.list.hasSelection) { this.openDocumentDetail(Array.from(this.list.selected)[0]) } else { this.openDocumentDetail(this.list.documents[0]) diff --git a/src-ui/src/app/services/document-list-view.service.spec.ts b/src-ui/src/app/services/document-list-view.service.spec.ts index 2b36fa95f..30cb8c701 100644 --- a/src-ui/src/app/services/document-list-view.service.spec.ts +++ b/src-ui/src/app/services/document-list-view.service.spec.ts @@ -534,12 +534,16 @@ describe('DocumentListViewService', () => { }) it('should support select all', () => { - documentListViewService.selectAll() - const req = httpTestingController.expectOne( - `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` + documentListViewService.reload() + const reloadReq = httpTestingController.expectOne( + `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` ) - expect(req.request.method).toEqual('GET') - req.flush(full_results) + expect(reloadReq.request.method).toEqual('GET') + reloadReq.flush(full_results) + + documentListViewService.selectAll() + expect(documentListViewService.allSelected).toBeTruthy() + expect(documentListViewService.selectedCount).toEqual(documents.length) expect(documentListViewService.selected.size).toEqual(documents.length) expect(documentListViewService.isSelected(documents[0])).toBeTruthy() documentListViewService.selectNone() @@ -575,26 +579,62 @@ describe('DocumentListViewService', () => { expect(documentListViewService.isSelected(documents[3])).toBeTruthy() }) - it('should support selection range reduction', () => { + it('should clear all-selected mode when toggling a single document', () => { + documentListViewService.reload() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` + ) + req.flush(full_results) + documentListViewService.selectAll() + expect(documentListViewService.allSelected).toBeTruthy() + + documentListViewService.toggleSelected(documents[0]) + + expect(documentListViewService.allSelected).toBeFalsy() + expect(documentListViewService.isSelected(documents[0])).toBeFalsy() + }) + + it('should clear all-selected mode when selecting a range', () => { + documentListViewService.reload() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` + ) + req.flush(full_results) + + documentListViewService.selectAll() + documentListViewService.toggleSelected(documents[1]) + documentListViewService.selectAll() + expect(documentListViewService.allSelected).toBeTruthy() + + documentListViewService.selectRangeTo(documents[3]) + + expect(documentListViewService.allSelected).toBeFalsy() + expect(documentListViewService.isSelected(documents[1])).toBeTruthy() + expect(documentListViewService.isSelected(documents[2])).toBeTruthy() + expect(documentListViewService.isSelected(documents[3])).toBeTruthy() + }) + + it('should support selection range reduction', () => { + documentListViewService.reload() let req = httpTestingController.expectOne( - `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` + `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` ) expect(req.request.method).toEqual('GET') req.flush(full_results) + + documentListViewService.selectAll() expect(documentListViewService.selected.size).toEqual(6) documentListViewService.setFilterRules(filterRules) - httpTestingController.expectOne( + req = httpTestingController.expectOne( `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=9` ) - const reqs = httpTestingController.match( - `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id&tags__id__all=9` - ) - reqs[0].flush({ + req.flush({ count: 3, results: documents.slice(0, 3), }) + expect(documentListViewService.allSelected).toBeTruthy() expect(documentListViewService.selected.size).toEqual(3) }) diff --git a/src-ui/src/app/services/document-list-view.service.ts b/src-ui/src/app/services/document-list-view.service.ts index 86888a088..1d37000bf 100644 --- a/src-ui/src/app/services/document-list-view.service.ts +++ b/src-ui/src/app/services/document-list-view.service.ts @@ -80,6 +80,11 @@ export interface ListViewState { */ selected?: Set + /** + * True if the full filtered result set is selected. + */ + allSelected?: boolean + /** * The page size of the list view. */ @@ -199,6 +204,20 @@ export class DocumentListViewService { sortReverse: true, filterRules: [], selected: new Set(), + allSelected: false, + } + } + + private syncSelectedToCurrentPage() { + if (!this.allSelected) { + return + } + + this.selected.clear() + this.documents?.forEach((doc) => this.selected.add(doc.id)) + + if (!this.collectionSize) { + this.selectNone() } } @@ -305,6 +324,7 @@ export class DocumentListViewService { activeListViewState.collectionSize = result.count activeListViewState.documents = result.results this.selectionData = resultWithSelectionData.selection_data ?? null + this.syncSelectedToCurrentPage() if (updateQueryParams && !this._activeSavedViewId) { let base = ['/documents'] @@ -437,6 +457,20 @@ export class DocumentListViewService { return this.activeListViewState.selected } + get allSelected(): boolean { + return this.activeListViewState.allSelected ?? false + } + + get selectedCount(): number { + return this.allSelected + ? (this.collectionSize ?? this.selected.size) + : this.selected.size + } + + get hasSelection(): boolean { + return this.allSelected || this.selected.size > 0 + } + setSort(field: string, reverse: boolean) { this.activeListViewState.sortField = field this.activeListViewState.sortReverse = reverse @@ -591,11 +625,16 @@ export class DocumentListViewService { } selectNone() { + this.activeListViewState.allSelected = false this.selected.clear() this.rangeSelectionAnchorIndex = this.lastRangeSelectionToIndex = null } reduceSelectionToFilter() { + if (this.allSelected) { + return + } + if (this.selected.size > 0) { this.documentService .listAllFilteredIds(this.filterRules) @@ -610,12 +649,12 @@ export class DocumentListViewService { } selectAll() { - this.documentService - .listAllFilteredIds(this.filterRules) - .subscribe((ids) => ids.forEach((id) => this.selected.add(id))) + this.activeListViewState.allSelected = true + this.syncSelectedToCurrentPage() } selectPage() { + this.activeListViewState.allSelected = false this.selected.clear() this.documents.forEach((doc) => { this.selected.add(doc.id) @@ -623,10 +662,13 @@ export class DocumentListViewService { } isSelected(d: Document) { - return this.selected.has(d.id) + return this.allSelected || this.selected.has(d.id) } toggleSelected(d: Document): void { + if (this.allSelected) { + this.activeListViewState.allSelected = false + } if (this.selected.has(d.id)) this.selected.delete(d.id) else this.selected.add(d.id) this.rangeSelectionAnchorIndex = this.documentIndexInCurrentView(d.id) @@ -634,6 +676,10 @@ export class DocumentListViewService { } selectRangeTo(d: Document) { + if (this.allSelected) { + this.activeListViewState.allSelected = false + } + if (this.rangeSelectionAnchorIndex !== null) { const documentToIndex = this.documentIndexInCurrentView(d.id) const fromIndex = Math.min( 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 b3a9757ff..711aab743 100644 --- a/src-ui/src/app/services/rest/document.service.spec.ts +++ b/src-ui/src/app/services/rest/document.service.spec.ts @@ -198,7 +198,7 @@ describe(`DocumentService`, () => { const content = 'both' const useFilenameFormatting = false subscription = service - .bulkDownload(ids, content, useFilenameFormatting) + .bulkDownload({ documents: ids }, content, useFilenameFormatting) .subscribe() const req = httpTestingController.expectOne( `${environment.apiBaseUrl}${endpoint}/bulk_download/` @@ -218,7 +218,9 @@ describe(`DocumentService`, () => { add_tags: [15], remove_tags: [6], } - subscription = service.bulkEdit(ids, method, parameters).subscribe() + subscription = service + .bulkEdit({ documents: ids }, method, parameters) + .subscribe() const req = httpTestingController.expectOne( `${environment.apiBaseUrl}${endpoint}/bulk_edit/` ) @@ -230,9 +232,32 @@ describe(`DocumentService`, () => { }) }) + it('should call appropriate api endpoint for bulk edit with all and filters', () => { + const method = 'modify_tags' + const parameters = { + add_tags: [15], + remove_tags: [6], + } + const selection = { + all: true, + filters: { title__icontains: 'apple' }, + } + subscription = service.bulkEdit(selection, method, parameters).subscribe() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}${endpoint}/bulk_edit/` + ) + expect(req.request.method).toEqual('POST') + expect(req.request.body).toEqual({ + all: true, + filters: { title__icontains: 'apple' }, + method, + parameters, + }) + }) + it('should call appropriate api endpoint for delete documents', () => { const ids = [1, 2, 3] - subscription = service.deleteDocuments(ids).subscribe() + subscription = service.deleteDocuments({ documents: ids }).subscribe() const req = httpTestingController.expectOne( `${environment.apiBaseUrl}${endpoint}/delete/` ) @@ -244,7 +269,7 @@ describe(`DocumentService`, () => { it('should call appropriate api endpoint for reprocess documents', () => { const ids = [1, 2, 3] - subscription = service.reprocessDocuments(ids).subscribe() + subscription = service.reprocessDocuments({ documents: ids }).subscribe() const req = httpTestingController.expectOne( `${environment.apiBaseUrl}${endpoint}/reprocess/` ) @@ -256,7 +281,7 @@ describe(`DocumentService`, () => { it('should call appropriate api endpoint for rotate documents', () => { const ids = [1, 2, 3] - subscription = service.rotateDocuments(ids, 90).subscribe() + subscription = service.rotateDocuments({ documents: ids }, 90).subscribe() const req = httpTestingController.expectOne( `${environment.apiBaseUrl}${endpoint}/rotate/` ) diff --git a/src-ui/src/app/services/rest/document.service.ts b/src-ui/src/app/services/rest/document.service.ts index 203b35341..cfee4c405 100644 --- a/src-ui/src/app/services/rest/document.service.ts +++ b/src-ui/src/app/services/rest/document.service.ts @@ -68,6 +68,12 @@ export interface RemovePasswordDocumentsRequest { source_mode?: BulkEditSourceMode } +export interface DocumentSelectionQuery { + documents?: number[] + all?: boolean + filters?: { [key: string]: any } +} + @Injectable({ providedIn: 'root', }) @@ -325,33 +331,37 @@ export class DocumentService extends AbstractPaperlessService { return this.http.get(url.toString()) } - bulkEdit(ids: number[], method: DocumentBulkEditMethod, args: any) { + bulkEdit( + selection: DocumentSelectionQuery, + method: DocumentBulkEditMethod, + args: any + ) { return this.http.post(this.getResourceUrl(null, 'bulk_edit'), { - documents: ids, + ...selection, method: method, parameters: args, }) } - deleteDocuments(ids: number[]) { + deleteDocuments(selection: DocumentSelectionQuery) { return this.http.post(this.getResourceUrl(null, 'delete'), { - documents: ids, + ...selection, }) } - reprocessDocuments(ids: number[]) { + reprocessDocuments(selection: DocumentSelectionQuery) { return this.http.post(this.getResourceUrl(null, 'reprocess'), { - documents: ids, + ...selection, }) } rotateDocuments( - ids: number[], + selection: DocumentSelectionQuery, degrees: number, sourceMode: BulkEditSourceMode = BulkEditSourceMode.LATEST_VERSION ) { return this.http.post(this.getResourceUrl(null, 'rotate'), { - documents: ids, + ...selection, degrees, source_mode: sourceMode, }) @@ -399,14 +409,14 @@ export class DocumentService extends AbstractPaperlessService { } bulkDownload( - ids: number[], + selection: DocumentSelectionQuery, content = 'both', useFilenameFormatting: boolean = false ) { return this.http.post( this.getResourceUrl(null, 'bulk_download'), { - documents: ids, + ...selection, content: content, follow_formatting: useFilenameFormatting, }, diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 8f96f638d..a8beb70c0 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -1558,6 +1558,41 @@ class DocumentListSerializer(serializers.Serializer): return documents +class DocumentSelectionSerializer(DocumentListSerializer): + documents = serializers.ListField( + required=False, + label="Documents", + write_only=True, + child=serializers.IntegerField(), + ) + + all = serializers.BooleanField( + default=False, + required=False, + write_only=True, + ) + + filters = serializers.DictField( + required=False, + allow_empty=True, + write_only=True, + ) + + def validate(self, attrs): + if attrs.get("all", False): + attrs.setdefault("documents", []) + return attrs + + if "documents" not in attrs: + raise serializers.ValidationError( + "documents is required unless all is true.", + ) + + documents = attrs["documents"] + self._validate_document_id_list(documents) + return attrs + + class SourceModeValidationMixin: def validate_source_mode(self, source_mode: str) -> str: if source_mode not in bulk_edit.SourceModeChoices.__dict__.values(): @@ -1565,7 +1600,7 @@ class SourceModeValidationMixin: return source_mode -class RotateDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin): +class RotateDocumentsSerializer(DocumentSelectionSerializer, SourceModeValidationMixin): degrees = serializers.IntegerField(required=True) source_mode = serializers.CharField( required=False, @@ -1648,17 +1683,17 @@ class RemovePasswordDocumentsSerializer( ) -class DeleteDocumentsSerializer(DocumentListSerializer): +class DeleteDocumentsSerializer(DocumentSelectionSerializer): pass -class ReprocessDocumentsSerializer(DocumentListSerializer): +class ReprocessDocumentsSerializer(DocumentSelectionSerializer): pass class BulkEditSerializer( SerializerWithPerms, - DocumentListSerializer, + DocumentSelectionSerializer, SetPermissionsMixin, SourceModeValidationMixin, ): @@ -1986,6 +2021,19 @@ class BulkEditSerializer( raise serializers.ValidationError("password must be a string") def validate(self, attrs): + attrs = super().validate(attrs) + + if attrs.get("all", False) and attrs["method"] in [ + bulk_edit.merge, + bulk_edit.split, + bulk_edit.delete_pages, + bulk_edit.edit_pdf, + bulk_edit.remove_password, + ]: + raise serializers.ValidationError( + "This method does not support all=true.", + ) + method = attrs["method"] parameters = attrs["parameters"] @@ -2243,7 +2291,7 @@ class DocumentVersionLabelSerializer(serializers.Serializer): return normalized or None -class BulkDownloadSerializer(DocumentListSerializer): +class BulkDownloadSerializer(DocumentSelectionSerializer): content = serializers.ChoiceField( choices=["archive", "originals", "both"], default="archive", diff --git a/src/documents/tests/test_api_bulk_edit.py b/src/documents/tests/test_api_bulk_edit.py index 86ef8bb44..ff780dccd 100644 --- a/src/documents/tests/test_api_bulk_edit.py +++ b/src/documents/tests/test_api_bulk_edit.py @@ -614,6 +614,63 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(Document.objects.count(), 5) + def test_api_requires_documents_unless_all_is_true(self) -> None: + response = self.client.post( + "/api/documents/bulk_edit/", + json.dumps( + { + "method": "set_storage_path", + "parameters": {"storage_path": self.sp1.id}, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn(b"documents is required unless all is true", response.content) + + @mock.patch("documents.serialisers.bulk_edit.set_storage_path") + def test_api_bulk_edit_with_all_true_resolves_documents_from_filters( + self, + m, + ) -> None: + self.setup_mock(m, "set_storage_path") + + response = self.client.post( + "/api/documents/bulk_edit/", + json.dumps( + { + "all": True, + "filters": {"title__icontains": "B"}, + "method": "set_storage_path", + "parameters": {"storage_path": self.sp1.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.doc2.id]) + self.assertEqual(kwargs["storage_path"], self.sp1.id) + + def test_api_bulk_edit_with_all_true_rejects_unsupported_methods(self) -> None: + response = self.client.post( + "/api/documents/bulk_edit/", + json.dumps( + { + "all": True, + "method": "merge", + "parameters": {"metadata_document_id": self.doc2.id}, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn(b"This method does not support all=true", response.content) + def test_api_invalid_method(self) -> None: self.assertEqual(Document.objects.count(), 5) response = self.client.post( diff --git a/src/documents/views.py b/src/documents/views.py index 3bcf77430..244e81161 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -2241,7 +2241,36 @@ class SavedViewViewSet(BulkPermissionMixin, PassUserMixin, ModelViewSet): ordering_fields = ("name",) -class DocumentOperationPermissionMixin(PassUserMixin): +class DocumentSelectionMixin: + def _resolve_document_ids( + self, + *, + user: User, + validated_data: dict[str, Any], + permission_codename: str = "view_document", + ) -> list[int]: + if not validated_data.get("all", False): + # if all is not true, just pass through the provided document ids + return validated_data["documents"] + + # otherwise, reconstruct the document list based on the provided filters + filters = validated_data.get("filters") or {} + permitted_documents = get_objects_for_user_owner_aware( + user, + permission_codename, + Document, + ) + return list( + DocumentFilterSet( + data=filters, + queryset=permitted_documents, + ) + .qs.distinct() + .values_list("pk", flat=True), + ) + + +class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin): permission_classes = (IsAuthenticated,) parser_classes = (parsers.JSONParser,) METHOD_NAMES_REQUIRING_USER = { @@ -2335,8 +2364,15 @@ class DocumentOperationPermissionMixin(PassUserMixin): 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"} + documents = self._resolve_document_ids( + user=self.request.user, + validated_data=validated_data, + ) + parameters = { + k: v + for k, v in validated_data.items() + if k not in {"documents", "all", "filters"} + } user = self.request.user if method.__name__ in self.METHOD_NAMES_REQUIRING_USER: @@ -2424,7 +2460,10 @@ class BulkEditView(DocumentOperationPermissionMixin): user = self.request.user method = serializer.validated_data.get("method") parameters = serializer.validated_data.get("parameters") - documents = serializer.validated_data.get("documents") + documents = self._resolve_document_ids( + user=user, + validated_data=serializer.validated_data, + ) if method.__name__ in self.METHOD_NAMES_REQUIRING_USER: parameters["user"] = user if not self._has_document_permissions( @@ -3276,7 +3315,7 @@ class StatisticsView(GenericAPIView): ) -class BulkDownloadView(GenericAPIView): +class BulkDownloadView(DocumentSelectionMixin, GenericAPIView): permission_classes = (IsAuthenticated,) serializer_class = BulkDownloadSerializer parser_classes = (parsers.JSONParser,) @@ -3285,7 +3324,10 @@ class BulkDownloadView(GenericAPIView): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - ids = serializer.validated_data.get("documents") + ids = self._resolve_document_ids( + user=request.user, + validated_data=serializer.validated_data, + ) documents = Document.objects.filter(pk__in=ids) compression = serializer.validated_data.get("compression") content = serializer.validated_data.get("content")