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/) 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). 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) ## v0.6.0 (2024-08-13)
### Feat ### Feat

View file

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

View file

@ -135,6 +135,12 @@ def make_pyramid_config(settings):
pyramid_config.include('pyramid_mako') pyramid_config.include('pyramid_mako')
pyramid_config.include('pyramid_tm') pyramid_config.include('pyramid_tm')
# add some permissions magic
pyramid_config.add_directive('add_wutta_permission_group',
'wuttaweb.auth.add_permission_group')
pyramid_config.add_directive('add_wutta_permission',
'wuttaweb.auth.add_permission')
return pyramid_config return pyramid_config

View file

@ -148,3 +148,93 @@ class WuttaSecurityPolicy:
auth = app.get_auth_handler() auth = app.get_auth_handler()
user = self.identity(request) user = self.identity(request)
return auth.has_permission(self.db_session, user, permission) return auth.has_permission(self.db_session, user, permission)
def add_permission_group(pyramid_config, key, label=None, overwrite=True):
"""
Pyramid directive to add a "permission group" to the app's
awareness.
The app must be made aware of all permissions, so they are exposed
when editing a
:class:`~wuttjamaican:wuttjamaican.db.model.auth.Role`. The logic
for discovering permissions is in
:meth:`~wuttaweb.views.roles.RoleView.get_available_permissions()`.
This is usually called from within a master view's
:meth:`~wuttaweb.views.master.MasterView.defaults()` to establish
the permission group which applies to the view model.
A simple example of usage::
pyramid_config.add_permission_group('widgets', label="Widgets")
:param key: Unique key for the permission group. In the context
of a master view, this will be the same as
:attr:`~wuttaweb.views.master.MasterView.permission_prefix`.
:param label: Optional label for the permission group. If not
specified, it is derived from ``key``.
:param overwrite: If the permission group was already established,
this flag controls whether the group's label should be
overwritten (with ``label``).
See also :func:`add_permission()`.
"""
config = pyramid_config.get_settings()['wutta_config']
app = config.get_app()
def action():
perms = pyramid_config.get_settings().get('wutta_permissions', {})
if overwrite or key not in perms:
group = perms.setdefault(key, {'key': key})
group['label'] = label or app.make_title(key)
pyramid_config.add_settings({'wutta_permissions': perms})
pyramid_config.action(None, action)
def add_permission(pyramid_config, groupkey, key, label=None):
"""
Pyramid directive to add a single "permission" to the app's
awareness.
The app must be made aware of all permissions, so they are exposed
when editing a
:class:`~wuttjamaican:wuttjamaican.db.model.auth.Role`. The logic
for discovering permissions is in
:meth:`~wuttaweb.views.roles.RoleView.get_available_permissions()`.
This is usually called from within a master view's
:meth:`~wuttaweb.views.master.MasterView.defaults()` to establish
"known" permissions based on master view feature flags
(:attr:`~wuttaweb.views.master.MasterView.viewable`,
:attr:`~wuttaweb.views.master.MasterView.editable`, etc.).
A simple example of usage::
pyramid_config.add_permission('widgets', 'widgets.polish',
label="Polish all the widgets")
:param key: Unique key for the permission group. In the context
of a master view, this will be the same as
:attr:`~wuttaweb.views.master.MasterView.permission_prefix`.
:param key: Unique key for the permission. This should be the
"complete" permission name which includes the permission
prefix.
:param label: Optional label for the permission. If not
specified, it is derived from ``key``.
See also :func:`add_permission_group()`.
"""
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()`. 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 .. attribute:: readonly
Boolean indicating the form does not allow submit. In practice Boolean indicating the form does not allow submit. In practice
@ -248,6 +255,7 @@ class Form:
nodes={}, nodes={},
widgets={}, widgets={},
validators={}, validators={},
defaults={},
readonly=False, readonly=False,
readonly_fields=[], readonly_fields=[],
required_fields={}, required_fields={},
@ -271,6 +279,7 @@ class Form:
self.nodes = nodes or {} self.nodes = nodes or {}
self.widgets = widgets or {} self.widgets = widgets or {}
self.validators = validators or {} self.validators = validators or {}
self.defaults = defaults or {}
self.readonly = readonly self.readonly = readonly
self.readonly_fields = set(readonly_fields or []) self.readonly_fields = set(readonly_fields or [])
self.required_fields = required_fields or {} self.required_fields = required_fields or {}
@ -375,6 +384,23 @@ class Form:
""" """
self.fields = FieldList(fields) 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): def remove(self, *keys):
""" """
Remove some fields(s) from the form. Remove some fields(s) from the form.
@ -471,6 +497,18 @@ class Form:
if self.schema and key in self.schema: if self.schema and key in self.schema:
self.schema[key].validator = validator 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): def set_readonly(self, key, readonly=True):
""" """
Enable or disable the "readonly" flag for a given field. Enable or disable the "readonly" flag for a given field.
@ -624,29 +662,22 @@ class Form:
if self.model_class: if self.model_class:
# first define full list of 'includes' - final schema # collect list of field names and/or nodes
# should contain all of these fields includes = []
includes = list(fields) for key in fields:
# determine which we want ColanderAlchemy to handle
auto_includes = []
for key in includes:
# skip if we already have a node defined
if key in self.nodes: if key in self.nodes:
continue includes.append(self.nodes[key])
else:
# we want the magic for this field includes.append(key)
auto_includes.append(key)
# make initial schema with ColanderAlchemy magic # make initial schema with ColanderAlchemy magic
schema = SQLAlchemySchemaNode(self.model_class, schema = SQLAlchemySchemaNode(self.model_class,
includes=auto_includes) includes=includes)
# now fill in the blanks for non-magic fields # fill in the blanks if anything got missed
for key in includes: for key in fields:
if key not in auto_includes: if key not in schema:
node = self.nodes[key] node = colander.SchemaNode(colander.String(), name=key)
schema.add(node) schema.add(node)
else: else:
@ -685,6 +716,11 @@ class Form:
elif key in schema: # field-level elif key in schema: # field-level
schema[key].validator = validator 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 # apply required flags
for key, required in self.required_fields.items(): for key, required in self.required_fields.items():
if key in schema: if key in schema:
@ -775,7 +811,12 @@ class Form:
output = render(template, context) output = render(template, context)
return HTML.literal(output) 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 Render the given field completely, i.e. ``<b-field>`` wrapper
with label and containing a widget. with label and containing a widget.
@ -791,6 +832,12 @@ class Form:
message="something went wrong!"> message="something went wrong!">
<!-- widget element(s) --> <!-- widget element(s) -->
</b-field> </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 # readonly comes from: caller, field flag, or form flag
if readonly is None: if readonly is None:
@ -903,6 +950,20 @@ class Form:
return model_data 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): def validate(self):
""" """
Try to validate the form, using data from the :attr:`request`. 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): def sort_query(self, query):
""" """ """ """
return query.order_by(self.model_class.full_name) 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.Widget` (base class)
* :class:`deform:deform.widget.TextInputWidget` * :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.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 webhelpers2.html import HTML
from wuttaweb.db import Session
class ObjectRefWidget(SelectWidget): class ObjectRefWidget(SelectWidget):
""" """
@ -48,6 +57,18 @@ class ObjectRefWidget(SelectWidget):
the form schema; via the form schema; via
:meth:`~wuttaweb.forms.schema.ObjectRef.widget_maker()`. :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 .. attribute:: model_instance
Reference to the model record instance, i.e. the "far side" of 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 when the :class:`~wuttaweb.forms.schema.ObjectRef` type
instance (associated with the node) is serialized. instance (associated with the node) is serialized.
""" """
readonly_template = 'readonly/objectref'
def __init__(self, request, *args, **kwargs): def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.request = request 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): def serialize(self, field, cstruct, **kw):
""" """ """
Serialize the widget. # special logic when field is editable
In readonly mode, returns a ``<span>`` tag around the
:attr:`model_instance` rendered as string.
Otherwise renders via the ``deform/select`` template.
"""
readonly = kw.get('readonly', self.readonly) readonly = kw.get('readonly', self.readonly)
if readonly: if not readonly:
obj = field.schema.model_instance
return HTML.tag('span', c=str(obj or '')) # 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) return super().serialize(field, cstruct, **kw)

View file

