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

1# ~~Formulaic:Library~~ 

2 

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} 

75 

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 

106 

107from flask import render_template 

108from wtforms import Form 

109from wtforms.fields.core import UnboundField, FieldList, FormField, Field 

110from wtforms.validators import ValidationError 

111 

112from portality.lib import plugin 

113 

114log = logging.getLogger(__name__) 

115 

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] 

135 

136 

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) 

144 

145 

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 

152 

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 

157 

158 fieldsets = context_def.get("fieldsets", []) 

159 

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 

167 

168 expanded_fields = self._process_fields(context_name, fieldset_def.get("fields", [])) 

169 

170 fieldset_def["fields"] = expanded_fields 

171 expanded_fieldsets.append(fieldset_def) 

172 

173 context_def["fieldsets"] = expanded_fieldsets 

174 return FormulaicContext(context_name, context_def, self, 

175 extra_param=extra_param or {}) 

176 

177 @property 

178 def wtforms_builders(self): 

179 return self._wtforms_builders 

180 

181 @property 

182 def function_map(self): 

183 return self._function_map 

184 

185 @property 

186 def javascript_functions(self): 

187 return self._javascript_functions 

188 

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 [] 

193 

194 return FormulaicField._options2choices(field_def, formulaic_context) 

195 

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)) 

203 

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 

209 

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"] 

213 

214 field_defs.append(field_def) 

215 

216 return field_defs 

217 

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) 

228 

229 

230# ~~->$ FormulaicContext:Feature~~ 

231class FormulaicContext(object): 

232 def __init__(self, name, definition, parent: 'Formulaic', extra_param: Dict = None): 

233 """ 

234 

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 

247 

248 self._wtform_class = self.wtform_class() 

249 self._wtform_inst = self.wtform() 

250 

251 self.extra_param = extra_param or {} 

252 

253 @property 

254 def name(self): 

255 return self._name 

256 

257 @property 

258 def wtforms_builders(self): 

259 return self._formulaic.wtforms_builders 

260 

261 @property 

262 def function_map(self): 

263 return self._formulaic.function_map 

264 

265 @property 

266 def wtform_inst(self): 

267 return self._wtform_inst 

268 

269 @property 

270 def javascript_functions(self): 

271 return self._formulaic.javascript_functions 

272 

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 

282 

283 @property 

284 def default_field_template(self): 

285 return self._definition.get("templates", {}).get("default_field") 

286 

287 @property 

288 def default_group_template(self): 

289 return self._definition.get("templates", {}).get("default_group") 

290 

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 

300 

301 def make_wtform_class(self, fields): 

302 # ~~^-> WTForms:Library~~ 

303 class TempForm(Form): 

304 pass 

305 

306 for field in fields: 

307 self.bind_wtforms_field(TempForm, field) 

308 

309 return TempForm 

310 

311 def wtform_class(self): 

312 if self._definition is None: 

313 return 

314 

315 if self._wtform_class is not None: 

316 return self._wtform_class 

317 

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) 

328 

329 klazz = self.make_wtform_class(fields) 

330 self._wtform_class = klazz 

331 return self._wtform_class 

332 

333 def wtform(self, formdata=None, data=None): 

334 if self._definition is None: 

335 return 

336 

337 klazz = self.wtform_class() 

338 self._wtform_inst = klazz(formdata=formdata, data=data) 

339 

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 

360 

361 return self._wtform_inst 

362 

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) 

370 

371 def repeatable_fields(self, parent=None): 

372 if parent is None: 

373 parent = self 

374 

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)) 

380 

381 return reps 

382 

383 def disabled_fields(self, parent=None): 

384 if parent is None: 

385 parent = self 

386 

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) 

393 

394 return disableds 

395 

396 def conditional_fields(self, parent=None): 

397 if parent is None: 

398 parent = self 

399 

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) 

406 

407 return conditionals 

408 

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 

414 

415 def fieldsets(self): 

416 return [FormulaicFieldset(fs, self) for fs in self._definition.get("fieldsets", [])] 

417 

418 def json(self): 

419 return json.dumps(self._definition) 

420 

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) 

425 

426 def processor(self, formdata=None, source=None) -> 'FormProcessor': 

427 """ 

