Coverage for .tox/coverage/lib/python3.11/site-packages/wuttaweb/forms/base.py: 100%

280 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-08-23 22:41 -0500

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

26 

27import logging 

28from collections import OrderedDict 

29 

30import colander 

31import deform 

32from colanderalchemy import SQLAlchemySchemaNode 

33from pyramid.renderers import render 

34from webhelpers2.html import HTML 

35 

36from wuttaweb.util import FieldList, get_form_data, get_model_fields, make_json_safe 

37 

38 

39log = logging.getLogger(__name__) 

40 

41 

42class Form: 

43 """ 

44 Base class for all forms. 

45 

46 :param request: Reference to current :term:`request` object. 

47 

48 :param fields: List of field names for the form. This is 

49 optional; if not specified an attempt will be made to deduce 

50 the list automatically. See also :attr:`fields`. 

51 

52 :param schema: Colander-based schema object for the form. This is 

53 optional; if not specified an attempt will be made to construct 

54 one automatically. See also :meth:`get_schema()`. 

55 

56 :param labels: Optional dict of default field labels. 

57 

58 .. note:: 

59 

60 Some parameters are not explicitly described above. However 

61 their corresponding attributes are described below. 

62 

63 Form instances contain the following attributes: 

64 

65 .. attribute:: request 

66 

67 Reference to current :term:`request` object. 

68 

69 .. attribute:: fields 

70 

71 :class:`~wuttaweb.util.FieldList` instance containing string 

72 field names for the form. By default, fields will appear in 

73 the same order as they are in this list. 

74 

75 See also :meth:`set_fields()`. 

76 

77 .. attribute:: schema 

78 

79 :class:`colander:colander.Schema` object for the form. This is 

80 optional; if not specified an attempt will be made to construct 

81 one automatically. 

82 

83 See also :meth:`get_schema()`. 

84 

85 .. attribute:: model_class 

86 

87 Model class for the form, if applicable. When set, this is 

88 usually a SQLAlchemy mapped class. This (or 

89 :attr:`model_instance`) may be used instead of specifying the 

90 :attr:`schema`. 

91 

92 .. attribute:: model_instance 

93 

94 Optional instance from which initial form data should be 

95 obtained. In simple cases this might be a dict, or maybe an 

96 instance of :attr:`model_class`. 

97 

98 Note that this also may be used instead of specifying the 

99 :attr:`schema`, if the instance belongs to a class which is 

100 SQLAlchemy-mapped. (In that case :attr:`model_class` can be 

101 determined automatically.) 

102 

103 .. attribute:: nodes 

104 

105 Dict of node overrides, used to construct the form in 

106 :meth:`get_schema()`. 

107 

108 See also :meth:`set_node()`. 

109 

110 .. attribute:: widgets 

111 

112 Dict of widget overrides, used to construct the form in 

113 :meth:`get_schema()`. 

114 

115 See also :meth:`set_widget()`. 

116 

117 .. attribute:: validators 

118 

119 Dict of node validators, used to construct the form in 

120 :meth:`get_schema()`. 

121 

122 See also :meth:`set_validator()`. 

123 

124 .. attribute:: defaults 

125 

126 Dict of default field values, used to construct the form in 

127 :meth:`get_schema()`. 

128 

129 See also :meth:`set_default()`. 

130 

131 .. attribute:: readonly 

132 

133 Boolean indicating the form does not allow submit. In practice 

134 this means there will not even be a ``<form>`` tag involved. 

135 

136 Default for this is ``False`` in which case the ``<form>`` tag 

137 will exist and submit is allowed. 

138 

139 .. attribute:: readonly_fields 

140 

141 A :class:`~python:set` of field names which should be readonly. 

142 Each will still be rendered but with static value text and no 

143 widget. 

144 

145 This is only applicable if :attr:`readonly` is ``False``. 

146 

147 See also :meth:`set_readonly()` and :meth:`is_readonly()`. 

148 

149 .. attribute:: required_fields 

150 

151 A dict of "required" field flags. Keys are field names, and 

152 values are boolean flags indicating whether the field is 

153 required. 

154 

155 Depending on :attr:`schema`, some fields may be "(not) 

156 required" by default. However ``required_fields`` keeps track 

157 of any "overrides" per field. 

158 

159 See also :meth:`set_required()` and :meth:`is_required()`. 

160 

161 .. attribute:: action_url 

162 

163 String URL to which the form should be submitted, if applicable. 

164 

165 .. attribute:: cancel_url 

166 

167 String URL to which the Cancel button should "always" redirect, 

168 if applicable. 

169 

170 Code should not access this directly, but instead call 

171 :meth:`get_cancel_url()`. 

172 

173 .. attribute:: cancel_url_fallback 

174 

175 String URL to which the Cancel button should redirect, if 

176 referrer cannot be determined from request. 

177 

178 Code should not access this directly, but instead call 

179 :meth:`get_cancel_url()`. 

180 

181 .. attribute:: vue_tagname 

182 

183 String name for Vue component tag. By default this is 

184 ``'wutta-form'``. See also :meth:`render_vue_tag()`. 

185 

186 See also :attr:`vue_component`. 

187 

188 .. attribute:: align_buttons_right 

189 

190 Flag indicating whether the buttons (submit, cancel etc.) 

191 should be aligned to the right of the area below the form. If 

192 not set, the buttons are left-aligned. 

193 

194 .. attribute:: auto_disable_submit 

195 

196 Flag indicating whether the submit button should be 

197 auto-disabled, whenever the form is submitted. 

198 

199 .. attribute:: button_label_submit 

200 

201 String label for the form submit button. Default is ``"Save"``. 

202 

203 .. attribute:: button_icon_submit 

204 

205 String icon name for the form submit button. Default is ``'save'``. 

206 

207 .. attribute:: button_type_submit 

208 

209 Buefy type for the submit button. Default is ``'is-primary'``, 

210 so for example: 

211 

212 .. code-block:: html 

213 

214 <b-button type="is-primary" 

215 native-type="submit"> 

216 Save 

217 </b-button> 

218 

219 See also the `Buefy docs 

220 <https://buefy.org/documentation/button/#api-view>`_. 

221 

222 .. attribute:: show_button_reset 

223 

224 Flag indicating whether a Reset button should be shown. 

225 Default is ``False``. 

226 

227 .. attribute:: show_button_cancel 

228 

229 Flag indicating whether a Cancel button should be shown. 

230 Default is ``True``. 

231 

232 .. attribute:: button_label_cancel 

233 

234 String label for the form cancel button. Default is 

235 ``"Cancel"``. 

236 

237 .. attribute:: auto_disable_cancel 

238 

239 Flag indicating whether the cancel button should be 

240 auto-disabled, whenever the button is clicked. Default is 

241 ``True``. 

242 

243 .. attribute:: validated 

244 

245 If the :meth:`validate()` method was called, and it succeeded, 

246 this will be set to the validated data dict. 

247 

248 Note that in all other cases, this attribute may not exist. 

249 """ 

