Enhancement: bulk edit object permissions (#4176)

* bulk_edit_object_perms API endpoint

* Frontend support for bulk object permissions edit
This commit is contained in:
shamoon 2023-09-19 13:40:21 -07:00 committed by GitHub
parent 95c12c1840
commit f5717cca1c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 540 additions and 20 deletions

View File

@ -57,7 +57,8 @@ export class ToastsComponent implements OnInit, OnDestroy {
} }
getErrorText(error: any) { 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 ? '...' : ''}` return `${text.slice(0, 200)}${text.length > 200 ? '...' : ''}`
} }
} }

View File

@ -1,4 +1,14 @@
<pngx-page-header title="{{ typeNamePlural | titlecase }}"> <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> <button type="button" class="btn btn-sm btn-outline-primary" (click)="openCreateDialog()" *pngxIfPermissions="{ action: PermissionAction.Add, type: permissionType }" i18n>Create</button>
</pngx-page-header> </pngx-page-header>
@ -16,6 +26,12 @@
<table class="table table-striped align-middle border shadow-sm"> <table class="table table-striped align-middle border shadow-sm">
<thead> <thead>
<tr> <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" 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" 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> <th scope="col" pngxSortable="document_count" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Document count</th>
@ -30,7 +46,13 @@
<ng-container i18n>Loading...</ng-container> <ng-container i18n>Loading...</ng-container>
</td> </td>
</tr> </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">{{ object.name }}</td>
<td scope="row" class="d-none d-sm-table-cell">{{ getMatching(object) }}</td> <td scope="row" class="d-none d-sm-table-cell">{{ getMatching(object) }}</td>
<td scope="row">{{ object.document_count }}</td> <td scope="row">{{ object.document_count }}</td>
@ -54,17 +76,17 @@
</div> </div>
</div> </div>
<div class="btn-group d-none d-sm-block"> <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"> <svg class="buttonicon-sm" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#filter" /> <use xlink:href="assets/bootstrap-icons.svg#filter" />
</svg>&nbsp;<ng-container i18n>Documents</ng-container> </svg>&nbsp;<ng-container i18n>Documents</ng-container>
</button> </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"> <svg class="buttonicon-sm" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#pencil" /> <use xlink:href="assets/bootstrap-icons.svg#pencil" />
</svg>&nbsp;<ng-container i18n>Edit</ng-container> </svg>&nbsp;<ng-container i18n>Edit</ng-container>
</button> </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"> <svg class="buttonicon-sm" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#trash" /> <use xlink:href="assets/bootstrap-icons.svg#trash" />
</svg>&nbsp;<ng-container i18n>Delete</ng-container> </svg>&nbsp;<ng-container i18n>Delete</ng-container>
@ -75,7 +97,10 @@
</tbody> </tbody>
</table> </table>
<div class="d-flex" *ngIf="!isLoading"> <div class="d-flex mb-2" *ngIf="!isLoading">
<div i18n *ngIf="collectionSize > 0">{collectionSize, plural, =1 {One {{typeName}}} other {{{collectionSize || 0}} total {{typeNamePlural}}}}</div> <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> <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> </div>

View File

@ -35,6 +35,7 @@ import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { MATCH_AUTO } from 'src/app/data/matching-model' import { MATCH_AUTO } from 'src/app/data/matching-model'
import { MATCH_NONE } 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 { MATCH_LITERAL } from 'src/app/data/matching-model'
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
const tags: PaperlessTag[] = [ const tags: PaperlessTag[] = [
{ {
@ -72,6 +73,7 @@ describe('ManagementListComponent', () => {
IfPermissionsDirective, IfPermissionsDirective,
SafeHtmlPipe, SafeHtmlPipe,
ConfirmDialogComponent, ConfirmDialogComponent,
PermissionsDialogComponent,
], ],
providers: [ providers: [
{ {
@ -145,7 +147,7 @@ describe('ManagementListComponent', () => {
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const reloadSpy = jest.spyOn(component, 'reloadData') 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') createButton.triggerEventHandler('click')
expect(modal).not.toBeUndefined() expect(modal).not.toBeUndefined()
@ -170,7 +172,7 @@ describe('ManagementListComponent', () => {
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const reloadSpy = jest.spyOn(component, 'reloadData') 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') editButton.triggerEventHandler('click')
expect(modal).not.toBeUndefined() expect(modal).not.toBeUndefined()
@ -196,7 +198,7 @@ describe('ManagementListComponent', () => {
const deleteSpy = jest.spyOn(tagService, 'delete') const deleteSpy = jest.spyOn(tagService, 'delete')
const reloadSpy = jest.spyOn(component, 'reloadData') 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') deleteButton.triggerEventHandler('click')
expect(modal).not.toBeUndefined() expect(modal).not.toBeUndefined()
@ -216,7 +218,7 @@ describe('ManagementListComponent', () => {
it('should support quick filter for objects', () => { it('should support quick filter for objects', () => {
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter') 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') filterButton.triggerEventHandler('click')
expect(qfSpy).toHaveBeenCalledWith([ expect(qfSpy).toHaveBeenCalledWith([
{ rule_type: FILTER_HAS_TAGS_ALL, value: tags[0].id.toString() }, { rule_type: FILTER_HAS_TAGS_ALL, value: tags[0].id.toString() },
@ -229,4 +231,47 @@ describe('ManagementListComponent', () => {
sortable.triggerEventHandler('click') sortable.triggerEventHandler('click')
expect(reloadSpy).toHaveBeenCalled() 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()
})
}) })

View File

@ -6,7 +6,7 @@ import {
ViewChildren, ViewChildren,
} from '@angular/core' } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { Subject, Subscription } from 'rxjs' import { Subject } from 'rxjs'
import { debounceTime, distinctUntilChanged, takeUntil } from 'rxjs/operators' import { debounceTime, distinctUntilChanged, takeUntil } from 'rxjs/operators'
import { import {
MatchingModel, MatchingModel,
@ -15,7 +15,10 @@ import {
MATCH_NONE, MATCH_NONE,
} from 'src/app/data/matching-model' } from 'src/app/data/matching-model'
import { ObjectWithId } from 'src/app/data/object-with-id' 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 { import {
SortableDirective, SortableDirective,
SortEvent, SortEvent,
@ -28,11 +31,9 @@ import {
import { AbstractNameFilterService } from 'src/app/services/rest/abstract-name-filter-service' import { AbstractNameFilterService } from 'src/app/services/rest/abstract-name-filter-service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
EditDialogComponent,
EditDialogMode,
} from '../../common/edit-dialog/edit-dialog.component'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component' import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
export interface ManagementListColumn { export interface ManagementListColumn {
key: string key: string
@ -82,6 +83,8 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
private unsubscribeNotifier: Subject<any> = new Subject() private unsubscribeNotifier: Subject<any> = new Subject()
private _nameFilter: string private _nameFilter: string
public selectedObjects: Set<number> = new Set()
ngOnInit(): void { ngOnInit(): void {
this.reloadData() this.reloadData()
@ -243,4 +246,63 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
object 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
)
},
})
}
)
}
} }

View File

@ -47,12 +47,12 @@
<tbody> <tbody>
<ng-container *ngFor="let task of tasks | slice: (page-1) * pageSize : page * pageSize"> <ng-container *ngFor="let task of tasks | slice: (page-1) * pageSize : page * pageSize">
<tr (click)="toggleSelected(task, $event); $event.stopPropagation();"> <tr (click)="toggleSelected(task, $event); $event.stopPropagation();">
<th> <td>
<div class="form-check"> <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();"> <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> <label class="form-check-label" for="task{{task.id}}"></label>
</div> </div>
</th> </td>
<td class="overflow-auto name-col">{{ task.task_file_name }}</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">{{ task.date_created | customDate:'short' }}</td>
<td class="d-none d-lg-table-cell" *ngIf="activeTab !== 'started' && activeTab !== 'queued'"> <td class="d-none d-lg-table-cell" *ngIf="activeTab !== 'started' && activeTab !== 'queued'">

View File

@ -1,5 +1,4 @@
import { ObjectWithId } from './object-with-id' import { ObjectWithId } from './object-with-id'
import { PaperlessUser } from './paperless-user'
export interface PermissionsObject { export interface PermissionsObject {
view: { view: {

View File

@ -39,6 +39,31 @@ export const commonAbstractNameFilterPaperlessServiceTests = (
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.flush([]) 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(() => { beforeEach(() => {

View File

@ -1,5 +1,7 @@
import { ObjectWithId } from 'src/app/data/object-with-id' import { ObjectWithId } from 'src/app/data/object-with-id'
import { AbstractPaperlessService } from './abstract-paperless-service' import { AbstractPaperlessService } from './abstract-paperless-service'
import { PermissionsObject } from 'src/app/data/object-with-permissions'
import { Observable } from 'rxjs'
export abstract class AbstractNameFilterService< export abstract class AbstractNameFilterService<
T extends ObjectWithId, T extends ObjectWithId,
@ -21,4 +23,16 @@ export abstract class AbstractNameFilterService<
} }
return this.list(page, pageSize, sortField, sortReverse, params) 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,
})
}
} }

View File

@ -960,3 +960,78 @@ class ShareLinkSerializer(OwnedObjectSerializer):
def create(self, validated_data): def create(self, validated_data):
validated_data["slug"] = get_random_string(50) validated_data["slug"] = get_random_string(50)
return super().create(validated_data) 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

View File

@ -25,6 +25,7 @@ from django.test import override_settings
from django.utils import timezone from django.utils import timezone
from guardian.shortcuts import assign_perm from guardian.shortcuts import assign_perm
from guardian.shortcuts import get_perms from guardian.shortcuts import get_perms
from guardian.shortcuts import get_users_with_perms
from rest_framework import status from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from whoosh.writing import AsyncWriter from whoosh.writing import AsyncWriter
@ -5088,3 +5089,227 @@ class TestApiGroup(DirectoriesMixin, APITestCase):
returned_group1 = Group.objects.get(pk=group1.pk) returned_group1 = Group.objects.get(pk=group1.pk)
self.assertEqual(returned_group1.name, "Updated Name 1") 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)

View File

@ -63,6 +63,7 @@ from documents.permissions import PaperlessAdminPermissions
from documents.permissions import PaperlessObjectPermissions from documents.permissions import PaperlessObjectPermissions
from documents.permissions import get_objects_for_user_owner_aware from documents.permissions import get_objects_for_user_owner_aware
from documents.permissions import has_perms_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 documents.tasks import consume_file
from paperless import version from paperless import version
from paperless.db import GnuPG 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 .parsers import parse_date_generator
from .serialisers import AcknowledgeTasksViewSerializer from .serialisers import AcknowledgeTasksViewSerializer
from .serialisers import BulkDownloadSerializer from .serialisers import BulkDownloadSerializer
from .serialisers import BulkEditObjectPermissionsSerializer
from .serialisers import BulkEditSerializer from .serialisers import BulkEditSerializer
from .serialisers import CorrespondentSerializer from .serialisers import CorrespondentSerializer
from .serialisers import DocumentListSerializer from .serialisers import DocumentListSerializer
@ -1205,3 +1207,44 @@ def serve_file(doc: Document, use_archive: bool, disposition: str):
) )
response["Content-Disposition"] = content_disposition response["Content-Disposition"] = content_disposition
return response 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.",
)

View File

@ -12,6 +12,7 @@ from rest_framework.routers import DefaultRouter
from documents.views import AcknowledgeTasksView from documents.views import AcknowledgeTasksView
from documents.views import BulkDownloadView from documents.views import BulkDownloadView
from documents.views import BulkEditObjectPermissionsView
from documents.views import BulkEditView from documents.views import BulkEditView
from documents.views import CorrespondentViewSet from documents.views import CorrespondentViewSet
from documents.views import DocumentTypeViewSet from documents.views import DocumentTypeViewSet
@ -109,6 +110,11 @@ urlpatterns = [
name="mail_accounts_test", name="mail_accounts_test",
), ),
path("token/", views.obtain_auth_token), path("token/", views.obtain_auth_token),
re_path(
"^bulk_edit_object_perms/",
BulkEditObjectPermissionsView.as_view(),
name="bulk_edit_object_permissions",
),
*api_router.urls, *api_router.urls,
], ],
), ),