@ -24,6 +24,7 @@
Base grid classes Base grid classes
""" """
import functools
import json import json
import logging import logging
@ -81,6 +82,12 @@ class Grid:
model records) or else an object capable of producing such a model records) or else an object capable of producing such a
list, e.g. SQLAlchemy query. list, e.g. SQLAlchemy query.
.. attribute:: renderers
Dict of column (cell) value renderer overrides.
See also :meth:`set_renderer()`.
.. attribute:: actions .. attribute:: actions
List of :class:`GridAction` instances represenging action links List of :class:`GridAction` instances represenging action links
@ -106,6 +113,7 @@ class Grid:
key=None, key=None,
columns=None, columns=None,
data=None, data=None,
renderers={},
actions=[], actions=[],
linked_columns=[], linked_columns=[],
vue_tagname='wutta-grid', vue_tagname='wutta-grid',
@ -114,6 +122,7 @@ class Grid:
self.model_class = model_class self.model_class = model_class
self.key = key self.key = key
self.data = data self.data = data
self.renderers = renderers or {}
self.actions = actions or [] self.actions = actions or []
self.linked_columns = linked_columns or [] self.linked_columns = linked_columns or []
self.vue_tagname = vue_tagname self.vue_tagname = vue_tagname
@ -177,6 +186,23 @@ class Grid:
""" """
self.columns = FieldList(columns) 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): def remove(self, *keys):
""" """
Remove some column(s) from the grid. Remove some column(s) from the grid.
@ -194,6 +220,47 @@ class Grid:
if key in self.columns: if key in self.columns:
self.columns.remove(key) 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): def set_link(self, key, link=True):
""" """
Explicitly enable or disable auto-link behavior for a given 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 # we have action(s), so add URL(s) for each record in data
data = [] data = []
for i, record in enumerate(original_data): for i, record in enumerate(original_data):
original_record = record
record = dict(record)
# convert data if needed, for json compat # convert data if needed, for json compat
record = make_json_safe(record, record = make_json_safe(record,
# TODO: is this a good idea? # TODO: is this a good idea?
warn=False) 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 # add action urls to each record
for action in self.actions: for action in self.actions:
url = action.get_url(record, i)
key = f'_action_url_{action.key}' 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) data.append(record)
@ -475,7 +552,7 @@ class GridAction:
See also :meth:`render_icon_and_label()`. See also :meth:`render_icon_and_label()`.
""" """
if self.request.use_oruga: 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}') return HTML.tag('i', class_=f'fas fa-{self.icon}')

View file