250 

251 def __init__( 

252 self, 

253 request, 

254 fields=None, 

255 schema=None, 

256 model_class=None, 

257 model_instance=None, 

258 nodes={}, 

259 widgets={}, 

260 validators={}, 

261 defaults={}, 

262 readonly=False, 

263 readonly_fields=[], 

264 required_fields={}, 

265 labels={}, 

266 action_url=None, 

267 cancel_url=None, 

268 cancel_url_fallback=None, 

269 vue_tagname='wutta-form', 

270 align_buttons_right=False, 

271 auto_disable_submit=True, 

272 button_label_submit="Save", 

273 button_icon_submit='save', 

274 button_type_submit='is-primary', 

275 show_button_reset=False, 

276 show_button_cancel=True, 

277 button_label_cancel="Cancel", 

278 auto_disable_cancel=True, 

279 ): 

280 self.request = request 

281 self.schema = schema 

282 self.nodes = nodes or {} 

283 self.widgets = widgets or {} 

284 self.validators = validators or {} 

285 self.defaults = defaults or {} 

286 self.readonly = readonly 

287 self.readonly_fields = set(readonly_fields or []) 

288 self.required_fields = required_fields or {} 

289 self.labels = labels or {} 

290 self.action_url = action_url 

291 self.cancel_url = cancel_url 

292 self.cancel_url_fallback = cancel_url_fallback 

