From 230e2fd1abd5ea9a231566e6618536b8387b0899 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 14 Aug 2024 15:10:54 -0500 Subject: [PATCH] feat: expose Role permissions for editing --- src/wuttaweb/app.py | 6 + src/wuttaweb/auth.py | 90 ++++++++++++ src/wuttaweb/forms/schema.py | 69 ++++++++- src/wuttaweb/forms/widgets.py | 54 ++++++- src/wuttaweb/templates/deform/permissions.pt | 23 +++ .../templates/deform/readonly/permissions.pt | 18 +++ src/wuttaweb/views/master.py | 57 ++++++++ src/wuttaweb/views/people.py | 2 + src/wuttaweb/views/roles.py | 118 +++++++++++++++ src/wuttaweb/views/settings.py | 4 + src/wuttaweb/views/users.py | 2 + tests/forms/test_schema.py | 31 ++++ tests/forms/test_widgets.py | 40 +++++- tests/test_auth.py | 24 ++++ tests/util.py | 7 +- tests/views/test___init__.py | 12 +- tests/views/test_master.py | 74 ++++++++-- tests/views/test_roles.py | 135 +++++++++++++++++- tests/views/test_settings.py | 4 + 19 files changed, 736 insertions(+), 34 deletions(-) create mode 100644 src/wuttaweb/templates/deform/permissions.pt create mode 100644 src/wuttaweb/templates/deform/readonly/permissions.pt 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()} + +
+
+

${permissions[groupkey]['label']}

+
+
+
+ + + ${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 @@ +
+ +
+
+

${permissions[groupkey]['label']}

+
+
+
+ + ${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)