@ -272,9 +272,8 @@ class MenuHandler(GenericHandler):
current user. current user.
""" """
perm = item.get('perm') perm = item.get('perm')
# TODO if perm:
# if perm: return request.has_perm(perm)
# return request.has_perm(perm)
return True return True
def _mark_allowed(self, request, menus): 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_config.add_subscriber('wuttaweb.subscribers.new_request_set_user',
'pyramid.events.NewRequest') '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: This will add to the request object:
.. attribute:: request.user .. attribute:: request.user
@ -158,19 +168,36 @@ def new_request_set_user(
privileges. This is only possible if :attr:`request.is_admin` privileges. This is only possible if :attr:`request.is_admin`
is also true. is also true.
You may wish to "supplement" this hook by registering your own .. attribute:: request.user_permissions
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 The ``set`` of permission names which are granted to the
from database, instead of :func:`default_user_getter()`. 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 request = event.request
config = request.registry.settings['wutta_config'] config = request.registry.settings['wutta_config']
app = config.get_app() app = config.get_app()
auth = app.get_auth_handler()
# request.user # request.user
if db_session: if db_session:
@ -179,7 +206,6 @@ def new_request_set_user(
# request.is_admin # request.is_admin
def is_admin(request): def is_admin(request):
auth = app.get_auth_handler()
return auth.user_is_admin(request.user) return auth.user_is_admin(request.user)
request.set_property(is_admin, reify=True) request.set_property(is_admin, reify=True)
@ -191,6 +217,29 @@ def new_request_set_user(
return False return False
request.set_property(is_root, reify=True) 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): def before_render(event):
""" """

View file

@ -222,7 +222,7 @@
% else: % else:
<h1 class="title">${index_title}</h1> <h1 class="title">${index_title}</h1>
% endif % 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" <wutta-button once type="is-primary"
tag="a" href="${url(f'{route_prefix}.create')}" tag="a" href="${url(f'{route_prefix}.create')}"
icon-left="plus" icon-left="plus"
@ -235,8 +235,7 @@
<div class="level-right"> <div class="level-right">
## TODO % if master and master.configurable and not master.configuring and master.has_perm('configure'):
% if master and master.configurable and not master.configuring:
<div class="level-item"> <div class="level-item">
<wutta-button once type="is-primary" <wutta-button once type="is-primary"
tag="a" href="${url(f'{route_prefix}.configure')}" tag="a" href="${url(f'{route_prefix}.configure')}"
@ -348,7 +347,7 @@
% elif request.is_admin: % elif request.is_admin:
${h.form(url('become_root'), ref='startBeingRootForm')} ${h.form(url('become_root'), ref='startBeingRootForm')}
${h.csrf_token(request)} ${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()" <a @click="startBeingRoot()"
class="navbar-item has-background-danger has-text-white"> class="navbar-item has-background-danger has-text-white">
Become root Become root
@ -378,19 +377,23 @@
tag="a" href="${master.get_action_url('edit', instance)}" tag="a" href="${master.get_action_url('edit', instance)}"
icon-left="edit" icon-left="edit"
label="Edit This" /> label="Edit This" />
<wutta-button once type="is-danger" % if instance_deletable:
tag="a" href="${master.get_action_url('delete', instance)}" <wutta-button once type="is-danger"
icon-left="trash" tag="a" href="${master.get_action_url('delete', instance)}"
label="Delete This" /> icon-left="trash"
label="Delete This" />
% endif
% elif master.editing: % elif master.editing:
<wutta-button once <wutta-button once
tag="a" href="${master.get_action_url('view', instance)}" tag="a" href="${master.get_action_url('view', instance)}"
icon-left="eye" icon-left="eye"
label="View This" /> label="View This" />
<wutta-button once type="is-danger" % if instance_deletable:
tag="a" href="${master.get_action_url('delete', instance)}" <wutta-button once type="is-danger"
icon-left="trash" tag="a" href="${master.get_action_url('delete', instance)}"
label="Delete This" /> icon-left="trash"
label="Delete This" />
% endif
% elif master.deleting: % elif master.deleting:
<wutta-button once <wutta-button once
tag="a" href="${master.get_action_url('view', instance)}" 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()"> <%def name="render_this_page_template()">
${parent.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>
<%def name="finalize_this_page_vars()"> <%def name="finalize_this_page_vars()">
${parent.finalize_this_page_vars()} ${parent.finalize_this_page_vars()}
<script> % if form is not Undefined:
${form.vue_component}.data = function() { return ${form.vue_component}Data } <script>
Vue.component('${form.vue_tagname}', ${form.vue_component}) ${form.vue_component}.data = function() { return ${form.vue_component}Data }
</script> Vue.component('${form.vue_tagname}', ${form.vue_component})
</script>
% endif
</%def> </%def>

View file

@ -24,7 +24,8 @@
label="Actions" label="Actions"
v-slot="props"> v-slot="props">
% for action in grid.actions: % 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}"> class="${action.link_class}">
${action.render_icon_and_label()} ${action.render_icon_and_label()}
</a> </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 json
import logging import logging
import sqlalchemy as sa
import colander import colander
from webhelpers2.html import HTML, tags 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 that to determine the field listing if applicable. Otherwise this
returns ``None``. returns ``None``.
""" """
if model_class: if not model_class:
import sqlalchemy as sa return
app = config.get_app()
model = app.model app = config.get_app()
if model_class and issubclass(model_class, model.Base): model = app.model
mapper = sa.inspect(model_class) if not issubclass(model_class, model.Base):
fields = list([prop.key for prop in mapper.iterate_properties]) return
return fields
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): def make_json_safe(value, key=None, warn=True):

View file

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

View file

@ -24,7 +24,11 @@
Common Views Common Views
""" """
import colander
from wuttaweb.views import View from wuttaweb.views import View
from wuttaweb.forms import widgets
from wuttaweb.db import Session
class CommonView(View): class CommonView(View):
@ -32,7 +36,7 @@ class CommonView(View):
Common views shared by all apps. Common views shared by all apps.
""" """
def home(self): def home(self, session=None):
""" """
Home page view. Home page view.
@ -40,12 +44,144 @@ class CommonView(View):
This is normally the view shown when a user navigates to the This is normally the view shown when a user navigates to the
root URL for the web app. 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 { return {
'index_title': self.app.get_title(), '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 @classmethod
def defaults(cls, config): def defaults(cls, config):
cls._defaults(config) cls._defaults(config)
@ -53,15 +189,28 @@ class CommonView(View):
@classmethod @classmethod
def _defaults(cls, config): def _defaults(cls, config):
# auto-correct URLs which require trailing slash
config.add_notfound_view(cls, attr='notfound', append_slash=True)
# home page # home page
config.add_route('home', '/') config.add_route('home', '/')
config.add_view(cls, attr='home', config.add_view(cls, attr='home',
route_name='home', route_name='home',
renderer='/home.mako') 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): def defaults(config, **kwargs):
base = globals() base = globals()

View file

@ -28,6 +28,7 @@ import sqlalchemy as sa
from sqlalchemy import orm from sqlalchemy import orm
from pyramid.renderers import render_to_response from pyramid.renderers import render_to_response
from webhelpers2.html import HTML
from wuttaweb.views import View from wuttaweb.views import View
from wuttaweb.util import get_form_data, get_model_fields 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 Code should not access this directly but instead call
:meth:`get_route_prefix()`. :meth:`get_route_prefix()`.
.. attribute:: permission_prefix
Optional override for the view's permission prefix,
e.g. ``'wutta_widgets'``.
Code should not access this directly but instead call
:meth:`get_permission_prefix()`.
.. attribute:: url_prefix .. attribute:: url_prefix
Optional override for the view's URL prefix, Optional override for the view's URL prefix,
@ -189,12 +198,16 @@ class MasterView(View):
i.e. it should have an :meth:`edit()` view. Default value is i.e. it should have an :meth:`edit()` view. Default value is
``True``. ``True``.
See also :meth:`is_editable()`.
.. attribute:: deletable .. attribute:: deletable
Boolean indicating whether the view model supports "deleting" - Boolean indicating whether the view model supports "deleting" -
i.e. it should have a :meth:`delete()` view. Default value is i.e. it should have a :meth:`delete()` view. Default value is
``True``. ``True``.
See also :meth:`is_deletable()`.
.. attribute:: form_fields .. attribute:: form_fields
List of columns for the model form. List of columns for the model form.
@ -229,6 +242,9 @@ class MasterView(View):
deleting = False deleting = False
configuring = False configuring = False
# default DB session
Session = Session
############################## ##############################
# index methods # index methods
############################## ##############################
@ -292,7 +308,7 @@ class MasterView(View):
if form.validate(): if form.validate():
obj = self.create_save_form(form) obj = self.create_save_form(form)
Session.flush() self.Session.flush()
return self.redirect(self.get_action_url('view', obj)) return self.redirect(self.get_action_url('view', obj))
context = { context = {
@ -349,7 +365,6 @@ class MasterView(View):
context = { context = {
'instance': instance, 'instance': instance,
'instance_title': self.get_instance_title(instance),
'form': form, 'form': form,
} }
return self.render_to_response('view', context) return self.render_to_response('view', context)
@ -383,7 +398,6 @@ class MasterView(View):
""" """
self.editing = True self.editing = True
instance = self.get_instance() instance = self.get_instance()
instance_title = self.get_instance_title(instance)
form = self.make_model_form(instance, form = self.make_model_form(instance,
cancel_url_fallback=self.get_action_url('view', instance)) cancel_url_fallback=self.get_action_url('view', instance))
@ -394,7 +408,6 @@ class MasterView(View):
context = { context = {
'instance': instance, 'instance': instance,
'instance_title': instance_title,
'form': form, 'form': form,
} }
return self.render_to_response('edit', context) return self.render_to_response('edit', context)
@ -447,7 +460,9 @@ class MasterView(View):
""" """
self.deleting = True self.deleting = True
instance = self.get_instance() 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.. # nb. this form proper is not readonly..
form = self.make_model_form(instance, form = self.make_model_form(instance,
@ -465,7 +480,6 @@ class MasterView(View):
context = { context = {
'instance': instance, 'instance': instance,
'instance_title': instance_title,
'form': form, 'form': form,
} }
return self.render_to_response('delete', context) return self.render_to_response('delete', context)
@ -500,7 +514,7 @@ class MasterView(View):
# configure methods # configure methods
############################## ##############################
def configure(self): def configure(self, session=None):
""" """
View for configuring aspects of the app which are pertinent to View for configuring aspects of the app which are pertinent to
this master view and/or model. this master view and/or model.
@ -546,7 +560,7 @@ class MasterView(View):
# maybe just remove settings # maybe just remove settings
if self.request.POST.get('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.", self.request.session.flash(f"All settings for {config_title} have been removed.",
'warning') 'warning')
@ -556,8 +570,8 @@ class MasterView(View):
# gather/save settings # gather/save settings
data = get_form_data(self.request) data = get_form_data(self.request)
settings = self.configure_gather_settings(data) settings = self.configure_gather_settings(data)
self.configure_remove_settings() self.configure_remove_settings(session=session)
self.configure_save_settings(settings) self.configure_save_settings(settings, session=session)
self.request.session.flash("Settings have been saved.") self.request.session.flash("Settings have been saved.")
# reload configure page # reload configure page
@ -735,6 +749,7 @@ class MasterView(View):
def configure_remove_settings( def configure_remove_settings(
self, self,
simple_settings=None, simple_settings=None,
session=None,
): ):
""" """
Remove all "known" settings from the DB; this is called by Remove all "known" settings from the DB; this is called by
@ -762,11 +777,11 @@ class MasterView(View):
if names: if names:
# nb. must avoid self.Session here in case that does not # nb. must avoid self.Session here in case that does not
# point to our primary app DB # point to our primary app DB
session = Session() session = session or self.Session()
for name in names: for name in names:
self.app.delete_setting(session, name) 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 Save the given settings to the DB; this is called by
:meth:`configure()`. :meth:`configure()`.
@ -779,7 +794,7 @@ class MasterView(View):
""" """
# nb. must avoid self.Session here in case that does not point # nb. must avoid self.Session here in case that does not point
# to our primary app DB # to our primary app DB
session = Session() session = session or self.Session()
for setting in settings: for setting in settings:
self.app.save_setting(session, setting['name'], setting['value'], self.app.save_setting(session, setting['name'], setting['value'],
force_create=True) force_create=True)
@ -788,6 +803,43 @@ class MasterView(View):
# support methods # 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): def render_to_response(self, template, context):
""" """
Locate and render an appropriate template, with the given Locate and render an appropriate template, with the given
@ -830,6 +882,16 @@ class MasterView(View):
defaults.update(context) defaults.update(context)
context = defaults 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 # first try the template path most specific to this view
template_prefix = self.get_template_prefix() template_prefix = self.get_template_prefix()
mako_path = f'{template_prefix}/{template}.mako' mako_path = f'{template_prefix}/{template}.mako'
@ -919,15 +981,15 @@ class MasterView(View):
# TODO: should split this off into index_get_grid_actions() ? # 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', actions.append(self.make_grid_action('view', icon='eye',
url=self.get_action_url_view)) 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', actions.append(self.make_grid_action('edit', icon='edit',
url=self.get_action_url_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', actions.append(self.make_grid_action('delete', icon='trash',
url=self.get_action_url_delete, url=self.get_action_url_delete,
link_class='has-text-danger')) link_class='has-text-danger'))
@ -977,26 +1039,7 @@ class MasterView(View):
""" """
query = self.get_query(session=session) query = self.get_query(session=session)
if query: if query:
data = query.all() return 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 [] return []
@ -1012,7 +1055,7 @@ class MasterView(View):
model = self.app.model model = self.app.model
model_class = self.get_model_class() model_class = self.get_model_class()
if model_class and issubclass(model_class, model.Base): if model_class and issubclass(model_class, model.Base):
session = session or Session() session = session or self.Session()
return session.query(model_class) return session.query(model_class)
def configure_grid(self, grid): def configure_grid(self, grid):
@ -1032,6 +1075,33 @@ class MasterView(View):
for key in self.get_model_key(): for key in self.get_model_key():
grid.set_link(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): def get_instance(self, session=None):
""" """
This should return the "current" model instance based on the This should return the "current" model instance based on the
@ -1045,7 +1115,7 @@ class MasterView(View):
""" """
model_class = self.get_model_class() model_class = self.get_model_class()
if model_class: if model_class:
session = session or Session() session = session or self.Session()
def filtr(query, model_key): def filtr(query, model_key):
key = self.request.matchdict[model_key] key = self.request.matchdict[model_key]
@ -1111,25 +1181,61 @@ class MasterView(View):
def get_action_url_edit(self, obj, i): 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 Most typically this is like ``/widgets/XXX/edit`` where
``XXX`` represents the object's key/ID. ``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): 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 Most typically this is like ``/widgets/XXX/delete`` where
``XXX`` represents the object's key/ID. ``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): 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): if model_class and issubclass(model_class, model.Base):
# add sqlalchemy model to session # add sqlalchemy model to session
session = session or Session() session = session or self.Session()
session.add(obj) session.add(obj)
############################## ##############################
@ -1414,6 +1520,29 @@ class MasterView(View):
model_name = cls.get_model_name_normalized() model_name = cls.get_model_name_normalized()
return f'{model_name}s' return f'{model_name}s'
@classmethod
def get_permission_prefix(cls):
"""
Returns the "permission prefix" for the master view. This
prefix is used for all permissions defined by the view class.
For instance if permission prefix is ``'widgets'`` then a view
might have these permissions:
* ``'widgets.list'``
* ``'widgets.create'``
* ``'widgets.edit'``
* ``'widgets.delete'``
The default logic will call :meth:`get_route_prefix()` and use
that value as-is. A subclass may override by assigning
:attr:`permission_prefix`.
"""
if hasattr(cls, 'permission_prefix'):
return cls.permission_prefix
return cls.get_route_prefix()
@classmethod @classmethod
def get_url_prefix(cls): def get_url_prefix(cls):
""" """
@ -1553,27 +1682,47 @@ class MasterView(View):
@classmethod @classmethod
def _defaults(cls, config): def _defaults(cls, config):
route_prefix = cls.get_route_prefix() route_prefix = cls.get_route_prefix()
permission_prefix = cls.get_permission_prefix()
url_prefix = cls.get_url_prefix() url_prefix = cls.get_url_prefix()
model_title = cls.get_model_title()
model_title_plural = cls.get_model_title_plural()
# permission group
config.add_wutta_permission_group(permission_prefix,
model_title_plural,
overwrite=False)
# index # index
if cls.listable: if cls.listable:
config.add_route(route_prefix, f'{url_prefix}/') config.add_route(route_prefix, f'{url_prefix}/')
config.add_view(cls, attr='index', config.add_view(cls, attr='index',
route_name=route_prefix) route_name=route_prefix,
permission=f'{permission_prefix}.list')
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.list',
f"Browse / search {model_title_plural}")
# create # create
if cls.creatable: if cls.creatable:
config.add_route(f'{route_prefix}.create', config.add_route(f'{route_prefix}.create',
f'{url_prefix}/new') f'{url_prefix}/new')
config.add_view(cls, attr='create', config.add_view(cls, attr='create',
route_name=f'{route_prefix}.create') route_name=f'{route_prefix}.create',
permission=f'{permission_prefix}.create')
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.create',
f"Create new {model_title}")
# view # view
if cls.viewable: if cls.viewable:
instance_url_prefix = cls.get_instance_url_prefix() instance_url_prefix = cls.get_instance_url_prefix()
config.add_route(f'{route_prefix}.view', instance_url_prefix) config.add_route(f'{route_prefix}.view', instance_url_prefix)
config.add_view(cls, attr='view', config.add_view(cls, attr='view',
route_name=f'{route_prefix}.view') route_name=f'{route_prefix}.view',
permission=f'{permission_prefix}.view')
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.view',
f"View {model_title}")
# edit # edit
if cls.editable: if cls.editable:
@ -1581,7 +1730,11 @@ class MasterView(View):
config.add_route(f'{route_prefix}.edit', config.add_route(f'{route_prefix}.edit',
f'{instance_url_prefix}/edit') f'{instance_url_prefix}/edit')
config.add_view(cls, attr='edit', config.add_view(cls, attr='edit',
route_name=f'{route_prefix}.edit') route_name=f'{route_prefix}.edit',
permission=f'{permission_prefix}.edit')
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.edit',
f"Edit {model_title}")
# delete # delete
if cls.deletable: if cls.deletable:
@ -1589,11 +1742,19 @@ class MasterView(View):
config.add_route(f'{route_prefix}.delete', config.add_route(f'{route_prefix}.delete',
f'{instance_url_prefix}/delete') f'{instance_url_prefix}/delete')
config.add_view(cls, attr='delete', config.add_view(cls, attr='delete',
route_name=f'{route_prefix}.delete') route_name=f'{route_prefix}.delete',
permission=f'{permission_prefix}.delete')
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.delete',
f"Delete {model_title}")
# configure # configure
if cls.configurable: if cls.configurable:
config.add_route(f'{route_prefix}.configure', config.add_route(f'{route_prefix}.configure',
f'{url_prefix}/configure') f'{url_prefix}/configure')
config.add_view(cls, attr='configure', config.add_view(cls, attr='configure',
route_name=f'{route_prefix}.configure') route_name=f'{route_prefix}.configure',
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. Master view for people.
Default route prefix is ``people``.
Notable URLs provided by this class: Notable URLs provided by this class:
* ``/people/`` * ``/people/``
@ -83,6 +85,47 @@ class PersonView(MasterView):
if 'users' in f: if 'users' in f:
f.fields.remove('users') 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): def defaults(config, **kwargs):
base = globals() base = globals()