293 self.vue_tagname = vue_tagname 

294 self.align_buttons_right = align_buttons_right 

295 self.auto_disable_submit = auto_disable_submit 

296 self.button_label_submit = button_label_submit 

297 self.button_icon_submit = button_icon_submit 

298 self.button_type_submit = button_type_submit 

299 self.show_button_reset = show_button_reset 

300 self.show_button_cancel = show_button_cancel 

301 self.button_label_cancel = button_label_cancel 

302 self.auto_disable_cancel = auto_disable_cancel 

303 

304 self.config = self.request.wutta_config 

305 self.app = self.config.get_app() 

306 

307 self.model_class = model_class 

308 self.model_instance = model_instance 

309 if self.model_instance and not self.model_class: 

310 if type(self.model_instance) is not dict: 

311 self.model_class = type(self.model_instance) 

312 

313 self.set_fields(fields or self.get_fields()) 

314 

315 # nb. this tracks grid JSON data for inclusion in page template 

316 self.grid_vue_context = OrderedDict() 

317 

318 def __contains__(self, name): 

319 """ 

320 Custom logic for the ``in`` operator, to allow easily checking 

321 if the form contains a given field:: 

322 

323 myform = Form() 

324 if 'somefield' in myform: 

325 print("my form has some field") 

326 """ 

327 return bool(self.fields and name in self.fields) 

328 

329 def __iter__(self): 

330 """ 

331 Custom logic to allow iterating over form field names:: 

332 

333 myform = Form(fields=['foo', 'bar']) 

334 for fieldname in myform: 

335 print(fieldname) 

336 """ 

337 return iter(self.fields) 

338 

339 @property 

340 def vue_component(self): 

341 """ 

342 String name for the Vue component, e.g. ``'WuttaForm'``. 

343 

344 This is a generated value based on :attr:`vue_tagname`. 

345 """ 

346 words = self.vue_tagname.split('-') 

347 return ''.join([word.capitalize() for word in words]) 

348 

349 def get_cancel_url(self): 

350 """ 

351 Returns the URL for the Cancel button. 

352 

353 If :attr:`cancel_url` is set, its value is returned. 

354 

355 Or, if the referrer can be deduced from the request, that is 

356 returned. 

357 

358 Or, if :attr:`cancel_url_fallback` is set, that value is 

359 returned. 

360 

361 As a last resort the "default" URL from 

362 :func:`~wuttaweb.subscribers.request.get_referrer()` is 

363 returned. 

364 """ 

365 # use "permanent" URL if set 

366 if self.cancel_url: 

367 return self.cancel_url 

368 

369 # nb. use fake default to avoid normal default logic; 

370 # that way if we get something it's a real referrer 

371 url = self.request.get_referrer(default='NOPE') 

372 if url and url != 'NOPE': 

373 return url 

374 

375 # use fallback URL if set 

376 if self.cancel_url_fallback: 

377 return self.cancel_url_fallback 

378 

379 # okay, home page then (or whatever is the default URL) 

380 return self.request.get_referrer() 

381 

382 def set_fields(self, fields): 

383 """ 

384 Explicitly set the list of form fields. 

385 

386 This will overwrite :attr:`fields` with a new 

387 :class:`~wuttaweb.util.FieldList` instance. 

388 

389 :param fields: List of string field names. 

390 """ 

391 self.fields = FieldList(fields) 

392 

393 def append(self, *keys): 

394 """ 

395 Add some fields(s) to the form. 

396 

397 This is a convenience to allow adding multiple fields at 

398 once:: 

399 

400 form.append('first_field', 

401 'second_field', 

402 'third_field') 

403 

404 It will add each field to :attr:`fields`. 

405 """ 

406 for key in keys: 

407 if key not in self.fields: 

408 self.fields.append(key) 

409 

410 def remove(self, *keys): 

411 """ 

412 Remove some fields(s) from the form. 

413 

414 This is a convenience to allow removal of multiple fields at 

415 once:: 

416 

417 form.remove('first_field', 

418 'second_field', 

419 'third_field') 

420 

421 It will remove each field from :attr:`fields`. 

422 """ 

423 for key in keys: 

424 if key in self.fields: 

425 self.fields.remove(key) 

