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