428 Returns a FormProcessor instance and also update data in FormulaicContext (self) 

429 """ 

430 

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) 

436 

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) 

446 

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) 

455 

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) 

461 

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 

469 

470 label = "" 

471 if hasattr(field, "label"): 

472 label = field.label 

473 

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 

479 

480 writer.writerow([ 

481 i, 

482 name, 

483 label, 

484 field.input, 

485 options, 

486 field.is_disabled, 

487 fs.name 

488 ]) 

489 

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 

503 

504 

505# ~~->$ FormulaicFieldset:Feature~~ 

506class FormulaicFieldset(object): 

507 def __init__(self, definition, parent): 

508 self._definition = definition 

509 self._formulaic_context = parent 

510 

511 @property 

512 def wtforms_builders(self): 

513 return self._formulaic_context.wtforms_builders 

514 

515 @property 

516 def function_map(self): 

517 return self._formulaic_context.function_map 

518 

519 @property 

520 def wtform_inst(self): 

521 return self._formulaic_context.wtform_inst 

522 

523 @property 

524 def default_field_template(self): 

525 return self._formulaic_context.default_field_template 

526 

527 @property 

528 def default_group_template(self): 

529 return self._formulaic_context.default_group_template 

530 

531 def fields(self): 

532 return [FormulaicField(f, self) for f in 

533 self._definition.get("fields", []) if not f.get("group")] 

534 

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) 

539 

540 def __getattr__(self, name): 

541 if hasattr(self.__class__, name): 

542 return object.__getattribute__(self, name) 

543 

544 if name in self._definition: 

545 return self._definition[name] 

546 

547 raise AttributeError('{name} is not set'.format(name=name)) 

548 

549 

550# ~~->$ FormulaicField:Feature~~ 

551class FormulaicField(object): 

552 def __init__(self, definition, parent): 

553 self._definition = definition 

554 self._formulaic_fieldset = parent 

555 

556 self.wtfinst: Optional[Field] = None # used by some plugins function 

557 

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. 

560 

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. 

564 

565 self.wtfinst should be defined before calling this method. 

566 

567 :param fieldset_name: 

568 :param formulaic_context: 

569 :return: 

570 """ 

571 

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 

575 

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 

583 

584 def __contains__(self, item): 

585 return item in self._definition 

586 

587 def __getattr__(self, name): 

588 if hasattr(self.__class__, name): 

589 return object.__getattribute__(self, name) 

590 

591 if name in self._definition: 

592 return self._definition[name] 

593 

594 raise AttributeError('{name} is not set'.format(name=name)) 

595 

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 

601 

602 def get(self, attr, default=None): 

603 return self._definition.get(attr, default) 

604 

605 def help(self, key, default=None): 

606 return self._definition.get("help", {}).get(key, default) 

607 

608 @property 

609 def optional(self): 

610 return self._definition.get("optional", False) 

611 

612 @property 

613 def wtforms_builders(self): 

614 return self._formulaic_fieldset.wtforms_builders 

615 

616 @property 

617 def function_map(self): 

618 return self._formulaic_fieldset.function_map 

619 

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 

627 

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 

636 

637 @property 

638 def explicit_options(self): 

639 opts = self._definition.get("options", []) 

640 if isinstance(opts, list): 

641 return opts 

642 return [] 

643 

644 @property 

645 def options_fn_name(self): 

646 return self._definition.get("options_fn") 

647 

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 

655 

656 @property 

657 def has_conditional(self): 

658 return len(self._definition.get("conditional", [])) > 0 

659 

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 

664 

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 

669 

670 @property 

671 def template(self): 

672 local = self._definition.get("template") 

673 if local is not None: 

674 return local 

675 

676 if self._definition.get("input") == "group": 

677 return self._formulaic_fieldset.default_group_template 

678 

