diff --git a/HISTORY.rst b/HISTORY.rst index d2f77cb1c2..ef1c925c59 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -185,6 +185,8 @@ * Increase default title truncation length to 120 characters. * Improve inherited permission computation. * Add test case mixin that produces ephimeral models. +* Update ACL permissions view to use the new AddRemoveView class. +* Add ACL created and edited events. 3.1.11 (2019-04-XX) =================== diff --git a/docs/releases/3.2.rst b/docs/releases/3.2.rst index 9bfd308ad5..57eef03f53 100644 --- a/docs/releases/3.2.rst +++ b/docs/releases/3.2.rst @@ -217,6 +217,8 @@ Other changes * Increase default title truncation length to 120 characters. * Improve inherited permission computation. * Add test case mixin that produces ephimeral models. +* Update ACL permissions view to use the new AddRemoveView class. +* Add ACL created and edited events. Removals -------- diff --git a/mayan/apps/acls/apps.py b/mayan/apps/acls/apps.py index cf7918407b..d097e0e750 100644 --- a/mayan/apps/acls/apps.py +++ b/mayan/apps/acls/apps.py @@ -4,8 +4,13 @@ from django.utils.translation import ugettext_lazy as _ from mayan.apps.common.apps import MayanAppConfig from mayan.apps.common.menus import menu_object, menu_secondary +from mayan.apps.events.classes import ModelEventType +from mayan.apps.events.links import ( + link_events_for_object, link_object_event_types_user_subcriptions_list +) from mayan.apps.navigation.classes import SourceColumn +from .events import event_acl_created, event_acl_edited from .links import link_acl_create, link_acl_delete, link_acl_permissions @@ -19,17 +24,29 @@ class ACLsApp(MayanAppConfig): def ready(self): super(ACLsApp, self).ready() + from actstream import registry AccessControlList = self.get_model(model_name='AccessControlList') + ModelEventType.register( + event_types=(event_acl_created, event_acl_edited), + model=AccessControlList + ) + SourceColumn( attribute='role', is_sortable=True, source=AccessControlList, ) menu_object.bind_links( - links=(link_acl_permissions, link_acl_delete), + links=( + link_acl_permissions, link_acl_delete, + link_events_for_object, + link_object_event_types_user_subcriptions_list + ), sources=(AccessControlList,) ) menu_secondary.bind_links( links=(link_acl_create,), sources=('acls:acl_list',) ) + + registry.register(AccessControlList) diff --git a/mayan/apps/acls/events.py b/mayan/apps/acls/events.py new file mode 100644 index 0000000000..6ee8a3d674 --- /dev/null +++ b/mayan/apps/acls/events.py @@ -0,0 +1,16 @@ +from __future__ import absolute_import, unicode_literals + +from django.utils.translation import ugettext_lazy as _ + +from mayan.apps.events.classes import EventTypeNamespace + +namespace = EventTypeNamespace( + label=_('Access control lists'), name='acls' +) + +event_acl_created = namespace.add_event_type( + label=_('ACL created'), name='acl_created' +) +event_acl_edited = namespace.add_event_type( + label=_('ACL edited'), name='acl_edited' +) diff --git a/mayan/apps/acls/models.py b/mayan/apps/acls/models.py index 1c6939c32e..82cc2896e5 100644 --- a/mayan/apps/acls/models.py +++ b/mayan/apps/acls/models.py @@ -4,13 +4,14 @@ import logging from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType -from django.db import models +from django.db import models, transaction from django.urls import reverse from django.utils.encoding import python_2_unicode_compatible from django.utils.translation import ugettext_lazy as _ from mayan.apps.permissions.models import Role, StoredPermission +from .events import event_acl_created, event_acl_edited from .managers import AccessControlListManager logger = logging.getLogger(__name__) @@ -73,3 +74,32 @@ class AccessControlList(models.Model): return AccessControlList.objects.get_inherited_permissions( obj=self.content_object, role=self.role ) + + def permissions_add(self, queryset, _user=None): + with transaction.atomic(): + event_acl_edited.commit( + actor=_user, target=self + ) + self.permissions.add(*queryset) + + def permissions_remove(self, queryset, _user=None): + with transaction.atomic(): + event_acl_edited.commit( + actor=_user, target=self + ) + self.permissions.remove(*queryset) + + def save(self, *args, **kwargs): + _user = kwargs.pop('_user', None) + + with transaction.atomic(): + is_new = not self.pk + super(AccessControlList, self).save(*args, **kwargs) + if is_new: + event_acl_created.commit( + actor=_user, target=self + ) + else: + event_acl_edited.commit( + actor=_user, target=self + ) diff --git a/mayan/apps/acls/tests/mixins.py b/mayan/apps/acls/tests/mixins.py index 79a79fae20..700a60709f 100644 --- a/mayan/apps/acls/tests/mixins.py +++ b/mayan/apps/acls/tests/mixins.py @@ -1,13 +1,18 @@ from __future__ import unicode_literals +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ImproperlyConfigured -from mayan.apps.acls.models import AccessControlList +from mayan.apps.common.tests.mixins import TestModelTestMixin from mayan.apps.permissions.tests.mixins import ( PermissionTestMixin, RoleTestCaseMixin, RoleTestMixin ) from mayan.apps.user_management.tests.mixins import UserTestCaseMixin +from ..classes import ModelPermission +from ..models import AccessControlList +from ..permissions import permission_acl_edit, permission_acl_view + class ACLTestCaseMixin(RoleTestCaseMixin, UserTestCaseMixin): def setUp(self): @@ -27,7 +32,7 @@ class ACLTestCaseMixin(RoleTestCaseMixin, UserTestCaseMixin): ) -class ACLTestMixin(PermissionTestMixin, RoleTestMixin): +class ACLTestMixin(PermissionTestMixin, RoleTestMixin, TestModelTestMixin): auto_create_test_role = True def _create_test_acl(self): @@ -39,3 +44,32 @@ class ACLTestMixin(PermissionTestMixin, RoleTestMixin): super(ACLTestMixin, self).setUp() if self.auto_create_test_role: self._create_test_role() + + def _inject_test_object_content_type(self): + self.test_object_content_type = ContentType.objects.get_for_model( + model=self.test_object + ) + + self.test_content_object_view_kwargs = { + 'app_label': self.test_object_content_type.app_label, + 'model_name': self.test_object_content_type.model, + 'object_id': self.test_object.pk + } + + def _setup_test_object(self): + self._create_test_model() + self._create_test_object() + ModelPermission.register( + model=self.test_object._meta.model, permissions=( + permission_acl_edit, permission_acl_view, + ) + ) + + self._create_test_permission() + ModelPermission.register( + model=self.test_object._meta.model, permissions=( + self.test_permission, + ) + ) + + self._inject_test_object_content_type() diff --git a/mayan/apps/acls/tests/test_models.py b/mayan/apps/acls/tests/test_models.py index 70ebb39b5e..0f144dc985 100644 --- a/mayan/apps/acls/tests/test_models.py +++ b/mayan/apps/acls/tests/test_models.py @@ -10,6 +10,7 @@ from mayan.apps.documents.tests import ( TEST_DOCUMENT_TYPE_2_LABEL ) +from ..classes import ModelPermission from ..models import AccessControlList from .mixins import ACLTestMixin @@ -148,3 +149,36 @@ class PermissionTestCase(ACLTestMixin, BaseTestCase): self.assertTrue(self.document_1 in result) self.assertTrue(self.document_2 in result) self.assertTrue(self.document_3 in result) + + +class InheritedPermissionTestCase(ACLTestMixin, BaseTestCase): + def test_retrieve_inherited_role_permission_not_model_applicable(self): + self._create_test_model() + self.test_object = self.TestModel.objects.create() + self._create_test_acl() + self._create_test_permission() + + self.test_role.grant(permission=self.test_permission) + + queryset = AccessControlList.objects.get_inherited_permissions( + obj=self.test_object, role=self.test_role + ) + self.assertTrue(self.test_permission.stored_permission not in queryset) + + def test_retrieve_inherited_role_permission_model_applicable(self): + self._create_test_model() + self.test_object = self.TestModel.objects.create() + self._create_test_acl() + self._create_test_permission() + + ModelPermission.register( + model=self.test_object._meta.model, permissions=( + self.test_permission, + ) + ) + self.test_role.grant(permission=self.test_permission) + + queryset = AccessControlList.objects.get_inherited_permissions( + obj=self.test_object, role=self.test_role + ) + self.assertTrue(self.test_permission.stored_permission in queryset) diff --git a/mayan/apps/acls/views.py b/mayan/apps/acls/views.py index 242d81e7ec..1f7528fde8 100644 --- a/mayan/apps/acls/views.py +++ b/mayan/apps/acls/views.py @@ -1,6 +1,5 @@ from __future__ import absolute_import, unicode_literals -import itertools import logging from django.contrib.contenttypes.models import ContentType @@ -12,11 +11,10 @@ from django.utils.encoding import force_text from django.utils.translation import ugettext_lazy as _ from mayan.apps.common.generics import ( - AssignRemoveView, SingleObjectCreateView, SingleObjectDeleteView, + AddRemoveView, SingleObjectCreateView, SingleObjectDeleteView, SingleObjectListView ) -from mayan.apps.permissions import Permission, PermissionNamespace -from mayan.apps.permissions.models import Role, StoredPermission +from mayan.apps.permissions.models import Role from .classes import ModelPermission from .forms import ACLCreateForm @@ -96,6 +94,8 @@ class ACLDeleteView(SingleObjectDeleteView): def get_extra_context(self): return { + 'acl': self.get_object(), + 'navigation_object_list': ('object', 'acl'), 'object': self.get_object().content_object, 'title': _('Delete ACL: %s') % self.get_object(), } @@ -161,102 +161,88 @@ class ACLListView(SingleObjectListView): ) -class ACLPermissionsView(AssignRemoveView): - grouped = True - left_list_title = _('Available permissions') - right_list_title = _('Granted permissions') +class ACLPermissionsView(AddRemoveView): + action_add_method = 'permissions_add' + action_remove_method = 'permissions_remove' + main_object_model = AccessControlList + main_object_permission = permission_acl_edit + main_object_pk_url_kwarg = 'pk' + list_added_title = _('Granted permissions') + list_available_title = _('Available permissions') + related_field = 'permissions' - @staticmethod - def generate_choices(entries): - results = [] + def generate_choices(self, queryset): + namespaces_dictionary = {} # Sort permissions by their translatable label - entries = sorted( - entries, key=lambda permission: permission.volatile_permission.label + object_list = sorted( + queryset, key=lambda permission: permission.volatile_permission.label ) # Group permissions by namespace - for namespace, permissions in itertools.groupby(entries, lambda entry: entry.namespace): - permission_options = [ - (force_text(permission.pk), permission) for permission in permissions - ] - results.append( - (PermissionNamespace.get(name=namespace), permission_options) + for permission in object_list: + namespaces_dictionary.setdefault( + permission.volatile_permission.namespace.label, + [] + ) + namespaces_dictionary[permission.volatile_permission.namespace.label].append( + (permission.pk, force_text(permission)) ) - return results + # Sort permissions by their translatable namespace label + return sorted(namespaces_dictionary.items()) - def add(self, item): - permission = get_object_or_404(klass=StoredPermission, pk=item) - self.get_object().permissions.add(permission) - - def dispatch(self, request, *args, **kwargs): - acl = get_object_or_404(klass=AccessControlList, pk=self.kwargs['pk']) - - AccessControlList.objects.check_access( - permissions=permission_acl_edit, user=request.user, - obj=acl.content_object - ) - - return super( - ACLPermissionsView, self - ).dispatch(request, *args, **kwargs) - - def get_available_list(self): - return ModelPermission.get_for_instance( - instance=self.get_object().content_object - ).exclude(id__in=self.get_granted_list().values_list('pk', flat=True)) + def get_actions_extra_kwargs(self): + return {'_user': self.request.user} def get_disabled_choices(self): """ - Get permissions from a parent's acls but remove the permissions we - already hold for this object + Get permissions from a parent's ACLs or directly granted to the role. + We return a list since that is what the form widget's can process. """ - return map( - str, set( - self.get_object().get_inherited_permissions().values_list( - 'pk', flat=True - ) - ).difference( - self.get_object().permissions.values_list('pk', flat=True) - ) + return self.main_object.get_inherited_permissions().values_list( + 'pk', flat=True ) def get_extra_context(self): return { - 'object': self.get_object().content_object, - 'title': _('Role "%(role)s" permission\'s for "%(object)s"') % { - 'role': self.get_object().role, - 'object': self.get_object().content_object, - }, + 'acl': self.main_object, + 'object': self.main_object.content_object, + 'navigation_object_list': ('object', 'acl'), + 'title': _('Role "%(role)s" permission\'s for "%(object)s".') % { + 'role': self.main_object.role, + 'object': self.main_object.content_object, + } } - def get_granted_list(self): - """ - Merge or permissions we hold for this object and the permissions we - hold for this object's parent via another ACL - """ - merged_pks = self.get_object().permissions.values_list('pk', flat=True) | self.get_object().get_inherited_permissions().values_list('pk', flat=True) - return StoredPermission.objects.filter(pk__in=merged_pks) - - def get_object(self): - return get_object_or_404(klass=AccessControlList, pk=self.kwargs['pk']) - - def get_right_list_help_text(self): - if self.get_object().get_inherited_permissions(): + def get_list_added_help_text(self): + if self.main_object.get_inherited_permissions(): return _( - 'Disabled permissions are inherited from a parent object.' + 'Disabled permissions are inherited from a parent object or ' + 'directly granted to the role and can\'t be removed from this ' + 'view. Inherited permissions need to be removed from the ' + 'parent object\'s ACL or from them role via the Setup menu.' ) + else: + return super(ACLPermissionsView, self).get_list_added_help_text() - return None + def get_list_added_queryset(self): + """ + Merge of permissions we hold for this object and the permissions we + hold for this object's parents via another ACL. .distinct() is added + in case the permission was added to the ACL and then added to a + parent ACL's and thus inherited and would appear twice. If + order to remove the double permission from the ACL it would need to be + remove from the parent first to enable the choice in the form, + remove it from the ACL and then re-add it to the parent ACL. + """ + queryset_acl = super(ACLPermissionsView, self).get_list_added_queryset() - def left_list(self): - Permission.refresh() - return ACLPermissionsView.generate_choices(self.get_available_list()) + return ( + queryset_acl | self.main_object.get_inherited_permissions() + ).distinct() - def remove(self, item): - permission = get_object_or_404(klass=StoredPermission, pk=item) - self.get_object().permissions.remove(permission) - - def right_list(self): - return ACLPermissionsView.generate_choices(self.get_granted_list()) + def get_secondary_object_source_queryset(self): + return ModelPermission.get_for_instance( + instance=self.main_object.content_object + )