View file

@ -27,12 +27,16 @@ Views for roles
from wuttjamaican.db.model import Role from wuttjamaican.db.model import Role
from wuttaweb.views import MasterView from wuttaweb.views import MasterView
from wuttaweb.db import Session from wuttaweb.db import Session
from wuttaweb.forms import widgets
from wuttaweb.forms.schema import Permissions
class RoleView(MasterView): class RoleView(MasterView):
""" """
Master view for roles. Master view for roles.
Default route prefix is ``roles``.
Notable URLs provided by this class: Notable URLs provided by this class:
* ``/roles/`` * ``/roles/``
@ -62,9 +66,44 @@ class RoleView(MasterView):
# name # name
g.set_link('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): def configure_form(self, f):
""" """ """ """
super().configure_form(f) super().configure_form(f)
role = f.model_instance
# never show these # never show these
f.remove('permission_refs', f.remove('permission_refs',
@ -73,6 +112,16 @@ class RoleView(MasterView):
# name # name
f.set_validator('name', self.unique_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): def unique_name(self, node, value):
""" """ """ """
model = self.app.model model = self.app.model
@ -88,6 +137,128 @@ class RoleView(MasterView):
if query.count(): if query.count():
node.raise_invalid("Name must be unique") node.raise_invalid("Name must be unique")
def get_available_permissions(self):
"""
Returns all "available" permissions. This is used when
viewing or editing a role; the result is passed into the
:class:`~wuttaweb.forms.schema.Permissions` field schema.
The app itself must be made aware of each permission, in order
for them to found by this method. This is done via
:func:`~wuttaweb.auth.add_permission_group()` and
:func:`~wuttaweb.auth.add_permission()`.
When in "view" (readonly) mode, this method will return the
full set of known permissions.
However in "edit" mode, it will prune the set to remove any
permissions which the current user does not also have. The
idea here is to allow "many" users to manage roles, but ensure
they cannot "break out" of their own role by assigning extra
permissions to it.
The permissions returned will also be grouped, and each single
permission is also represented as a simple dict, e.g.::
{
'books': {
'key': 'books',
'label': "Books",
'perms': {
'books.list': {
'key': 'books.list',
'label': "Browse / search Books",
},
'books.view': {
'key': 'books.view',
'label': "View Book",
},
},
},
'widgets': {
'key': 'widgets',
'label': "Widgets",
'perms': {
'widgets.list': {
'key': 'widgets.list',
'label': "Browse / search Widgets",
},
'widgets.view': {
'key': 'widgets.view',
'label': "View Widget",
},
},
},
}
"""
# get all known permissions from settings cache
permissions = self.request.registry.settings.get('wutta_permissions', {})
# when viewing, we allow all permissions to be exposed for all users
if self.viewing:
return permissions
# admin user gets to manage all permissions
if self.request.is_admin:
return permissions
# non-admin user can only see permissions they're granted
available = {}
for gkey, group in permissions.items():
for pkey, perm in group['perms'].items():
if self.request.has_perm(pkey):
if gkey not in available:
available[gkey] = {
'key': gkey,
'label': group['label'],
'perms': {},
}
available[gkey]['perms'][pkey] = perm
return available
def objectify(self, form):
""" """
# normal logic first
role = super().objectify(form)
# update permissions for role
self.update_permissions(role, form)
return role
def update_permissions(self, role, form):
""" """
if 'permissions' not in form.validated:
return
auth = self.app.get_auth_handler()
available = self.wutta_permissions
permissions = form.validated['permissions']
for gkey, group in available.items():
for pkey, perm in group['perms'].items():
if pkey in permissions:
auth.grant_permission(role, pkey)
else:
auth.revoke_permission(role, pkey)
@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): def defaults(config, **kwargs):
base = globals() base = globals()

View file

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

View file

@ -28,7 +28,8 @@ import colander
from wuttjamaican.db.model import User from wuttjamaican.db.model import User
from wuttaweb.views import MasterView 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 from wuttaweb.db import Session
@ -36,6 +37,8 @@ class UserView(MasterView):
""" """
Master view for users. Master view for users.
Default route prefix is ``users``.
Notable URLs provided by this class: Notable URLs provided by this class:
* ``/users/`` * ``/users/``
@ -77,10 +80,10 @@ class UserView(MasterView):
def configure_form(self, f): def configure_form(self, f):
""" """ """ """
super().configure_form(f) super().configure_form(f)
user = f.model_instance
# never show these # never show these
f.remove('person_uuid', f.remove('person_uuid',
'password',
'role_refs') 'role_refs')
# person # person
@ -90,6 +93,24 @@ class UserView(MasterView):
# username # username
f.set_validator('username', self.unique_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): def unique_username(self, node, value):
""" """ """ """
model = self.app.model model = self.app.model
@ -105,6 +126,69 @@ class UserView(MasterView):
if query.count(): if query.count():
node.raise_invalid("Username must be unique") 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): def defaults(config, **kwargs):
base = globals() base = globals()

View file

@ -84,6 +84,12 @@ class TestForm(TestCase):
form.set_fields(['baz']) form.set_fields(['baz'])
self.assertEqual(form.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): def test_remove(self):
form = self.make_form(fields=['one', 'two', 'three', 'four']) form = self.make_form(fields=['one', 'two', 'three', 'four'])
self.assertEqual(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(form.validators['foo'], validate2)
self.assertIs(schema['foo'].validator, 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): def test_get_schema(self):
model = self.app.model model = self.app.model
form = self.make_form() form = self.make_form()
@ -204,6 +218,18 @@ class TestForm(TestCase):
self.assertIn('name', schema) self.assertIn('name', schema)
self.assertIn('value', 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 # schema nodes are required by default
form = self.make_form(fields=['foo', 'bar']) form = self.make_form(fields=['foo', 'bar'])
schema = form.get_schema() schema = form.get_schema()
@ -233,6 +259,12 @@ class TestForm(TestCase):
schema = form.get_schema() schema = form.get_schema()
self.assertIs(schema.validator, validate) 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): def test_get_deform(self):
model = self.app.model model = self.app.model
schema = self.make_schema() schema = self.make_schema()
@ -422,6 +454,41 @@ class TestForm(TestCase):
# nb. no error message # nb. no error message
self.assertNotIn('message', html) 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): def test_get_field_errors(self):
schema = self.make_schema() schema = self.make_schema()
form = self.make_form(schema=schema) form = self.make_form(schema=schema)

View file

@ -197,3 +197,58 @@ class TestPersonRef(DataTestCase):
sorted_query = typ.sort_query(query) sorted_query = typ.sort_query(query)
self.assertIsInstance(sorted_query, orm.Query) self.assertIsInstance(sorted_query, orm.Query)
self.assertIsNot(sorted_query, 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 import deform
from pyramid import testing from pyramid import testing
from wuttaweb.forms import widgets from wuttaweb.forms import widgets as mod
from wuttaweb.forms.schema import PersonRef from wuttaweb.forms.schema import PersonRef, RoleRefs, Permissions
from tests.util import WebTestCase from tests.util import WebTestCase
class TestObjectRefWidget(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): def test_serialize(self):
model = self.app.model model = self.app.model
person = model.Person(full_name="Betty Boop") person = model.Person(full_name="Betty Boop")
@ -18,15 +25,91 @@ class TestObjectRefWidget(WebTestCase):
# standard (editable) # standard (editable)
node = colander.SchemaNode(PersonRef(self.request, session=self.session)) node = colander.SchemaNode(PersonRef(self.request, session=self.session))
widget = widgets.ObjectRefWidget(self.request) widget = mod.ObjectRefWidget(self.request)
field = deform.Field(node) field = self.make_field(node)
html = widget.serialize(field, person.uuid) html = widget.serialize(field, person.uuid)
self.assertIn('<select ', html) self.assertIn('<b-select ', html)
# readonly # readonly
node = colander.SchemaNode(PersonRef(self.request, session=self.session)) node = colander.SchemaNode(PersonRef(self.request, session=self.session))
node.model_instance = person node.model_instance = person
widget = widgets.ObjectRefWidget(self.request) widget = mod.ObjectRefWidget(self.request)
field = deform.Field(node) field = self.make_field(node)
html = widget.serialize(field, person.uuid, readonly=True) 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.columns, ['name', 'value'])
self.assertEqual(grid.get_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): def test_remove(self):
grid = self.make_grid(columns=['one', 'two', 'three', 'four']) grid = self.make_grid(columns=['one', 'two', 'three', 'four'])
self.assertEqual(grid.columns, ['one', 'two', 'three', 'four']) self.assertEqual(grid.columns, ['one', 'two', 'three', 'four'])
grid.remove('two', 'three') grid.remove('two', 'three')
self.assertEqual(grid.columns, ['one', 'four']) 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): def test_linked_columns(self):
grid = self.make_grid(columns=['foo', 'bar']) grid = self.make_grid(columns=['foo', 'bar'])
self.assertEqual(grid.linked_columns, []) self.assertEqual(grid.linked_columns, [])
@ -143,6 +168,11 @@ class TestGrid(TestCase):
self.assertIsNot(data, mydata) self.assertIsNot(data, mydata)
self.assertEqual(data, [{'foo': 'bar', '_action_url_view': '/blarg'}]) 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): class TestGridAction(TestCase):
@ -160,9 +190,10 @@ class TestGridAction(TestCase):
html = action.render_icon() html = action.render_icon()
self.assertIn('<i class="fas fa-blarg">', html) self.assertIn('<i class="fas fa-blarg">', html)
# oruga not yet supported # oruga has different output
self.request.use_oruga = True 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): def test_render_label(self):