426 

427 def set_node(self, key, nodeinfo, **kwargs): 

428 """ 

429 Set/override the node for a field. 

430 

431 :param key: Name of field. 

432 

433 :param nodeinfo: Should be either a 

434 :class:`colander:colander.SchemaNode` instance, or else a 

435 :class:`colander:colander.SchemaType` instance. 

436 

437 If ``nodeinfo`` is a proper node instance, it will be used 

438 as-is. Otherwise an 

439 :class:`~wuttaweb.forms.schema.ObjectNode` instance will be 

440 constructed using ``nodeinfo`` as the type (``typ``). 

441 

442 Node overrides are tracked via :attr:`nodes`. 

443 """ 

444 from wuttaweb.forms.schema import ObjectNode 

445 

446 if isinstance(nodeinfo, colander.SchemaNode): 

447 # assume nodeinfo is a complete node 

448 node = nodeinfo 

449 

450 else: # assume nodeinfo is a schema type 

451 kwargs.setdefault('name', key) 

452 node = ObjectNode(nodeinfo, **kwargs) 

453 

454 self.nodes[key] = node 

455 

456 # must explicitly replace node, if we already have a schema 

457 if self.schema: 

458 self.schema[key] = node 

459 

460 def set_widget(self, key, widget): 

461 """ 

462 Set/override the widget for a field. 

463 

464 :param key: Name of field. 

465 

466 :param widget: Instance of 

467 :class:`deform:deform.widget.Widget`. 

468 

469 Widget overrides are tracked via :attr:`widgets`. 

470 """ 

471 self.widgets[key] = widget 

472 

473 # update schema if necessary 

474 if self.schema and key in self.schema: 

475 self.schema[key].widget = widget 

476 

477 def set_validator(self, key, validator): 

478 """ 

479 Set/override the validator for a field, or the form. 

480 

481 :param key: Name of field. This may also be ``None`` in which 

482 case the validator will apply to the whole form instead of 

483 a field. 

484 

485 :param validator: Callable which accepts ``(node, value)`` 

486 args. For instance:: 

487 

488 def validate_foo(node, value): 

489 if value == 42: 

490 node.raise_invalid("42 is not allowed!") 

491 

492 form = Form(fields=['foo', 'bar']) 

493 

494 form.set_validator('foo', validate_foo) 

495 

496 Validator overrides are tracked via :attr:`validators`. 

497 """ 

498 self.validators[key] = validator 

499 

500 # nb. must apply to existing schema if present 

501 if self.schema and key in self.schema: 

502 self.schema[key].validator = validator 

503 

504 def set_default(self, key, value): 

505 """ 

506 Set/override the default value for a field. 

507 

508 :param key: Name of field. 

509 

510 :param validator: Default value for the field. 

511 

512 Default value overrides are tracked via :attr:`defaults`. 

513 """ 

514 self.defaults[key] = value 

515 

516 def set_readonly(self, key, readonly=True): 

517 """ 

518 Enable or disable the "readonly" flag for a given field. 

519 

520 When a field is marked readonly, it will be shown in the form 

521 but there will be no editable widget. The field is skipped 

522 over (not saved) when form is submitted. 

523 

524 See also :meth:`is_readonly()`; this is tracked via 

525 :attr:`readonly_fields`. 

526 

527 :param key: String key (fieldname) for the field. 

528 

529 :param readonly: New readonly flag for the field. 

530 """ 

531 if readonly: 

532 self.readonly_fields.add(key) 

533 else: 

534 if key in self.readonly_fields: 

535 self.readonly_fields.remove(key) 

536 

537 def is_readonly(self, key): 

538 """ 

539 Returns boolean indicating if the given field is marked as 

540 readonly. 

541 

542 See also :meth:`set_readonly()`. 

543 

544 :param key: Field key/name as string. 

545 """ 

546 if self.readonly_fields: 

547 if key in self.readonly_fields: 

548 return True 

549 return False 

550 

551 def set_required(self, key, required=True): 

