Coverage for .tox/coverage/lib/python3.13/site-packages/wuttaweb/forms/base.py: 100%
448 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-24 16:36 -0500
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-24 16:36 -0500
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# wuttaweb -- Web App for Wutta Framework
5# Copyright © 2024-2026 Lance Edgar
6#
7# This file is part of Wutta Framework.
8#
9# Wutta Framework is free software: you can redistribute it and/or modify it
10# under the terms of the GNU General Public License as published by the Free
11# Software Foundation, either version 3 of the License, or (at your option) any
12# later version.
13#
14# Wutta Framework is distributed in the hope that it will be useful, but
15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
17# more details.
18#
19# You should have received a copy of the GNU General Public License along with
20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
21#
22################################################################################
23"""
24Base form classes
25"""
27# pylint: disable=too-many-lines
29import logging
30from collections import OrderedDict
32import sqlalchemy as sa
33from sqlalchemy import orm
35import colander
36import deform
37from colanderalchemy import SQLAlchemySchemaNode
38from pyramid.renderers import render
39from webhelpers2.html import HTML
41from wuttaweb.util import (
42 FieldList,
43 get_form_data,
44 get_model_fields,
45 make_json_safe,
46 render_vue_finalize,
47)
49log = logging.getLogger(__name__)
52class Form: # pylint: disable=too-many-instance-attributes,too-many-public-methods
53 """
54 Base class for all forms.
56 :param request: Reference to current :term:`request` object.
58 :param fields: List of field names for the form. This is
59 optional; if not specified an attempt will be made to deduce
60 the list automatically. See also :attr:`fields`.
62 :param schema: Colander-based schema object for the form. This is
63 optional; if not specified an attempt will be made to construct
64 one automatically. See also :meth:`get_schema()`.
66 :param labels: Optional dict of default field labels.
68 .. note::
70 Some parameters are not explicitly described above. However
71 their corresponding attributes are described below.
73 Form instances contain the following attributes:
75 .. attribute:: request
77 Reference to current :term:`request` object.
79 .. attribute:: fields
81 :class:`~wuttaweb.util.FieldList` instance containing string
82 field names for the form. By default, fields will appear in
83 the same order as they are in this list.
85 See also :meth:`set_fields()`.
87 .. attribute:: schema
89 :class:`colander:colander.Schema` object for the form. This is
90 optional; if not specified an attempt will be made to construct
91 one automatically.
93 See also :meth:`get_schema()`.
95 .. attribute:: model_class
97 Model class for the form, if applicable. When set, this is
98 usually a SQLAlchemy mapped class. This (or
99 :attr:`model_instance`) may be used instead of specifying the
100 :attr:`schema`.
102 .. attribute:: model_instance
104 Optional instance from which initial form data should be
105 obtained. In simple cases this might be a dict, or maybe an
106 instance of :attr:`model_class`.
108 Note that this also may be used instead of specifying the
109 :attr:`schema`, if the instance belongs to a class which is
110 SQLAlchemy-mapped. (In that case :attr:`model_class` can be
111 determined automatically.)
113 .. attribute:: nodes
115 Dict of node overrides, used to construct the form in
116 :meth:`get_schema()`.
118 See also :meth:`set_node()`.
120 .. attribute:: widgets
122 Dict of widget overrides, used to construct the form in
123 :meth:`get_schema()`.
125 See also :meth:`set_widget()`.
127 .. attribute:: validators
129 Dict of node validators, used to construct the form in
130 :meth:`get_schema()`.
132 See also :meth:`set_validator()`.
134 .. attribute:: defaults
136 Dict of default field values, used to construct the form in
137 :meth:`get_schema()`.
139 See also :meth:`set_default()`.
141 .. attribute:: readonly
143 Boolean indicating the form does not allow submit. In practice
144 this means there will not even be a ``<form>`` tag involved.
146 Default for this is ``False`` in which case the ``<form>`` tag
147 will exist and submit is allowed.
149 .. attribute:: readonly_fields
151 A :class:`~python:set` of field names which should be readonly.
152 Each will still be rendered but with static value text and no
153 widget.
155 This is only applicable if :attr:`readonly` is ``False``.
157 See also :meth:`set_readonly()` and :meth:`is_readonly()`.
159 .. attribute:: required_fields
161 A dict of "required" field flags. Keys are field names, and
162 values are boolean flags indicating whether the field is
163 required.
165 Depending on :attr:`schema`, some fields may be "(not)
166 required" by default. However ``required_fields`` keeps track
167 of any "overrides" per field.
169 See also :meth:`set_required()` and :meth:`is_required()`.
171 .. attribute:: autofocus
173 Dict of "autofocus" field flags. Keys are field names and
174 values should be boolean, where ``True`` means the field widget
175 should be rendered with the ``autofocus`` HTML attribute.
177 See also :meth:`set_autofocus()`.
179 .. attribute:: action_method
181 HTTP method to use when submitting form; ``'post'`` is default.
183 .. attribute:: action_url
185 String URL to which the form should be submitted, if applicable.
187 .. attribute:: reset_url
189 String URL to which the reset button should "always" redirect,
190 if applicable.
192 This is null by default, in which case it will use standard
193 browser behavior for the form reset button (if shown). See
194 also :attr:`show_button_reset`.
196 .. attribute:: cancel_url
198 String URL to which the Cancel button should "always" redirect,
199 if applicable.
201 Code should not access this directly, but instead call
202 :meth:`get_cancel_url()`.
204 .. attribute:: cancel_url_fallback
206 String URL to which the Cancel button should redirect, if
207 referrer cannot be determined from request.
209 Code should not access this directly, but instead call
210 :meth:`get_cancel_url()`.
212 .. attribute:: vue_tagname
214 String name for Vue component tag. By default this is
215 ``'wutta-form'``. See also :meth:`render_vue_tag()`.
217 See also :attr:`vue_component`.
219 .. attribute:: align_buttons_right
221 Flag indicating whether the buttons (submit, cancel etc.)
222 should be aligned to the right of the area below the form. If
223 not set, the buttons are left-aligned.
225 .. attribute:: auto_disable_submit
227 Flag indicating whether the submit button should be
228 auto-disabled, whenever the form is submitted.
230 .. attribute:: button_label_submit
232 String label for the form submit button. Default is ``"Save"``.
234 .. attribute:: button_icon_submit
236 String icon name for the form submit button. Default is ``'save'``.
238 .. attribute:: button_type_submit
240 Buefy type for the submit button. Default is ``'is-primary'``,
241 so for example:
243 .. code-block:: html
245 <b-button type="is-primary"
246 native-type="submit">
247 Save
248 </b-button>
250 See also the `Buefy docs
251 <https://buefy.org/documentation/button/#api-view>`_.
253 .. attribute:: show_button_reset
255 Flag indicating whether a Reset button should be shown.
256 Default is ``False``.
258 Unless there is a :attr:`reset_url`, the reset button will use
259 standard behavior per the browser.
261 .. attribute:: show_button_cancel
263 Flag indicating whether a Cancel button should be shown.
264 Default is ``True``.
266 .. attribute:: button_label_cancel
268 String label for the form cancel button. Default is
269 ``"Cancel"``.
271 .. attribute:: auto_disable_cancel
273 Flag indicating whether the cancel button should be
274 auto-disabled, whenever the button is clicked. Default is
275 ``True``.
277 .. attribute:: validated
279 If the :meth:`validate()` method was called, and it succeeded,
280 this will be set to the validated data dict.
281 """
283 deform_form = None
284 validated = None
286 vue_template = "/forms/vue_template.mako"
287 fields_template = "/forms/vue_fields.mako"
288 buttons_template = "/forms/vue_buttons.mako"
290 def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
291 self,
292 request,
293 fields=None,
294 schema=None,
295 model_class=None,
296 model_instance=None,
297 nodes=None,
298 widgets=None,
299 validators=None,
300 defaults=None,
301 readonly=False,
302 readonly_fields=None,
303 required_fields=None,
304 labels=None,
305 autofocus=None,
306 action_method="post",
307 action_url=None,
308 reset_url=None,
309 cancel_url=None,
310 cancel_url_fallback=None,
311 vue_tagname="wutta-form",
312 align_buttons_right=False,
313 auto_disable_submit=True,
314 button_label_submit="Save",
315 button_icon_submit="save",
316 button_type_submit="is-primary",
317 show_button_reset=False,
318 show_button_cancel=True,
319 button_label_cancel="Cancel",
320 auto_disable_cancel=True,
321 ):
322 self.request = request
323 self.schema = schema
324 self.nodes = nodes or {}
325 self.widgets = widgets or {}
326 self.validators = validators or {}
327 self.defaults = defaults or {}
328 self.readonly = readonly
329 self.readonly_fields = set(readonly_fields or [])
330 self.required_fields = required_fields or {}
331 self.labels = labels or {}
332 self.action_method = action_method
333 self.action_url = action_url
334 self.cancel_url = cancel_url
335 self.cancel_url_fallback = cancel_url_fallback
336 self.reset_url = reset_url
337 self.vue_tagname = vue_tagname
338 self.align_buttons_right = align_buttons_right
339 self.auto_disable_submit = auto_disable_submit
340 self.button_label_submit = button_label_submit
341 self.button_icon_submit = button_icon_submit
342 self.button_type_submit = button_type_submit
343 self.show_button_reset = show_button_reset
344 self.show_button_cancel = show_button_cancel
345 self.button_label_cancel = button_label_cancel
346 self.auto_disable_cancel = auto_disable_cancel
347 self.form_attrs = {}
349 self.config = self.request.wutta_config
350 self.app = self.config.get_app()
352 self.model_class = model_class
353 self.model_instance = model_instance
354 if self.model_instance and not self.model_class:
355 if not isinstance(self.model_instance, dict):
356 self.model_class = type(self.model_instance)
358 self.set_fields(fields or self.get_fields())
359 self.set_default_widgets()
360 self.set_autofocus(autofocus)
362 # nb. this tracks grid JSON data for inclusion in page template
363 self.grid_vue_context = OrderedDict()
365 def __contains__(self, name):
366 """
367 Custom logic for the ``in`` operator, to allow easily checking
368 if the form contains a given field::
370 myform = Form()
371 if 'somefield' in myform:
372 print("my form has some field")
373 """
374 return bool(self.fields and name in self.fields)
376 def __iter__(self):
377 """
378 Custom logic to allow iterating over form field names::
380 myform = Form(fields=['foo', 'bar'])
381 for fieldname in myform:
382 print(fieldname)
383 """
384 return iter(self.fields)
386 @property
387 def vue_component(self):
388 """
389 String name for the Vue component, e.g. ``'WuttaForm'``.
391 This is a generated value based on :attr:`vue_tagname`.
392 """
393 words = self.vue_tagname.split("-")
394 return "".join([word.capitalize() for word in words])
396 def get_cancel_url(self):
397 """
398 Returns the URL for the Cancel button.
400 If :attr:`cancel_url` is set, its value is returned.
402 Or, if the referrer can be deduced from the request, that is
403 returned.
405 Or, if :attr:`cancel_url_fallback` is set, that value is
406 returned.
408 As a last resort the "default" URL from
409 :func:`~wuttaweb.subscribers.request.get_referrer()` is
410 returned.
411 """
412 # use "permanent" URL if set
413 if self.cancel_url:
414 return self.cancel_url
416 # nb. use fake default to avoid normal default logic;
417 # that way if we get something it's a real referrer
418 url = self.request.get_referrer(default="NOPE")
419 if url and url != "NOPE":
420 return url
422 # use fallback URL if set
423 if self.cancel_url_fallback:
424 return self.cancel_url_fallback
426 # okay, home page then (or whatever is the default URL)
427 return self.request.get_referrer()
429 def set_fields(self, fields):
430 """
431 Explicitly set the list of form fields.
433 This will overwrite :attr:`fields` with a new
434 :class:`~wuttaweb.util.FieldList` instance.
436 :param fields: List of string field names.
437 """
438 self.fields = FieldList(fields)
440 def set_autofocus(self, field, focus=True):
441 """
442 Set field autofocus flag(s).
444 The method signature seems obvious enough, you can toggle on/off
445 the "autofocus" flag for a given field.
447 However in practice the ``field`` param is special. Instead
448 of specifying a field name you can pass a full dict (or
449 ``None``) in which case this will overwrite :attr:`autofocus`
450 entirely::
452 # turn on autofocus for a field
453 form.set_autofocus("description")
455 # turn off autofocus for a field
456 form.set_autofocus("description", False)
458 # clear out all autofocus info
459 form.set_autofocus(None)
461 # set explicit autofocus info
462 form.set_autofocus({
463 "description": True,
464 "foo": True,
465 "bar": False,
466 })
468 :param field: String field name, or else a dict or ``None`` as
469 shown above.
471 :param focus: Whether the field should be considered for
472 autofocus. Specifying ``False`` here is useful only when
473 you need to turn *off* autofocus which may have previously
474 been turned *on* e.g. by default logic. This param is only
475 relevant when ``field`` is just a field name.
476 """
478 # nb. caller may provide full dict instead of just a field
479 if field is None:
480 self.autofocus = {}
481 elif not isinstance(field, str):
482 self.autofocus = field
483 else:
484 self.autofocus[field] = focus
486 def append(self, *keys):
487 """
488 Add some fields(s) to the form.
490 This is a convenience to allow adding multiple fields at
491 once::
493 form.append('first_field',
494 'second_field',
495 'third_field')
497 It will add each field to :attr:`fields`.
498 """
499 for key in keys:
500 if key not in self.fields:
501 self.fields.append(key)
503 def remove(self, *keys):
504 """
505 Remove some fields(s) from the form.
507 This is a convenience to allow removal of multiple fields at
508 once::
510 form.remove('first_field',
511 'second_field',
512 'third_field')
514 It will remove each field from :attr:`fields`.
515 """
516 for key in keys:
517 if key in self.fields:
518 self.fields.remove(key)
520 def set_node(self, key, nodeinfo, **kwargs):
521 """
522 Set/override the node for a field.
524 :param key: Name of field.
526 :param nodeinfo: Should be either a
527 :class:`colander:colander.SchemaNode` instance, or else a
528 :class:`colander:colander.SchemaType` instance.
530 If ``nodeinfo`` is a proper node instance, it will be used
531 as-is. Otherwise an
532 :class:`~wuttaweb.forms.schema.ObjectNode` instance will be
533 constructed using ``nodeinfo`` as the type (``typ``).
535 Node overrides are tracked via :attr:`nodes`.
536 """
537 from wuttaweb.forms.schema import ( # pylint: disable=import-outside-toplevel
538 ObjectNode,
539 )
541 if isinstance(nodeinfo, colander.SchemaNode):
542 # assume nodeinfo is a complete node
543 node = nodeinfo
545 else: # assume nodeinfo is a schema type
547 # we will construct a new node for this type, which may
548 # override a (future) automatic node from ColanderAlchemy.
549 # we also must effectively auto-set the "required" flag,
550 # since normally ColanderAlchemy handles that.
551 if self.model_class and hasattr(self.model_class, key):
552 if attr := getattr(self.model_class, key):
553 if getattr(attr, "nullable", False):
554 kwargs.setdefault("missing", colander.null)
556 kwargs.setdefault("name", key)
557 node = ObjectNode(nodeinfo, **kwargs)
559 self.nodes[key] = node
561 # must explicitly replace node, if we already have a schema
562 if self.schema:
563 self.schema[key] = node
565 def set_widget(self, key, widget, **kwargs):
566 """
567 Set/override the widget for a field.
569 You can specify a widget instance or else a named "type" of
570 widget, in which case that is passed along to
571 :meth:`make_widget()`.
573 :param key: Name of field.
575 :param widget: Either a :class:`deform:deform.widget.Widget`
576 instance, or else a widget "type" name.
578 :param \\**kwargs: Any remaining kwargs are passed along to
579 :meth:`make_widget()` - if applicable.
581 Widget overrides are tracked via :attr:`widgets`.
582 """
583 if not isinstance(widget, deform.widget.Widget):
584 widget_obj = self.make_widget(widget, **kwargs)
585 if not widget_obj:
586 raise ValueError(f"widget type not supported: {widget}")
587 widget = widget_obj
589 self.widgets[key] = widget
591 # update schema if necessary
592 if self.schema and key in self.schema:
593 self.schema[key].widget = widget
595 def make_widget(self, widget_type, **kwargs):
596 """
597 Make and return a new field widget of the given type.
599 This has built-in support for the following types (although
600 subclass can override as needed):
602 * ``'notes'`` => :class:`~wuttaweb.forms.widgets.NotesWidget`
604 See also :meth:`set_widget()` which may call this method
605 automatically.
607 :param widget_type: Which of the above (or custom) widget
608 type to create.
610 :param \\**kwargs: Remaining kwargs are passed as-is to the
611 widget factory.
613 :returns: New widget instance, or ``None`` if e.g. it could
614 not determine how to create the widget.
615 """
616 from wuttaweb.forms import widgets # pylint: disable=import-outside-toplevel
618 if widget_type == "notes":
619 return widgets.NotesWidget(**kwargs)
621 return None
623 def set_default_widgets(self):
624 """
625 Set default field widgets, where applicable.
627 This will add new entries to :attr:`widgets` for columns
628 whose data type implies a default widget should be used.
629 This is generally only possible if :attr:`model_class` is set
630 to a valid SQLAlchemy mapped class.
632 This only checks for a couple of data types, with mapping as
633 follows:
635 * :class:`sqlalchemy:sqlalchemy.types.Date` ->
636 :class:`~wuttaweb.forms.widgets.WuttaDateWidget`
637 * :class:`sqlalchemy:sqlalchemy.types.DateTime` ->
638 :class:`~wuttaweb.forms.widgets.WuttaDateTimeWidget`
639 """
640 from wuttaweb.forms import widgets # pylint: disable=import-outside-toplevel
642 if not self.model_class:
643 return
645 for key in self.fields:
646 if key in self.widgets:
647 continue
649 attr = getattr(self.model_class, key, None)
650 if attr:
651 prop = getattr(attr, "prop", None)
652 if prop and isinstance(prop, orm.ColumnProperty):
653 column = prop.columns[0]
654 if isinstance(column.type, sa.Date):
655 self.set_widget(key, widgets.WuttaDateWidget(self.request))
656 elif isinstance(column.type, sa.DateTime):
657 self.set_widget(key, widgets.WuttaDateTimeWidget(self.request))
659 def set_grid(self, key, grid):
660 """
661 Establish a :term:`grid` to be displayed for a field. This
662 uses a :class:`~wuttaweb.forms.widgets.GridWidget` to wrap the
663 rendered grid.
665 :param key: Name of field.
667 :param widget: :class:`~wuttaweb.grids.base.Grid` instance,
668 pre-configured and (usually) with data.
669 """
670 from wuttaweb.forms.widgets import ( # pylint: disable=import-outside-toplevel
671 GridWidget,
672 )
674 widget = GridWidget(self.request, grid)
675 self.set_widget(key, widget)
676 self.add_grid_vue_context(grid)
678 def add_grid_vue_context(self, grid): # pylint: disable=empty-docstring
679 """ """
680 if not grid.key:
681 raise ValueError("grid must have a key!")
683 if grid.key in self.grid_vue_context:
684 log.warning(
685 "grid data with key '%s' already registered, but will be replaced",
686 grid.key,
687 )
689 self.grid_vue_context[grid.key] = grid.get_vue_context()
691 def set_validator(self, key, validator):
692 """
693 Set/override the validator for a field, or the form.
695 :param key: Name of field. This may also be ``None`` in which
696 case the validator will apply to the whole form instead of
697 a field.
699 :param validator: Callable which accepts ``(node, value)``
700 args. For instance::
702 def validate_foo(node, value):
703 if value == 42:
704 node.raise_invalid("42 is not allowed!")
706 form = Form(fields=['foo', 'bar'])
708 form.set_validator('foo', validate_foo)
710 Validator overrides are tracked via :attr:`validators`.
711 """
712 self.validators[key] = validator
714 # nb. must apply to existing schema if present
715 if self.schema and key in self.schema:
716 self.schema[key].validator = validator
718 def set_default(self, key, value):
719 """
720 Set/override the default value for a field.
722 :param key: Name of field.
724 :param validator: Default value for the field.
726 Default value overrides are tracked via :attr:`defaults`.
727 """
728 self.defaults[key] = value
730 def set_readonly(self, key, readonly=True):
731 """
732 Enable or disable the "readonly" flag for a given field.
734 When a field is marked readonly, it will be shown in the form
735 but there will be no editable widget. The field is skipped
736 over (not saved) when form is submitted.
738 See also :meth:`is_readonly()`; this is tracked via
739 :attr:`readonly_fields`.
741 :param key: String key (fieldname) for the field.
743 :param readonly: New readonly flag for the field.
744 """
745 if readonly:
746 self.readonly_fields.add(key)
747 else:
748 if key in self.readonly_fields:
749 self.readonly_fields.remove(key)
751 if self.deform_form and key in self.deform_form:
752 self.deform_form[key].widget.readonly = readonly
754 def is_readonly(self, key):
755 """
756 Returns boolean indicating if the given field is marked as
757 readonly.
759 See also :meth:`set_readonly()`.
761 :param key: Field key/name as string.
762 """
763 if self.readonly_fields:
764 if key in self.readonly_fields:
765 return True
766 return False
768 def set_required(self, key, required=True):
769 """
770 Enable or disable the "required" flag for a given field.
772 When a field is marked required, a value must be provided
773 or else it fails validation.
775 In practice if a field is "not required" then a default
776 "empty" value is assumed, should the user not provide one.
778 See also :meth:`is_required()`; this is tracked via
779 :attr:`required_fields`.
781 :param key: String key (fieldname) for the field.
783 :param required: New required flag for the field. Usually a
784 boolean, but may also be ``None`` to remove any flag and
785 revert to default behavior for the field.
786 """
787 self.required_fields[key] = required
789 def is_required(self, key):
790 """
791 Returns boolean indicating if the given field is marked as
792 required.
794 See also :meth:`set_required()`.
796 :param key: Field key/name as string.
798 :returns: Value for the flag from :attr:`required_fields` if
799 present; otherwise ``None``.
800 """
801 return self.required_fields.get(key, None)
803 def set_label(self, key, label):
804 """
805 Set the label for given field name.
807 See also :meth:`get_label()`.
808 """
809 self.labels[key] = label
811 # update schema if necessary
812 if self.schema and key in self.schema:
813 self.schema[key].title = label
815 def get_label(self, key):
816 """
817 Get the label for given field name.
819 Note that this will always return a string, auto-generating
820 the label if needed.
822 See also :meth:`set_label()`.
823 """
824 return self.labels.get(key, self.app.make_title(key))
826 def get_fields(self):
827 """
828 Returns the official list of field names for the form, or
829 ``None``.
831 If :attr:`fields` is set and non-empty, it is returned.
833 Or, if :attr:`schema` is set, the field list is derived
834 from that.
836 Or, if :attr:`model_class` is set, the field list is derived
837 from that, via :meth:`get_model_fields()`.
839 Otherwise ``None`` is returned.
840 """
841 if hasattr(self, "fields") and self.fields:
842 return self.fields
844 if self.schema:
845 return [field.name for field in self.schema]
847 fields = self.get_model_fields()
848 if fields:
849 return fields
851 return []
853 def get_model_fields(self, model_class=None):
854 """
855 This method is a shortcut which calls
856 :func:`~wuttaweb.util.get_model_fields()`.
858 :param model_class: Optional model class for which to return
859 fields. If not set, the form's :attr:`model_class` is
860 assumed.
861 """
862 return get_model_fields(
863 self.config, model_class=model_class or self.model_class
864 )
866 def get_schema(self): # pylint: disable=too-many-branches
867 """
868 Return the :class:`colander:colander.Schema` object for the
869 form, generating it automatically if necessary.
871 Note that if :attr:`schema` is already set, that will be
872 returned as-is.
873 """
874 if not self.schema:
876 ##############################
877 # create schema
878 ##############################
880 # get fields
881 fields = self.get_fields()
882 if not fields:
883 raise ValueError(
884 "could not determine fields list; "
885 "please set model_class or fields explicitly"
886 )
888 if self.model_class:
890 # collect list of field names and/or nodes
891 includes = []
892 for key in fields:
893 if key in self.nodes:
894 includes.append(self.nodes[key])
895 else:
896 includes.append(key)
898 # make initial schema with ColanderAlchemy magic
899 schema = WuttaSchemaNode(self.model_class, includes=includes)
901 # fill in the blanks if anything got missed
902 for key in fields:
903 if key not in schema:
904 node = colander.SchemaNode(colander.String(), name=key)
905 schema.add(node)
907 else:
909 # make basic schema
910 schema = colander.Schema()
911 for key in fields:
912 node = None
914 # use node override if present
915 if key in self.nodes:
916 node = self.nodes[key]
917 if not node:
919 # otherwise make simple string node
920 node = colander.SchemaNode(colander.String(), name=key)
922 schema.add(node)
924 ##############################
925 # customize schema
926 ##############################
928 # apply widget overrides
929 for key, widget in self.widgets.items():
930 if key in schema:
931 schema[key].widget = widget
933 # apply validator overrides
934 for key, validator in self.validators.items():
935 if key is None:
936 # nb. this one is form-wide
937 schema.validator = validator
938 elif key in schema: # field-level
939 schema[key].validator = validator
941 # apply default value overrides
942 for key, value in self.defaults.items():
943 if key in schema:
944 schema[key].default = value
946 # apply required flags
947 for key, required in self.required_fields.items():
948 if key in schema:
949 if required is False:
950 schema[key].missing = colander.null
951 elif required:
952 schema[key].missing = colander.required
954 # apply autofocus
955 for key, focus in self.autofocus.items():
956 if not focus:
957 continue
958 if node := schema.get(key):
959 node.autofocus = "autofocus"
961 self.schema = schema
963 return self.schema
965 def get_deform(self):
966 """
967 Return the :class:`deform:deform.Form` instance for the form,
968 generating it automatically if necessary.
969 """
970 if not self.deform_form:
971 schema = self.get_schema()
972 kwargs = {}
974 if self.model_instance:
976 # TODO: i keep finding problems with this, not sure
977 # what needs to happen. some forms will have a simple
978 # dict for model_instance, others will have a proper
979 # SQLAlchemy object. and in the latter case, it may
980 # not be "wutta-native" but from another DB.
982 # so the problem is, how to detect whether we should
983 # use the model_instance as-is or if we should convert
984 # to a dict. some options include:
986 # - check if instance has dictify() method
987 # i *think* this was tried and didn't work? but do not recall
989 # - check if is instance of model.Base
990 # this is unreliable since model.Base is wutta-native
992 # - check if form has a model_class
993 # has not been tried yet
995 # - check if schema is from colanderalchemy
996 # this is what we are trying currently...
998 if isinstance(schema, SQLAlchemySchemaNode):
999 kwargs["appstruct"] = schema.dictify(self.model_instance)
1000 else:
1001 kwargs["appstruct"] = self.model_instance
1003 # create the Deform instance
1004 # nb. must give a reference back to wutta form; this is
1005 # for sake of field schema nodes and widgets, e.g. to
1006 # access the main model instance
1007 form = deform.Form(schema, **kwargs)
1008 form.wutta_form = self
1009 self.deform_form = form
1011 return self.deform_form
1013 def render_vue_tag(self, **kwargs):
1014 """
1015 Render the Vue component tag for the form.
1017 By default this simply returns:
1019 .. code-block:: html
1021 <wutta-form></wutta-form>
1023 The actual output will depend on various form attributes, in
1024 particular :attr:`vue_tagname`.
1025 """
1026 return HTML.tag(self.vue_tagname, **kwargs)
1028 def render_vue_template(self, template=None, **context):
1029 """
1030 Render the Vue template block for the form.
1032 This returns something like:
1034 .. code-block:: none
1036 <script type="text/x-template" id="wutta-form-template">
1037 <form>
1038 <!-- fields etc. -->
1039 </form>
1040 </script>
1042 <script>
1043 const WuttaFormData = {}
1044 const WuttaForm = {
1045 template: 'wutta-form-template',
1046 }
1047 </script>
1049 .. todo::
1051 Why can't Sphinx render the above code block as 'html' ?
1053 It acts like it can't handle a ``<script>`` tag at all?
1055 Actual output will of course depend on form attributes, i.e.
1056 :attr:`vue_tagname` and :attr:`fields` list etc.
1058 Default logic will also invoke (indirectly):
1060 * :meth:`render_vue_fields()`
1061 * :meth:`render_vue_buttons()`
1063 :param template: Optional template path to override the class
1064 default.
1066 :returns: HTML literal
1067 """
1068 context = self.get_vue_context(**context)
1069 html = render(template or self.vue_template, context)
1070 return HTML.literal(html)
1072 def get_vue_context(self, **context): # pylint: disable=missing-function-docstring
1073 context["form"] = self
1074 context["dform"] = self.get_deform()
1075 context.setdefault("request", self.request)
1076 context["model_data"] = self.get_vue_model_data()
1078 # set form method, enctype
1079 form_attrs = context.setdefault("form_attrs", dict(self.form_attrs))
1080 form_attrs.setdefault("method", self.action_method)
1081 if self.action_method == "post":
1082 form_attrs.setdefault("enctype", "multipart/form-data")
1084 # auto disable button on submit
1085 if self.auto_disable_submit:
1086 form_attrs["@submit"] = "formSubmitting = true"
1088 # duplicate entire context for sake of fields/buttons template
1089 context["form_context"] = context
1091 return context
1093 def render_vue_fields(self, context, template=None, **kwargs):
1094 """
1095 Render the fields section within the form template.
1097 This is normally invoked from within the form's
1098 ``vue_template`` like this:
1100 .. code-block:: none
1102 ${form.render_vue_fields(form_context)}
1104 There is a default ``fields_template`` but that is only the
1105 last resort. Logic will first look for a
1106 ``form_vue_fields()`` def within the *main template* being
1107 rendered for the page.
1109 An example will surely help:
1111 .. code-block:: mako
1113 <%inherit file="/master/edit.mako" />
1115 <%def name="form_vue_fields()">
1117 <p>this is my custom fields section:</p>
1119 ${form.render_vue_field("myfield")}
1121 </%def>
1123 This keeps the custom fields section within the main page
1124 template as opposed to yet another file. But if your page
1125 template has no ``form_vue_fields()`` def, then the class
1126 default template is used. (Unless the ``template`` param
1127 is specified.)
1129 See also :meth:`render_vue_template()` and
1130 :meth:`render_vue_buttons()`.
1132 :param context: This must be the original context as provided
1133 to the form's ``vue_template``. See example above.
1135 :param template: Optional template path to use instead of the
1136 defaults described above.
1138 :returns: HTML literal
1139 """
1140 context.update(kwargs)
1141 html = False
1143 if not template:
1145 if main_template := context.get("main_template"):
1146 try:
1147 vue_fields = main_template.get_def("form_vue_fields")
1148 except AttributeError:
1149 pass
1150 else:
1151 html = vue_fields.render(**context)
1153 if html is False:
1154 template = self.fields_template
1156 if html is False:
1157 html = render(template, context)
1159 return HTML.literal(html)
1161 def render_vue_field(
1162 self,
1163 fieldname,
1164 readonly=None,
1165 label=True,
1166 horizontal=True,
1167 **kwargs,
1168 ): # pylint: disable=unused-argument,too-many-locals,too-many-branches
1169 """
1170 Render the given field completely, i.e. ``<b-field>`` wrapper
1171 with label and a widget, with validation errors flagged as
1172 needed.
1174 Actual output will depend on the field attributes etc.
1175 Typical output might look like:
1177 .. code-block:: html
1179 <b-field label="Foo"
1180 horizontal
1181 type="is-danger"
1182 message="something went wrong!">
1183 <b-input name="foo"
1184 v-model="${form.get_field_vmodel('foo')}" />
1185 </b-field>
1187 Note that this method can render a *readonly* field as well as
1188 one with input widget as shown above. Some fields are always
1189 readonly, and caller can force readonly via method param.
1191 However some fields really can only be rendered as readonly,
1192 so you can't necessarily force it to render a sane input
1193 widget unless the field supports it.
1195 :param fieldname: Name of field to render.
1197 :param readonly: Optional override for readonly flag.
1199 :param label: Whether to include/set the field label.
1201 :param horizontal: Boolean value for the ``horizontal`` flag
1202 on the field.
1204 :param \\**kwargs: Remaining kwargs are passed to widget's
1205 ``serialize()`` method.
1207 :returns: HTML literal
1208 """
1209 # readonly comes from: caller, field flag, or form flag
1210 if readonly is None:
1211 readonly = self.is_readonly(fieldname)
1212 if not readonly:
1213 readonly = self.readonly
1215 # but also, fields not in deform/schema must be readonly
1216 dform = self.get_deform()
1217 if not readonly and fieldname not in dform:
1218 readonly = True
1220 # render the field widget or whatever
1221 if fieldname in dform:
1223 # render proper widget if field is in deform/schema
1224 field = dform[fieldname]
1225 if readonly:
1226 kwargs["readonly"] = True
1228 try:
1229 html = field.serialize(**kwargs)
1230 except Exception as exc:
1231 log.warning(
1232 "widget serialization failed for field: %s",
1233 fieldname,
1234 exc_info=True,
1235 )
1236 raise RuntimeError(
1237 f"widget serialization failed for field: {fieldname}"
1238 ) from exc
1240 else:
1241 # render static text if field not in deform/schema
1242 # TODO: need to abstract this somehow
1243 if self.model_instance:
1244 value = self.app.get_value(self.model_instance, fieldname)
1245 html = str(value) if value is not None else ""
1246 else:
1247 html = ""
1249 # mark all that as safe
1250 html = HTML.literal(html or " ")
1252 # render field label
1253 if label:
1254 label = self.get_label(fieldname)
1256 # b-field attrs
1257 attrs = {
1258 ":horizontal": "true" if horizontal else "false",
1259 "label": label or "",
1260 }
1262 # next we will build array of messages to display..some
1263 # fields always show a "helptext" msg, and some may have
1264 # validation errors..
1265 field_type = None
1266 messages = []
1268 # show errors if present
1269 errors = self.get_field_errors(fieldname)
1270 if errors:
1271 field_type = "is-danger"
1272 messages.extend(errors)
1274 # ..okay now we can declare the field messages and type
1275 if field_type:
1276 attrs["type"] = field_type
1277 if messages:
1278 cls = "is-size-7"
1279 if field_type == "is-danger":
1280 cls += " has-text-danger"
1281 messages = [HTML.tag("p", c=[msg], class_=cls) for msg in messages]
1282 slot = HTML.tag("slot", name="messages", c=messages)
1283 html = HTML.tag("div", c=[html, slot])
1285 return HTML.tag("b-field", c=[html], **attrs)
1287 def render_vue_buttons(self, context, template=None, **kwargs):
1288 """
1289 Render the buttons section within the form template.
1291 This is normally invoked from within the form's
1292 ``vue_template`` like this:
1294 .. code-block:: none
1296 ${form.render_vue_buttons(form_context)}
1298 .. note::
1300 This method does not yet inspect the main page template,
1301 unlike :meth:`render_vue_fields()`.
1303 See also :meth:`render_vue_template()`.
1305 :param context: This must be the original context as provided
1306 to the form's ``vue_template``. See example above.
1308 :param template: Optional template path to override the class
1309 default.
1311 :returns: HTML literal
1312 """
1313 context.update(kwargs)
1314 html = render(template or self.buttons_template, context)
1315 return HTML.literal(html)
1317 def render_vue_finalize(self):
1318 """
1319 Render the Vue "finalize" script for the form.
1321 By default this simply returns:
1323 .. code-block:: html
1325 <script>
1326 WuttaForm.data = function() { return WuttaFormData }
1327 Vue.component('wutta-form', WuttaForm)
1328 </script>
1330 The actual output may depend on various form attributes, in
1331 particular :attr:`vue_tagname`.
1332 """
1333 return render_vue_finalize(self.vue_tagname, self.vue_component)
1335 def get_field_vmodel(self, field):
1336 """
1337 Convenience to return the ``v-model`` data reference for the
1338 given field. For instance:
1340 .. code-block:: none
1342 <b-input name="myfield"
1343 v-model="${form.get_field_vmodel('myfield')}" />
1345 <div v-show="${form.get_field_vmodel('myfield')} == 'easter'">
1346 easter egg!
1347 </div>
1349 :returns: JS-valid string referencing the field value
1350 """
1351 dform = self.get_deform()
1352 return f"modelData.{dform[field].oid}"
1354 def get_vue_model_data(self):
1355 """
1356 Returns a dict with form model data. Values may be nested
1357 depending on the types of fields contained in the form.
1359 This collects the ``cstruct`` values for all fields which are
1360 present both in :attr:`fields` as well as the Deform schema.
1362 It also converts each as needed, to ensure it is
1363 JSON-serializable.
1365 :returns: Dict of field/value items.
1366 """
1367 dform = self.get_deform()
1368 model_data = {}
1370 def assign(field):
1371 value = field.cstruct
1373 # TODO: we need a proper true/false on the Vue side,
1374 # but deform/colander want 'true' and 'false' ..so
1375 # for now we explicitly translate here, ugh. also
1376 # note this does not yet allow for null values.. :(
1377 if isinstance(field.typ, colander.Boolean):
1378 value = value == field.typ.true_val
1380 model_data[field.oid] = make_json_safe(value)
1382 for key in self.fields:
1384 # TODO: i thought commented code was useful, but no longer sure?
1386 # TODO: need to describe the scenario when this is true
1387 if key not in dform:
1388 # log.warning("field '%s' is missing from deform", key)
1389 continue
1391 field = dform[key]
1393 # if hasattr(field, 'children'):
1394 # for subfield in field.children:
1395 # assign(subfield)
1397 assign(field)
1399 return model_data
1401 # TODO: for tailbone compat, should document?
1402 # (ideally should remove this and find a better way)
1403 def get_vue_field_value(self, key): # pylint: disable=empty-docstring
1404 """ """
1405 if key not in self.fields:
1406 return None
1408 dform = self.get_deform()
1409 if key not in dform:
1410 return None
1412 field = dform[key]
1413 return make_json_safe(field.cstruct)
1415 def validate(self):
1416 """
1417 Try to validate the form, using data from the :attr:`request`.
1419 Uses :func:`~wuttaweb.util.get_form_data()` to retrieve the
1420 form data from POST or JSON body.
1422 If the form data is valid, the data dict is returned. This
1423 data dict is also made available on the form object via the
1424 :attr:`validated` attribute.
1426 However if the data is not valid, ``False`` is returned, and
1427 the :attr:`validated` attribute will be ``None``. In that
1428 case you should inspect the form errors to learn/display what
1429 went wrong for the user's sake. See also
1430 :meth:`get_field_errors()`.
1432 This uses :meth:`deform:deform.Field.validate()` under the
1433 hood.
1435 :returns: Data dict, or ``False``.
1436 """
1437 self.validated = None
1439 if self.request.method != "POST":
1440 return False
1442 schema = self.get_schema()
1443 dform = self.get_deform()
1445 # remove readonly fields to avoid validation mess
1446 removed_fields = []
1447 removed_nodes = []
1448 for key in self.readonly_fields:
1449 if key not in schema:
1450 continue
1452 field = dform[key]
1453 i = dform.children.index(field)
1454 dform.children.pop(i)
1455 removed_fields.append((i, field))
1457 node = schema[key]
1458 i = schema.children.index(node)
1459 schema.children.pop(i)
1460 removed_nodes.append((i, node))
1462 # let deform do real validation
1463 controls = get_form_data(self.request).items()
1464 try:
1465 self.validated = dform.validate(controls)
1466 except deform.ValidationFailure:
1467 log.warning("form did not validate: %s", dform.error)
1468 return False
1469 finally:
1471 # re-attach readonly fields
1472 for i, node in removed_nodes:
1473 schema.children.insert(i, node)
1474 for i, field in removed_fields:
1475 dform.children.insert(i, field)
1477 return self.validated
1479 def has_global_errors(self):
1480 """
1481 Convenience function to check if the form has any "global"
1482 (not field-level) errors.
1484 See also :meth:`get_global_errors()`.
1486 :returns: ``True`` if global errors present, else ``False``.
1487 """
1488 dform = self.get_deform()
1489 return bool(dform.error)
1491 def get_global_errors(self):
1492 """
1493 Returns a list of "global" (not field-level) error messages
1494 for the form.
1496 See also :meth:`has_global_errors()`.
1498 :returns: List of error messages (possibly empty).
1499 """
1500 dform = self.get_deform()
1501 if dform.error is None:
1502 return []
1503 return dform.error.messages()
1505 def get_field_errors(self, field):
1506 """
1507 Return a list of error messages for the given field.
1509 Not useful unless a call to :meth:`validate()` failed.
1510 """
1511 dform = self.get_deform()
1512 if field in dform:
1513 field = dform[field]
1514 if field.error:
1515 return field.error.messages()
1516 return []
1519class WuttaSchemaNode(SQLAlchemySchemaNode): # pylint: disable=abstract-method
1520 """
1521 Custom schema node type based on ColanderAlchemy, but adding
1522 support for association proxy fields.
1524 This class is used under the hood but you will not normally
1525 interact with it directly.
1527 It's a subclass of
1528 :class:`colanderalchemy:colanderalchemy.SQLAlchemySchemaNode`.
1529 """
1531 def add_nodes(self, includes, excludes, overrides):
1532 super().add_nodes(includes, excludes, overrides)
1534 for name in includes or []:
1535 prop = self.inspector.attrs.get(name, name)
1536 if isinstance(prop, str):
1538 # add node for association proxy field
1539 if column := get_association_proxy_column(self.inspector, name):
1540 name_overrides_copy = overrides.get(name, {}).copy()
1541 node = self.get_schema_from_column(column, name_overrides_copy)
1542 if node is not None:
1543 self.add(node)
1545 def dictify(self, obj): # pylint: disable=empty-docstring
1546 """ """
1547 dct = super().dictify(obj)
1549 # loop thru all fields to add the association proxies
1550 for node in self:
1551 name = node.name
1552 if name in dct:
1553 continue # value already set
1555 # we only care about association proxies here
1556 if not get_association_proxy_column(self.inspector, name):
1557 continue
1559 dct[name] = getattr(obj, name)
1561 # special handling when value is None
1562 if dct[name] is None:
1564 # nb. colander/deform know how to behave when using
1565 # their dedicated colander.null value, but ``None``
1566 # seems to cause issues for string fields, so swap
1567 # that out here if applicable
1568 if isinstance(node.typ, colander.String):
1569 dct[name] = colander.null
1571 return dct
1573 def objectify(self, dict_, context=None): # pylint: disable=empty-docstring
1574 """ """
1575 context = super().objectify(dict_, context=context)
1577 for attr in dict_:
1578 if self.inspector.has_property(attr):
1579 continue # upstream logic handles these
1581 # try to process association proxy field
1582 if get_association_proxy_column(self.inspector, attr):
1583 value = dict_[attr]
1584 if value is colander.null:
1585 # `colander.null` is never an appropriate
1586 # value to be placed on an SQLAlchemy object
1587 # so we translate it into `None`.
1588 value = None
1589 setattr(context, attr, value)
1591 return context
1594def get_association_proxy(mapper, field):
1595 """
1596 Return the association proxy "descriptor" corresponding to the
1597 given field name, if it exists.
1599 :param mapper: SQLAlchemy mapper for the main class.
1601 :param field: Field name on the main class, which may (or may not)
1602 be proxied via association.
1604 :returns:
1605 :class:`~sqlalchemy:sqlalchemy.ext.associationproxy.AssociationProxy`
1606 instance, or ``None``.
1608 Using the ``User.first_name`` (which proxies to
1609 ``User.person.first_name``) example, this would return the proxy
1610 descriptor for ``User.first_name``.
1611 """
1612 try:
1613 desc = getattr(mapper.all_orm_descriptors, field)
1614 except AttributeError:
1615 pass
1616 else:
1617 if desc.extension_type.name == "ASSOCIATION_PROXY":
1618 return desc
1619 return None
1622def get_association_proxy_target(mapper, field):
1623 """
1624 Return the relationship property involved in the association proxy
1625 for the given field, if applicable.
1627 :param mapper: SQLAlchemy mapper for the main class.
1629 :param field: Field name on the main class, which may (or may not)
1630 be proxied via association.
1632 :returns: :class:`~sqlalchemy:sqlalchemy.orm.RelationshipProperty`
1633 instance, or ``None``.
1635 Using the ``User.first_name`` (which proxies to
1636 ``User.person.first_name``) example, this would return the
1637 ``User.person`` relationship property.
1638 """
1639 if proxy := get_association_proxy(mapper, field):
1640 proxy_target = mapper.get_property(proxy.target_collection)
1641 if (
1642 isinstance(proxy_target, orm.RelationshipProperty)
1643 and not proxy_target.uselist
1644 ):
1645 return proxy_target
1646 return None
1649def get_association_proxy_column(mapper, field):
1650 """
1651 Return the target column property involved in the association
1652 proxy for the given field, if applicable.
1654 :param mapper: SQLAlchemy mapper for the main class.
1656 :param field: Field name on the main class, which may (or may not)
1657 be proxied via association.
1659 :returns: :class:`~sqlalchemy:sqlalchemy.orm.ColumnProperty`
1660 instance, or ``None``.
1662 Using the ``User.first_name`` (which proxies to
1663 ``User.person.first_name``) example, this would return the
1664 ``Person.first_name`` column property.
1665 """
1666 if proxy_target := get_association_proxy_target(mapper, field):
1667 if proxy_target.mapper.has_property(field):
1668 prop = proxy_target.mapper.get_property(field)
1669 if isinstance(prop, orm.ColumnProperty) and isinstance(
1670 prop.columns[0], sa.Column
1671 ):
1672 return prop
1673 return None