Coverage for .tox / coverage / lib / python3.11 / site-packages / wuttaweb / forms / base.py: 100%
416 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-25 09:16 -0600
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-25 09:16 -0600
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# wuttaweb -- Web App for Wutta Framework
5# Copyright © 2024-2025 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"""
26# pylint: disable=too-many-lines
28import logging
29from collections import OrderedDict
31import sqlalchemy as sa
32from sqlalchemy import orm
34import colander
35import deform
36from colanderalchemy import SQLAlchemySchemaNode
37from pyramid.renderers import render
38from webhelpers2.html import HTML
40from wuttaweb.util import (
41 FieldList,
42 get_form_data,
43 get_model_fields,
44 make_json_safe,
45 render_vue_finalize,
46)
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:: action_method
173 HTTP method to use when submitting form; ``'post'`` is default.
175 .. attribute:: action_url
177 String URL to which the form should be submitted, if applicable.
179 .. attribute:: reset_url
181 String URL to which the reset button should "always" redirect,
182 if applicable.
184 This is null by default, in which case it will use standard
185 browser behavior for the form reset button (if shown). See
186 also :attr:`show_button_reset`.
188 .. attribute:: cancel_url
190 String URL to which the Cancel button should "always" redirect,
191 if applicable.
193 Code should not access this directly, but instead call
194 :meth:`get_cancel_url()`.
196 .. attribute:: cancel_url_fallback
198 String URL to which the Cancel button should redirect, if
199 referrer cannot be determined from request.
201 Code should not access this directly, but instead call
202 :meth:`get_cancel_url()`.
204 .. attribute:: vue_tagname
206 String name for Vue component tag. By default this is
207 ``'wutta-form'``. See also :meth:`render_vue_tag()`.
209 See also :attr:`vue_component`.
211 .. attribute:: align_buttons_right
213 Flag indicating whether the buttons (submit, cancel etc.)
214 should be aligned to the right of the area below the form. If
215 not set, the buttons are left-aligned.
217 .. attribute:: auto_disable_submit
219 Flag indicating whether the submit button should be
220 auto-disabled, whenever the form is submitted.
222 .. attribute:: button_label_submit
224 String label for the form submit button. Default is ``"Save"``.
226 .. attribute:: button_icon_submit
228 String icon name for the form submit button. Default is ``'save'``.
230 .. attribute:: button_type_submit
232 Buefy type for the submit button. Default is ``'is-primary'``,
233 so for example:
235 .. code-block:: html
237 <b-button type="is-primary"
238 native-type="submit">
239 Save
240 </b-button>
242 See also the `Buefy docs
243 <https://buefy.org/documentation/button/#api-view>`_.
245 .. attribute:: show_button_reset
247 Flag indicating whether a Reset button should be shown.
248 Default is ``False``.
250 Unless there is a :attr:`reset_url`, the reset button will use
251 standard behavior per the browser.
253 .. attribute:: show_button_cancel
255 Flag indicating whether a Cancel button should be shown.
256 Default is ``True``.
258 .. attribute:: button_label_cancel
260 String label for the form cancel button. Default is
261 ``"Cancel"``.
263 .. attribute:: auto_disable_cancel
265 Flag indicating whether the cancel button should be
266 auto-disabled, whenever the button is clicked. Default is
267 ``True``.
269 .. attribute:: validated
271 If the :meth:`validate()` method was called, and it succeeded,
272 this will be set to the validated data dict.
273 """
275 deform_form = None
276 validated = None
278 vue_template = "/forms/vue_template.mako"
279 fields_template = "/forms/vue_fields.mako"
280 buttons_template = "/forms/vue_buttons.mako"
282 def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
283 self,
284 request,
285 fields=None,
286 schema=None,
287 model_class=None,
288 model_instance=None,
289 nodes=None,
290 widgets=None,
291 validators=None,
292 defaults=None,
293 readonly=False,
294 readonly_fields=None,
295 required_fields=None,
296 labels=None,
297 action_method="post",
298 action_url=None,
299 reset_url=None,
300 cancel_url=None,
301 cancel_url_fallback=None,
302 vue_tagname="wutta-form",
303 align_buttons_right=False,
304 auto_disable_submit=True,
305 button_label_submit="Save",
306 button_icon_submit="save",
307 button_type_submit="is-primary",
308 show_button_reset=False,
309 show_button_cancel=True,
310 button_label_cancel="Cancel",
311 auto_disable_cancel=True,
312 ):
313 self.request = request
314 self.schema = schema
315 self.nodes = nodes or {}
316 self.widgets = widgets or {}
317 self.validators = validators or {}
318 self.defaults = defaults or {}
319 self.readonly = readonly
320 self.readonly_fields = set(readonly_fields or [])
321 self.required_fields = required_fields or {}
322 self.labels = labels or {}
323 self.action_method = action_method
324 self.action_url = action_url
325 self.cancel_url = cancel_url
326 self.cancel_url_fallback = cancel_url_fallback
327 self.reset_url = reset_url
328 self.vue_tagname = vue_tagname
329 self.align_buttons_right = align_buttons_right
330 self.auto_disable_submit = auto_disable_submit
331 self.button_label_submit = button_label_submit
332 self.button_icon_submit = button_icon_submit
333 self.button_type_submit = button_type_submit
334 self.show_button_reset = show_button_reset
335 self.show_button_cancel = show_button_cancel
336 self.button_label_cancel = button_label_cancel
337 self.auto_disable_cancel = auto_disable_cancel
338 self.form_attrs = {}
340 self.config = self.request.wutta_config
341 self.app = self.config.get_app()
343 self.model_class = model_class
344 self.model_instance = model_instance
345 if self.model_instance and not self.model_class:
346 if not isinstance(self.model_instance, dict):
347 self.model_class = type(self.model_instance)
349 self.set_fields(fields or self.get_fields())
350 self.set_default_widgets()
352 # nb. this tracks grid JSON data for inclusion in page template
353 self.grid_vue_context = OrderedDict()
355 def __contains__(self, name):
356 """
357 Custom logic for the ``in`` operator, to allow easily checking
358 if the form contains a given field::
360 myform = Form()
361 if 'somefield' in myform:
362 print("my form has some field")
363 """
364 return bool(self.fields and name in self.fields)
366 def __iter__(self):
367 """
368 Custom logic to allow iterating over form field names::
370 myform = Form(fields=['foo', 'bar'])
371 for fieldname in myform:
372 print(fieldname)
373 """
374 return iter(self.fields)
376 @property
377 def vue_component(self):
378 """
379 String name for the Vue component, e.g. ``'WuttaForm'``.
381 This is a generated value based on :attr:`vue_tagname`.
382 """
383 words = self.vue_tagname.split("-")
384 return "".join([word.capitalize() for word in words])
386 def get_cancel_url(self):
387 """
388 Returns the URL for the Cancel button.
390 If :attr:`cancel_url` is set, its value is returned.
392 Or, if the referrer can be deduced from the request, that is
393 returned.
395 Or, if :attr:`cancel_url_fallback` is set, that value is
396 returned.
398 As a last resort the "default" URL from
399 :func:`~wuttaweb.subscribers.request.get_referrer()` is
400 returned.
401 """
402 # use "permanent" URL if set
403 if self.cancel_url:
404 return self.cancel_url
406 # nb. use fake default to avoid normal default logic;
407 # that way if we get something it's a real referrer
408 url = self.request.get_referrer(default="NOPE")
409 if url and url != "NOPE":
410 return url
412 # use fallback URL if set
413 if self.cancel_url_fallback:
414 return self.cancel_url_fallback
416 # okay, home page then (or whatever is the default URL)
417 return self.request.get_referrer()
419 def set_fields(self, fields):
420 """
421 Explicitly set the list of form fields.
423 This will overwrite :attr:`fields` with a new
424 :class:`~wuttaweb.util.FieldList` instance.
426 :param fields: List of string field names.
427 """
428 self.fields = FieldList(fields)
430 def append(self, *keys):
431 """
432 Add some fields(s) to the form.
434 This is a convenience to allow adding multiple fields at
435 once::
437 form.append('first_field',
438 'second_field',
439 'third_field')
441 It will add each field to :attr:`fields`.
442 """
443 for key in keys:
444 if key not in self.fields:
445 self.fields.append(key)
447 def remove(self, *keys):
448 """
449 Remove some fields(s) from the form.
451 This is a convenience to allow removal of multiple fields at
452 once::
454 form.remove('first_field',
455 'second_field',
456 'third_field')
458 It will remove each field from :attr:`fields`.
459 """
460 for key in keys:
461 if key in self.fields:
462 self.fields.remove(key)
464 def set_node(self, key, nodeinfo, **kwargs):
465 """
466 Set/override the node for a field.
468 :param key: Name of field.
470 :param nodeinfo: Should be either a
471 :class:`colander:colander.SchemaNode` instance, or else a
472 :class:`colander:colander.SchemaType` instance.
474 If ``nodeinfo`` is a proper node instance, it will be used
475 as-is. Otherwise an
476 :class:`~wuttaweb.forms.schema.ObjectNode` instance will be
477 constructed using ``nodeinfo`` as the type (``typ``).
479 Node overrides are tracked via :attr:`nodes`.
480 """
481 from wuttaweb.forms.schema import ( # pylint: disable=import-outside-toplevel
482 ObjectNode,
483 )
485 if isinstance(nodeinfo, colander.SchemaNode):
486 # assume nodeinfo is a complete node
487 node = nodeinfo
489 else: # assume nodeinfo is a schema type
490 kwargs.setdefault("name", key)
491 node = ObjectNode(nodeinfo, **kwargs)
493 self.nodes[key] = node
495 # must explicitly replace node, if we already have a schema
496 if self.schema:
497 self.schema[key] = node
499 def set_widget(self, key, widget, **kwargs):
500 """
501 Set/override the widget for a field.
503 You can specify a widget instance or else a named "type" of
504 widget, in which case that is passed along to
505 :meth:`make_widget()`.
507 :param key: Name of field.
509 :param widget: Either a :class:`deform:deform.widget.Widget`
510 instance, or else a widget "type" name.
512 :param \\**kwargs: Any remaining kwargs are passed along to
513 :meth:`make_widget()` - if applicable.
515 Widget overrides are tracked via :attr:`widgets`.
516 """
517 if not isinstance(widget, deform.widget.Widget):
518 widget_obj = self.make_widget(widget, **kwargs)
519 if not widget_obj:
520 raise ValueError(f"widget type not supported: {widget}")
521 widget = widget_obj
523 self.widgets[key] = widget
525 # update schema if necessary
526 if self.schema and key in self.schema:
527 self.schema[key].widget = widget
529 def make_widget(self, widget_type, **kwargs):
530 """
531 Make and return a new field widget of the given type.
533 This has built-in support for the following types (although
534 subclass can override as needed):
536 * ``'notes'`` => :class:`~wuttaweb.forms.widgets.NotesWidget`
538 See also :meth:`set_widget()` which may call this method
539 automatically.
541 :param widget_type: Which of the above (or custom) widget
542 type to create.
544 :param \\**kwargs: Remaining kwargs are passed as-is to the
545 widget factory.
547 :returns: New widget instance, or ``None`` if e.g. it could
548 not determine how to create the widget.
549 """
550 from wuttaweb.forms import widgets # pylint: disable=import-outside-toplevel
552 if widget_type == "notes":
553 return widgets.NotesWidget(**kwargs)
555 return None
557 def set_default_widgets(self):
558 """
559 Set default field widgets, where applicable.
561 This will add new entries to :attr:`widgets` for columns
562 whose data type implies a default widget should be used.
563 This is generally only possible if :attr:`model_class` is set
564 to a valid SQLAlchemy mapped class.
566 This only checks for a couple of data types, with mapping as
567 follows:
569 * :class:`sqlalchemy:sqlalchemy.types.Date` ->
570 :class:`~wuttaweb.forms.widgets.WuttaDateWidget`
571 * :class:`sqlalchemy:sqlalchemy.types.DateTime` ->
572 :class:`~wuttaweb.forms.widgets.WuttaDateTimeWidget`
573 """
574 from wuttaweb.forms import widgets # pylint: disable=import-outside-toplevel
576 if not self.model_class:
577 return
579 for key in self.fields:
580 if key in self.widgets:
581 continue
583 attr = getattr(self.model_class, key, None)
584 if attr:
585 prop = getattr(attr, "prop", None)
586 if prop and isinstance(prop, orm.ColumnProperty):
587 column = prop.columns[0]
588 if isinstance(column.type, sa.Date):
589 self.set_widget(key, widgets.WuttaDateWidget(self.request))
590 elif isinstance(column.type, sa.DateTime):
591 self.set_widget(key, widgets.WuttaDateTimeWidget(self.request))
593 def set_grid(self, key, grid):
594 """
595 Establish a :term:`grid` to be displayed for a field. This
596 uses a :class:`~wuttaweb.forms.widgets.GridWidget` to wrap the
597 rendered grid.
599 :param key: Name of field.
601 :param widget: :class:`~wuttaweb.grids.base.Grid` instance,
602 pre-configured and (usually) with data.
603 """
604 from wuttaweb.forms.widgets import ( # pylint: disable=import-outside-toplevel
605 GridWidget,
606 )
608 widget = GridWidget(self.request, grid)
609 self.set_widget(key, widget)
610 self.add_grid_vue_context(grid)
612 def add_grid_vue_context(self, grid): # pylint: disable=empty-docstring
613 """ """
614 if not grid.key:
615 raise ValueError("grid must have a key!")
617 if grid.key in self.grid_vue_context:
618 log.warning(
619 "grid data with key '%s' already registered, but will be replaced",
620 grid.key,
621 )
623 self.grid_vue_context[grid.key] = grid.get_vue_context()
625 def set_validator(self, key, validator):
626 """
627 Set/override the validator for a field, or the form.
629 :param key: Name of field. This may also be ``None`` in which
630 case the validator will apply to the whole form instead of
631 a field.
633 :param validator: Callable which accepts ``(node, value)``
634 args. For instance::
636 def validate_foo(node, value):
637 if value == 42:
638 node.raise_invalid("42 is not allowed!")
640 form = Form(fields=['foo', 'bar'])
642 form.set_validator('foo', validate_foo)
644 Validator overrides are tracked via :attr:`validators`.
645 """
646 self.validators[key] = validator
648 # nb. must apply to existing schema if present
649 if self.schema and key in self.schema:
650 self.schema[key].validator = validator
652 def set_default(self, key, value):
653 """
654 Set/override the default value for a field.
656 :param key: Name of field.
658 :param validator: Default value for the field.
660 Default value overrides are tracked via :attr:`defaults`.
661 """
662 self.defaults[key] = value
664 def set_readonly(self, key, readonly=True):
665 """
666 Enable or disable the "readonly" flag for a given field.
668 When a field is marked readonly, it will be shown in the form
669 but there will be no editable widget. The field is skipped
670 over (not saved) when form is submitted.
672 See also :meth:`is_readonly()`; this is tracked via
673 :attr:`readonly_fields`.
675 :param key: String key (fieldname) for the field.
677 :param readonly: New readonly flag for the field.
678 """
679 if readonly:
680 self.readonly_fields.add(key)
681 else:
682 if key in self.readonly_fields:
683 self.readonly_fields.remove(key)
685 def is_readonly(self, key):
686 """
687 Returns boolean indicating if the given field is marked as
688 readonly.
690 See also :meth:`set_readonly()`.
692 :param key: Field key/name as string.
693 """
694 if self.readonly_fields:
695 if key in self.readonly_fields:
696 return True
697 return False
699 def set_required(self, key, required=True):
700 """
701 Enable or disable the "required" flag for a given field.
703 When a field is marked required, a value must be provided
704 or else it fails validation.
706 In practice if a field is "not required" then a default
707 "empty" value is assumed, should the user not provide one.
709 See also :meth:`is_required()`; this is tracked via
710 :attr:`required_fields`.
712 :param key: String key (fieldname) for the field.
714 :param required: New required flag for the field. Usually a
715 boolean, but may also be ``None`` to remove any flag and
716 revert to default behavior for the field.
717 """
718 self.required_fields[key] = required
720 def is_required(self, key):
721 """
722 Returns boolean indicating if the given field is marked as
723 required.
725 See also :meth:`set_required()`.
727 :param key: Field key/name as string.
729 :returns: Value for the flag from :attr:`required_fields` if
730 present; otherwise ``None``.
731 """
732 return self.required_fields.get(key, None)
734 def set_label(self, key, label):
735 """
736 Set the label for given field name.
738 See also :meth:`get_label()`.
739 """
740 self.labels[key] = label
742 # update schema if necessary
743 if self.schema and key in self.schema:
744 self.schema[key].title = label
746 def get_label(self, key):
747 """
748 Get the label for given field name.
750 Note that this will always return a string, auto-generating
751 the label if needed.
753 See also :meth:`set_label()`.
754 """
755 return self.labels.get(key, self.app.make_title(key))
757 def get_fields(self):
758 """
759 Returns the official list of field names for the form, or
760 ``None``.
762 If :attr:`fields` is set and non-empty, it is returned.
764 Or, if :attr:`schema` is set, the field list is derived
765 from that.
767 Or, if :attr:`model_class` is set, the field list is derived
768 from that, via :meth:`get_model_fields()`.
770 Otherwise ``None`` is returned.
771 """
772 if hasattr(self, "fields") and self.fields:
773 return self.fields
775 if self.schema:
776 return [field.name for field in self.schema]
778 fields = self.get_model_fields()
779 if fields:
780 return fields
782 return []
784 def get_model_fields(self, model_class=None):
785 """
786 This method is a shortcut which calls
787 :func:`~wuttaweb.util.get_model_fields()`.
789 :param model_class: Optional model class for which to return
790 fields. If not set, the form's :attr:`model_class` is
791 assumed.
792 """
793 return get_model_fields(
794 self.config, model_class=model_class or self.model_class
795 )
797 def get_schema(self): # pylint: disable=too-many-branches
798 """
799 Return the :class:`colander:colander.Schema` object for the
800 form, generating it automatically if necessary.
802 Note that if :attr:`schema` is already set, that will be
803 returned as-is.
804 """
805 if not self.schema:
807 ##############################
808 # create schema
809 ##############################
811 # get fields
812 fields = self.get_fields()
813 if not fields:
814 raise ValueError(
815 "could not determine fields list; "
816 "please set model_class or fields explicitly"
817 )
819 if self.model_class:
821 # collect list of field names and/or nodes
822 includes = []
823 for key in fields:
824 if key in self.nodes:
825 includes.append(self.nodes[key])
826 else:
827 includes.append(key)
829 # make initial schema with ColanderAlchemy magic
830 schema = WuttaSchemaNode(self.model_class, includes=includes)
832 # fill in the blanks if anything got missed
833 for key in fields:
834 if key not in schema:
835 node = colander.SchemaNode(colander.String(), name=key)
836 schema.add(node)
838 else:
840 # make basic schema
841 schema = colander.Schema()
842 for key in fields:
843 node = None
845 # use node override if present
846 if key in self.nodes:
847 node = self.nodes[key]
848 if not node:
850 # otherwise make simple string node
851 node = colander.SchemaNode(colander.String(), name=key)
853 schema.add(node)
855 ##############################
856 # customize schema
857 ##############################
859 # apply widget overrides
860 for key, widget in self.widgets.items():
861 if key in schema:
862 schema[key].widget = widget
864 # apply validator overrides
865 for key, validator in self.validators.items():
866 if key is None:
867 # nb. this one is form-wide
868 schema.validator = validator
869 elif key in schema: # field-level
870 schema[key].validator = validator
872 # apply default value overrides
873 for key, value in self.defaults.items():
874 if key in schema:
875 schema[key].default = value
877 # apply required flags
878 for key, required in self.required_fields.items():
879 if key in schema:
880 if required is False:
881 schema[key].missing = colander.null
883 self.schema = schema
885 return self.schema
887 def get_deform(self):
888 """
889 Return the :class:`deform:deform.Form` instance for the form,
890 generating it automatically if necessary.
891 """
892 if not self.deform_form:
893 schema = self.get_schema()
894 kwargs = {}
896 if self.model_instance:
898 # TODO: i keep finding problems with this, not sure
899 # what needs to happen. some forms will have a simple
900 # dict for model_instance, others will have a proper
901 # SQLAlchemy object. and in the latter case, it may
902 # not be "wutta-native" but from another DB.
904 # so the problem is, how to detect whether we should
905 # use the model_instance as-is or if we should convert
906 # to a dict. some options include:
908 # - check if instance has dictify() method
909 # i *think* this was tried and didn't work? but do not recall
911 # - check if is instance of model.Base
912 # this is unreliable since model.Base is wutta-native
914 # - check if form has a model_class
915 # has not been tried yet
917 # - check if schema is from colanderalchemy
918 # this is what we are trying currently...
920 if isinstance(schema, SQLAlchemySchemaNode):
921 kwargs["appstruct"] = schema.dictify(self.model_instance)
922 else:
923 kwargs["appstruct"] = self.model_instance
925 # create the Deform instance
926 # nb. must give a reference back to wutta form; this is
927 # for sake of field schema nodes and widgets, e.g. to
928 # access the main model instance
929 form = deform.Form(schema, **kwargs)
930 form.wutta_form = self
931 self.deform_form = form
933 return self.deform_form
935 def render_vue_tag(self, **kwargs):
936 """
937 Render the Vue component tag for the form.
939 By default this simply returns:
941 .. code-block:: html
943 <wutta-form></wutta-form>
945 The actual output will depend on various form attributes, in
946 particular :attr:`vue_tagname`.
947 """
948 return HTML.tag(self.vue_tagname, **kwargs)
950 def render_vue_template(self, template=None, **context):
951 """
952 Render the Vue template block for the form.
954 This returns something like:
956 .. code-block:: none
958 <script type="text/x-template" id="wutta-form-template">
959 <form>
960 <!-- fields etc. -->
961 </form>
962 </script>
964 <script>
965 const WuttaFormData = {}
966 const WuttaForm = {
967 template: 'wutta-form-template',
968 }
969 </script>
971 .. todo::
973 Why can't Sphinx render the above code block as 'html' ?
975 It acts like it can't handle a ``<script>`` tag at all?
977 Actual output will of course depend on form attributes, i.e.
978 :attr:`vue_tagname` and :attr:`fields` list etc.
980 Default logic will also invoke (indirectly):
982 * :meth:`render_vue_fields()`
983 * :meth:`render_vue_buttons()`
985 :param template: Optional template path to override the class
986 default.
988 :returns: HTML literal
989 """
990 context = self.get_vue_context(**context)
991 html = render(template or self.vue_template, context)
992 return HTML.literal(html)
994 def get_vue_context(self, **context): # pylint: disable=missing-function-docstring
995 context["form"] = self
996 context["dform"] = self.get_deform()
997 context.setdefault("request", self.request)
998 context["model_data"] = self.get_vue_model_data()
1000 # set form method, enctype
1001 form_attrs = context.setdefault("form_attrs", dict(self.form_attrs))
1002 form_attrs.setdefault("method", self.action_method)
1003 if self.action_method == "post":
1004 form_attrs.setdefault("enctype", "multipart/form-data")
1006 # auto disable button on submit
1007 if self.auto_disable_submit:
1008 form_attrs["@submit"] = "formSubmitting = true"
1010 # duplicate entire context for sake of fields/buttons template
1011 context["form_context"] = context
1013 return context
1015 def render_vue_fields(self, context, template=None, **kwargs):
1016 """
1017 Render the fields section within the form template.
1019 This is normally invoked from within the form's
1020 ``vue_template`` like this:
1022 .. code-block:: none
1024 ${form.render_vue_fields(form_context)}
1026 There is a default ``fields_template`` but that is only the
1027 last resort. Logic will first look for a
1028 ``form_vue_fields()`` def within the *main template* being
1029 rendered for the page.
1031 An example will surely help:
1033 .. code-block:: mako
1035 <%inherit file="/master/edit.mako" />
1037 <%def name="form_vue_fields()">
1039 <p>this is my custom fields section:</p>
1041 ${form.render_vue_field("myfield")}
1043 </%def>
1045 This keeps the custom fields section within the main page
1046 template as opposed to yet another file. But if your page
1047 template has no ``form_vue_fields()`` def, then the class
1048 default template is used. (Unless the ``template`` param
1049 is specified.)
1051 See also :meth:`render_vue_template()` and
1052 :meth:`render_vue_buttons()`.
1054 :param context: This must be the original context as provided
1055 to the form's ``vue_template``. See example above.
1057 :param template: Optional template path to use instead of the
1058 defaults described above.
1060 :returns: HTML literal
1061 """
1062 context.update(kwargs)
1063 html = False
1065 if not template:
1067 if main_template := context.get("main_template"):
1068 try:
1069 vue_fields = main_template.get_def("form_vue_fields")
1070 except AttributeError:
1071 pass
1072 else:
1073 html = vue_fields.render(**context)
1075 if html is False:
1076 template = self.fields_template
1078 if html is False:
1079 html = render(template, context)
1081 return HTML.literal(html)
1083 def render_vue_field(
1084 self,
1085 fieldname,
1086 readonly=None,
1087 label=True,
1088 horizontal=True,
1089 **kwargs,
1090 ): # pylint: disable=unused-argument,too-many-locals,too-many-branches
1091 """
1092 Render the given field completely, i.e. ``<b-field>`` wrapper
1093 with label and a widget, with validation errors flagged as
1094 needed.
1096 Actual output will depend on the field attributes etc.
1097 Typical output might look like:
1099 .. code-block:: html
1101 <b-field label="Foo"
1102 horizontal
1103 type="is-danger"
1104 message="something went wrong!">
1105 <b-input name="foo"
1106 v-model="${form.get_field_vmodel('foo')}" />
1107 </b-field>
1109 :param fieldname: Name of field to render.
1111 :param readonly: Optional override for readonly flag.
1113 :param label: Whether to include/set the field label.
1115 :param horizontal: Boolean value for the ``horizontal`` flag
1116 on the field.
1118 :param \\**kwargs: Remaining kwargs are passed to widget's
1119 ``serialize()`` method.
1121 :returns: HTML literal
1122 """
1123 # readonly comes from: caller, field flag, or form flag
1124 if readonly is None:
1125 readonly = self.is_readonly(fieldname)
1126 if not readonly:
1127 readonly = self.readonly
1129 # but also, fields not in deform/schema must be readonly
1130 dform = self.get_deform()
1131 if not readonly and fieldname not in dform:
1132 readonly = True
1134 # render the field widget or whatever
1135 if fieldname in dform:
1137 # render proper widget if field is in deform/schema
1138 field = dform[fieldname]
1139 if readonly:
1140 kwargs["readonly"] = True
1142 try:
1143 html = field.serialize(**kwargs)
1144 except Exception as exc:
1145 log.warning(
1146 "widget serialization failed for field: %s",
1147 fieldname,
1148 exc_info=True,
1149 )
1150 raise RuntimeError(
1151 f"widget serialization failed for field: {fieldname}"
1152 ) from exc
1154 else:
1155 # render static text if field not in deform/schema
1156 # TODO: need to abstract this somehow
1157 if self.model_instance:
1158 value = self.app.get_value(self.model_instance, fieldname)
1159 html = str(value) if value is not None else ""
1160 else:
1161 html = ""
1163 # mark all that as safe
1164 html = HTML.literal(html or " ")
1166 # render field label
1167 if label:
1168 label = self.get_label(fieldname)
1170 # b-field attrs
1171 attrs = {
1172 ":horizontal": "true" if horizontal else "false",
1173 "label": label or "",
1174 }
1176 # next we will build array of messages to display..some
1177 # fields always show a "helptext" msg, and some may have
1178 # validation errors..
1179 field_type = None
1180 messages = []
1182 # show errors if present
1183 errors = self.get_field_errors(fieldname)
1184 if errors:
1185 field_type = "is-danger"
1186 messages.extend(errors)
1188 # ..okay now we can declare the field messages and type
1189 if field_type:
1190 attrs["type"] = field_type
1191 if messages:
1192 cls = "is-size-7"
1193 if field_type == "is-danger":
1194 cls += " has-text-danger"
1195 messages = [HTML.tag("p", c=[msg], class_=cls) for msg in messages]
1196 slot = HTML.tag("slot", name="messages", c=messages)
1197 html = HTML.tag("div", c=[html, slot])
1199 return HTML.tag("b-field", c=[html], **attrs)
1201 def render_vue_buttons(self, context, template=None, **kwargs):
1202 """
1203 Render the buttons section within the form template.
1205 This is normally invoked from within the form's
1206 ``vue_template`` like this:
1208 .. code-block:: none
1210 ${form.render_vue_buttons(form_context)}
1212 .. note::
1214 This method does not yet inspect the main page template,
1215 unlike :meth:`render_vue_fields()`.
1217 See also :meth:`render_vue_template()`.
1219 :param context: This must be the original context as provided
1220 to the form's ``vue_template``. See example above.
1222 :param template: Optional template path to override the class
1223 default.
1225 :returns: HTML literal
1226 """
1227 context.update(kwargs)
1228 html = render(template or self.buttons_template, context)
1229 return HTML.literal(html)
1231 def render_vue_finalize(self):
1232 """
1233 Render the Vue "finalize" script for the form.
1235 By default this simply returns:
1237 .. code-block:: html
1239 <script>
1240 WuttaForm.data = function() { return WuttaFormData }
1241 Vue.component('wutta-form', WuttaForm)
1242 </script>
1244 The actual output may depend on various form attributes, in
1245 particular :attr:`vue_tagname`.
1246 """
1247 return render_vue_finalize(self.vue_tagname, self.vue_component)
1249 def get_field_vmodel(self, field):
1250 """
1251 Convenience to return the ``v-model`` data reference for the
1252 given field. For instance:
1254 .. code-block:: none
1256 <b-input name="myfield"
1257 v-model="${form.get_field_vmodel('myfield')}" />
1259 <div v-show="${form.get_field_vmodel('myfield')} == 'easter'">
1260 easter egg!
1261 </div>
1263 :returns: JS-valid string referencing the field value
1264 """
1265 dform = self.get_deform()
1266 return f"modelData.{dform[field].oid}"
1268 def get_vue_model_data(self):
1269 """
1270 Returns a dict with form model data. Values may be nested
1271 depending on the types of fields contained in the form.
1273 This collects the ``cstruct`` values for all fields which are
1274 present both in :attr:`fields` as well as the Deform schema.
1276 It also converts each as needed, to ensure it is
1277 JSON-serializable.
1279 :returns: Dict of field/value items.
1280 """
1281 dform = self.get_deform()
1282 model_data = {}
1284 def assign(field):
1285 value = field.cstruct
1287 # TODO: we need a proper true/false on the Vue side,
1288 # but deform/colander want 'true' and 'false' ..so
1289 # for now we explicitly translate here, ugh. also
1290 # note this does not yet allow for null values.. :(
1291 if isinstance(field.typ, colander.Boolean):
1292 value = value == field.typ.true_val
1294 model_data[field.oid] = make_json_safe(value)
1296 for key in self.fields:
1298 # TODO: i thought commented code was useful, but no longer sure?
1300 # TODO: need to describe the scenario when this is true
1301 if key not in dform:
1302 # log.warning("field '%s' is missing from deform", key)
1303 continue
1305 field = dform[key]
1307 # if hasattr(field, 'children'):
1308 # for subfield in field.children:
1309 # assign(subfield)
1311 assign(field)
1313 return model_data
1315 # TODO: for tailbone compat, should document?
1316 # (ideally should remove this and find a better way)
1317 def get_vue_field_value(self, key): # pylint: disable=empty-docstring
1318 """ """
1319 if key not in self.fields:
1320 return None
1322 dform = self.get_deform()
1323 if key not in dform:
1324 return None
1326 field = dform[key]
1327 return make_json_safe(field.cstruct)
1329 def validate(self):
1330 """
1331 Try to validate the form, using data from the :attr:`request`.
1333 Uses :func:`~wuttaweb.util.get_form_data()` to retrieve the
1334 form data from POST or JSON body.
1336 If the form data is valid, the data dict is returned. This
1337 data dict is also made available on the form object via the
1338 :attr:`validated` attribute.
1340 However if the data is not valid, ``False`` is returned, and
1341 the :attr:`validated` attribute will be ``None``. In that
1342 case you should inspect the form errors to learn/display what
1343 went wrong for the user's sake. See also
1344 :meth:`get_field_errors()`.
1346 This uses :meth:`deform:deform.Field.validate()` under the
1347 hood.
1349 .. warning::
1351 Calling ``validate()`` on some forms will cause the
1352 underlying Deform and Colander structures to mutate. In
1353 particular, all :attr:`readonly_fields` will be *removed*
1354 from the :attr:`schema` to ensure they are not involved in
1355 the validation.
1357 :returns: Data dict, or ``False``.
1358 """
1359 self.validated = None
1361 if self.request.method != "POST":
1362 return False
1364 # remove all readonly fields from deform / schema
1365 dform = self.get_deform()
1366 if self.readonly_fields:
1367 schema = self.get_schema()
1368 for field in self.readonly_fields:
1369 if field in schema:
1370 del schema[field]
1371 dform.children.remove(dform[field])
1373 # let deform do real validation
1374 controls = get_form_data(self.request).items()
1375 try:
1376 self.validated = dform.validate(controls)
1377 except deform.ValidationFailure:
1378 log.debug("form not valid: %s", dform.error)
1379 return False
1381 return self.validated
1383 def has_global_errors(self):
1384 """
1385 Convenience function to check if the form has any "global"
1386 (not field-level) errors.
1388 See also :meth:`get_global_errors()`.
1390 :returns: ``True`` if global errors present, else ``False``.
1391 """
1392 dform = self.get_deform()
1393 return bool(dform.error)
1395 def get_global_errors(self):
1396 """
1397 Returns a list of "global" (not field-level) error messages
1398 for the form.
1400 See also :meth:`has_global_errors()`.
1402 :returns: List of error messages (possibly empty).
1403 """
1404 dform = self.get_deform()
1405 if dform.error is None:
1406 return []
1407 return dform.error.messages()
1409 def get_field_errors(self, field):
1410 """
1411 Return a list of error messages for the given field.
1413 Not useful unless a call to :meth:`validate()` failed.
1414 """
1415 dform = self.get_deform()
1416 if field in dform:
1417 field = dform[field]
1418 if field.error:
1419 return field.error.messages()
1420 return []
1423class WuttaSchemaNode(SQLAlchemySchemaNode): # pylint: disable=abstract-method
1424 """
1425 Custom schema node type based on ColanderAlchemy, but adding
1426 support for association proxy fields.
1428 This class is used under the hood but you will not normally
1429 interact with it directly.
1431 It's a subclass of
1432 :class:`colanderalchemy:colanderalchemy.SQLAlchemySchemaNode`.
1433 """
1435 def add_nodes(self, includes, excludes, overrides):
1436 super().add_nodes(includes, excludes, overrides)
1438 for name in includes or []:
1439 prop = self.inspector.attrs.get(name, name)
1440 if isinstance(prop, str):
1442 # add node for association proxy field
1443 if column := get_association_proxy_column(self.inspector, name):
1444 name_overrides_copy = overrides.get(name, {}).copy()
1445 node = self.get_schema_from_column(column, name_overrides_copy)
1446 if node is not None:
1447 self.add(node)
1449 def dictify(self, obj): # pylint: disable=empty-docstring
1450 """ """
1451 dct = super().dictify(obj)
1453 # loop thru all fields to add the association proxies
1454 for node in self:
1455 name = node.name
1456 if name in dct:
1457 continue # value already set
1459 # we only care about association proxies here
1460 if not get_association_proxy_column(self.inspector, name):
1461 continue
1463 dct[name] = getattr(obj, name)
1465 # special handling when value is None
1466 if dct[name] is None:
1468 # nb. colander/deform know how to behave when using
1469 # their dedicated colander.null value, but ``None``
1470 # seems to cause issues for string fields, so swap
1471 # that out here if applicable
1472 if isinstance(node.typ, colander.String):
1473 dct[name] = colander.null
1475 return dct
1477 def objectify(self, dict_, context=None): # pylint: disable=empty-docstring
1478 """ """
1479 context = super().objectify(dict_, context=context)
1481 for attr in dict_:
1482 if self.inspector.has_property(attr):
1483 continue # upstream logic handles these
1485 # try to process association proxy field
1486 if get_association_proxy_column(self.inspector, attr):
1487 value = dict_[attr]
1488 if value is colander.null:
1489 # `colander.null` is never an appropriate
1490 # value to be placed on an SQLAlchemy object
1491 # so we translate it into `None`.
1492 value = None
1493 setattr(context, attr, value)
1495 return context
1498def get_association_proxy(mapper, field):
1499 """
1500 Return the association proxy "descriptor" corresponding to the
1501 given field name, if it exists.
1503 :param mapper: SQLAlchemy mapper for the main class.
1505 :param field: Field name on the main class, which may (or may not)
1506 be proxied via association.
1508 :returns:
1509 :class:`~sqlalchemy:sqlalchemy.ext.associationproxy.AssociationProxy`
1510 instance, or ``None``.
1512 Using the ``User.first_name`` (which proxies to
1513 ``User.person.first_name``) example, this would return the proxy
1514 descriptor for ``User.first_name``.
1515 """
1516 try:
1517 desc = getattr(mapper.all_orm_descriptors, field)
1518 except AttributeError:
1519 pass
1520 else:
1521 if desc.extension_type.name == "ASSOCIATION_PROXY":
1522 return desc
1523 return None
1526def get_association_proxy_target(mapper, field):
1527 """
1528 Return the relationship property involved in the association proxy
1529 for the given field, if applicable.
1531 :param mapper: SQLAlchemy mapper for the main class.
1533 :param field: Field name on the main class, which may (or may not)
1534 be proxied via association.
1536 :returns: :class:`~sqlalchemy:sqlalchemy.orm.RelationshipProperty`
1537 instance, or ``None``.
1539 Using the ``User.first_name`` (which proxies to
1540 ``User.person.first_name``) example, this would return the
1541 ``User.person`` relationship property.
1542 """
1543 if proxy := get_association_proxy(mapper, field):
1544 proxy_target = mapper.get_property(proxy.target_collection)
1545 if (
1546 isinstance(proxy_target, orm.RelationshipProperty)
1547 and not proxy_target.uselist
1548 ):
1549 return proxy_target
1550 return None
1553def get_association_proxy_column(mapper, field):
1554 """
1555 Return the target column property involved in the association
1556 proxy for the given field, if applicable.
1558 :param mapper: SQLAlchemy mapper for the main class.
1560 :param field: Field name on the main class, which may (or may not)
1561 be proxied via association.
1563 :returns: :class:`~sqlalchemy:sqlalchemy.orm.ColumnProperty`
1564 instance, or ``None``.
1566 Using the ``User.first_name`` (which proxies to
1567 ``User.person.first_name``) example, this would return the
1568 ``Person.first_name`` column property.
1569 """
1570 if proxy_target := get_association_proxy_target(mapper, field):
1571 if proxy_target.mapper.has_property(field):
1572 prop = proxy_target.mapper.get_property(field)
1573 if isinstance(prop, orm.ColumnProperty) and isinstance(
1574 prop.columns[0], sa.Column
1575 ):
1576 return prop
1577 return None