552 """ 

553 Enable or disable the "required" flag for a given field. 

554 

555 When a field is marked required, a value must be provided 

556 or else it fails validation. 

557 

558 In practice if a field is "not required" then a default 

559 "empty" value is assumed, should the user not provide one. 

560 

561 See also :meth:`is_required()`; this is tracked via 

562 :attr:`required_fields`. 

563 

564 :param key: String key (fieldname) for the field. 

565 

566 :param required: New required flag for the field. Usually a 

567 boolean, but may also be ``None`` to remove any flag and 

568 revert to default behavior for the field. 

569 """ 

570 self.required_fields[key] = required 

571 

572 def is_required(self, key): 

573 """ 

574 Returns boolean indicating if the given field is marked as 

575 required. 

576 

577 See also :meth:`set_required()`. 

578 

579 :param key: Field key/name as string. 

580 

581 :returns: Value for the flag from :attr:`required_fields` if 

582 present; otherwise ``None``. 

583 """ 

584 return self.required_fields.get(key, None) 

585 

586 def set_label(self, key, label): 

587 """ 

588 Set the label for given field name. 

589 

590 See also :meth:`get_label()`. 

591 """ 

592 self.labels[key] = label 

593 

594 # update schema if necessary 

595 if self.schema and key in self.schema: 

596 self.schema[key].title = label 

597 

598 def get_label(self, key): 

599 """ 

600 Get the label for given field name. 

601 

602 Note that this will always return a string, auto-generating 

603 the label if needed. 

604 

605 See also :meth:`set_label()`. 

606 """ 

607 return self.labels.get(key, self.app.make_title(key)) 

608 

609 def get_fields(self): 

610 """ 

611 Returns the official list of field names for the form, or 

612 ``None``. 

613 

614 If :attr:`fields` is set and non-empty, it is returned. 

615 

616 Or, if :attr:`schema` is set, the field list is derived 

617 from that. 

618 

619 Or, if :attr:`model_class` is set, the field list is derived 

620 from that, via :meth:`get_model_fields()`. 

621 

622 Otherwise ``None`` is returned. 

623 """ 

624 if hasattr(self, 'fields') and self.fields: 

625 return self.fields 

626 

627 if self.schema: 

628 return [field.name for field in self.schema] 

629 

630 fields = self.get_model_fields() 

631 if fields: 

632 return fields 

633 

634 return [] 

635 

636 def get_model_fields(self, model_class=None): 

637 """ 

638 This method is a shortcut which calls 

639 :func:`~wuttaweb.util.get_model_fields()`. 

640 

641 :param model_class: Optional model class for which to return 

642 fields. If not set, the form's :attr:`model_class` is 

643 assumed. 

644 """ 

645 return get_model_fields(self.config, 

646 model_class=model_class or self.model_class) 

647 

648 def get_schema(self): 

649 """ 

650 Return the :class:`colander:colander.Schema` object for the 

651 form, generating it automatically if necessary. 

652 

653 Note that if :attr:`schema` is already set, that will be 

654 returned as-is. 

655 """ 

656 if not self.schema: 

657 

658 ############################## 

659 # create schema 

660 ############################## 

661 

662 # get fields 

663 fields = self.get_fields() 

664 if not fields: 

665 raise NotImplementedError 

666 

667 if self.model_class: 

668 

669 # collect list of field names and/or nodes 

670 includes = [] 

671 for key in fields: 

672 if key in self.nodes: 

673 includes.append(self.nodes[key]) 

674 else: 

675 includes.append(key) 

676 

677 # make initial schema with ColanderAlchemy magic 

678 schema = SQLAlchemySchemaNode(self.model_class, 

679 includes=includes) 

680 

681 # fill in the blanks if anything got missed 

682 for key in fields: 

683 if key not in schema: 

684 node = colander.SchemaNode(colander.String(), name=key) 

685 schema.add(node) 

686 

687 else: 

688 

689 # make basic schema 

690 schema = colander.Schema() 

691 for key in fields: 

692 node = None 

693 

694 # use node override if present 

695 if key in self.nodes: 

696 node = self.nodes[key] 

697 if not node: 

698 

699 # otherwise make simple string node 

700 node = colander.SchemaNode( 

701 colander.String(), 

702 name=key) 

703 

704 schema.add(node) 

705 

706 ############################## 

707 # customize schema 

708 ############################## 

709 

710 # apply widget overrides 

