Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Manage roles and permissions via the django admin UserAdmin Groups and Permissions. #72

Merged
merged 6 commits into from
Dec 5, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions docs/admin.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
=================
Admin Integration
=================

Use Django User Admin Site to manage roles and permissions interactively.


Permission Names
================

Permissions defined in ``roles.py`` are given 'human-friendly' names.

All such permissions are assigned to the ``auth | user`` Content Type.

Permission names are a Title Case version of the snake_case or camelCase permission codename, so...

* ``create_medical_record`` is named ``auth | user | Create Medical Record``
* ``enterSurgery`` is named ``auth | user | Enter Surgery``


.. _rolepermissions-useradmin:

RolePermissions User Admin
==========================

Assign / remove roles when editing Users in the Django User Admin Site.

.. function:: RolePermissionsUserAdmin

Custom ``django.contrib.auth.admin.UserAdmin`` that essentially adds the following logic:

* ``remove_role(user, group)`` is called for each Group, removed via the Admin, that represents a role.
* ``assign_role(user, group)`` is called for each Group, added via the Admin, that represents a role.

Opt-in with ``setting``: :ref:`ROLEPERMISSIONS_REGISTER_ADMIN <register-user-admin-setting>` = True

.. function:: RolePermissionsUserAdminMixin

Mixin the functionality of ``RolePermissionsUserAdmin`` to your own custom ``UserAdmin`` class


.. code-block:: python
class MyCustomUserAdmin(RolePermissionsUserAdminMixin, django.contrib.auth.admin.UserAdmin):
...
.. warning:: ``remove_role`` removes every permission associated with a removed ``Group``,
regardless of how those permissions were originally assigned.
See :ref:`remove_role() <remove-role>`


Management Commands
===================

.. code-block:: shell
django-admin sync_roles
Ensures that ``django.contrib.auth.models`` ``Group`` and ``Permission`` objects exist
for each role defined in ``roles.py``

This makes the roles and permissions defined in code immediately acccessible via the Django User Admin

.. note:: ``sync_roles`` never deletes a ``Group`` or ``Permission``.

If you remove a role or permission from ``roles.py``, the corresponding ``Group`` / ``Persission``
continues to exist until it is manually removed.

.. code-block:: shell
django-admin sync_roles --reset_user_permissions
Additionally, update every User's permissions to ensure they include all those defined by their current roles.

.. warning:: ``--reset_user_permissions`` is primarily intended for development, not production!

Changing which permissions are associated with a role in ``roles.py`` does NOT change any User's actual permissions!
``--reset_user_permissions`` simply clears each User's roles and then re-assign them.
This guarantees that Users will have all permissions defined by their role(s) in ``roles.py``,
but in no way does this imply that any permissions previously granted to the User have been revoked!
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Contents:
object_permissions
utils
views_utils
admin
settings


Expand Down
17 changes: 17 additions & 0 deletions docs/settings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,20 @@ Add the following variable to your django ``settings.py``:
.. code-block:: python
ROLEPERMISSIONS_REDIRECT_TO_LOGIN = True
.. _register-user-admin-setting:

Regiser User Admin
==================

Replace the default ``django.contrib.auth.admin.UserAdmin`` with :ref:`RolePermissionsUserAdmin <rolepermissions-useradmin>`
so you can manange roles interactively via the Django User Admin Site.

Add the following variable to your django ``settings.py``:

``settings.py``

.. code-block:: python
ROLEPERMISSIONS_REGISTER_ADMIN = True
2 changes: 2 additions & 0 deletions docs/utils.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ Assigns a role to the user. Role parameter can be passed as string or role class
assign_role(user, 'doctor')
.. _remove-role:

.. function:: remove_role(user, role)

Removes a role from a user. Role parameter can be passed as string or role class object.
Expand Down
40 changes: 39 additions & 1 deletion rolepermissions/admin.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,40 @@
from django.conf import settings
from django.contrib import admin, auth
from django.contrib.auth.models import Group
from django.contrib.auth.admin import UserAdmin
from rolepermissions import roles

from django.contrib import admin
ROLEPERMISSIONS_REGISTER_ADMIN = getattr(settings, 'ROLEPERMISSIONS_REGISTER_ADMIN', False)
UserModel = auth.get_user_model()


