2
0
Fork 0

feat: expose Role permissions for editing

This commit is contained in:
Lance Edgar 2024-08-14 15:10:54 -05:00
parent 97e914c2e0
commit 230e2fd1ab
19 changed files with 736 additions and 34 deletions

View file

@ -135,6 +135,12 @@ def make_pyramid_config(settings):
pyramid_config.include('pyramid_mako') pyramid_config.include('pyramid_mako')
pyramid_config.include('pyramid_tm') 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 return pyramid_config

View file

@ -148,3 +148,93 @@ class WuttaSecurityPolicy:
auth = app.get_auth_handler() auth = app.get_auth_handler()
user = self.identity(request) user = self.identity(request)
return auth.has_permission(self.db_session, user, permission) 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)

View file

@ -259,20 +259,38 @@ class PersonRef(ObjectRef):
return query.order_by(self.model_class.full_name) return query.order_by(self.model_class.full_name)
class RoleRefs(colander.Set): class WuttaSet(colander.Set):
""" """
Form schema type for the User Custom schema type for :class:`python:set` fields.
:attr:`~wuttjamaican:wuttjamaican.db.model.auth.User.roles`
association proxy field. 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): def __init__(self, request, session=None):
super().__init__(*args, **kwargs) super().__init__()
self.request = request self.request = request
self.config = self.request.wutta_config self.config = self.request.wutta_config
self.app = self.config.get_app() self.app = self.config.get_app()
self.session = session or Session() 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): def widget_maker(self, **kwargs):
""" """
Constructs a default widget for the field. Constructs a default widget for the field.
@ -298,3 +316,42 @@ class RoleRefs(colander.Set):
kwargs['values'] = values kwargs['values'] = values
return widgets.RoleRefsWidget(self.request, **kwargs) 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)

View file

@ -103,14 +103,20 @@ class NotesWidget(TextAreaWidget):
readonly_template = 'readonly/notes' readonly_template = 'readonly/notes'
class RoleRefsWidget(CheckboxChoiceWidget): class WuttaCheckboxChoiceWidget(CheckboxChoiceWidget):
""" """
Widget for use with User Custom widget for :class:`python:set` fields.
:attr:`~wuttjamaican:wuttjamaican.db.model.auth.User.roles` field.
This is a subclass of This is a subclass of
:class:`deform:deform.widget.CheckboxChoiceWidget` and uses these :class:`deform:deform.widget.CheckboxChoiceWidget`, but adds
Deform templates: 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`` * ``checkbox_choice``
* ``readonly/checkbox_choice`` * ``readonly/checkbox_choice``
@ -123,6 +129,15 @@ class RoleRefsWidget(CheckboxChoiceWidget):
self.app = self.config.get_app() self.app = self.config.get_app()
self.session = session or Session() 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): def serialize(self, field, cstruct, **kw):
""" """ """ """
# special logic when field is editable # special logic when field is editable
@ -143,3 +158,32 @@ class RoleRefsWidget(CheckboxChoiceWidget):
# default logic from here # default logic from here
return super().serialize(field, cstruct, **kw) 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)

View file

@ -0,0 +1,23 @@
<div>
${field.start_sequence()}
<tal:loop tal:repeat="groupkey sorted(permissions, key=lambda k: permissions[k]['label'].lower())">
<div class="card block"
tal:define="perms permissions[groupkey]['perms'];">
<header class="card-header">
<p class="card-header-title">${permissions[groupkey]['label']}</p>
</header>
<div class="card-content">
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
<tal:loop tal:repeat="key sorted(perms, key=lambda p: perms[p]['label'].lower())">
<b-checkbox name="checkbox"
native-value="${key}"
tal:attributes=":value 'true' if key in cstruct else 'false';">
${perms[key]['label']}
</b-checkbox>
</tal:loop>
</div>
</div>
</div>
</tal:loop>
${field.end_sequence()}
</div>

View file