711 for key, widget in self.widgets.items(): 

712 if key in schema: 

713 schema[key].widget = widget 

714 

715 # apply validator overrides 

716 for key, validator in self.validators.items(): 

717 if key is None: 

718 # nb. this one is form-wide 

719 schema.validator = validator 

720 elif key in schema: # field-level 

721 schema[key].validator = validator 

722 

723 # apply default value overrides 

724 for key, value in self.defaults.items(): 

725 if key in schema: 

726 schema[key].default = value 

727 

728 # apply required flags 

729 for key, required in self.required_fields.items(): 

730 if key in schema: 

731 if required is False: 

732 schema[key].missing = colander.null 

733 

734 self.schema = schema 

735 

736 return self.schema 

737 

738 def get_deform(self): 

739 """ 

740 Return the :class:`deform:deform.Form` instance for the form, 

741 generating it automatically if necessary. 

742 """ 

743 if not hasattr(self, 'deform_form'): 

744 model = self.app.model 

745 schema = self.get_schema() 

746 kwargs = {} 

747 

748 if self.model_instance: 

749 

750 # TODO: i keep finding problems with this, not sure 

751 # what needs to happen. some forms will have a simple 

752 # dict for model_instance, others will have a proper 

753 # SQLAlchemy object. and in the latter case, it may 

754 # not be "wutta-native" but from another DB. 

755 

756 # so the problem is, how to detect whether we should 

757 # use the model_instance as-is or if we should convert 

758 # to a dict. some options include: 

759 

760 # - check if instance has dictify() method 

761 # i *think* this was tried and didn't work? but do not recall 

762 

763 # - check if is instance of model.Base 

764 # this is unreliable since model.Base is wutta-native 

765 

766 # - check if form has a model_class 

767 # has not been tried yet 

768 

769 # - check if schema is from colanderalchemy 

770 # this is what we are trying currently... 

771 

772 if isinstance(schema, SQLAlchemySchemaNode): 

773 kwargs['appstruct'] = schema.dictify(self.model_instance) 

774 else: 

775 kwargs['appstruct'] = self.model_instance 

776 

777 # create the Deform instance 

778 # nb. must give a reference back to wutta form; this is 

779 # for sake of field schema nodes and widgets, e.g. to 

780 # access the main model instance 

781 form = deform.Form(schema, **kwargs) 

782 form.wutta_form = self 

783 self.deform_form = form 

784 

785 return self.deform_form 

786 

787 def render_vue_tag(self, **kwargs): 

788 """ 

789 Render the Vue component tag for the form. 

790 

791 By default this simply returns: 

792 

793 .. code-block:: html 

794 

795 <wutta-form></wutta-form> 

796 

797 The actual output will depend on various form attributes, in 

798 particular :attr:`vue_tagname`. 

799 """ 

800 return HTML.tag(self.vue_tagname, **kwargs) 

801 

802 def render_vue_template( 

803 self, 

804 template='/forms/vue_template.mako', 

805 **context): 

806 """ 

807 Render the Vue template block for the form. 

808 

809 This returns something like: 

810 

811 .. code-block:: none 

812 

813 <script type="text/x-template" id="wutta-form-template"> 

814 <form> 

815 <!-- fields etc. --> 

816 </form> 

817 </script> 

818 

819 <script> 

820 WuttaFormData = {} 

821 WuttaForm = { 

822 template: 'wutta-form-template', 

823 } 

824 </script> 

825 

826 .. todo:: 

827 

828 Why can't Sphinx render the above code block as 'html' ? 

829 

830 It acts like it can't handle a ``<script>`` tag at all? 

831 

832 Actual output will of course depend on form attributes, i.e. 

833 :attr:`vue_tagname` and :attr:`fields` list etc. 

834 

835 :param template: Path to Mako template which is used to render 

836 the output. 

837 """ 

838 context['form'] = self 

839 context['dform'] = self.get_deform() 

840 context.setdefault('form_attrs', {}) 

841 context.setdefault('request', self.request) 

842 context['model_data'] = self.get_vue_model_data() 

843 

844 # auto disable button on submit 

845 if self.auto_disable_submit: 

846 context['form_attrs']['@submit'] = 'formSubmitting = true' 

847 

