mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-04-17 10:13:56 -05:00
Enhancement: bulk edit object permissions (#4176)
* bulk_edit_object_perms API endpoint * Frontend support for bulk object permissions edit
This commit is contained in:
parent
95c12c1840
commit
f5717cca1c
@ -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 ? '...' : ''}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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> <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> <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> <ng-container i18n>Documents</ng-container>
|
</svg> <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> <ng-container i18n>Edit</ng-container>
|
</svg> <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> <ng-container i18n>Delete</ng-container>
|
</svg> <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"> ({{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>
|
||||||
|
@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@ -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
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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'">
|
||||||
|
@ -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: {
|
||||||
|
@ -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(() => {
|
||||||
|
@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
@ -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.",
|
||||||
|
)
|
||||||
|
@ -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,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
Loading…
x
Reference in New Issue
Block a user