Coverage for .tox/coverage/lib/python3.11/site-packages/wuttaweb/forms/base.py: 100%
326 statements
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-15 08:54 -0600
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-15 08:54 -0600
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# wuttaweb -- Web App for Wutta Framework
5# Copyright © 2024 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"""
27import logging
28from collections import OrderedDict
30import sqlalchemy as sa
31from sqlalchemy import orm
33import colander
34import deform
35from colanderalchemy import SQLAlchemySchemaNode
36from pyramid.renderers import render
37from webhelpers2.html import HTML
39from wuttaweb.util import FieldList, get_form_data, get_model_fields, make_json_safe
42log = logging.getLogger(__name__)
45class Form:
46 """
47 Base class for all forms.
49 :param request: Reference to current :term:`request` object.
51 :param fields: List of field names for the form. This is
52 optional; if not specified an attempt will be made to deduce
53 the list automatically. See also :attr:`fields`.
55 :param schema: Colander-based schema object for the form. This is
56 optional; if not specified an attempt will be made to construct
57 one automatically. See also :meth:`get_schema()`.
59 :param labels: Optional dict of default field labels.
61 .. note::
63 Some parameters are not explicitly described above. However
64 their corresponding attributes are described below.
66 Form instances contain the following attributes:
68 .. attribute:: request
70 Reference to current :term:`request` object.
72 .. attribute:: fields
74 :class:`~wuttaweb.util.FieldList` instance containing string
75 field names for the form. By default, fields will appear in
76 the same order as they are in this list.
78 See also :meth:`set_fields()`.
80 .. attribute:: schema
82 :class:`colander:colander.Schema` object for the form. This is
83 optional; if not specified an attempt will be made to construct
84 one automatically.
86 See also :meth:`get_schema()`.
88 .. attribute:: model_class
90 Model class for the form, if applicable. When set, this is
91 usually a SQLAlchemy mapped class. This (or
92 :attr:`model_instance`) may be used instead of specifying the
93 :attr:`schema`.
95 .. attribute:: model_instance
97 Optional instance from which initial form data should be
98 obtained. In simple cases this might be a dict, or maybe an
99 instance of :attr:`model_class`.
101 Note that this also may be used instead of specifying the
102 :attr:`schema`, if the instance belongs to a class which is
103 SQLAlchemy-mapped. (In that case :attr:`model_class` can be
104 determined automatically.)
106 .. attribute:: nodes
108 Dict of node overrides, used to construct the form in
109 :meth:`get_schema()`.
111 See also :meth:`set_node()`.
113 .. attribute:: widgets
115 Dict of widget overrides, used to construct the form in
116 :meth:`get_schema()`.
118 See also :meth:`set_widget()`.
120 .. attribute:: validators
122 Dict of node validators, used to construct the form in
123 :meth:`get_schema()`.
125 See also :meth:`set_validator()`.
127 .. attribute:: defaults
129 Dict of default field values, used to construct the form in
130 :meth:`get_schema()`.
132 See also :meth:`set_default()`.
134 .. attribute:: readonly
136 Boolean indicating the form does not allow submit. In practice
137 this means there will not even be a ``<form>`` tag involved.
139 Default for this is ``False`` in which case the ``<form>`` tag
140 will exist and submit is allowed.
142 .. attribute:: readonly_fields
144 A :class:`~python:set` of field names which should be readonly.
145 Each will still be rendered but with static value text and no
146 widget.
148 This is only applicable if :attr:`readonly` is ``False``.
150 See also :meth:`set_readonly()` and :meth:`is_readonly()`.
152 .. attribute:: required_fields
154 A dict of "required" field flags. Keys are field names, and
155 values are boolean flags indicating whether the field is
156 required.
158 Depending on :attr:`schema`, some fields may be "(not)
159 required" by default. However ``required_fields`` keeps track
160 of any "overrides" per field.
162 See also :meth:`set_required()` and :meth:`is_required()`.
164 .. attribute:: action_method
166 HTTP method to use when submitting form; ``'post'`` is default.
168 .. attribute:: action_url
170 String URL to which the form should be submitted, if applicable.
172 .. attribute:: reset_url
174 String URL to which the reset button should "always" redirect,
175 if applicable.
177 This is null by default, in which case it will use standard
178 browser behavior for the form reset button (if shown). See
179 also :attr:`show_button_reset`.
181 .. attribute:: cancel_url
183 String URL to which the Cancel button should "always" redirect,
184 if applicable.
186 Code should not access this directly, but instead call
187 :meth:`get_cancel_url()`.
189 .. attribute:: cancel_url_fallback
191 String URL to which the Cancel button should redirect, if
192 referrer cannot be determined from request.
194 Code should not access this directly, but instead call
195 :meth:`get_cancel_url()`.
197 .. attribute:: vue_tagname
199 String name for Vue component tag. By default this is
200 ``'wutta-form'``. See also :meth:`render_vue_tag()`.
202 See also :attr:`vue_component`.
204 .. attribute:: align_buttons_right
206 Flag indicating whether the buttons (submit, cancel etc.)
207 should be aligned to the right of the area below the form. If
208 not set, the buttons are left-aligned.
210 .. attribute:: auto_disable_submit
212 Flag indicating whether the submit button should be
213 auto-disabled, whenever the form is submitted.
215 .. attribute:: button_label_submit
217 String label for the form submit button. Default is ``"Save"``.
219 .. attribute:: button_icon_submit
221 String icon name for the form submit button. Default is ``'save'``.
223 .. attribute:: button_type_submit
225 Buefy type for the submit button. Default is ``'is-primary'``,
226 so for example:
228 .. code-block:: html
230 <b-button type="is-primary"
231 native-type="submit">
232 Save
233 </b-button>
235 See also the `Buefy docs
236 <https://buefy.org/documentation/button/#api-view>`_.
238 .. attribute:: show_button_reset
240 Flag indicating whether a Reset button should be shown.
241 Default is ``False``.
243 Unless there is a :attr:`reset_url`, the reset button will use
244 standard behavior per the browser.
246 .. attribute:: show_button_cancel
248 Flag indicating whether a Cancel button should be shown.
249 Default is ``True``.
251 .. attribute:: button_label_cancel
253 String label for the form cancel button. Default is
254 ``"Cancel"``.
256 .. attribute:: auto_disable_cancel
258 Flag indicating whether the cancel button should be
259 auto-disabled, whenever the button is clicked. Default is
260 ``True``.
262 .. attribute:: validated
264 If the :meth:`validate()` method was called, and it succeeded,
265 this will be set to the validated data dict.
267 Note that in all other cases, this attribute may not exist.
268 """
270 def __init__(
271 self,
272 request,
273 fields=None,
274 schema=None,
275 model_class=None,
276 model_instance=None,
277 nodes={},
278 widgets={},
279 validators={},
280 defaults={},
281 readonly=False,
282 readonly_fields=[],
283 required_fields={},
284 labels={},
285 action_method='post',
286 action_url=None,
287 reset_url=None,
288 cancel_url=None,
289 cancel_url_fallback=None,
290 vue_tagname='wutta-form',
291 align_buttons_right=False,
292 auto_disable_submit=True,
293 button_label_submit="Save",
294 button_icon_submit='save',
295 button_type_submit='is-primary',
296 show_button_reset=False,
297 show_button_cancel=True,
298 button_label_cancel="Cancel",
299 auto_disable_cancel=True,
300 ):
301 self.request = request
302 self.schema = schema
303 self.nodes = nodes or {}
304 self.widgets = widgets or {}
305 self.validators = validators or {}
306 self.defaults = defaults or {}
307 self.readonly = readonly
308 self.readonly_fields = set(readonly_fields or [])
309 self.required_fields = required_fields or {}
310 self.labels = labels or {}
311 self.action_method = action_method
312 self.action_url = action_url
313 self.cancel_url = cancel_url
314 self.cancel_url_fallback = cancel_url_fallback
315 self.reset_url = reset_url
316 self.vue_tagname = vue_tagname
317 self.align_buttons_right = align_buttons_right
318 self.auto_disable_submit = auto_disable_submit
319 self.button_label_submit = button_label_submit
320 self.button_icon_submit = button_icon_submit
321 self.button_type_submit = button_type_submit
322 self.show_button_reset = show_button_reset
323 self.show_button_cancel = show_button_cancel
324 self.button_label_cancel = button_label_cancel
325 self.auto_disable_cancel = auto_disable_cancel
327 self.config = self.request.wutta_config
328 self.app = self.config.get_app()
330 self.model_class = model_class
331 self.model_instance = model_instance
332 if self.model_instance and not self.model_class:
333 if type(self.model_instance) is not dict:
334 self.model_class = type(self.model_instance)
336 self.set_fields(fields or self.get_fields())
337 self.set_default_widgets()
339 # nb. this tracks grid JSON data for inclusion in page template
340 self.grid_vue_context = OrderedDict()
342 def __contains__(self, name):
343 """
344 Custom logic for the ``in`` operator, to allow easily checking
345 if the form contains a given field::
347 myform = Form()
348 if 'somefield' in myform:
349 print("my form has some field")
350 """
351 return bool(self.fields and name in self.fields)
353 def __iter__(self):
354 """
355 Custom logic to allow iterating over form field names::
357 myform = Form(fields=['foo', 'bar'])
358 for fieldname in myform:
359 print(fieldname)
360 """
361 return iter(self.fields)
363 @property
364 def vue_component(self):
365 """
366 String name for the Vue component, e.g. ``'WuttaForm'``.
368 This is a generated value based on :attr:`vue_tagname`.
369 """
370 words = self.vue_tagname.split('-')
371 return ''.join([word.capitalize() for word in words])
373 def get_cancel_url(self):
374 """
375 Returns the URL for the Cancel button.
377 If :attr:`cancel_url` is set, its value is returned.
379 Or, if the referrer can be deduced from the request, that is
380 returned.
382 Or, if :attr:`cancel_url_fallback` is set, that value is
383 returned.
385 As a last resort the "default" URL from
386 :func:`~wuttaweb.subscribers.request.get_referrer()` is
387 returned.
388 """
389 # use "permanent" URL if set
390 if self.cancel_url:
391 return self.cancel_url
393 # nb. use fake default to avoid normal default logic;
394 # that way if we get something it's a real referrer
395 url = self.request.get_referrer(default='NOPE')
396 if url and url != 'NOPE':
397 return url
399 # use fallback URL if set
400 if self.cancel_url_fallback:
401 return self.cancel_url_fallback
403 # okay, home page then (or whatever is the default URL)
404 return self.request.get_referrer()
406 def set_fields(self, fields):
407 """
408 Explicitly set the list of form fields.
410 This will overwrite :attr:`fields` with a new
411 :class:`~wuttaweb.util.FieldList` instance.
413 :param fields: List of string field names.
414 """
415 self.fields = FieldList(fields)
417 def append(self, *keys):
418 """
419 Add some fields(s) to the form.
421 This is a convenience to allow adding multiple fields at
422 once::
424 form.append('first_field',
425 'second_field',
426 'third_field')
428 It will add each field to :attr:`fields`.
429 """
430 for key in keys:
431 if key not in self.fields:
432 self.fields.append(key)
434 def remove(self, *keys):
435 """
436 Remove some fields(s) from the form.
438 This is a convenience to allow removal of multiple fields at
439 once::
441 form.remove('first_field',
442 'second_field',
443 'third_field')
445 It will remove each field from :attr:`fields`.
446 """
447 for key in keys:
448 if key in self.fields:
449 self.fields.remove(key)
451 def set_node(self, key, nodeinfo, **kwargs):
452 """
453 Set/override the node for a field.
455 :param key: Name of field.
457 :param nodeinfo: Should be either a
458 :class:`colander:colander.SchemaNode` instance, or else a
459 :class:`colander:colander.SchemaType` instance.
461 If ``nodeinfo`` is a proper node instance, it will be used
462 as-is. Otherwise an
463 :class:`~wuttaweb.forms.schema.ObjectNode` instance will be
464 constructed using ``nodeinfo`` as the type (``typ``).
466 Node overrides are tracked via :attr:`nodes`.
467 """
468 from wuttaweb.forms.schema import ObjectNode
470 if isinstance(nodeinfo, colander.SchemaNode):
471 # assume nodeinfo is a complete node
472 node = nodeinfo
474 else: # assume nodeinfo is a schema type
475 kwargs.setdefault('name', key)
476 node = ObjectNode(nodeinfo, **kwargs)
478 self.nodes[key] = node
480 # must explicitly replace node, if we already have a schema
481 if self.schema:
482 self.schema[key] = node
484 def set_widget(self, key, widget, **kwargs):
485 """
486 Set/override the widget for a field.
488 You can specify a widget instance or else a named "type" of
489 widget, in which case that is passed along to
490 :meth:`make_widget()`.
492 :param key: Name of field.
494 :param widget: Either a :class:`deform:deform.widget.Widget`
495 instance, or else a widget "type" name.
497 :param \**kwargs: Any remaining kwargs are passed along to
498 :meth:`make_widget()` - if applicable.
500 Widget overrides are tracked via :attr:`widgets`.
501 """
502 if not isinstance(widget, deform.widget.Widget):
503 widget_obj = self.make_widget(widget, **kwargs)
504 if not widget_obj:
505 raise ValueError(f"widget type not supported: {widget}")
506 widget = widget_obj
508 self.widgets[key] = widget
510 # update schema if necessary
511 if self.schema and key in self.schema:
512 self.schema[key].widget = widget
514 def make_widget(self, widget_type, **kwargs):
515 """
516 Make and return a new field widget of the given type.
518 This has built-in support for the following types (although
519 subclass can override as needed):
521 * ``'notes'`` => :class:`~wuttaweb.forms.widgets.NotesWidget`
523 See also :meth:`set_widget()` which may call this method
524 automatically.
526 :param widget_type: Which of the above (or custom) widget
527 type to create.
529 :param \**kwargs: Remaining kwargs are passed as-is to the
530 widget factory.
532 :returns: New widget instance, or ``None`` if e.g. it could
533 not determine how to create the widget.
534 """
535 from wuttaweb.forms import widgets
537 if widget_type == 'notes':
538 return widgets.NotesWidget(**kwargs)
540 def set_default_widgets(self):
541 """
542 Set default field widgets, where applicable.
544 This will add new entries to :attr:`widgets` for columns
545 whose data type implies a default widget should be used.
546 This is generally only possible if :attr:`model_class` is set
547 to a valid SQLAlchemy mapped class.
549 This only checks for a couple of data types, with mapping as
550 follows:
552 * :class:`sqlalchemy:sqlalchemy.types.Date` ->
553 :class:`~wuttaweb.forms.widgets.WuttaDateWidget`
554 * :class:`sqlalchemy:sqlalchemy.types.DateTime` ->
555 :class:`~wuttaweb.forms.widgets.WuttaDateTimeWidget`
556 """
557 from wuttaweb.forms import widgets
559 if not self.model_class:
560 return
562 for key in self.fields:
563 if key in self.widgets:
564 continue
566 attr = getattr(self.model_class, key, None)
567 if attr:
568 prop = getattr(attr, 'prop', None)
569 if prop and isinstance(prop, orm.ColumnProperty):
570 column = prop.columns[0]
571 if isinstance(column.type, sa.Date):
572 self.set_widget(key, widgets.WuttaDateWidget(self.request))
573 elif isinstance(column.type, sa.DateTime):
574 self.set_widget(key, widgets.WuttaDateTimeWidget(self.request))
576 def set_grid(self, key, grid):
577 """
578 Establish a :term:`grid` to be displayed for a field. This
579 uses a :class:`~wuttaweb.forms.widgets.GridWidget` to wrap the
580 rendered grid.
582 :param key: Name of field.
584 :param widget: :class:`~wuttaweb.grids.base.Grid` instance,
585 pre-configured and (usually) with data.
586 """
587 from wuttaweb.forms.widgets import GridWidget
589 widget = GridWidget(self.request, grid)
590 self.set_widget(key, widget)
591 self.add_grid_vue_context(grid)
593 def add_grid_vue_context(self, grid):
594 """ """
595 if not grid.key:
596 raise ValueError("grid must have a key!")
598 if grid.key in self.grid_vue_context:
599 log.warning("grid data with key '%s' already registered, "
600 "but will be replaced", grid.key)
602 self.grid_vue_context[grid.key] = grid.get_vue_context()
604 def set_validator(self, key, validator):
605 """
606 Set/override the validator for a field, or the form.
608 :param key: Name of field. This may also be ``None`` in which
609 case the validator will apply to the whole form instead of
610 a field.
612 :param validator: Callable which accepts ``(node, value)``
613 args. For instance::
615 def validate_foo(node, value):
616 if value == 42:
617 node.raise_invalid("42 is not allowed!")
619 form = Form(fields=['foo', 'bar'])
621 form.set_validator('foo', validate_foo)
623 Validator overrides are tracked via :attr:`validators`.
624 """
625 self.validators[key] = validator
627 # nb. must apply to existing schema if present
628 if self.schema and key in self.schema:
629 self.schema[key].validator = validator
631 def set_default(self, key, value):
632 """
633 Set/override the default value for a field.
635 :param key: Name of field.
637 :param validator: Default value for the field.
639 Default value overrides are tracked via :attr:`defaults`.
640 """
641 self.defaults[key] = value
643 def set_readonly(self, key, readonly=True):
644 """
645 Enable or disable the "readonly" flag for a given field.
647 When a field is marked readonly, it will be shown in the form
648 but there will be no editable widget. The field is skipped
649 over (not saved) when form is submitted.
651 See also :meth:`is_readonly()`; this is tracked via
652 :attr:`readonly_fields`.
654 :param key: String key (fieldname) for the field.
656 :param readonly: New readonly flag for the field.
657 """
658 if readonly:
659 self.readonly_fields.add(key)
660 else:
661 if key in self.readonly_fields:
662 self.readonly_fields.remove(key)
664 def is_readonly(self, key):
665 """
666 Returns boolean indicating if the given field is marked as
667 readonly.
669 See also :meth:`set_readonly()`.
671 :param key: Field key/name as string.
672 """
673 if self.readonly_fields:
674 if key in self.readonly_fields:
675 return True
676 return False
678 def set_required(self, key, required=True):
679 """
680 Enable or disable the "required" flag for a given field.
682 When a field is marked required, a value must be provided
683 or else it fails validation.
685 In practice if a field is "not required" then a default
686 "empty" value is assumed, should the user not provide one.
688 See also :meth:`is_required()`; this is tracked via
689 :attr:`required_fields`.
691 :param key: String key (fieldname) for the field.
693 :param required: New required flag for the field. Usually a
694 boolean, but may also be ``None`` to remove any flag and
695 revert to default behavior for the field.
696 """
697 self.required_fields[key] = required
699 def is_required(self, key):
700 """
701 Returns boolean indicating if the given field is marked as
702 required.
704 See also :meth:`set_required()`.
706 :param key: Field key/name as string.
708 :returns: Value for the flag from :attr:`required_fields` if
709 present; otherwise ``None``.
710 """
711 return self.required_fields.get(key, None)
713 def set_label(self, key, label):
714 """
715 Set the label for given field name.
717 See also :meth:`get_label()`.
718 """
719 self.labels[key] = label
721 # update schema if necessary
722 if self.schema and key in self.schema:
723 self.schema[key].title = label
725 def get_label(self, key):
726 """
727 Get the label for given field name.
729 Note that this will always return a string, auto-generating
730 the label if needed.
732 See also :meth:`set_label()`.
733 """
734 return self.labels.get(key, self.app.make_title(key))
736 def get_fields(self):
737 """
738 Returns the official list of field names for the form, or
739 ``None``.
741 If :attr:`fields` is set and non-empty, it is returned.
743 Or, if :attr:`schema` is set, the field list is derived
744 from that.
746 Or, if :attr:`model_class` is set, the field list is derived
747 from that, via :meth:`get_model_fields()`.
749 Otherwise ``None`` is returned.
750 """
751 if hasattr(self, 'fields') and self.fields:
752 return self.fields
754 if self.schema:
755 return [field.name for field in self.schema]
757 fields = self.get_model_fields()
758 if fields:
759 return fields
761 return []
763 def get_model_fields(self, model_class=None):
764 """
765 This method is a shortcut which calls
766 :func:`~wuttaweb.util.get_model_fields()`.
768 :param model_class: Optional model class for which to return
769 fields. If not set, the form's :attr:`model_class` is
770 assumed.
771 """
772 return get_model_fields(self.config,
773 model_class=model_class or self.model_class)
775 def get_schema(self):
776 """
777 Return the :class:`colander:colander.Schema` object for the
778 form, generating it automatically if necessary.
780 Note that if :attr:`schema` is already set, that will be
781 returned as-is.
782 """
783 if not self.schema:
785 ##############################
786 # create schema
787 ##############################
789 # get fields
790 fields = self.get_fields()
791 if not fields:
792 raise NotImplementedError
794 if self.model_class:
796 # collect list of field names and/or nodes
797 includes = []
798 for key in fields:
799 if key in self.nodes:
800 includes.append(self.nodes[key])
801 else:
802 includes.append(key)
804 # make initial schema with ColanderAlchemy magic
805 schema = SQLAlchemySchemaNode(self.model_class,
806 includes=includes)
808 # fill in the blanks if anything got missed
809 for key in fields:
810 if key not in schema:
811 node = colander.SchemaNode(colander.String(), name=key)
812 schema.add(node)
814 else:
816 # make basic schema
817 schema = colander.Schema()
818 for key in fields:
819 node = None
821 # use node override if present
822 if key in self.nodes:
823 node = self.nodes[key]
824 if not node:
826 # otherwise make simple string node
827 node = colander.SchemaNode(
828 colander.String(),
829 name=key)
831 schema.add(node)
833 ##############################
834 # customize schema
835 ##############################
837 # apply widget overrides
838 for key, widget in self.widgets.items():
839 if key in schema:
840 schema[key].widget = widget
842 # apply validator overrides
843 for key, validator in self.validators.items():
844 if key is None:
845 # nb. this one is form-wide
846 schema.validator = validator
847 elif key in schema: # field-level
848 schema[key].validator = validator
850 # apply default value overrides
851 for key, value in self.defaults.items():
852 if key in schema:
853 schema[key].default = value
855 # apply required flags
856 for key, required in self.required_fields.items():
857 if key in schema:
858 if required is False:
859 schema[key].missing = colander.null
861 self.schema = schema
863 return self.schema
865 def get_deform(self):
866 """
867 Return the :class:`deform:deform.Form` instance for the form,
868 generating it automatically if necessary.
869 """
870 if not hasattr(self, 'deform_form'):
871 model = self.app.model
872 schema = self.get_schema()
873 kwargs = {}
875 if self.model_instance:
877 # TODO: i keep finding problems with this, not sure
878 # what needs to happen. some forms will have a simple
879 # dict for model_instance, others will have a proper
880 # SQLAlchemy object. and in the latter case, it may
881 # not be "wutta-native" but from another DB.
883 # so the problem is, how to detect whether we should
884 # use the model_instance as-is or if we should convert
885 # to a dict. some options include:
887 # - check if instance has dictify() method
888 # i *think* this was tried and didn't work? but do not recall
890 # - check if is instance of model.Base
891 # this is unreliable since model.Base is wutta-native
893 # - check if form has a model_class
894 # has not been tried yet
896 # - check if schema is from colanderalchemy
897 # this is what we are trying currently...
899 if isinstance(schema, SQLAlchemySchemaNode):
900 kwargs['appstruct'] = schema.dictify(self.model_instance)
901 else:
902 kwargs['appstruct'] = self.model_instance
904 # create the Deform instance
905 # nb. must give a reference back to wutta form; this is
906 # for sake of field schema nodes and widgets, e.g. to
907 # access the main model instance
908 form = deform.Form(schema, **kwargs)
909 form.wutta_form = self
910 self.deform_form = form
912 return self.deform_form
914 def render_vue_tag(self, **kwargs):
915 """
916 Render the Vue component tag for the form.
918 By default this simply returns:
920 .. code-block:: html
922 <wutta-form></wutta-form>
924 The actual output will depend on various form attributes, in
925 particular :attr:`vue_tagname`.
926 """
927 return HTML.tag(self.vue_tagname, **kwargs)
929 def render_vue_template(
930 self,
931 template='/forms/vue_template.mako',
932 **context):
933 """
934 Render the Vue template block for the form.
936 This returns something like:
938 .. code-block:: none
940 <script type="text/x-template" id="wutta-form-template">
941 <form>
942 <!-- fields etc. -->
943 </form>
944 </script>
946 <script>
947 WuttaFormData = {}
948 WuttaForm = {
949 template: 'wutta-form-template',
950 }
951 </script>
953 .. todo::
955 Why can't Sphinx render the above code block as 'html' ?
957 It acts like it can't handle a ``<script>`` tag at all?
959 Actual output will of course depend on form attributes, i.e.
960 :attr:`vue_tagname` and :attr:`fields` list etc.
962 :param template: Path to Mako template which is used to render
963 the output.
964 """
965 context['form'] = self
966 context['dform'] = self.get_deform()
967 context.setdefault('request', self.request)
968 context['model_data'] = self.get_vue_model_data()
970 # set form method, enctype
971 context.setdefault('form_attrs', {})
972 context['form_attrs'].setdefault('method', self.action_method)
973 if self.action_method == 'post':
974 context['form_attrs'].setdefault('enctype', 'multipart/form-data')
976 # auto disable button on submit
977 if self.auto_disable_submit:
978 context['form_attrs']['@submit'] = 'formSubmitting = true'
980 output = render(template, context)
981 return HTML.literal(output)
983 def render_vue_field(
984 self,
985 fieldname,
986 readonly=None,
987 **kwargs,
988 ):
989 """
990 Render the given field completely, i.e. ``<b-field>`` wrapper
991 with label and containing a widget.
993 Actual output will depend on the field attributes etc.
994 Typical output might look like:
996 .. code-block:: html
998 <b-field label="Foo"
999 horizontal
1000 type="is-danger"
1001 message="something went wrong!">
1002 <!-- widget element(s) -->
1003 </b-field>
1005 .. warning::
1007 Any ``**kwargs`` received from caller are ignored by this
1008 method. For now they are allowed, for sake of backwawrd
1009 compatibility. This may change in the future.
1010 """
1011 # readonly comes from: caller, field flag, or form flag
1012 if readonly is None:
1013 readonly = self.is_readonly(fieldname)
1014 if not readonly:
1015 readonly = self.readonly
1017 # but also, fields not in deform/schema must be readonly
1018 dform = self.get_deform()
1019 if not readonly and fieldname not in dform:
1020 readonly = True
1022 # render the field widget or whatever
1023 if fieldname in dform:
1025 # render proper widget if field is in deform/schema
1026 field = dform[fieldname]
1027 kw = {}
1028 if readonly:
1029 kw['readonly'] = True
1030 html = field.serialize(**kw)
1032 else:
1033 # render static text if field not in deform/schema
1034 # TODO: need to abstract this somehow
1035 if self.model_instance:
1036 html = str(self.model_instance[fieldname])
1037 else:
1038 html = ''
1040 # mark all that as safe
1041 html = HTML.literal(html)
1043 # render field label
1044 label = self.get_label(fieldname)
1046 # b-field attrs
1047 attrs = {
1048 ':horizontal': 'true',
1049 'label': label,
1050 }
1052 # next we will build array of messages to display..some
1053 # fields always show a "helptext" msg, and some may have
1054 # validation errors..
1055 field_type = None
1056 messages = []
1058 # show errors if present
1059 errors = self.get_field_errors(fieldname)
1060 if errors:
1061 field_type = 'is-danger'
1062 messages.extend(errors)
1064 # ..okay now we can declare the field messages and type
1065 if field_type:
1066 attrs['type'] = field_type
1067 if messages:
1068 cls = 'is-size-7'
1069 if field_type == 'is-danger':
1070 cls += ' has-text-danger'
1071 messages = [HTML.tag('p', c=[msg], class_=cls)
1072 for msg in messages]
1073 slot = HTML.tag('slot', name='messages', c=messages)
1074 html = HTML.tag('div', c=[html, slot])
1076 return HTML.tag('b-field', c=[html], **attrs)
1078 def render_vue_finalize(self):
1079 """
1080 Render the Vue "finalize" script for the form.
1082 By default this simply returns:
1084 .. code-block:: html
1086 <script>
1087 WuttaForm.data = function() { return WuttaFormData }
1088 Vue.component('wutta-form', WuttaForm)
1089 </script>
1091 The actual output may depend on various form attributes, in
1092 particular :attr:`vue_tagname`.
1093 """
1094 set_data = f"{self.vue_component}.data = function() {{ return {self.vue_component}Data }}"
1095 make_component = f"Vue.component('{self.vue_tagname}', {self.vue_component})"
1096 return HTML.tag('script', c=['\n',
1097 HTML.literal(set_data),
1098 '\n',
1099 HTML.literal(make_component),
1100 '\n'])
1102 def get_vue_model_data(self):
1103 """
1104 Returns a dict with form model data. Values may be nested
1105 depending on the types of fields contained in the form.
1107 This collects the ``cstruct`` values for all fields which are
1108 present both in :attr:`fields` as well as the Deform schema.
1110 It also converts each as needed, to ensure it is
1111 JSON-serializable.
1113 :returns: Dict of field/value items.
1114 """
1115 dform = self.get_deform()
1116 model_data = {}
1118 def assign(field):
1119 value = field.cstruct
1121 # TODO: we need a proper true/false on the Vue side,
1122 # but deform/colander want 'true' and 'false' ..so
1123 # for now we explicitly translate here, ugh. also
1124 # note this does not yet allow for null values.. :(
1125 if isinstance(field.typ, colander.Boolean):
1126 value = True if value == field.typ.true_val else False
1128 model_data[field.oid] = make_json_safe(value)
1130 for key in self.fields:
1132 # TODO: i thought commented code was useful, but no longer sure?
1134 # TODO: need to describe the scenario when this is true
1135 if key not in dform:
1136 # log.warning("field '%s' is missing from deform", key)
1137 continue
1139 field = dform[key]
1141 # if hasattr(field, 'children'):
1142 # for subfield in field.children:
1143 # assign(subfield)
1145 assign(field)
1147 return model_data
1149 # TODO: for tailbone compat, should document?
1150 # (ideally should remove this and find a better way)
1151 def get_vue_field_value(self, key):
1152 """ """
1153 if key not in self.fields:
1154 return
1156 dform = self.get_deform()
1157 if key not in dform:
1158 return
1160 field = dform[key]
1161 return make_json_safe(field.cstruct)
1163 def validate(self):
1164 """
1165 Try to validate the form, using data from the :attr:`request`.
1167 Uses :func:`~wuttaweb.util.get_form_data()` to retrieve the
1168 form data from POST or JSON body.
1170 If the form data is valid, the data dict is returned. This
1171 data dict is also made available on the form object via the
1172 :attr:`validated` attribute.
1174 However if the data is not valid, ``False`` is returned, and
1175 there will be no :attr:`validated` attribute. In that case
1176 you should inspect the form errors to learn/display what went
1177 wrong for the user's sake. See also
1178 :meth:`get_field_errors()`.
1180 This uses :meth:`deform:deform.Field.validate()` under the
1181 hood.
1183 .. warning::
1185 Calling ``validate()`` on some forms will cause the
1186 underlying Deform and Colander structures to mutate. In
1187 particular, all :attr:`readonly_fields` will be *removed*
1188 from the :attr:`schema` to ensure they are not involved in
1189 the validation.
1191 :returns: Data dict, or ``False``.
1192 """
1193 if hasattr(self, 'validated'):
1194 del self.validated
1196 if self.request.method != 'POST':
1197 return False
1199 # remove all readonly fields from deform / schema
1200 dform = self.get_deform()
1201 if self.readonly_fields:
1202 schema = self.get_schema()
1203 for field in self.readonly_fields:
1204 if field in schema:
1205 del schema[field]
1206 dform.children.remove(dform[field])
1208 # let deform do real validation
1209 controls = get_form_data(self.request).items()
1210 try:
1211 self.validated = dform.validate(controls)
1212 except deform.ValidationFailure:
1213 log.debug("form not valid: %s", dform.error)
1214 return False
1216 return self.validated
1218 def has_global_errors(self):
1219 """
1220 Convenience function to check if the form has any "global"
1221 (not field-level) errors.
1223 See also :meth:`get_global_errors()`.
1225 :returns: ``True`` if global errors present, else ``False``.
1226 """
1227 dform = self.get_deform()
1228 return bool(dform.error)
1230 def get_global_errors(self):
1231 """
1232 Returns a list of "global" (not field-level) error messages
1233 for the form.
1235 See also :meth:`has_global_errors()`.
1237 :returns: List of error messages (possibly empty).
1238 """
1239 dform = self.get_deform()
1240 if dform.error is None:
1241 return []
1242 return dform.error.messages()
1244 def get_field_errors(self, field):
1245 """
1246 Return a list of error messages for the given field.
1248 Not useful unless a call to :meth:`validate()` failed.
1249 """
1250 dform = self.get_deform()
1251 if field in dform:
1252 field = dform[field]
1253 if field.error:
1254 return field.error.messages()
1255 return []