848 output = render(template, context) 

849 return HTML.literal(output) 

850 

851 def add_grid_vue_context(self, grid): 

852 """ """ 

853 if not grid.key: 

854 raise ValueError("grid must have a key!") 

855 

856 if grid.key in self.grid_vue_context: 

857 log.warning("grid data with key '%s' already registered, " 

858 "but will be replaced", grid.key) 

859 

860 self.grid_vue_context[grid.key] = grid.get_vue_context() 

861 

862 def render_vue_field( 

863 self, 

864 fieldname, 

865 readonly=None, 

866 **kwargs, 

867 ): 

868 """ 

869 Render the given field completely, i.e. ``<b-field>`` wrapper 

870 with label and containing a widget. 

871 

872 Actual output will depend on the field attributes etc. 

873 Typical output might look like: 

874 

875 .. code-block:: html 

876 

877 <b-field label="Foo" 

878 horizontal 

879 type="is-danger" 

880 message="something went wrong!"> 

881 <!-- widget element(s) --> 

882 </b-field> 

883 

884 .. warning:: 

885 

886 Any ``**kwargs`` received from caller are ignored by this 

887 method. For now they are allowed, for sake of backwawrd 

888 compatibility. This may change in the future. 

889 """ 

890 # readonly comes from: caller, field flag, or form flag 

891 if readonly is None: 

892 readonly = self.is_readonly(fieldname) 

893 if not readonly: 

894 readonly = self.readonly 

895 

896 # but also, fields not in deform/schema must be readonly 

897 dform = self.get_deform() 

898 if not readonly and fieldname not in dform: 

899 readonly = True 

900 

901 # render the field widget or whatever 

902 if fieldname in dform: 

903 

904 # render proper widget if field is in deform/schema 

905 field = dform[fieldname] 

906 kw = {} 

907 if readonly: 

908 kw['readonly'] = True 

909 html = field.serialize(**kw) 

910 

911 else: 

912 # render static text if field not in deform/schema 

913 # TODO: need to abstract this somehow 

914 if self.model_instance: 

915 html = str(self.model_instance[fieldname]) 

916 else: 

917 html = '' 

918 

919 # mark all that as safe 

920 html = HTML.literal(html) 

921 

922 # render field label 

923 label = self.get_label(fieldname) 

924 

925 # b-field attrs 

926 attrs = { 

927 ':horizontal': 'true', 

928 'label': label, 

929 } 

930 

931 # next we will build array of messages to display..some 

932 # fields always show a "helptext" msg, and some may have 

933 # validation errors.. 

934 field_type = None 

935 messages = [] 

936 

937 # show errors if present 

938 errors = self.get_field_errors(fieldname) 

939 if errors: 

940 field_type = 'is-danger' 

941 messages.extend(errors) 

942 

943 # ..okay now we can declare the field messages and type 

944 if field_type: 

945 attrs['type'] = field_type 

946 if messages: 

947 cls = 'is-size-7' 

948 if field_type == 'is-danger': 

949 cls += ' has-text-danger' 

950 messages = [HTML.tag('p', c=[msg], class_=cls) 

951 for msg in messages] 

952 slot = HTML.tag('slot', name='messages', c=messages) 

953 html = HTML.tag('div', c=[html, slot]) 

954 

955 return HTML.tag('b-field', c=[html], **attrs) 

956 

957 def render_vue_finalize(self): 

958 """ 

959 Render the Vue "finalize" script for the form. 

960 

961 By default this simply returns: 

962 

963 .. code-block:: html 

964 

965 <script> 

966 WuttaForm.data = function() { return WuttaFormData } 

967 Vue.component('wutta-form', WuttaForm) 

968 </script> 

969 

970 The actual output may depend on various form attributes, in 

971 particular :attr:`vue_tagname`. 

972 """ 

973 set_data = f"{self.vue_component}.data = function() {{ return {self.vue_component}Data }}" 

974 make_component = f"Vue.component('{self.vue_tagname}', {self.vue_component})" 

975 return HTML.tag('script', c=['\n', 

976 HTML.literal(set_data), 

977 '\n', 

978 HTML.literal(make_component), 

979 '\n']) 

980 

981 def get_vue_model_data(self): 

