diff --git a/docs/api.md b/docs/api.md index bd5154ada..fa32bf2aa 100644 --- a/docs/api.md +++ b/docs/api.md @@ -375,14 +375,15 @@ The following methods are supported: ### Objects -Bulk editing for objects (tags, document types etc.) currently supports only updating permissions, using -the endpoint: `/api/bulk_edit_object_perms/` which requires a json payload of the format: +Bulk editing for objects (tags, document types etc.) currently supports set permissions or delete +operations, using the endpoint: `/api/bulk_edit_objects/`, which requires a json payload of the format: ```json { "objects": [LIST_OF_OBJECT_IDS], - "object_type": "tags", "correspondents", "document_types" or "storage_paths" - "owner": OWNER_ID // optional + "object_type": "tags", "correspondents", "document_types" or "storage_paths", + "operation": "set_permissions" or "delete", + "owner": OWNER_ID, // optional "permissions": { "view": { "users": [] ... }, "change": { ... } }, // (see 'set_permissions' format above) "merge": true / false // defaults to false, see above } diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index f2b356d9a..a959d9fb2 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -644,19 +644,19 @@ src/app/components/manage/management-list/management-list.component.html - 48 + 51 src/app/components/manage/management-list/management-list.component.html - 48 + 51 src/app/components/manage/management-list/management-list.component.html - 48 + 51 src/app/components/manage/management-list/management-list.component.html - 48 + 51 @@ -966,7 +966,7 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.html - 78 + 82 src/app/components/document-list/filter-editor/filter-editor.component.html @@ -1262,35 +1262,35 @@ src/app/components/manage/management-list/management-list.component.html - 17 + 20 src/app/components/manage/management-list/management-list.component.html - 17 + 20 src/app/components/manage/management-list/management-list.component.html - 17 + 20 src/app/components/manage/management-list/management-list.component.html - 17 + 20 src/app/components/manage/management-list/management-list.component.html - 34 + 37 src/app/components/manage/management-list/management-list.component.html - 34 + 37 src/app/components/manage/management-list/management-list.component.html - 34 + 37 src/app/components/manage/management-list/management-list.component.html - 34 + 37 src/app/components/manage/workflows/workflows.component.html @@ -1354,7 +1354,7 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.html - 84 + 88 src/app/components/manage/custom-fields/custom-fields.component.html @@ -1370,19 +1370,19 @@ src/app/components/manage/management-list/management-list.component.html - 40 + 43 src/app/components/manage/management-list/management-list.component.html - 40 + 43 src/app/components/manage/management-list/management-list.component.html - 40 + 43 src/app/components/manage/management-list/management-list.component.html - 40 + 43 src/app/components/manage/workflows/workflows.component.html @@ -1429,7 +1429,7 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.html - 136 + 140 src/app/components/manage/custom-fields/custom-fields.component.html @@ -1445,39 +1445,55 @@ src/app/components/manage/management-list/management-list.component.html - 81 + 9 src/app/components/manage/management-list/management-list.component.html - 81 + 9 src/app/components/manage/management-list/management-list.component.html - 81 + 9 src/app/components/manage/management-list/management-list.component.html - 81 + 9 src/app/components/manage/management-list/management-list.component.html - 93 + 84 src/app/components/manage/management-list/management-list.component.html - 93 + 84 src/app/components/manage/management-list/management-list.component.html - 93 + 84 src/app/components/manage/management-list/management-list.component.html - 93 + 84 + + + src/app/components/manage/management-list/management-list.component.html + 96 + + + src/app/components/manage/management-list/management-list.component.html + 96 + + + src/app/components/manage/management-list/management-list.component.html + 96 + + + src/app/components/manage/management-list/management-list.component.html + 96 src/app/components/manage/management-list/management-list.component.ts - 205 + 208 src/app/components/manage/workflows/workflows.component.html @@ -1881,35 +1897,35 @@ src/app/components/manage/management-list/management-list.component.html - 80 + 83 src/app/components/manage/management-list/management-list.component.html - 80 + 83 src/app/components/manage/management-list/management-list.component.html - 80 + 83 src/app/components/manage/management-list/management-list.component.html - 80 + 83 src/app/components/manage/management-list/management-list.component.html - 90 + 93 src/app/components/manage/management-list/management-list.component.html - 90 + 93 src/app/components/manage/management-list/management-list.component.html - 90 + 93 src/app/components/manage/management-list/management-list.component.html - 90 + 93 src/app/components/manage/workflows/workflows.component.html @@ -1985,11 +2001,11 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 489 + 580 src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 528 + 619 src/app/components/manage/custom-fields/custom-fields.component.ts @@ -2003,6 +2019,10 @@ src/app/components/manage/mail/mail.component.ts 173 + + src/app/components/manage/management-list/management-list.component.ts + 320 + src/app/components/manage/workflows/workflows.component.ts 97 @@ -2024,7 +2044,7 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 530 + 621 src/app/components/manage/custom-fields/custom-fields.component.ts @@ -2038,6 +2058,10 @@ src/app/components/manage/mail/mail.component.ts 175 + + src/app/components/manage/management-list/management-list.component.ts + 322 + src/app/components/manage/workflows/workflows.component.ts 99 @@ -2180,19 +2204,19 @@ src/app/components/manage/management-list/management-list.component.html - 87 + 90 src/app/components/manage/management-list/management-list.component.html - 87 + 90 src/app/components/manage/management-list/management-list.component.html - 87 + 90 src/app/components/manage/management-list/management-list.component.html - 87 + 90 @@ -2456,19 +2480,19 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 351 + 356 src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 391 + 396 src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 429 + 434 src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 467 + 472 @@ -3713,18 +3737,45 @@ 27 + + Create + + src/app/components/common/filterable-dropdown/filterable-dropdown.component.html + 50 + + + src/app/components/common/share-links-dropdown/share-links-dropdown.component.html + 64 + + + src/app/components/manage/management-list/management-list.component.html + 12 + + + src/app/components/manage/management-list/management-list.component.html + 12 + + + src/app/components/manage/management-list/management-list.component.html + 12 + + + src/app/components/manage/management-list/management-list.component.html + 12 + + Apply src/app/components/common/filterable-dropdown/filterable-dropdown.component.html - 49 + 56 Click again to exclude items. src/app/components/common/filterable-dropdown/filterable-dropdown.component.html - 55 + 63 @@ -4248,29 +4299,6 @@ 51 - - Create - - src/app/components/common/share-links-dropdown/share-links-dropdown.component.html - 64 - - - src/app/components/manage/management-list/management-list.component.html - 9 - - - src/app/components/manage/management-list/management-list.component.html - 9 - - - src/app/components/manage/management-list/management-list.component.html - 9 - - - src/app/components/manage/management-list/management-list.component.html - 9 - - 1 day @@ -4423,7 +4451,7 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.html - 35 + 36 src/app/components/document-list/document-list.component.html @@ -4457,7 +4485,7 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.html - 102 + 106 src/app/components/document-list/document-card-large/document-card-large.component.html @@ -4590,7 +4618,7 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 296 + 301 this string is used to separate processing, failed and added on the file upload widget @@ -4683,7 +4711,7 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.html - 87 + 91 @@ -4744,7 +4772,7 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.html - 48 + 50 src/app/components/document-list/document-list.component.html @@ -4767,7 +4795,7 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.html - 61 + 64 src/app/components/document-list/document-list.component.html @@ -4994,7 +5022,11 @@ src/app/components/manage/management-list/management-list.component.ts - 201 + 204 + + + src/app/components/manage/management-list/management-list.component.ts + 318 @@ -5033,7 +5065,7 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 526 + 617 @@ -5093,7 +5125,7 @@ Filter correspondents src/app/components/document-list/bulk-editor/bulk-editor.component.html - 36 + 37 src/app/components/document-list/filter-editor/filter-editor.component.html @@ -5104,7 +5136,7 @@ Filter document types src/app/components/document-list/bulk-editor/bulk-editor.component.html - 49 + 51 src/app/components/document-list/filter-editor/filter-editor.component.html @@ -5115,7 +5147,7 @@ Filter storage paths src/app/components/document-list/bulk-editor/bulk-editor.component.html - 62 + 65 src/app/components/document-list/filter-editor/filter-editor.component.html @@ -5126,53 +5158,53 @@ Include: src/app/components/document-list/bulk-editor/bulk-editor.component.html - 108 + 112 Archived files src/app/components/document-list/bulk-editor/bulk-editor.component.html - 112,114 + 116,118 Original files src/app/components/document-list/bulk-editor/bulk-editor.component.html - 118,120 + 122,124 Use formatted filename src/app/components/document-list/bulk-editor/bulk-editor.component.html - 125,127 + 129,131 Error executing bulk operation src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 213 + 218 "" src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 288 + 293 src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 294 + 299 "" and "" src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 290 + 295 This is for messages like 'modify "tag1" and "tag2"' @@ -5180,7 +5212,7 @@ and "" src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 298,300 + 303,305 this is for messages like 'modify "tag1", "tag2" and "tag3"' @@ -5188,14 +5220,14 @@ Confirm tags assignment src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 315 + 320 This operation will add the tag "" to selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 321 + 326 @@ -5204,14 +5236,14 @@ )"/> to selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 326,328 + 331,333 This operation will remove the tag "" from selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 334 + 339 @@ -5220,7 +5252,7 @@ )"/> from selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 339,341 + 344,346 @@ -5231,98 +5263,98 @@ )"/> on selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 343,347 + 348,352 Confirm correspondent assignment src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 384 + 389 This operation will assign the correspondent "" to selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 386 + 391 This operation will remove the correspondent from selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 388 + 393 Confirm document type assignment src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 422 + 427 This operation will assign the document type "" to selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 424 + 429 This operation will remove the document type from selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 426 + 431 Confirm storage path assignment src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 460 + 465 This operation will assign the storage path "" to selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 462 + 467 This operation will remove the storage path from selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 464 + 469 Delete confirm src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 487 + 578 This operation will permanently delete selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 488 + 579 Delete document(s) src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 491 + 582 This operation will permanently redo OCR for selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 527 + 618 @@ -6188,109 +6220,109 @@ src/app/components/manage/management-list/management-list.component.ts - 301 + 305 Filter by: src/app/components/manage/management-list/management-list.component.html - 16 + 19 src/app/components/manage/management-list/management-list.component.html - 16 + 19 src/app/components/manage/management-list/management-list.component.html - 16 + 19 src/app/components/manage/management-list/management-list.component.html - 16 + 19 Matching src/app/components/manage/management-list/management-list.component.html - 35 + 38 src/app/components/manage/management-list/management-list.component.html - 35 + 38 src/app/components/manage/management-list/management-list.component.html - 35 + 38 src/app/components/manage/management-list/management-list.component.html - 35 + 38 Document count src/app/components/manage/management-list/management-list.component.html - 36 + 39 src/app/components/manage/management-list/management-list.component.html - 36 + 39 src/app/components/manage/management-list/management-list.component.html - 36 + 39 src/app/components/manage/management-list/management-list.component.html - 36 + 39 Filter Documents src/app/components/manage/management-list/management-list.component.html - 79 + 82 src/app/components/manage/management-list/management-list.component.html - 79 + 82 src/app/components/manage/management-list/management-list.component.html - 79 + 82 src/app/components/manage/management-list/management-list.component.html - 79 + 82 {VAR_PLURAL, plural, =1 {One } other { total }} src/app/components/manage/management-list/management-list.component.html - 107 + 110 src/app/components/manage/management-list/management-list.component.html - 107 + 110 src/app/components/manage/management-list/management-list.component.html - 107 + 110 src/app/components/manage/management-list/management-list.component.html - 107 + 110 Automatic src/app/components/manage/management-list/management-list.component.ts - 113 + 116 src/app/data/matching-model.ts @@ -6301,7 +6333,7 @@ None src/app/components/manage/management-list/management-list.component.ts - 115 + 118 src/app/data/matching-model.ts @@ -6312,49 +6344,70 @@ Successfully created . src/app/components/manage/management-list/management-list.component.ts - 158 + 161 Error occurred while creating . src/app/components/manage/management-list/management-list.component.ts - 163 + 166 Successfully updated . src/app/components/manage/management-list/management-list.component.ts - 178 + 181 Error occurred while saving . src/app/components/manage/management-list/management-list.component.ts - 183 + 186 Associated documents will not be deleted. src/app/components/manage/management-list/management-list.component.ts - 203 + 206 Error while deleting element src/app/components/manage/management-list/management-list.component.ts - 219 + 222 Permissions updated successfully src/app/components/manage/management-list/management-list.component.ts - 294 + 298 + + + + This operation will permanently delete all objects. + + src/app/components/manage/management-list/management-list.component.ts + 319 + + + + Objects deleted successfully + + src/app/components/manage/management-list/management-list.component.ts + 333 + + + + Error deleting objects + + src/app/components/manage/management-list/management-list.component.ts + 339 diff --git a/src-ui/src/app/components/manage/management-list/management-list.component.html b/src-ui/src/app/components/manage/management-list/management-list.component.html index 9d6cf87c5..58101c388 100644 --- a/src-ui/src/app/components/manage/management-list/management-list.component.html +++ b/src-ui/src/app/components/manage/management-list/management-list.component.html @@ -2,9 +2,12 @@ - + diff --git a/src-ui/src/app/components/manage/management-list/management-list.component.spec.ts b/src-ui/src/app/components/manage/management-list/management-list.component.spec.ts index 6196a3c8a..710d3018a 100644 --- a/src-ui/src/app/components/manage/management-list/management-list.component.spec.ts +++ b/src-ui/src/app/components/manage/management-list/management-list.component.spec.ts @@ -37,6 +37,7 @@ import { MATCH_NONE } from 'src/app/data/matching-model' import { MATCH_LITERAL } from 'src/app/data/matching-model' import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' +import { BulkEditObjectOperation } from 'src/app/services/rest/abstract-name-filter-service' const tags: Tag[] = [ { @@ -149,7 +150,7 @@ describe('ManagementListComponent', () => { const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const reloadSpy = jest.spyOn(component, 'reloadData') - const createButton = fixture.debugElement.queryAll(By.css('button'))[2] + const createButton = fixture.debugElement.queryAll(By.css('button'))[3] createButton.triggerEventHandler('click') expect(modal).not.toBeUndefined() @@ -173,7 +174,7 @@ describe('ManagementListComponent', () => { const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const reloadSpy = jest.spyOn(component, 'reloadData') - const editButton = fixture.debugElement.queryAll(By.css('button'))[6] + const editButton = fixture.debugElement.queryAll(By.css('button'))[7] editButton.triggerEventHandler('click') expect(modal).not.toBeUndefined() @@ -198,7 +199,7 @@ describe('ManagementListComponent', () => { const deleteSpy = jest.spyOn(tagService, 'delete') const reloadSpy = jest.spyOn(component, 'reloadData') - const deleteButton = fixture.debugElement.queryAll(By.css('button'))[7] + const deleteButton = fixture.debugElement.queryAll(By.css('button'))[8] deleteButton.triggerEventHandler('click') expect(modal).not.toBeUndefined() @@ -218,7 +219,7 @@ describe('ManagementListComponent', () => { it('should support quick filter for objects', () => { const qfSpy = jest.spyOn(documentListViewService, 'quickFilter') - const filterButton = fixture.debugElement.queryAll(By.css('button'))[5] + const filterButton = fixture.debugElement.queryAll(By.css('button'))[6] filterButton.triggerEventHandler('click') expect(qfSpy).toHaveBeenCalledWith([ { rule_type: FILTER_HAS_TAGS_ALL, value: tags[0].id.toString() }, @@ -246,7 +247,7 @@ describe('ManagementListComponent', () => { }) it('should support bulk edit permissions', () => { - const bulkEditPermsSpy = jest.spyOn(tagService, 'bulk_update_permissions') + const bulkEditPermsSpy = jest.spyOn(tagService, 'bulk_edit_objects') component.toggleSelected(tags[0]) component.toggleSelected(tags[1]) component.toggleSelected(tags[2]) @@ -280,4 +281,35 @@ describe('ManagementListComponent', () => { expect(bulkEditPermsSpy).toHaveBeenCalled() expect(successToastSpy).toHaveBeenCalled() }) + + it('should support bulk delete objects', () => { + const bulkEditSpy = jest.spyOn(tagService, 'bulk_edit_objects') + component.toggleSelected(tags[0]) + component.toggleSelected(tags[1]) + const selected = new Set([tags[0].id, tags[1].id]) + expect(component.selectedObjects).toEqual(selected) + let modal: NgbModalRef + modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1])) + fixture.detectChanges() + component.delete() + expect(modal).not.toBeUndefined() + + // fail first + bulkEditSpy.mockReturnValueOnce( + throwError(() => new Error('error setting permissions')) + ) + const errorToastSpy = jest.spyOn(toastService, 'showError') + modal.componentInstance.confirmClicked.emit(null) + expect(bulkEditSpy).toHaveBeenCalledWith( + Array.from(selected), + BulkEditObjectOperation.Delete + ) + expect(errorToastSpy).toHaveBeenCalled() + + const successToastSpy = jest.spyOn(toastService, 'showInfo') + bulkEditSpy.mockReturnValueOnce(of('OK')) + modal.componentInstance.confirmClicked.emit(null) + expect(bulkEditSpy).toHaveBeenCalled() + expect(successToastSpy).toHaveBeenCalled() + }) }) diff --git a/src-ui/src/app/components/manage/management-list/management-list.component.ts b/src-ui/src/app/components/manage/management-list/management-list.component.ts index a89b5e4f6..0b0365f06 100644 --- a/src-ui/src/app/components/manage/management-list/management-list.component.ts +++ b/src-ui/src/app/components/manage/management-list/management-list.component.ts @@ -28,7 +28,10 @@ import { PermissionsService, PermissionType, } from 'src/app/services/permissions.service' -import { AbstractNameFilterService } from 'src/app/services/rest/abstract-name-filter-service' +import { + AbstractNameFilterService, + BulkEditObjectOperation, +} from 'src/app/services/rest/abstract-name-filter-service' import { ToastService } from 'src/app/services/toast.service' import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component' @@ -282,8 +285,9 @@ export abstract class ManagementListComponent ({ permissions, merge }) => { modal.componentInstance.buttonsEnabled = false this.service - .bulk_update_permissions( + .bulk_edit_objects( Array.from(this.selectedObjects), + BulkEditObjectOperation.SetPermissions, permissions, merge ) @@ -306,4 +310,37 @@ export abstract class ManagementListComponent } ) } + + delete() { + let modal = this.modalService.open(ConfirmDialogComponent, { + backdrop: 'static', + }) + modal.componentInstance.title = $localize`Confirm delete` + modal.componentInstance.messageBold = $localize`This operation will permanently delete all objects.` + modal.componentInstance.message = $localize`This operation cannot be undone.` + modal.componentInstance.btnClass = 'btn-danger' + modal.componentInstance.btnCaption = $localize`Proceed` + modal.componentInstance.confirmClicked.subscribe(() => { + modal.componentInstance.buttonsEnabled = false + this.service + .bulk_edit_objects( + Array.from(this.selectedObjects), + BulkEditObjectOperation.Delete + ) + .subscribe({ + next: () => { + modal.close() + this.toastService.showInfo($localize`Objects deleted successfully`) + this.reloadData() + }, + error: (error) => { + modal.componentInstance.buttonsEnabled = true + this.toastService.showError( + $localize`Error deleting objects`, + error + ) + }, + }) + }) + } } diff --git a/src-ui/src/app/services/rest/abstract-name-filter-service.spec.ts b/src-ui/src/app/services/rest/abstract-name-filter-service.spec.ts index e09270701..f61efc640 100644 --- a/src-ui/src/app/services/rest/abstract-name-filter-service.spec.ts +++ b/src-ui/src/app/services/rest/abstract-name-filter-service.spec.ts @@ -2,7 +2,10 @@ import { HttpTestingController } from '@angular/common/http/testing' import { Subscription } from 'rxjs' import { TestBed } from '@angular/core/testing' import { environment } from 'src/environments/environment' -import { AbstractNameFilterService } from './abstract-name-filter-service' +import { + AbstractNameFilterService, + BulkEditObjectOperation, +} from './abstract-name-filter-service' import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec' let httpTestingController: HttpTestingController @@ -53,8 +56,9 @@ export const commonAbstractNameFilterPaperlessServiceTests = ( }, } subscription = service - .bulk_update_permissions( + .bulk_edit_objects( [1, 2], + BulkEditObjectOperation.SetPermissions, { owner, set_permissions: permissions, @@ -63,9 +67,33 @@ export const commonAbstractNameFilterPaperlessServiceTests = ( ) .subscribe() const req = httpTestingController.expectOne( - `${environment.apiBaseUrl}bulk_edit_object_perms/` + `${environment.apiBaseUrl}bulk_edit_objects/` ) expect(req.request.method).toEqual('POST') + expect(req.request.body).toEqual({ + objects: [1, 2], + object_type: endpoint, + operation: BulkEditObjectOperation.SetPermissions, + permissions, + owner, + merge: true, + }) + req.flush([]) + }) + + test('should call appropriate api endpoint for bulk delete objects', () => { + subscription = service + .bulk_edit_objects([1, 2], BulkEditObjectOperation.Delete) + .subscribe() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}bulk_edit_objects/` + ) + expect(req.request.method).toEqual('POST') + expect(req.request.body).toEqual({ + objects: [1, 2], + object_type: endpoint, + operation: BulkEditObjectOperation.Delete, + }) req.flush([]) }) }) diff --git a/src-ui/src/app/services/rest/abstract-name-filter-service.ts b/src-ui/src/app/services/rest/abstract-name-filter-service.ts index b38994086..1018f0fa2 100644 --- a/src-ui/src/app/services/rest/abstract-name-filter-service.ts +++ b/src-ui/src/app/services/rest/abstract-name-filter-service.ts @@ -3,6 +3,11 @@ import { AbstractPaperlessService } from './abstract-paperless-service' import { PermissionsObject } from 'src/app/data/object-with-permissions' import { Observable } from 'rxjs' +export enum BulkEditObjectOperation { + SetPermissions = 'set_permissions', + Delete = 'delete', +} + export abstract class AbstractNameFilterService< T extends ObjectWithId, > extends AbstractPaperlessService { @@ -24,17 +29,22 @@ export abstract class AbstractNameFilterService< return this.list(page, pageSize, sortField, sortReverse, params) } - bulk_update_permissions( + bulk_edit_objects( objects: Array, - permissions: { owner: number; set_permissions: PermissionsObject }, - merge: boolean + operation: BulkEditObjectOperation, + permissions: { owner: number; set_permissions: PermissionsObject } = null, + merge: boolean = null ): Observable { - return this.http.post(`${this.baseUrl}bulk_edit_object_perms/`, { + const params = { objects, object_type: this.resourceName, - owner: permissions.owner, - permissions: permissions.set_permissions, - merge, - }) + operation, + } + if (operation === BulkEditObjectOperation.SetPermissions) { + params['owner'] = permissions?.owner + params['permissions'] = permissions?.set_permissions + params['merge'] = merge + } + return this.http.post(`${this.baseUrl}bulk_edit_objects/`, params) } } diff --git a/src-ui/src/environments/environment.prod.ts b/src-ui/src/environments/environment.prod.ts index 6135915d9..7de16a988 100644 --- a/src-ui/src/environments/environment.prod.ts +++ b/src-ui/src/environments/environment.prod.ts @@ -3,7 +3,7 @@ const base_url = new URL(document.baseURI) export const environment = { production: true, apiBaseUrl: document.baseURI + 'api/', - apiVersion: '4', + apiVersion: '5', appTitle: 'Paperless-ngx', version: '2.4.3-dev', webSocketHost: window.location.host, diff --git a/src-ui/src/environments/environment.ts b/src-ui/src/environments/environment.ts index fccb8927c..18715e90f 100644 --- a/src-ui/src/environments/environment.ts +++ b/src-ui/src/environments/environment.ts @@ -5,7 +5,7 @@ export const environment = { production: false, apiBaseUrl: 'http://localhost:8000/api/', - apiVersion: '4', + apiVersion: '5', appTitle: 'Paperless-ngx', version: 'DEVELOPMENT', webSocketHost: 'localhost:8000', diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index e712d4b59..5fa104640 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -1281,7 +1281,7 @@ class ShareLinkSerializer(OwnedObjectSerializer): return super().create(validated_data) -class BulkEditObjectPermissionsSerializer(serializers.Serializer, SetPermissionsMixin): +class BulkEditObjectsSerializer(serializers.Serializer, SetPermissionsMixin): objects = serializers.ListField( required=True, allow_empty=False, @@ -1301,6 +1301,16 @@ class BulkEditObjectPermissionsSerializer(serializers.Serializer, SetPermissions write_only=True, ) + operation = serializers.ChoiceField( + choices=[ + "set_permissions", + "delete", + ], + label="Operation", + required=True, + write_only=True, + ) + owner = serializers.PrimaryKeyRelatedField( queryset=User.objects.all(), required=False, @@ -1353,11 +1363,14 @@ class BulkEditObjectPermissionsSerializer(serializers.Serializer, SetPermissions def validate(self, attrs): object_type = attrs["object_type"] objects = attrs["objects"] - permissions = attrs.get("permissions") + operation = attrs.get("operation") self._validate_objects(objects, object_type) - if permissions is not None: - self._validate_permissions(permissions) + + if operation == "set_permissions": + permissions = attrs.get("permissions") + if permissions is not None: + self._validate_permissions(permissions) return attrs diff --git a/src/documents/tests/test_api_objects.py b/src/documents/tests/test_api_objects.py index e894cae90..3b38f2b5f 100644 --- a/src/documents/tests/test_api_objects.py +++ b/src/documents/tests/test_api_objects.py @@ -222,3 +222,118 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase): args, _ = bulk_update_mock.call_args self.assertCountEqual([document.pk], args[0]) + + +class TestBulkEditObjects(APITestCase): + # See test_api_permissions.py for bulk tests on permissions + def setUp(self): + super().setUp() + + self.temp_admin = User.objects.create_superuser(username="temp_admin") + self.client.force_authenticate(user=self.temp_admin) + + self.t1 = Tag.objects.create(name="t1") + self.t2 = Tag.objects.create(name="t2") + self.c1 = Correspondent.objects.create(name="c1") + self.dt1 = DocumentType.objects.create(name="dt1") + self.sp1 = StoragePath.objects.create(name="sp1") + self.user1 = User.objects.create(username="user1") + self.user2 = User.objects.create(username="user2") + self.user3 = User.objects.create(username="user3") + + def test_bulk_objects_delete(self): + """ + GIVEN: + - Existing objects + WHEN: + - bulk_edit_objects API endpoint is called with delete operation + THEN: + - Objects are deleted + """ + response = self.client.post( + "/api/bulk_edit_objects/", + json.dumps( + { + "objects": [self.t1.id, self.t2.id], + "object_type": "tags", + "operation": "delete", + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Tag.objects.count(), 0) + + response = self.client.post( + "/api/bulk_edit_objects/", + json.dumps( + { + "objects": [self.c1.id], + "object_type": "correspondents", + "operation": "delete", + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Correspondent.objects.count(), 0) + + response = self.client.post( + "/api/bulk_edit_objects/", + json.dumps( + { + "objects": [self.dt1.id], + "object_type": "document_types", + "operation": "delete", + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(DocumentType.objects.count(), 0) + + response = self.client.post( + "/api/bulk_edit_objects/", + json.dumps( + { + "objects": [self.sp1.id], + "object_type": "storage_paths", + "operation": "delete", + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(StoragePath.objects.count(), 0) + + def test_bulk_edit_object_permissions_insufficient_perms(self): + """ + GIVEN: + - Objects owned by user other than logged in user + WHEN: + - bulk_edit_objects API endpoint is called with delete operation + THEN: + - User is not able to delete objects + """ + self.t1.owner = User.objects.get(username="temp_admin") + self.t1.save() + self.client.force_authenticate(user=self.user1) + + response = self.client.post( + "/api/bulk_edit_objects/", + json.dumps( + { + "objects": [self.t1.id, self.t2.id], + "object_type": "tags", + "operation": "delete", + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.content, b"Insufficient permissions") diff --git a/src/documents/tests/test_api_permissions.py b/src/documents/tests/test_api_permissions.py index 851608f37..92e47a1ed 100644 --- a/src/documents/tests/test_api_permissions.py +++ b/src/documents/tests/test_api_permissions.py @@ -717,7 +717,7 @@ class TestBulkEditObjectPermissions(APITestCase): GIVEN: - Existing objects WHEN: - - bulk_edit_object_perms API endpoint is called + - bulk_edit_objects API endpoint is called with set_permissions operation THEN: - Permissions and / or owner are changed """ @@ -733,11 +733,12 @@ class TestBulkEditObjectPermissions(APITestCase): } response = self.client.post( - "/api/bulk_edit_object_perms/", + "/api/bulk_edit_objects/", json.dumps( { "objects": [self.t1.id, self.t2.id], "object_type": "tags", + "operation": "set_permissions", "permissions": permissions, }, ), @@ -748,11 +749,12 @@ class TestBulkEditObjectPermissions(APITestCase): self.assertIn(self.user1, get_users_with_perms(self.t1)) response = self.client.post( - "/api/bulk_edit_object_perms/", + "/api/bulk_edit_objects/", json.dumps( { "objects": [self.c1.id], "object_type": "correspondents", + "operation": "set_permissions", "permissions": permissions, }, ), @@ -763,11 +765,12 @@ class TestBulkEditObjectPermissions(APITestCase): self.assertIn(self.user1, get_users_with_perms(self.c1)) response = self.client.post( - "/api/bulk_edit_object_perms/", + "/api/bulk_edit_objects/", json.dumps( { "objects": [self.dt1.id], "object_type": "document_types", + "operation": "set_permissions", "permissions": permissions, }, ), @@ -778,11 +781,12 @@ class TestBulkEditObjectPermissions(APITestCase): self.assertIn(self.user1, get_users_with_perms(self.dt1)) response = self.client.post( - "/api/bulk_edit_object_perms/", + "/api/bulk_edit_objects/", json.dumps( { "objects": [self.sp1.id], "object_type": "storage_paths", + "operation": "set_permissions", "permissions": permissions, }, ), @@ -793,11 +797,12 @@ class TestBulkEditObjectPermissions(APITestCase): self.assertIn(self.user1, get_users_with_perms(self.sp1)) response = self.client.post( - "/api/bulk_edit_object_perms/", + "/api/bulk_edit_objects/", json.dumps( { "objects": [self.t1.id, self.t2.id], "object_type": "tags", + "operation": "set_permissions", "owner": self.user3.id, }, ), @@ -808,11 +813,12 @@ class TestBulkEditObjectPermissions(APITestCase): self.assertEqual(Tag.objects.get(pk=self.t2.id).owner, self.user3) response = self.client.post( - "/api/bulk_edit_object_perms/", + "/api/bulk_edit_objects/", json.dumps( { "objects": [self.sp1.id], "object_type": "storage_paths", + "operation": "set_permissions", "owner": self.user3.id, }, ), @@ -827,7 +833,7 @@ class TestBulkEditObjectPermissions(APITestCase): GIVEN: - Existing objects WHEN: - - bulk_edit_object_perms API endpoint is called with merge=True or merge=False (default) + - bulk_edit_objects API endpoint is called with set_permissions operation with merge=True or merge=False (default) THEN: - Permissions and / or owner are replaced or merged, depending on the merge flag """ @@ -848,13 +854,14 @@ class TestBulkEditObjectPermissions(APITestCase): # merge=True response = self.client.post( - "/api/bulk_edit_object_perms/", + "/api/bulk_edit_objects/", json.dumps( { "objects": [self.t1.id, self.t2.id], "object_type": "tags", "owner": self.user1.id, "permissions": permissions, + "operation": "set_permissions", "merge": True, }, ), @@ -877,12 +884,13 @@ class TestBulkEditObjectPermissions(APITestCase): # merge=False (default) response = self.client.post( - "/api/bulk_edit_object_perms/", + "/api/bulk_edit_objects/", json.dumps( { "objects": [self.t1.id, self.t2.id], "object_type": "tags", "permissions": permissions, + "operation": "set_permissions", "merge": False, }, ), @@ -900,7 +908,7 @@ class TestBulkEditObjectPermissions(APITestCase): GIVEN: - Objects owned by user other than logged in user WHEN: - - bulk_edit_object_perms API endpoint is called + - bulk_edit_objects API endpoint is called with set_permissions operation THEN: - User is not able to change permissions """ @@ -909,11 +917,12 @@ class TestBulkEditObjectPermissions(APITestCase): self.client.force_authenticate(user=self.user1) response = self.client.post( - "/api/bulk_edit_object_perms/", + "/api/bulk_edit_objects/", json.dumps( { "objects": [self.t1.id, self.t2.id], "object_type": "tags", + "operation": "set_permissions", "owner": self.user1.id, }, ), @@ -928,17 +937,18 @@ class TestBulkEditObjectPermissions(APITestCase): GIVEN: - Existing objects WHEN: - - bulk_edit_object_perms API endpoint is called with invalid params + - bulk_edit_objects API endpoint is called with set_permissions operation with invalid params THEN: - Validation fails """ # not a list response = self.client.post( - "/api/bulk_edit_object_perms/", + "/api/bulk_edit_objects/", json.dumps( { "objects": self.t1.id, "object_type": "tags", + "operation": "set_permissions", "owner": self.user1.id, }, ), @@ -949,7 +959,7 @@ class TestBulkEditObjectPermissions(APITestCase): # not a list of ints response = self.client.post( - "/api/bulk_edit_object_perms/", + "/api/bulk_edit_objects/", json.dumps( { "objects": ["one"], @@ -964,11 +974,12 @@ class TestBulkEditObjectPermissions(APITestCase): # duplicates response = self.client.post( - "/api/bulk_edit_object_perms/", + "/api/bulk_edit_objects/", json.dumps( { "objects": [self.t1.id, self.t2.id, self.t1.id], "object_type": "tags", + "operation": "set_permissions", "owner": self.user1.id, }, ), @@ -979,11 +990,12 @@ class TestBulkEditObjectPermissions(APITestCase): # not a valid object type response = self.client.post( - "/api/bulk_edit_object_perms/", + "/api/bulk_edit_objects/", json.dumps( { "objects": [1], "object_type": "madeup", + "operation": "set_permissions", "owner": self.user1.id, }, ), diff --git a/src/documents/views.py b/src/documents/views.py index 7a037c27d..3be7f4ec6 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -115,7 +115,7 @@ from documents.permissions import has_perms_owner_aware from documents.permissions import set_permissions_for_object from documents.serialisers import AcknowledgeTasksViewSerializer from documents.serialisers import BulkDownloadSerializer -from documents.serialisers import BulkEditObjectPermissionsSerializer +from documents.serialisers import BulkEditObjectsSerializer from documents.serialisers import BulkEditSerializer from documents.serialisers import CorrespondentSerializer from documents.serialisers import CustomFieldSerializer @@ -1401,9 +1401,9 @@ def serve_file(doc: Document, use_archive: bool, disposition: str): return response -class BulkEditObjectPermissionsView(GenericAPIView, PassUserMixin): +class BulkEditObjectsView(GenericAPIView, PassUserMixin): permission_classes = (IsAuthenticated,) - serializer_class = BulkEditObjectPermissionsSerializer + serializer_class = BulkEditObjectsSerializer parser_classes = (parsers.JSONParser,) def post(self, request, *args, **kwargs): @@ -1414,42 +1414,52 @@ class BulkEditObjectPermissionsView(GenericAPIView, PassUserMixin): object_type = serializer.validated_data.get("object_type") object_ids = serializer.validated_data.get("objects") object_class = serializer.get_object_class(object_type) - permissions = serializer.validated_data.get("permissions") - owner = serializer.validated_data.get("owner") - merge = serializer.validated_data.get("merge") + operation = serializer.validated_data.get("operation") + + objs = object_class.objects.filter(pk__in=object_ids) if not user.is_superuser: - objs = object_class.objects.filter(pk__in=object_ids) has_perms = all((obj.owner == user or obj.owner is None) for obj in objs) if not has_perms: return HttpResponseForbidden("Insufficient permissions") - try: - qs = object_class.objects.filter(id__in=object_ids) + if operation == "set_permissions": + permissions = serializer.validated_data.get("permissions") + owner = serializer.validated_data.get("owner") + merge = serializer.validated_data.get("merge") - # if merge is true, we dont want to remove the owner - if "owner" in serializer.validated_data and ( - not merge or (merge and owner is not None) - ): - # if merge is true, we dont want to overwrite the owner - qs_owner_update = qs.filter(owner__isnull=True) if merge else qs - qs_owner_update.update(owner=owner) + try: + qs = object_class.objects.filter(id__in=object_ids) - if "permissions" in serializer.validated_data: - for obj in qs: - set_permissions_for_object( - permissions=permissions, - object=obj, - merge=merge, - ) + # if merge is true, we dont want to remove the owner + if "owner" in serializer.validated_data and ( + not merge or (merge and owner is not None) + ): + # if merge is true, we dont want to overwrite the owner + qs_owner_update = qs.filter(owner__isnull=True) if merge else qs + qs_owner_update.update(owner=owner) - return Response({"result": "OK"}) - except Exception as e: - logger.warning(f"An error occurred performing bulk permissions edit: {e!s}") - return HttpResponseBadRequest( - "Error performing bulk permissions edit, check logs for more detail.", - ) + if "permissions" in serializer.validated_data: + for obj in qs: + set_permissions_for_object( + permissions=permissions, + object=obj, + merge=merge, + ) + + except Exception as e: + logger.warning( + f"An error occurred performing bulk permissions edit: {e!s}", + ) + return HttpResponseBadRequest( + "Error performing bulk permissions edit, check logs for more detail.", + ) + + elif operation == "delete": + objs.delete() + + return Response({"result": "OK"}) class WorkflowTriggerViewSet(ModelViewSet): diff --git a/src/paperless/settings.py b/src/paperless/settings.py index 94400c8dd..a9792db9f 100644 --- a/src/paperless/settings.py +++ b/src/paperless/settings.py @@ -322,7 +322,7 @@ REST_FRAMEWORK = { "DEFAULT_VERSION": "1", # Make sure these are ordered and that the most recent version appears # last - "ALLOWED_VERSIONS": ["1", "2", "3", "4"], + "ALLOWED_VERSIONS": ["1", "2", "3", "4", "5"], } if DEBUG: diff --git a/src/paperless/urls.py b/src/paperless/urls.py index 74f6fc108..0419b8e66 100644 --- a/src/paperless/urls.py +++ b/src/paperless/urls.py @@ -16,7 +16,7 @@ from rest_framework.routers import DefaultRouter from documents.views import AcknowledgeTasksView from documents.views import BulkDownloadView -from documents.views import BulkEditObjectPermissionsView +from documents.views import BulkEditObjectsView from documents.views import BulkEditView from documents.views import CorrespondentViewSet from documents.views import CustomFieldViewSet @@ -129,9 +129,9 @@ urlpatterns = [ ), path("token/", views.obtain_auth_token), re_path( - "^bulk_edit_object_perms/", - BulkEditObjectPermissionsView.as_view(), - name="bulk_edit_object_permissions", + "^bulk_edit_objects/", + BulkEditObjectsView.as_view(), + name="bulk_edit_objects", ), path("profile/generate_auth_token/", GenerateAuthTokenView.as_view()), path(