View file

@ -7,6 +7,7 @@ from pyramid import testing
from wuttjamaican.conf import WuttaConfig from wuttjamaican.conf import WuttaConfig
from wuttaweb import auth as mod from wuttaweb import auth as mod
from tests.util import WebTestCase
class TestLoginUser(TestCase): class TestLoginUser(TestCase):
@ -143,3 +144,26 @@ class TestWuttaSecurityPolicy(TestCase):
self.assertFalse(self.policy.permits(self.request, None, 'some-root-perm')) self.assertFalse(self.policy.permits(self.request, None, 'some-root-perm'))
self.request.is_root = True self.request.is_root = True
self.assertTrue(self.policy.permits(self.request, None, 'some-root-perm')) self.assertTrue(self.policy.permits(self.request, None, 'some-root-perm'))
class TestAddPermissionGroup(WebTestCase):
def test_basic(self):
permissions = self.pyramid_config.get_settings().get('wutta_permissions', {})
self.assertNotIn('widgets', permissions)
self.pyramid_config.add_wutta_permission_group('widgets')
permissions = self.pyramid_config.get_settings().get('wutta_permissions', {})
self.assertIn('widgets', permissions)
self.assertEqual(permissions['widgets']['label'], "Widgets")
class TestAddPermission(WebTestCase):
def test_basic(self):
permissions = self.pyramid_config.get_settings().get('wutta_permissions', {})
self.assertNotIn('widgets', permissions)
self.pyramid_config.add_wutta_permission('widgets', 'widgets.polish')
permissions = self.pyramid_config.get_settings().get('wutta_permissions', {})
self.assertIn('widgets', permissions)
self.assertEqual(permissions['widgets']['label'], "Widgets")
self.assertIn('widgets.polish', permissions['widgets']['perms'])

View file