679 return self._formulaic_fieldset.default_field_template 

680 

681 @property 

682 def entry_template(self): 

683 return self._definition.get("entry_template") 

684 

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 

693 

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 

703 

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] 

711 

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 

720 

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] 

726 

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 

732 

733 def has_errors(self): 

734 return len(self.errors()) > 0 

735 

736 def errors(self): 

737 wtf = self.wtfield 

738 return wtf.errors 

739 

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 

748 

749 def render_form_control(self, custom_args=None, wtfinst=None): 

750 # ~~-> WTForms:Library~~ 

751 self.wtfinst = wtfinst 

752 

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"] 

758 

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) 

766 

767 if self.has_options_subfields(): 

768 kwargs["formulaic"] = self 

769 

770 add_data_as_choice = False 

771 if self.is_disabled: 

772 kwargs["disabled"] = "disabled" 

773 add_data_as_choice = True 

774 

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 

779 

780 wtf = None 

781 if wtfinst is not None: 

782 wtf = wtfinst 

783 else: 

784 wtf = self.wtfield 

785 

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)] 

788 

789 return wtf(**kwargs) 

790 

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"))) 

796 

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)) 

812 

813 # if len(field.get("conditional", [])) > 0: 

814 # validators.append(ConditionalValidator(field["conditional"], formulaic_context)) 

815 

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) 

825 

826 return builder(formulaic_context, field, wtargs) 

827 

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 

834 

835 @classmethod 

836 def _options2choices(self, field, formulaic_context): 

837 # function_map = formulaic_context.function_map.get("options", {}) 

838 

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) 

848 

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)) 

856 

857 return choices 

858 

859 

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 

870 

871 # now create our form instance, with the form_data (if there is any) 

872 if formdata is not None: 

873 self.data2form() 

874 

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() 

878 

879 # if there is no source, then a blank form object 

880 else: 

881 self.blank_form() 

882 

883 ############################################################ 

884 # getters and setters on the main FormContext properties 

885 ############################################################ 

886 

887 @property 

888 def form(self): 

889 # return self._form 

890 return self._formulaic.wtform_inst 

891 

892 @property 

893 def source(self): 

894 return self._source 

895 

896 @property 

897 def form_data(self): 

898 return self._formdata 

899 

900 @property 

901 def target(self): 

902 return self._target 

903 

904 @target.setter 

905 def target(self, val): 

906 self._target = val 

907 

908 # @property 

909 # def template(self): 

910 # return self._template 

911 # 

912 # @template.setter 

913 # def template(self, val): 

914 # self._template = val 

915 

916 @property 

917 def alert(self): 

918 return self._alert 

919 

920 def add_alert(self, val): 

921 self._alert.append(val) 

922 

923 @property 

924 def info(self): 

925 return self._info 

926 

927 @info.setter 

928 def info(self, val): 

929 self._info = val 

930 

931 ############################################################# 

932 # Lifecycle functions you don't have to overwrite unless you 

933 # want to do something different 

934 ############################################################# 

935 

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() 

942 

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) 

949 

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) 

956 

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() 

962 

963 ############################################################ 

964 # Lifecycle functions that subclasses should implement 

965 ############################################################ 

966 

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 

977 

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()) 

982 

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) 

999 

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() 

1005 

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")) 

1016 

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 

1022 

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) 

1043 

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 

1057 

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) 

1064 

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 

1070 

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() 

1077 

1078 def draft(self, *args, **kwargs): 

1079 pass 

1080 

1081 ############################################################ 

1082 # Functions which can be called directly, but may be overridden if desired 

1083 ############################################################ 

1084 

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 

1092 

1093 @property 

1094 def errors(self): 

1095 f = self.form 

1096 if f is not None: 

1097 return f.errors 

1098 return False 

1099 

1100 

1101class WTFormsBuilder: 

1102 @staticmethod 

1103 def match(field): 

1104 return False 

1105 

1106 @staticmethod 

1107 def wtform(formulaic_context, field, wtfargs): 

1108 return None 

1109 

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)