diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bb0853..d8c5635 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ 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.6.0 (2024-08-13) + +### Feat + +- add basic Roles view +- add Users view; improve CRUD master for SQLAlchemy models +- add People view; improve CRUD master for SQLAlchemy models +- add basic support for SQLAlchemy model in master view +- add basic Create support for CRUD master view +- add basic Delete support for CRUD master view +- add basic Edit support for CRUD master view +- add auto-link (to "View") behavior for grid columns +- add basic support for "view" part of CRUD +- add basic `Grid` class, and /settings master view + +### Fix + +- rename MasterView method to `configure_grid()` +- replace default logo, favicon images +- tweak labels for Web Libraries config + ## v0.5.0 (2024-08-06) ### Feat diff --git a/docs/api/wuttaweb/forms.schema.rst b/docs/api/wuttaweb/forms.schema.rst new file mode 100644 index 0000000..333ade6 --- /dev/null +++ b/docs/api/wuttaweb/forms.schema.rst @@ -0,0 +1,6 @@ + +``wuttaweb.forms.schema`` +========================= + +.. automodule:: wuttaweb.forms.schema + :members: diff --git a/docs/api/wuttaweb/forms.widgets.rst b/docs/api/wuttaweb/forms.widgets.rst new file mode 100644 index 0000000..2fe509c --- /dev/null +++ b/docs/api/wuttaweb/forms.widgets.rst @@ -0,0 +1,6 @@ + +``wuttaweb.forms.widgets`` +========================== + +.. automodule:: wuttaweb.forms.widgets + :members: diff --git a/docs/api/wuttaweb/index.rst b/docs/api/wuttaweb/index.rst index 1b1a61f..1410a20 100644 --- a/docs/api/wuttaweb/index.rst +++ b/docs/api/wuttaweb/index.rst @@ -12,6 +12,8 @@ db forms forms.base + forms.schema + forms.widgets grids grids.base handler @@ -26,4 +28,7 @@ views.common views.essential views.master + views.people + views.roles views.settings + views.users diff --git a/docs/api/wuttaweb/views.people.rst b/docs/api/wuttaweb/views.people.rst new file mode 100644 index 0000000..89c6883 --- /dev/null +++ b/docs/api/wuttaweb/views.people.rst @@ -0,0 +1,6 @@ + +``wuttaweb.views.people`` +=========================== + +.. automodule:: wuttaweb.views.people + :members: diff --git a/docs/api/wuttaweb/views.roles.rst b/docs/api/wuttaweb/views.roles.rst new file mode 100644 index 0000000..8770256 --- /dev/null +++ b/docs/api/wuttaweb/views.roles.rst @@ -0,0 +1,6 @@ + +``wuttaweb.views.roles`` +======================== + +.. automodule:: wuttaweb.views.roles + :members: diff --git a/docs/api/wuttaweb/views.users.rst b/docs/api/wuttaweb/views.users.rst new file mode 100644 index 0000000..f4563a9 --- /dev/null +++ b/docs/api/wuttaweb/views.users.rst @@ -0,0 +1,6 @@ + +``wuttaweb.views.users`` +======================== + +.. automodule:: wuttaweb.views.users + :members: diff --git a/pyproject.toml b/pyproject.toml index 91c1912..c3ce9d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "WuttaWeb" -version = "0.5.0" +version = "0.6.0" description = "Web App for Wutta Framework" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -30,6 +30,7 @@ classifiers = [ ] requires-python = ">= 3.8" dependencies = [ + "ColanderAlchemy", "pyramid>=2", "pyramid_beaker", "pyramid_deform", @@ -38,7 +39,7 @@ dependencies = [ "pyramid_tm", "waitress", "WebHelpers2", - "WuttJamaican[db]>=0.10.0", + "WuttJamaican[db]>=0.11.0", "zope.sqlalchemy>=1.5", ] diff --git a/src/wuttaweb/forms/base.py b/src/wuttaweb/forms/base.py index 89664e3..7ee9b01 100644 --- a/src/wuttaweb/forms/base.py +++ b/src/wuttaweb/forms/base.py @@ -24,65 +24,20 @@ Base form classes """ -import json import logging import colander import deform +from colanderalchemy import SQLAlchemySchemaNode from pyramid.renderers import render from webhelpers2.html import HTML -from wuttaweb.util import get_form_data +from wuttaweb.util import FieldList, get_form_data, get_model_fields, make_json_safe log = logging.getLogger(__name__) -class FieldList(list): - """ - Convenience wrapper for a form's field list. This is a subclass - of :class:`python:list`. - - You normally would not need to instantiate this yourself, but it - is used under the hood for :attr:`Form.fields` as well as - :attr:`~wuttaweb.grids.base.Grid.columns`. - """ - - def insert_before(self, field, newfield): - """ - Insert a new field, before an existing field. - - :param field: String name for the existing field. - - :param newfield: String name for the new field, to be inserted - just before the existing ``field``. - """ - if field in self: - i = self.index(field) - self.insert(i, newfield) - else: - log.warning("field '%s' not found, will append new field: %s", - field, newfield) - self.append(newfield) - - def insert_after(self, field, newfield): - """ - Insert a new field, after an existing field. - - :param field: String name for the existing field. - - :param newfield: String name for the new field, to be inserted - just after the existing ``field``. - """ - if field in self: - i = self.index(field) - self.insert(i + 1, newfield) - else: - log.warning("field '%s' not found, will append new field: %s", - field, newfield) - self.append(newfield) - - class Form: """ Base class for all forms. @@ -112,23 +67,26 @@ class Form: .. attribute:: fields - :class:`FieldList` instance containing string field names for - the form. By default, fields will appear in the same order as - they are in this list. + :class:`~wuttaweb.util.FieldList` instance containing string + field names for the form. By default, fields will appear in + the same order as they are in this list. + + See also :meth:`set_fields()`. .. attribute:: schema - Colander-based schema object for the form. This is optional; - if not specified an attempt will be made to construct one - automatically. + :class:`colander:colander.Schema` object for the form. This is + optional; if not specified an attempt will be made to construct + one automatically. See also :meth:`get_schema()`. .. attribute:: model_class - Optional "class" for the model. If set, this usually would be - a SQLAlchemy mapped class. This may be used instead of - specifying the :attr:`schema`. + Model class for the form, if applicable. When set, this is + usually a SQLAlchemy mapped class. This (or + :attr:`model_instance`) may be used instead of specifying the + :attr:`schema`. .. attribute:: model_instance @@ -141,6 +99,27 @@ class Form: SQLAlchemy-mapped. (In that case :attr:`model_class` can be determined automatically.) + .. attribute:: nodes + + Dict of node overrides, used to construct the form in + :meth:`get_schema()`. + + See also :meth:`set_node()`. + + .. attribute:: widgets + + Dict of widget overrides, used to construct the form in + :meth:`get_schema()`. + + See also :meth:`set_widget()`. + + .. attribute:: validators + + Dict of node validators, used to construct the form in + :meth:`get_schema()`. + + See also :meth:`set_validator()`. + .. attribute:: readonly Boolean indicating the form does not allow submit. In practice @@ -149,10 +128,48 @@ class Form: Default for this is ``False`` in which case the ``
`` tag will exist and submit is allowed. + .. attribute:: readonly_fields + + A :class:`~python:set` of field names which should be readonly. + Each will still be rendered but with static value text and no + widget. + + This is only applicable if :attr:`readonly` is ``False``. + + See also :meth:`set_readonly()` and :meth:`is_readonly()`. + + .. attribute:: required_fields + + A dict of "required" field flags. Keys are field names, and + values are boolean flags indicating whether the field is + required. + + Depending on :attr:`schema`, some fields may be "(not) + required" by default. However ``required_fields`` keeps track + of any "overrides" per field. + + See also :meth:`set_required()` and :meth:`is_required()`. + .. attribute:: action_url String URL to which the form should be submitted, if applicable. + .. attribute:: cancel_url + + String URL to which the Cancel button should "always" redirect, + if applicable. + + Code should not access this directly, but instead call + :meth:`get_cancel_url()`. + + .. attribute:: cancel_url_fallback + + String URL to which the Cancel button should redirect, if + referrer cannot be determined from request. + + Code should not access this directly, but instead call + :meth:`get_cancel_url()`. + .. attribute:: vue_tagname String name for Vue component tag. By default this is @@ -177,9 +194,41 @@ class Form: String icon name for the form submit button. Default is ``'save'``. + .. attribute:: button_type_submit + + Buefy type for the submit button. Default is ``'is-primary'``, + so for example: + + .. code-block:: html + + + Save + + + See also the `Buefy docs + `_. + .. attribute:: show_button_reset Flag indicating whether a Reset button should be shown. + Default is ``False``. + + .. attribute:: show_button_cancel + + Flag indicating whether a Cancel button should be shown. + Default is ``True``. + + .. attribute:: button_label_cancel + + String label for the form cancel button. Default is + ``"Cancel"``. + + .. attribute:: auto_disable_cancel + + Flag indicating whether the cancel button should be + auto-disabled, whenever the button is clicked. Default is + ``True``. .. attribute:: validated @@ -196,40 +245,60 @@ class Form: schema=None, model_class=None, model_instance=None, + nodes={}, + widgets={}, + validators={}, readonly=False, + readonly_fields=[], + required_fields={}, labels={}, action_url=None, + cancel_url=None, + cancel_url_fallback=None, vue_tagname='wutta-form', align_buttons_right=False, auto_disable_submit=True, button_label_submit="Save", button_icon_submit='save', + button_type_submit='is-primary', show_button_reset=False, + show_button_cancel=True, + button_label_cancel="Cancel", + auto_disable_cancel=True, ): self.request = request self.schema = schema + self.nodes = nodes or {} + self.widgets = widgets or {} + self.validators = validators or {} self.readonly = readonly + self.readonly_fields = set(readonly_fields or []) + self.required_fields = required_fields or {} self.labels = labels or {} self.action_url = action_url + self.cancel_url = cancel_url + self.cancel_url_fallback = cancel_url_fallback self.vue_tagname = vue_tagname self.align_buttons_right = align_buttons_right self.auto_disable_submit = auto_disable_submit self.button_label_submit = button_label_submit self.button_icon_submit = button_icon_submit + self.button_type_submit = button_type_submit self.show_button_reset = show_button_reset + self.show_button_cancel = show_button_cancel + self.button_label_cancel = button_label_cancel + self.auto_disable_cancel = auto_disable_cancel self.config = self.request.wutta_config self.app = self.config.get_app() self.model_class = model_class self.model_instance = model_instance + if self.model_instance and not self.model_class: + if type(self.model_instance) is not dict: + self.model_class = type(self.model_instance) - if fields is not None: - self.set_fields(fields) - elif self.schema: - self.set_fields([f.name for f in self.schema]) - else: - self.fields = None + self.set_fields(fields or self.get_fields()) def __contains__(self, name): """ @@ -262,17 +331,216 @@ class Form: words = self.vue_tagname.split('-') return ''.join([word.capitalize() for word in words]) + def get_cancel_url(self): + """ + Returns the URL for the Cancel button. + + If :attr:`cancel_url` is set, its value is returned. + + Or, if the referrer can be deduced from the request, that is + returned. + + Or, if :attr:`cancel_url_fallback` is set, that value is + returned. + + As a last resort the "default" URL from + :func:`~wuttaweb.subscribers.request.get_referrer()` is + returned. + """ + # use "permanent" URL if set + if self.cancel_url: + return self.cancel_url + + # nb. use fake default to avoid normal default logic; + # that way if we get something it's a real referrer + url = self.request.get_referrer(default='NOPE') + if url and url != 'NOPE': + return url + + # use fallback URL if set + if self.cancel_url_fallback: + return self.cancel_url_fallback + + # okay, home page then (or whatever is the default URL) + return self.request.get_referrer() + def set_fields(self, fields): """ Explicitly set the list of form fields. This will overwrite :attr:`fields` with a new - :class:`FieldList` instance. + :class:`~wuttaweb.util.FieldList` instance. :param fields: List of string field names. """ self.fields = FieldList(fields) + def remove(self, *keys): + """ + Remove some fields(s) from the form. + + This is a convenience to allow removal of multiple fields at + once:: + + form.remove('first_field', + 'second_field', + 'third_field') + + It will remove each field from :attr:`fields`. + """ + for key in keys: + if key in self.fields: + self.fields.remove(key) + + def set_node(self, key, nodeinfo, **kwargs): + """ + Set/override the node for a field. + + :param key: Name of field. + + :param nodeinfo: Should be either a + :class:`colander:colander.SchemaNode` instance, or else a + :class:`colander:colander.SchemaType` instance. + + If ``nodeinfo`` is a proper node instance, it will be used + as-is. Otherwise an + :class:`~wuttaweb.forms.schema.ObjectNode` instance will be + constructed using ``nodeinfo`` as the type (``typ``). + + Node overrides are tracked via :attr:`nodes`. + """ + if isinstance(nodeinfo, colander.SchemaNode): + # assume nodeinfo is a complete node + node = nodeinfo + + else: # assume nodeinfo is a schema type + kwargs.setdefault('name', key) + + from wuttaweb.forms.schema import ObjectNode + + # node = colander.SchemaNode(nodeinfo, **kwargs) + node = ObjectNode(nodeinfo, **kwargs) + + self.nodes[key] = node + + # must explicitly replace node, if we already have a schema + if self.schema: + self.schema[key] = node + + def set_widget(self, key, widget): + """ + Set/override the widget for a field. + + :param key: Name of field. + + :param widget: Instance of + :class:`deform:deform.widget.Widget`. + + Widget overrides are tracked via :attr:`widgets`. + """ + self.widgets[key] = widget + + # update schema if necessary + if self.schema and key in self.schema: + self.schema[key].widget = widget + + def set_validator(self, key, validator): + """ + Set/override the validator for a field, or the form. + + :param key: Name of field. This may also be ``None`` in which + case the validator will apply to the whole form instead of + a field. + + :param validator: Callable which accepts ``(node, value)`` + args. For instance:: + + def validate_foo(node, value): + if value == 42: + node.raise_invalid("42 is not allowed!") + + form = Form(fields=['foo', 'bar']) + + form.set_validator('foo', validate_foo) + + Validator overrides are tracked via :attr:`validators`. + """ + self.validators[key] = validator + + # nb. must apply to existing schema if present + if self.schema and key in self.schema: + self.schema[key].validator = validator + + def set_readonly(self, key, readonly=True): + """ + Enable or disable the "readonly" flag for a given field. + + When a field is marked readonly, it will be shown in the form + but there will be no editable widget. The field is skipped + over (not saved) when form is submitted. + + See also :meth:`is_readonly()`; this is tracked via + :attr:`readonly_fields`. + + :param key: String key (fieldname) for the field. + + :param readonly: New readonly flag for the field. + """ + if readonly: + self.readonly_fields.add(key) + else: + if key in self.readonly_fields: + self.readonly_fields.remove(key) + + def is_readonly(self, key): + """ + Returns boolean indicating if the given field is marked as + readonly. + + See also :meth:`set_readonly()`. + + :param key: Field key/name as string. + """ + if self.readonly_fields: + if key in self.readonly_fields: + return True + return False + + def set_required(self, key, required=True): + """ + Enable or disable the "required" flag for a given field. + + When a field is marked required, a value must be provided + or else it fails validation. + + In practice if a field is "not required" then a default + "empty" value is assumed, should the user not provide one. + + See also :meth:`is_required()`; this is tracked via + :attr:`required_fields`. + + :param key: String key (fieldname) for the field. + + :param required: New required flag for the field. Usually a + boolean, but may also be ``None`` to remove any flag and + revert to default behavior for the field. + """ + self.required_fields[key] = required + + def is_required(self, key): + """ + Returns boolean indicating if the given field is marked as + required. + + See also :meth:`set_required()`. + + :param key: Field key/name as string. + + :returns: Value for the flag from :attr:`required_fields` if + present; otherwise ``None``. + """ + return self.required_fields.get(key, None) + def set_label(self, key, label): """ Set the label for given field name. @@ -296,6 +564,45 @@ class Form: """ return self.labels.get(key, self.app.make_title(key)) + def get_fields(self): + """ + Returns the official list of field names for the form, or + ``None``. + + If :attr:`fields` is set and non-empty, it is returned. + + Or, if :attr:`schema` is set, the field list is derived + from that. + + Or, if :attr:`model_class` is set, the field list is derived + from that, via :meth:`get_model_fields()`. + + Otherwise ``None`` is returned. + """ + if hasattr(self, 'fields') and self.fields: + return self.fields + + if self.schema: + return [field.name for field in self.schema] + + fields = self.get_model_fields() + if fields: + return fields + + return [] + + def get_model_fields(self, model_class=None): + """ + This method is a shortcut which calls + :func:`~wuttaweb.util.get_model_fields()`. + + :param model_class: Optional model class for which to return + fields. If not set, the form's :attr:`model_class` is + assumed. + """ + return get_model_fields(self.config, + model_class=model_class or self.model_class) + def get_schema(self): """ Return the :class:`colander:colander.Schema` object for the @@ -306,17 +613,86 @@ class Form: """ if not self.schema: - if self.fields: - schema = colander.Schema() - for name in self.fields: - schema.add(colander.SchemaNode( - colander.String(), - name=name)) - self.schema = schema + ############################## + # create schema + ############################## - else: # no fields + # get fields + fields = self.get_fields() + if not fields: raise NotImplementedError + 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 + if key in self.nodes: + continue + + # we want the magic for this field + auto_includes.append(key) + + # make initial schema with ColanderAlchemy magic + schema = SQLAlchemySchemaNode(self.model_class, + includes=auto_includes) + + # now fill in the blanks for non-magic fields + for key in includes: + if key not in auto_includes: + node = self.nodes[key] + schema.add(node) + + else: + + # make basic schema + schema = colander.Schema() + for key in fields: + node = None + + # use node override if present + if key in self.nodes: + node = self.nodes[key] + if not node: + + # otherwise make simple string node + node = colander.SchemaNode( + colander.String(), + name=key) + + schema.add(node) + + ############################## + # customize schema + ############################## + + # apply widget overrides + for key, widget in self.widgets.items(): + if key in schema: + schema[key].widget = widget + + # apply validator overrides + for key, validator in self.validators.items(): + if key is None: + # nb. this one is form-wide + schema.validator = validator + elif key in schema: # field-level + schema[key].validator = validator + + # apply required flags + for key, required in self.required_fields.items(): + if key in schema: + if required is False: + schema[key].missing = colander.null + + self.schema = schema + return self.schema def get_deform(self): @@ -325,11 +701,17 @@ class Form: generating it automatically if necessary. """ if not hasattr(self, 'deform_form'): + model = self.app.model schema = self.get_schema() kwargs = {} if self.model_instance: - kwargs['appstruct'] = self.model_instance + # TODO: would it be smarter to test with hasattr() ? + # if hasattr(schema, 'dictify'): + if isinstance(self.model_instance, model.Base): + kwargs['appstruct'] = schema.dictify(self.model_instance) + else: + kwargs['appstruct'] = self.model_instance form = deform.Form(schema, **kwargs) self.deform_form = form @@ -381,8 +763,10 @@ class Form: the output. """ context['form'] = self + context['dform'] = self.get_deform() context.setdefault('form_attrs', {}) context.setdefault('request', self.request) + context['model_data'] = self.get_vue_model_data() # auto disable button on submit if self.auto_disable_submit: @@ -408,17 +792,34 @@ class Form: """ - + # readonly comes from: caller, field flag, or form flag if readonly is None: - readonly = self.readonly + readonly = self.is_readonly(fieldname) + if not readonly: + readonly = self.readonly + + # but also, fields not in deform/schema must be readonly + dform = self.get_deform() + if not readonly and fieldname not in dform: + readonly = True # render the field widget or whatever - dform = self.get_deform() - field = dform[fieldname] - kw = {} - if readonly: - kw['readonly'] = True - html = field.serialize(**kw) + if fieldname in dform: + + # render proper widget if field is in deform/schema + field = dform[fieldname] + kw = {} + if readonly: + kw['readonly'] = True + html = field.serialize(**kw) + + else: + # render static text if field not in deform/schema + # TODO: need to abstract this somehow + if self.model_instance: + html = str(self.model_instance[fieldname]) + else: + html = '' # mark all that as safe html = HTML.literal(html) @@ -463,6 +864,100 @@ class Form: return HTML.tag('b-field', c=[html], **attrs) + def get_vue_model_data(self): + """ + Returns a dict with form model data. Values may be nested + depending on the types of fields contained in the form. + + Note that the values need not be "converted" (to be + JSON-compatible) at this stage, for instance ``colander.null`` + is not a problem here. The point is to collect the raw data. + + The dict should have a key/value for each field in the form. + + This method is called by :meth:`render_vue_model_data()` which + is responsible for ensuring JSON compatibility. + """ + dform = self.get_deform() + model_data = {} + + def assign(field): + model_data[field.oid] = make_json_safe(field.cstruct) + + for key in self.fields: + + # TODO: i thought commented code was useful, but no longer sure? + + # TODO: need to describe the scenario when this is true + if key not in dform: + # log.warning("field '%s' is missing from deform", key) + continue + + field = dform[key] + + # if hasattr(field, 'children'): + # for subfield in field.children: + # assign(subfield) + + assign(field) + + return model_data + + def validate(self): + """ + Try to validate the form, using data from the :attr:`request`. + + Uses :func:`~wuttaweb.util.get_form_data()` to retrieve the + form data from POST or JSON body. + + If the form data is valid, the data dict is returned. This + data dict is also made available on the form object via the + :attr:`validated` attribute. + + However if the data is not valid, ``False`` is returned, and + there will be no :attr:`validated` attribute. In that case + you should inspect the form errors to learn/display what went + wrong for the user's sake. See also + :meth:`get_field_errors()`. + + This uses :meth:`deform:deform.Field.validate()` under the + hood. + + .. warning:: + + Calling ``validate()`` on some forms will cause the + underlying Deform and Colander structures to mutate. In + particular, all :attr:`readonly_fields` will be *removed* + from the :attr:`schema` to ensure they are not involved in + the validation. + + :returns: Data dict, or ``False``. + """ + if hasattr(self, 'validated'): + del self.validated + + if self.request.method != 'POST': + return False + + # remove all readonly fields from deform / schema + dform = self.get_deform() + if self.readonly_fields: + schema = self.get_schema() + for field in self.readonly_fields: + if field in schema: + del schema[field] + dform.children.remove(dform[field]) + + # let deform do real validation + controls = get_form_data(self.request).items() + try: + self.validated = dform.validate(controls) + except deform.ValidationFailure: + log.debug("form not valid: %s", dform.error) + return False + + return self.validated + def get_field_errors(self, field): """ Return a list of error messages for the given field. @@ -475,70 +970,3 @@ class Form: if error: return [error] return [] - - def get_vue_field_value(self, field): - """ - This method returns a JSON string which will be assigned as - the initial model value for the given field. This JSON will - be written as part of the overall response, to be interpreted - on the client side. - - Again, this must return a *string* such as: - - * ``'null'`` - * ``'{"foo": "bar"}'`` - - In practice this calls :meth:`jsonify_value()` to convert the - ``field.cstruct`` value to string. - """ - if isinstance(field, str): - dform = self.get_deform() - field = dform[field] - - return self.jsonify_value(field.cstruct) - - def jsonify_value(self, value): - """ - Convert a Python value to JSON string. - - See also :meth:`get_vue_field_value()`. - """ - if value is colander.null: - return 'null' - - return json.dumps(value) - - def validate(self): - """ - Try to validate the form. - - This should work whether request data was submitted as classic - POST data, or as JSON body. - - If the form data is valid, this method returns the data dict. - This data dict is also then available on the form object via - the :attr:`validated` attribute. - - However if the data is not valid, ``False`` is returned, and - there will be no :attr:`validated` attribute. In that case - you should inspect the form errors to learn/display what went - wrong for the user's sake. See also - :meth:`get_field_errors()`. - - :returns: Data dict, or ``False``. - """ - if hasattr(self, 'validated'): - del self.validated - - if self.request.method != 'POST': - return False - - dform = self.get_deform() - controls = get_form_data(self.request).items() - - try: - self.validated = dform.validate(controls) - except deform.ValidationFailure: - return False - - return self.validated diff --git a/src/wuttaweb/forms/schema.py b/src/wuttaweb/forms/schema.py new file mode 100644 index 0000000..ccb357f --- /dev/null +++ b/src/wuttaweb/forms/schema.py @@ -0,0 +1,259 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# wuttaweb -- Web App for Wutta Framework +# Copyright © 2024 Lance Edgar +# +# This file is part of Wutta Framework. +# +# Wutta Framework is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# Wutta Framework is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# Wutta Framework. If not, see . +# +################################################################################ +""" +Form schema types +""" + +import colander + +from wuttaweb.db import Session +from wuttaweb.forms import widgets +from wuttjamaican.db.model import Person + + +class ObjectNode(colander.SchemaNode): + """ + Custom schema node class which adds methods for compatibility with + ColanderAlchemy. This is a direct subclass of + :class:`colander:colander.SchemaNode`. + + ColanderAlchemy will call certain methods on any node found in the + schema. However these methods are not "standard" and only exist + for ColanderAlchemy nodes. + + So we must add nodes using this class, to ensure the node has all + methods needed by ColanderAlchemy. + """ + + def dictify(self, obj): + """ + This method is called by ColanderAlchemy when translating the + in-app Python object to a value suitable for use in the form + data dict. + + The logic here will look for a ``dictify()`` method on the + node's "type" instance (``self.typ``; see also + :class:`colander:colander.SchemaNode`) and invoke it if found. + + For an example type which is supported in this way, see + :class:`ObjectRef`. + + If the node's type does not have a ``dictify()`` method, this + will raise ``NotImplementeError``. + """ + if hasattr(self.typ, 'dictify'): + return self.typ.dictify(obj) + + class_name = self.typ.__class__.__name__ + raise NotImplementedError(f"you must define {class_name}.dictify()") + + def objectify(self, value): + """ + This method is called by ColanderAlchemy when translating form + data to the final Python representation. + + The logic here will look for an ``objectify()`` method on the + node's "type" instance (``self.typ``; see also + :class:`colander:colander.SchemaNode`) and invoke it if found. + + For an example type which is supported in this way, see + :class:`ObjectRef`. + + If the node's type does not have an ``objectify()`` method, + this will raise ``NotImplementeError``. + """ + if hasattr(self.typ, 'objectify'): + return self.typ.objectify(value) + + class_name = self.typ.__class__.__name__ + raise NotImplementedError(f"you must define {class_name}.objectify()") + + +class ObjectRef(colander.SchemaType): + """ + Custom schema type for a model class reference field. + + This expects the incoming ``appstruct`` to be either a model + record instance, or ``None``. + + Serializes to the instance UUID as string, or ``colander.null``; + form data should be of the same nature. + + This schema type is not useful directly, but various other types + will subclass it. Each should define (at least) the + :attr:`model_class` attribute or property. + + :param request: Current :term:`request` object. + + :param empty_option: If a select widget is used, this determines + whether an empty option is included for the dropdown. Set + this to one of the following to add an empty option: + + * ``True`` to add the default empty option + * label text for the empty option + * tuple of ``(value, label)`` for the empty option + + Note that in the latter, ``value`` must be a string. + """ + + default_empty_option = ('', "(none)") + + def __init__( + self, + request, + empty_option=None, + session=None, + *args, + **kwargs, + ): + super().__init__(*args, **kwargs) + self.request = request + self.config = self.request.wutta_config + self.app = self.config.get_app() + self.model_instance = None + self.session = session or Session() + + if empty_option: + if empty_option is True: + self.empty_option = self.default_empty_option + elif isinstance(empty_option, tuple) and len(empty_option) == 2: + self.empty_option = empty_option + else: + self.empty_option = ('', str(empty_option)) + else: + self.empty_option = None + + @property + def model_class(self): + """ + Should be a reference to the model class to which this schema + type applies + (e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`). + """ + class_name = self.__class__.__name__ + raise NotImplementedError(f"you must define {class_name}.model_class") + + def serialize(self, node, appstruct): + """ """ + if appstruct is colander.null: + return colander.null + + # nb. keep a ref to this for later use + node.model_instance = appstruct + + # serialize to uuid + return appstruct.uuid + + def deserialize(self, node, cstruct): + """ """ + if not cstruct: + return colander.null + + # nb. use shortcut to fetch model instance from DB + return self.objectify(cstruct) + + def dictify(self, obj): + """ """ + + # TODO: would we ever need to do something else? + return obj + + def objectify(self, value): + """ + For the given UUID value, returns the object it represents + (based on :attr:`model_class`). + + If the value is empty, returns ``None``. + + If the value is not empty but object cannot be found, raises + ``colander.Invalid``. + """ + if not value: + return + + if isinstance(value, self.model_class): + return value + + # fetch object from DB + model = self.app.model + obj = self.session.query(self.model_class).get(value) + + # raise error if not found + if not obj: + class_name = self.model_class.__name__ + raise ValueError(f"{class_name} not found: {value}") + + return obj + + def get_query(self): + """ + Returns the main SQLAlchemy query responsible for locating the + dropdown choices for the select widget. + + This is called by :meth:`widget_maker()`. + """ + query = self.session.query(self.model_class) + query = self.sort_query(query) + return query + + def sort_query(self, query): + """ + TODO + """ + return query + + def widget_maker(self, **kwargs): + """ + This method is responsible for producing the default widget + for the schema node. + + Deform calls this method automatically when constructing the + default widget for a field. + + :returns: Instance of + :class:`~wuttaweb.forms.widgets.ObjectRefWidget`. + """ + + if 'values' not in kwargs: + query = self.get_query() + objects = query.all() + values = [(obj.uuid, str(obj)) + for obj in objects] + if self.empty_option: + values.insert(0, self.empty_option) + kwargs['values'] = values + + return widgets.ObjectRefWidget(self.request, **kwargs) + + +class PersonRef(ObjectRef): + """ + Custom schema type for a ``Person`` reference field. + + This is a subclass of :class:`ObjectRef`. + """ + model_class = Person + + def sort_query(self, query): + """ """ + return query.order_by(self.model_class.full_name) diff --git a/src/wuttaweb/forms/widgets.py b/src/wuttaweb/forms/widgets.py new file mode 100644 index 0000000..6627375 --- /dev/null +++ b/src/wuttaweb/forms/widgets.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# wuttaweb -- Web App for Wutta Framework +# Copyright © 2024 Lance Edgar +# +# This file is part of Wutta Framework. +# +# Wutta Framework is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# Wutta Framework is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# Wutta Framework. If not, see . +# +################################################################################ +""" +Form widgets + +This module defines some custom widgets for use with WuttaWeb. + +However for convenience it also makes other Deform widgets available +in the namespace: + +* :class:`deform:deform.widget.Widget` (base class) +* :class:`deform:deform.widget.TextInputWidget` +* :class:`deform:deform.widget.SelectWidget` +""" + +from deform.widget import Widget, TextInputWidget, SelectWidget +from webhelpers2.html import HTML + + +class ObjectRefWidget(SelectWidget): + """ + Widget for use with model "object reference" fields, e.g. foreign + key UUID => TargetModel instance. + + While you may create instances of this widget directly, it + normally happens automatically when schema nodes of the + :class:`~wuttaweb.forms.schema.ObjectRef` (sub)type are part of + the form schema; via + :meth:`~wuttaweb.forms.schema.ObjectRef.widget_maker()`. + + .. attribute:: model_instance + + Reference to the model record instance, i.e. the "far side" of + the foreign key relationship. + + .. note:: + + You do not need to provide the ``model_instance`` when + constructing the widget. Rather, it is set automatically + when the :class:`~wuttaweb.forms.schema.ObjectRef` type + instance (associated with the node) is serialized. + """ + + def __init__(self, request, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + + def serialize(self, field, cstruct, **kw): + """ + Serialize the widget. + + In readonly mode, returns a ```` tag around the + :attr:`model_instance` rendered as string. + + Otherwise renders via the ``deform/select`` template. + """ + readonly = kw.get('readonly', self.readonly) + if readonly: + obj = field.schema.model_instance + return HTML.tag('span', c=str(obj or '')) + + return super().serialize(field, cstruct, **kw) diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py index 17eaa3f..a53cca4 100644 --- a/src/wuttaweb/grids/base.py +++ b/src/wuttaweb/grids/base.py @@ -24,10 +24,18 @@ Base grid classes """ +import json +import logging + +import sqlalchemy as sa + from pyramid.renderers import render from webhelpers2.html import HTML -from wuttaweb.forms import FieldList +from wuttaweb.util import FieldList, get_model_fields, make_json_safe + + +log = logging.getLogger(__name__) class Grid: @@ -52,13 +60,19 @@ class Grid: Presumably unique key for the grid; used to track per-grid sort/filter settings etc. + .. attribute:: model_class + + Model class for the grid, if applicable. When set, this is + usually a SQLAlchemy mapped class. This may be used for + deriving the default :attr:`columns` among other things. + .. attribute:: columns :class:`~wuttaweb.forms.base.FieldList` instance containing string column names for the grid. Columns will appear in the same order as they are in this list. - See also :meth:`set_columns()`. + See also :meth:`set_columns()` and :meth:`get_columns()`. .. attribute:: data @@ -72,6 +86,13 @@ class Grid: List of :class:`GridAction` instances represenging action links to be shown for each record in the grid. + .. attribute:: linked_columns + + List of column names for which auto-link behavior should be + applied. + + See also :meth:`set_link()` and :meth:`is_linked()`. + .. attribute:: vue_tagname String name for Vue component tag. By default this is @@ -81,25 +102,59 @@ class Grid: def __init__( self, request, + model_class=None, key=None, columns=None, data=None, actions=[], + linked_columns=[], vue_tagname='wutta-grid', ): self.request = request + self.model_class = model_class self.key = key self.data = data self.actions = actions or [] + self.linked_columns = linked_columns or [] self.vue_tagname = vue_tagname self.config = self.request.wutta_config self.app = self.config.get_app() - if columns is not None: - self.set_columns(columns) - else: - self.columns = None + self.set_columns(columns or self.get_columns()) + + def get_columns(self): + """ + Returns the official list of column names for the grid, or + ``None``. + + If :attr:`columns` is set and non-empty, it is returned. + + Or, if :attr:`model_class` is set, the field list is derived + from that, via :meth:`get_model_columns()`. + + Otherwise ``None`` is returned. + """ + if hasattr(self, 'columns') and self.columns: + return self.columns + + columns = self.get_model_columns() + if columns: + return columns + + return [] + + def get_model_columns(self, model_class=None): + """ + This method is a shortcut which calls + :func:`~wuttaweb.util.get_model_fields()`. + + :param model_class: Optional model class for which to return + fields. If not set, the grid's :attr:`model_class` is + assumed. + """ + return get_model_fields(self.config, + model_class=model_class or self.model_class) @property def vue_component(self): @@ -122,6 +177,68 @@ class Grid: """ self.columns = FieldList(columns) + def remove(self, *keys): + """ + Remove some column(s) from the grid. + + This is a convenience to allow removal of multiple columns at + once:: + + grid.remove('first_field', + 'second_field', + 'third_field') + + It will remove each column from :attr:`columns`. + """ + for key in keys: + if key in self.columns: + self.columns.remove(key) + + def set_link(self, key, link=True): + """ + Explicitly enable or disable auto-link behavior for a given + column. + + If a column has auto-link enabled, then each of its cell + contents will automatically be wrapped with a hyperlink. The + URL for this will be the same as for the "View" + :class:`GridAction` + (aka. :meth:`~wuttaweb.views.master.MasterView.view()`). + Although of course each cell gets a different link depending + on which data record it points to. + + It is typical to enable auto-link for fields relating to ID, + description etc. or some may prefer to auto-link all columns. + + See also :meth:`is_linked()`; the list is tracked via + :attr:`linked_columns`. + + :param key: Column key as string. + + :param link: Boolean indicating whether column's cell contents + should be auto-linked. + """ + if link: + if key not in self.linked_columns: + self.linked_columns.append(key) + else: # unlink + if self.linked_columns and key in self.linked_columns: + self.linked_columns.remove(key) + + def is_linked(self, key): + """ + Returns boolean indicating if auto-link behavior is enabled + for a given column. + + See also :meth:`set_link()` which describes auto-link behavior. + + :param key: Column key as string. + """ + if self.linked_columns: + if key in self.linked_columns: + return True + return False + def render_vue_tag(self, **kwargs): """ Render the Vue component tag for the grid. @@ -201,22 +318,52 @@ class Grid: Returns a list of Vue-compatible data records. This uses :attr:`data` as the basis, but may add some extra - values to each record for sake of action URLs etc. + values to each record, e.g. URLs for :attr:`actions` etc. - See also :meth:`get_vue_columns()`. + Importantly, this also ensures each value in the dict is + JSON-serializable, using + :func:`~wuttaweb.util.make_json_safe()`. + + :returns: List of data record dicts for use with Vue table + component. """ - # use data as-is unless we have actions - if not self.actions: - return self.data + original_data = self.data or [] + + # TODO: at some point i thought it was useful to wrangle the + # columns here, but now i can't seem to figure out why..? + + # # determine which columns are relevant for data set + # columns = None + # if not columns: + # columns = self.get_columns() + # if not columns: + # raise ValueError("cannot determine columns for the grid") + # columns = set(columns) + # if self.model_class: + # mapper = sa.inspect(self.model_class) + # for column in mapper.primary_key: + # columns.add(column.key) + + # # prune data fields for which no column is defined + # for i, record in enumerate(original_data): + # original_data[i]= dict([(key, record[key]) + # for key in columns]) # we have action(s), so add URL(s) for each record in data data = [] - for i, record in enumerate(self.data): - record = dict(record) + for i, record in enumerate(original_data): + + # convert data if needed, for json compat + record = make_json_safe(record, + # TODO: is this a good idea? + warn=False) + + # add action urls to each record for action in self.actions: url = action.get_url(record, i) key = f'_action_url_{action.key}' record[key] = url + data.append(record) return data @@ -277,6 +424,10 @@ class GridAction: Name of icon to be shown for the action link. See also :meth:`render_icon()`. + + .. attribute:: link_class + + Optional HTML class attribute for the action's ```` tag. """ def __init__( @@ -286,6 +437,7 @@ class GridAction: label=None, url=None, icon=None, + link_class=None, ): self.request = request self.config = self.request.wutta_config @@ -294,6 +446,20 @@ class GridAction: self.url = url self.label = label or self.app.make_title(key) self.icon = icon or key + self.link_class = link_class or '' + + def render_icon_and_label(self): + """ + Render the HTML snippet for action link icon and label. + + Default logic returns the output from :meth:`render_icon()` + and :meth:`render_label()`. + """ + html = [ + self.render_icon(), + self.render_label(), + ] + return HTML.literal(' ').join(html) def render_icon(self): """ @@ -305,6 +471,8 @@ class GridAction: .. code-block:: html + + See also :meth:`render_icon_and_label()`. """ if self.request.use_oruga: raise NotImplementedError @@ -316,6 +484,8 @@ class GridAction: Render the label text for the action link. Default behavior is to return :attr:`label` as-is. + + See also :meth:`render_icon_and_label()`. """ return self.label diff --git a/src/wuttaweb/menus.py b/src/wuttaweb/menus.py index 92e8162..fc4581a 100644 --- a/src/wuttaweb/menus.py +++ b/src/wuttaweb/menus.py @@ -97,11 +97,41 @@ class MenuHandler(GenericHandler): is expected for most apps to override it. The return value should be a list of dicts as described above. + + The default logic returns a list of menus obtained from + calling these methods: + + * :meth:`make_people_menu()` + * :meth:`make_admin_menu()` """ return [ + self.make_people_menu(request), self.make_admin_menu(request), ] + def make_people_menu(self, request, **kwargs): + """ + Generate a typical People menu. + + This method provides a semi-sane menu set by default, but it + is expected for most apps to override it. + + The return value for this method should be a *single* dict, + which will ultimately be one element of the final list of + dicts as described in :class:`MenuHandler`. + """ + return { + 'title': "People", + 'type': 'menu', + 'items': [ + { + 'title': "All People", + 'route': 'people', + 'perm': 'people.list', + }, + ], + } + def make_admin_menu(self, request, **kwargs): """ Generate a typical Admin menu. @@ -111,12 +141,23 @@ class MenuHandler(GenericHandler): The return value for this method should be a *single* dict, which will ultimately be one element of the final list of - dicts as described above. + dicts as described in :class:`MenuHandler`. """ return { 'title': "Admin", 'type': 'menu', 'items': [ + { + 'title': "Users", + 'route': 'users', + 'perm': 'users.list', + }, + { + 'title': "Roles", + 'route': 'roles', + 'perm': 'roles.list', + }, + {'type': 'sep'}, { 'title': "App Info", 'route': 'appinfo', diff --git a/src/wuttaweb/static/img/favicon.ico b/src/wuttaweb/static/img/favicon.ico index 2b7edf1..daf0cbc 100644 Binary files a/src/wuttaweb/static/img/favicon.ico and b/src/wuttaweb/static/img/favicon.ico differ diff --git a/src/wuttaweb/static/img/logo.png b/src/wuttaweb/static/img/logo.png index 96fae96..823ff75 100644 Binary files a/src/wuttaweb/static/img/logo.png and b/src/wuttaweb/static/img/logo.png differ diff --git a/src/wuttaweb/subscribers.py b/src/wuttaweb/subscribers.py index 92d40d9..2202059 100644 --- a/src/wuttaweb/subscribers.py +++ b/src/wuttaweb/subscribers.py @@ -64,7 +64,7 @@ def new_request(event): Reference to the app :term:`config object`. - .. method:: request.get_referrer(default=None) + .. function:: request.get_referrer(default=None) Request method to get the "canonical" HTTP referrer value. This has logic to check for referrer in the request params, diff --git a/src/wuttaweb/templates/appinfo/index.mako b/src/wuttaweb/templates/appinfo/index.mako index 7354514..710d02c 100644 --- a/src/wuttaweb/templates/appinfo/index.mako +++ b/src/wuttaweb/templates/appinfo/index.mako @@ -3,7 +3,7 @@ <%def name="page_content()"> -