@ -3,20 +3,15 @@
from unittest import TestCase from unittest import TestCase
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from wuttjamaican.conf import WuttaConfig
from pyramid import testing
from wuttaweb import menus as mod from wuttaweb import menus as mod
from tests.util import WebTestCase
class TestMenuHandler(TestCase): class TestMenuHandler(WebTestCase):
def setUp(self): def setUp(self):
self.config = WuttaConfig() self.setup_web()
self.app = self.config.get_app()
self.handler = mod.MenuHandler(self.config) self.handler = mod.MenuHandler(self.config)
self.request = testing.DummyRequest()
def test_make_admin_menu(self): def test_make_admin_menu(self):
menus = self.handler.make_admin_menu(self.request) menus = self.handler.make_admin_menu(self.request)
@ -27,7 +22,27 @@ class TestMenuHandler(TestCase):
self.assertIsInstance(menus, list) self.assertIsInstance(menus, list)
def test_is_allowed(self): 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 = {} item = {}
self.assertTrue(self.handler._is_allowed(self.request, item)) self.assertTrue(self.handler._is_allowed(self.request, item))

View file

@ -2,7 +2,7 @@
import json import json
from unittest import TestCase from unittest import TestCase
from unittest.mock import MagicMock from unittest.mock import MagicMock, patch
from wuttjamaican.conf import WuttaConfig from wuttjamaican.conf import WuttaConfig
@ -210,6 +210,137 @@ class TestNewRequestSetUser(TestCase):
self.assertTrue(self.request.is_admin) self.assertTrue(self.request.is_admin)
self.assertTrue(self.request.is_root) 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): class TestBeforeRender(TestCase):

View file

@ -442,6 +442,14 @@ class TestGetModelFields(TestCase):
self.config = WuttaConfig() self.config = WuttaConfig()
self.app = self.config.get_app() 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): def test_basic(self):
model = self.app.model model = self.app.model
fields = util.get_model_fields(self.config, model.Setting) fields = util.get_model_fields(self.config, model.Setting)

View file

