2
0
Fork 0

Compare commits

..

12 commits

Author SHA1 Message Date
Lance Edgar a8514da107 bump: version 0.6.0 → 0.7.0 2024-08-15 16:20:21 -05:00
Lance Edgar 9805f808da fix: add stub for PersonView.make_user()
for tailbone compat, but pretty sure we'll want this too
2024-08-15 15:47:05 -05:00
Lance Edgar 17b8af27a7 fix: allow arbitrary kwargs for Form.render_vue_field()
for tailbone compat
2024-08-15 15:02:12 -05:00
Lance Edgar be8a45e543 fix: make some tweaks for better tailbone compatibility
this is the result of minimally testing the PersonView from wutta,
configured via a tailbone app.

had to add the `view_profile()` stub, pretty sure we want that..?
2024-08-15 02:10:08 -05:00
Lance Edgar 058632ebeb feat: add sane views for 403 Forbidden and 404 Not Found 2024-08-14 22:13:55 -05:00
Lance Edgar e3942ce65e feat: add permission checks for menus, view routes 2024-08-14 21:20:00 -05:00
Lance Edgar 675b51cac2 feat: add first-time setup page to create admin user 2024-08-14 18:29:08 -05:00
Lance Edgar bc49392140 fix: prevent delete for built-in roles 2024-08-14 17:03:51 -05:00
Lance Edgar 330ee324ba feat: expose User password for editing in master views 2024-08-14 15:55:10 -05:00
Lance Edgar 230e2fd1ab feat: expose Role permissions for editing 2024-08-14 15:10:54 -05:00
Lance Edgar 97e914c2e0 feat: expose User "roles" for editing 2024-08-13 21:44:56 -05:00
Lance Edgar bdfa0197b2 feat: improve widget, rendering for Role notes 2024-08-13 21:44:53 -05:00
48 changed files with 2626 additions and 283 deletions

View file

@ -5,6 +5,25 @@ All notable changes to wuttaweb will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
## v0.7.0 (2024-08-15)
### Feat
- add sane views for 403 Forbidden and 404 Not Found
- add permission checks for menus, view routes
- add first-time setup page to create admin user
- expose User password for editing in master views
- expose Role permissions for editing
- expose User "roles" for editing
- improve widget, rendering for Role notes
### Fix
- add stub for `PersonView.make_user()`
- allow arbitrary kwargs for `Form.render_vue_field()`
- make some tweaks for better tailbone compatibility
- prevent delete for built-in roles
## v0.6.0 (2024-08-13)
### Feat

View file

@ -6,7 +6,7 @@ build-backend = "hatchling.build"
[project]
name = "WuttaWeb"
version = "0.6.0"
version = "0.7.0"
description = "Web App for Wutta Framework"
readme = "README.md"
authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
@ -39,7 +39,7 @@ dependencies = [
"pyramid_tm",
"waitress",
"WebHelpers2",
"WuttJamaican[db]>=0.11.0",
"WuttJamaican[db]>=0.11.1",
"zope.sqlalchemy>=1.5",
]

View file

@ -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

View file