@ -0,0 +1,18 @@
<div>
<tal:loop tal:repeat="groupkey sorted(permissions, key=lambda k: permissions[k]['label'].lower())">
<div class="card block"
tal:condition="any([key in cstruct for key in permissions[groupkey]['perms']])"
tal:define="perms permissions[groupkey]['perms'];">
<header class="card-header">
<p class="card-header-title">${permissions[groupkey]['label']}</p>
</header>
<div class="card-content">
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
<tal:loop tal:repeat="key sorted(perms, key=lambda p: perms[p]['label'].lower())">
<span tal:condition="key in cstruct">${perms[key]['label']}</span>
</tal:loop>
</div>
</div>
</div>
</tal:loop>
</div>

View file

@ -139,6 +139,14 @@ class MasterView(View):
Code should not access this directly but instead call Code should not access this directly but instead call
:meth:`get_route_prefix()`. :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 .. attribute:: url_prefix
Optional override for the view's URL prefix, Optional override for the view's URL prefix,
@ -1442,6 +1450,29 @@ class MasterView(View):
model_name = cls.get_model_name_normalized() model_name = cls.get_model_name_normalized()
return f'{model_name}s' 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 @classmethod
def get_url_prefix(cls): def get_url_prefix(cls):
""" """
@ -1581,13 +1612,24 @@ class MasterView(View):
@classmethod @classmethod
def _defaults(cls, config): def _defaults(cls, config):
route_prefix = cls.get_route_prefix() route_prefix = cls.get_route_prefix()
permission_prefix = cls.get_permission_prefix()
url_prefix = cls.get_url_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 # index
if cls.listable: if cls.listable:
config.add_route(route_prefix, f'{url_prefix}/') config.add_route(route_prefix, f'{url_prefix}/')
config.add_view(cls, attr='index', config.add_view(cls, attr='index',
route_name=route_prefix) route_name=route_prefix)
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.list',
f"Browse / search {model_title_plural}")
# create # create
if cls.creatable: if cls.creatable:
@ -1595,6 +1637,9 @@ class MasterView(View):
f'{url_prefix}/new') f'{url_prefix}/new')
config.add_view(cls, attr='create', config.add_view(cls, attr='create',
route_name=f'{route_prefix}.create') route_name=f'{route_prefix}.create')
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.create',
f"Create new {model_title}")
# view # view
if cls.viewable: if cls.viewable:
@ -1602,6 +1647,9 @@ class MasterView(View):
config.add_route(f'{route_prefix}.view', instance_url_prefix) config.add_route(f'{route_prefix}.view', instance_url_prefix)
config.add_view(cls, attr='view', config.add_view(cls, attr='view',
route_name=f'{route_prefix}.view') route_name=f'{route_prefix}.view')
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.view',
f"View {model_title}")
# edit # edit
if cls.editable: if cls.editable:
@ -1610,6 +1658,9 @@ class MasterView(View):
f'{instance_url_prefix}/edit') f'{instance_url_prefix}/edit')
config.add_view(cls, attr='edit', config.add_view(cls, attr='edit',
route_name=f'{route_prefix}.edit') route_name=f'{route_prefix}.edit')
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.edit',
f"Edit {model_title}")
# delete # delete
if cls.deletable: if cls.deletable:
@ -1618,6 +1669,9 @@ class MasterView(View):
f'{instance_url_prefix}/delete') f'{instance_url_prefix}/delete')
config.add_view(cls, attr='delete', config.add_view(cls, attr='delete',
route_name=f'{route_prefix}.delete') route_name=f'{route_prefix}.delete')
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.delete',
f"Delete {model_title}")
# configure # configure
if cls.configurable: if cls.configurable:
@ -1625,3 +1679,6 @@ class MasterView(View):
f'{url_prefix}/configure') f'{url_prefix}/configure')
config.add_view(cls, attr='configure', config.add_view(cls, attr='configure',
route_name=f'{route_prefix}.configure') route_name=f'{route_prefix}.configure')
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.configure',
f"Configure {model_title_plural}")

View file

@ -32,6 +32,8 @@ class PersonView(MasterView):
""" """
Master view for people. Master view for people.
Default route prefix is ``people``.
Notable URLs provided by this class: Notable URLs provided by this class:
* ``/people/`` * ``/people/``

View file

