Merge branch 'dev' into feature-confirm-buttons

This commit is contained in:
shamoon 2024-02-08 10:16:46 -08:00
commit ad773cd876
14 changed files with 340 additions and 79 deletions

View File

@ -375,14 +375,15 @@ The following methods are supported:
### Objects ### Objects
Bulk editing for objects (tags, document types etc.) currently supports only updating permissions, using Bulk editing for objects (tags, document types etc.) currently supports set permissions or delete
the endpoint: `/api/bulk_edit_object_perms/` which requires a json payload of the format: operations, using the endpoint: `/api/bulk_edit_objects/`, which requires a json payload of the format:
```json ```json
{ {
"objects": [LIST_OF_OBJECT_IDS], "objects": [LIST_OF_OBJECT_IDS],
"object_type": "tags", "correspondents", "document_types" or "storage_paths" "object_type": "tags", "correspondents", "document_types" or "storage_paths",
"owner": OWNER_ID // optional "operation": "set_permissions" or "delete",
"owner": OWNER_ID, // optional
"permissions": { "view": { "users": [] ... }, "change": { ... } }, // (see 'set_permissions' format above) "permissions": { "view": { "users": [] ... }, "change": { ... } }, // (see 'set_permissions' format above)
"merge": true / false // defaults to false, see above "merge": true / false // defaults to false, see above
} }

View File

@ -2,9 +2,12 @@
<button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedObjects.size === 0"> <button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedObjects.size === 0">
<i-bs name="x"></i-bs>&nbsp;<ng-container i18n>Clear selection</ng-container> <i-bs name="x"></i-bs>&nbsp;<ng-container i18n>Clear selection</ng-container>
</button> </button>
<button type="button" class="btn btn-sm btn-outline-primary me-5" (click)="setPermissions()" [disabled]="!userOwnsAll || selectedObjects.size === 0"> <button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || selectedObjects.size === 0">
<i-bs name="person-fill-lock"></i-bs>&nbsp;<ng-container i18n>Permissions</ng-container> <i-bs name="person-fill-lock"></i-bs>&nbsp;<ng-container i18n>Permissions</ng-container>
</button> </button>
<button type="button" class="btn btn-sm btn-outline-danger me-5" (click)="delete()" [disabled]="!userOwnsAll || selectedObjects.size === 0">
<i-bs name="trash"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
</button>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="openCreateDialog()" *pngxIfPermissions="{ action: PermissionAction.Add, type: permissionType }"> <button type="button" class="btn btn-sm btn-outline-primary" (click)="openCreateDialog()" *pngxIfPermissions="{ action: PermissionAction.Add, type: permissionType }">
<i-bs name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Create</ng-container> <i-bs name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Create</ng-container>
</button> </button>

View File

@ -39,6 +39,7 @@ import { MATCH_LITERAL } from 'src/app/data/matching-model'
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component' import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component' import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
import { BulkEditObjectOperation } from 'src/app/services/rest/abstract-name-filter-service'
const tags: Tag[] = [ const tags: Tag[] = [
{ {
@ -153,7 +154,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'))[2] const createButton = fixture.debugElement.queryAll(By.css('button'))[3]
createButton.triggerEventHandler('click') createButton.triggerEventHandler('click')
expect(modal).not.toBeUndefined() expect(modal).not.toBeUndefined()
@ -177,7 +178,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'))[6] const editButton = fixture.debugElement.queryAll(By.css('button'))[7]
editButton.triggerEventHandler('click') editButton.triggerEventHandler('click')
expect(modal).not.toBeUndefined() expect(modal).not.toBeUndefined()
@ -218,7 +219,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'))[5] const filterButton = fixture.debugElement.queryAll(By.css('button'))[6]
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() },
@ -246,7 +247,7 @@ describe('ManagementListComponent', () => {
}) })
it('should support bulk edit permissions', () => { it('should support bulk edit permissions', () => {
const bulkEditPermsSpy = jest.spyOn(tagService, 'bulk_update_permissions') const bulkEditPermsSpy = jest.spyOn(tagService, 'bulk_edit_objects')
component.toggleSelected(tags[0]) component.toggleSelected(tags[0])
component.toggleSelected(tags[1]) component.toggleSelected(tags[1])
component.toggleSelected(tags[2]) component.toggleSelected(tags[2])
@ -280,4 +281,35 @@ describe('ManagementListComponent', () => {
expect(bulkEditPermsSpy).toHaveBeenCalled() expect(bulkEditPermsSpy).toHaveBeenCalled()
expect(successToastSpy).toHaveBeenCalled() expect(successToastSpy).toHaveBeenCalled()
}) })
it('should support bulk delete objects', () => {
const bulkEditSpy = jest.spyOn(tagService, 'bulk_edit_objects')
component.toggleSelected(tags[0])
component.toggleSelected(tags[1])
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.delete()
expect(modal).not.toBeUndefined()
// fail first
bulkEditSpy.mockReturnValueOnce(
throwError(() => new Error('error setting permissions'))
)
const errorToastSpy = jest.spyOn(toastService, 'showError')
modal.componentInstance.confirmClicked.emit(null)
expect(bulkEditSpy).toHaveBeenCalledWith(
Array.from(selected),
BulkEditObjectOperation.Delete
)
expect(errorToastSpy).toHaveBeenCalled()
const successToastSpy = jest.spyOn(toastService, 'showInfo')
bulkEditSpy.mockReturnValueOnce(of('OK'))
modal.componentInstance.confirmClicked.emit(null)
expect(bulkEditSpy).toHaveBeenCalled()
expect(successToastSpy).toHaveBeenCalled()
})
}) })

