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 321a34ccb..0f99d5dcc 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -960,3 +960,78 @@ class ShareLinkSerializer(OwnedObjectSerializer): def create(self, validated_data): validated_data["slug"] = get_random_string(50) return super().create(validated_data) + + +class BulkEditObjectPermissionsSerializer(serializers.Serializer, SetPermissionsMixin): + objects = serializers.ListField( + required=True, + allow_empty=False, + label="Objects", + write_only=True, + child=serializers.IntegerField(), + ) + + object_type = serializers.ChoiceField( + choices=[ + "tags", + "correspondents", + "document_types", + "storage_paths", + ], + label="Object Type", + write_only=True, + ) + + owner = serializers.PrimaryKeyRelatedField( + queryset=User.objects.all(), + required=False, + allow_null=True, + ) + + permissions = serializers.DictField( + label="Set permissions", + allow_empty=False, + required=False, + write_only=True, + ) + + def get_object_class(self, object_type): + object_class = None + if object_type == "tags": + object_class = Tag + elif object_type == "correspondents": + object_class = Correspondent + elif object_type == "document_types": + object_class = DocumentType + elif object_type == "storage_paths": + object_class = StoragePath + return object_class + + def _validate_objects(self, objects, object_type): + if not isinstance(objects, list): + raise serializers.ValidationError("objects must be a list") + 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) + count = object_class.objects.filter(id__in=objects).count() + if not count == len(objects): + raise serializers.ValidationError( + "Some ids in objects don't exist or were specified twice.", + ) + return objects + + def _validate_permissions(self, permissions): + self.validate_set_permissions( + permissions, + ) + + def validate(self, attrs): + object_type = attrs["object_type"] + objects = attrs["objects"] + permissions = attrs["permissions"] if "permissions" in attrs else None + + self._validate_objects(objects, object_type) + if permissions is not None: + self._validate_permissions(permissions) + + return attrs diff --git a/src/documents/tests/test_api.py b/src/documents/tests/test_api.py index f9c6da0a8..d4d6afe04 100644 --- a/src/documents/tests/test_api.py +++ b/src/documents/tests/test_api.py @@ -25,6 +25,7 @@ from django.test import override_settings from django.utils import timezone from guardian.shortcuts import assign_perm from guardian.shortcuts import get_perms +from guardian.shortcuts import get_users_with_perms from rest_framework import status from rest_framework.test import APITestCase from whoosh.writing import AsyncWriter @@ -5088,3 +5089,227 @@ class TestApiGroup(DirectoriesMixin, APITestCase): returned_group1 = Group.objects.get(pk=group1.pk) self.assertEqual(returned_group1.name, "Updated Name 1") + + +class TestBulkEditObjectPermissions(APITestCase): + def setUp(self): + super().setUp() + + user = User.objects.create_superuser(username="temp_admin") + self.client.force_authenticate(user=user) + + 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_object_set_permissions(self): + """ + GIVEN: + - Existing objects + WHEN: + - bulk_edit_object_perms API endpoint is called + THEN: + - Permissions and / or owner are changed + """ + permissions = { + "view": { + "users": [self.user1.id, self.user2.id], + "groups": [], + }, + "change": { + "users": [self.user1.id], + "groups": [], + }, + } + + response = self.client.post( + "/api/bulk_edit_object_perms/", + json.dumps( + { + "objects": [self.t1.id, self.t2.id], + "object_type": "tags", + "permissions": permissions, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn(self.user1, get_users_with_perms(self.t1)) + + response = self.client.post( + "/api/bulk_edit_object_perms/", + json.dumps( + { + "objects": [self.c1.id], + "object_type": "correspondents", + "permissions": permissions, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn(self.user1, get_users_with_perms(self.c1)) + + response = self.client.post( + "/api/bulk_edit_object_perms/", + json.dumps( + { + "objects": [self.dt1.id], + "object_type": "document_types", + "permissions": permissions, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn(self.user1, get_users_with_perms(self.dt1)) + + response = self.client.post( + "/api/bulk_edit_object_perms/", + json.dumps( + { + "objects": [self.sp1.id], + "object_type": "storage_paths", + "permissions": permissions, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn(self.user1, get_users_with_perms(self.sp1)) + + response = self.client.post( + "/api/bulk_edit_object_perms/", + json.dumps( + { + "objects": [self.t1.id, self.t2.id], + "object_type": "tags", + "owner": self.user3.id, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Tag.objects.get(pk=self.t2.id).owner, self.user3) + + response = self.client.post( + "/api/bulk_edit_object_perms/", + json.dumps( + { + "objects": [self.sp1.id], + "object_type": "storage_paths", + "owner": self.user3.id, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(StoragePath.objects.get(pk=self.sp1.id).owner, self.user3) + + def test_bulk_edit_object_permissions_insufficient_perms(self): + """ + GIVEN: + - Objects owned by user other than logged in user + WHEN: + - bulk_edit_object_perms API endpoint is called + THEN: + - User is not able to change permissions + """ + 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_object_perms/", + json.dumps( + { + "objects": [self.t1.id, self.t2.id], + "object_type": "tags", + "owner": self.user1.id, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.content, b"Insufficient permissions") + + def test_bulk_edit_object_permissions_validation(self): + """ + GIVEN: + - Existing objects + WHEN: + - bulk_edit_object_perms API endpoint is called with invalid params + THEN: + - Validation fails + """ + # not a list + response = self.client.post( + "/api/bulk_edit_object_perms/", + json.dumps( + { + "objects": self.t1.id, + "object_type": "tags", + "owner": self.user1.id, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + # not a list of ints + response = self.client.post( + "/api/bulk_edit_object_perms/", + json.dumps( + { + "objects": ["one"], + "object_type": "tags", + "owner": self.user1.id, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + # duplicates + response = self.client.post( + "/api/bulk_edit_object_perms/", + json.dumps( + { + "objects": [self.t1.id, self.t2.id, self.t1.id], + "object_type": "tags", + "owner": self.user1.id, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + # not a valid object type + response = self.client.post( + "/api/bulk_edit_object_perms/", + json.dumps( + { + "objects": [1], + "object_type": "madeup", + "owner": self.user1.id, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/src/documents/views.py b/src/documents/views.py index 856b27e27..be6ce1ff7 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -63,6 +63,7 @@ from documents.permissions import PaperlessAdminPermissions from documents.permissions import PaperlessObjectPermissions from documents.permissions import get_objects_for_user_owner_aware from documents.permissions import has_perms_owner_aware +from documents.permissions import set_permissions_for_object from documents.tasks import consume_file from paperless import version from paperless.db import GnuPG @@ -98,6 +99,7 @@ from .parsers import get_parser_class_for_mime_type from .parsers import parse_date_generator from .serialisers import AcknowledgeTasksViewSerializer from .serialisers import BulkDownloadSerializer +from .serialisers import BulkEditObjectPermissionsSerializer from .serialisers import BulkEditSerializer from .serialisers import CorrespondentSerializer from .serialisers import DocumentListSerializer @@ -1205,3 +1207,44 @@ def serve_file(doc: Document, use_archive: bool, disposition: str): ) response["Content-Disposition"] = content_disposition return response + + +class BulkEditObjectPermissionsView(GenericAPIView, PassUserMixin): + permission_classes = (IsAuthenticated,) + serializer_class = BulkEditObjectPermissionsSerializer + parser_classes = (parsers.JSONParser,) + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + user = self.request.user + 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") + + 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 "owner" in serializer.validated_data: + qs.update(owner=owner) + + if "permissions" in serializer.validated_data: + for obj in qs: + set_permissions_for_object(permissions, obj) + + 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.", + ) diff --git a/src/paperless/urls.py b/src/paperless/urls.py index 5d24478aa..05e772ee0 100644 --- a/src/paperless/urls.py +++ b/src/paperless/urls.py @@ -12,6 +12,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 BulkEditView from documents.views import CorrespondentViewSet from documents.views import DocumentTypeViewSet @@ -109,6 +110,11 @@ urlpatterns = [ name="mail_accounts_test", ), path("token/", views.obtain_auth_token), + re_path( + "^bulk_edit_object_perms/", + BulkEditObjectPermissionsView.as_view(), + name="bulk_edit_object_permissions", + ), *api_router.urls, ], ),