@ -28,12 +28,15 @@ from wuttjamaican.db.model import Role
from wuttaweb.views import MasterView from wuttaweb.views import MasterView
from wuttaweb.db import Session from wuttaweb.db import Session
from wuttaweb.forms import widgets from wuttaweb.forms import widgets
from wuttaweb.forms.schema import Permissions
class RoleView(MasterView): class RoleView(MasterView):
""" """
Master view for roles. Master view for roles.
Default route prefix is ``roles``.
Notable URLs provided by this class: Notable URLs provided by this class:
* ``/roles/`` * ``/roles/``
@ -69,6 +72,7 @@ class RoleView(MasterView):
def configure_form(self, f): def configure_form(self, f):
""" """ """ """
super().configure_form(f) super().configure_form(f)
role = f.model_instance
# never show these # never show these
f.remove('permission_refs', f.remove('permission_refs',
@ -80,6 +84,13 @@ class RoleView(MasterView):
# notes # notes
f.set_widget('notes', widgets.NotesWidget()) 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): def unique_name(self, node, value):
""" """ """ """
model = self.app.model model = self.app.model
@ -95,6 +106,113 @@ class RoleView(MasterView):
if query.count(): if query.count():
node.raise_invalid("Name must be unique") 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): def defaults(config, **kwargs):
base = globals() base = globals()

View file

@ -35,6 +35,8 @@ class AppInfoView(MasterView):
""" """
Master view for the core app info, to show/edit config etc. Master view for the core app info, to show/edit config etc.
Default route prefix is ``appinfo``.
Notable URLs provided by this class: Notable URLs provided by this class:
* ``/appinfo/`` * ``/appinfo/``
@ -137,6 +139,8 @@ class SettingView(MasterView):
""" """
Master view for the "raw" settings table. Master view for the "raw" settings table.
Default route prefix is ``settings``.
Notable URLs provided by this class: Notable URLs provided by this class:
* ``/settings/`` * ``/settings/``

View file

@ -36,6 +36,8 @@ class UserView(MasterView):
""" """
Master view for users. Master view for users.
Default route prefix is ``users``.
Notable URLs provided by this class: Notable URLs provided by this class:
* ``/users/`` * ``/users/``

View file

@ -221,3 +221,34 @@ class TestRoleRefs(DataTestCase):
self.assertEqual(len(widget.values), 2) self.assertEqual(len(widget.values), 2)
self.assertEqual(widget.values[0][1], "Administrator") self.assertEqual(widget.values[0][1], "Administrator")
self.assertEqual(widget.values[1][1], "Blokes") 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"))

View file

@ -5,7 +5,7 @@ import deform
from pyramid import testing from pyramid import testing
from wuttaweb.forms import widgets as mod 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 from tests.util import WebTestCase
@ -75,3 +75,41 @@ class TestRoleRefsWidget(WebTestCase):
html = widget.serialize(field, {admin.uuid, blokes.uuid}) html = widget.serialize(field, {admin.uuid, blokes.uuid})
self.assertIn(admin.uuid, html) self.assertIn(admin.uuid, html)
self.assertIn(blokes.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)

View file

@ -7,6 +7,7 @@ from pyramid import testing
from wuttjamaican.conf import WuttaConfig from wuttjamaican.conf import WuttaConfig
from wuttaweb import auth as mod from wuttaweb import auth as mod
from tests.util import WebTestCase
class TestLoginUser(TestCase): class TestLoginUser(TestCase):
@ -143,3 +144,26 @@ class TestWuttaSecurityPolicy(TestCase):
self.assertFalse(self.policy.permits(self.request, None, 'some-root-perm')) self.assertFalse(self.policy.permits(self.request, None, 'some-root-perm'))
self.request.is_root = True self.request.is_root = True
self.assertTrue(self.policy.permits(self.request, None, 'some-root-perm')) 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'])

View file

