diff --git a/src/wuttaweb/app.py b/src/wuttaweb/app.py
index bafc921..845b41f 100644
--- a/src/wuttaweb/app.py
+++ b/src/wuttaweb/app.py
@@ -135,6 +135,12 @@ def make_pyramid_config(settings):
pyramid_config.include('pyramid_mako')
pyramid_config.include('pyramid_tm')
+ # add some permissions magic
+ pyramid_config.add_directive('add_wutta_permission_group',
+ 'wuttaweb.auth.add_permission_group')
+ pyramid_config.add_directive('add_wutta_permission',
+ 'wuttaweb.auth.add_permission')
+
return pyramid_config
diff --git a/src/wuttaweb/auth.py b/src/wuttaweb/auth.py
index de9b868..fb0519b 100644
--- a/src/wuttaweb/auth.py
+++ b/src/wuttaweb/auth.py
@@ -148,3 +148,93 @@ class WuttaSecurityPolicy:
auth = app.get_auth_handler()
user = self.identity(request)
return auth.has_permission(self.db_session, user, permission)
+
+
+def add_permission_group(pyramid_config, key, label=None, overwrite=True):
+ """
+ Pyramid directive to add a "permission group" to the app's
+ awareness.
+
+ The app must be made aware of all permissions, so they are exposed
+ when editing a
+ :class:`~wuttjamaican:wuttjamaican.db.model.auth.Role`. The logic
+ for discovering permissions is in
+ :meth:`~wuttaweb.views.roles.RoleView.get_available_permissions()`.
+
+ This is usually called from within a master view's
+ :meth:`~wuttaweb.views.master.MasterView.defaults()` to establish
+ the permission group which applies to the view model.
+
+ A simple example of usage::
+
+ pyramid_config.add_permission_group('widgets', label="Widgets")
+
+ :param key: Unique key for the permission group. In the context
+ of a master view, this will be the same as
+ :attr:`~wuttaweb.views.master.MasterView.permission_prefix`.
+
+ :param label: Optional label for the permission group. If not
+ specified, it is derived from ``key``.
+
+ :param overwrite: If the permission group was already established,
+ this flag controls whether the group's label should be
+ overwritten (with ``label``).
+
+ See also :func:`add_permission()`.
+ """
+ config = pyramid_config.get_settings()['wutta_config']
+ app = config.get_app()
+ def action():
+ perms = pyramid_config.get_settings().get('wutta_permissions', {})
+ if overwrite or key not in perms:
+ group = perms.setdefault(key, {'key': key})
+ group['label'] = label or app.make_title(key)
+ pyramid_config.add_settings({'wutta_permissions': perms})
+ pyramid_config.action(None, action)
+
+
+def add_permission(pyramid_config, groupkey, key, label=None):
+ """
+ Pyramid directive to add a single "permission" to the app's
+ awareness.
+
+ The app must be made aware of all permissions, so they are exposed
+ when editing a
+ :class:`~wuttjamaican:wuttjamaican.db.model.auth.Role`. The logic
+ for discovering permissions is in
+ :meth:`~wuttaweb.views.roles.RoleView.get_available_permissions()`.
+
+ This is usually called from within a master view's
+ :meth:`~wuttaweb.views.master.MasterView.defaults()` to establish
+ "known" permissions based on master view feature flags
+ (:attr:`~wuttaweb.views.master.MasterView.viewable`,
+ :attr:`~wuttaweb.views.master.MasterView.editable`, etc.).
+
+ A simple example of usage::
+
+ pyramid_config.add_permission('widgets', 'widgets.polish',
+ label="Polish all the widgets")
+
+ :param key: Unique key for the permission group. In the context
+ of a master view, this will be the same as
+ :attr:`~wuttaweb.views.master.MasterView.permission_prefix`.
+
+ :param key: Unique key for the permission. This should be the
+ "complete" permission name which includes the permission
+ prefix.
+
+ :param label: Optional label for the permission. If not
+ specified, it is derived from ``key``.
+
+ See also :func:`add_permission_group()`.
+ """
+ config = pyramid_config.get_settings()['wutta_config']
+ app = config.get_app()
+ def action():
+ perms = pyramid_config.get_settings().get('wutta_permissions', {})
+ group = perms.setdefault(groupkey, {'key': groupkey})
+ group.setdefault('label', app.make_title(groupkey))
+ perm = group.setdefault('perms', {}).setdefault(key, {'key': key})
+ perm['label'] = label or app.make_title(key)
+ pyramid_config.add_settings({'wutta_permissions': perms})
+ pyramid_config.action(None, action)
diff --git a/src/wuttaweb/forms/schema.py b/src/wuttaweb/forms/schema.py
index dd46fb3..98ce0f1 100644
--- a/src/wuttaweb/forms/schema.py
+++ b/src/wuttaweb/forms/schema.py
@@ -259,20 +259,38 @@ class PersonRef(ObjectRef):
return query.order_by(self.model_class.full_name)
-class RoleRefs(colander.Set):
+class WuttaSet(colander.Set):
"""
- Form schema type for the User
- :attr:`~wuttjamaican:wuttjamaican.db.model.auth.User.roles`
- association proxy field.
+ Custom schema type for :class:`python:set` fields.
+
+ This is a subclass of :class:`colander.Set`, but adds
+ Wutta-related params to the constructor.
+
+ :param request: Current :term:`request` object.
+
+ :param session: Optional :term:`db session` to use instead of
+ :class:`wuttaweb.db.Session`.
"""
- def __init__(self, request, session=None, *args, **kwargs):
- super().__init__(*args, **kwargs)
+ def __init__(self, request, session=None):
+ super().__init__()
self.request = request
self.config = self.request.wutta_config
self.app = self.config.get_app()
self.session = session or Session()
+
+class RoleRefs(WuttaSet):
+ """
+ Form schema type for the User
+ :attr:`~wuttjamaican:wuttjamaican.db.model.auth.User.roles`
+ association proxy field.
+
+ This is a subclass of :class:`WuttaSet`. It uses a ``set`` of
+ :class:`~wuttjamaican:wuttjamaican.db.model.auth.Role` ``uuid``
+ values for underlying data format.
+ """
+
def widget_maker(self, **kwargs):
"""
Constructs a default widget for the field.
@@ -298,3 +316,42 @@ class RoleRefs(colander.Set):
kwargs['values'] = values
return widgets.RoleRefsWidget(self.request, **kwargs)
+
+
+class Permissions(WuttaSet):
+ """
+ Form schema type for the Role
+ :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Role.permissions`
+ association proxy field.
+
+ This is a subclass of :class:`WuttaSet`. It uses a ``set`` of
+ :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Permission.permission`
+ values for underlying data format.
+
+ :param permissions: Dict with all possible permissions. Should be
+ in the same format as returned by
+ :meth:`~wuttaweb.views.roles.RoleView.get_available_permissions()`.
+ """
+
+ def __init__(self, request, permissions, *args, **kwargs):
+ super().__init__(request, *args, **kwargs)
+ self.permissions = permissions
+
+ def widget_maker(self, **kwargs):
+ """
+ Constructs a default widget for the field.
+
+ :returns: Instance of
+ :class:`~wuttaweb.forms.widgets.PermissionsWidget`.
+ """
+ kwargs.setdefault('session', self.session)
+ kwargs.setdefault('permissions', self.permissions)
+
+ if 'values' not in kwargs:
+ values = []
+ for gkey, group in self.permissions.items():
+ for pkey, perm in group['perms'].items():
+ values.append((pkey, perm['label']))
+ kwargs['values'] = values
+
+ return widgets.PermissionsWidget(self.request, **kwargs)
diff --git a/src/wuttaweb/forms/widgets.py b/src/wuttaweb/forms/widgets.py
index f5c1523..b766af0 100644
--- a/src/wuttaweb/forms/widgets.py
+++ b/src/wuttaweb/forms/widgets.py
@@ -103,14 +103,20 @@ class NotesWidget(TextAreaWidget):
readonly_template = 'readonly/notes'
-class RoleRefsWidget(CheckboxChoiceWidget):
+class WuttaCheckboxChoiceWidget(CheckboxChoiceWidget):
"""
- Widget for use with User
- :attr:`~wuttjamaican:wuttjamaican.db.model.auth.User.roles` field.
+ Custom widget for :class:`python:set` fields.
This is a subclass of
- :class:`deform:deform.widget.CheckboxChoiceWidget` and uses these
- Deform templates:
+ :class:`deform:deform.widget.CheckboxChoiceWidget`, but adds
+ Wutta-related params to the constructor.
+
+ :param request: Current :term:`request` object.
+
+ :param session: Optional :term:`db session` to use instead of
+ :class:`wuttaweb.db.Session`.
+
+ It uses these Deform templates:
* ``checkbox_choice``
* ``readonly/checkbox_choice``
@@ -123,6 +129,15 @@ class RoleRefsWidget(CheckboxChoiceWidget):
self.app = self.config.get_app()
self.session = session or Session()
+
+class RoleRefsWidget(WuttaCheckboxChoiceWidget):
+ """
+ Widget for use with User
+ :attr:`~wuttjamaican:wuttjamaican.db.model.auth.User.roles` field.
+
+ This is a subclass of :class:`WuttaCheckboxChoiceWidget`.
+ """
+
def serialize(self, field, cstruct, **kw):
""" """
# special logic when field is editable
@@ -143,3 +158,32 @@ class RoleRefsWidget(CheckboxChoiceWidget):
# default logic from here
return super().serialize(field, cstruct, **kw)
+
+
+class PermissionsWidget(WuttaCheckboxChoiceWidget):
+ """
+ Widget for use with Role
+ :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Role.permissions`
+ field.
+
+ This is a subclass of :class:`WuttaCheckboxChoiceWidget`. It uses
+ these Deform templates:
+
+ * ``permissions``
+ * ``readonly/permissions``
+ """
+ template = 'permissions'
+ readonly_template = 'readonly/permissions'
+
+ def serialize(self, field, cstruct, **kw):
+ """ """
+ kw.setdefault('permissions', self.permissions)
+
+ if 'values' not in kw:
+ values = []
+ for gkey, group in self.permissions.items():
+ for pkey, perm in group['perms'].items():
+ values.append((pkey, perm['label']))
+ kw['values'] = values
+
+ return super().serialize(field, cstruct, **kw)
diff --git a/src/wuttaweb/templates/deform/permissions.pt b/src/wuttaweb/templates/deform/permissions.pt
new file mode 100644
index 0000000..4b34793
--- /dev/null
+++ b/src/wuttaweb/templates/deform/permissions.pt
@@ -0,0 +1,23 @@
+
+ ${field.start_sequence()}
+
+
+
+
+
+
+
+ ${perms[key]['label']}
+
+
+
+
+
+
+ ${field.end_sequence()}
+
diff --git a/src/wuttaweb/templates/deform/readonly/permissions.pt b/src/wuttaweb/templates/deform/readonly/permissions.pt
new file mode 100644
index 0000000..6a433b3
--- /dev/null
+++ b/src/wuttaweb/templates/deform/readonly/permissions.pt
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+ ${perms[key]['label']}
+
+
+
+
+
+
diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py
index 8d8ce67..4009901 100644
--- a/src/wuttaweb/views/master.py
+++ b/src/wuttaweb/views/master.py
@@ -139,6 +139,14 @@ class MasterView(View):
Code should not access this directly but instead call
:meth:`get_route_prefix()`.
+ .. attribute:: permission_prefix
+
+ Optional override for the view's permission prefix,
+ e.g. ``'wutta_widgets'``.
+
+ Code should not access this directly but instead call
+ :meth:`get_permission_prefix()`.
+
.. attribute:: url_prefix
Optional override for the view's URL prefix,
@@ -1442,6 +1450,29 @@ class MasterView(View):
model_name = cls.get_model_name_normalized()
return f'{model_name}s'
+ @classmethod
+ def get_permission_prefix(cls):
+ """
+ Returns the "permission prefix" for the master view. This
+ prefix is used for all permissions defined by the view class.
+
+ For instance if permission prefix is ``'widgets'`` then a view
+ might have these permissions:
+
+ * ``'widgets.list'``
+ * ``'widgets.create'``
+ * ``'widgets.edit'``
+ * ``'widgets.delete'``
+
+ The default logic will call :meth:`get_route_prefix()` and use
+ that value as-is. A subclass may override by assigning
+ :attr:`permission_prefix`.
+ """
+ if hasattr(cls, 'permission_prefix'):
+ return cls.permission_prefix
+
+ return cls.get_route_prefix()
+
@classmethod
def get_url_prefix(cls):
"""
@@ -1581,13 +1612,24 @@ class MasterView(View):
@classmethod
def _defaults(cls, config):
route_prefix = cls.get_route_prefix()
+ permission_prefix = cls.get_permission_prefix()
url_prefix = cls.get_url_prefix()
+ model_title = cls.get_model_title()
+ model_title_plural = cls.get_model_title_plural()
+
+ # permission group
+ config.add_wutta_permission_group(permission_prefix,
+ model_title_plural,
+ overwrite=False)
# index
if cls.listable:
config.add_route(route_prefix, f'{url_prefix}/')
config.add_view(cls, attr='index',
route_name=route_prefix)
+ config.add_wutta_permission(permission_prefix,
+ f'{permission_prefix}.list',
+ f"Browse / search {model_title_plural}")
# create
if cls.creatable:
@@ -1595,6 +1637,9 @@ class MasterView(View):
f'{url_prefix}/new')
config.add_view(cls, attr='create',
route_name=f'{route_prefix}.create')
+ config.add_wutta_permission(permission_prefix,
+ f'{permission_prefix}.create',
+ f"Create new {model_title}")
# view
if cls.viewable:
@@ -1602,6 +1647,9 @@ class MasterView(View):
config.add_route(f'{route_prefix}.view', instance_url_prefix)
config.add_view(cls, attr='view',
route_name=f'{route_prefix}.view')
+ config.add_wutta_permission(permission_prefix,
+ f'{permission_prefix}.view',
+ f"View {model_title}")
# edit
if cls.editable:
@@ -1610,6 +1658,9 @@ class MasterView(View):
f'{instance_url_prefix}/edit')
config.add_view(cls, attr='edit',
route_name=f'{route_prefix}.edit')
+ config.add_wutta_permission(permission_prefix,
+ f'{permission_prefix}.edit',
+ f"Edit {model_title}")
# delete
if cls.deletable:
@@ -1618,6 +1669,9 @@ class MasterView(View):
f'{instance_url_prefix}/delete')
config.add_view(cls, attr='delete',
route_name=f'{route_prefix}.delete')
+ config.add_wutta_permission(permission_prefix,
+ f'{permission_prefix}.delete',
+ f"Delete {model_title}")
# configure
if cls.configurable:
@@ -1625,3 +1679,6 @@ class MasterView(View):
f'{url_prefix}/configure')
config.add_view(cls, attr='configure',
route_name=f'{route_prefix}.configure')
+ config.add_wutta_permission(permission_prefix,
+ f'{permission_prefix}.configure',
+ f"Configure {model_title_plural}")
diff --git a/src/wuttaweb/views/people.py b/src/wuttaweb/views/people.py
index cd56c83..249aa3f 100644
--- a/src/wuttaweb/views/people.py
+++ b/src/wuttaweb/views/people.py
@@ -32,6 +32,8 @@ class PersonView(MasterView):
"""
Master view for people.
+ Default route prefix is ``people``.
+
Notable URLs provided by this class:
* ``/people/``
diff --git a/src/wuttaweb/views/roles.py b/src/wuttaweb/views/roles.py
index ecaea7e..f667775 100644
--- a/src/wuttaweb/views/roles.py
+++ b/src/wuttaweb/views/roles.py
@@ -28,12 +28,15 @@ from wuttjamaican.db.model import Role
from wuttaweb.views import MasterView
from wuttaweb.db import Session
from wuttaweb.forms import widgets
+from wuttaweb.forms.schema import Permissions
class RoleView(MasterView):
"""
Master view for roles.
+ Default route prefix is ``roles``.
+
Notable URLs provided by this class:
* ``/roles/``
@@ -69,6 +72,7 @@ class RoleView(MasterView):
def configure_form(self, f):
""" """
super().configure_form(f)
+ role = f.model_instance
# never show these
f.remove('permission_refs',
@@ -80,6 +84,13 @@ class RoleView(MasterView):
# notes
f.set_widget('notes', widgets.NotesWidget())
+ # permissions
+ f.append('permissions')
+ self.wutta_permissions = self.get_available_permissions()
+ f.set_node('permissions', Permissions(self.request, permissions=self.wutta_permissions))
+ if not self.creating:
+ f.set_default('permissions', list(role.permissions))
+
def unique_name(self, node, value):
""" """
model = self.app.model
@@ -95,6 +106,113 @@ class RoleView(MasterView):
if query.count():
node.raise_invalid("Name must be unique")
+ def get_available_permissions(self):
+ """
+ Returns all "available" permissions. This is used when
+ viewing or editing a role; the result is passed into the
+ :class:`~wuttaweb.forms.schema.Permissions` field schema.
+
+ The app itself must be made aware of each permission, in order
+ for them to found by this method. This is done via
+ :func:`~wuttaweb.auth.add_permission_group()` and
+ :func:`~wuttaweb.auth.add_permission()`.
+
+ When in "view" (readonly) mode, this method will return the
+ full set of known permissions.
+
+ However in "edit" mode, it will prune the set to remove any
+ permissions which the current user does not also have. The
+ idea here is to allow "many" users to manage roles, but ensure
+ they cannot "break out" of their own role by assigning extra
+ permissions to it.
+
+ The permissions returned will also be grouped, and each single
+ permission is also represented as a simple dict, e.g.::
+
+ {
+ 'books': {
+ 'key': 'books',
+ 'label': "Books",
+ 'perms': {
+ 'books.list': {
+ 'key': 'books.list',
+ 'label': "Browse / search Books",
+ },
+ 'books.view': {
+ 'key': 'books.view',
+ 'label': "View Book",
+ },
+ },
+ },
+ 'widgets': {
+ 'key': 'widgets',
+ 'label': "Widgets",
+ 'perms': {
+ 'widgets.list': {
+ 'key': 'widgets.list',
+ 'label': "Browse / search Widgets",
+ },
+ 'widgets.view': {
+ 'key': 'widgets.view',
+ 'label': "View Widget",
+ },
+ },
+ },
+ }
+ """
+
+ # get all known permissions from settings cache
+ permissions = self.request.registry.settings.get('wutta_permissions', {})
+
+ # when viewing, we allow all permissions to be exposed for all users
+ if self.viewing:
+ return permissions
+
+ # admin user gets to manage all permissions
+ if self.request.is_admin:
+ return permissions
+
+ # non-admin user can only see permissions they're granted
+ available = {}
+ for gkey, group in permissions.items():
+ for pkey, perm in group['perms'].items():
+ if self.request.has_perm(pkey):
+ if gkey not in available:
+ available[gkey] = {
+ 'key': gkey,
+ 'label': group['label'],
+ 'perms': {},
+ }
+ available[gkey]['perms'][pkey] = perm
+
+ return available
+
+ def objectify(self, form):
+ """ """
+ # normal logic first
+ role = super().objectify(form)
+
+ # update permissions for role
+ self.update_permissions(role, form)
+
+ return role
+
+ def update_permissions(self, role, form):
+ """ """
+ if 'permissions' not in form.validated:
+ return
+
+ auth = self.app.get_auth_handler()
+ available = self.wutta_permissions
+ permissions = form.validated['permissions']
+
+ for gkey, group in available.items():
+ for pkey, perm in group['perms'].items():
+ if pkey in permissions:
+ auth.grant_permission(role, pkey)
+ else:
+ auth.revoke_permission(role, pkey)
+
def defaults(config, **kwargs):
base = globals()
diff --git a/src/wuttaweb/views/settings.py b/src/wuttaweb/views/settings.py
index a85be38..7fd027b 100644
--- a/src/wuttaweb/views/settings.py
+++ b/src/wuttaweb/views/settings.py
@@ -35,6 +35,8 @@ class AppInfoView(MasterView):
"""
Master view for the core app info, to show/edit config etc.
+ Default route prefix is ``appinfo``.
+
Notable URLs provided by this class:
* ``/appinfo/``
@@ -137,6 +139,8 @@ class SettingView(MasterView):
"""
Master view for the "raw" settings table.
+ Default route prefix is ``settings``.
+
Notable URLs provided by this class:
* ``/settings/``
diff --git a/src/wuttaweb/views/users.py b/src/wuttaweb/views/users.py
index 6dd1a8f..0c017c6 100644
--- a/src/wuttaweb/views/users.py
+++ b/src/wuttaweb/views/users.py
@@ -36,6 +36,8 @@ class UserView(MasterView):
"""
Master view for users.
+ Default route prefix is ``users``.
+
Notable URLs provided by this class:
* ``/users/``
diff --git a/tests/forms/test_schema.py b/tests/forms/test_schema.py
index 02d69ce..aef3432 100644
--- a/tests/forms/test_schema.py
+++ b/tests/forms/test_schema.py
@@ -221,3 +221,34 @@ class TestRoleRefs(DataTestCase):
self.assertEqual(len(widget.values), 2)
self.assertEqual(widget.values[0][1], "Administrator")
self.assertEqual(widget.values[1][1], "Blokes")
+
+
+class TestPermissions(DataTestCase):
+
+ def setUp(self):
+ self.setup_db()
+ self.request = testing.DummyRequest(wutta_config=self.config)
+
+ def test_widget_maker(self):
+
+ # no supported permissions
+ permissions = {}
+ typ = mod.Permissions(self.request, permissions)
+ widget = typ.widget_maker()
+ self.assertEqual(len(widget.values), 0)
+
+ # supported permissions are morphed to values
+ permissions = {
+ 'widgets': {
+ 'label': "Widgets",
+ 'perms': {
+ 'widgets.polish': {
+ 'label': "Polish the widgets",
+ },
+ },
+ },
+ }
+ typ = mod.Permissions(self.request, permissions)
+ widget = typ.widget_maker()
+ self.assertEqual(len(widget.values), 1)
+ self.assertEqual(widget.values[0], ('widgets.polish', "Polish the widgets"))
diff --git a/tests/forms/test_widgets.py b/tests/forms/test_widgets.py
index e2ed0e7..7c55769 100644
--- a/tests/forms/test_widgets.py
+++ b/tests/forms/test_widgets.py
@@ -5,7 +5,7 @@ import deform
from pyramid import testing
from wuttaweb.forms import widgets as mod
-from wuttaweb.forms.schema import PersonRef, RoleRefs
+from wuttaweb.forms.schema import PersonRef, RoleRefs, Permissions
from tests.util import WebTestCase
@@ -75,3 +75,41 @@ class TestRoleRefsWidget(WebTestCase):
html = widget.serialize(field, {admin.uuid, blokes.uuid})
self.assertIn(admin.uuid, html)
self.assertIn(blokes.uuid, html)
+
+
+class TestPermissionsWidget(WebTestCase):
+
+ def make_field(self, node, **kwargs):
+ # TODO: not sure why default renderer is in use even though
+ # pyramid_deform was included in setup? but this works..
+ kwargs.setdefault('renderer', deform.Form.default_renderer)
+ return deform.Field(node, **kwargs)
+
+ def test_serialize(self):
+ permissions = {
+ 'widgets': {
+ 'label': "Widgets",
+ 'perms': {
+ 'widgets.polish': {
+ 'label': "Polish the widgets",
+ },
+ },
+ },
+ }
+
+ # nb. we let the field construct the widget via our type
+ node = colander.SchemaNode(Permissions(self.request, permissions, session=self.session))
+ field = self.make_field(node)
+ widget = field.widget
+
+ # readonly output does *not* include the perm by default
+ html = widget.serialize(field, set(), readonly=True)
+ self.assertNotIn("Polish the widgets", html)
+
+ # readonly output includes the perm if set
+ html = widget.serialize(field, {'widgets.polish'}, readonly=True)
+ self.assertIn("Polish the widgets", html)
+
+ # editable output always includes the perm
+ html = widget.serialize(field, set())
+ self.assertIn("Polish the widgets", html)
diff --git a/tests/test_auth.py b/tests/test_auth.py
index 5d6c406..aa77280 100644
--- a/tests/test_auth.py
+++ b/tests/test_auth.py
@@ -7,6 +7,7 @@ from pyramid import testing
from wuttjamaican.conf import WuttaConfig
from wuttaweb import auth as mod
+from tests.util import WebTestCase
class TestLoginUser(TestCase):
@@ -143,3 +144,26 @@ class TestWuttaSecurityPolicy(TestCase):
self.assertFalse(self.policy.permits(self.request, None, 'some-root-perm'))
self.request.is_root = True
self.assertTrue(self.policy.permits(self.request, None, 'some-root-perm'))
+
+
+class TestAddPermissionGroup(WebTestCase):
+
+ def test_basic(self):
+ permissions = self.pyramid_config.get_settings().get('wutta_permissions', {})
+ self.assertNotIn('widgets', permissions)
+ self.pyramid_config.add_wutta_permission_group('widgets')
+ permissions = self.pyramid_config.get_settings().get('wutta_permissions', {})
+ self.assertIn('widgets', permissions)
+ self.assertEqual(permissions['widgets']['label'], "Widgets")
+
+
+class TestAddPermission(WebTestCase):
+
+ def test_basic(self):
+ permissions = self.pyramid_config.get_settings().get('wutta_permissions', {})
+ self.assertNotIn('widgets', permissions)
+ self.pyramid_config.add_wutta_permission('widgets', 'widgets.polish')
+ permissions = self.pyramid_config.get_settings().get('wutta_permissions', {})
+ self.assertIn('widgets', permissions)
+ self.assertEqual(permissions['widgets']['label'], "Widgets")
+ self.assertIn('widgets.polish', permissions['widgets']['perms'])
diff --git a/tests/util.py b/tests/util.py
index ee76a47..9a2a928 100644
--- a/tests/util.py
+++ b/tests/util.py
@@ -56,10 +56,13 @@ class WebTestCase(DataTestCase):
# init web
self.pyramid_config.include('pyramid_deform')
self.pyramid_config.include('pyramid_mako')
- self.pyramid_config.include('wuttaweb.static')
- self.pyramid_config.include('wuttaweb.views.essential')
+ self.pyramid_config.add_directive('add_wutta_permission_group',
+ 'wuttaweb.auth.add_permission_group')
+ self.pyramid_config.add_directive('add_wutta_permission',
+ 'wuttaweb.auth.add_permission')
self.pyramid_config.add_subscriber('wuttaweb.subscribers.before_render',
'pyramid.events.BeforeRender')
+ self.pyramid_config.include('wuttaweb.static')
# setup new request w/ anonymous user
event = MagicMock(request=self.request)
diff --git a/tests/views/test___init__.py b/tests/views/test___init__.py
index 0132cff..6da63f0 100644
--- a/tests/views/test___init__.py
+++ b/tests/views/test___init__.py
@@ -1,14 +1,10 @@
# -*- coding: utf-8; -*-
-from unittest import TestCase
-
-from pyramid import testing
+from tests.util import WebTestCase
-class TestIncludeMe(TestCase):
+class TestIncludeMe(WebTestCase):
def test_basic(self):
- with testing.testConfig() as pyramid_config:
-
- # just ensure no error happens when included..
- pyramid_config.include('wuttaweb.views')
+ # just ensure no error happens when included..
+ self.pyramid_config.include('wuttaweb.views')
diff --git a/tests/views/test_master.py b/tests/views/test_master.py
index cfad1e6..253e59d 100644
--- a/tests/views/test_master.py
+++ b/tests/views/test_master.py
@@ -162,6 +162,24 @@ class TestMasterView(WebTestCase):
model_class=MyModel):
self.assertEqual(master.MasterView.get_route_prefix(), 'trucks')
+ def test_get_permission_prefix(self):
+
+ # error by default (since no model class)
+ self.assertRaises(AttributeError, master.MasterView.get_permission_prefix)
+
+ # subclass may specify permission prefix
+ with patch.object(master.MasterView, 'permission_prefix', new='widgets', create=True):
+ self.assertEqual(master.MasterView.get_permission_prefix(), 'widgets')
+
+ # subclass may specify route prefix
+ with patch.object(master.MasterView, 'route_prefix', new='widgets', create=True):
+ self.assertEqual(master.MasterView.get_permission_prefix(), 'widgets')
+
+ # or it may specify model class
+ Truck = MagicMock(__name__='Truck')
+ with patch.object(master.MasterView, 'model_class', new=Truck, create=True):
+ self.assertEqual(master.MasterView.get_permission_prefix(), 'trucks')
+
def test_get_url_prefix(self):
# error by default (since no model class)
@@ -315,6 +333,9 @@ class TestMasterView(WebTestCase):
##############################
def test_render_to_response(self):
+ self.pyramid_config.include('wuttaweb.views.common')
+ self.pyramid_config.include('wuttaweb.views.auth')
+ self.pyramid_config.add_route('appinfo', '/appinfo/')
def widgets(request): return {}
self.pyramid_config.add_route('widgets', '/widgets/')
@@ -539,28 +560,38 @@ class TestMasterView(WebTestCase):
##############################
def test_index(self):
+ self.pyramid_config.include('wuttaweb.views.common')
+ self.pyramid_config.include('wuttaweb.views.auth')
+ self.pyramid_config.add_route('settings.create', '/settings/new')
+ self.pyramid_config.add_route('settings.view', '/settings/{name}')
+ self.pyramid_config.add_route('settings.edit', '/settings/{name}/edit')
+ self.pyramid_config.add_route('settings.delete', '/settings/{name}/delete')
# sanity/coverage check using /settings/
- master.MasterView.model_name = 'Setting'
- master.MasterView.model_key = 'name'
- master.MasterView.grid_columns = ['name', 'value']
- view = master.MasterView(self.request)
- response = view.index()
- # then again with data, to include view action url
- data = [{'name': 'foo', 'value': 'bar'}]
- with patch.object(view, 'get_grid_data', return_value=data):
+ with patch.multiple(master.MasterView, create=True,
+ model_name='Setting',
+ model_key='name',
+ get_index_url=MagicMock(return_value='/settings/'),
+ grid_columns=['name', 'value']):
+ view = master.MasterView(self.request)
response = view.index()
- del master.MasterView.model_name
- del master.MasterView.model_key
- del master.MasterView.grid_columns
+
+ # then again with data, to include view action url
+ data = [{'name': 'foo', 'value': 'bar'}]
+ with patch.object(view, 'get_grid_data', return_value=data):
+ response = view.index()
def test_create(self):
+ self.pyramid_config.include('wuttaweb.views.common')
+ self.pyramid_config.include('wuttaweb.views.auth')
+ self.pyramid_config.add_route('settings.view', '/settings/{name}')
model = self.app.model
# sanity/coverage check using /settings/new
with patch.multiple(master.MasterView, create=True,
model_name='Setting',
model_key='name',
+ get_index_url=MagicMock(return_value='/settings/'),
form_fields=['name', 'value']):
view = master.MasterView(self.request)
@@ -604,6 +635,11 @@ class TestMasterView(WebTestCase):
self.assertEqual(self.app.get_setting(self.session, 'foo.bar'), 'fraggle')
def test_view(self):
+ self.pyramid_config.include('wuttaweb.views.common')
+ self.pyramid_config.include('wuttaweb.views.auth')
+ self.pyramid_config.add_route('settings.create', '/settings/new')
+ self.pyramid_config.add_route('settings.edit', '/settings/{name}/edit')
+ self.pyramid_config.add_route('settings.delete', '/settings/{name}/delete')
# sanity/coverage check using /settings/XXX
setting = {'name': 'foo.bar', 'value': 'baz'}
@@ -611,6 +647,7 @@ class TestMasterView(WebTestCase):
with patch.multiple(master.MasterView, create=True,
model_name='Setting',
model_key='name',
+ get_index_url=MagicMock(return_value='/settings/'),
grid_columns=['name', 'value'],
form_fields=['name', 'value']):
view = master.MasterView(self.request)
@@ -618,6 +655,11 @@ class TestMasterView(WebTestCase):
response = view.view()
def test_edit(self):
+ self.pyramid_config.include('wuttaweb.views.common')
+ self.pyramid_config.include('wuttaweb.views.auth')
+ self.pyramid_config.add_route('settings.create', '/settings/new')
+ self.pyramid_config.add_route('settings.view', '/settings/{name}')
+ self.pyramid_config.add_route('settings.delete', '/settings/{name}/delete')
model = self.app.model
self.app.save_setting(self.session, 'foo.bar', 'frazzle')
self.session.commit()
@@ -634,6 +676,7 @@ class TestMasterView(WebTestCase):
with patch.multiple(master.MasterView, create=True,
model_name='Setting',
model_key='name',
+ get_index_url=MagicMock(return_value='/settings/'),
form_fields=['name', 'value']):
view = master.MasterView(self.request)
with patch.object(view, 'get_instance', new=get_instance):
@@ -675,6 +718,11 @@ class TestMasterView(WebTestCase):
self.assertEqual(self.app.get_setting(self.session, 'foo.bar'), 'froogle')
def test_delete(self):
+ self.pyramid_config.include('wuttaweb.views.common')
+ self.pyramid_config.include('wuttaweb.views.auth')
+ self.pyramid_config.add_route('settings.create', '/settings/new')
+ self.pyramid_config.add_route('settings.view', '/settings/{name}')
+ self.pyramid_config.add_route('settings.edit', '/settings/{name}/edit')
model = self.app.model
self.app.save_setting(self.session, 'foo.bar', 'frazzle')
self.session.commit()
@@ -692,6 +740,7 @@ class TestMasterView(WebTestCase):
with patch.multiple(master.MasterView, create=True,
model_name='Setting',
model_key='name',
+ get_index_url=MagicMock(return_value='/settings/'),
form_fields=['name', 'value']):
view = master.MasterView(self.request)
with patch.object(view, 'get_instance', new=get_instance):
@@ -730,6 +779,8 @@ class TestMasterView(WebTestCase):
self.assertEqual(self.session.query(model.Setting).count(), 0)
def test_configure(self):
+ self.pyramid_config.include('wuttaweb.views.common')
+ self.pyramid_config.include('wuttaweb.views.auth')
model = self.app.model
# mock settings
@@ -750,6 +801,7 @@ class TestMasterView(WebTestCase):
route_prefix='appinfo',
template_prefix='/appinfo',
creatable=False,
+ get_index_url=MagicMock(return_value='/appinfo/'),
configure_get_simple_settings=MagicMock(return_value=settings)):
# get the form page
diff --git a/tests/views/test_roles.py b/tests/views/test_roles.py
index 49c6197..4cfc977 100644
--- a/tests/views/test_roles.py
+++ b/tests/views/test_roles.py
@@ -15,6 +15,9 @@ class TestRoleView(WebTestCase):
def make_view(self):
return mod.RoleView(self.request)
+ def test_includeme(self):
+ self.pyramid_config.include('wuttaweb.views.roles')
+
def test_get_query(self):
view = self.make_view()
query = view.get_query(session=self.session)
@@ -30,8 +33,9 @@ class TestRoleView(WebTestCase):
def test_configure_form(self):
model = self.app.model
+ role = model.Role(name="Foo")
view = self.make_view()
- form = view.make_form(model_class=model.Person)
+ form = view.make_form(model_instance=role)
self.assertNotIn('name', form.validators)
view.configure_form(form)
self.assertIsNotNone(form.validators['name'])
@@ -55,3 +59,132 @@ class TestRoleView(WebTestCase):
self.request.matchdict = {'uuid': role.uuid}
node = colander.SchemaNode(colander.String(), name='name')
self.assertIsNone(view.unique_name(node, 'Foo'))
+
+ def get_permissions(self):
+ return {
+ 'widgets': {
+ 'label': "Widgets",
+ 'perms': {
+ 'widgets.list': {
+ 'label': "List widgets",
+ },
+ 'widgets.polish': {
+ 'label': "Polish the widgets",
+ },
+ 'widgets.view': {
+ 'label': "View widget",
+ },
+ },
+ },
+ }
+
+ def test_get_available_permissions(self):
+ model = self.app.model
+ auth = self.app.get_auth_handler()
+ blokes = model.Role(name="Blokes")
+ auth.grant_permission(blokes, 'widgets.list')
+ self.session.add(blokes)
+ barney = model.User(username='barney')
+ barney.roles.append(blokes)
+ self.session.add(barney)
+ self.session.commit()
+ view = self.make_view()
+ all_perms = self.get_permissions()
+ self.request.registry.settings['wutta_permissions'] = all_perms
+
+ def has_perm(perm):
+ if perm == 'widgets.list':
+ return True
+ return False
+
+ with patch.object(self.request, 'has_perm', new=has_perm, create=True):
+
+ # sanity check; current request has 1 perm
+ self.assertTrue(self.request.has_perm('widgets.list'))
+ self.assertFalse(self.request.has_perm('widgets.polish'))
+ self.assertFalse(self.request.has_perm('widgets.view'))
+
+ # when editing, user sees only the 1 perm
+ with patch.object(view, 'editing', new=True):
+ perms = view.get_available_permissions()
+ self.assertEqual(list(perms), ['widgets'])
+ self.assertEqual(list(perms['widgets']['perms']), ['widgets.list'])
+
+ # but when viewing, same user sees all perms
+ with patch.object(view, 'viewing', new=True):
+ perms = view.get_available_permissions()
+ self.assertEqual(list(perms), ['widgets'])
+ self.assertEqual(list(perms['widgets']['perms']),
+ ['widgets.list', 'widgets.polish', 'widgets.view'])
+
+ # also, when admin user is editing, sees all perms
+ self.request.is_admin = True
+ with patch.object(view, 'editing', new=True):
+ perms = view.get_available_permissions()
+ self.assertEqual(list(perms), ['widgets'])
+ self.assertEqual(list(perms['widgets']['perms']),
+ ['widgets.list', 'widgets.polish', 'widgets.view'])
+
+ def test_objectify(self):
+ model = self.app.model
+ auth = self.app.get_auth_handler()
+ blokes = model.Role(name="Blokes")
+ self.session.add(blokes)
+ barney = model.User(username='barney')
+ barney.roles.append(blokes)
+ self.session.add(barney)
+ self.session.commit()
+ view = self.make_view()
+ permissions = self.get_permissions()
+
+ # sanity check, role has just 1 perm
+ auth.grant_permission(blokes, 'widgets.list')
+ self.session.commit()
+ self.assertEqual(blokes.permissions, ['widgets.list'])
+
+ # form can update role perms
+ view.editing = True
+ self.request.matchdict = {'uuid': blokes.uuid}
+ with patch.object(view, 'get_available_permissions', return_value=permissions):
+ form = view.make_model_form(model_instance=blokes)
+ form.validated = {'name': 'Blokes',
+ 'permissions': {'widgets.list', 'widgets.polish', 'widgets.view'}}
+ role = view.objectify(form)
+ self.session.commit()
+ self.assertIs(role, blokes)
+ self.assertEqual(blokes.permissions, ['widgets.list', 'widgets.polish', 'widgets.view'])
+
+ def test_update_permissions(self):
+ model = self.app.model
+ auth = self.app.get_auth_handler()
+ blokes = model.Role(name="Blokes")
+ auth.grant_permission(blokes, 'widgets.list')
+ self.session.add(blokes)
+ barney = model.User(username='barney')
+ barney.roles.append(blokes)
+ self.session.add(barney)
+ self.session.commit()
+ view = self.make_view()
+ permissions = self.get_permissions()
+
+ with patch.object(view, 'get_available_permissions', return_value=permissions):
+
+ # no error if data is missing perms
+ form = view.make_model_form(model_instance=blokes)
+ form.validated = {'name': 'BloX'}
+ role = view.objectify(form)
+ self.session.commit()
+ self.assertIs(role, blokes)
+ self.assertEqual(blokes.name, 'BloX')
+
+ # sanity check, role has just 1 perm
+ self.assertEqual(blokes.permissions, ['widgets.list'])
+
+ # role perms are updated
+ form = view.make_model_form(model_instance=blokes)
+ form.validated = {'name': 'Blokes',
+ 'permissions': {'widgets.polish', 'widgets.view'}}
+ role = view.objectify(form)
+ self.session.commit()
+ self.assertIs(role, blokes)
+ self.assertEqual(blokes.permissions, ['widgets.polish', 'widgets.view'])
diff --git a/tests/views/test_settings.py b/tests/views/test_settings.py
index d01857b..9ede0be 100644
--- a/tests/views/test_settings.py
+++ b/tests/views/test_settings.py
@@ -10,6 +10,10 @@ from tests.util import WebTestCase
class TestAppInfoView(WebTestCase):
+ def setUp(self):
+ self.setup_web()
+ self.pyramid_config.include('wuttaweb.views.essential')
+
def make_view(self):
return settings.AppInfoView(self.request)