Source code for wuttaweb.forms.base
# -*- coding: utf-8; -*-
################################################################################
#
# wuttaweb -- Web App for Wutta Framework
# Copyright © 2024-2025 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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Base form classes
"""
# pylint: disable=too-many-lines
import logging
from collections import OrderedDict
import sqlalchemy as sa
from sqlalchemy import orm
import colander
import deform
from colanderalchemy import SQLAlchemySchemaNode
from pyramid.renderers import render
from webhelpers2.html import HTML
from wuttaweb.util import (
FieldList,
get_form_data,
get_model_fields,
make_json_safe,
render_vue_finalize,
)
log = logging.getLogger(__name__)
[docs]
class Form: # pylint: disable=too-many-instance-attributes,too-many-public-methods
"""
Base class for all forms.
:param request: Reference to current :term:`request` object.
:param fields: List of field names for the form. This is
optional; if not specified an attempt will be made to deduce
the list automatically. See also :attr:`fields`.
:param schema: Colander-based 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()`.
:param labels: Optional dict of default field labels.
.. note::
Some parameters are not explicitly described above. However
their corresponding attributes are described below.
Form instances contain the following attributes:
.. attribute:: request
Reference to current :term:`request` object.
.. attribute:: fields
: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
: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
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
Optional instance from which initial form data should be
obtained. In simple cases this might be a dict, or maybe an
instance of :attr:`model_class`.
Note that this also may be used instead of specifying the
:attr:`schema`, if the instance belongs to a class which is
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:: 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
this means there will not even be a ``<form>`` tag involved.
Default for this is ``False`` in which case the ``<form>`` 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_method
HTTP method to use when submitting form; ``'post'`` is default.
.. attribute:: action_url
String URL to which the form should be submitted, if applicable.
.. attribute:: reset_url
String URL to which the reset button should "always" redirect,
if applicable.
This is null by default, in which case it will use standard
browser behavior for the form reset button (if shown). See
also :attr:`show_button_reset`.
.. 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
``'wutta-form'``. See also :meth:`render_vue_tag()`.
See also :attr:`vue_component`.
.. attribute:: align_buttons_right
Flag indicating whether the buttons (submit, cancel etc.)
should be aligned to the right of the area below the form. If
not set, the buttons are left-aligned.
.. attribute:: auto_disable_submit
Flag indicating whether the submit button should be
auto-disabled, whenever the form is submitted.
.. attribute:: button_label_submit
String label for the form submit button. Default is ``"Save"``.
.. attribute:: button_icon_submit
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
<b-button type="is-primary"
native-type="submit">
Save
</b-button>
See also the `Buefy docs
<https://buefy.org/documentation/button/#api-view>`_.
.. attribute:: show_button_reset
Flag indicating whether a Reset button should be shown.
Default is ``False``.
Unless there is a :attr:`reset_url`, the reset button will use
standard behavior per the browser.
.. 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
If the :meth:`validate()` method was called, and it succeeded,
this will be set to the validated data dict.
"""
deform_form = None
validated = None
vue_template = "/forms/vue_template.mako"
fields_template = "/forms/vue_fields.mako"
buttons_template = "/forms/vue_buttons.mako"
def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
self,
request,
fields=None,
schema=None,
model_class=None,
model_instance=None,
nodes=None,
widgets=None,
validators=None,
defaults=None,
readonly=False,
readonly_fields=None,
required_fields=None,
labels=None,
action_method="post",
action_url=None,
reset_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.defaults = defaults or {}
self.readonly = readonly
self.readonly_fields = set(readonly_fields or [])
self.required_fields = required_fields or {}
self.labels = labels or {}
self.action_method = action_method
self.action_url = action_url
self.cancel_url = cancel_url
self.cancel_url_fallback = cancel_url_fallback
self.reset_url = reset_url
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.form_attrs = {}
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 not isinstance(self.model_instance, dict):
self.model_class = type(self.model_instance)
self.set_fields(fields or self.get_fields())
self.set_default_widgets()
# nb. this tracks grid JSON data for inclusion in page template
self.grid_vue_context = OrderedDict()
def __contains__(self, name):
"""
Custom logic for the ``in`` operator, to allow easily checking
if the form contains a given field::
myform = Form()
if 'somefield' in myform:
print("my form has some field")
"""
return bool(self.fields and name in self.fields)
def __iter__(self):
"""
Custom logic to allow iterating over form field names::
myform = Form(fields=['foo', 'bar'])
for fieldname in myform:
print(fieldname)
"""
return iter(self.fields)
@property
def vue_component(self):
"""
String name for the Vue component, e.g. ``'WuttaForm'``.
This is a generated value based on :attr:`vue_tagname`.
"""
words = self.vue_tagname.split("-")
return "".join([word.capitalize() for word in words])
[docs]
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()
[docs]
def set_fields(self, fields):
"""
Explicitly set the list of form fields.
This will overwrite :attr:`fields` with a new
:class:`~wuttaweb.util.FieldList` instance.
:param fields: List of string field names.
"""
self.fields = FieldList(fields)
[docs]
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)
[docs]
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)
[docs]
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`.
"""
from wuttaweb.forms.schema import ( # pylint: disable=import-outside-toplevel
ObjectNode,
)
if isinstance(nodeinfo, colander.SchemaNode):
# assume nodeinfo is a complete node
node = nodeinfo
else: # assume nodeinfo is a schema type
kwargs.setdefault("name", key)
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
[docs]
def set_widget(self, key, widget, **kwargs):
"""
Set/override the widget for a field.
You can specify a widget instance or else a named "type" of
widget, in which case that is passed along to
:meth:`make_widget()`.
:param key: Name of field.
:param widget: Either a :class:`deform:deform.widget.Widget`
instance, or else a widget "type" name.
:param \\**kwargs: Any remaining kwargs are passed along to
:meth:`make_widget()` - if applicable.
Widget overrides are tracked via :attr:`widgets`.
"""
if not isinstance(widget, deform.widget.Widget):
widget_obj = self.make_widget(widget, **kwargs)
if not widget_obj:
raise ValueError(f"widget type not supported: {widget}")
widget = widget_obj
self.widgets[key] = widget
# update schema if necessary
if self.schema and key in self.schema:
self.schema[key].widget = widget
[docs]
def make_widget(self, widget_type, **kwargs):
"""
Make and return a new field widget of the given type.
This has built-in support for the following types (although
subclass can override as needed):
* ``'notes'`` => :class:`~wuttaweb.forms.widgets.NotesWidget`
See also :meth:`set_widget()` which may call this method
automatically.
:param widget_type: Which of the above (or custom) widget
type to create.
:param \\**kwargs: Remaining kwargs are passed as-is to the
widget factory.
:returns: New widget instance, or ``None`` if e.g. it could
not determine how to create the widget.
"""
from wuttaweb.forms import widgets # pylint: disable=import-outside-toplevel
if widget_type == "notes":
return widgets.NotesWidget(**kwargs)
return None
[docs]
def set_default_widgets(self):
"""
Set default field widgets, where applicable.
This will add new entries to :attr:`widgets` for columns
whose data type implies a default widget should be used.
This is generally only possible if :attr:`model_class` is set
to a valid SQLAlchemy mapped class.
This only checks for a couple of data types, with mapping as
follows:
* :class:`sqlalchemy:sqlalchemy.types.Date` ->
:class:`~wuttaweb.forms.widgets.WuttaDateWidget`
* :class:`sqlalchemy:sqlalchemy.types.DateTime` ->
:class:`~wuttaweb.forms.widgets.WuttaDateTimeWidget`
"""
from wuttaweb.forms import widgets # pylint: disable=import-outside-toplevel
if not self.model_class:
return
for key in self.fields:
if key in self.widgets:
continue
attr = getattr(self.model_class, key, None)
if attr:
prop = getattr(attr, "prop", None)
if prop and isinstance(prop, orm.ColumnProperty):
column = prop.columns[0]
if isinstance(column.type, sa.Date):
self.set_widget(key, widgets.WuttaDateWidget(self.request))
elif isinstance(column.type, sa.DateTime):
self.set_widget(key, widgets.WuttaDateTimeWidget(self.request))
[docs]
def set_grid(self, key, grid):
"""
Establish a :term:`grid` to be displayed for a field. This
uses a :class:`~wuttaweb.forms.widgets.GridWidget` to wrap the
rendered grid.
:param key: Name of field.
:param widget: :class:`~wuttaweb.grids.base.Grid` instance,
pre-configured and (usually) with data.
"""
from wuttaweb.forms.widgets import ( # pylint: disable=import-outside-toplevel
GridWidget,
)
widget = GridWidget(self.request, grid)
self.set_widget(key, widget)
self.add_grid_vue_context(grid)
def add_grid_vue_context(self, grid): # pylint: disable=empty-docstring
""" """
if not grid.key:
raise ValueError("grid must have a key!")
if grid.key in self.grid_vue_context:
log.warning(
"grid data with key '%s' already registered, but will be replaced",
grid.key,
)
self.grid_vue_context[grid.key] = grid.get_vue_context()
[docs]
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
[docs]
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
[docs]
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)
[docs]
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
[docs]
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
[docs]
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)
[docs]
def set_label(self, key, label):
"""
Set the label for given field name.
See also :meth:`get_label()`.
"""
self.labels[key] = label
# update schema if necessary
if self.schema and key in self.schema:
self.schema[key].title = label
[docs]
def get_label(self, key):
"""
Get the label for given field name.
Note that this will always return a string, auto-generating
the label if needed.
See also :meth:`set_label()`.
"""
return self.labels.get(key, self.app.make_title(key))
[docs]
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 []
[docs]
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
)
[docs]
def get_schema(self): # pylint: disable=too-many-branches
"""
Return the :class:`colander:colander.Schema` object for the
form, generating it automatically if necessary.
Note that if :attr:`schema` is already set, that will be
returned as-is.
"""
if not self.schema:
##############################
# create schema
##############################
# get fields
fields = self.get_fields()
if not fields:
raise ValueError(
"could not determine fields list; "
"please set model_class or fields explicitly"
)
if self.model_class:
# collect list of field names and/or nodes
includes = []
for key in fields:
if key in self.nodes:
includes.append(self.nodes[key])
else:
includes.append(key)
# make initial schema with ColanderAlchemy magic
schema = WuttaSchemaNode(self.model_class, includes=includes)
# 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:
# 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 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:
if required is False:
schema[key].missing = colander.null
self.schema = schema
return self.schema
[docs]
def get_deform(self):
"""
Return the :class:`deform:deform.Form` instance for the form,
generating it automatically if necessary.
"""
if not self.deform_form:
schema = self.get_schema()
kwargs = {}
if self.model_instance:
# TODO: i keep finding problems with this, not sure
# what needs to happen. some forms will have a simple
# dict for model_instance, others will have a proper
# SQLAlchemy object. and in the latter case, it may
# not be "wutta-native" but from another DB.
# so the problem is, how to detect whether we should
# use the model_instance as-is or if we should convert
# to a dict. some options include:
# - check if instance has dictify() method
# i *think* this was tried and didn't work? but do not recall
# - check if is instance of model.Base
# this is unreliable since model.Base is wutta-native
# - check if form has a model_class
# has not been tried yet
# - check if schema is from colanderalchemy
# this is what we are trying currently...
if isinstance(schema, SQLAlchemySchemaNode):
kwargs["appstruct"] = schema.dictify(self.model_instance)
else:
kwargs["appstruct"] = self.model_instance
# create the Deform instance
# nb. must give a reference back to wutta form; this is
# for sake of field schema nodes and widgets, e.g. to
# access the main model instance
form = deform.Form(schema, **kwargs)
form.wutta_form = self
self.deform_form = form
return self.deform_form
[docs]
def render_vue_tag(self, **kwargs):
"""
Render the Vue component tag for the form.
By default this simply returns:
.. code-block:: html
<wutta-form></wutta-form>
The actual output will depend on various form attributes, in
particular :attr:`vue_tagname`.
"""
return HTML.tag(self.vue_tagname, **kwargs)
[docs]
def render_vue_template(self, template=None, **context):
"""
Render the Vue template block for the form.
This returns something like:
.. code-block:: none
<script type="text/x-template" id="wutta-form-template">
<form>
<!-- fields etc. -->
</form>
</script>
<script>
const WuttaFormData = {}
const WuttaForm = {
template: 'wutta-form-template',
}
</script>
.. todo::
Why can't Sphinx render the above code block as 'html' ?
It acts like it can't handle a ``<script>`` tag at all?
Actual output will of course depend on form attributes, i.e.
:attr:`vue_tagname` and :attr:`fields` list etc.
Default logic will also invoke (indirectly):
* :meth:`render_vue_fields()`
* :meth:`render_vue_buttons()`
:param template: Optional template path to override the class
default.
:returns: HTML literal
"""
context = self.get_vue_context(**context)
html = render(template or self.vue_template, context)
return HTML.literal(html)
def get_vue_context(self, **context): # pylint: disable=missing-function-docstring
context["form"] = self
context["dform"] = self.get_deform()
context.setdefault("request", self.request)
context["model_data"] = self.get_vue_model_data()
# set form method, enctype
form_attrs = context.setdefault("form_attrs", dict(self.form_attrs))
form_attrs.setdefault("method", self.action_method)
if self.action_method == "post":
form_attrs.setdefault("enctype", "multipart/form-data")
# auto disable button on submit
if self.auto_disable_submit:
form_attrs["@submit"] = "formSubmitting = true"
# duplicate entire context for sake of fields/buttons template
context["form_context"] = context
return context
[docs]
def render_vue_fields(self, context, template=None, **kwargs):
"""
Render the fields section within the form template.
This is normally invoked from within the form's
``vue_template`` like this:
.. code-block:: none
${form.render_vue_fields(form_context)}
There is a default ``fields_template`` but that is only the
last resort. Logic will first look for a
``form_vue_fields()`` def within the *main template* being
rendered for the page.
An example will surely help:
.. code-block:: mako
<%inherit file="/master/edit.mako" />
<%def name="form_vue_fields()">
<p>this is my custom fields section:</p>
${form.render_vue_field("myfield")}
</%def>
This keeps the custom fields section within the main page
template as opposed to yet another file. But if your page
template has no ``form_vue_fields()`` def, then the class
default template is used. (Unless the ``template`` param
is specified.)
See also :meth:`render_vue_template()` and
:meth:`render_vue_buttons()`.
:param context: This must be the original context as provided
to the form's ``vue_template``. See example above.
:param template: Optional template path to use instead of the
defaults described above.
:returns: HTML literal
"""
context.update(kwargs)
html = False
if not template:
if main_template := context.get("main_template"):
try:
vue_fields = main_template.get_def("form_vue_fields")
except AttributeError:
pass
else:
html = vue_fields.render(**context)
if html is False:
template = self.fields_template
if html is False:
html = render(template, context)
return HTML.literal(html)
[docs]
def render_vue_field(
self,
fieldname,
readonly=None,
label=True,
horizontal=True,
**kwargs,
): # pylint: disable=unused-argument,too-many-locals,too-many-branches
"""
Render the given field completely, i.e. ``<b-field>`` wrapper
with label and a widget, with validation errors flagged as
needed.
Actual output will depend on the field attributes etc.
Typical output might look like:
.. code-block:: html
<b-field label="Foo"
horizontal
type="is-danger"
message="something went wrong!">
<b-input name="foo"
v-model="${form.get_field_vmodel('foo')}" />
</b-field>
:param fieldname: Name of field to render.
:param readonly: Optional override for readonly flag.
:param label: Whether to include/set the field label.
:param horizontal: Boolean value for the ``horizontal`` flag
on the field.
:param \\**kwargs: Remaining kwargs are passed to widget's
``serialize()`` method.
:returns: HTML literal
"""
# readonly comes from: caller, field flag, or form flag
if readonly is None:
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
if fieldname in dform:
# render proper widget if field is in deform/schema
field = dform[fieldname]
if readonly:
kwargs["readonly"] = True
try:
html = field.serialize(**kwargs)
except Exception as exc:
log.warning(
"widget serialization failed for field: %s",
fieldname,
exc_info=True,
)
raise RuntimeError(
f"widget serialization failed for field: {fieldname}"
) from exc
else:
# render static text if field not in deform/schema
# TODO: need to abstract this somehow
if self.model_instance:
value = self.app.get_value(self.model_instance, fieldname)
html = str(value) if value is not None else ""
else:
html = ""
# mark all that as safe
html = HTML.literal(html or " ")
# render field label
if label:
label = self.get_label(fieldname)
# b-field attrs
attrs = {
":horizontal": "true" if horizontal else "false",
"label": label or "",
}
# next we will build array of messages to display..some
# fields always show a "helptext" msg, and some may have
# validation errors..
field_type = None
messages = []
# show errors if present
errors = self.get_field_errors(fieldname)
if errors:
field_type = "is-danger"
messages.extend(errors)
# ..okay now we can declare the field messages and type
if field_type:
attrs["type"] = field_type
if messages:
cls = "is-size-7"
if field_type == "is-danger":
cls += " has-text-danger"
messages = [HTML.tag("p", c=[msg], class_=cls) for msg in messages]
slot = HTML.tag("slot", name="messages", c=messages)
html = HTML.tag("div", c=[html, slot])
return HTML.tag("b-field", c=[html], **attrs)
[docs]
def render_vue_buttons(self, context, template=None, **kwargs):
"""
Render the buttons section within the form template.
This is normally invoked from within the form's
``vue_template`` like this:
.. code-block:: none
${form.render_vue_buttons(form_context)}
.. note::
This method does not yet inspect the main page template,
unlike :meth:`render_vue_fields()`.
See also :meth:`render_vue_template()`.
:param context: This must be the original context as provided
to the form's ``vue_template``. See example above.
:param template: Optional template path to override the class
default.
:returns: HTML literal
"""
context.update(kwargs)
html = render(template or self.buttons_template, context)
return HTML.literal(html)
[docs]
def render_vue_finalize(self):
"""
Render the Vue "finalize" script for the form.
By default this simply returns:
.. code-block:: html
<script>
WuttaForm.data = function() { return WuttaFormData }
Vue.component('wutta-form', WuttaForm)
</script>
The actual output may depend on various form attributes, in
particular :attr:`vue_tagname`.
"""
return render_vue_finalize(self.vue_tagname, self.vue_component)
[docs]
def get_field_vmodel(self, field):
"""
Convenience to return the ``v-model`` data reference for the
given field. For instance:
.. code-block:: none
<b-input name="myfield"
v-model="${form.get_field_vmodel('myfield')}" />
<div v-show="${form.get_field_vmodel('myfield')} == 'easter'">
easter egg!
</div>
:returns: JS-valid string referencing the field value
"""
dform = self.get_deform()
return f"modelData.{dform[field].oid}"
[docs]
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.
This collects the ``cstruct`` values for all fields which are
present both in :attr:`fields` as well as the Deform schema.
It also converts each as needed, to ensure it is
JSON-serializable.
:returns: Dict of field/value items.
"""
dform = self.get_deform()
model_data = {}
def assign(field):
value = field.cstruct
# TODO: we need a proper true/false on the Vue side,
# but deform/colander want 'true' and 'false' ..so
# for now we explicitly translate here, ugh. also
# note this does not yet allow for null values.. :(
if isinstance(field.typ, colander.Boolean):
value = value == field.typ.true_val
model_data[field.oid] = make_json_safe(value)
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
# TODO: for tailbone compat, should document?
# (ideally should remove this and find a better way)
def get_vue_field_value(self, key): # pylint: disable=empty-docstring
""" """
if key not in self.fields:
return None
dform = self.get_deform()
if key not in dform:
return None
field = dform[key]
return make_json_safe(field.cstruct)
[docs]
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
the :attr:`validated` attribute will be ``None``. 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``.
"""
self.validated = None
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
[docs]
def has_global_errors(self):
"""
Convenience function to check if the form has any "global"
(not field-level) errors.
See also :meth:`get_global_errors()`.
:returns: ``True`` if global errors present, else ``False``.
"""
dform = self.get_deform()
return bool(dform.error)
[docs]
def get_global_errors(self):
"""
Returns a list of "global" (not field-level) error messages
for the form.
See also :meth:`has_global_errors()`.
:returns: List of error messages (possibly empty).
"""
dform = self.get_deform()
if dform.error is None:
return []
return dform.error.messages()
[docs]
def get_field_errors(self, field):
"""
Return a list of error messages for the given field.
Not useful unless a call to :meth:`validate()` failed.
"""
dform = self.get_deform()
if field in dform:
field = dform[field]
if field.error:
return field.error.messages()
return []
[docs]
class WuttaSchemaNode(SQLAlchemySchemaNode): # pylint: disable=abstract-method
"""
Custom schema node type based on ColanderAlchemy, but adding
support for association proxy fields.
This class is used under the hood but you will not normally
interact with it directly.
It's a subclass of
:class:`colanderalchemy:colanderalchemy.SQLAlchemySchemaNode`.
"""
def add_nodes(self, includes, excludes, overrides):
super().add_nodes(includes, excludes, overrides)
for name in includes or []:
prop = self.inspector.attrs.get(name, name)
if isinstance(prop, str):
# add node for association proxy field
if column := get_association_proxy_column(self.inspector, name):
name_overrides_copy = overrides.get(name, {}).copy()
node = self.get_schema_from_column(column, name_overrides_copy)
if node is not None:
self.add(node)
def dictify(self, obj): # pylint: disable=empty-docstring
""" """
dct = super().dictify(obj)
# loop thru all fields to add the association proxies
for node in self:
name = node.name
if name in dct:
continue # value already set
# we only care about association proxies here
if not get_association_proxy_column(self.inspector, name):
continue
dct[name] = getattr(obj, name)
# special handling when value is None
if dct[name] is None:
# nb. colander/deform know how to behave when using
# their dedicated colander.null value, but ``None``
# seems to cause issues for string fields, so swap
# that out here if applicable
if isinstance(node.typ, colander.String):
dct[name] = colander.null
return dct
def objectify(self, dict_, context=None): # pylint: disable=empty-docstring
""" """
context = super().objectify(dict_, context=context)
for attr in dict_:
if self.inspector.has_property(attr):
continue # upstream logic handles these
# try to process association proxy field
if get_association_proxy_column(self.inspector, attr):
value = dict_[attr]
if value is colander.null:
# `colander.null` is never an appropriate
# value to be placed on an SQLAlchemy object
# so we translate it into `None`.
value = None
setattr(context, attr, value)
return context
[docs]
def get_association_proxy(mapper, field):
"""
Return the association proxy "descriptor" corresponding to the
given field name, if it exists.
:param mapper: SQLAlchemy mapper for the main class.
:param field: Field name on the main class, which may (or may not)
be proxied via association.
:returns:
:class:`~sqlalchemy:sqlalchemy.ext.associationproxy.AssociationProxy`
instance, or ``None``.
Using the ``User.first_name`` (which proxies to
``User.person.first_name``) example, this would return the proxy
descriptor for ``User.first_name``.
"""
try:
desc = getattr(mapper.all_orm_descriptors, field)
except AttributeError:
pass
else:
if desc.extension_type.name == "ASSOCIATION_PROXY":
return desc
return None
[docs]
def get_association_proxy_target(mapper, field):
"""
Return the relationship property involved in the association proxy
for the given field, if applicable.
:param mapper: SQLAlchemy mapper for the main class.
:param field: Field name on the main class, which may (or may not)
be proxied via association.
:returns: :class:`~sqlalchemy:sqlalchemy.orm.RelationshipProperty`
instance, or ``None``.
Using the ``User.first_name`` (which proxies to
``User.person.first_name``) example, this would return the
``User.person`` relationship property.
"""
if proxy := get_association_proxy(mapper, field):
proxy_target = mapper.get_property(proxy.target_collection)
if (
isinstance(proxy_target, orm.RelationshipProperty)
and not proxy_target.uselist
):
return proxy_target
return None
[docs]
def get_association_proxy_column(mapper, field):
"""
Return the target column property involved in the association
proxy for the given field, if applicable.
:param mapper: SQLAlchemy mapper for the main class.
:param field: Field name on the main class, which may (or may not)
be proxied via association.
:returns: :class:`~sqlalchemy:sqlalchemy.orm.ColumnProperty`
instance, or ``None``.
Using the ``User.first_name`` (which proxies to
``User.person.first_name``) example, this would return the
``Person.first_name`` column property.
"""
if proxy_target := get_association_proxy_target(mapper, field):
if proxy_target.mapper.has_property(field):
prop = proxy_target.mapper.get_property(field)
if isinstance(prop, orm.ColumnProperty) and isinstance(
prop.columns[0], sa.Column
):
return prop
return None