class RolePermissionsUserAdminMixin(object):
""" Must be mixed in with an UserAdmin class"""
def save_related(self, request, form, formsets, change):
user = UserModel.objects.get(pk=form.instance.pk)
old_user_roles = set(r.get_name() for r in roles.get_user_roles(user))
super(RolePermissionsUserAdminMixin, self).save_related(request, form, formsets, change)

new_user_groups = set(g.name for g in user.groups.all())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool approach, liked it!


for role_name in (old_user_roles - new_user_groups): # roles removed from User's groups
try: # put the recently removed group back, let rolepermissions remove it...
group = Group.objects.get(name=role_name)
user.groups.add(group)
except Group.DoesNotExist:
pass
roles.remove_role(user, role_name)

for group_name in (new_user_groups - old_user_roles): # groups potentially added to User's roles
try:
roles.assign_role(user, group_name)
except roles.RoleDoesNotExist:
pass


class RolePermissionsUserAdmin(RolePermissionsUserAdminMixin, UserAdmin):
pass

if ROLEPERMISSIONS_REGISTER_ADMIN:
admin.site.unregister(UserModel)
admin.site.register(UserModel, RolePermissionsUserAdmin)
Empty file.
Empty file.
42 changes: 42 additions & 0 deletions rolepermissions/management/commands/sync_roles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from django.conf import settings
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
from rolepermissions import roles


class Command(BaseCommand):
ROLEPERMISSIONS_MODULE = getattr(settings, 'ROLEPERMISSIONS_MODULE', 'roles.py')
help = "Synchronize auth Groups and Permissions with UserRoles defined in %s."%ROLEPERMISSIONS_MODULE
version = "1.0.0"

def get_version(self):
return self.version

def add_arguments(self, parser):
# Optional argument
parser.add_argument(
'--reset_user_permissions',
action='store_true',
dest='reset_user_permissions',
default=False,
help='Re-assign all User roles -- resets user Permissions to defaults defined by role(s) !! CAUTION !!',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool!

)

def handle(self, *args, **options):
# Sync auth.Group with current registered roles (leaving existing groups intact!)
for role in roles.RolesManager.get_roles() :
group, created = role.get_or_create_group()
if created:
self.stdout.write("Created Group: %s from Role: %s"%(group.name, role.get_name()))
# Sync auth.Permission with permissions for this role
role.get_default_true_permissions()

if options.get('reset_user_permissions', False): # dj1.7 compat
# Push any permission changes made to roles and remove any unregistered roles from all auth.Users
self.stdout.write("Resetting permissions for ALL Users to defaults defined by roles.")

for user in get_user_model().objects.all():
user_roles = roles.get_user_roles(user=user)
roles.clear_roles(user=user)
for role in user_roles:
roles.assign_role(user=user, role=role)
6 changes: 2 additions & 4 deletions rolepermissions/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from rolepermissions.exceptions import (
RolePermissionScopeException, CheckerNotRegistered)
from rolepermissions.roles import get_user_roles
from rolepermissions.roles import get_user_roles, get_or_create_permission


class PermissionsManager(object):
Expand Down Expand Up @@ -37,9 +37,7 @@ def fuction_decorator(func):

def get_permission(permission_name):
"""Get a Permission object from a permission name."""
user_ct = ContentType.objects.get_for_model(get_user_model())
permission, _created = Permission.objects.get_or_create(
content_type=user_ct, codename=permission_name)
permission, created = get_or_create_permission(permission_name)

return permission

Expand Down
32 changes: 25 additions & 7 deletions rolepermissions/roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType

from rolepermissions.utils import camelToSnake
from rolepermissions.utils import camelToSnake, camel_or_snake_to_title
from rolepermissions.exceptions import RoleDoesNotExist


Expand All @@ -29,6 +29,10 @@ def retrieve_role(cls, role_name):
def get_roles_names(cls):
return registered_roles.keys()

@classmethod
def get_roles(cls):
return registered_roles.values()


class RolesClassRegister(type):

Expand Down Expand Up @@ -122,7 +126,7 @@ def remove_role_from_user(cls, user):
# Grab the adjusted true permissions before the removal
current_adjusted_true_permissions = cls._get_adjusted_true_permissions(user)

group, _created = Group.objects.get_or_create(name=cls.get_name())
group, _created = cls.get_or_create_group()
user.groups.remove(group)

# Grab the adjusted true permissions after the removal
Expand Down Expand Up @@ -158,11 +162,11 @@ def get_or_create_permissions(cls, permission_names):
permissions = list(Permission.objects.filter(
content_type=user_ct, codename__in=permission_names).all())