@ -56,10 +56,13 @@ class WebTestCase(DataTestCase):
# init web # init web
self.pyramid_config.include('pyramid_deform') self.pyramid_config.include('pyramid_deform')
self.pyramid_config.include('pyramid_mako') self.pyramid_config.include('pyramid_mako')
self.pyramid_config.include('wuttaweb.static') self.pyramid_config.add_directive('add_wutta_permission_group',
self.pyramid_config.include('wuttaweb.views.essential') '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', self.pyramid_config.add_subscriber('wuttaweb.subscribers.before_render',
'pyramid.events.BeforeRender') 'pyramid.events.BeforeRender')
self.pyramid_config.include('wuttaweb.static')
# setup new request w/ anonymous user # setup new request w/ anonymous user
event = MagicMock(request=self.request) event = MagicMock(request=self.request)

View file

@ -1,14 +1,10 @@
# -*- coding: utf-8; -*- # -*- coding: utf-8; -*-
from unittest import TestCase from tests.util import WebTestCase
from pyramid import testing
class TestIncludeMe(TestCase): class TestIncludeMe(WebTestCase):
def test_basic(self): def test_basic(self):
with testing.testConfig() as pyramid_config: # just ensure no error happens when included..
self.pyramid_config.include('wuttaweb.views')
# just ensure no error happens when included..
pyramid_config.include('wuttaweb.views')

View file

@ -162,6 +162,24 @@ class TestMasterView(WebTestCase):
model_class=MyModel): model_class=MyModel):
self.assertEqual(master.MasterView.get_route_prefix(), 'trucks') 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): def test_get_url_prefix(self):
# error by default (since no model class) # error by default (since no model class)
@ -315,6 +333,9 @@ class TestMasterView(WebTestCase):
############################## ##############################
def test_render_to_response(self): 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 {} def widgets(request): return {}
self.pyramid_config.add_route('widgets', '/widgets/') self.pyramid_config.add_route('widgets', '/widgets/')
@ -539,28 +560,38 @@ class TestMasterView(WebTestCase):
############################## ##############################
def test_index(self): 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/ # sanity/coverage check using /settings/
master.MasterView.model_name = 'Setting' with patch.multiple(master.MasterView, create=True,
master.MasterView.model_key = 'name' model_name='Setting',
master.MasterView.grid_columns = ['name', 'value'] model_key='name',
view = master.MasterView(self.request) get_index_url=MagicMock(return_value='/settings/'),
response = view.index() grid_columns=['name', 'value']):
# then again with data, to include view action url view = master.MasterView(self.request)
data = [{'name': 'foo', 'value': 'bar'}]
with patch.object(view, 'get_grid_data', return_value=data):
response = view.index() response = view.index()
del master.MasterView.model_name
del master.MasterView.model_key # then again with data, to include view action url
del master.MasterView.grid_columns data = [{'name': 'foo', 'value': 'bar'}]
with patch.object(view, 'get_grid_data', return_value=data):
response = view.index()
def test_create(self): 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 model = self.app.model
# sanity/coverage check using /settings/new # sanity/coverage check using /settings/new
with patch.multiple(master.MasterView, create=True, with patch.multiple(master.MasterView, create=True,
model_name='Setting', model_name='Setting',
model_key='name', model_key='name',
get_index_url=MagicMock(return_value='/settings/'),
form_fields=['name', 'value']): form_fields=['name', 'value']):
view = master.MasterView(self.request) view = master.MasterView(self.request)
@ -604,6 +635,11 @@ class TestMasterView(WebTestCase):
self.assertEqual(self.app.get_setting(self.session, 'foo.bar'), 'fraggle') self.assertEqual(self.app.get_setting(self.session, 'foo.bar'), 'fraggle')
def test_view(self): 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 # sanity/coverage check using /settings/XXX
setting = {'name': 'foo.bar', 'value': 'baz'} setting = {'name': 'foo.bar', 'value': 'baz'}
@ -611,6 +647,7 @@ class TestMasterView(WebTestCase):
with patch.multiple(master.MasterView, create=True, with patch.multiple(master.MasterView, create=True,
model_name='Setting', model_name='Setting',
model_key='name', model_key='name',
get_index_url=MagicMock(return_value='/settings/'),
grid_columns=['name', 'value'], grid_columns=['name', 'value'],
form_fields=['name', 'value']): form_fields=['name', 'value']):
view = master.MasterView(self.request) view = master.MasterView(self.request)
@ -618,6 +655,11 @@ class TestMasterView(WebTestCase):
response = view.view() response = view.view()
def test_edit(self): 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 model = self.app.model
self.app.save_setting(self.session, 'foo.bar', 'frazzle') self.app.save_setting(self.session, 'foo.bar', 'frazzle')
self.session.commit() self.session.commit()
@ -634,6 +676,7 @@ class TestMasterView(WebTestCase):
with patch.multiple(master.MasterView, create=True, with patch.multiple(master.MasterView, create=True,
model_name='Setting', model_name='Setting',
model_key='name', model_key='name',
get_index_url=MagicMock(return_value='/settings/'),
form_fields=['name', 'value']): form_fields=['name', 'value']):
view = master.MasterView(self.request) view = master.MasterView(self.request)
with patch.object(view, 'get_instance', new=get_instance): 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') self.assertEqual(self.app.get_setting(self.session, 'foo.bar'), 'froogle')
def test_delete(self): 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 model = self.app.model
self.app.save_setting(self.session, 'foo.bar', 'frazzle') self.app.save_setting(self.session, 'foo.bar', 'frazzle')
self.session.commit() self.session.commit()
@ -692,6 +740,7 @@ class TestMasterView(WebTestCase):
with patch.multiple(master.MasterView, create=True, with patch.multiple(master.MasterView, create=True,
model_name='Setting', model_name='Setting',
model_key='name', model_key='name',
get_index_url=MagicMock(return_value='/settings/'),
form_fields=['name', 'value']): form_fields=['name', 'value']):
view = master.MasterView(self.request) view = master.MasterView(self.request)
with patch.object(view, 'get_instance', new=get_instance): 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) self.assertEqual(self.session.query(model.Setting).count(), 0)
def test_configure(self): def test_configure(self):
self.pyramid_config.include('wuttaweb.views.common')
self.pyramid_config.include('wuttaweb.views.auth')
model = self.app.model model = self.app.model
# mock settings # mock settings
@ -750,6 +801,7 @@ class TestMasterView(WebTestCase):
route_prefix='appinfo', route_prefix='appinfo',
template_prefix='/appinfo', template_prefix='/appinfo',
creatable=False, creatable=False,
get_index_url=MagicMock(return_value='/appinfo/'),
configure_get_simple_settings=MagicMock(return_value=settings)): configure_get_simple_settings=MagicMock(return_value=settings)):
# get the form page # get the form page