@ -46,20 +46,23 @@ class WebTestCase(DataTestCase):
def setup_web(self): def setup_web(self):
self.setup_db() self.setup_db()
self.request = testing.DummyRequest() self.request = self.make_request()
self.pyramid_config = testing.setUp(request=self.request, settings={ self.pyramid_config = testing.setUp(request=self.request, settings={
'wutta_config': self.config, 'wutta_config': self.config,
'mako.directories': ['wuttaweb:templates'], '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 # init web
self.pyramid_config.include('pyramid_deform')
self.pyramid_config.include('pyramid_mako') self.pyramid_config.include('pyramid_mako')
self.pyramid_config.include('wuttaweb.static') self.pyramid_config.add_directive('add_wutta_permission_group',
self.pyramid_config.include('wuttaweb.views.essential') 'wuttaweb.auth.add_permission_group')
self.pyramid_config.add_directive('add_wutta_permission',
'wuttaweb.auth.add_permission')
self.pyramid_config.add_subscriber('wuttaweb.subscribers.before_render', self.pyramid_config.add_subscriber('wuttaweb.subscribers.before_render',
'pyramid.events.BeforeRender') 'pyramid.events.BeforeRender')
self.pyramid_config.include('wuttaweb.static')
# setup new request w/ anonymous user # setup new request w/ anonymous user
event = MagicMock(request=self.request) event = MagicMock(request=self.request)
@ -75,6 +78,9 @@ class WebTestCase(DataTestCase):
testing.tearDown() testing.tearDown()
self.teardown_db() self.teardown_db()
def make_request(self):
return testing.DummyRequest()
class NullMenuHandler(MenuHandler): class NullMenuHandler(MenuHandler):
""" """

View file

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

View file

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

View file

@ -1,27 +1,95 @@
# -*- coding: utf-8; -*- # -*- coding: utf-8; -*-
from unittest import TestCase from wuttaweb.views import common as mod
from tests.util import WebTestCase
from pyramid import testing
from wuttjamaican.conf import WuttaConfig
from wuttaweb.views import common
class TestCommonView(TestCase): class TestCommonView(WebTestCase):
def setUp(self): def make_view(self):
self.config = WuttaConfig() return mod.CommonView(self.request)
self.app = self.config.get_app()
self.request = testing.DummyRequest() def test_includeme(self):
self.request.wutta_config = self.config
self.pyramid_config = testing.setUp(request=self.request)
self.pyramid_config.include('wuttaweb.views.common') self.pyramid_config.include('wuttaweb.views.common')
def tearDown(self): def test_forbidden_view(self):
testing.tearDown() 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): def test_home(self):
view = common.CommonView(self.request) self.pyramid_config.add_route('setup', '/setup')
context = view.home() 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()) 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 import testing
from pyramid.response import Response from pyramid.response import Response
from pyramid.httpexceptions import HTTPFound, HTTPNotFound from pyramid.httpexceptions import HTTPNotFound
from wuttjamaican.conf import WuttaConfig from wuttjamaican.conf import WuttaConfig
from wuttaweb.views import master from wuttaweb.views import master
@ -16,12 +16,14 @@ from tests.util import WebTestCase
class TestMasterView(WebTestCase): class TestMasterView(WebTestCase):
def make_view(self):
return master.MasterView(self.request)
def test_defaults(self): def test_defaults(self):
with patch.multiple(master.MasterView, create=True, with patch.multiple(master.MasterView, create=True,
model_name='Widget', model_name='Widget',
viewable=False, model_key='uuid',
editable=False, configurable=True):
deletable=False):
master.MasterView.defaults(self.pyramid_config) master.MasterView.defaults(self.pyramid_config)
############################## ##############################
@ -159,6 +161,24 @@ class TestMasterView(WebTestCase):
model_class=MyModel): model_class=MyModel):
self.assertEqual(master.MasterView.get_route_prefix(), 'trucks') self.assertEqual(master.MasterView.get_route_prefix(), 'trucks')
def test_get_permission_prefix(self):
# error by default (since no model class)
self.assertRaises(AttributeError, master.MasterView.get_permission_prefix)
# subclass may specify permission prefix
with patch.object(master.MasterView, 'permission_prefix', new='widgets', create=True):
self.assertEqual(master.MasterView.get_permission_prefix(), 'widgets')
# subclass may specify route prefix
with patch.object(master.MasterView, 'route_prefix', new='widgets', create=True):
self.assertEqual(master.MasterView.get_permission_prefix(), 'widgets')
# or it may specify model class
Truck = MagicMock(__name__='Truck')
with patch.object(master.MasterView, 'model_class', new=Truck, create=True):
self.assertEqual(master.MasterView.get_permission_prefix(), 'trucks')
def test_get_url_prefix(self): def test_get_url_prefix(self):
# error by default (since no model class) # error by default (since no model class)
@ -311,7 +331,68 @@ class TestMasterView(WebTestCase):
# support methods # 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): def test_render_to_response(self):
self.pyramid_config.include('wuttaweb.views.common')
self.pyramid_config.include('wuttaweb.views.auth')
self.pyramid_config.add_route('appinfo', '/appinfo/')
def widgets(request): return {} def widgets(request): return {}
self.pyramid_config.add_route('widgets', '/widgets/') self.pyramid_config.add_route('widgets', '/widgets/')
@ -364,10 +445,39 @@ class TestMasterView(WebTestCase):
grid = view.make_model_grid(session=self.session) grid = view.make_model_grid(session=self.session)
self.assertIs(grid.model_class, model.Setting) 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): def test_get_grid_data(self):
model = self.app.model model = self.app.model
self.app.save_setting(self.session, 'foo', 'bar') self.app.save_setting(self.session, 'foo', 'bar')
self.session.commit() 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 # basic logic with Setting model
with patch.multiple(master.MasterView, create=True, with patch.multiple(master.MasterView, create=True,
@ -375,16 +485,7 @@ class TestMasterView(WebTestCase):
view = master.MasterView(self.request) view = master.MasterView(self.request)
data = view.get_grid_data(session=self.session) data = view.get_grid_data(session=self.session)
self.assertEqual(len(data), 1) self.assertEqual(len(data), 1)
self.assertEqual(data[0], {'name': 'foo', 'value': 'bar'}) self.assertIs(data[0], setting)
# 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)
def test_configure_grid(self): def test_configure_grid(self):
model = self.app.model model = self.app.model
@ -399,6 +500,28 @@ class TestMasterView(WebTestCase):
view.configure_grid(grid) view.configure_grid(grid)
self.assertNotIn('uuid', grid.columns) 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): def test_get_instance(self):
model = self.app.model model = self.app.model
self.app.save_setting(self.session, 'foo', 'bar') self.app.save_setting(self.session, 'foo', 'bar')
@ -425,6 +548,57 @@ class TestMasterView(WebTestCase):
self.request.matchdict = {'name': 'blarg'} self.request.matchdict = {'name': 'blarg'}
self.assertRaises(HTTPNotFound, view.get_instance, session=self.session) 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): def test_make_model_form(self):
model = self.app.model model = self.app.model
@ -514,28 +688,38 @@ class TestMasterView(WebTestCase):
############################## ##############################
def test_index(self): def test_index(self):
self.pyramid_config.include('wuttaweb.views.common')
self.pyramid_config.include('wuttaweb.views.auth')
self.pyramid_config.add_route('settings.create', '/settings/new')
self.pyramid_config.add_route('settings.view', '/settings/{name}')
self.pyramid_config.add_route('settings.edit', '/settings/{name}/edit')
self.pyramid_config.add_route('settings.delete', '/settings/{name}/delete')
# sanity/coverage check using /settings/ # sanity/coverage check using /settings/
master.MasterView.model_name = 'Setting' with patch.multiple(master.MasterView, create=True,
master.MasterView.model_key = 'name' model_name='Setting',
master.MasterView.grid_columns = ['name', 'value'] model_key='name',
view = master.MasterView(self.request) get_index_url=MagicMock(return_value='/settings/'),
response = view.index() grid_columns=['name', 'value']):
# then again with data, to include view action url view = master.MasterView(self.request)
data = [{'name': 'foo', 'value': 'bar'}]
with patch.object(view, 'get_grid_data', return_value=data):
response = view.index() response = view.index()
del master.MasterView.model_name
del master.MasterView.model_key # then again with data, to include view action url
del master.MasterView.grid_columns data = [{'name': 'foo', 'value': 'bar'}]
with patch.object(view, 'get_grid_data', return_value=data):
response = view.index()
def test_create(self): def test_create(self):
self.pyramid_config.include('wuttaweb.views.common')
self.pyramid_config.include('wuttaweb.views.auth')
self.pyramid_config.add_route('settings.view', '/settings/{name}')
model = self.app.model model = self.app.model
# sanity/coverage check using /settings/new # sanity/coverage check using /settings/new
with patch.multiple(master.MasterView, create=True, with patch.multiple(master.MasterView, create=True,
model_name='Setting', model_name='Setting',
model_key='name', model_key='name',
get_index_url=MagicMock(return_value='/settings/'),
form_fields=['name', 'value']): form_fields=['name', 'value']):
view = master.MasterView(self.request) view = master.MasterView(self.request)
@ -579,6 +763,11 @@ class TestMasterView(WebTestCase):
self.assertEqual(self.app.get_setting(self.session, 'foo.bar'), 'fraggle') self.assertEqual(self.app.get_setting(self.session, 'foo.bar'), 'fraggle')
def test_view(self): def test_view(self):
self.pyramid_config.include('wuttaweb.views.common')
self.pyramid_config.include('wuttaweb.views.auth')
self.pyramid_config.add_route('settings.create', '/settings/new')
self.pyramid_config.add_route('settings.edit', '/settings/{name}/edit')
self.pyramid_config.add_route('settings.delete', '/settings/{name}/delete')
# sanity/coverage check using /settings/XXX # sanity/coverage check using /settings/XXX
setting = {'name': 'foo.bar', 'value': 'baz'} setting = {'name': 'foo.bar', 'value': 'baz'}
@ -586,6 +775,7 @@ class TestMasterView(WebTestCase):
with patch.multiple(master.MasterView, create=True, with patch.multiple(master.MasterView, create=True,
model_name='Setting', model_name='Setting',
model_key='name', model_key='name',
get_index_url=MagicMock(return_value='/settings/'),
grid_columns=['name', 'value'], grid_columns=['name', 'value'],
form_fields=['name', 'value']): form_fields=['name', 'value']):
view = master.MasterView(self.request) view = master.MasterView(self.request)
@ -593,6 +783,11 @@ class TestMasterView(WebTestCase):
response = view.view() response = view.view()
def test_edit(self): def test_edit(self):
self.pyramid_config.include('wuttaweb.views.common')
self.pyramid_config.include('wuttaweb.views.auth')
self.pyramid_config.add_route('settings.create', '/settings/new')
self.pyramid_config.add_route('settings.view', '/settings/{name}')
self.pyramid_config.add_route('settings.delete', '/settings/{name}/delete')
model = self.app.model model = self.app.model
self.app.save_setting(self.session, 'foo.bar', 'frazzle') self.app.save_setting(self.session, 'foo.bar', 'frazzle')
self.session.commit() self.session.commit()
@ -609,6 +804,7 @@ class TestMasterView(WebTestCase):
with patch.multiple(master.MasterView, create=True, with patch.multiple(master.MasterView, create=True,
model_name='Setting', model_name='Setting',
model_key='name', model_key='name',
get_index_url=MagicMock(return_value='/settings/'),
form_fields=['name', 'value']): form_fields=['name', 'value']):
view = master.MasterView(self.request) view = master.MasterView(self.request)
with patch.object(view, 'get_instance', new=get_instance): with patch.object(view, 'get_instance', new=get_instance):
@ -650,6 +846,11 @@ class TestMasterView(WebTestCase):
self.assertEqual(self.app.get_setting(self.session, 'foo.bar'), 'froogle') self.assertEqual(self.app.get_setting(self.session, 'foo.bar'), 'froogle')
def test_delete(self): def test_delete(self):
self.pyramid_config.include('wuttaweb.views.common')
self.pyramid_config.include('wuttaweb.views.auth')
self.pyramid_config.add_route('settings.create', '/settings/new')
self.pyramid_config.add_route('settings.view', '/settings/{name}')
self.pyramid_config.add_route('settings.edit', '/settings/{name}/edit')
model = self.app.model model = self.app.model
self.app.save_setting(self.session, 'foo.bar', 'frazzle') self.app.save_setting(self.session, 'foo.bar', 'frazzle')
self.session.commit() self.session.commit()
@ -667,6 +868,7 @@ class TestMasterView(WebTestCase):
with patch.multiple(master.MasterView, create=True, with patch.multiple(master.MasterView, create=True,
model_name='Setting', model_name='Setting',
model_key='name', model_key='name',
get_index_url=MagicMock(return_value='/settings/'),
form_fields=['name', 'value']): form_fields=['name', 'value']):
view = master.MasterView(self.request) view = master.MasterView(self.request)
with patch.object(view, 'get_instance', new=get_instance): with patch.object(view, 'get_instance', new=get_instance):
@ -680,15 +882,24 @@ class TestMasterView(WebTestCase):
def delete_instance(setting): def delete_instance(setting):
self.app.delete_setting(self.session, setting['name']) self.app.delete_setting(self.session, setting['name'])
# post request to save settings
self.request.method = 'POST' self.request.method = 'POST'
self.request.POST = {} self.request.POST = {}
with patch.object(view, 'delete_instance', new=delete_instance): 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() response = view.delete()
# nb. should get redirect back to view page # nb. should get redirect back to view page
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
# setting should be gone from DB # setting should be gone from DB
self.assertEqual(self.session.query(model.Setting).count(), 0) self.assertEqual(self.session.query(model.Setting).count(), 0)
def test_delete_instance(self): def test_delete_instance(self):
model = self.app.model model = self.app.model
@ -705,6 +916,8 @@ class TestMasterView(WebTestCase):
self.assertEqual(self.session.query(model.Setting).count(), 0) self.assertEqual(self.session.query(model.Setting).count(), 0)
def test_configure(self): def test_configure(self):
self.pyramid_config.include('wuttaweb.views.common')
self.pyramid_config.include('wuttaweb.views.auth')
model = self.app.model model = self.app.model
# mock settings # mock settings
@ -725,10 +938,11 @@ class TestMasterView(WebTestCase):
route_prefix='appinfo', route_prefix='appinfo',
template_prefix='/appinfo', template_prefix='/appinfo',
creatable=False, creatable=False,
get_index_url=MagicMock(return_value='/appinfo/'),
configure_get_simple_settings=MagicMock(return_value=settings)): configure_get_simple_settings=MagicMock(return_value=settings)):
# get the form page # get the form page
response = view.configure() response = view.configure(session=self.session)
self.assertIsInstance(response, Response) self.assertIsInstance(response, Response)
# post request to save settings # post request to save settings
@ -738,9 +952,9 @@ class TestMasterView(WebTestCase):
'wutta.foo': 'bar', 'wutta.foo': 'bar',
'wutta.flag': 'true', 'wutta.flag': 'true',
} }
response = view.configure() response = view.configure(session=self.session)
# nb. should get redirect back to configure page # nb. should get redirect back to configure page
self.assertIsInstance(response, HTTPFound) self.assertEqual(response.status_code, 302)
# should now have 5 settings # should now have 5 settings
count = self.session.query(model.Setting).count() count = self.session.query(model.Setting).count()
@ -756,9 +970,9 @@ class TestMasterView(WebTestCase):
# post request to remove settings # post request to remove settings
self.request.method = 'POST' self.request.method = 'POST'
self.request.POST = {'remove_settings': '1'} self.request.POST = {'remove_settings': '1'}
response = view.configure() response = view.configure(session=self.session)
# nb. should get redirect back to configure page # nb. should get redirect back to configure page
self.assertIsInstance(response, HTTPFound) self.assertEqual(response.status_code, 302)
# should now have 0 settings # should now have 0 settings
count = self.session.query(model.Setting).count() count = self.session.query(model.Setting).count()