if len(permissions) != len(permission_names):
for permission_name in permission_names:
permission, created = Permission.objects.get_or_create(
content_type=user_ct, codename=permission_name)
if created:
missing_permissions = set(permission_names) - set((p.codename for p in permissions))
if len(missing_permissions) > 0:
for permission_name in missing_permissions:
permission, created = get_or_create_permission(permission_name)
if created: # assert created is True
permissions.append(permission)

return permissions
Expand All @@ -171,6 +175,20 @@ def get_or_create_permissions(cls, permission_names):
def get_default(cls, permission_name):
return cls.available_permissions[permission_name]

@classmethod
def get_or_create_group(cls):
return Group.objects.get_or_create(name=cls.get_name())


def get_or_create_permission(codename, name=camel_or_snake_to_title):
"""
Get a Permission object from a permission name.
@:param codename: permission code name
@:param name: human-readable permissions name (str) or callable that takes codename as argument and returns str
"""
user_ct = ContentType.objects.get_for_model(get_user_model())
return Permission.objects.get_or_create(content_type=user_ct, codename=codename,
defaults={'name':name(codename) if callable(name) else name})

def retrieve_role(role_name):
"""Get a Role object from a role name."""
Expand Down
91 changes: 91 additions & 0 deletions rolepermissions/tests/test_admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
from collections import namedtuple
from django.core.management import call_command
from django.test import TestCase
from django.utils.six import StringIO
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission

from model_mommy import mommy

from rolepermissions.roles import AbstractUserRole, get_user_roles
from rolepermissions.admin import RolePermissionsUserAdminMixin


class AdminRole1(AbstractUserRole):
available_permissions = {
'admin_perm1': True,
'admin_perm2': False,
}


class UserAdminMixinTest(TestCase):
class UserAdminMock:
def save_related(self, request, form, formsets, change) :
pass
class CustomUserAdminMock(RolePermissionsUserAdminMixin, UserAdminMock):
pass

FormMock = namedtuple('FormMock', ['instance', ])

def setup(self):
pass

def test_admin_save_related_syncs_roles(self):
user = mommy.make(get_user_model())
grp1 = mommy.make(Group)
grp2 = mommy.make(Group, name=AdminRole1.get_name())
user.groups.add(grp1)
user.groups.add(grp2)
form = self.FormMock(instance=user)
self.CustomUserAdminMock().save_related(None, form, None, None)
user_roles = get_user_roles(user)
self.assertNotIn(grp1.name, (role.get_name() for role in user_roles))
self.assertIn(AdminRole1, user_roles)


class SyncRolesTest(TestCase):

def setup(self):
pass

def test_sync_group(self):
out = StringIO()
call_command('sync_roles', stdout=out)
self.assertIn('Created Group: %s'%AdminRole1.get_name(), out.getvalue())
group_names = [group['name'] for group in Group.objects.all().values('name')]
self.assertIn(AdminRole1.get_name(), group_names)

def test_sync_permissions(self):
out = StringIO()
call_command('sync_roles', stdout=out)
permissions = [perm['codename'] for perm in Permission.objects.all().values('codename')]
self.assertIn('admin_perm1', permissions)
self.assertNotIn('admin_perm2', permissions)

def test_sync_user_role_permissions(self):
user = mommy.make(get_user_model())
grp1 = mommy.make(Group)
grp2 = mommy.make(Group, name=AdminRole1.get_name())
user.groups.add(grp1)
user.groups.add(grp2)
out = StringIO()
call_command('sync_roles', reset_user_permissions=True, stdout=out)

user_group_names = [group['name'] for group in user.groups.all().values('name')]
self.assertIn(grp1.name, user_group_names)
self.assertIn(grp2.name, user_group_names)

user_permission_names = [perm['codename'] for perm in user.user_permissions.all().values('codename')]
self.assertIn('admin_perm1', user_permission_names)
self.assertNotIn('admin_perm2', user_permission_names)

def test_sync_preserves_groups(self):
grp1 = mommy.make(Group)
grp2 = mommy.make(Group, name=AdminRole1.get_name())
out = StringIO()
call_command('sync_roles', stdout=out)
group_names = [group['name'] for group in Group.objects.all().values('name')]
self.assertIn(grp1.name, group_names)
self.assertIn(grp2.name, group_names)


Loading