Coverage for .tox / coverage / lib / python3.11 / site-packages / wuttaweb / forms / base.py: 100%
356 statements
« prev ^ index » next coverage.py v7.13.1, created at 2025-12-28 15:23 -0600
« prev ^ index » next coverage.py v7.13.1, created at 2025-12-28 15:23 -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 = SQLAlchemySchemaNode(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( # pylint: disable=unused-argument,too-many-locals
1084 self,
1085 fieldname,
1086 readonly=None,
1087 label=True,
1088 horizontal=True,
1089 **kwargs,
1090 ):
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
1141 html = field.serialize(**kwargs)
1143 else:
1144 # render static text if field not in deform/schema
1145 # TODO: need to abstract this somehow
1146 if self.model_instance:
1147 value = self.model_instance[fieldname]
1148 html = str(value) if value is not None else ""
1149 else:
1150 html = ""
1152 # mark all that as safe
1153 html = HTML.literal(html or " ")
1155 # render field label
1156 if label:
1157 label = self.get_label(fieldname)
1159 # b-field attrs
1160 attrs = {
1161 ":horizontal": "true" if horizontal else "false",
1162 "label": label or "",
1163 }
1165 # next we will build array of messages to display..some
1166 # fields always show a "helptext" msg, and some may have
1167 # validation errors..
1168 field_type = None
1169 messages = []
1171 # show errors if present
1172 errors = self.get_field_errors(fieldname)
1173 if errors:
1174 field_type = "is-danger"
1175 messages.extend(errors)
1177 # ..okay now we can declare the field messages and type
1178 if field_type:
1179 attrs["type"] = field_type
1180 if messages:
1181 cls = "is-size-7"
1182 if field_type == "is-danger":
1183 cls += " has-text-danger"
1184 messages = [HTML.tag("p", c=[msg], class_=cls) for msg in messages]
1185 slot = HTML.tag("slot", name="messages", c=messages)
1186 html = HTML.tag("div", c=[html, slot])
1188 return HTML.tag("b-field", c=[html], **attrs)
1190 def render_vue_buttons(self, context, template=None, **kwargs):
1191 """
1192 Render the buttons section within the form template.
1194 This is normally invoked from within the form's
1195 ``vue_template`` like this:
1197 .. code-block:: none
1199 ${form.render_vue_buttons(form_context)}
1201 .. note::
1203 This method does not yet inspect the main page template,
1204 unlike :meth:`render_vue_fields()`.
1206 See also :meth:`render_vue_template()`.
1208 :param context: This must be the original context as provided
1209 to the form's ``vue_template``. See example above.
1211 :param template: Optional template path to override the class
1212 default.
1214 :returns: HTML literal
1215 """
1216 context.update(kwargs)
1217 html = render(template or self.buttons_template, context)
1218 return HTML.literal(html)
1220 def render_vue_finalize(self):
1221 """
1222 Render the Vue "finalize" script for the form.
1224 By default this simply returns:
1226 .. code-block:: html
1228 <script>
1229 WuttaForm.data = function() { return WuttaFormData }
1230 Vue.component('wutta-form', WuttaForm)
1231 </script>
1233 The actual output may depend on various form attributes, in
1234 particular :attr:`vue_tagname`.
1235 """
1236 return render_vue_finalize(self.vue_tagname, self.vue_component)
1238 def get_field_vmodel(self, field):
1239 """
1240 Convenience to return the ``v-model`` data reference for the
1241 given field. For instance:
1243 .. code-block:: none
1245 <b-input name="myfield"
1246 v-model="${form.get_field_vmodel('myfield')}" />
1248 <div v-show="${form.get_field_vmodel('myfield')} == 'easter'">
1249 easter egg!
1250 </div>
1252 :returns: JS-valid string referencing the field value
1253 """
1254 dform = self.get_deform()
1255 return f"modelData.{dform[field].oid}"
1257 def get_vue_model_data(self):
1258 """
1259 Returns a dict with form model data. Values may be nested
1260 depending on the types of fields contained in the form.
1262 This collects the ``cstruct`` values for all fields which are
1263 present both in :attr:`fields` as well as the Deform schema.
1265 It also converts each as needed, to ensure it is
1266 JSON-serializable.
1268 :returns: Dict of field/value items.
1269 """
1270 dform = self.get_deform()
1271 model_data = {}
1273 def assign(field):
1274 value = field.cstruct
1276 # TODO: we need a proper true/false on the Vue side,
1277 # but deform/colander want 'true' and 'false' ..so
1278 # for now we explicitly translate here, ugh. also
1279 # note this does not yet allow for null values.. :(
1280 if isinstance(field.typ, colander.Boolean):
1281 value = value == field.typ.true_val
1283 model_data[field.oid] = make_json_safe(value)
1285 for key in self.fields:
1287 # TODO: i thought commented code was useful, but no longer sure?
1289 # TODO: need to describe the scenario when this is true
1290 if key not in dform:
1291 # log.warning("field '%s' is missing from deform", key)
1292 continue
1294 field = dform[key]
1296 # if hasattr(field, 'children'):
1297 # for subfield in field.children:
1298 # assign(subfield)
1300 assign(field)
1302 return model_data
1304 # TODO: for tailbone compat, should document?
1305 # (ideally should remove this and find a better way)
1306 def get_vue_field_value(self, key): # pylint: disable=empty-docstring
1307 """ """
1308 if key not in self.fields:
1309 return None
1311 dform = self.get_deform()
1312 if key not in dform:
1313 return None
1315 field = dform[key]
1316 return make_json_safe(field.cstruct)
1318 def validate(self):
1319 """
1320 Try to validate the form, using data from the :attr:`request`.
1322 Uses :func:`~wuttaweb.util.get_form_data()` to retrieve the
1323 form data from POST or JSON body.
1325 If the form data is valid, the data dict is returned. This
1326 data dict is also made available on the form object via the
1327 :attr:`validated` attribute.
1329 However if the data is not valid, ``False`` is returned, and
1330 the :attr:`validated` attribute will be ``None``. In that
1331 case you should inspect the form errors to learn/display what
1332 went wrong for the user's sake. See also
1333 :meth:`get_field_errors()`.
1335 This uses :meth:`deform:deform.Field.validate()` under the
1336 hood.
1338 .. warning::
1340 Calling ``validate()`` on some forms will cause the
1341 underlying Deform and Colander structures to mutate. In
1342 particular, all :attr:`readonly_fields` will be *removed*
1343 from the :attr:`schema` to ensure they are not involved in
1344 the validation.
1346 :returns: Data dict, or ``False``.
1347 """
1348 self.validated = None
1350 if self.request.method != "POST":
1351 return False
1353 # remove all readonly fields from deform / schema
1354 dform = self.get_deform()
1355 if self.readonly_fields:
1356 schema = self.get_schema()
1357 for field in self.readonly_fields:
1358 if field in schema:
1359 del schema[field]
1360 dform.children.remove(dform[field])
1362 # let deform do real validation
1363 controls = get_form_data(self.request).items()
1364 try:
1365 self.validated = dform.validate(controls)
1366 except deform.ValidationFailure:
1367 log.debug("form not valid: %s", dform.error)
1368 return False
1370 return self.validated
1372 def has_global_errors(self):
1373 """
1374 Convenience function to check if the form has any "global"
1375 (not field-level) errors.
1377 See also :meth:`get_global_errors()`.
1379 :returns: ``True`` if global errors present, else ``False``.
1380 """
1381 dform = self.get_deform()
1382 return bool(dform.error)
1384 def get_global_errors(self):
1385 """
1386 Returns a list of "global" (not field-level) error messages
1387 for the form.
1389 See also :meth:`has_global_errors()`.
1391 :returns: List of error messages (possibly empty).
1392 """
1393 dform = self.get_deform()
1394 if dform.error is None:
1395 return []
1396 return dform.error.messages()
1398 def get_field_errors(self, field):
1399 """
1400 Return a list of error messages for the given field.
1402 Not useful unless a call to :meth:`validate()` failed.
1403 """
1404 dform = self.get_deform()
1405 if field in dform:
1406 field = dform[field]
1407 if field.error:
1408 return field.error.messages()
1409 return []