Skip to content

Commit

Permalink
Merge pull request #72 from powderflask/master
Browse files Browse the repository at this point in the history
Manage roles and permissions via the django admin UserAdmin Groups and Permissions.
  • Loading branch information
filipeximenes authored Dec 5, 2017
2 parents cd0a61c + 845819e commit 6c3600d
Show file tree
Hide file tree
Showing 14 changed files with 379 additions and 13 deletions.
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())

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 !!',
)

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

0 comments on commit 6c3600d

Please sign in to comment.