mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-11-04 03:27:12 -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