diff --git a/CHANGELOG.md b/CHANGELOG.md
index d8c5635..6de4999 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,25 @@ All notable changes to wuttaweb will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
+## v0.7.0 (2024-08-15)
+
+### Feat
+
+- add sane views for 403 Forbidden and 404 Not Found
+- add permission checks for menus, view routes
+- add first-time setup page to create admin user
+- expose User password for editing in master views
+- expose Role permissions for editing
+- expose User "roles" for editing
+- improve widget, rendering for Role notes
+
+### Fix
+
+- add stub for `PersonView.make_user()`
+- allow arbitrary kwargs for `Form.render_vue_field()`
+- make some tweaks for better tailbone compatibility
+- prevent delete for built-in roles
+
## v0.6.0 (2024-08-13)
### Feat
diff --git a/pyproject.toml b/pyproject.toml
index c3ce9d4..35d94ff 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
[project]
name = "WuttaWeb"
-version = "0.6.0"
+version = "0.7.0"
description = "Web App for Wutta Framework"
readme = "README.md"
authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
@@ -39,7 +39,7 @@ dependencies = [
"pyramid_tm",
"waitress",
"WebHelpers2",
- "WuttJamaican[db]>=0.11.0",
+ "WuttJamaican[db]>=0.11.1",
"zope.sqlalchemy>=1.5",
]
diff --git a/src/wuttaweb/app.py b/src/wuttaweb/app.py
index bafc921..845b41f 100644
--- a/src/wuttaweb/app.py
+++ b/src/wuttaweb/app.py
@@ -135,6 +135,12 @@ def make_pyramid_config(settings):
pyramid_config.include('pyramid_mako')
pyramid_config.include('pyramid_tm')
+ # add some permissions magic
+ pyramid_config.add_directive('add_wutta_permission_group',
+ 'wuttaweb.auth.add_permission_group')
+ pyramid_config.add_directive('add_wutta_permission',
+ 'wuttaweb.auth.add_permission')
+
return pyramid_config
diff --git a/src/wuttaweb/auth.py b/src/wuttaweb/auth.py
index de9b868..88b1fea 100644
--- a/src/wuttaweb/auth.py
+++ b/src/wuttaweb/auth.py
@@ -148,3 +148,93 @@ class WuttaSecurityPolicy:
auth = app.get_auth_handler()
user = self.identity(request)
return auth.has_permission(self.db_session, user, permission)
+
+
+def add_permission_group(pyramid_config, key, label=None, overwrite=True):
+ """
+ Pyramid directive to add a "permission group" to the app's
+ awareness.
+
+ The app must be made aware of all permissions, so they are exposed
+ when editing a
+ :class:`~wuttjamaican:wuttjamaican.db.model.auth.Role`. The logic
+ for discovering permissions is in
+ :meth:`~wuttaweb.views.roles.RoleView.get_available_permissions()`.
+
+ This is usually called from within a master view's
+ :meth:`~wuttaweb.views.master.MasterView.defaults()` to establish
+ the permission group which applies to the view model.
+
+ A simple example of usage::
+
+ pyramid_config.add_permission_group('widgets', label="Widgets")
+
+ :param key: Unique key for the permission group. In the context
+ of a master view, this will be the same as
+ :attr:`~wuttaweb.views.master.MasterView.permission_prefix`.
+
+ :param label: Optional label for the permission group. If not
+ specified, it is derived from ``key``.
+
+ :param overwrite: If the permission group was already established,
+ this flag controls whether the group's label should be
+ overwritten (with ``label``).
+
+ See also :func:`add_permission()`.
+ """
+ config = pyramid_config.get_settings()['wutta_config']
+ app = config.get_app()
+ def action():
+ perms = pyramid_config.get_settings().get('wutta_permissions', {})
+ if overwrite or key not in perms:
+ group = perms.setdefault(key, {'key': key})
+ group['label'] = label or app.make_title(key)
+ pyramid_config.add_settings({'wutta_permissions': perms})
+ pyramid_config.action(None, action)
+
+
+def add_permission(pyramid_config, groupkey, key, label=None):
+ """
+ Pyramid directive to add a single "permission" to the app's
+ awareness.
+
+ The app must be made aware of all permissions, so they are exposed
+ when editing a
+ :class:`~wuttjamaican:wuttjamaican.db.model.auth.Role`. The logic
+ for discovering permissions is in
+ :meth:`~wuttaweb.views.roles.RoleView.get_available_permissions()`.
+
+ This is usually called from within a master view's
+ :meth:`~wuttaweb.views.master.MasterView.defaults()` to establish
+ "known" permissions based on master view feature flags
+ (:attr:`~wuttaweb.views.master.MasterView.viewable`,
+ :attr:`~wuttaweb.views.master.MasterView.editable`, etc.).
+
+ A simple example of usage::
+
+ pyramid_config.add_permission('widgets', 'widgets.polish',
+ label="Polish all the widgets")
+
+ :param key: Unique key for the permission group. In the context
+ of a master view, this will be the same as
+ :attr:`~wuttaweb.views.master.MasterView.permission_prefix`.
+
+ :param key: Unique key for the permission. This should be the
+ "complete" permission name which includes the permission
+ prefix.
+
+ :param label: Optional label for the permission. If not
+ specified, it is derived from ``key``.
+
+ See also :func:`add_permission_group()`.
+ """
+ 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)
diff --git a/src/wuttaweb/forms/base.py b/src/wuttaweb/forms/base.py
index 7ee9b01..8ed17f8 100644
--- a/src/wuttaweb/forms/base.py
+++ b/src/wuttaweb/forms/base.py
@@ -120,6 +120,13 @@ class Form:
See also :meth:`set_validator()`.
+ .. attribute:: defaults
+
+ Dict of default field values, used to construct the form in
+ :meth:`get_schema()`.
+
+ See also :meth:`set_default()`.
+
.. attribute:: readonly
Boolean indicating the form does not allow submit. In practice
@@ -248,6 +255,7 @@ class Form:
nodes={},
widgets={},
validators={},
+ defaults={},
readonly=False,
readonly_fields=[],
required_fields={},
@@ -271,6 +279,7 @@ class Form:
self.nodes = nodes or {}
self.widgets = widgets or {}
self.validators = validators or {}
+ self.defaults = defaults or {}
self.readonly = readonly
self.readonly_fields = set(readonly_fields or [])
self.required_fields = required_fields or {}
@@ -375,6 +384,23 @@ class Form:
"""
self.fields = FieldList(fields)
+ def append(self, *keys):
+ """
+ Add some fields(s) to the form.
+
+ This is a convenience to allow adding multiple fields at
+ once::
+
+ form.append('first_field',
+ 'second_field',
+ 'third_field')
+
+ It will add each field to :attr:`fields`.
+ """
+ for key in keys:
+ if key not in self.fields:
+ self.fields.append(key)
+
def remove(self, *keys):
"""
Remove some fields(s) from the form.
@@ -471,6 +497,18 @@ class Form:
if self.schema and key in self.schema:
self.schema[key].validator = validator
+ def set_default(self, key, value):
+ """
+ Set/override the default value for a field.
+
+ :param key: Name of field.
+
+ :param validator: Default value for the field.
+
+ Default value overrides are tracked via :attr:`defaults`.
+ """
+ self.defaults[key] = value
+
def set_readonly(self, key, readonly=True):
"""
Enable or disable the "readonly" flag for a given field.
@@ -624,29 +662,22 @@ class Form:
if self.model_class:
- # first define full list of 'includes' - final schema
- # should contain all of these fields
- includes = list(fields)
-
- # determine which we want ColanderAlchemy to handle
- auto_includes = []
- for key in includes:
-
- # skip if we already have a node defined
+ # collect list of field names and/or nodes
+ includes = []
+ for key in fields:
if key in self.nodes:
- continue
-
- # we want the magic for this field
- auto_includes.append(key)
+ includes.append(self.nodes[key])
+ else:
+ includes.append(key)
# make initial schema with ColanderAlchemy magic
schema = SQLAlchemySchemaNode(self.model_class,
- includes=auto_includes)
+ includes=includes)
- # now fill in the blanks for non-magic fields
- for key in includes:
- if key not in auto_includes:
- node = self.nodes[key]
+ # fill in the blanks if anything got missed
+ for key in fields:
+ if key not in schema:
+ node = colander.SchemaNode(colander.String(), name=key)
schema.add(node)
else:
@@ -685,6 +716,11 @@ class Form:
elif key in schema: # field-level
schema[key].validator = validator
+ # apply default value overrides
+ for key, value in self.defaults.items():
+ if key in schema:
+ schema[key].default = value
+
# apply required flags
for key, required in self.required_fields.items():
if key in schema:
@@ -775,7 +811,12 @@ class Form:
output = render(template, context)
return HTML.literal(output)
- def render_vue_field(self, fieldname, readonly=None):
+ def render_vue_field(
+ self,
+ fieldname,
+ readonly=None,
+ **kwargs,
+ ):
"""
Render the given field completely, i.e. ```` wrapper
with label and containing a widget.
@@ -791,6 +832,12 @@ class Form:
message="something went wrong!">
+
+ .. warning::
+
+ Any ``**kwargs`` received from caller are ignored by this
+ method. For now they are allowed, for sake of backwawrd
+ compatibility. This may change in the future.
"""
# readonly comes from: caller, field flag, or form flag
if readonly is None:
@@ -903,6 +950,20 @@ class Form:
return model_data
+ # TODO: for tailbone compat, should document?
+ # (ideally should remove this and find a better way)
+ def get_vue_field_value(self, key):
+ """ """
+ if key not in self.fields:
+ return
+
+ dform = self.get_deform()
+ if key not in dform:
+ return
+
+ field = dform[key]
+ return make_json_safe(field.cstruct)
+
def validate(self):
"""
Try to validate the form, using data from the :attr:`request`.
diff --git a/src/wuttaweb/forms/schema.py b/src/wuttaweb/forms/schema.py
index ccb357f..98ce0f1 100644
--- a/src/wuttaweb/forms/schema.py
+++ b/src/wuttaweb/forms/schema.py
@@ -257,3 +257,101 @@ class PersonRef(ObjectRef):
def sort_query(self, query):
""" """
return query.order_by(self.model_class.full_name)
+
+
+class WuttaSet(colander.Set):
+ """
+ Custom schema type for :class:`python:set` fields.
+
+ This is a subclass of :class:`colander.Set`, but adds
+ Wutta-related params to the constructor.
+
+ :param request: Current :term:`request` object.
+
+ :param session: Optional :term:`db session` to use instead of
+ :class:`wuttaweb.db.Session`.
+ """
+
+ def __init__(self, request, session=None):
+ super().__init__()
+ self.request = request
+ self.config = self.request.wutta_config
+ self.app = self.config.get_app()
+ self.session = session or Session()
+
+
+class RoleRefs(WuttaSet):
+ """
+ Form schema type for the User
+ :attr:`~wuttjamaican:wuttjamaican.db.model.auth.User.roles`
+ association proxy field.
+
+ This is a subclass of :class:`WuttaSet`. It uses a ``set`` of
+ :class:`~wuttjamaican:wuttjamaican.db.model.auth.Role` ``uuid``
+ values for underlying data format.
+ """
+
+ def widget_maker(self, **kwargs):
+ """
+ Constructs a default widget for the field.
+
+ :returns: Instance of
+ :class:`~wuttaweb.forms.widgets.RoleRefsWidget`.
+ """
+ kwargs.setdefault('session', self.session)
+
+ if 'values' not in kwargs:
+ model = self.app.model
+ auth = self.app.get_auth_handler()
+ avoid = {
+ auth.get_role_authenticated(self.session),
+ auth.get_role_anonymous(self.session),
+ }
+ avoid = set([role.uuid for role in avoid])
+ roles = self.session.query(model.Role)\
+ .filter(~model.Role.uuid.in_(avoid))\
+ .order_by(model.Role.name)\
+ .all()
+ values = [(role.uuid, role.name) for role in roles]
+ kwargs['values'] = values
+
+ return widgets.RoleRefsWidget(self.request, **kwargs)
+
+
+class Permissions(WuttaSet):
+ """
+ Form schema type for the Role
+ :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Role.permissions`
+ association proxy field.
+
+ This is a subclass of :class:`WuttaSet`. It uses a ``set`` of
+ :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Permission.permission`
+ values for underlying data format.
+
+ :param permissions: Dict with all possible permissions. Should be
+ in the same format as returned by
+ :meth:`~wuttaweb.views.roles.RoleView.get_available_permissions()`.
+ """
+
+ def __init__(self, request, permissions, *args, **kwargs):
+ super().__init__(request, *args, **kwargs)
+ self.permissions = permissions
+
+ def widget_maker(self, **kwargs):
+ """
+ Constructs a default widget for the field.
+
+ :returns: Instance of
+ :class:`~wuttaweb.forms.widgets.PermissionsWidget`.
+ """
+ kwargs.setdefault('session', self.session)
+ kwargs.setdefault('permissions', self.permissions)
+
+ if 'values' not in kwargs:
+ values = []
+ for gkey, group in self.permissions.items():
+ for pkey, perm in group['perms'].items():
+ values.append((pkey, perm['label']))
+ kwargs['values'] = values
+
+ return widgets.PermissionsWidget(self.request, **kwargs)
diff --git a/src/wuttaweb/forms/widgets.py b/src/wuttaweb/forms/widgets.py
index 6627375..b4d8254 100644
--- a/src/wuttaweb/forms/widgets.py
+++ b/src/wuttaweb/forms/widgets.py
@@ -30,12 +30,21 @@ in the namespace:
* :class:`deform:deform.widget.Widget` (base class)
* :class:`deform:deform.widget.TextInputWidget`
+* :class:`deform:deform.widget.TextAreaWidget`
+* :class:`deform:deform.widget.PasswordWidget`
+* :class:`deform:deform.widget.CheckedPasswordWidget`
* :class:`deform:deform.widget.SelectWidget`
+* :class:`deform:deform.widget.CheckboxChoiceWidget`
"""
-from deform.widget import Widget, TextInputWidget, SelectWidget
+import colander
+from deform.widget import (Widget, TextInputWidget, TextAreaWidget,
+ PasswordWidget, CheckedPasswordWidget,
+ SelectWidget, CheckboxChoiceWidget)
from webhelpers2.html import HTML
+from wuttaweb.db import Session
+
class ObjectRefWidget(SelectWidget):
"""
@@ -48,6 +57,18 @@ class ObjectRefWidget(SelectWidget):
the form schema; via
:meth:`~wuttaweb.forms.schema.ObjectRef.widget_maker()`.
+ In readonly mode, this renders a ```` tag around the
+ :attr:`model_instance` (converted to string).
+
+ Otherwise it renders a select (dropdown) element allowing user to
+ choose from available records.
+
+ This is a subclass of :class:`deform:deform.widget.SelectWidget`
+ and uses these Deform templates:
+
+ * ``select``
+ * ``readonly/objectref``
+
.. attribute:: model_instance
Reference to the model record instance, i.e. the "far side" of
@@ -60,23 +81,112 @@ class ObjectRefWidget(SelectWidget):
when the :class:`~wuttaweb.forms.schema.ObjectRef` type
instance (associated with the node) is serialized.
"""
+ readonly_template = 'readonly/objectref'
def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = request
+
+class NotesWidget(TextAreaWidget):
+ """
+ Widget for use with "notes" fields.
+
+ In readonly mode, this shows the notes with a background to make
+ them stand out a bit more.
+
+ Otherwise it effectively shows a ``