View File

@ -25,7 +25,10 @@ import {
PermissionsService, PermissionsService,
PermissionType, PermissionType,
} from 'src/app/services/permissions.service' } from 'src/app/services/permissions.service'
import { AbstractNameFilterService } from 'src/app/services/rest/abstract-name-filter-service' import {
AbstractNameFilterService,
BulkEditObjectOperation,
} 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 { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component' import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
@ -266,8 +269,9 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
({ permissions, merge }) => { ({ permissions, merge }) => {
modal.componentInstance.buttonsEnabled = false modal.componentInstance.buttonsEnabled = false
this.service this.service
.bulk_update_permissions( .bulk_edit_objects(
Array.from(this.selectedObjects), Array.from(this.selectedObjects),
BulkEditObjectOperation.SetPermissions,
permissions, permissions,
merge merge
) )
@ -290,4 +294,37 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
} }
) )
} }
delete() {
let modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Confirm delete`
modal.componentInstance.messageBold = $localize`This operation will permanently delete all objects.`
modal.componentInstance.message = $localize`This operation cannot be undone.`
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled = false
this.service
.bulk_edit_objects(
Array.from(this.selectedObjects),
BulkEditObjectOperation.Delete
)
.subscribe({
next: () => {
modal.close()
this.toastService.showInfo($localize`Objects deleted successfully`)
this.reloadData()
},
error: (error) => {
modal.componentInstance.buttonsEnabled = true
this.toastService.showError(
$localize`Error deleting objects`,
error
)
},
})
})
}
} }

View File

@ -2,7 +2,10 @@ import { HttpTestingController } from '@angular/common/http/testing'
import { Subscription } from 'rxjs' import { Subscription } from 'rxjs'
import { TestBed } from '@angular/core/testing' import { TestBed } from '@angular/core/testing'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { AbstractNameFilterService } from './abstract-name-filter-service' import {
AbstractNameFilterService,
BulkEditObjectOperation,
} from './abstract-name-filter-service'
import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec' import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
let httpTestingController: HttpTestingController let httpTestingController: HttpTestingController
@ -53,8 +56,9 @@ export const commonAbstractNameFilterPaperlessServiceTests = (
}, },
} }
subscription = service subscription = service
.bulk_update_permissions( .bulk_edit_objects(
[1, 2], [1, 2],
BulkEditObjectOperation.SetPermissions,
{ {
owner, owner,
set_permissions: permissions, set_permissions: permissions,
@ -63,9 +67,33 @@ export const commonAbstractNameFilterPaperlessServiceTests = (
) )
.subscribe() .subscribe()
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}bulk_edit_object_perms/` `${environment.apiBaseUrl}bulk_edit_objects/`
) )
expect(req.request.method).toEqual('POST') expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({
objects: [1, 2],
object_type: endpoint,
operation: BulkEditObjectOperation.SetPermissions,
permissions,
owner,
merge: true,
})
req.flush([])
})
test('should call appropriate api endpoint for bulk delete objects', () => {
subscription = service
.bulk_edit_objects([1, 2], BulkEditObjectOperation.Delete)
.subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}bulk_edit_objects/`
)
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({
objects: [1, 2],
object_type: endpoint,
operation: BulkEditObjectOperation.Delete,
})
req.flush([]) req.flush([])
}) })
}) })

View File

@ -3,6 +3,11 @@ import { AbstractPaperlessService } from './abstract-paperless-service'
import { PermissionsObject } from 'src/app/data/object-with-permissions' import { PermissionsObject } from 'src/app/data/object-with-permissions'
import { Observable } from 'rxjs' import { Observable } from 'rxjs'
export enum BulkEditObjectOperation {
SetPermissions = 'set_permissions',
Delete = 'delete',
}
export abstract class AbstractNameFilterService< export abstract class AbstractNameFilterService<
T extends ObjectWithId, T extends ObjectWithId,
> extends AbstractPaperlessService<T> { > extends AbstractPaperlessService<T> {
@ -24,17 +29,22 @@ export abstract class AbstractNameFilterService<
return this.list(page, pageSize, sortField, sortReverse, params) return this.list(page, pageSize, sortField, sortReverse, params)
} }
bulk_update_permissions( bulk_edit_objects(
objects: Array<number>, objects: Array<number>,
permissions: { owner: number; set_permissions: PermissionsObject }, operation: BulkEditObjectOperation,
merge: boolean permissions: { owner: number; set_permissions: PermissionsObject } = null,
merge: boolean = null
): Observable<string> { ): Observable<string> {
return this.http.post<string>(`${this.baseUrl}bulk_edit_object_perms/`, { const params = {
objects, objects,
object_type: this.resourceName, object_type: this.resourceName,
owner: permissions.owner, operation,
permissions: permissions.set_permissions, }
merge, if (operation === BulkEditObjectOperation.SetPermissions) {
}) params['owner'] = permissions?.owner
params['permissions'] = permissions?.set_permissions
params['merge'] = merge
}
return this.http.post<string>(`${this.baseUrl}bulk_edit_objects/`, params)
} }
} }

View File

@ -3,7 +3,7 @@ const base_url = new URL(document.baseURI)
export const environment = { export const environment = {
production: true, production: true,
apiBaseUrl: document.baseURI + 'api/', apiBaseUrl: document.baseURI + 'api/',
apiVersion: '4', apiVersion: '5',
appTitle: 'Paperless-ngx', appTitle: 'Paperless-ngx',
version: '2.4.3-dev', version: '2.4.3-dev',
webSocketHost: window.location.host, webSocketHost: window.location.host,

View File

@ -5,7 +5,7 @@
export const environment = { export const environment = {
production: false, production: false,
apiBaseUrl: 'http://localhost:8000/api/', apiBaseUrl: 'http://localhost:8000/api/',
apiVersion: '4', apiVersion: '5',
appTitle: 'Paperless-ngx', appTitle: 'Paperless-ngx',
version: 'DEVELOPMENT', version: 'DEVELOPMENT',
webSocketHost: 'localhost:8000', webSocketHost: 'localhost:8000',

View File

@ -1281,7 +1281,7 @@ class ShareLinkSerializer(OwnedObjectSerializer):
return super().create(validated_data) return super().create(validated_data)
class BulkEditObjectPermissionsSerializer(serializers.Serializer, SetPermissionsMixin): class BulkEditObjectsSerializer(serializers.Serializer, SetPermissionsMixin):
objects = serializers.ListField( objects = serializers.ListField(
required=True, required=True,
allow_empty=False, allow_empty=False,
@ -1301,6 +1301,16 @@ class BulkEditObjectPermissionsSerializer(serializers.Serializer, SetPermissions
write_only=True, write_only=True,
) )
operation = serializers.ChoiceField(
choices=[
"set_permissions",
"delete",
],
label="Operation",
required=True,
write_only=True,
)
owner = serializers.PrimaryKeyRelatedField( owner = serializers.PrimaryKeyRelatedField(
queryset=User.objects.all(), queryset=User.objects.all(),
required=False, required=False,
@ -1353,11 +1363,14 @@ class BulkEditObjectPermissionsSerializer(serializers.Serializer, SetPermissions
def validate(self, attrs): def validate(self, attrs):
object_type = attrs["object_type"] object_type = attrs["object_type"]
objects = attrs["objects"] objects = attrs["objects"]
permissions = attrs.get("permissions") operation = attrs.get("operation")
self._validate_objects(objects, object_type) self._validate_objects(objects, object_type)
if permissions is not None:
self._validate_permissions(permissions) if operation == "set_permissions":
permissions = attrs.get("permissions")
if permissions is not None:
self._validate_permissions(permissions)
return attrs return attrs

View File

@ -222,3 +222,118 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
args, _ = bulk_update_mock.call_args args, _ = bulk_update_mock.call_args
self.assertCountEqual([document.pk], args[0]) self.assertCountEqual([document.pk], args[0])
class TestBulkEditObjects(APITestCase):
# See test_api_permissions.py for bulk tests on permissions
def setUp(self):
super().setUp()
self.temp_admin = User.objects.create_superuser(username="temp_admin")
self.client.force_authenticate(user=self.temp_admin)
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_objects_delete(self):
"""
GIVEN:
- Existing objects
WHEN:
- bulk_edit_objects API endpoint is called with delete operation
THEN:
- Objects are deleted
"""
response = self.client.post(
"/api/bulk_edit_objects/",
json.dumps(
{
"objects": [self.t1.id, self.t2.id],
"object_type": "tags",
"operation": "delete",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(Tag.objects.count(), 0)
response = self.client.post(
"/api/bulk_edit_objects/",
json.dumps(
{
"objects": [self.c1.id],
"object_type": "correspondents",
"operation": "delete",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(Correspondent.objects.count(), 0)
response = self.client.post(
"/api/bulk_edit_objects/",
json.dumps(
{
"objects": [self.dt1.id],
"object_type": "document_types",
"operation": "delete",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(DocumentType.objects.count(), 0)
response = self.client.post(
"/api/bulk_edit_objects/",
json.dumps(
{
"objects": [self.sp1.id],
"object_type": "storage_paths",
"operation": "delete",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(StoragePath.objects.count(), 0)
def test_bulk_edit_object_permissions_insufficient_perms(self):
"""
GIVEN:
- Objects owned by user other than logged in user
WHEN:
- bulk_edit_objects API endpoint is called with delete operation
THEN:
- User is not able to delete objects
"""
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_objects/",
json.dumps(
{
"objects": [self.t1.id, self.t2.id],
"object_type": "tags",
"operation": "delete",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.content, b"Insufficient permissions")

View File

@ -717,7 +717,7 @@ class TestBulkEditObjectPermissions(APITestCase):
GIVEN: GIVEN:
- Existing objects - Existing objects
WHEN: WHEN:
- bulk_edit_object_perms API endpoint is called - bulk_edit_objects API endpoint is called with set_permissions operation
THEN: THEN:
- Permissions and / or owner are changed - Permissions and / or owner are changed
""" """
@ -733,11 +733,12 @@ class TestBulkEditObjectPermissions(APITestCase):
} }
response = self.client.post( response = self.client.post(
"/api/bulk_edit_object_perms/", "/api/bulk_edit_objects/",
json.dumps( json.dumps(
{ {
"objects": [self.t1.id, self.t2.id], "objects": [self.t1.id, self.t2.id],
"object_type": "tags", "object_type": "tags",
"operation": "set_permissions",
"permissions": permissions, "permissions": permissions,
}, },
), ),
@ -748,11 +749,12 @@ class TestBulkEditObjectPermissions(APITestCase):
self.assertIn(self.user1, get_users_with_perms(self.t1)) self.assertIn(self.user1, get_users_with_perms(self.t1))
response = self.client.post( response = self.client.post(
"/api/bulk_edit_object_perms/", "/api/bulk_edit_objects/",
json.dumps( json.dumps(
{ {
"objects": [self.c1.id], "objects": [self.c1.id],
"object_type": "correspondents", "object_type": "correspondents",
"operation": "set_permissions",
"permissions": permissions, "permissions": permissions,
}, },
), ),
@ -763,11 +765,12 @@ class TestBulkEditObjectPermissions(APITestCase):
self.assertIn(self.user1, get_users_with_perms(self.c1)) self.assertIn(self.user1, get_users_with_perms(self.c1))
response = self.client.post( response = self.client.post(
"/api/bulk_edit_object_perms/", "/api/bulk_edit_objects/",
json.dumps( json.dumps(
{ {
"objects": [self.dt1.id], "objects": [self.dt1.id],
"object_type": "document_types", "object_type": "document_types",
"operation": "set_permissions",
"permissions": permissions, "permissions": permissions,
}, },
), ),
@ -778,11 +781,12 @@ class TestBulkEditObjectPermissions(APITestCase):
self.assertIn(self.user1, get_users_with_perms(self.dt1)) self.assertIn(self.user1, get_users_with_perms(self.dt1))
response = self.client.post( response = self.client.post(
"/api/bulk_edit_object_perms/", "/api/bulk_edit_objects/",
json.dumps( json.dumps(
{ {
"objects": [self.sp1.id], "objects": [self.sp1.id],
"object_type": "storage_paths", "object_type": "storage_paths",
"operation": "set_permissions",
"permissions": permissions, "permissions": permissions,
}, },
), ),
@ -793,11 +797,12 @@ class TestBulkEditObjectPermissions(APITestCase):
self.assertIn(self.user1, get_users_with_perms(self.sp1)) self.assertIn(self.user1, get_users_with_perms(self.sp1))
response = self.client.post( response = self.client.post(
"/api/bulk_edit_object_perms/", "/api/bulk_edit_objects/",
json.dumps( json.dumps(
{ {
"objects": [self.t1.id, self.t2.id], "objects": [self.t1.id, self.t2.id],
"object_type": "tags", "object_type": "tags",
"operation": "set_permissions",
"owner": self.user3.id, "owner": self.user3.id,
}, },
), ),
@ -808,11 +813,12 @@ class TestBulkEditObjectPermissions(APITestCase):
self.assertEqual(Tag.objects.get(pk=self.t2.id).owner, self.user3) self.assertEqual(Tag.objects.get(pk=self.t2.id).owner, self.user3)
response = self.client.post( response = self.client.post(
"/api/bulk_edit_object_perms/", "/api/bulk_edit_objects/",
json.dumps( json.dumps(
{ {
"objects": [self.sp1.id], "objects": [self.sp1.id],
"object_type": "storage_paths", "object_type": "storage_paths",
"operation": "set_permissions",
"owner": self.user3.id, "owner": self.user3.id,
}, },
), ),
@ -827,7 +833,7 @@ class TestBulkEditObjectPermissions(APITestCase):
GIVEN: GIVEN:
- Existing objects - Existing objects
WHEN: WHEN:
- bulk_edit_object_perms API endpoint is called with merge=True or merge=False (default) - bulk_edit_objects API endpoint is called with set_permissions operation with merge=True or merge=False (default)
THEN: THEN:
- Permissions and / or owner are replaced or merged, depending on the merge flag - Permissions and / or owner are replaced or merged, depending on the merge flag
""" """
@ -848,13 +854,14 @@ class TestBulkEditObjectPermissions(APITestCase):
# merge=True # merge=True
response = self.client.post( response = self.client.post(
"/api/bulk_edit_object_perms/", "/api/bulk_edit_objects/",
json.dumps( json.dumps(
{ {
"objects": [self.t1.id, self.t2.id], "objects": [self.t1.id, self.t2.id],
"object_type": "tags", "object_type": "tags",
"owner": self.user1.id, "owner": self.user1.id,
"permissions": permissions, "permissions": permissions,
"operation": "set_permissions",
"merge": True, "merge": True,
}, },
), ),
@ -877,12 +884,13 @@ class TestBulkEditObjectPermissions(APITestCase):
# merge=False (default) # merge=False (default)
response = self.client.post( response = self.client.post(
"/api/bulk_edit_object_perms/", "/api/bulk_edit_objects/",
json.dumps( json.dumps(
{ {
"objects": [self.t1.id, self.t2.id], "objects": [self.t1.id, self.t2.id],
"object_type": "tags", "object_type": "tags",
"permissions": permissions, "permissions": permissions,
"operation": "set_permissions",
"merge": False, "merge": False,
}, },
), ),
@ -900,7 +908,7 @@ class TestBulkEditObjectPermissions(APITestCase):
GIVEN: GIVEN:
- Objects owned by user other than logged in user - Objects owned by user other than logged in user
WHEN: WHEN:
- bulk_edit_object_perms API endpoint is called - bulk_edit_objects API endpoint is called with set_permissions operation
THEN: THEN:
- User is not able to change permissions - User is not able to change permissions
""" """
@ -909,11 +917,12 @@ class TestBulkEditObjectPermissions(APITestCase):
self.client.force_authenticate(user=self.user1) self.client.force_authenticate(user=self.user1)
response = self.client.post( response = self.client.post(
"/api/bulk_edit_object_perms/", "/api/bulk_edit_objects/",
json.dumps( json.dumps(
{ {
"objects": [self.t1.id, self.t2.id], "objects": [self.t1.id, self.t2.id],
"object_type": "tags", "object_type": "tags",
"operation": "set_permissions",
"owner": self.user1.id, "owner": self.user1.id,
}, },
), ),
@ -928,17 +937,18 @@ class TestBulkEditObjectPermissions(APITestCase):
GIVEN: GIVEN:
- Existing objects - Existing objects
WHEN: WHEN:
- bulk_edit_object_perms API endpoint is called with invalid params - bulk_edit_objects API endpoint is called with set_permissions operation with invalid params
THEN: THEN:
- Validation fails - Validation fails
""" """
# not a list # not a list
response = self.client.post( response = self.client.post(
"/api/bulk_edit_object_perms/", "/api/bulk_edit_objects/",
json.dumps( json.dumps(
{ {
"objects": self.t1.id, "objects": self.t1.id,
"object_type": "tags", "object_type": "tags",
"operation": "set_permissions",
"owner": self.user1.id, "owner": self.user1.id,
}, },
), ),
@ -949,7 +959,7 @@ class TestBulkEditObjectPermissions(APITestCase):
# not a list of ints # not a list of ints
response = self.client.post( response = self.client.post(
"/api/bulk_edit_object_perms/", "/api/bulk_edit_objects/",
json.dumps( json.dumps(
{ {
"objects": ["one"], "objects": ["one"],
@ -964,11 +974,12 @@ class TestBulkEditObjectPermissions(APITestCase):
# duplicates # duplicates
response = self.client.post( response = self.client.post(
"/api/bulk_edit_object_perms/", "/api/bulk_edit_objects/",
json.dumps( json.dumps(
{ {
"objects": [self.t1.id, self.t2.id, self.t1.id], "objects": [self.t1.id, self.t2.id, self.t1.id],
"object_type": "tags", "object_type": "tags",
"operation": "set_permissions",
"owner": self.user1.id, "owner": self.user1.id,
}, },
), ),
@ -979,11 +990,12 @@ class TestBulkEditObjectPermissions(APITestCase):
# not a valid object type # not a valid object type
response = self.client.post( response = self.client.post(
"/api/bulk_edit_object_perms/", "/api/bulk_edit_objects/",
json.dumps( json.dumps(
{ {
"objects": [1], "objects": [1],
"object_type": "madeup", "object_type": "madeup",
"operation": "set_permissions",
"owner": self.user1.id, "owner": self.user1.id,
}, },
), ),

View File

@ -115,7 +115,7 @@ from documents.permissions import has_perms_owner_aware
from documents.permissions import set_permissions_for_object from documents.permissions import set_permissions_for_object
from documents.serialisers import AcknowledgeTasksViewSerializer from documents.serialisers import AcknowledgeTasksViewSerializer
from documents.serialisers import BulkDownloadSerializer from documents.serialisers import BulkDownloadSerializer
from documents.serialisers import BulkEditObjectPermissionsSerializer from documents.serialisers import BulkEditObjectsSerializer
from documents.serialisers import BulkEditSerializer from documents.serialisers import BulkEditSerializer
from documents.serialisers import CorrespondentSerializer from documents.serialisers import CorrespondentSerializer
from documents.serialisers import CustomFieldSerializer from documents.serialisers import CustomFieldSerializer
@ -1401,9 +1401,9 @@ def serve_file(doc: Document, use_archive: bool, disposition: str):
return response return response
class BulkEditObjectPermissionsView(GenericAPIView, PassUserMixin): class BulkEditObjectsView(GenericAPIView, PassUserMixin):
permission_classes = (IsAuthenticated,) permission_classes = (IsAuthenticated,)
serializer_class = BulkEditObjectPermissionsSerializer serializer_class = BulkEditObjectsSerializer
parser_classes = (parsers.JSONParser,) parser_classes = (parsers.JSONParser,)
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
@ -1414,42 +1414,52 @@ class BulkEditObjectPermissionsView(GenericAPIView, PassUserMixin):
object_type = serializer.validated_data.get("object_type") object_type = serializer.validated_data.get("object_type")
object_ids = serializer.validated_data.get("objects") object_ids = serializer.validated_data.get("objects")
object_class = serializer.get_object_class(object_type) object_class = serializer.get_object_class(object_type)
permissions = serializer.validated_data.get("permissions") operation = serializer.validated_data.get("operation")
owner = serializer.validated_data.get("owner")
merge = serializer.validated_data.get("merge") objs = object_class.objects.filter(pk__in=object_ids)
if not user.is_superuser: 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) has_perms = all((obj.owner == user or obj.owner is None) for obj in objs)
if not has_perms: if not has_perms:
return HttpResponseForbidden("Insufficient permissions") return HttpResponseForbidden("Insufficient permissions")
try: if operation == "set_permissions":
qs = object_class.objects.filter(id__in=object_ids) permissions = serializer.validated_data.get("permissions")
owner = serializer.validated_data.get("owner")
merge = serializer.validated_data.get("merge")
# if merge is true, we dont want to remove the owner try:
if "owner" in serializer.validated_data and ( qs = object_class.objects.filter(id__in=object_ids)
not merge or (merge and owner is not None)
):
# if merge is true, we dont want to overwrite the owner
qs_owner_update = qs.filter(owner__isnull=True) if merge else qs
qs_owner_update.update(owner=owner)
if "permissions" in serializer.validated_data: # if merge is true, we dont want to remove the owner
for obj in qs: if "owner" in serializer.validated_data and (
set_permissions_for_object( not merge or (merge and owner is not None)
permissions=permissions, ):
object=obj, # if merge is true, we dont want to overwrite the owner
merge=merge, qs_owner_update = qs.filter(owner__isnull=True) if merge else qs
) qs_owner_update.update(owner=owner)
return Response({"result": "OK"}) if "permissions" in serializer.validated_data:
except Exception as e: for obj in qs:
logger.warning(f"An error occurred performing bulk permissions edit: {e!s}") set_permissions_for_object(
return HttpResponseBadRequest( permissions=permissions,
"Error performing bulk permissions edit, check logs for more detail.", object=obj,
) merge=merge,
)
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.",
)
elif operation == "delete":
objs.delete()
return Response({"result": "OK"})
class WorkflowTriggerViewSet(ModelViewSet): class WorkflowTriggerViewSet(ModelViewSet):

View File

@ -322,7 +322,7 @@ REST_FRAMEWORK = {
"DEFAULT_VERSION": "1", "DEFAULT_VERSION": "1",
# Make sure these are ordered and that the most recent version appears # Make sure these are ordered and that the most recent version appears
# last # last
"ALLOWED_VERSIONS": ["1", "2", "3", "4"], "ALLOWED_VERSIONS": ["1", "2", "3", "4", "5"],
} }
if DEBUG: if DEBUG:

View File

@ -16,7 +16,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 BulkEditObjectsView
from documents.views import BulkEditView from documents.views import BulkEditView
from documents.views import CorrespondentViewSet from documents.views import CorrespondentViewSet
from documents.views import CustomFieldViewSet from documents.views import CustomFieldViewSet
@ -129,9 +129,9 @@ urlpatterns = [
), ),
path("token/", views.obtain_auth_token), path("token/", views.obtain_auth_token),
re_path( re_path(
"^bulk_edit_object_perms/", "^bulk_edit_objects/",
BulkEditObjectPermissionsView.as_view(), BulkEditObjectsView.as_view(),
name="bulk_edit_object_permissions", name="bulk_edit_objects",
), ),
path("profile/generate_auth_token/", GenerateAuthTokenView.as_view()), path("profile/generate_auth_token/", GenerateAuthTokenView.as_view()),
path( path(