Bulk edit permissions

This commit is contained in:
Michael Shamoon 2022-12-08 02:03:50 -08:00
parent 211fbf0cf6
commit 6ece5240a5
12 changed files with 267 additions and 99 deletions

View File

@ -108,6 +108,7 @@ import localeSr from '@angular/common/locales/sr'
import localeSv from '@angular/common/locales/sv' import localeSv from '@angular/common/locales/sv'
import localeTr from '@angular/common/locales/tr' import localeTr from '@angular/common/locales/tr'
import localeZh from '@angular/common/locales/zh' import localeZh from '@angular/common/locales/zh'
import { PermissionsDialogComponent } from './components/common/permissions-dialog/permissions-dialog.component'
registerLocaleData(localeBe) registerLocaleData(localeBe)
registerLocaleData(localeCs) registerLocaleData(localeCs)
@ -203,6 +204,7 @@ function initializeApp(settings: SettingsService) {
PermissionsGroupComponent, PermissionsGroupComponent,
IfOwnerDirective, IfOwnerDirective,
IfObjectPermissionsDirective, IfObjectPermissionsDirective,
PermissionsDialogComponent,
], ],
imports: [ imports: [
BrowserModule, BrowserModule,

View File

@ -1,6 +1,6 @@
import { Component, EventEmitter, Input, Output } from '@angular/core' import { Component, EventEmitter, Input, Output } from '@angular/core'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { interval, Subject, switchMap, take } from 'rxjs' import { interval, Subject, take } from 'rxjs'
@Component({ @Component({
selector: 'app-confirm-dialog', selector: 'app-confirm-dialog',

View File

@ -0,0 +1,29 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancelClicked()">
</button>
</div>
<div class="modal-body">
<p class="mb-0" *ngIf="message" [innerHTML]="message | safeHtml"></p>
<form [formGroup]="form">
<div formGroupName="set_permissions">
<h6 i18n>View</h6>
<div formGroupName="view">
<app-permissions-user type="view" formControlName="users"></app-permissions-user>
<app-permissions-group type="view" formControlName="groups"></app-permissions-group>
</div>
<h6 i18n>Edit</h6>
<div formGroupName="change">
<app-permissions-user type="change" formControlName="users"></app-permissions-user>
<app-permissions-group type="change" formControlName="groups"></app-permissions-group>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-primary" (click)="cancelClicked()" i18n>Cancel</button>
<button type="button" class="btn btn-primary" (click)="confirmClicked.emit(permissions)" i18n>Confirm</button>
</div>

View File

@ -0,0 +1,46 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
import { FormControl, FormGroup } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { PaperlessGroup } from 'src/app/data/paperless-group'
import { PaperlessUser } from 'src/app/data/paperless-user'
@Component({
selector: 'app-permissions-dialog',
templateUrl: './permissions-dialog.component.html',
styleUrls: ['./permissions-dialog.component.scss'],
})
export class PermissionsDialogComponent implements OnInit {
constructor(public activeModal: NgbActiveModal) {}
@Output()
public confirmClicked = new EventEmitter()
@Input()
title = $localize`Set Permissions`
form = new FormGroup({
set_permissions: new FormGroup({
view: new FormGroup({
users: new FormControl([]),
groups: new FormControl([]),
}),
change: new FormGroup({
users: new FormControl([]),
groups: new FormControl([]),
}),
}),
})
get permissions() {
return this.form.value['set_permissions']
}
@Input()
message = $localize`Note that permissions set here will override any existing permissions`
ngOnInit(): void {}
cancelClicked() {
this.activeModal.close()
}
}

View File

@ -65,7 +65,13 @@
</div> </div>
</div> </div>
<div class="col-auto ms-auto mb-2 mb-xl-0 d-flex"> <div class="col-auto ms-auto mb-2 mb-xl-0 d-flex">
<div class="btn-group btn-group-sm me-2"> <div class="btn-toolbar me-2">
<button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()">
<svg width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#person-fill-lock" />
</svg>&nbsp;<ng-container i18n>Permissions</ng-container>
</button>
<div ngbDropdown class="me-2 d-flex"> <div ngbDropdown class="me-2 d-flex">
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle> <button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle>

View File

@ -26,6 +26,7 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path' import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path'
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
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'
@Component({ @Component({
selector: 'app-bulk-editor', selector: 'app-bulk-editor',
@ -397,4 +398,16 @@ export class BulkEditorComponent extends ComponentWithPermissions {
this.executeBulkOperation(modal, 'redo_ocr', {}) this.executeBulkOperation(modal, 'redo_ocr', {})
}) })
} }
setPermissions() {
let modal = this.modalService.open(PermissionsDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.confirmClicked.subscribe((permissions) => {
modal.componentInstance.buttonsEnabled = false
this.executeBulkOperation(modal, 'set_permissions', {
permissions,
})
})
}
} }

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 861 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@ -5,6 +5,7 @@ from documents.models import Correspondent
from documents.models import Document from documents.models import Document
from documents.models import DocumentType from documents.models import DocumentType
from documents.models import StoragePath from documents.models import StoragePath
from documents.permissions import set_permissions_for_object
from documents.tasks import bulk_update_documents from documents.tasks import bulk_update_documents
from documents.tasks import update_document_archive_file from documents.tasks import update_document_archive_file
@ -128,3 +129,15 @@ def redo_ocr(doc_ids):
) )
return "OK" return "OK"
def set_permissions(doc_ids, permissions):
qs = Document.objects.filter(id__in=doc_ids)
for doc in qs:
set_permissions_for_object(permissions, doc)
affected_docs = [doc.id for doc in qs]
bulk_update_documents.delay(document_ids=affected_docs)
return "OK"

View File

@ -1,3 +1,11 @@
from django.contrib.auth.models import Group
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from guardian.models import GroupObjectPermission
from guardian.shortcuts import assign_perm
from guardian.shortcuts import get_users_with_perms
from guardian.shortcuts import remove_perm
from rest_framework.permissions import BasePermission from rest_framework.permissions import BasePermission
from rest_framework.permissions import DjangoObjectPermissions from rest_framework.permissions import DjangoObjectPermissions
@ -31,3 +39,65 @@ class PaperlessObjectPermissions(DjangoObjectPermissions):
class PaperlessAdminPermissions(BasePermission): class PaperlessAdminPermissions(BasePermission):
def has_permission(self, request, view): def has_permission(self, request, view):
return request.user.has_perm("admin.view_logentry") return request.user.has_perm("admin.view_logentry")
def get_groups_with_only_permission(obj, codename):
ctype = ContentType.objects.get_for_model(obj)
permission = Permission.objects.get(content_type=ctype, codename=codename)
group_object_perm_group_ids = (
GroupObjectPermission.objects.filter(
object_pk=obj.pk,
content_type=ctype,
)
.filter(permission=permission)
.values_list("group_id")
)
return Group.objects.filter(id__in=group_object_perm_group_ids).distinct()
def set_permissions_for_object(permissions, object):
print(permissions, object)
for action in permissions:
permission = f"{action}_{object.__class__.__name__.lower()}"
# users
users_to_add = User.objects.filter(id__in=permissions[action]["users"])
users_to_remove = get_users_with_perms(
object,
only_with_perms_in=[permission],
)
if len(users_to_add) > 0 and len(users_to_remove) > 0:
users_to_remove = users_to_remove.difference(users_to_add)
if len(users_to_remove) > 0:
for user in users_to_remove:
remove_perm(permission, user, object)
if len(users_to_add) > 0:
for user in users_to_add:
assign_perm(permission, user, object)
if action == "change":
# change gives view too
assign_perm(
f"view_{object.__class__.__name__.lower()}",
user,
object,
)
# groups
groups_to_add = Group.objects.filter(id__in=permissions[action]["groups"])
groups_to_remove = get_groups_with_only_permission(
object,
permission,
)
if len(groups_to_add) > 0 and len(groups_to_remove) > 0:
groups_to_remove = groups_to_remove.difference(groups_to_add)
if len(groups_to_remove) > 0:
for group in groups_to_remove:
remove_perm(permission, group, object)
if len(groups_to_add) > 0:
for group in groups_to_add:
assign_perm(permission, group, object)
if action == "change":
# change gives view too
assign_perm(
f"view_{object.__class__.__name__.lower()}",
group,
object,
)

View File

@ -28,16 +28,13 @@ from .models import UiSettings
from .models import PaperlessTask from .models import PaperlessTask
from .parsers import is_mime_type_supported from .parsers import is_mime_type_supported
from guardian.models import GroupObjectPermission
from guardian.shortcuts import assign_perm
from guardian.shortcuts import remove_perm
from guardian.shortcuts import get_users_with_perms from guardian.shortcuts import get_users_with_perms
from django.contrib.contenttypes.models import ContentType
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.contrib.auth.models import Permission
from documents.permissions import get_groups_with_only_permission
from documents.permissions import set_permissions_for_object
# https://www.django-rest-framework.org/api-guide/serializers/#example # https://www.django-rest-framework.org/api-guide/serializers/#example
@ -85,56 +82,7 @@ class MatchingModelSerializer(serializers.ModelSerializer):
return match return match
def get_groups_with_only_permission(obj, codename): class SetPermissionsMixin:
ctype = ContentType.objects.get_for_model(obj)
permission = Permission.objects.get(content_type=ctype, codename=codename)
group_object_perm_group_ids = (
GroupObjectPermission.objects.filter(
object_pk=obj.pk,
content_type=ctype,
)
.filter(permission=permission)
.values_list("group_id")
)
return Group.objects.filter(id__in=group_object_perm_group_ids).distinct()
class OwnedObjectSerializer(serializers.ModelSerializer):
def get_permissions(self, obj):
view_codename = f"view_{obj.__class__.__name__.lower()}"
change_codename = f"change_{obj.__class__.__name__.lower()}"
return {
"view": {
"users": get_users_with_perms(
obj,
only_with_perms_in=[view_codename],
).values_list("id", flat=True),
"groups": get_groups_with_only_permission(
obj,
codename=view_codename,
).values_list("id", flat=True),
},
"change": {
"users": get_users_with_perms(
obj,
only_with_perms_in=[change_codename],
).values_list("id", flat=True),
"groups": get_groups_with_only_permission(
obj,
codename=change_codename,
).values_list("id", flat=True),
},
}
permissions = SerializerMethodField(read_only=True)
set_permissions = serializers.DictField(
label="Set permissions",
allow_empty=True,
required=False,
write_only=True,
)
def _validate_user_ids(self, user_ids): def _validate_user_ids(self, user_ids):
users = User.objects.none() users = User.objects.none()
if user_ids is not None: if user_ids is not None:
@ -169,52 +117,55 @@ class OwnedObjectSerializer(serializers.ModelSerializer):
if set_permissions is not None: if set_permissions is not None:
for action in permissions_dict: for action in permissions_dict:
users = set_permissions[action]["users"] users = set_permissions[action]["users"]
permissions_dict[action]["users"] = self._validate_user_ids(users) self._validate_user_ids(users)
groups = set_permissions[action]["groups"] groups = set_permissions[action]["groups"]
permissions_dict[action]["groups"] = self._validate_group_ids(groups) self._validate_group_ids(groups)
return permissions_dict return permissions_dict
def _set_permissions(self, permissions, object):
set_permissions_for_object(permissions, object)
class OwnedObjectSerializer(serializers.ModelSerializer, SetPermissionsMixin):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user", None) self.user = kwargs.pop("user", None)
return super().__init__(*args, **kwargs) return super().__init__(*args, **kwargs)
def _set_permissions(self, permissions, object): def get_permissions(self, obj):
for action in permissions: view_codename = f"view_{obj.__class__.__name__.lower()}"
permission = f"{action}_{object.__class__.__name__.lower()}" change_codename = f"change_{obj.__class__.__name__.lower()}"
# users return {
users_to_add = permissions[action]["users"] "view": {
users_to_remove = get_users_with_perms( "users": get_users_with_perms(
object, obj,
only_with_perms_in=[permission], only_with_perms_in=[view_codename],
).difference(users_to_add) ).values_list("id", flat=True),
for user in users_to_remove: "groups": get_groups_with_only_permission(
remove_perm(permission, user, object) obj,
for user in users_to_add: codename=view_codename,
assign_perm(permission, user, object) ).values_list("id", flat=True),
if action == "change": },
# change gives view too "change": {
assign_perm( "users": get_users_with_perms(
f"view_{object.__class__.__name__.lower()}", obj,
user, only_with_perms_in=[change_codename],
object, ).values_list("id", flat=True),
) "groups": get_groups_with_only_permission(
# groups obj,
groups_to_add = permissions[action]["groups"] codename=change_codename,
groups_to_remove = get_groups_with_only_permission( ).values_list("id", flat=True),
object, },
permission, }
).difference(groups_to_add)
for group in groups_to_remove: permissions = SerializerMethodField(read_only=True)
remove_perm(permission, group, object)
for group in groups_to_add: set_permissions = serializers.DictField(
assign_perm(permission, group, object) label="Set permissions",
if action == "change": allow_empty=True,
# change gives view too required=False,
assign_perm( write_only=True,
f"view_{object.__class__.__name__.lower()}", )
group, # other methods in mixin
object,
)
def create(self, validated_data): def create(self, validated_data):
if self.user and ( if self.user and (
@ -515,7 +466,7 @@ class DocumentListSerializer(serializers.Serializer):
return documents return documents
class BulkEditSerializer(DocumentListSerializer): class BulkEditSerializer(DocumentListSerializer, SetPermissionsMixin):
method = serializers.ChoiceField( method = serializers.ChoiceField(
choices=[ choices=[
@ -527,6 +478,7 @@ class BulkEditSerializer(DocumentListSerializer):
"modify_tags", "modify_tags",
"delete", "delete",
"redo_ocr", "redo_ocr",
"set_permissions",
], ],
label="Method", label="Method",
write_only=True, write_only=True,
@ -562,6 +514,8 @@ class BulkEditSerializer(DocumentListSerializer):
return bulk_edit.delete return bulk_edit.delete
elif method == "redo_ocr": elif method == "redo_ocr":
return bulk_edit.redo_ocr return bulk_edit.redo_ocr
elif method == "set_permissions":
return bulk_edit.set_permissions
else: else:
raise serializers.ValidationError("Unsupported method.") raise serializers.ValidationError("Unsupported method.")
@ -625,6 +579,12 @@ class BulkEditSerializer(DocumentListSerializer):
else: else:
raise serializers.ValidationError("remove_tags not specified") raise serializers.ValidationError("remove_tags not specified")
def _validate_parameters_set_permissions(self, parameters):
if "permissions" in parameters:
self.validate_set_permissions(parameters["permissions"])
else:
raise serializers.ValidationError("permissions not specified")
def validate(self, attrs): def validate(self, attrs):
method = attrs["method"] method = attrs["method"]
@ -640,6 +600,8 @@ class BulkEditSerializer(DocumentListSerializer):
self._validate_parameters_modify_tags(parameters) self._validate_parameters_modify_tags(parameters)
elif method == bulk_edit.set_storage_path: elif method == bulk_edit.set_storage_path:
self._validate_storage_path(parameters) self._validate_storage_path(parameters)
elif method == bulk_edit.set_permissions:
self._validate_parameters_set_permissions(parameters)
return attrs return attrs

View File

@ -41,6 +41,8 @@ from paperless import version
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from whoosh.writing import AsyncWriter from whoosh.writing import AsyncWriter
from guardian.shortcuts import get_users_with_perms
class TestDocumentApi(DirectoriesMixin, APITestCase): class TestDocumentApi(DirectoriesMixin, APITestCase):
def setUp(self): def setUp(self):
@ -2329,6 +2331,31 @@ class TestBulkEdit(DirectoriesMixin, APITestCase):
], ],
) )
def test_set_permissions(self):
user1 = User.objects.create(username="user1")
user2 = User.objects.create(username="user2")
permissions = {
"view": {
"users": User.objects.filter(id__in=[user1.id, user2.id]),
"groups": Group.objects.none(),
},
"change": {
"users": User.objects.filter(id__in=[user1.id]),
"groups": Group.objects.none(),
},
}
bulk_edit.set_permissions(
[self.doc2.id, self.doc3.id],
permissions=permissions,
)
self.assertEqual(get_users_with_perms(self.doc2).count(), 2)
self.async_task.assert_called_once()
args, kwargs = self.async_task.call_args
self.assertCountEqual(kwargs["document_ids"], [self.doc2.id, self.doc3.id])
class TestBulkDownload(DirectoriesMixin, APITestCase): class TestBulkDownload(DirectoriesMixin, APITestCase):
def setUp(self): def setUp(self):