Coverage for portality / lib / formulaic.py: 71%
674 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-04 09:41 +0100
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-04 09:41 +0100
1# ~~Formulaic:Library~~
3"""
4EXAMPLE = {
5 "contexts" : {
6 "[context name]" : {
7 "fieldsets" : [
8 "[fieldset name]"
9 ],
10 "asynchronous_warnings" : [
11 "[warning function reference]"
12 ]
13 }
14 },
15 "fieldsets" : {
16 "[fieldset name]" : {
17 "label" : "[label]",
18 "fields" : [
19 "[field name]"
20 ]
21 }
22 },
23 "fields" : {
24 "[field name]" : {
25 "label" : "[label]",
26 "input" : "[input type: radio|text|taglist|select|checkbox]",
27 "multiple" : "[select multiple True|False]",
28 "datatype" : "[coerce datatype]",
29 "options" : [ # for radio, select and checkbox
30 {
31 "display" : "[display value]",
32 "value" : "[stored value]",
33 "exclusive" : "[exclusive: True|False]", # if you select this option you can't select others
34 "subfields" : ["[field name]"]
35 }
36 ],
37 "options_fn" : "function name to generate options",
38 "default" : "[default value]",
39 "disabled" : "[disabled: True|False OR a function reference string]",
40 "conditional" : [ # conditions to AND together
41 {
42 "field" : "[field name]",
43 "value" : "[field value]"
44 }
45 ],
46 "help" : {
47 "placeholder" : "[input field placeholder text]",
48 "description" : "[description]",
49 "tooltip" : "[tooltip/long description]",
50 "doaj_criteria" : "[doaj compliance criteria]"
51 },
52 "validate" : {
53 "[validate function]",
54 {"[validate function]" : {"arg" : "argval"}}
55 },
56 "widgets" : {
57 "[widget function]",
58 {"[widget function]" : {"arg" : "argval"}}
59 },
60 "postprocessing" : {
61 "[processing function]",
62 {"[processing function]" : {"arg" : "argval"}}
63 },
64 "attr" : {
65 "[html attribute name]" : "[html attribute value]"
66 },
67 "contexts" : {
68 "[context name]" : {
69 "[field property]" : "[field property value]"
70 }
71 }
72 }
73 }
74}
76CONTEXT_EXAMPLE = {
77 "fieldsets" : [
78 {
79 "name" : "[fieldset name]",
80 "label" : "[label]",
81 "fields" : [
82 {
83 "name" : "[field name]",
84 "[field property]" : "[field property value]"
85 }
86 ]
87 }
88 ],
89 "asynchronous_warnings" : [
90 "[warning function reference]"
91 ],
92 "template" : "path/to/form/page/template.html",
93 "crosswalks" : {
94 "obj2form" : "crosswalk.obj2form",
95 "form2obj" : "crosswalk.form2obj"
96 },
97 "processor" : "module.path.to.processor"
98}
99"""
100import csv
101import itertools
102import json
103import logging
104from copy import deepcopy
105from typing import Optional, Dict, Iterable
107from flask import render_template
108from wtforms import Form
109from wtforms.fields.core import UnboundField, FieldList, FormField, Field
110from wtforms.validators import ValidationError
112from portality.lib import plugin
114log = logging.getLogger(__name__)
116UI_CONFIG_FIELDS = [
117 "label",
118 "input",
119 "options",
120 "help",
121 "validate",
122 "visible",
123 "conditional",
124 "widgets",
125 "attr",
126 "multiple",
127 "repeatable",
128 "datatype",
129 "disabled",
130 "name",
131 "subfields",
132 "subfield",
133 "group"
134]
137class FormulaicException(Exception):
138 def __init__(self, *args):
139 try:
140 self.message = args[0]
141 except IndexError:
142 self.message = ''
143 super(FormulaicException, self).__init__(*args)
146class Formulaic(object):
147 def __init__(self, definition, wtforms_builders, function_map=None, javascript_functions=None):
148 self._definition = definition
149 self._wtforms_builders = wtforms_builders
150 self._function_map = function_map
151 self._javascript_functions = javascript_functions
153 def context(self, context_name, extra_param: Dict = None) -> Optional['FormulaicContext']:
154 context_def = deepcopy(self._definition.get("contexts", {}).get(context_name))
155 if context_def is None:
156 return None
158 fieldsets = context_def.get("fieldsets", [])
160 expanded_fieldsets = []
161 for fsn in fieldsets:
162 fieldset_def = deepcopy(self._definition.get("fieldsets", {}).get(fsn))
163 if fieldset_def is None:
164 raise FormulaicException("Unable to locate fieldset with name {x} in context {y}"
165 .format(x=fsn, y=context_name))
166 fieldset_def["name"] = fsn
168 expanded_fields = self._process_fields(context_name, fieldset_def.get("fields", []))
170 fieldset_def["fields"] = expanded_fields
171 expanded_fieldsets.append(fieldset_def)
173 context_def["fieldsets"] = expanded_fieldsets
174 return FormulaicContext(context_name, context_def, self,
175 extra_param=extra_param or {})
177 @property
178 def wtforms_builders(self):
179 return self._wtforms_builders
181 @property
182 def function_map(self):
183 return self._function_map
185 @property
186 def javascript_functions(self):
187 return self._javascript_functions
189 def choices_for(self, field_name, formulaic_context=None):
190 field_def = self._definition.get("fields", {}).get(field_name)
191 if field_def is None:
192 return []
194 return FormulaicField._options2choices(field_def, formulaic_context)
196 def _process_fields(self, context_name, field_names):
197 field_defs = []
198 for fn in field_names:
199 field_def = deepcopy(self._definition.get("fields", {}).get(fn))
200 field_def["name"] = fn
201 if field_def is None:
202 raise FormulaicException("Field '{x}' is referenced but not defined".format(x=fn))
204 # filter for context
205 context_overrides = field_def.get("contexts", {}).get(context_name)
206 if context_overrides is not None:
207 for k, v in context_overrides.items():
208 field_def[k] = v
210 # and remove the context overrides settings, so they don't bleed to contexts that don't require them
211 if "contexts" in field_def:
212 del field_def["contexts"]
214 field_defs.append(field_def)
216 return field_defs
218 @classmethod
219 def run_options_fn(cls, field_def, formulaic_context):
220 # opt_fn = options_function_map.get(field_def["options_fn"])
221 opt_fn = formulaic_context.function_map.get("options", {}).get(field_def.get("options_fn"))
222 if opt_fn is None:
223 raise FormulaicException(
224 "No function mapping defined for function reference '{x}'".format(x=field_def["options_fn"]))
225 if isinstance(opt_fn, str):
226 opt_fn = plugin.load_function(opt_fn)
227 return opt_fn(field_def, formulaic_context)
230# ~~->$ FormulaicContext:Feature~~
231class FormulaicContext(object):
232 def __init__(self, name, definition, parent: 'Formulaic', extra_param: Dict = None):
233 """
235 :param name:
236 :param definition:
237 :param parent:
238 :param extra_param:
239 the parameter that could be used by plugin function like
240 "disable_edit_note_except_editing_user"
241 """
242 self._name = name
243 self._definition = definition
244 self._formulaic = parent
245 self._wtform_class = None
246 self._wtform_inst = None
248 self._wtform_class = self.wtform_class()
249 self._wtform_inst = self.wtform()
251 self.extra_param = extra_param or {}
253 @property
254 def name(self):
255 return self._name
257 @property
258 def wtforms_builders(self):
259 return self._formulaic.wtforms_builders
261 @property
262 def function_map(self):
263 return self._formulaic.function_map
265 @property
266 def wtform_inst(self):
267 return self._wtform_inst
269 @property
270 def javascript_functions(self):
271 return self._formulaic.javascript_functions
273 @property
274 def ui_settings(self):
275 ui = deepcopy(self._definition.get("fieldsets", []))
276 for fieldset in ui:
277 for field in fieldset.get("fields", []):
278 for fn in [k for k in field.keys()]:
279 if fn not in UI_CONFIG_FIELDS:
280 del field[fn]
281 return ui
283 @property
284 def default_field_template(self):
285 return self._definition.get("templates", {}).get("default_field")
287 @property
288 def default_group_template(self):
289 return self._definition.get("templates", {}).get("default_group")
291 def list_fields_in_order(self):
292 fieldlist = []
293 for fs in self.fieldsets():
294 for field in fs.fields():
295 fieldlist.append(field)
296 if field.group_subfields():
297 for sf in field.group_subfields():
298 fieldlist.append(sf)
299 return fieldlist
301 def make_wtform_class(self, fields):
302 # ~~^-> WTForms:Library~~
303 class TempForm(Form):
304 pass
306 for field in fields:
307 self.bind_wtforms_field(TempForm, field)
309 return TempForm
311 def wtform_class(self):
312 if self._definition is None:
313 return
315 if self._wtform_class is not None:
316 return self._wtform_class
318 # FIXME: we should just store a list of fields in the context, and reference them
319 # from the fieldset, which would get rid of a lot of this double-layered looping
320 fields = []
321 for fieldset in self._definition.get("fieldsets", []):
322 for field in fieldset.get("fields", []):
323 if "group" in field:
324 continue
325 # if "subfield" in field:
326 # continue
327 fields.append(field)
329 klazz = self.make_wtform_class(fields)
330 self._wtform_class = klazz
331 return self._wtform_class
333 def wtform(self, formdata=None, data=None):
334 if self._definition is None:
335 return
337 klazz = self.wtform_class()
338 self._wtform_inst = klazz(formdata=formdata, data=data)
340 if formdata is not None or data is not None:
341 for fs in self._definition.get("fieldsets", []):
342 for f in fs.get("fields", []):
343 if "options_fn" in f:
344 opts = FormulaicField._options2choices(f, self)
345 wtf = None
346 if f.get("group") is not None:
347 wtf = self._wtform_inst[f["group"]]
348 if isinstance(wtf, FieldList):
349 for entry in wtf:
350 entry[f["name"]].choices = opts
351 else:
352 wtf[f["name"]].choices = opts
353 else:
354 wtf = self._wtform_inst[f["name"]]
355 if isinstance(wtf, FieldList):
356 for entry in wtf:
357 entry.choices = opts
358 else:
359 wtf.choices = opts
361 return self._wtform_inst
363 def get(self, field_name, parent=None):
364 if parent is None:
365 parent = self
366 for fs in self._definition.get("fieldsets", []):
367 for f in fs.get("fields", []):
368 if f.get("name") == field_name:
369 return FormulaicField(f, parent)
371 def repeatable_fields(self, parent=None):
372 if parent is None:
373 parent = self
375 reps = []
376 for fs in self._definition.get("fieldsets", []):
377 for f in fs.get("fields", []):
378 if "repeatable" in f:
379 reps.append(FormulaicField(f, parent))
381 return reps
383 def disabled_fields(self, parent=None):
384 if parent is None:
385 parent = self
387 disableds = []
388 for fs in self._definition.get("fieldsets", []):
389 for f in fs.get("fields", []):
390 field = FormulaicField(f, parent)
391 if field.is_disabled:
392 disableds.append(field)
394 return disableds
396 def conditional_fields(self, parent=None):
397 if parent is None:
398 parent = self
400 conditionals = []
401 for fs in self._definition.get("fieldsets", []):
402 for f in fs.get("fields", []):
403 field = FormulaicField(f, parent)
404 if field.has_conditional:
405 conditionals.append(field)
407 return conditionals
409 def fieldset(self, fieldset_name):
410 for fs in self._definition.get("fieldsets", []):
411 if fs.get("name") == fieldset_name:
412 return FormulaicFieldset(fs, self)
413 return None
415 def fieldsets(self):
416 return [FormulaicFieldset(fs, self) for fs in self._definition.get("fieldsets", [])]
418 def json(self):
419 return json.dumps(self._definition)
421 def render_template(self, **kwargs):
422 # ~~^-> Jinja2:Technology~~
423 template = self._definition.get("templates", {}).get("form")
424 return render_template(template, formulaic_context=self, **kwargs)
426 def processor(self, formdata=None, source=None) -> 'FormProcessor':
427 """
428 Returns a FormProcessor instance and also update data in FormulaicContext (self)
429 """
431 # ~~^-> FormProcessor:Feature~~
432 klazz = self._definition.get("processor")
433 if isinstance(klazz, str):
434 klazz = plugin.load_class(klazz)
435 return klazz(formdata=formdata, source=source, parent=self)
437 def obj2form(self, obj):
438 # ~~^-> Crosswalk:Feature~~
439 xwalk_fn = self._definition.get("crosswalks", {}).get("obj2form")
440 if xwalk_fn is None:
441 return None
442 if isinstance(xwalk_fn, str):
443 xwalk_fn = plugin.load_function(xwalk_fn)
444 data = xwalk_fn(obj)
445 return self.wtform(data=data)
447 def form2obj(self):
448 # ~~^-> Crosswalk:Feature~~
449 xwalk_fn = self._definition.get("crosswalks", {}).get("form2obj")
450 if xwalk_fn is None:
451 return None
452 if isinstance(xwalk_fn, str):
453 xwalk_fn = plugin.load_function(xwalk_fn)
454 return xwalk_fn(self._wtform_inst)
456 def bind_wtforms_field(self, FormClass, field):
457 field_name = field.get("name")
458 if not hasattr(FormClass, field_name):
459 field_definition = FormulaicField.make_wtforms_field(self, field)
460 setattr(FormClass, field_name, field_definition)
462 def to_summary_csv(self, out_file):
463 def _make_row(i, fs, parent, field, writer):
464 options = ""
465 if field.explicit_options:
466 options = "; ".join([o.get("display") + " (" + o.get("value") + ")" for o in field.explicit_options])
467 elif field.options_fn_name:
468 options = field.options_fn_name
470 label = ""
471 if hasattr(field, "label"):
472 label = field.label
474 name = ""
475 if parent is not None and hasattr(parent, "name"):
476 name = parent.name + "."
477 if hasattr(field, "name"):
478 name += field.name
480 writer.writerow([
481 i,
482 name,
483 label,
484 field.input,
485 options,
486 field.is_disabled,
487 fs.name
488 ])
490 with open(out_file, "w", encoding="utf-8") as f:
491 writer = csv.writer(f)
492 writer.writerow(["Form Position", "Field Name", "Label", "Input Type",
493 "Options", "Disabled?", "Fieldset ID"])
494 i = 1
495 for fs in self.fieldsets():
496 for field in fs.fields():
497 _make_row(i, fs, None, field, writer)
498 i += 1
499 if field.group_subfields():
500 for sf in field.group_subfields():
501 _make_row(i, fs, field, sf, writer)
502 i += 1
505# ~~->$ FormulaicFieldset:Feature~~
506class FormulaicFieldset(object):
507 def __init__(self, definition, parent):
508 self._definition = definition
509 self._formulaic_context = parent
511 @property
512 def wtforms_builders(self):
513 return self._formulaic_context.wtforms_builders
515 @property
516 def function_map(self):
517 return self._formulaic_context.function_map
519 @property
520 def wtform_inst(self):
521 return self._formulaic_context.wtform_inst
523 @property
524 def default_field_template(self):
525 return self._formulaic_context.default_field_template
527 @property
528 def default_group_template(self):
529 return self._formulaic_context.default_group_template
531 def fields(self):
532 return [FormulaicField(f, self) for f in
533 self._definition.get("fields", []) if not f.get("group")]
535 def field(self, field_name):
536 for f in self._definition.get("fields", []):
537 if f.get("name") == field_name:
538 return FormulaicField(f, self)
540 def __getattr__(self, name):
541 if hasattr(self.__class__, name):
542 return object.__getattribute__(self, name)
544 if name in self._definition:
545 return self._definition[name]
547 raise AttributeError('{name} is not set'.format(name=name))
550# ~~->$ FormulaicField:Feature~~
551class FormulaicField(object):
552 def __init__(self, definition, parent):
553 self._definition = definition
554 self._formulaic_fieldset = parent
556 self.wtfinst: Optional[Field] = None # used by some plugins function
558 def find_related_form_field(self, fieldset_name, formulaic_context: 'FormulaicContext') -> Optional[FormField]:
559 """ Find the "parent" FormField of self.wtfinst in the given fieldset.
561 the reason you need to call this method to find FormField inst is because
562 wtfinst is subfield of FormField, and you need data of other subfields and
563 FormField inst contain all data of subfields.
565 self.wtfinst should be defined before calling this method.
567 :param fieldset_name:
568 :param formulaic_context:
569 :return:
570 """
572 if not self.wtfinst:
573 log.debug('wtfinst is not defined, cannot find related form field with find_related_form_field')
574 return None
576 fields: Iterable[FormField] = itertools.chain.from_iterable(
577 f.wtfield for f in formulaic_context.fieldset(fieldset_name).fields())
578 for form_field in fields:
579 for f in form_field:
580 if f == self.wtfinst:
581 return form_field
582 return None
584 def __contains__(self, item):
585 return item in self._definition
587 def __getattr__(self, name):
588 if hasattr(self.__class__, name):
589 return object.__getattribute__(self, name)
591 if name in self._definition:
592 return self._definition[name]
594 raise AttributeError('{name} is not set'.format(name=name))
596 @property
597 def parent_context(self):
598 if isinstance(self._formulaic_fieldset, FormulaicContext):
599 return self._formulaic_fieldset
600 return self._formulaic_fieldset._formulaic_context
602 def get(self, attr, default=None):
603 return self._definition.get(attr, default)
605 def help(self, key, default=None):
606 return self._definition.get("help", {}).get(key, default)
608 @property
609 def optional(self):
610 return self._definition.get("optional", False)
612 @property
613 def wtforms_builders(self):
614 return self._formulaic_fieldset.wtforms_builders
616 @property
617 def function_map(self):
618 return self._formulaic_fieldset.function_map
620 @property
621 def wtform_inst(self):
622 if "group" in self._definition:
623 group = self._definition["group"]
624 group_field = self._formulaic_fieldset.field(group)
625 return group_field.wtfield
626 return self._formulaic_fieldset.wtform_inst
628 @property
629 def wtfield(self):
630 name = self._definition.get("name")
631 if self.wtform_inst is None:
632 return None
633 if hasattr(self.wtform_inst, name):
634 return getattr(self.wtform_inst, name)
635 return None
637 @property
638 def explicit_options(self):
639 opts = self._definition.get("options", [])
640 if isinstance(opts, list):
641 return opts
642 return []
644 @property
645 def options_fn_name(self):
646 return self._definition.get("options_fn")
648 @property
649 def is_disabled(self):
650 differently_abled = self._definition.get("disabled", False)
651 if isinstance(differently_abled, str):
652 fn = self.function_map.get("disabled", {}).get(differently_abled)
653 differently_abled = fn(self, self.parent_context)
654 return differently_abled
656 @property
657 def has_conditional(self):
658 return len(self._definition.get("conditional", [])) > 0
660 @property
661 def condition_field(self):
662 condition = self._definition.get("conditional", [])
663 return condition[0].get("field", None) if len(condition) > 0 else None
665 @property
666 def condition_value(self):
667 condition = self._definition.get("conditional", [])
668 return condition[0].get("value", None) if len(condition) > 0 else None
670 @property
671 def template(self):
672 local = self._definition.get("template")
673 if local is not None:
674 return local
676 if self._definition.get("input") == "group":
677 return self._formulaic_fieldset.default_group_template
679 return self._formulaic_fieldset.default_field_template
681 @property
682 def entry_template(self):
683 return self._definition.get("entry_template")
685 def has_validator(self, validator_name):
686 for validator in self._definition.get("validate", []):
687 if isinstance(validator, str) and validator == validator_name:
688 return True
689 if isinstance(validator, dict):
690 if list(validator.keys())[0] == validator_name:
691 return True
692 return False
694 def get_validator_settings(self, validator_name):
695 for validator in self._definition.get("validate", []):
696 if isinstance(validator, str) and validator == validator_name:
697 return {}
698 if isinstance(validator, dict):
699 name = list(validator.keys())[0]
700 if name == validator_name:
701 return validator[name]
702 return False
704 def validators(self):
705 for validator in self._definition.get("validate", []):
706 if isinstance(validator, str):
707 yield validator, {}
708 if isinstance(validator, dict):
709 name = list(validator.keys())[0]
710 yield name, validator[name]
712 def get_subfields(self, option_value):
713 for option in self.explicit_options:
714 if option.get("value") == option_value:
715 sfs = []
716 for sf in option.get("subfields", []):
717 subimpl = self._formulaic_fieldset._formulaic_context.get(sf, self._formulaic_fieldset)
718 sfs.append(subimpl)
719 return sfs
721 def group_subfields(self):
722 subs = self._definition.get("subfields")
723 if subs is None:
724 return None
725 return [self._formulaic_fieldset.field(s) for s in subs]
727 def has_options_subfields(self):
728 for option in self.explicit_options:
729 if len(option.get("subfields", [])) > 0:
730 return True
731 return False
733 def has_errors(self):
734 return len(self.errors()) > 0
736 def errors(self):
737 wtf = self.wtfield
738 return wtf.errors
740 def has_widget(self, widget_name):
741 for w in self._definition.get("widgets", []):
742 if w == widget_name:
743 return True
744 if isinstance(w, dict):
745 if widget_name in w:
746 return True
747 return False
749 def render_form_control(self, custom_args=None, wtfinst=None):
750 # ~~-> WTForms:Library~~
751 self.wtfinst = wtfinst
753 kwargs = deepcopy(self._definition.get("attr", {}))
754 if "placeholder" in self._definition.get("help", {}):
755 kwargs["placeholder"] = self._definition["help"]["placeholder"]
756 if "warning_message" in self._definition.get("help", {}):
757 kwargs["warning_message"] = self._definition["help"]["warning_message"]
759 render_functions = self.function_map.get("validate", {}).get("render", {})
760 for validator, settings in self.validators():
761 if validator in render_functions:
762 fn = render_functions[validator]
763 if isinstance(fn, str):
764 fn = plugin.load_function(fn)
765 fn(settings, kwargs)
767 if self.has_options_subfields():
768 kwargs["formulaic"] = self
770 add_data_as_choice = False
771 if self.is_disabled:
772 kwargs["disabled"] = "disabled"
773 add_data_as_choice = True
775 # allow custom args to overwite all other arguments
776 if custom_args is not None:
777 for k, v in custom_args.items():
778 kwargs[k] = v
780 wtf = None
781 if wtfinst is not None:
782 wtf = wtfinst
783 else:
784 wtf = self.wtfield
786 if add_data_as_choice and hasattr(wtf, "choices") and wtf.data not in [c[0] for c in wtf.choices]:
787 wtf.choices += [(wtf.data, wtf.data)]
789 return wtf(**kwargs)
791 @classmethod
792 def make_wtforms_field(cls, formulaic_context, field) -> UnboundField:
793 builder = cls._get_wtforms_builder(field, formulaic_context.wtforms_builders)
794 if builder is None:
795 raise FormulaicException("No WTForms mapping for field '{x}'".format(x=field.get("name")))
797 validators = []
798 vfuncs = formulaic_context.function_map.get("validate", {}).get("wtforms", {})
799 for v in field.get("validate", []):
800 vname = v
801 args = {}
802 if isinstance(v, dict):
803 vname = list(v.keys())[0]
804 args = v[vname]
805 if vname not in vfuncs:
806 raise FormulaicException("No validate WTForms function defined for {x} in python function references"
807 .format(x=vname))
808 vfn = vfuncs[vname]
809 if isinstance(vfn, str):
810 vfn = plugin.load_function(vfn)
811 validators.append(vfn(field, args))
813 # if len(field.get("conditional", [])) > 0:
814 # validators.append(ConditionalValidator(field["conditional"], formulaic_context))
816 wtargs = {
817 "label": field.get("label"),
818 "validators": validators,
819 "description": field.get("help", {}).get("description"),
820 }
821 if "default" in field:
822 wtargs["default"] = field.get("default")
823 if "options" in field or "options_fn" in field:
824 wtargs["choices"] = cls._options2choices(field, formulaic_context)
826 return builder(formulaic_context, field, wtargs)
828 @classmethod
829 def _get_wtforms_builder(self, field, wtforms_builders):
830 for builder in wtforms_builders:
831 if builder.match(field):
832 return builder.wtform
833 return None
835 @classmethod
836 def _options2choices(self, field, formulaic_context):
837 # function_map = formulaic_context.function_map.get("options", {})
839 options = field.get("options", [])
840 if len(options) == 0 and "options_fn" in field:
841 # options = Formulaic.run_options_fn(field, formulaic_context)
842 options = Formulaic.run_options_fn(field, formulaic_context)
843 # fnpath = function_map.get(field["options_fn"])
844 # if fnpath is None:
845 # raise FormulaicException("No function mapping defined for function reference '{x}'".format(x=field["options_fn"]))
846 # fn = plugin.load_function(fnpath)
847 # options = fn(field, formulaic_context.name)
849 choices = []
850 for o in options:
851 display = o.get("display")
852 value = o.get("value")
853 if value is None:
854 value = o.get("display")
855 choices.append((value, display))
857 return choices
860# ~~->$ FormProcessor:Feature~~
861class FormProcessor(object):
862 def __init__(self, formdata=None, source=None, parent: FormulaicContext = None):
863 # initialise our core properties
864 self._source = source
865 self._target = None
866 self._formdata = formdata
867 self._alert = []
868 self._info = ''
869 self._formulaic = parent
871 # now create our form instance, with the form_data (if there is any)
872 if formdata is not None:
873 self.data2form()
875 # if there isn't any form data, then we should create the form properties from source instead
876 elif source is not None:
877 self.source2form()
879 # if there is no source, then a blank form object
880 else:
881 self.blank_form()
883 ############################################################
884 # getters and setters on the main FormContext properties
885 ############################################################
887 @property
888 def form(self):
889 # return self._form
890 return self._formulaic.wtform_inst
892 @property
893 def source(self):
894 return self._source
896 @property
897 def form_data(self):
898 return self._formdata
900 @property
901 def target(self):
902 return self._target
904 @target.setter
905 def target(self, val):
906 self._target = val
908 # @property
909 # def template(self):
910 # return self._template
911 #
912 # @template.setter
913 # def template(self, val):
914 # self._template = val
916 @property
917 def alert(self):
918 return self._alert
920 def add_alert(self, val):
921 self._alert.append(val)
923 @property
924 def info(self):
925 return self._info
927 @info.setter
928 def info(self, val):
929 self._info = val
931 #############################################################
932 # Lifecycle functions you don't have to overwrite unless you
933 # want to do something different
934 #############################################################
936 def blank_form(self):
937 """
938 This will be called during init, and must populate the self.form_data property with an instance of the form in this
939 context, based on no originating source or form data
940 """
941 self._formulaic.wtform()
943 def data2form(self):
944 """
945 This will be called during init, and must convert the form_data into an instance of the form in this context,
946 and write to self.form
947 """
948 self._formulaic.wtform(formdata=self.form_data)
950 def source2form(self):
951 """
952 This will be called during init, and must convert the source object into an instance of the form in this
953 context, and write to self.form
954 """
955 self._formulaic.obj2form(self.source)
957 def form2target(self):
958 """
959 Convert the form object into a the target system object, and write to self.target
960 """
961 self.target = self._formulaic.form2obj()
963 ############################################################
964 # Lifecycle functions that subclasses should implement
965 ############################################################
967 def pre_validate(self):
968 """
969 This will be run before validation against the form is run.
970 Use it to patch the form with any relevant data, such as fields which were disabled
971 """
972 repeatables = self._formulaic.repeatable_fields()
973 for repeatable in repeatables:
974 wtf = repeatable.wtfield
975 if not isinstance(wtf, FieldList):
976 continue
978 # get all of the entries off the field list, leaving the field list temporarily empty
979 entries = []
980 for i in range(len(wtf.entries)):
981 entries.append(wtf.pop_entry())
983 # go through each entry, and if it has any data in it put it back onto the list, otherwise
984 # leave it off
985 for entry in entries:
986 if isinstance(entry, FormField):
987 data = entry.data
988 has_data = False
989 for k, v in data.items():
990 if v:
991 has_data = True
992 break
993 if not has_data:
994 continue
995 else:
996 if not entry.data:
997 continue
998 wtf.append_entry(entry.data)
1000 # finally, ensure that the minimum number of fields are populated in the list
1001 min = repeatable.get("repeatable", {}).get("minimum", 0)
1002 if min > len(wtf.entries):
1003 for i in range(len(wtf.entries), min):
1004 wtf.append_entry()
1006 # patch over any disabled fields
1007 disableds = self._formulaic.disabled_fields()
1008 if len(disableds) > 0:
1009 alt_formulaic = self._formulaic.__class__(self._formulaic.name, self._formulaic._definition,
1010 self._formulaic._formulaic)
1011 other_processor = alt_formulaic.processor(source=self.source)
1012 other_form = other_processor.form
1013 for dis in disableds:
1014 if dis.get("group") is not None:
1015 dis = self._formulaic.get(dis.get("group"))
1017 if dis.get("merge_disabled"):
1018 self._merge_disabled(dis, other_form)
1019 else:
1020 wtf = dis.wtfield
1021 wtf.data = other_form._fields[dis.get("name")].data
1023 # remove any conditional fields where the conditions are not met
1024 conditionals = self._formulaic.conditional_fields()
1025 if len(conditionals) > 0:
1026 for field in conditionals:
1027 condition_met = False
1028 for c in field.get("conditional"):
1029 other_field = c.get("field")
1030 expected_value = c.get("value")
1031 if other_field in self.form:
1032 other_value = self.form[other_field].data
1033 if isinstance(other_value, list):
1034 if expected_value in other_value:
1035 condition_met = True
1036 break
1037 elif other_value == expected_value:
1038 condition_met = True
1039 break
1040 if not condition_met:
1041 fi = field.wtfield
1042 self._reset_field_to_default(fi)
1044 def _reset_field_to_default(self, field):
1045 if isinstance(field, FormField):
1046 for subfield in field.form:
1047 self._reset_field_to_default(subfield)
1048 elif isinstance(field, FieldList):
1049 for sub in field:
1050 if isinstance(sub, FormField):
1051 for subfield in sub:
1052 self._reset_field_to_default(sub)
1053 else:
1054 sub.data = sub.default
1055 else:
1056 field.data = field.default
1058 def _merge_disabled(self, disabled, other_form):
1059 fnref = disabled.get("merge_disabled")
1060 fn = self._formulaic.function_map.get("merge_disabled", {}).get(fnref)
1061 if not fn:
1062 raise FormulaicException("No merge_disabled function defined {x}".format(x=fnref))
1063 fn(disabled, other_form)
1065 def patch_target(self):
1066 """
1067 Patch the target with data from the source. This will be run by the finalise method (unless you override it)
1068 """
1069 pass
1071 def finalise(self, *args, **kwargs):
1072 """
1073 Finish up with the FormContext. Carry out any final workflow tasks, etc.
1074 """
1075 self.form2target()
1076 self.patch_target()
1078 def draft(self, *args, **kwargs):
1079 pass
1081 ############################################################
1082 # Functions which can be called directly, but may be overridden if desired
1083 ############################################################
1085 def validate(self):
1086 self.pre_validate()
1087 f = self.form
1088 valid = False
1089 if f is not None:
1090 valid = f.validate()
1091 return valid
1093 @property
1094 def errors(self):
1095 f = self.form
1096 if f is not None:
1097 return f.errors
1098 return False
1101class WTFormsBuilder:
1102 @staticmethod
1103 def match(field):
1104 return False
1106 @staticmethod
1107 def wtform(formulaic_context, field, wtfargs):
1108 return None
1110# This code can be used to (partially) solve the conditional fields validation issue
1111#
1112# class AugmentedValidationError(ValidationError):
1113# def __init__(self, error_type, parameters, message='', *args, **kwargs):
1114# super(AugmentedValidationError, self).__init__(message, *args, **kwargs)
1115#
1116# class ConditionalValidator:
1117# def __init__(self, conditions, context, message=None):
1118# self.conditions = conditions
1119# self.context = context
1120# self.message = message
1121#
1122# def __call__(self, form, field):
1123# for c in self.conditions:
1124# other_field = c.get("field")
1125# expected_value = c.get("value")
1126# if other_field in form:
1127# other_value = form[other_field].data
1128# if other_value != expected_value:
1129# ff = self.context.get(other_field)
1130# # NOTE: this only works on fields with explicitly declared options. Dynamic options cannot be handled in the same way.
1131# # so we treat those as just regular text values
1132# if len(ff.explicit_options) > 0:
1133# for o in ff.explicit_options:
1134# if o.get("value") == expected_value:
1135# expected_value = o.get("display")
1136# break
1137# msg = self.message or "Field may only contain data if '{x}' has the value '{y}'".format(x=ff.label, y=expected_value)
1138# parameters = {
1139# "other_field": other_field,
1140# }
1141# raise ValidationError("conditional", message=msg)