From 60a76b02e68756f25e599ed6620c17fbde47d975 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Wed, 13 Sep 2023 01:02:44 -0700 Subject: [PATCH] Frontend support for bulk object permissions edit --- .../common/toasts/toasts.component.ts | 3 +- .../management-list.component.html | 37 ++++++++-- .../management-list.component.spec.ts | 53 ++++++++++++- .../management-list.component.ts | 74 +++++++++++++++++-- .../manage/tasks/tasks.component.html | 4 +- .../src/app/data/object-with-permissions.ts | 1 - .../rest/abstract-name-filter-service.spec.ts | 25 +++++++ .../rest/abstract-name-filter-service.ts | 14 ++++ src/documents/serialisers.py | 20 ++--- src/documents/tests/test_api.py | 9 ++- 10 files changed, 205 insertions(+), 35 deletions(-) diff --git a/src-ui/src/app/components/common/toasts/toasts.component.ts b/src-ui/src/app/components/common/toasts/toasts.component.ts index e2194ef1b..5af81d027 100644 --- a/src-ui/src/app/components/common/toasts/toasts.component.ts +++ b/src-ui/src/app/components/common/toasts/toasts.component.ts @@ -57,7 +57,8 @@ export class ToastsComponent implements OnInit, OnDestroy { } getErrorText(error: any) { - const text: string = error.error?.detail ?? error.error ?? '' + let text: string = error.error?.detail ?? error.error ?? '' + if (typeof text === 'object') text = JSON.stringify(text) return `${text.slice(0, 200)}${text.length > 200 ? '...' : ''}` } } 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 0c7355873..777a33a91 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 @@ -1,4 +1,14 @@ + + @@ -16,6 +26,12 @@ + @@ -30,7 +46,13 @@ Loading... - + + @@ -54,17 +76,17 @@
- - -
+
+ + +
+
Name Matching Document count
+
+ + +
+
{{ object.name }} {{ getMatching(object) }} {{ object.document_count }}
-
-
{collectionSize, plural, =1 {One {{typeName}}} other {{{collectionSize || 0}} total {{typeNamePlural}}}}
+
+
+ {collectionSize, plural, =1 {One {{typeName}}} other {{{collectionSize || 0}} total {{typeNamePlural}}}} +  ({{selectedObjects.size}} selected) +
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 9579e5bd8..a106c830f 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 @@ -35,6 +35,7 @@ import { PermissionsGuard } from 'src/app/guards/permissions.guard' import { MATCH_AUTO } from 'src/app/data/matching-model' 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' const tags: PaperlessTag[] = [ { @@ -72,6 +73,7 @@ describe('ManagementListComponent', () => { IfPermissionsDirective, SafeHtmlPipe, ConfirmDialogComponent, + PermissionsDialogComponent, ], providers: [ { @@ -145,7 +147,7 @@ describe('ManagementListComponent', () => { const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const reloadSpy = jest.spyOn(component, 'reloadData') - const createButton = fixture.debugElement.queryAll(By.css('button'))[0] + const createButton = fixture.debugElement.queryAll(By.css('button'))[2] createButton.triggerEventHandler('click') expect(modal).not.toBeUndefined() @@ -170,7 +172,7 @@ describe('ManagementListComponent', () => { const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const reloadSpy = jest.spyOn(component, 'reloadData') - const editButton = fixture.debugElement.queryAll(By.css('button'))[3] + const editButton = fixture.debugElement.queryAll(By.css('button'))[5] editButton.triggerEventHandler('click') expect(modal).not.toBeUndefined() @@ -196,7 +198,7 @@ describe('ManagementListComponent', () => { const deleteSpy = jest.spyOn(tagService, 'delete') const reloadSpy = jest.spyOn(component, 'reloadData') - const deleteButton = fixture.debugElement.queryAll(By.css('button'))[4] + const deleteButton = fixture.debugElement.queryAll(By.css('button'))[6] deleteButton.triggerEventHandler('click') expect(modal).not.toBeUndefined() @@ -216,7 +218,7 @@ describe('ManagementListComponent', () => { it('should support quick filter for objects', () => { const qfSpy = jest.spyOn(documentListViewService, 'quickFilter') - const filterButton = fixture.debugElement.queryAll(By.css('button'))[2] + const filterButton = fixture.debugElement.queryAll(By.css('button'))[4] filterButton.triggerEventHandler('click') expect(qfSpy).toHaveBeenCalledWith([ { rule_type: FILTER_HAS_TAGS_ALL, value: tags[0].id.toString() }, @@ -229,4 +231,47 @@ describe('ManagementListComponent', () => { sortable.triggerEventHandler('click') expect(reloadSpy).toHaveBeenCalled() }) + + it('should support toggle all items in view', () => { + expect(component.selectedObjects.size).toEqual(0) + const toggleAllSpy = jest.spyOn(component, 'toggleAll') + const checkButton = fixture.debugElement.queryAll( + By.css('input.form-check-input') + )[0] + checkButton.nativeElement.dispatchEvent(new Event('click')) + checkButton.nativeElement.checked = true + checkButton.nativeElement.dispatchEvent(new Event('click')) + expect(toggleAllSpy).toHaveBeenCalled() + expect(component.selectedObjects.size).toEqual(tags.length) + }) + + it('should support bulk edit permissions', () => { + const bulkEditPermsSpy = jest.spyOn(tagService, 'bulk_update_permissions') + component.toggleSelected(tags[0]) + component.toggleSelected(tags[1]) + component.toggleSelected(tags[2]) + component.toggleSelected(tags[2]) // uncheck, for coverage + 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.setPermissions() + expect(modal).not.toBeUndefined() + + // fail first + bulkEditPermsSpy.mockReturnValueOnce( + throwError(() => new Error('error setting permissions')) + ) + const errorToastSpy = jest.spyOn(toastService, 'showError') + modal.componentInstance.confirmClicked.emit() + expect(bulkEditPermsSpy).toHaveBeenCalled() + expect(errorToastSpy).toHaveBeenCalled() + + const successToastSpy = jest.spyOn(toastService, 'showInfo') + bulkEditPermsSpy.mockReturnValueOnce(of('OK')) + modal.componentInstance.confirmClicked.emit() + expect(bulkEditPermsSpy).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 c4e2ef0ea..e20b5d4a7 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 @@ -6,7 +6,7 @@ import { ViewChildren, } from '@angular/core' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' -import { Subject, Subscription } from 'rxjs' +import { Subject } from 'rxjs' import { debounceTime, distinctUntilChanged, takeUntil } from 'rxjs/operators' import { MatchingModel, @@ -15,7 +15,10 @@ import { MATCH_NONE, } from 'src/app/data/matching-model' import { ObjectWithId } from 'src/app/data/object-with-id' -import { ObjectWithPermissions } from 'src/app/data/object-with-permissions' +import { + ObjectWithPermissions, + PermissionsObject, +} from 'src/app/data/object-with-permissions' import { SortableDirective, SortEvent, @@ -28,11 +31,9 @@ import { import { AbstractNameFilterService } 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 { - EditDialogComponent, - EditDialogMode, -} from '../../common/edit-dialog/edit-dialog.component' +import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component' import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component' +import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component' export interface ManagementListColumn { key: string @@ -82,6 +83,8 @@ export abstract class ManagementListComponent private unsubscribeNotifier: Subject = new Subject() private _nameFilter: string + public selectedObjects: Set = new Set() + ngOnInit(): void { this.reloadData() @@ -243,4 +246,63 @@ export abstract class ManagementListComponent object ) } + + get userOwnsAll(): boolean { + let ownsAll: boolean = true + const objects = this.data.filter((o) => this.selectedObjects.has(o.id)) + ownsAll = objects.every((o) => + this.permissionsService.currentUserOwnsObject(o) + ) + return ownsAll + } + + toggleAll(event: PointerEvent) { + if ((event.target as HTMLInputElement).checked) { + this.selectedObjects = new Set(this.data.map((o) => o.id)) + } else { + this.clearSelection() + } + } + + clearSelection() { + this.selectedObjects.clear() + } + + toggleSelected(object) { + this.selectedObjects.has(object.id) + ? this.selectedObjects.delete(object.id) + : this.selectedObjects.add(object.id) + } + + setPermissions() { + let modal = this.modalService.open(PermissionsDialogComponent, { + backdrop: 'static', + }) + modal.componentInstance.confirmClicked.subscribe( + (permissions: { owner: number; set_permissions: PermissionsObject }) => { + modal.componentInstance.buttonsEnabled = false + this.service + .bulk_update_permissions( + Array.from(this.selectedObjects), + permissions + ) + .subscribe({ + next: () => { + modal.close() + this.toastService.showInfo( + $localize`Permissions updated successfully` + ) + this.reloadData() + }, + error: (error) => { + modal.componentInstance.buttonsEnabled = true + this.toastService.showError( + $localize`Error updating permissions`, + error + ) + }, + }) + } + ) + } } diff --git a/src-ui/src/app/components/manage/tasks/tasks.component.html b/src-ui/src/app/components/manage/tasks/tasks.component.html index 66f81ea7f..62799c9f6 100644 --- a/src-ui/src/app/components/manage/tasks/tasks.component.html +++ b/src-ui/src/app/components/manage/tasks/tasks.component.html @@ -47,12 +47,12 @@ - +
- + {{ task.task_file_name }} {{ task.date_created | customDate:'short' }} diff --git a/src-ui/src/app/data/object-with-permissions.ts b/src-ui/src/app/data/object-with-permissions.ts index 9346aa85c..29db6bf26 100644 --- a/src-ui/src/app/data/object-with-permissions.ts +++ b/src-ui/src/app/data/object-with-permissions.ts @@ -1,5 +1,4 @@ import { ObjectWithId } from './object-with-id' -import { PaperlessUser } from './paperless-user' export interface PermissionsObject { view: { 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 e4ec93aeb..70ae211e5 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 @@ -39,6 +39,31 @@ export const commonAbstractNameFilterPaperlessServiceTests = ( expect(req.request.method).toEqual('GET') req.flush([]) }) + + test('should call appropriate api endpoint for bulk permissions edit', () => { + const owner = 3 + const permissions = { + view: { + users: [], + groups: [3], + }, + change: { + users: [12, 13], + groups: [], + }, + } + subscription = service + .bulk_update_permissions([1, 2], { + owner, + set_permissions: permissions, + }) + .subscribe() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}bulk_edit_object_perms/` + ) + expect(req.request.method).toEqual('POST') + req.flush([]) + }) }) beforeEach(() => { 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 1164545b2..5e0377cb9 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 @@ -1,5 +1,7 @@ import { ObjectWithId } from 'src/app/data/object-with-id' import { AbstractPaperlessService } from './abstract-paperless-service' +import { PermissionsObject } from 'src/app/data/object-with-permissions' +import { Observable } from 'rxjs' export abstract class AbstractNameFilterService< T extends ObjectWithId, @@ -21,4 +23,16 @@ export abstract class AbstractNameFilterService< } return this.list(page, pageSize, sortField, sortReverse, params) } + + bulk_update_permissions( + objects: Array, + permissions: { owner: number; set_permissions: PermissionsObject } + ): Observable { + return this.http.post(`${this.baseUrl}bulk_edit_object_perms/`, { + objects, + object_type: this.resourceName, + owner: permissions.owner, + permissions: permissions.set_permissions, + }) + } } diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 47ee2ac85..0f99d5dcc 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -973,10 +973,10 @@ class BulkEditObjectPermissionsSerializer(serializers.Serializer, SetPermissions object_type = serializers.ChoiceField( choices=[ - "tag", - "correspondent", - "document_type", - "storage_path", + "tags", + "correspondents", + "document_types", + "storage_paths", ], label="Object Type", write_only=True, @@ -997,13 +997,13 @@ class BulkEditObjectPermissionsSerializer(serializers.Serializer, SetPermissions def get_object_class(self, object_type): object_class = None - if object_type == "tag": + if object_type == "tags": object_class = Tag - elif object_type == "correspondent": + elif object_type == "correspondents": object_class = Correspondent - elif object_type == "document_type": + elif object_type == "document_types": object_class = DocumentType - elif object_type == "storage_path": + elif object_type == "storage_paths": object_class = StoragePath return object_class @@ -1013,10 +1013,6 @@ class BulkEditObjectPermissionsSerializer(serializers.Serializer, SetPermissions if not all(isinstance(i, int) for i in objects): raise serializers.ValidationError("objects must be a list of integers") object_class = self.get_object_class(object_type) - if object_class is None: - raise serializers.ValidationError( - "Unknown object type.", - ) count = object_class.objects.filter(id__in=objects).count() if not count == len(objects): raise serializers.ValidationError( diff --git a/src/documents/tests/test_api.py b/src/documents/tests/test_api.py index b390cf86b..d4d6afe04 100644 --- a/src/documents/tests/test_api.py +++ b/src/documents/tests/test_api.py @@ -5100,6 +5100,9 @@ class TestBulkEditObjectPermissions(APITestCase): 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") @@ -5129,7 +5132,7 @@ class TestBulkEditObjectPermissions(APITestCase): json.dumps( { "objects": [self.t1.id, self.t2.id], - "object_type": "tag", + "object_type": "tags", "permissions": permissions, }, ), @@ -5189,7 +5192,7 @@ class TestBulkEditObjectPermissions(APITestCase): json.dumps( { "objects": [self.t1.id, self.t2.id], - "object_type": "tag", + "object_type": "tags", "owner": self.user3.id, }, ), @@ -5232,7 +5235,7 @@ class TestBulkEditObjectPermissions(APITestCase): json.dumps( { "objects": [self.t1.id, self.t2.id], - "object_type": "tag", + "object_type": "tags", "owner": self.user1.id, }, ),