View file

@ -15,6 +15,9 @@ class TestPersonView(WebTestCase):
def make_view(self): def make_view(self):
return people.PersonView(self.request) return people.PersonView(self.request)
def test_includeme(self):
self.pyramid_config.include('wuttaweb.views.people')
def test_get_query(self): def test_get_query(self):
view = self.make_view() view = self.make_view()
query = view.get_query(session=self.session) query = view.get_query(session=self.session)
@ -37,3 +40,34 @@ class TestPersonView(WebTestCase):
view.configure_form(form) view.configure_form(form)
self.assertTrue(form.required_fields) self.assertTrue(form.required_fields)
self.assertFalse(form.required_fields['middle_name']) 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): def make_view(self):
return mod.RoleView(self.request) return mod.RoleView(self.request)
def test_includeme(self):
self.pyramid_config.include('wuttaweb.views.roles')
def test_get_query(self): def test_get_query(self):
view = self.make_view() view = self.make_view()
query = view.get_query(session=self.session) query = view.get_query(session=self.session)
@ -28,10 +31,63 @@ class TestRoleView(WebTestCase):
view.configure_grid(grid) view.configure_grid(grid)
self.assertTrue(grid.is_linked('name')) 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): def test_configure_form(self):
model = self.app.model model = self.app.model
role = model.Role(name="Foo")
view = self.make_view() view = self.make_view()
form = view.make_form(model_class=model.Person) form = view.make_form(model_instance=role)
self.assertNotIn('name', form.validators) self.assertNotIn('name', form.validators)
view.configure_form(form) view.configure_form(form)
self.assertIsNotNone(form.validators['name']) self.assertIsNotNone(form.validators['name'])
@ -55,3 +111,132 @@ class TestRoleView(WebTestCase):
self.request.matchdict = {'uuid': role.uuid} self.request.matchdict = {'uuid': role.uuid}
node = colander.SchemaNode(colander.String(), name='name') node = colander.SchemaNode(colander.String(), name='name')
self.assertIsNone(view.unique_name(node, 'Foo')) self.assertIsNone(view.unique_name(node, 'Foo'))
def get_permissions(self):
return {
'widgets': {
'label': "Widgets",
'perms': {
'widgets.list': {
'label': "List widgets",
},
'widgets.polish': {
'label': "Polish the widgets",
},
'widgets.view': {
'label': "View widget",
},
},
},
}
def test_get_available_permissions(self):
model = self.app.model
auth = self.app.get_auth_handler()
blokes = model.Role(name="Blokes")
auth.grant_permission(blokes, 'widgets.list')
self.session.add(blokes)
barney = model.User(username='barney')
barney.roles.append(blokes)
self.session.add(barney)
self.session.commit()
view = self.make_view()
all_perms = self.get_permissions()
self.request.registry.settings['wutta_permissions'] = all_perms
def has_perm(perm):
if perm == 'widgets.list':
return True
return False
with patch.object(self.request, 'has_perm', new=has_perm, create=True):
# sanity check; current request has 1 perm
self.assertTrue(self.request.has_perm('widgets.list'))
self.assertFalse(self.request.has_perm('widgets.polish'))
self.assertFalse(self.request.has_perm('widgets.view'))
# when editing, user sees only the 1 perm
with patch.object(view, 'editing', new=True):
perms = view.get_available_permissions()
self.assertEqual(list(perms), ['widgets'])
self.assertEqual(list(perms['widgets']['perms']), ['widgets.list'])
# but when viewing, same user sees all perms
with patch.object(view, 'viewing', new=True):
perms = view.get_available_permissions()
self.assertEqual(list(perms), ['widgets'])
self.assertEqual(list(perms['widgets']['perms']),
['widgets.list', 'widgets.polish', 'widgets.view'])
# also, when admin user is editing, sees all perms
self.request.is_admin = True
with patch.object(view, 'editing', new=True):
perms = view.get_available_permissions()
self.assertEqual(list(perms), ['widgets'])
self.assertEqual(list(perms['widgets']['perms']),
['widgets.list', 'widgets.polish', 'widgets.view'])
def test_objectify(self):
model = self.app.model
auth = self.app.get_auth_handler()
blokes = model.Role(name="Blokes")
self.session.add(blokes)
barney = model.User(username='barney')
barney.roles.append(blokes)
self.session.add(barney)
self.session.commit()
view = self.make_view()
permissions = self.get_permissions()
# sanity check, role has just 1 perm
auth.grant_permission(blokes, 'widgets.list')
self.session.commit()
self.assertEqual(blokes.permissions, ['widgets.list'])
# form can update role perms
view.editing = True
self.request.matchdict = {'uuid': blokes.uuid}
with patch.object(view, 'get_available_permissions', return_value=permissions):
form = view.make_model_form(model_instance=blokes)
form.validated = {'name': 'Blokes',
'permissions': {'widgets.list', 'widgets.polish', 'widgets.view'}}
role = view.objectify(form)
self.session.commit()
self.assertIs(role, blokes)
self.assertEqual(blokes.permissions, ['widgets.list', 'widgets.polish', 'widgets.view'])
def test_update_permissions(self):
model = self.app.model
auth = self.app.get_auth_handler()
blokes = model.Role(name="Blokes")
auth.grant_permission(blokes, 'widgets.list')
self.session.add(blokes)
barney = model.User(username='barney')
barney.roles.append(blokes)
self.session.add(barney)
self.session.commit()
view = self.make_view()
permissions = self.get_permissions()
with patch.object(view, 'get_available_permissions', return_value=permissions):
# no error if data is missing perms
form = view.make_model_form(model_instance=blokes)
form.validated = {'name': 'BloX'}
role = view.objectify(form)
self.session.commit()
self.assertIs(role, blokes)
self.assertEqual(blokes.name, 'BloX')
# sanity check, role has just 1 perm
self.assertEqual(blokes.permissions, ['widgets.list'])
# role perms are updated
form = view.make_model_form(model_instance=blokes)
form.validated = {'name': 'Blokes',
'permissions': {'widgets.polish', 'widgets.view'}}
role = view.objectify(form)
self.session.commit()
self.assertIs(role, blokes)
self.assertEqual(blokes.permissions, ['widgets.polish', 'widgets.view'])

View file

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

View file

@ -15,6 +15,9 @@ class TestUserView(WebTestCase):
def make_view(self): def make_view(self):
return mod.UserView(self.request) return mod.UserView(self.request)
def test_includeme(self):
self.pyramid_config.include('wuttaweb.views.users')
def test_get_query(self): def test_get_query(self):
view = self.make_view() view = self.make_view()
query = view.get_query(session=self.session) query = view.get_query(session=self.session)
@ -30,11 +33,29 @@ class TestUserView(WebTestCase):
def test_configure_form(self): def test_configure_form(self):
model = self.app.model model = self.app.model
barney = model.User(username='barney')
self.session.add(barney)
self.session.commit()
view = self.make_view() view = self.make_view()
form = view.make_form(model_class=model.Person)
self.assertIsNone(form.is_required('person')) # person is *not* required
view.configure_form(form) with patch.object(view, 'creating', new=True):
self.assertFalse(form.is_required('person')) 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): def test_unique_username(self):
model = self.app.model model = self.app.model
@ -55,3 +76,113 @@ class TestUserView(WebTestCase):
self.request.matchdict = {'uuid': user.uuid} self.request.matchdict = {'uuid': user.uuid}
node = colander.SchemaNode(colander.String(), name='username') node = colander.SchemaNode(colander.String(), name='username')
self.assertIsNone(view.unique_username(node, 'foo')) 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)