@ -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()`.
"""
def action():
config = pyramid_config.get_settings()['wutta_config']
app = config.get_app()
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

@ -120,6 +120,13 @@ class Form:
See also :meth:`set_validator()`.
.. attribute:: defaults
Dict of default field values, used to construct the form in
:meth:`get_schema()`.
See also :meth:`set_default()`.
.. attribute:: readonly
Boolean indicating the form does not allow submit. In practice
@ -248,6 +255,7 @@ class Form:
nodes={},
widgets={},
validators={},
defaults={},
readonly=False,
readonly_fields=[],
required_fields={},
@ -271,6 +279,7 @@ class Form:
self.nodes = nodes or {}
self.widgets = widgets or {}
self.validators = validators or {}
self.defaults = defaults or {}
self.readonly = readonly
self.readonly_fields = set(readonly_fields or [])
self.required_fields = required_fields or {}
@ -375,6 +384,23 @@ class Form:
"""
self.fields = FieldList(fields)
def append(self, *keys):
"""
Add some fields(s) to the form.
This is a convenience to allow adding multiple fields at
once::
form.append('first_field',
'second_field',
'third_field')
It will add each field to :attr:`fields`.
"""
for key in keys:
if key not in self.fields:
self.fields.append(key)
def remove(self, *keys):
"""
Remove some fields(s) from the form.
@ -471,6 +497,18 @@ class Form:
if self.schema and key in self.schema:
self.schema[key].validator = validator
def set_default(self, key, value):
"""
Set/override the default value for a field.
:param key: Name of field.
:param validator: Default value for the field.
Default value overrides are tracked via :attr:`defaults`.
"""
self.defaults[key] = value
def set_readonly(self, key, readonly=True):
"""
Enable or disable the "readonly" flag for a given field.
@ -624,29 +662,22 @@ class Form:
if self.model_class:
# first define full list of 'includes' - final schema
# should contain all of these fields
includes = list(fields)
# determine which we want ColanderAlchemy to handle
auto_includes = []
for key in includes:
# skip if we already have a node defined
# collect list of field names and/or nodes
includes = []
for key in fields:
if key in self.nodes:
continue
# we want the magic for this field
auto_includes.append(key)
includes.append(self.nodes[key])
else:
includes.append(key)
# make initial schema with ColanderAlchemy magic
schema = SQLAlchemySchemaNode(self.model_class,
includes=auto_includes)
includes=includes)
# now fill in the blanks for non-magic fields
for key in includes:
if key not in auto_includes:
node = self.nodes[key]
# fill in the blanks if anything got missed
for key in fields:
if key not in schema:
node = colander.SchemaNode(colander.String(), name=key)
schema.add(node)
else:
@ -685,6 +716,11 @@ class Form:
elif key in schema: # field-level
schema[key].validator = validator
# apply default value overrides
for key, value in self.defaults.items():
if key in schema:
schema[key].default = value
# apply required flags
for key, required in self.required_fields.items():
if key in schema:
@ -775,7 +811,12 @@ class Form:
output = render(template, context)
return HTML.literal(output)
def render_vue_field(self, fieldname, readonly=None):
def render_vue_field(
self,
fieldname,
readonly=None,
**kwargs,
):
"""
Render the given field completely, i.e. ``<b-field>`` wrapper
with label and containing a widget.
@ -791,6 +832,12 @@ class Form:
message="something went wrong!">
<!-- widget element(s) -->
</b-field>
.. warning::
Any ``**kwargs`` received from caller are ignored by this
method. For now they are allowed, for sake of backwawrd
compatibility. This may change in the future.
"""
# readonly comes from: caller, field flag, or form flag
if readonly is None:
@ -903,6 +950,20 @@ class Form:
return model_data
# TODO: for tailbone compat, should document?
# (ideally should remove this and find a better way)
def get_vue_field_value(self, key):
""" """
if key not in self.fields:
return
dform = self.get_deform()
if key not in dform:
return
field = dform[key]
return make_json_safe(field.cstruct)
def validate(self):
"""
Try to validate the form, using data from the :attr:`request`.

View file

@ -257,3 +257,101 @@ class PersonRef(ObjectRef):
def sort_query(self, query):
""" """
return query.order_by(self.model_class.full_name)
class WuttaSet(colander.Set):
"""
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):
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.
:returns: Instance of
:class:`~wuttaweb.forms.widgets.RoleRefsWidget`.
"""
kwargs.setdefault('session', self.session)
if 'values' not in kwargs:
model = self.app.model
auth = self.app.get_auth_handler()
avoid = {
auth.get_role_authenticated(self.session),
auth.get_role_anonymous(self.session),
}
avoid = set([role.uuid for role in avoid])
roles = self.session.query(model.Role)\
.filter(~model.Role.uuid.in_(avoid))\
.order_by(model.Role.name)\
.all()
values = [(role.uuid, role.name) for role in roles]
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)

View file

@ -30,12 +30,21 @@ in the namespace:
* :class:`deform:deform.widget.Widget` (base class)
* :class:`deform:deform.widget.TextInputWidget`
* :class:`deform:deform.widget.TextAreaWidget`
* :class:`deform:deform.widget.PasswordWidget`
* :class:`deform:deform.widget.CheckedPasswordWidget`
* :class:`deform:deform.widget.SelectWidget`
* :class:`deform:deform.widget.CheckboxChoiceWidget`
"""
from deform.widget import Widget, TextInputWidget, SelectWidget
import colander
from deform.widget import (Widget, TextInputWidget, TextAreaWidget,
PasswordWidget, CheckedPasswordWidget,
SelectWidget, CheckboxChoiceWidget)
from webhelpers2.html import HTML
from wuttaweb.db import Session
class ObjectRefWidget(SelectWidget):
"""
@ -48,6 +57,18 @@ class ObjectRefWidget(SelectWidget):
the form schema; via
:meth:`~wuttaweb.forms.schema.ObjectRef.widget_maker()`.
In readonly mode, this renders a ``<span>`` tag around the
:attr:`model_instance` (converted to string).
Otherwise it renders a select (dropdown) element allowing user to
choose from available records.
This is a subclass of :class:`deform:deform.widget.SelectWidget`
and uses these Deform templates:
* ``select``
* ``readonly/objectref``
.. attribute:: model_instance
Reference to the model record instance, i.e. the "far side" of
@ -60,23 +81,112 @@ class ObjectRefWidget(SelectWidget):
when the :class:`~wuttaweb.forms.schema.ObjectRef` type
instance (associated with the node) is serialized.
"""
readonly_template = 'readonly/objectref'
def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = request
class NotesWidget(TextAreaWidget):
"""
Widget for use with "notes" fields.
In readonly mode, this shows the notes with a background to make
them stand out a bit more.
Otherwise it effectively shows a ``<textarea>`` input element.
This is a subclass of :class:`deform:deform.widget.TextAreaWidget`
and uses these Deform templates:
* ``textarea``
* ``readonly/notes``
"""
readonly_template = 'readonly/notes'
class WuttaCheckboxChoiceWidget(CheckboxChoiceWidget):
"""
Custom widget for :class:`python:set` fields.
This is a subclass of
: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``
"""
def __init__(self, request, session=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = request
self.config = self.request.wutta_config
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):
"""
Serialize the widget.
In readonly mode, returns a ``<span>`` tag around the
:attr:`model_instance` rendered as string.
Otherwise renders via the ``deform/select`` template.
"""
""" """
# special logic when field is editable
readonly = kw.get('readonly', self.readonly)
if readonly:
obj = field.schema.model_instance
return HTML.tag('span', c=str(obj or ''))
if not readonly:
# but does not apply if current user is root
if not self.request.is_root:
auth = self.app.get_auth_handler()
admin = auth.get_role_administrator(self.session)
# prune admin role from values list; it should not be
# one of the options since current user is not admin
values = kw.get('values', self.values)
values = [val for val in values
if val[0] != admin.uuid]
kw['values'] = values
# 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)

View file

@ -24,6 +24,7 @@
Base grid classes
"""
import functools
import json
import logging
@ -81,6 +82,12 @@ class Grid:
model records) or else an object capable of producing such a
list, e.g. SQLAlchemy query.
.. attribute:: renderers
Dict of column (cell) value renderer overrides.
See also :meth:`set_renderer()`.
.. attribute:: actions
List of :class:`GridAction` instances represenging action links
@ -106,6 +113,7 @@ class Grid:
key=None,
columns=None,
data=None,
renderers={},
actions=[],
linked_columns=[],
vue_tagname='wutta-grid',
@ -114,6 +122,7 @@ class Grid:
self.model_class = model_class
self.key = key
self.data = data
self.renderers = renderers or {}
self.actions = actions or []
self.linked_columns = linked_columns or []
self.vue_tagname = vue_tagname
@ -177,6 +186,23 @@ class Grid:
"""
self.columns = FieldList(columns)
def append(self, *keys):
"""
Add some columns(s) to the grid.
This is a convenience to allow adding multiple columns at
once::
grid.append('first_field',
'second_field',
'third_field')
It will add each column to :attr:`columns`.
"""
for key in keys:
if key not in self.columns:
self.columns.append(key)
def remove(self, *keys):
"""
Remove some column(s) from the grid.
@ -194,6 +220,47 @@ class Grid:
if key in self.columns:
self.columns.remove(key)
def set_renderer(self, key, renderer, **kwargs):
"""
Set/override the value renderer for a column.
:param key: Name of column.
:param renderer: Callable as described below.
Depending on the nature of grid data, sometimes a cell's
"as-is" value will be undesirable for display purposes.
The logic in :meth:`get_vue_data()` will first "convert" all
grid data as necessary so that it is at least JSON-compatible.
But then it also will invoke a renderer override (if defined)
to obtain the "final" cell value.
A renderer must be a callable which accepts 3 args ``(record,
key, value)``:
* ``record`` is the "original" record from :attr:`data`
* ``key`` is the column name
* ``value`` is the JSON-safe cell value
Whatever the renderer returns, is then used as final cell
value. For instance::
from webhelpers2.html import HTML
def render_foo(record, key, value):
return HTML.literal("<p>this is the final cell value</p>")
grid = Grid(columns=['foo', 'bar'])
grid.set_renderer('foo', render_foo)
Renderer overrides are tracked via :attr:`renderers`.
"""
if kwargs:
renderer = functools.partial(renderer, **kwargs)
self.renderers[key] = renderer
def set_link(self, key, link=True):
"""
Explicitly enable or disable auto-link behavior for a given
@ -352,17 +419,27 @@ class Grid:
# we have action(s), so add URL(s) for each record in data
data = []
for i, record in enumerate(original_data):
original_record = record
record = dict(record)
# convert data if needed, for json compat
record = make_json_safe(record,
# TODO: is this a good idea?
warn=False)
# customize value rendering where applicable
for key in self.renderers:
value = record[key]
record[key] = self.renderers[key](original_record, key, value)
# add action urls to each record
for action in self.actions:
url = action.get_url(record, i)
key = f'_action_url_{action.key}'
record[key] = url
if key not in record:
url = action.get_url(original_record, i)
if url:
record[key] = url
data.append(record)
@ -475,7 +552,7 @@ class GridAction:
See also :meth:`render_icon_and_label()`.
"""
if self.request.use_oruga:
raise NotImplementedError
return HTML.tag('o-icon', icon=self.icon)
return HTML.tag('i', class_=f'fas fa-{self.icon}')

View file

@ -272,9 +272,8 @@ class MenuHandler(GenericHandler):
current user.
"""
perm = item.get('perm')
# TODO
# if perm:
# return request.has_perm(perm)
if perm:
return request.has_perm(perm)
return True
def _mark_allowed(self, request, menus):

View file

@ -139,6 +139,16 @@ def new_request_set_user(
pyramid_config.add_subscriber('wuttaweb.subscribers.new_request_set_user',
'pyramid.events.NewRequest')
You may wish to "supplement" this hook by registering your own
custom hook and then invoking this one as needed. You can then
pass certain params to override only parts of the logic:
:param user_getter: Optional getter function to retrieve the user
from database, instead of :func:`default_user_getter()`.
:param db_session: Optional :term:`db session` to use,
instead of :class:`wuttaweb.db.Session`.
This will add to the request object:
.. attribute:: request.user
@ -158,19 +168,36 @@ def new_request_set_user(
privileges. This is only possible if :attr:`request.is_admin`
is also true.
You may wish to "supplement" this hook by registering your own
custom hook and then invoking this one as needed. You can then
pass certain params to override only parts of the logic:
.. attribute:: request.user_permissions
:param user_getter: Optional getter function to retrieve the user
from database, instead of :func:`default_user_getter()`.
The ``set`` of permission names which are granted to the
current user.
This set is obtained by calling
:meth:`~wuttjamaican:wuttjamaican.auth.AuthHandler.get_permissions()`.
.. function:: request.has_perm(name)
Shortcut to check if current user has the given permission::
if not request.has_perm('users.edit'):
raise self.forbidden()
.. function:: request.has_any_perm(*names)
Shortcut to check if current user has any of the given
permissions::
if request.has_any_perm('users.list', 'users.view'):
return "can either list or view"
else:
raise self.forbidden()
:param db_session: Optional :term:`db session` to use,
instead of :class:`wuttaweb.db.Session`.
"""
request = event.request
config = request.registry.settings['wutta_config']
app = config.get_app()
auth = app.get_auth_handler()
# request.user
if db_session:
@ -179,7 +206,6 @@ def new_request_set_user(
# request.is_admin
def is_admin(request):
auth = app.get_auth_handler()
return auth.user_is_admin(request.user)
request.set_property(is_admin, reify=True)
@ -191,6 +217,29 @@ def new_request_set_user(
return False
request.set_property(is_root, reify=True)
# request.user_permissions
def user_permissions(request):
session = db_session or Session()
return auth.get_permissions(session, request.user)
request.set_property(user_permissions, reify=True)
# request.has_perm()
def has_perm(name):
if request.is_root:
return True
if name in request.user_permissions:
return True
return False
request.has_perm = has_perm
# request.has_any_perm()
def has_any_perm(*names):
for name in names:
if request.has_perm(name):
return True
return False
request.has_any_perm = has_any_perm
def before_render(event):
"""

View file

@ -222,7 +222,7 @@
% else:
<h1 class="title">${index_title}</h1>
% endif
% if master and master.creatable and not master.creating:
% if master and master.creatable and not master.creating and master.has_perm('create'):
<wutta-button once type="is-primary"
tag="a" href="${url(f'{route_prefix}.create')}"
icon-left="plus"
@ -235,8 +235,7 @@
<div class="level-right">
## TODO
% if master and master.configurable and not master.configuring:
% if master and master.configurable and not master.configuring and master.has_perm('configure'):
<div class="level-item">
<wutta-button once type="is-primary"
tag="a" href="${url(f'{route_prefix}.configure')}"
@ -348,7 +347,7 @@
% elif request.is_admin:
${h.form(url('become_root'), ref='startBeingRootForm')}
${h.csrf_token(request)}
<input type="hidden" name="referrer" value="${request.current_route_url()}" />
<input type="hidden" name="referrer" value="${request.url}" />
<a @click="startBeingRoot()"
class="navbar-item has-background-danger has-text-white">
Become root
@ -378,19 +377,23 @@
tag="a" href="${master.get_action_url('edit', instance)}"
icon-left="edit"
label="Edit This" />
<wutta-button once type="is-danger"
tag="a" href="${master.get_action_url('delete', instance)}"
icon-left="trash"
label="Delete This" />
% if instance_deletable:
<wutta-button once type="is-danger"
tag="a" href="${master.get_action_url('delete', instance)}"
icon-left="trash"
label="Delete This" />
% endif
% elif master.editing:
<wutta-button once
tag="a" href="${master.get_action_url('view', instance)}"
icon-left="eye"
label="View This" />
<wutta-button once type="is-danger"
tag="a" href="${master.get_action_url('delete', instance)}"
icon-left="trash"
label="Delete This" />
% if instance_deletable:
<wutta-button once type="is-danger"
tag="a" href="${master.get_action_url('delete', instance)}"
icon-left="trash"
label="Delete This" />
% endif
% elif master.deleting:
<wutta-button once
tag="a" href="${master.get_action_url('view', instance)}"

View file

@ -0,0 +1,18 @@
<div tal:define="css_class css_class|field.widget.css_class;
style style|field.widget.style;
oid oid|field.oid;">
${field.start_sequence()}
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
<div tal:repeat="choice values | field.widget.values"
tal:omit-tag="">
<b-checkbox tal:define="(value, title) choice"
name="checkbox"
native-value="${value}"
tal:attributes=":value 'true' if value in cstruct else 'false';
attributes|field.widget.attributes|{};">
${title}
</b-checkbox>
</div>
</div>
${field.end_sequence()}
</div>

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,7 @@
<div tal:omit-tag="">
<span tal:condition="not cstruct"></span>
<pre tal:condition="cstruct"
class="is-family-sans-serif"
style="white-space: pre-wrap;"
>${cstruct}</pre>
</div>

View file

@ -0,0 +1 @@
<span>${str(field.schema.model_instance or '')}</span>

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

@ -0,0 +1,11 @@
<div tal:define="name name|field.name;
oid oid|field.oid;
vmodel vmodel|'modelData.'+oid;
rows rows|field.widget.rows;
cols cols|field.widget.cols;"
tal:omit-tag="">
<b-input name="${name}"
v-model="${vmodel}"
type="textarea"
tal:attributes="attributes|field.widget.attributes|{};" />
</div>

View file

@ -0,0 +1,26 @@
## -*- coding: utf-8; -*-
<%inherit file="/page.mako" />
<%def name="title()">Access Denied</%def>
<%def name="page_content()">
<div style="padding: 4rem;">
<p class="block is-size-5">
You are trying to access something for which you do not have permission.
</p>
<p class="block is-size-5">
If you feel this is an error, please ask a site admin to give you access.
</p>
% if not request.user:
<p class="block is-size-5">
Or probably, you should just ${h.link_to("Login", url('login'))}.
</p>
% endif
<b-field label="Current URL">
${request.url}
</b-field>
</div>
</%def>
${parent.body()}

View file

@ -9,15 +9,19 @@
<%def name="render_this_page_template()">
${parent.render_this_page_template()}
${form.render_vue_template()}
% if form is not Undefined:
${form.render_vue_template()}
% endif
</%def>
<%def name="finalize_this_page_vars()">
${parent.finalize_this_page_vars()}
<script>
${form.vue_component}.data = function() { return ${form.vue_component}Data }
Vue.component('${form.vue_tagname}', ${form.vue_component})
</script>
% if form is not Undefined:
<script>
${form.vue_component}.data = function() { return ${form.vue_component}Data }
Vue.component('${form.vue_tagname}', ${form.vue_component})
</script>
% endif
</%def>

View file

@ -24,7 +24,8 @@
label="Actions"
v-slot="props">
% for action in grid.actions:
<a :href="props.row._action_url_${action.key}"
<a v-if="props.row._action_url_${action.key}"
:href="props.row._action_url_${action.key}"
class="${action.link_class}">
${action.render_icon_and_label()}
</a>

View file

@ -0,0 +1,23 @@
## -*- coding: utf-8; -*-
<%inherit file="/page.mako" />
<%def name="title()">Not Found</%def>
<%def name="page_content()">
<div style="padding: 4rem;">
<p class="block is-size-5">
Not saying <span class="has-text-weight-bold">you</span> don't
know what you're talking about..
</p>
<p class="block is-size-5">
..but <span class="has-text-weight-bold">*I*</span> don't know
what you're talking about.
</p>
<b-field label="Current URL">
${request.url}
</b-field>
</div>
</%def>
${parent.body()}

View file

@ -0,0 +1,12 @@
## -*- coding: utf-8; -*-
<%inherit file="/master/view.mako" />
<%def name="page_content()">
<p class="block is-size-5">
TODO: view profile page content
</p>
</%def>
<%def name="title()">TODO: title</%def>
${parent.body()}

View file

@ -0,0 +1,20 @@
## -*- coding: utf-8; -*-
<%inherit file="/form.mako" />
<%def name="title()">First-Time Setup</%def>
<%def name="page_content()">
<b-notification type="is-success">
<p class="block">
The app is running okay!
</p>
<p class="block">
Please setup the first Administrator account below.
</p>
</b-notification>
${parent.page_content()}
</%def>
${parent.body()}

View file

@ -28,6 +28,8 @@ import importlib
import json
import logging
import sqlalchemy as sa
import colander
from webhelpers2.html import HTML, tags
@ -420,14 +422,17 @@ def get_model_fields(config, model_class=None):
that to determine the field listing if applicable. Otherwise this
returns ``None``.
"""
if model_class:
import sqlalchemy as sa
app = config.get_app()
model = app.model
if model_class and issubclass(model_class, model.Base):
mapper = sa.inspect(model_class)
fields = list([prop.key for prop in mapper.iterate_properties])
return fields
if not model_class:
return
app = config.get_app()
model = app.model
if not issubclass(model_class, model.Base):
return
mapper = sa.inspect(model_class)
fields = [prop.key for prop in mapper.iterate_properties]
return fields
def make_json_safe(value, key=None, warn=True):

View file

@ -25,11 +25,11 @@ Auth Views
"""
import colander
from deform.widget import TextInputWidget, PasswordWidget, CheckedPasswordWidget
from wuttaweb.views import View
from wuttaweb.db import Session
from wuttaweb.auth import login_user, logout_user
from wuttaweb.forms import widgets
class AuthView(View):
@ -47,10 +47,16 @@ class AuthView(View):
* route: ``login``
* template: ``/auth/login.mako``
"""
model = self.app.model
session = session or Session()
auth = self.app.get_auth_handler()
# TODO: should call request.get_referrer()
referrer = self.request.route_url('home')
# nb. redirect to /setup if no users exist
user = session.query(model.User).first()
if not user:
return self.redirect(self.request.route_url('setup'))
referrer = self.request.get_referrer()
# redirect if already logged in
if self.request.user:
@ -69,7 +75,6 @@ class AuthView(View):
if data:
# truly validate user credentials
session = session or Session()
user = auth.authenticate_user(session, data['username'], data['password'])
if user:
@ -97,14 +102,14 @@ class AuthView(View):
schema.add(colander.SchemaNode(
colander.String(),
name='username',
widget=TextInputWidget(attributes={
widget=widgets.TextInputWidget(attributes={
'ref': 'username',
})))
schema.add(colander.SchemaNode(
colander.String(),
name='password',
widget=PasswordWidget(attributes={
widget=widgets.PasswordWidget(attributes={
'ref': 'password',
})))
@ -174,13 +179,13 @@ class AuthView(View):
schema.add(colander.SchemaNode(
colander.String(),
name='current_password',
widget=PasswordWidget(),
widget=widgets.PasswordWidget(),
validator=self.change_password_validate_current_password))
schema.add(colander.SchemaNode(
colander.String(),
name='new_password',
widget=CheckedPasswordWidget(),
widget=widgets.CheckedPasswordWidget(),
validator=self.change_password_validate_new_password))
return schema

View file

@ -24,7 +24,11 @@
Common Views
"""
import colander
from wuttaweb.views import View
from wuttaweb.forms import widgets
from wuttaweb.db import Session
class CommonView(View):
@ -32,7 +36,7 @@ class CommonView(View):
Common views shared by all apps.
"""
def home(self):
def home(self, session=None):
"""
Home page view.
@ -40,12 +44,144 @@ class CommonView(View):
This is normally the view shown when a user navigates to the
root URL for the web app.
"""
model = self.app.model
session = session or Session()
# nb. redirect to /setup if no users exist
user = session.query(model.User).first()
if not user:
return self.redirect(self.request.route_url('setup'))
return {
'index_title': self.app.get_title(),
}
def forbidden_view(self):
"""
This view is shown when a request triggers a 403 Forbidden error.
Template: ``/forbidden.mako``
"""
return {'index_title': self.app.get_title()}
def notfound_view(self):
"""
This view is shown when a request triggers a 404 Not Found error.
Template: ``/notfound.mako``
"""
return {'index_title': self.app.get_title()}
def setup(self, session=None):
"""
View for first-time app setup, to create admin user.
Template: ``/setup.mako``
This page is only meant for one-time use. As such, if the app
DB contains any users, this page will always redirect to the
home page.
However if no users exist yet, this will show a form which may
be used to create the first admin user. When finished, user
will be redirected to the login page.
.. note::
As long as there are no users in the DB, both the home and
login pages will automatically redirect to this one.
"""
model = self.app.model
session = session or Session()
# nb. this view only available until first user is created
user = session.query(model.User).first()
if user:
return self.redirect(self.request.route_url('home'))
form = self.make_form(fields=['username', 'password', 'first_name', 'last_name'],
show_button_cancel=False,
show_button_reset=True)
form.set_widget('password', widgets.CheckedPasswordWidget())
form.set_required('first_name', False)
form.set_required('last_name', False)
if form.validate():
auth = self.app.get_auth_handler()
data = form.validated
# make user
user = auth.make_user(session=session, username=data['username'])
auth.set_user_password(user, data['password'])
# assign admin role
admin = auth.get_role_administrator(session)
user.roles.append(admin)
admin.notes = ("users in this role may \"become root\".\n\n"
"it's recommended not to grant other perms to this role.")
# initialize built-in roles
authed = auth.get_role_authenticated(session)
authed.notes = ("this role represents any user who *is* logged in.\n\n"
"you may grant any perms you like to it.")
anon = auth.get_role_anonymous(session)
anon.notes = ("this role represents any user who is *not* logged in.\n\n"
"you may grant any perms you like to it.")
# also make "Site Admin" role
site_admin_perms = [
'appinfo.list',
'appinfo.configure',
'people.list',
'people.create',
'people.view',
'people.edit',
'people.delete',
'roles.list',
'roles.create',
'roles.view',
'roles.edit',
'roles.edit_builtin',
'roles.delete',
'settings.list',
'settings.create',
'settings.view',
'settings.edit',
'settings.delete',
'users.list',
'users.create',
'users.view',
'users.edit',
'users.delete',
]
admin2 = model.Role(name="Site Admin")
admin2.notes = ("this is the \"daily driver\" admin role.\n\n"
"you may grant any perms you like to it.")
session.add(admin2)
user.roles.append(admin2)
for perm in site_admin_perms:
auth.grant_permission(admin2, perm)
# maybe make person
if data['first_name'] or data['last_name']:
first = data['first_name']
last = data['last_name']
person = model.Person(first_name=first,
last_name=last,
full_name=(f"{first} {last}").strip())
session.add(person)
user.person = person
# send user to /login
self.request.session.flash("Account created! Please login below.")
return self.redirect(self.request.route_url('login'))
return {
'index_title': self.app.get_title(),
'form': form,
}
@classmethod
def defaults(cls, config):
cls._defaults(config)
@ -53,15 +189,28 @@ class CommonView(View):
@classmethod
def _defaults(cls, config):
# auto-correct URLs which require trailing slash
config.add_notfound_view(cls, attr='notfound', append_slash=True)
# home page
config.add_route('home', '/')
config.add_view(cls, attr='home',
route_name='home',
renderer='/home.mako')
# forbidden
config.add_forbidden_view(cls, attr='forbidden_view',
renderer='/forbidden.mako')
# notfound
# nb. also, auto-correct URLs which require trailing slash
config.add_notfound_view(cls, attr='notfound_view',
append_slash=True,
renderer='/notfound.mako')
# setup
config.add_route('setup', '/setup')
config.add_view(cls, attr='setup',
route_name='setup',
renderer='/setup.mako')
def defaults(config, **kwargs):
base = globals()

View file

@ -28,6 +28,7 @@ import sqlalchemy as sa
from sqlalchemy import orm
from pyramid.renderers import render_to_response
from webhelpers2.html import HTML
from wuttaweb.views import View
from wuttaweb.util import get_form_data, get_model_fields
@ -138,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,
@ -189,12 +198,16 @@ class MasterView(View):
i.e. it should have an :meth:`edit()` view. Default value is
``True``.
See also :meth:`is_editable()`.
.. attribute:: deletable
Boolean indicating whether the view model supports "deleting" -
i.e. it should have a :meth:`delete()` view. Default value is
``True``.
See also :meth:`is_deletable()`.
.. attribute:: form_fields
List of columns for the model form.
@ -229,6 +242,9 @@ class MasterView(View):
deleting = False
configuring = False
# default DB session
Session = Session
##############################
# index methods
##############################
@ -292,7 +308,7 @@ class MasterView(View):
if form.validate():
obj = self.create_save_form(form)
Session.flush()
self.Session.flush()
return self.redirect(self.get_action_url('view', obj))
context = {
@ -349,7 +365,6 @@ class MasterView(View):
context = {
'instance': instance,
'instance_title': self.get_instance_title(instance),
'form': form,
}
return self.render_to_response('view', context)
@ -383,7 +398,6 @@ class MasterView(View):
"""
self.editing = True
instance = self.get_instance()
instance_title = self.get_instance_title(instance)
form = self.make_model_form(instance,
cancel_url_fallback=self.get_action_url('view', instance))
@ -394,7 +408,6 @@ class MasterView(View):
context = {
'instance': instance,
'instance_title': instance_title,
'form': form,
}
return self.render_to_response('edit', context)
@ -447,7 +460,9 @@ class MasterView(View):
"""
self.deleting = True
instance = self.get_instance()
instance_title = self.get_instance_title(instance)
if not self.is_deletable(instance):
return self.redirect(self.get_action_url('view', instance))
# nb. this form proper is not readonly..
form = self.make_model_form(instance,
@ -465,7 +480,6 @@ class MasterView(View):
context = {
'instance': instance,
'instance_title': instance_title,
'form': form,
}
return self.render_to_response('delete', context)
@ -500,7 +514,7 @@ class MasterView(View):
# configure methods
##############################
def configure(self):
def configure(self, session=None):
"""
View for configuring aspects of the app which are pertinent to
this master view and/or model.
@ -546,7 +560,7 @@ class MasterView(View):
# maybe just remove settings
if self.request.POST.get('remove_settings'):
self.configure_remove_settings()
self.configure_remove_settings(session=session)
self.request.session.flash(f"All settings for {config_title} have been removed.",
'warning')
@ -556,8 +570,8 @@ class MasterView(View):
# gather/save settings
data = get_form_data(self.request)
settings = self.configure_gather_settings(data)
self.configure_remove_settings()
self.configure_save_settings(settings)
self.configure_remove_settings(session=session)
self.configure_save_settings(settings, session=session)
self.request.session.flash("Settings have been saved.")
# reload configure page
@ -735,6 +749,7 @@ class MasterView(View):
def configure_remove_settings(
self,
simple_settings=None,
session=None,
):
"""
Remove all "known" settings from the DB; this is called by
@ -762,11 +777,11 @@ class MasterView(View):
if names:
# nb. must avoid self.Session here in case that does not
# point to our primary app DB
session = Session()
session = session or self.Session()
for name in names:
self.app.delete_setting(session, name)
def configure_save_settings(self, settings):
def configure_save_settings(self, settings, session=None):
"""
Save the given settings to the DB; this is called by
:meth:`configure()`.
@ -779,7 +794,7 @@ class MasterView(View):
"""
# nb. must avoid self.Session here in case that does not point
# to our primary app DB
session = Session()
session = session or self.Session()
for setting in settings:
self.app.save_setting(session, setting['name'], setting['value'],
force_create=True)
@ -788,6 +803,43 @@ class MasterView(View):
# support methods
##############################
def has_perm(self, name):
"""
Shortcut to check if current user has the given permission.
This will automatically add the :attr:`permission_prefix` to
``name`` before passing it on to
:func:`~wuttaweb.subscribers.request.has_perm()`.
For instance within the
:class:`~wuttaweb.views.users.UserView` these give the same
result::
self.request.has_perm('users.edit')
self.has_perm('edit')
So this shortcut only applies to permissions defined for the
current master view. The first example above must still be
used to check for "foreign" permissions (i.e. any needing a
different prefix).
"""
permission_prefix = self.get_permission_prefix()
return self.request.has_perm(f'{permission_prefix}.{name}')
def has_any_perm(self, *names):
"""
Shortcut to check if current user has any of the given
permissions.
This calls :meth:`has_perm()` until one returns ``True``. If
none do, returns ``False``.
"""
for name in names:
if self.has_perm(name):
return True
return False
def render_to_response(self, template, context):
"""
Locate and render an appropriate template, with the given
@ -830,6 +882,16 @@ class MasterView(View):
defaults.update(context)
context = defaults
# add crud flags if we have an instance
if 'instance' in context:
instance = context['instance']
if 'instance_title' not in context:
context['instance_title'] = self.get_instance_title(instance)
if 'instance_editable' not in context:
context['instance_editable'] = self.is_editable(instance)
if 'instance_deletable' not in context:
context['instance_deletable'] = self.is_deletable(instance)
# first try the template path most specific to this view
template_prefix = self.get_template_prefix()
mako_path = f'{template_prefix}/{template}.mako'
@ -919,15 +981,15 @@ class MasterView(View):
# TODO: should split this off into index_get_grid_actions() ?
if self.viewable:
if self.viewable and self.has_perm('view'):
actions.append(self.make_grid_action('view', icon='eye',
url=self.get_action_url_view))
if self.editable:
if self.editable and self.has_perm('edit'):
actions.append(self.make_grid_action('edit', icon='edit',
url=self.get_action_url_edit))
if self.deletable:
if self.deletable and self.has_perm('delete'):
actions.append(self.make_grid_action('delete', icon='trash',
url=self.get_action_url_delete,
link_class='has-text-danger'))
@ -977,26 +1039,7 @@ class MasterView(View):
"""
query = self.get_query(session=session)
if query:
data = query.all()
# determine which columns are relevant for data set
if not columns:
columns = self.get_grid_columns()
if not columns:
model_class = self.get_model_class()
if model_class:
columns = get_model_fields(self.config, model_class)
if not columns:
raise ValueError("cannot determine columns for the grid")
columns = set(columns)
columns.update(self.get_model_key())
# prune data fields for which no column is defined
for i, record in enumerate(data):
data[i]= dict([(key, record[key])
for key in columns])
return data
return query.all()
return []
@ -1012,7 +1055,7 @@ class MasterView(View):
model = self.app.model
model_class = self.get_model_class()
if model_class and issubclass(model_class, model.Base):
session = session or Session()
session = session or self.Session()
return session.query(model_class)
def configure_grid(self, grid):
@ -1032,6 +1075,33 @@ class MasterView(View):
for key in self.get_model_key():
grid.set_link(key)
def grid_render_notes(self, record, key, value, maxlen=100):
"""
Custom grid renderer callable for "notes" fields.
If the given text ``value`` is shorter than ``maxlen``
characters, it is returned as-is.
But if it is longer, then it is truncated and an ellispsis is
added. The resulting ``<span>`` tag is also given a ``title``
attribute with the original (full) text, so that appears on
mouse hover.
To use this feature for your grid::
grid.set_renderer('my_notes_field', self.grid_render_notes)
# you can also override maxlen
grid.set_renderer('my_notes_field', self.grid_render_notes, maxlen=50)
"""
if value is None:
return
if len(value) < maxlen:
return value
return HTML.tag('span', title=value, c=f"{value[:maxlen]}...")
def get_instance(self, session=None):
"""
This should return the "current" model instance based on the
@ -1045,7 +1115,7 @@ class MasterView(View):
"""
model_class = self.get_model_class()
if model_class:
session = session or Session()
session = session or self.Session()
def filtr(query, model_key):
key = self.request.matchdict[model_key]
@ -1111,25 +1181,61 @@ class MasterView(View):
def get_action_url_edit(self, obj, i):
"""
Returns the "edit" grid action URL for the given object.
Returns the "edit" grid action URL for the given object, if
applicable.
Most typically this is like ``/widgets/XXX/edit`` where
``XXX`` represents the object's key/ID.
Calls :meth:`get_action_url()` under the hood.
This first calls :meth:`is_editable()` and if that is false,
this method will return ``None``.
Calls :meth:`get_action_url()` to generate the true URL.
"""
return self.get_action_url('edit', obj)
if self.is_editable(obj):
return self.get_action_url('edit', obj)
def get_action_url_delete(self, obj, i):
"""
Returns the "delete" grid action URL for the given object.
Returns the "delete" grid action URL for the given object, if
applicable.
Most typically this is like ``/widgets/XXX/delete`` where
``XXX`` represents the object's key/ID.
Calls :meth:`get_action_url()` under the hood.
This first calls :meth:`is_deletable()` and if that is false,
this method will return ``None``.
Calls :meth:`get_action_url()` to generate the true URL.
"""
return self.get_action_url('delete', obj)
if self.is_deletable(obj):
return self.get_action_url('delete', obj)
def is_editable(self, obj):
"""
Returns a boolean indicating whether "edit" should be allowed
for the given model instance (and for current user).
By default this always return ``True``; subclass can override
if needed.
Note that the use of this method implies :attr:`editable` is
true, so the method does not need to check that flag.
"""
return True
def is_deletable(self, obj):
"""
Returns a boolean indicating whether "delete" should be
allowed for the given model instance (and for current user).
By default this always return ``True``; subclass can override
if needed.
Note that the use of this method implies :attr:`deletable` is
true, so the method does not need to check that flag.
"""
return True
def make_model_form(self, model_instance=None, **kwargs):
"""
@ -1260,7 +1366,7 @@ class MasterView(View):
if model_class and issubclass(model_class, model.Base):
# add sqlalchemy model to session
session = session or Session()
session = session or self.Session()
session.add(obj)
##############################
@ -1414,6 +1520,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):
"""
@ -1553,27 +1682,47 @@ 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)
route_name=route_prefix,
permission=f'{permission_prefix}.list')
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.list',
f"Browse / search {model_title_plural}")
# create
if cls.creatable:
config.add_route(f'{route_prefix}.create',
f'{url_prefix}/new')
config.add_view(cls, attr='create',
route_name=f'{route_prefix}.create')
route_name=f'{route_prefix}.create',
permission=f'{permission_prefix}.create')
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.create',
f"Create new {model_title}")
# view
if cls.viewable:
instance_url_prefix = cls.get_instance_url_prefix()
config.add_route(f'{route_prefix}.view', instance_url_prefix)
config.add_view(cls, attr='view',
route_name=f'{route_prefix}.view')
route_name=f'{route_prefix}.view',
permission=f'{permission_prefix}.view')
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.view',
f"View {model_title}")
# edit
if cls.editable:
@ -1581,7 +1730,11 @@ class MasterView(View):
config.add_route(f'{route_prefix}.edit',
f'{instance_url_prefix}/edit')
config.add_view(cls, attr='edit',
route_name=f'{route_prefix}.edit')
route_name=f'{route_prefix}.edit',
permission=f'{permission_prefix}.edit')
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.edit',
f"Edit {model_title}")
# delete
if cls.deletable:
@ -1589,11 +1742,19 @@ class MasterView(View):
config.add_route(f'{route_prefix}.delete',
f'{instance_url_prefix}/delete')
config.add_view(cls, attr='delete',
route_name=f'{route_prefix}.delete')
route_name=f'{route_prefix}.delete',
permission=f'{permission_prefix}.delete')
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.delete',
f"Delete {model_title}")
# configure
if cls.configurable:
config.add_route(f'{route_prefix}.configure',
f'{url_prefix}/configure')
config.add_view(cls, attr='configure',
route_name=f'{route_prefix}.configure')
route_name=f'{route_prefix}.configure',
permission=f'{permission_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.
Default route prefix is ``people``.
Notable URLs provided by this class:
* ``/people/``
@ -83,6 +85,47 @@ class PersonView(MasterView):
if 'users' in f:
f.fields.remove('users')
def view_profile(self, session=None):
""" """
instance = self.get_instance(session=session)
context = {
'instance': instance,
}
return self.render_to_response('view_profile', context)
def make_user(self):
""" """
self.request.session.flash("TODO: this feature is not yet supported", 'error')
return self.redirect(self.request.get_referrer())
@classmethod
def defaults(cls, config):
cls._defaults(config)
cls._people_defaults(config)
@classmethod
def _people_defaults(cls, config):
route_prefix = cls.get_route_prefix()
url_prefix = cls.get_url_prefix()
instance_url_prefix = cls.get_instance_url_prefix()
permission_prefix = cls.get_permission_prefix()
# view profile
config.add_route(f'{route_prefix}.view_profile',
f'{instance_url_prefix}/profile',
request_method='GET')
config.add_view(cls, attr='view_profile',
route_name=f'{route_prefix}.view_profile',
permission=f'{permission_prefix}.view_profile')
# make user for person
config.add_route(f'{route_prefix}.make_user',
f'{url_prefix}/make-user',
request_method='POST')
config.add_view(cls, attr='make_user',
route_name=f'{route_prefix}.make_user',
permission='users.create')
def defaults(config, **kwargs):
base = globals()

View file

@ -27,12 +27,16 @@ Views for roles
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/``
@ -62,9 +66,44 @@ class RoleView(MasterView):
# name
g.set_link('name')
# notes
g.set_renderer('notes', self.grid_render_notes)
def is_editable(self, role):
""" """
session = self.app.get_session(role)
auth = self.app.get_auth_handler()
# only "root" can edit admin role
if role is auth.get_role_administrator(session):
return self.request.is_root
# other built-in roles require special perm
if role in (auth.get_role_authenticated(session),
auth.get_role_anonymous(session)):
return self.has_perm('edit_builtin')
return True
def is_deletable(self, role):
""" """
session = self.app.get_session(role)
auth = self.app.get_auth_handler()
# prevent delete for built-in roles
if role is auth.get_role_authenticated(session):
return False
if role is auth.get_role_anonymous(session):
return False
if role is auth.get_role_administrator(session):
return False
return True
def configure_form(self, f):
""" """
super().configure_form(f)
role = f.model_instance
# never show these
f.remove('permission_refs',
@ -73,6 +112,16 @@ class RoleView(MasterView):
# name
f.set_validator('name', self.unique_name)
# 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
@ -88,6 +137,128 @@ 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)
@classmethod
def defaults(cls, config):
cls._defaults(config)
cls._role_defaults(config)
@classmethod
def _role_defaults(cls, config):
permission_prefix = cls.get_permission_prefix()
model_title_plural = cls.get_model_title_plural()
# perm to edit built-in roles
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.edit_builtin',
f"Edit the Built-in {model_title_plural}")
def defaults(config, **kwargs):
base = globals()

View file

@ -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/``

View file

@ -28,7 +28,8 @@ import colander
from wuttjamaican.db.model import User
from wuttaweb.views import MasterView
from wuttaweb.forms.schema import PersonRef
from wuttaweb.forms import widgets
from wuttaweb.forms.schema import PersonRef, RoleRefs
from wuttaweb.db import Session
@ -36,6 +37,8 @@ class UserView(MasterView):
"""
Master view for users.
Default route prefix is ``users``.
Notable URLs provided by this class:
* ``/users/``
@ -77,10 +80,10 @@ class UserView(MasterView):
def configure_form(self, f):
""" """
super().configure_form(f)
user = f.model_instance
# never show these
f.remove('person_uuid',
'password',
'role_refs')
# person
@ -90,6 +93,24 @@ class UserView(MasterView):
# username
f.set_validator('username', self.unique_username)
# password
# nb. we must avoid 'password' as field name since
# ColanderAlchemy wants to handle the raw/hashed value
f.remove('password')
# nb. no need for password field if readonly
if self.creating or self.editing:
# nb. use 'set_password' as field name
f.append('set_password')
f.set_required('set_password', False)
f.set_widget('set_password', widgets.CheckedPasswordWidget())
# roles
f.append('roles')
f.set_node('roles', RoleRefs(self.request))
if not self.creating:
f.set_default('roles', [role.uuid for role in user.roles])
def unique_username(self, node, value):
""" """
model = self.app.model
@ -105,6 +126,69 @@ class UserView(MasterView):
if query.count():
node.raise_invalid("Username must be unique")
def objectify(self, form, session=None):
""" """
data = form.validated
# normal logic first
user = super().objectify(form)
# maybe set user password
if 'set_password' in form and data.get('set_password'):
auth = self.app.get_auth_handler()
auth.set_user_password(user, data['set_password'])
# update roles for user
# TODO
# if self.has_perm('edit_roles'):
self.update_roles(user, form, session=session)
return user
def update_roles(self, user, form, session=None):
""" """
# TODO
# if not self.has_perm('edit_roles'):
# return
data = form.validated
if 'roles' not in data:
return
model = self.app.model
session = session or Session()
auth = self.app.get_auth_handler()
old_roles = set([role.uuid for role in user.roles])
new_roles = data['roles']
admin = auth.get_role_administrator(session)
ignored = {
auth.get_role_authenticated(session).uuid,
auth.get_role_anonymous(session).uuid,
}
# add any new roles for the user, taking care to avoid certain
# unwanted operations for built-in roles
for uuid in new_roles:
if uuid in ignored:
continue
if uuid in old_roles:
continue
if uuid == admin.uuid and not self.request.is_root:
continue
role = session.get(model.Role, uuid)
user.roles.append(role)
# remove any roles which were *not* specified, taking care to
# avoid certain unwanted operations for built-in roles
for uuid in old_roles:
if uuid in new_roles:
continue
if uuid == admin.uuid and not self.request.is_root:
continue
role = session.get(model.Role, uuid)
user.roles.remove(role)
def defaults(config, **kwargs):
base = globals()

View file

@ -84,6 +84,12 @@ class TestForm(TestCase):
form.set_fields(['baz'])
self.assertEqual(form.fields, ['baz'])
def test_append(self):
form = self.make_form(fields=['one', 'two'])
self.assertEqual(form.fields, ['one', 'two'])
form.append('one', 'two', 'three')
self.assertEqual(form.fields, ['one', 'two', 'three'])
def test_remove(self):
form = self.make_form(fields=['one', 'two', 'three', 'four'])
self.assertEqual(form.fields, ['one', 'two', 'three', 'four'])
@ -157,6 +163,14 @@ class TestForm(TestCase):
self.assertIs(form.validators['foo'], validate2)
self.assertIs(schema['foo'].validator, validate2)
def test_set_default(self):
form = self.make_form(fields=['foo', 'bar'])
self.assertEqual(form.defaults, {})
# basic
form.set_default('foo', 42)
self.assertEqual(form.defaults['foo'], 42)
def test_get_schema(self):
model = self.app.model
form = self.make_form()
@ -204,6 +218,18 @@ class TestForm(TestCase):
self.assertIn('name', schema)
self.assertIn('value', schema)
# ColanderAlchemy schema still has *all* requested fields
form = self.make_form(model_instance=model.Setting(name='uhoh'),
fields=['name', 'value', 'foo', 'bar'])
self.assertEqual(form.fields, ['name', 'value', 'foo', 'bar'])
self.assertIsNone(form.schema)
schema = form.get_schema()
self.assertEqual(len(schema.children), 4)
self.assertIn('name', schema)
self.assertIn('value', schema)
self.assertIn('foo', schema)
self.assertIn('bar', schema)
# schema nodes are required by default
form = self.make_form(fields=['foo', 'bar'])
schema = form.get_schema()
@ -233,6 +259,12 @@ class TestForm(TestCase):
schema = form.get_schema()
self.assertIs(schema.validator, validate)
# default value overrides are honored
form = self.make_form(model_class=model.Setting)
form.set_default('name', 'foo')
schema = form.get_schema()
self.assertEqual(schema['name'].default, 'foo')
def test_get_deform(self):
model = self.app.model
schema = self.make_schema()
@ -422,6 +454,41 @@ class TestForm(TestCase):
# nb. no error message
self.assertNotIn('message', html)
def test_get_vue_field_value(self):
schema = self.make_schema()
form = self.make_form(schema=schema)
# TODO: yikes what a hack (?)
dform = form.get_deform()
dform.set_appstruct({'foo': 'one', 'bar': 'two'})
# null for missing field
value = form.get_vue_field_value('doesnotexist')
self.assertIsNone(value)
# normal value is returned
value = form.get_vue_field_value('foo')
self.assertEqual(value, 'one')
# but not if we remove field from deform
# TODO: what is the use case here again?
dform.children.remove(dform['foo'])
value = form.get_vue_field_value('foo')
self.assertIsNone(value)
def test_get_vue_model_data(self):
schema = self.make_schema()
form = self.make_form(schema=schema)
# 2 fields by default (foo, bar)
data = form.get_vue_model_data()
self.assertEqual(len(data), 2)
# still just 2 fields even if we request more
form.set_fields(['foo', 'bar', 'baz'])
data = form.get_vue_model_data()
self.assertEqual(len(data), 2)
def test_get_field_errors(self):
schema = self.make_schema()
form = self.make_form(schema=schema)

View file

@ -197,3 +197,58 @@ class TestPersonRef(DataTestCase):
sorted_query = typ.sort_query(query)
self.assertIsInstance(sorted_query, orm.Query)
self.assertIsNot(sorted_query, query)
class TestRoleRefs(DataTestCase):
def setUp(self):
self.setup_db()
self.request = testing.DummyRequest(wutta_config=self.config)
def test_widget_maker(self):
model = self.app.model
auth = self.app.get_auth_handler()
admin = auth.get_role_administrator(self.session)
authed = auth.get_role_authenticated(self.session)
anon = auth.get_role_anonymous(self.session)
blokes = model.Role(name="Blokes")
self.session.add(blokes)
self.session.commit()
# default values for widget includes all but: authed, anon
typ = mod.RoleRefs(self.request, session=self.session)
widget = typ.widget_maker()
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"))

View file

@ -4,12 +4,19 @@ import colander
import deform
from pyramid import testing
from wuttaweb.forms import widgets
from wuttaweb.forms.schema import PersonRef
from wuttaweb.forms import widgets as mod
from wuttaweb.forms.schema import PersonRef, RoleRefs, Permissions
from tests.util import WebTestCase
class TestObjectRefWidget(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):
model = self.app.model
person = model.Person(full_name="Betty Boop")
@ -18,15 +25,91 @@ class TestObjectRefWidget(WebTestCase):
# standard (editable)
node = colander.SchemaNode(PersonRef(self.request, session=self.session))
widget = widgets.ObjectRefWidget(self.request)
field = deform.Field(node)
widget = mod.ObjectRefWidget(self.request)
field = self.make_field(node)
html = widget.serialize(field, person.uuid)
self.assertIn('<select ', html)
self.assertIn('<b-select ', html)
# readonly
node = colander.SchemaNode(PersonRef(self.request, session=self.session))
node.model_instance = person
widget = widgets.ObjectRefWidget(self.request)
field = deform.Field(node)
widget = mod.ObjectRefWidget(self.request)
field = self.make_field(node)
html = widget.serialize(field, person.uuid, readonly=True)
self.assertEqual(html, '<span>Betty Boop</span>')
self.assertEqual(html.strip(), '<span>Betty Boop</span>')
class TestRoleRefsWidget(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):
model = self.app.model
auth = self.app.get_auth_handler()
admin = auth.get_role_administrator(self.session)
blokes = model.Role(name="Blokes")
self.session.add(blokes)
self.session.commit()
# nb. we let the field construct the widget via our type
node = colander.SchemaNode(RoleRefs(self.request, session=self.session))
field = self.make_field(node)
widget = field.widget
# readonly values list includes admin
html = widget.serialize(field, {admin.uuid, blokes.uuid}, readonly=True)
self.assertIn(admin.name, html)
self.assertIn(blokes.name, html)
# editable values list *excludes* admin (by default)
html = widget.serialize(field, {admin.uuid, blokes.uuid})
self.assertNotIn(admin.uuid, html)
self.assertIn(blokes.uuid, html)
# but admin is included for root user
self.request.is_root = True
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)

View file

@ -69,12 +69,37 @@ class TestGrid(TestCase):
self.assertEqual(grid.columns, ['name', 'value'])
self.assertEqual(grid.get_columns(), ['name', 'value'])
def test_append(self):
grid = self.make_grid(columns=['one', 'two'])
self.assertEqual(grid.columns, ['one', 'two'])
grid.append('one', 'two', 'three')
self.assertEqual(grid.columns, ['one', 'two', 'three'])
def test_remove(self):
grid = self.make_grid(columns=['one', 'two', 'three', 'four'])
self.assertEqual(grid.columns, ['one', 'two', 'three', 'four'])
grid.remove('two', 'three')
self.assertEqual(grid.columns, ['one', 'four'])
def test_set_renderer(self):
grid = self.make_grid(columns=['foo', 'bar'])
self.assertEqual(grid.renderers, {})
def render1(record, key, value):
pass
# basic
grid.set_renderer('foo', render1)
self.assertIs(grid.renderers['foo'], render1)
def render2(record, key, value, extra=None):
return extra
# can pass kwargs to get a partial
grid.set_renderer('foo', render2, extra=42)
self.assertIsNot(grid.renderers['foo'], render2)
self.assertEqual(grid.renderers['foo'](None, None, None), 42)
def test_linked_columns(self):
grid = self.make_grid(columns=['foo', 'bar'])
self.assertEqual(grid.linked_columns, [])
@ -143,6 +168,11 @@ class TestGrid(TestCase):
self.assertIsNot(data, mydata)
self.assertEqual(data, [{'foo': 'bar', '_action_url_view': '/blarg'}])
# also can override value rendering
grid.set_renderer('foo', lambda record, key, value: "blah blah")
data = grid.get_vue_data()
self.assertEqual(data, [{'foo': 'blah blah', '_action_url_view': '/blarg'}])
class TestGridAction(TestCase):
@ -160,9 +190,10 @@ class TestGridAction(TestCase):
html = action.render_icon()
self.assertIn('<i class="fas fa-blarg">', html)
# oruga not yet supported
# oruga has different output
self.request.use_oruga = True
self.assertRaises(NotImplementedError, action.render_icon)
html = action.render_icon()
self.assertIn('<o-icon icon="blarg">', html)
def test_render_label(self):

View file

@ -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'])

View file

@ -3,20 +3,15 @@
from unittest import TestCase
from unittest.mock import patch, MagicMock
from wuttjamaican.conf import WuttaConfig
from pyramid import testing
from wuttaweb import menus as mod
from tests.util import WebTestCase
class TestMenuHandler(TestCase):
class TestMenuHandler(WebTestCase):
def setUp(self):
self.config = WuttaConfig()
self.app = self.config.get_app()
self.setup_web()
self.handler = mod.MenuHandler(self.config)
self.request = testing.DummyRequest()
def test_make_admin_menu(self):
menus = self.handler.make_admin_menu(self.request)
@ -27,7 +22,27 @@ class TestMenuHandler(TestCase):
self.assertIsInstance(menus, list)
def test_is_allowed(self):
# TODO: this should test auth/perm handling
model = self.app.model
auth = self.app.get_auth_handler()
# user with perms
barney = model.User(username='barney')
self.session.add(barney)
blokes = model.Role(name="Blokes")
self.session.add(blokes)
barney.roles.append(blokes)
auth.grant_permission(blokes, 'appinfo.list')
self.request.user = barney
# perm not granted to user
item = {'perm': 'appinfo.configure'}
self.assertFalse(self.handler._is_allowed(self.request, item))
# perm *is* granted to user
item = {'perm': 'appinfo.list'}
self.assertTrue(self.handler._is_allowed(self.request, item))
# perm not required
item = {}
self.assertTrue(self.handler._is_allowed(self.request, item))

View file

@ -2,7 +2,7 @@
import json
from unittest import TestCase
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
from wuttjamaican.conf import WuttaConfig
@ -210,6 +210,137 @@ class TestNewRequestSetUser(TestCase):
self.assertTrue(self.request.is_admin)
self.assertTrue(self.request.is_root)
def test_user_permissions(self):
model = self.app.model
auth = self.app.get_auth_handler()
event = MagicMock(request=self.request)
# anonymous user
self.assertFalse(hasattr(self.request, 'user_permissions'))
subscribers.new_request_set_user(event, db_session=self.session)
self.assertEqual(self.request.user_permissions, set())
# reset
del self.request.user_permissions
# add user to role with perms
blokes = model.Role(name="Blokes")
self.session.add(blokes)
auth.grant_permission(blokes, 'appinfo.list')
self.user.roles.append(blokes)
self.session.commit()
# authenticated user, with perms
self.request.user = self.user
subscribers.new_request_set_user(event, db_session=self.session)
self.assertEqual(self.request.user_permissions, {'appinfo.list'})
def test_has_perm(self):
model = self.app.model
auth = self.app.get_auth_handler()
event = MagicMock(request=self.request)
# anonymous user
self.assertFalse(hasattr(self.request, 'has_perm'))
subscribers.new_request_set_user(event, db_session=self.session)
self.assertFalse(self.request.has_perm('appinfo.list'))
# reset
del self.request.user_permissions
del self.request.has_perm
del self.request.has_any_perm
# add user to role with perms
blokes = model.Role(name="Blokes")
self.session.add(blokes)
auth.grant_permission(blokes, 'appinfo.list')
self.user.roles.append(blokes)
self.session.commit()
# authenticated user, with perms
self.request.user = self.user
subscribers.new_request_set_user(event, db_session=self.session)
self.assertTrue(self.request.has_perm('appinfo.list'))
# reset
del self.request.user_permissions
del self.request.has_perm
del self.request.has_any_perm
# drop user from role, no more perms
self.user.roles.remove(blokes)
self.session.commit()
subscribers.new_request_set_user(event, db_session=self.session)
self.assertFalse(self.request.has_perm('appinfo.list'))
# reset
del self.request.user_permissions
del self.request.has_perm
del self.request.has_any_perm
del self.request.is_admin
del self.request.is_root
# root user always has perms
admin = auth.get_role_administrator(self.session)
self.user.roles.append(admin)
self.session.commit()
self.request.session['is_root'] = True
subscribers.new_request_set_user(event, db_session=self.session)
self.assertTrue(self.request.has_perm('appinfo.list'))
def test_has_any_perm(self):
model = self.app.model
auth = self.app.get_auth_handler()
event = MagicMock(request=self.request)
# anonymous user
self.assertFalse(hasattr(self.request, 'has_any_perm'))
subscribers.new_request_set_user(event, db_session=self.session)
self.assertFalse(self.request.has_any_perm('appinfo.list'))
# reset
del self.request.user_permissions
del self.request.has_perm
del self.request.has_any_perm
# add user to role with perms
blokes = model.Role(name="Blokes")
self.session.add(blokes)
auth.grant_permission(blokes, 'appinfo.list')
self.user.roles.append(blokes)
self.session.commit()
# authenticated user, with perms
self.request.user = self.user
subscribers.new_request_set_user(event, db_session=self.session)
self.assertTrue(self.request.has_any_perm('appinfo.list', 'appinfo.view'))
# reset
del self.request.user_permissions
del self.request.has_perm
del self.request.has_any_perm
# drop user from role, no more perms
self.user.roles.remove(blokes)
self.session.commit()
subscribers.new_request_set_user(event, db_session=self.session)
self.assertFalse(self.request.has_any_perm('appinfo.list'))
# reset
del self.request.user_permissions
del self.request.has_perm
del self.request.has_any_perm
del self.request.is_admin
del self.request.is_root
# root user always has perms
admin = auth.get_role_administrator(self.session)
self.user.roles.append(admin)
self.session.commit()
self.request.session['is_root'] = True
subscribers.new_request_set_user(event, db_session=self.session)
self.assertTrue(self.request.has_any_perm('appinfo.list'))
class TestBeforeRender(TestCase):

View file

@ -442,6 +442,14 @@ class TestGetModelFields(TestCase):
self.config = WuttaConfig()
self.app = self.config.get_app()
def test_empty_model_class(self):
fields = util.get_model_fields(self.config)
self.assertIsNone(fields)
def test_unknown_model_class(self):
fields = util.get_model_fields(self.config, TestCase)
self.assertIsNone(fields)
def test_basic(self):
model = self.app.model
fields = util.get_model_fields(self.config, model.Setting)

View file

@ -46,20 +46,23 @@ class WebTestCase(DataTestCase):
def setup_web(self):
self.setup_db()
self.request = testing.DummyRequest()
self.request = self.make_request()
self.pyramid_config = testing.setUp(request=self.request, settings={
'wutta_config': self.config,
'mako.directories': ['wuttaweb:templates'],
# TODO: have not need this yet, but will?
# 'pyramid_deform.template_search_path': 'wuttaweb:templates/deform',
'pyramid_deform.template_search_path': 'wuttaweb:templates/deform',
})
# 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)
@ -75,6 +78,9 @@ class WebTestCase(DataTestCase):
testing.tearDown()
self.teardown_db()
def make_request(self):
return testing.DummyRequest()
class NullMenuHandler(MenuHandler):
"""

View file

@ -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')

View file

@ -1,90 +1,88 @@
# -*- coding: utf-8; -*-
from unittest import TestCase
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
from pyramid import testing
from pyramid.httpexceptions import HTTPFound, HTTPForbidden
from wuttjamaican.conf import WuttaConfig
from wuttaweb.views import auth as mod
from wuttaweb.auth import WuttaSecurityPolicy
from wuttaweb.subscribers import new_request
from tests.util import WebTestCase
class TestAuthView(TestCase):
class TestAuthView(WebTestCase):
def setUp(self):
self.config = WuttaConfig(defaults={
'wutta.db.default.url': 'sqlite://',
})
self.request = testing.DummyRequest(wutta_config=self.config, user=None)
self.pyramid_config = testing.setUp(request=self.request, settings={
'wutta_config': self.config,
})
self.app = self.config.get_app()
auth = self.app.get_auth_handler()
model = self.app.model
model.Base.metadata.create_all(bind=self.config.appdb_engine)
self.session = self.app.make_session()
self.user = model.User(username='barney')
self.session.add(self.user)
auth.set_user_password(self.user, 'testpass')
self.session.commit()
self.pyramid_config.set_security_policy(WuttaSecurityPolicy(db_session=self.session))
self.pyramid_config.include('wuttaweb.views.auth')
self.setup_web()
self.pyramid_config.include('wuttaweb.views.common')
def tearDown(self):
testing.tearDown()
def make_view(self):
return mod.AuthView(self.request)
def test_includeme(self):
self.pyramid_config.include('wuttaweb.views.auth')
def test_login(self):
view = mod.AuthView(self.request)
context = view.login()
model = self.app.model
auth = self.app.get_auth_handler()
view = self.make_view()
# until user exists, will redirect
self.assertEqual(self.session.query(model.User).count(), 0)
response = view.login(session=self.session)
self.assertEqual(response.status_code, 302)
# make a user
barney = model.User(username='barney')
auth.set_user_password(barney, 'testpass')
self.session.add(barney)
self.session.commit()
# now since user exists, form will display
context = view.login(session=self.session)
self.assertIn('form', context)
# redirect if user already logged in
self.request.user = self.user
view = mod.AuthView(self.request)
redirect = view.login(session=self.session)
self.assertIsInstance(redirect, HTTPFound)
with patch.object(self.request, 'user', new=barney):
view = self.make_view()
response = view.login(session=self.session)
self.assertEqual(response.status_code, 302)
# login fails w/ wrong password
self.request.user = None
self.request.method = 'POST'
self.request.POST = {'username': 'barney', 'password': 'WRONG'}
view = mod.AuthView(self.request)
view = self.make_view()
context = view.login(session=self.session)
self.assertIn('form', context)
# redirect if login succeeds
self.request.method = 'POST'
self.request.POST = {'username': 'barney', 'password': 'testpass'}
view = mod.AuthView(self.request)
redirect = view.login(session=self.session)
self.assertIsInstance(redirect, HTTPFound)
view = self.make_view()
response = view.login(session=self.session)
self.assertEqual(response.status_code, 302)
def test_logout(self):
view = mod.AuthView(self.request)
self.pyramid_config.add_route('login', '/login')
view = self.make_view()
self.request.session.delete = MagicMock()
redirect = view.logout()
response = view.logout()
self.request.session.delete.assert_called_once_with()
self.assertIsInstance(redirect, HTTPFound)
self.assertEqual(response.status_code, 302)
def test_change_password(self):
view = mod.AuthView(self.request)
model = self.app.model
auth = self.app.get_auth_handler()
barney = model.User(username='barney')
self.session.add(barney)
self.session.commit()
view = self.make_view()
# unauthenticated user is redirected
redirect = view.change_password()
self.assertIsInstance(redirect, HTTPFound)
# now "login" the user, and set initial password
self.request.user = self.user
auth.set_user_password(self.user, 'foo')
self.request.user = barney
auth.set_user_password(barney, 'foo')
self.session.commit()
# view should now return context w/ form
@ -105,9 +103,8 @@ class TestAuthView(TestCase):
redirect = view.change_password()
self.assertIsInstance(redirect, HTTPFound)
self.session.commit()
self.session.refresh(self.user)
self.assertFalse(auth.check_user_password(self.user, 'foo'))
self.assertTrue(auth.check_user_password(self.user, 'bar'))
self.assertFalse(auth.check_user_password(barney, 'foo'))
self.assertTrue(auth.check_user_password(barney, 'bar'))
# at this point 'foo' is the password, now let's submit some
# invalid forms and make sure we get back a context w/ form
@ -147,8 +144,6 @@ class TestAuthView(TestCase):
self.assertEqual(dform['new_password'].errormsg, "New password must be different from old password.")
def test_become_root(self):
event = MagicMock(request=self.request)
new_request(event) # add request.get_referrer()
view = mod.AuthView(self.request)
# GET not allowed
@ -168,8 +163,6 @@ class TestAuthView(TestCase):
self.assertTrue(self.request.session['is_root'])
def test_stop_root(self):
event = MagicMock(request=self.request)
new_request(event) # add request.get_referrer()
view = mod.AuthView(self.request)
# GET not allowed

View file

@ -1,27 +1,95 @@
# -*- coding: utf-8; -*-
from unittest import TestCase
from pyramid import testing
from wuttjamaican.conf import WuttaConfig
from wuttaweb.views import common
from wuttaweb.views import common as mod
from tests.util import WebTestCase
class TestCommonView(TestCase):
class TestCommonView(WebTestCase):
def setUp(self):
self.config = WuttaConfig()
self.app = self.config.get_app()
self.request = testing.DummyRequest()
self.request.wutta_config = self.config
self.pyramid_config = testing.setUp(request=self.request)
def make_view(self):
return mod.CommonView(self.request)
def test_includeme(self):
self.pyramid_config.include('wuttaweb.views.common')
def tearDown(self):
testing.tearDown()
def test_forbidden_view(self):
view = self.make_view()
context = view.forbidden_view()
self.assertEqual(context['index_title'], self.app.get_title())
def test_notfound_view(self):
view = self.make_view()
context = view.notfound_view()
self.assertEqual(context['index_title'], self.app.get_title())
def test_home(self):
view = common.CommonView(self.request)
context = view.home()
self.pyramid_config.add_route('setup', '/setup')
model = self.app.model
view = self.make_view()
# if no users then home page will redirect
response = view.home(session=self.session)
self.assertEqual(response.status_code, 302)
# so add a user
user = model.User(username='foo')
self.session.add(user)
self.session.commit()
# now we see the home page
context = view.home(session=self.session)
self.assertEqual(context['index_title'], self.app.get_title())
def test_setup(self):
self.pyramid_config.add_route('home', '/')
self.pyramid_config.add_route('login', '/login')
model = self.app.model
auth = self.app.get_auth_handler()
view = self.make_view()
# at first, can see the setup page
self.assertEqual(self.session.query(model.User).count(), 0)
context = view.setup(session=self.session)
self.assertEqual(context['index_title'], self.app.get_title())
# so add a user
user = model.User(username='foo')
self.session.add(user)
self.session.commit()
# once user exists it will always redirect
response = view.setup(session=self.session)
self.assertEqual(response.status_code, 302)
# delete that user
self.session.delete(user)
self.session.commit()
# so we can see the setup page again
context = view.setup(session=self.session)
self.assertEqual(context['index_title'], self.app.get_title())
# and finally, post data to create admin user
self.request.method = 'POST'
self.request.POST = {
'username': 'barney',
'__start__': 'password:mapping',
'password': 'testpass',
'password-confirm': 'testpass',
'__end__': 'password:mapping',
'first_name': "Barney",
'last_name': "Rubble",
}
response = view.setup(session=self.session)
# nb. redirects on success
self.assertEqual(response.status_code, 302)
barney = self.session.query(model.User).one()
self.assertEqual(barney.username, 'barney')
self.assertTrue(auth.check_user_password(barney, 'testpass'))
admin = auth.get_role_administrator(self.session)
self.assertIn(admin, barney.roles)
self.assertIsNotNone(barney.person)
person = barney.person
self.assertEqual(person.first_name, "Barney")
self.assertEqual(person.last_name, "Rubble")
self.assertEqual(person.full_name, "Barney Rubble")

View file

@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch
from pyramid import testing
from pyramid.response import Response
from pyramid.httpexceptions import HTTPFound, HTTPNotFound
from pyramid.httpexceptions import HTTPNotFound
from wuttjamaican.conf import WuttaConfig
from wuttaweb.views import master
@ -16,12 +16,14 @@ from tests.util import WebTestCase
class TestMasterView(WebTestCase):
def make_view(self):
return master.MasterView(self.request)
def test_defaults(self):
with patch.multiple(master.MasterView, create=True,
model_name='Widget',
viewable=False,
editable=False,
deletable=False):
model_key='uuid',
configurable=True):
master.MasterView.defaults(self.pyramid_config)
##############################
@ -159,6 +161,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)
@ -311,7 +331,68 @@ class TestMasterView(WebTestCase):
# support methods
##############################
def test_has_perm(self):
model = self.app.model
auth = self.app.get_auth_handler()
with patch.multiple(master.MasterView, create=True,
model_name='Setting'):
view = self.make_view()
# anonymous user
self.assertFalse(view.has_perm('list'))
self.assertFalse(self.request.has_perm('list'))
# reset
del self.request.user_permissions
# make user with perms
barney = model.User(username='barney')
self.session.add(barney)
blokes = model.Role(name="Blokes")
self.session.add(blokes)
barney.roles.append(blokes)
auth.grant_permission(blokes, 'settings.list')
self.session.commit()
# this user has perms
self.request.user = barney
self.assertTrue(view.has_perm('list'))
self.assertTrue(self.request.has_perm('settings.list'))
def test_has_any_perm(self):
model = self.app.model
auth = self.app.get_auth_handler()
with patch.multiple(master.MasterView, create=True,
model_name='Setting'):
view = self.make_view()
# anonymous user
self.assertFalse(view.has_any_perm('list', 'view'))
self.assertFalse(self.request.has_any_perm('settings.list', 'settings.view'))
# reset
del self.request.user_permissions
# make user with perms
barney = model.User(username='barney')
self.session.add(barney)
blokes = model.Role(name="Blokes")
self.session.add(blokes)
barney.roles.append(blokes)
auth.grant_permission(blokes, 'settings.view')
self.session.commit()
# this user has perms
self.request.user = barney
self.assertTrue(view.has_any_perm('list', 'view'))
self.assertTrue(self.request.has_any_perm('settings.list', 'settings.view'))
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/')
@ -364,10 +445,39 @@ class TestMasterView(WebTestCase):
grid = view.make_model_grid(session=self.session)
self.assertIs(grid.model_class, model.Setting)
# no actions by default
with patch.multiple(master.MasterView, create=True,
model_class=model.Setting):
grid = view.make_model_grid(session=self.session)
self.assertEqual(grid.actions, [])
# now let's test some more actions logic
with patch.multiple(master.MasterView, create=True,
model_class=model.Setting,
viewable=True,
editable=True,
deletable=True):
# should have 3 actions now, but for lack of perms
grid = view.make_model_grid(session=self.session)
self.assertEqual(len(grid.actions), 0)
# but root user has perms, so gets 3 actions
with patch.object(self.request, 'is_root', new=True):
grid = view.make_model_grid(session=self.session)
self.assertEqual(len(grid.actions), 3)
def test_get_grid_data(self):
model = self.app.model
self.app.save_setting(self.session, 'foo', 'bar')
self.session.commit()
setting = self.session.query(model.Setting).one()
view = self.make_view()
# empty by default
self.assertFalse(hasattr(master.MasterView, 'model_class'))
data = view.get_grid_data(session=self.session)
self.assertEqual(data, [])
# basic logic with Setting model
with patch.multiple(master.MasterView, create=True,
@ -375,16 +485,7 @@ class TestMasterView(WebTestCase):
view = master.MasterView(self.request)
data = view.get_grid_data(session=self.session)
self.assertEqual(len(data), 1)
self.assertEqual(data[0], {'name': 'foo', 'value': 'bar'})
# error if model not known
view = master.MasterView(self.request)
self.assertFalse(hasattr(master.MasterView, 'model_class'))
def get_query(session=None):
session = session or self.session
return session.query(model.Setting)
with patch.object(view, 'get_query', new=get_query):
self.assertRaises(ValueError, view.get_grid_data, session=self.session)
self.assertIs(data[0], setting)
def test_configure_grid(self):
model = self.app.model
@ -399,6 +500,28 @@ class TestMasterView(WebTestCase):
view.configure_grid(grid)
self.assertNotIn('uuid', grid.columns)
def test_grid_render_notes(self):
model = self.app.model
view = self.make_view()
# null
text = None
role = model.Role(name="Foo", notes=text)
value = view.grid_render_notes(role, 'notes', text)
self.assertIsNone(value)
# short string
text = "hello world"
role = model.Role(name="Foo", notes=text)
value = view.grid_render_notes(role, 'notes', text)
self.assertEqual(value, text)
# long string
text = "hello world " * 20
role = model.Role(name="Foo", notes=text)
value = view.grid_render_notes(role, 'notes', text)
self.assertIn('<span ', value)
def test_get_instance(self):
model = self.app.model
self.app.save_setting(self.session, 'foo', 'bar')
@ -425,6 +548,57 @@ class TestMasterView(WebTestCase):
self.request.matchdict = {'name': 'blarg'}
self.assertRaises(HTTPNotFound, view.get_instance, session=self.session)
def test_get_action_url_view(self):
model = self.app.model
setting = model.Setting(name='foo', value='bar')
self.session.add(setting)
self.session.commit()
with patch.multiple(master.MasterView, create=True,
model_class=model.Setting):
master.MasterView.defaults(self.pyramid_config)
view = self.make_view()
url = view.get_action_url_view(setting, 0)
self.assertEqual(url, self.request.route_url('settings.view', name='foo'))
def test_get_action_url_edit(self):
model = self.app.model
setting = model.Setting(name='foo', value='bar')
self.session.add(setting)
self.session.commit()
with patch.multiple(master.MasterView, create=True,
model_class=model.Setting):
master.MasterView.defaults(self.pyramid_config)
view = self.make_view()
# typical
url = view.get_action_url_edit(setting, 0)
self.assertEqual(url, self.request.route_url('settings.edit', name='foo'))
# but null if instance not editable
with patch.object(view, 'is_editable', return_value=False):
url = view.get_action_url_edit(setting, 0)
self.assertIsNone(url)
def test_get_action_url_delete(self):
model = self.app.model
setting = model.Setting(name='foo', value='bar')
self.session.add(setting)
self.session.commit()
with patch.multiple(master.MasterView, create=True,
model_class=model.Setting):
master.MasterView.defaults(self.pyramid_config)
view = self.make_view()
# typical
url = view.get_action_url_delete(setting, 0)
self.assertEqual(url, self.request.route_url('settings.delete', name='foo'))
# but null if instance not deletable
with patch.object(view, 'is_deletable', return_value=False):
url = view.get_action_url_delete(setting, 0)
self.assertIsNone(url)
def test_make_model_form(self):
model = self.app.model
@ -514,28 +688,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)
@ -579,6 +763,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'}
@ -586,6 +775,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)
@ -593,6 +783,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()
@ -609,6 +804,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):
@ -650,6 +846,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()
@ -667,6 +868,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):
@ -680,15 +882,24 @@ class TestMasterView(WebTestCase):
def delete_instance(setting):
self.app.delete_setting(self.session, setting['name'])
# post request to save settings
self.request.method = 'POST'
self.request.POST = {}
with patch.object(view, 'delete_instance', new=delete_instance):
# enforces "instance not deletable" rules
with patch.object(view, 'is_deletable', return_value=False):
response = view.delete()
# nb. should get redirect back to view page
self.assertEqual(response.status_code, 302)
# setting remains in DB
self.assertEqual(self.session.query(model.Setting).count(), 1)
# post request to delete setting
response = view.delete()
# nb. should get redirect back to view page
self.assertEqual(response.status_code, 302)
# setting should be gone from DB
self.assertEqual(self.session.query(model.Setting).count(), 0)
# nb. should get redirect back to view page
self.assertEqual(response.status_code, 302)
# setting should be gone from DB
self.assertEqual(self.session.query(model.Setting).count(), 0)
def test_delete_instance(self):
model = self.app.model
@ -705,6 +916,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
@ -725,10 +938,11 @@ 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
response = view.configure()
response = view.configure(session=self.session)
self.assertIsInstance(response, Response)
# post request to save settings
@ -738,9 +952,9 @@ class TestMasterView(WebTestCase):
'wutta.foo': 'bar',
'wutta.flag': 'true',
}
response = view.configure()
response = view.configure(session=self.session)
# nb. should get redirect back to configure page
self.assertIsInstance(response, HTTPFound)
self.assertEqual(response.status_code, 302)
# should now have 5 settings
count = self.session.query(model.Setting).count()
@ -756,9 +970,9 @@ class TestMasterView(WebTestCase):
# post request to remove settings
self.request.method = 'POST'
self.request.POST = {'remove_settings': '1'}
response = view.configure()
response = view.configure(session=self.session)
# nb. should get redirect back to configure page
self.assertIsInstance(response, HTTPFound)
self.assertEqual(response.status_code, 302)
# should now have 0 settings
count = self.session.query(model.Setting).count()

View file

@ -15,6 +15,9 @@ class TestPersonView(WebTestCase):
def make_view(self):
return people.PersonView(self.request)
def test_includeme(self):
self.pyramid_config.include('wuttaweb.views.people')
def test_get_query(self):
view = self.make_view()
query = view.get_query(session=self.session)
@ -37,3 +40,34 @@ class TestPersonView(WebTestCase):
view.configure_form(form)
self.assertTrue(form.required_fields)
self.assertFalse(form.required_fields['middle_name'])
def test_view_profile(self):
self.pyramid_config.include('wuttaweb.views.common')
self.pyramid_config.include('wuttaweb.views.auth')
self.pyramid_config.add_route('people', '/people/')
model = self.app.model
person = model.Person(full_name="Barney Rubble")
self.session.add(person)
self.session.commit()
# sanity check
view = self.make_view()
self.request.matchdict = {'uuid': person.uuid}
response = view.view_profile(session=self.session)
self.assertEqual(response.status_code, 200)
def test_make_user(self):
self.pyramid_config.include('wuttaweb.views.common')
model = self.app.model
person = model.Person(full_name="Barney Rubble")
self.session.add(person)
self.session.commit()
# sanity check
view = self.make_view()
self.request.matchdict = {'uuid': person.uuid}
response = view.make_user()
# nb. this always redirects for now
self.assertEqual(response.status_code, 302)

View file

@ -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)
@ -28,10 +31,63 @@ class TestRoleView(WebTestCase):
view.configure_grid(grid)
self.assertTrue(grid.is_linked('name'))
def test_is_editable(self):
model = self.app.model
auth = self.app.get_auth_handler()
blokes = model.Role(name="Blokes")
self.session.add(blokes)
self.session.commit()
view = self.make_view()
admin = auth.get_role_administrator(self.session)
authed = auth.get_role_authenticated(self.session)
anon = auth.get_role_anonymous(self.session)
# editable by default
self.assertTrue(view.is_editable(blokes))
# built-in roles not editable by default
self.assertFalse(view.is_editable(admin))
self.assertFalse(view.is_editable(authed))
self.assertFalse(view.is_editable(anon))
# reset
del self.request.user_permissions
barney = model.User(username='barney')
self.session.add(barney)
barney.roles.append(blokes)
auth.grant_permission(blokes, 'roles.edit_builtin')
self.session.commit()
# user with perms can edit *some* built-in
self.request.user = barney
self.assertTrue(view.is_editable(authed))
self.assertTrue(view.is_editable(anon))
# nb. not this one yet
self.assertFalse(view.is_editable(admin))
def test_is_deletable(self):
model = self.app.model
auth = self.app.get_auth_handler()
blokes = model.Role(name="Blokes")
self.session.add(blokes)
self.session.commit()
view = self.make_view()
# deletable by default
self.assertTrue(view.is_deletable(blokes))
# built-in roles not deletable
self.assertFalse(view.is_deletable(auth.get_role_administrator(self.session)))
self.assertFalse(view.is_deletable(auth.get_role_authenticated(self.session)))
self.assertFalse(view.is_deletable(auth.get_role_anonymous(self.session)))
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 +111,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'])

View file

@ -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)

View file

@ -15,6 +15,9 @@ class TestUserView(WebTestCase):
def make_view(self):
return mod.UserView(self.request)
def test_includeme(self):
self.pyramid_config.include('wuttaweb.views.users')
def test_get_query(self):
view = self.make_view()
query = view.get_query(session=self.session)
@ -30,11 +33,29 @@ class TestUserView(WebTestCase):
def test_configure_form(self):
model = self.app.model
barney = model.User(username='barney')
self.session.add(barney)
self.session.commit()
view = self.make_view()
form = view.make_form(model_class=model.Person)
self.assertIsNone(form.is_required('person'))
view.configure_form(form)
self.assertFalse(form.is_required('person'))
# person is *not* required
with patch.object(view, 'creating', new=True):
form = view.make_form(model_class=model.User)
self.assertIsNone(form.is_required('person'))
view.configure_form(form)
self.assertFalse(form.is_required('person'))
# password removed (always, for now)
with patch.object(view, 'viewing', new=True):
form = view.make_form(model_instance=barney)
self.assertIn('password', form)
view.configure_form(form)
self.assertNotIn('password', form)
with patch.object(view, 'editing', new=True):
form = view.make_form(model_instance=barney)
self.assertIn('password', form)
view.configure_form(form)
self.assertNotIn('password', form)
def test_unique_username(self):
model = self.app.model
@ -55,3 +76,113 @@ class TestUserView(WebTestCase):
self.request.matchdict = {'uuid': user.uuid}
node = colander.SchemaNode(colander.String(), name='username')
self.assertIsNone(view.unique_username(node, 'foo'))
def test_objectify(self):
model = self.app.model
auth = self.app.get_auth_handler()
blokes = model.Role(name="Blokes")
self.session.add(blokes)
others = model.Role(name="Others")
self.session.add(others)
barney = model.User(username='barney')
auth.set_user_password(barney, 'testpass')
barney.roles.append(blokes)
self.session.add(barney)
self.session.commit()
view = self.make_view()
view.editing = True
self.request.matchdict = {'uuid': barney.uuid}
# sanity check, user is just in 'blokes' role
self.session.refresh(barney)
self.assertEqual(len(barney.roles), 1)
self.assertEqual(barney.roles[0].name, "Blokes")
# form can update user password
self.assertTrue(auth.check_user_password(barney, 'testpass'))
form = view.make_model_form(model_instance=barney)
form.validated = {'username': 'barney', 'set_password': 'testpass2'}
user = view.objectify(form, session=self.session)
self.assertIs(user, barney)
self.assertTrue(auth.check_user_password(barney, 'testpass2'))
# form can update user roles
form = view.make_model_form(model_instance=barney)
form.validated = {'username': 'barney', 'roles': {others.uuid}}
user = view.objectify(form, session=self.session)
self.assertIs(user, barney)
self.assertEqual(len(user.roles), 1)
self.assertEqual(user.roles[0].name, "Others")
def test_update_roles(self):
model = self.app.model
auth = self.app.get_auth_handler()
admin = auth.get_role_administrator(self.session)
authed = auth.get_role_authenticated(self.session)
anon = auth.get_role_anonymous(self.session)
blokes = model.Role(name="Blokes")
self.session.add(blokes)
others = model.Role(name="Others")
self.session.add(others)
barney = model.User(username='barney')
barney.roles.append(blokes)
self.session.add(barney)
self.session.commit()
view = self.make_view()
view.editing = True
self.request.matchdict = {'uuid': barney.uuid}
# no error if data is missing roles
form = view.make_model_form(model_instance=barney)
form.validated = {'username': 'barneyx'}
user = view.objectify(form, session=self.session)
self.assertIs(user, barney)
self.assertEqual(barney.username, 'barneyx')
# sanity check, user is just in 'blokes' role
self.session.refresh(barney)
self.assertEqual(len(barney.roles), 1)
self.assertEqual(barney.roles[0].name, "Blokes")
# let's test a bunch at once to ensure:
# - user roles are updated
# - authed / anon roles are not added
# - admin role not added if current user is not root
form = view.make_model_form(model_instance=barney)
form.validated = {'username': 'barney',
'roles': {admin.uuid, authed.uuid, anon.uuid, others.uuid}}
user = view.objectify(form, session=self.session)
self.assertIs(user, barney)
self.assertEqual(len(user.roles), 1)
self.assertEqual(user.roles[0].name, "Others")
# let's test a bunch at once to ensure:
# - user roles are updated
# - admin role is added if current user is root
self.request.is_root = True
form = view.make_model_form(model_instance=barney)
form.validated = {'username': 'barney',
'roles': {admin.uuid, blokes.uuid, others.uuid}}
user = view.objectify(form, session=self.session)
self.assertIs(user, barney)
self.assertEqual(len(user.roles), 3)
role_uuids = set([role.uuid for role in user.roles])
self.assertEqual(role_uuids, {admin.uuid, blokes.uuid, others.uuid})
# admin role not removed if current user is not root
self.request.is_root = False
form = view.make_model_form(model_instance=barney)
form.validated = {'username': 'barney',
'roles': {blokes.uuid, others.uuid}}
user = view.objectify(form, session=self.session)
self.assertIs(user, barney)
self.assertEqual(len(user.roles), 3)
# admin role is removed if current user is root
self.request.is_root = True
form = view.make_model_form(model_instance=barney)
form.validated = {'username': 'barney',
'roles': {blokes.uuid, others.uuid}}
user = view.objectify(form, session=self.session)
self.assertIs(user, barney)
self.assertEqual(len(user.roles), 2)