982 """ 

983 Returns a dict with form model data. Values may be nested 

984 depending on the types of fields contained in the form. 

985 

986 This collects the ``cstruct`` values for all fields which are 

987 present both in :attr:`fields` as well as the Deform schema. 

988 

989 It also converts each as needed, to ensure it is 

990 JSON-serializable. 

991 

992 :returns: Dict of field/value items. 

993 """ 

994 dform = self.get_deform() 

995 model_data = {} 

996 

997 def assign(field): 

998 value = field.cstruct 

999 

1000 # TODO: we need a proper true/false on the Vue side, 

1001 # but deform/colander want 'true' and 'false' ..so 

1002 # for now we explicitly translate here, ugh. also 

1003 # note this does not yet allow for null values.. :( 

1004 if isinstance(field.typ, colander.Boolean): 

1005 value = True if field.typ.true_val else False 

1006 

1007 model_data[field.oid] = make_json_safe(value) 

1008 

1009 for key in self.fields: 

1010 

1011 # TODO: i thought commented code was useful, but no longer sure? 

1012 

1013 # TODO: need to describe the scenario when this is true 

1014 if key not in dform: 

1015 # log.warning("field '%s' is missing from deform", key) 

1016 continue 

1017 

1018 field = dform[key] 

1019 

1020 # if hasattr(field, 'children'): 

1021 # for subfield in field.children: 

1022 # assign(subfield) 

1023 

1024 assign(field) 

1025 

1026 return model_data 

1027 

1028 # TODO: for tailbone compat, should document? 

1029 # (ideally should remove this and find a better way) 

1030 def get_vue_field_value(self, key): 

1031 """ """ 

1032 if key not in self.fields: 

1033 return 

1034 

1035 dform = self.get_deform() 

1036 if key not in dform: 

1037 return 

1038 

1039 field = dform[key] 

1040 return make_json_safe(field.cstruct) 

1041 

1042 def validate(self): 

1043 """ 

1044 Try to validate the form, using data from the :attr:`request`. 

1045 

1046 Uses :func:`~wuttaweb.util.get_form_data()` to retrieve the 

1047 form data from POST or JSON body. 

1048 

1049 If the form data is valid, the data dict is returned. This 

1050 data dict is also made available on the form object via the 

1051 :attr:`validated` attribute. 

1052 

1053 However if the data is not valid, ``False`` is returned, and 

1054 there will be no :attr:`validated` attribute. In that case 

1055 you should inspect the form errors to learn/display what went 

1056 wrong for the user's sake. See also 

1057 :meth:`get_field_errors()`. 

1058 

1059 This uses :meth:`deform:deform.Field.validate()` under the 

1060 hood. 

1061 

1062 .. warning:: 

1063 

1064 Calling ``validate()`` on some forms will cause the 

1065 underlying Deform and Colander structures to mutate. In 

1066 particular, all :attr:`readonly_fields` will be *removed* 

1067 from the :attr:`schema` to ensure they are not involved in 

1068 the validation. 

1069 

1070 :returns: Data dict, or ``False``. 

1071 """ 

1072 if hasattr(self, 'validated'): 

1073 del self.validated 

1074 

1075 if self.request.method != 'POST': 

1076 return False 

1077 

1078 # remove all readonly fields from deform / schema 

1079 dform = self.get_deform() 

1080 if self.readonly_fields: 

1081 schema = self.get_schema() 

1082 for field in self.readonly_fields: 

1083 if field in schema: 

1084 del schema[field] 

1085 dform.children.remove(dform[field]) 

1086 

1087 # let deform do real validation 

1088 controls = get_form_data(self.request).items() 

1089 try: 

1090 self.validated = dform.validate(controls) 

1091 except deform.ValidationFailure: 

1092 log.debug("form not valid: %s", dform.error) 

1093 return False 

1094 

1095 return self.validated 

1096 

1097 def get_field_errors(self, field): 

1098 """ 

1099 Return a list of error messages for the given field. 

1100 

1101 Not useful unless a call to :meth:`validate()` failed. 

1102 """ 

1103 dform = self.get_deform() 

1104 if field in dform: 

1105 field = dform[field] 

1106 if field.error: 

1107 return field.error.messages() 

1108 return []