View file

@ -15,6 +15,9 @@ class TestRoleView(WebTestCase):
def make_view(self): def make_view(self):
return mod.RoleView(self.request) return mod.RoleView(self.request)
def test_includeme(self):
self.pyramid_config.include('wuttaweb.views.roles')
def test_get_query(self): def test_get_query(self):
view = self.make_view() view = self.make_view()
query = view.get_query(session=self.session) query = view.get_query(session=self.session)
@ -30,8 +33,9 @@ class TestRoleView(WebTestCase):
def test_configure_form(self): def test_configure_form(self):
model = self.app.model model = self.app.model
role = model.Role(name="Foo")
view = self.make_view() view = self.make_view()
form = view.make_form(model_class=model.Person) form = view.make_form(model_instance=role)
self.assertNotIn('name', form.validators) self.assertNotIn('name', form.validators)
view.configure_form(form) view.configure_form(form)
self.assertIsNotNone(form.validators['name']) self.assertIsNotNone(form.validators['name'])
@ -55,3 +59,132 @@ class TestRoleView(WebTestCase):
self.request.matchdict = {'uuid': role.uuid} self.request.matchdict = {'uuid': role.uuid}
node = colander.SchemaNode(colander.String(), name='name') node = colander.SchemaNode(colander.String(), name='name')
self.assertIsNone(view.unique_name(node, 'Foo')) 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'])

View file

@ -10,6 +10,10 @@ from tests.util import WebTestCase
class TestAppInfoView(WebTestCase): class TestAppInfoView(WebTestCase):
def setUp(self):
self.setup_web()
self.pyramid_config.include('wuttaweb.views.essential')
def make_view(self): def make_view(self):
return settings.AppInfoView(self.request) return settings.AppInfoView(self.request)