]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Enhancement: bulk edit object permissions (#4176)
authorshamoon <4887959+shamoon@users.noreply.github.com>
Tue, 19 Sep 2023 20:40:21 +0000 (13:40 -0700)
committerGitHub <noreply@github.com>
Tue, 19 Sep 2023 20:40:21 +0000 (13:40 -0700)
* bulk_edit_object_perms API endpoint

* Frontend support for bulk object permissions edit

12 files changed:
src-ui/src/app/components/common/toasts/toasts.component.ts
src-ui/src/app/components/manage/management-list/management-list.component.html
src-ui/src/app/components/manage/management-list/management-list.component.spec.ts
src-ui/src/app/components/manage/management-list/management-list.component.ts
src-ui/src/app/components/manage/tasks/tasks.component.html
src-ui/src/app/data/object-with-permissions.ts
src-ui/src/app/services/rest/abstract-name-filter-service.spec.ts
src-ui/src/app/services/rest/abstract-name-filter-service.ts
src/documents/serialisers.py
src/documents/tests/test_api.py
src/documents/views.py
src/paperless/urls.py

index e2194ef1bc3a4fc1088106f6a600f1ac1e7944c6..5af81d027157877f3687a667884ae6c289952c24 100644 (file)
@@ -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 ? '...' : ''}`
   }
 }
index 0c73558732f8d5817e6b0f03a5e6e4b527db04aa..777a33a91401b005ee328976a4b00f1b9ab95d52 100644 (file)
@@ -1,4 +1,14 @@
 <pngx-page-header title="{{ typeNamePlural | titlecase }}">
+  <button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedObjects.size === 0">
+    <svg class="sidebaricon" fill="currentColor">
+      <use xlink:href="assets/bootstrap-icons.svg#x"/>
+    </svg>&nbsp;<ng-container i18n>Clear selection</ng-container>
+  </button>
+  <button type="button" class="btn btn-sm btn-outline-primary me-5" (click)="setPermissions()" [disabled]="!userOwnsAll || selectedObjects.size === 0">
+    <svg width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor">
+      <use xlink:href="assets/bootstrap-icons.svg#person-fill-lock" />
+    </svg>&nbsp;<ng-container i18n>Permissions</ng-container>
+  </button>
   <button type="button" class="btn btn-sm btn-outline-primary" (click)="openCreateDialog()" *pngxIfPermissions="{ action: PermissionAction.Add, type: permissionType }" i18n>Create</button>
 </pngx-page-header>
 
 <table class="table table-striped align-middle border shadow-sm">
   <thead>
     <tr>
+      <th scope="col">
+        <div class="form-check">
+          <input type="checkbox" class="form-check-input" id="all-objects" [disabled]="data.length === 0" (click)="toggleAll($event); $event.stopPropagation();">
+          <label class="form-check-label" for="all-objects"></label>
+        </div>
+      </th>
       <th scope="col" pngxSortable="name" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Name</th>
       <th scope="col" class="d-none d-sm-table-cell" pngxSortable="matching_algorithm" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Matching</th>
       <th scope="col" pngxSortable="document_count" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Document count</th>
         <ng-container i18n>Loading...</ng-container>
       </td>
     </tr>
-    <tr *ngFor="let object of data">
+    <tr *ngFor="let object of data" (click)="toggleSelected(object, $event); $event.stopPropagation();">
+      <td>
+        <div class="form-check">
+          <input type="checkbox" class="form-check-input" id="{{typeName}}{{object.id}}" [checked]="selectedObjects.has(object.id)" (click)="toggleSelected(object); $event.stopPropagation();">
+          <label class="form-check-label" for="{{typeName}}{{object.id}}"></label>
+        </div>
+      </td>
       <td scope="row">{{ object.name }}</td>
       <td scope="row" class="d-none d-sm-table-cell">{{ getMatching(object) }}</td>
       <td scope="row">{{ object.document_count }}</td>
           </div>
         </div>
         <div class="btn-group d-none d-sm-block">
-          <button class="btn btn-sm btn-outline-secondary" (click)="filterDocuments(object)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
+          <button class="btn btn-sm btn-outline-secondary" (click)="filterDocuments(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
             <svg class="buttonicon-sm" fill="currentColor">
               <use xlink:href="assets/bootstrap-icons.svg#filter" />
             </svg>&nbsp;<ng-container i18n>Documents</ng-container>
           </button>
-          <button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(object)" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" [disabled]="!userCanEdit(object)">
+          <button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" [disabled]="!userCanEdit(object)">
             <svg class="buttonicon-sm" fill="currentColor">
               <use xlink:href="assets/bootstrap-icons.svg#pencil" />
             </svg>&nbsp;<ng-container i18n>Edit</ng-container>
           </button>
-          <button class="btn btn-sm btn-outline-danger" (click)="openDeleteDialog(object)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" [disabled]="!userCanDelete(object)">
+          <button class="btn btn-sm btn-outline-danger" (click)="openDeleteDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" [disabled]="!userCanDelete(object)">
             <svg class="buttonicon-sm" fill="currentColor">
               <use xlink:href="assets/bootstrap-icons.svg#trash" />
             </svg>&nbsp;<ng-container i18n>Delete</ng-container>
   </tbody>
 </table>
 
-<div class="d-flex" *ngIf="!isLoading">
-  <div i18n *ngIf="collectionSize > 0">{collectionSize, plural, =1 {One {{typeName}}} other {{{collectionSize || 0}} total {{typeNamePlural}}}}</div>
+<div class="d-flex mb-2" *ngIf="!isLoading">
+  <div *ngIf="collectionSize > 0">
+    <ng-container i18n>{collectionSize, plural, =1 {One {{typeName}}} other {{{collectionSize || 0}} total {{typeNamePlural}}}}</ng-container>
+    <ng-container *ngIf="selectedObjects.size > 0">&nbsp;({{selectedObjects.size}} selected)</ng-container>
+  </div>
   <ngb-pagination *ngIf="collectionSize > 20" class="ms-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
 </div>
index 9579e5bd8475bc4d290aa0800b1581a74ff35231..a106c830f5368856817f92267a76a3793e31d504 100644 (file)
@@ -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()
+  })
 })
index c4e2ef0ea795cb9bf23f4245566947bfc41b7995..e20b5d4a7e5e5ccb650bd47190d8453ae9ad9d01 100644 (file)
@@ -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<T extends ObjectWithId>
   private unsubscribeNotifier: Subject<any> = new Subject()
   private _nameFilter: string
 
+  public selectedObjects: Set<number> = new Set()
+
   ngOnInit(): void {
     this.reloadData()
 
@@ -243,4 +246,63 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
       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
+              )
+            },
+          })
+      }
+    )
+  }
 }
index 66f81ea7f432df65420545e03017d19660b87242..62799c9f6749afb20a6e7829c19d4dd0f68cbe43 100644 (file)
     <tbody>
       <ng-container *ngFor="let task of tasks | slice: (page-1) * pageSize : page * pageSize">
       <tr (click)="toggleSelected(task, $event); $event.stopPropagation();">
-        <th>
+        <td>
           <div class="form-check">
             <input type="checkbox" class="form-check-input" id="task{{task.id}}" [checked]="selectedTasks.has(task.id)" (click)="toggleSelected(task, $event); $event.stopPropagation();">
             <label class="form-check-label" for="task{{task.id}}"></label>
           </div>
-        </th>
+        </td>
         <td class="overflow-auto name-col">{{ task.task_file_name }}</td>
         <td class="d-none d-lg-table-cell">{{ task.date_created | customDate:'short' }}</td>
         <td class="d-none d-lg-table-cell" *ngIf="activeTab !== 'started' && activeTab !== 'queued'">
index 9346aa85c095f666baf9af1294a2b70cbefe6fc8..29db6bf26ab7eeff564e753c686a1fe0b55b5773 100644 (file)
@@ -1,5 +1,4 @@
 import { ObjectWithId } from './object-with-id'
-import { PaperlessUser } from './paperless-user'
 
 export interface PermissionsObject {
   view: {
index e4ec93aeba8a6b2ce8de4ac171f944b69b1ddaac..70ae211e540aab7f6328546a3e6f0ac112e0b7c5 100644 (file)
@@ -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(() => {
index 1164545b27561f3deaf74525304d5a914d391c4f..5e0377cb9962b708cb3a314dc7c0804ad27a214a 100644 (file)
@@ -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<number>,
+    permissions: { owner: number; set_permissions: PermissionsObject }
+  ): Observable<string> {
+    return this.http.post<string>(`${this.baseUrl}bulk_edit_object_perms/`, {
+      objects,
+      object_type: this.resourceName,
+      owner: permissions.owner,
+      permissions: permissions.set_permissions,
+    })
+  }
 }
index 321a34ccba04ef44449b7bbc3f829f07c89840aa..0f99d5dcc4f1015f441810996f0fa220552ee176 100644 (file)
@@ -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
index f9c6da0a8edbad8e8a5f26d78ef0085db22692df..d4d6afe0416ab6e0028d49aade638c9a89e73d96 100644 (file)
@@ -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)
index 856b27e2763a43475fdee110d5b38826868b3d54..be6ce1ff732974272af396e23e44a3d455858d3a 100644 (file)
@@ -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.",
+            )
index 5d24478aa4bba11e5b372af1ada2ddb384cb1ee8..05e772ee0c6472210c679505ccdbbb59e3ad0f0b 100644 (file)
@@ -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,
             ],
         ),