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

326 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-01-15 08:54 -0600

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 sqlalchemy as sa 

31from sqlalchemy import orm 

32 

33import colander 

34import deform 

35from colanderalchemy import SQLAlchemySchemaNode 

36from pyramid.renderers import render 

37from webhelpers2.html import HTML 

38 

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

40 

41 

42log = logging.getLogger(__name__) 

43 

44 

45class Form: 

46 """ 

47 Base class for all forms. 

48 

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

50 

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

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

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

54 

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

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

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

58 

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

60 

61 .. note:: 

62 

63 Some parameters are not explicitly described above. However 

64 their corresponding attributes are described below. 

65 

66 Form instances contain the following attributes: 

67 

68 .. attribute:: request 

69 

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

71 

72 .. attribute:: fields 

73 

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

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

76 the same order as they are in this list. 

77 

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

79 

80 .. attribute:: schema 

81 

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

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

84 one automatically. 

85 

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

87 

88 .. attribute:: model_class 

89 

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

91 usually a SQLAlchemy mapped class. This (or 

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

93 :attr:`schema`. 

94 

95 .. attribute:: model_instance 

96 

97 Optional instance from which initial form data should be 

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

99 instance of :attr:`model_class`. 

100 

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

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

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

104 determined automatically.) 

105 

106 .. attribute:: nodes 

107 

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

109 :meth:`get_schema()`. 

110 

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

112 

113 .. attribute:: widgets 

114 

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

116 :meth:`get_schema()`. 

117 

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

119 

120 .. attribute:: validators 

121 

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

123 :meth:`get_schema()`. 

124 

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

126 

127 .. attribute:: defaults 

128 

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

130 :meth:`get_schema()`. 

131 

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

133 

134 .. attribute:: readonly 

135 

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

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

138 

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

140 will exist and submit is allowed. 

141 

142 .. attribute:: readonly_fields 

143 

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

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

146 widget. 

147 

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

149 

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

151 

152 .. attribute:: required_fields 

153 

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

155 values are boolean flags indicating whether the field is 

156 required. 

157 

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

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

160 of any "overrides" per field. 

161 

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

163 

164 .. attribute:: action_method 

165 

166 HTTP method to use when submitting form; ``'post'`` is default. 

167 

168 .. attribute:: action_url 

169 

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

171 

172 .. attribute:: reset_url 

173 

174 String URL to which the reset button should "always" redirect, 

175 if applicable. 

176 

177 This is null by default, in which case it will use standard 

178 browser behavior for the form reset button (if shown). See 

179 also :attr:`show_button_reset`. 

180 

181 .. attribute:: cancel_url 

182 

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

184 if applicable. 

185 

186 Code should not access this directly, but instead call 

187 :meth:`get_cancel_url()`. 

188 

189 .. attribute:: cancel_url_fallback 

190 

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

192 referrer cannot be determined from request. 

193 

194 Code should not access this directly, but instead call 

195 :meth:`get_cancel_url()`. 

196 

197 .. attribute:: vue_tagname 

198 

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

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

201 

202 See also :attr:`vue_component`. 

203 

204 .. attribute:: align_buttons_right 

205 

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

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

208 not set, the buttons are left-aligned. 

209 

210 .. attribute:: auto_disable_submit 

211 

212 Flag indicating whether the submit button should be 

213 auto-disabled, whenever the form is submitted. 

214 

215 .. attribute:: button_label_submit 

216 

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

218 

219 .. attribute:: button_icon_submit 

220 

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

222 

223 .. attribute:: button_type_submit 

224 

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

226 so for example: 

227 

228 .. code-block:: html 

229 

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

231 native-type="submit"> 

232 Save 

233 </b-button> 

234 

235 See also the `Buefy docs 

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

237 

238 .. attribute:: show_button_reset 

239 

240 Flag indicating whether a Reset button should be shown. 

241 Default is ``False``. 

242 

243 Unless there is a :attr:`reset_url`, the reset button will use 

244 standard behavior per the browser. 

245 

246 .. attribute:: show_button_cancel 

247 

248 Flag indicating whether a Cancel button should be shown. 

249 Default is ``True``. 

250 

251 .. attribute:: button_label_cancel 

252 

253 String label for the form cancel button. Default is 

254 ``"Cancel"``. 

255 

256 .. attribute:: auto_disable_cancel 

257 

258 Flag indicating whether the cancel button should be 

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

260 ``True``. 

261 

262 .. attribute:: validated 

263 

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

265 this will be set to the validated data dict. 

266 

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

268 """ 

269 

270 def __init__( 

271 self, 

272 request, 

273 fields=None, 

274 schema=None, 

275 model_class=None, 

276 model_instance=None, 

277 nodes={}, 

278 widgets={}, 

279 validators={}, 

280 defaults={}, 

281 readonly=False, 

282 readonly_fields=[], 

283 required_fields={}, 

284 labels={}, 

285 action_method='post', 

286 action_url=None, 

287 reset_url=None, 

288 cancel_url=None, 

289 cancel_url_fallback=None, 

290 vue_tagname='wutta-form', 

291 align_buttons_right=False, 

292 auto_disable_submit=True, 

293 button_label_submit="Save", 

294 button_icon_submit='save', 

295 button_type_submit='is-primary', 

296 show_button_reset=False, 

297 show_button_cancel=True, 

298 button_label_cancel="Cancel", 

299 auto_disable_cancel=True, 

300 ): 

301 self.request = request 

302 self.schema = schema 

303 self.nodes = nodes or {} 

304 self.widgets = widgets or {} 

305 self.validators = validators or {} 

306 self.defaults = defaults or {} 

307 self.readonly = readonly 

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

309 self.required_fields = required_fields or {} 

310 self.labels = labels or {} 

311 self.action_method = action_method 

312 self.action_url = action_url 

313 self.cancel_url = cancel_url 

314 self.cancel_url_fallback = cancel_url_fallback 

315 self.reset_url = reset_url 

316 self.vue_tagname = vue_tagname 

317 self.align_buttons_right = align_buttons_right 

318 self.auto_disable_submit = auto_disable_submit 

319 self.button_label_submit = button_label_submit 

320 self.button_icon_submit = button_icon_submit 

321 self.button_type_submit = button_type_submit 

322 self.show_button_reset = show_button_reset 

323 self.show_button_cancel = show_button_cancel 

324 self.button_label_cancel = button_label_cancel 

325 self.auto_disable_cancel = auto_disable_cancel 

326 

327 self.config = self.request.wutta_config 

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

329 

330 self.model_class = model_class 

331 self.model_instance = model_instance 

332 if self.model_instance and not self.model_class: 

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

334 self.model_class = type(self.model_instance) 

335 

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

337 self.set_default_widgets() 

338 

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

340 self.grid_vue_context = OrderedDict() 

341 

342 def __contains__(self, name): 

343 """ 

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

345 if the form contains a given field:: 

346 

347 myform = Form() 

348 if 'somefield' in myform: 

349 print("my form has some field") 

350 """ 

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

352 

353 def __iter__(self): 

354 """ 

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

356 

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

358 for fieldname in myform: 

359 print(fieldname) 

360 """ 

361 return iter(self.fields) 

362 

363 @property 

364 def vue_component(self): 

365 """ 

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

367 

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

369 """ 

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

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

372 

373 def get_cancel_url(self): 

374 """ 

375 Returns the URL for the Cancel button. 

376 

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

378 

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

380 returned. 

381 

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

383 returned. 

384 

385 As a last resort the "default" URL from 

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

387 returned. 

388 """ 

389 # use "permanent" URL if set 

390 if self.cancel_url: 

391 return self.cancel_url 

392 

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

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

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

396 if url and url != 'NOPE': 

397 return url 

398 

399 # use fallback URL if set 

400 if self.cancel_url_fallback: 

401 return self.cancel_url_fallback 

402 

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

404 return self.request.get_referrer() 

405 

406 def set_fields(self, fields): 

407 """ 

408 Explicitly set the list of form fields. 

409 

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

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

412 

413 :param fields: List of string field names. 

414 """ 

415 self.fields = FieldList(fields) 

416 

417 def append(self, *keys): 

418 """ 

419 Add some fields(s) to the form. 

420 

421 This is a convenience to allow adding multiple fields at 

422 once:: 

423 

424 form.append('first_field', 

425 'second_field', 

426 'third_field') 

427 

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

429 """ 

430 for key in keys: 

431 if key not in self.fields: 

432 self.fields.append(key) 

433 

434 def remove(self, *keys): 

435 """ 

436 Remove some fields(s) from the form. 

437 

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

439 once:: 

440 

441 form.remove('first_field', 

442 'second_field', 

443 'third_field') 

444 

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

446 """ 

447 for key in keys: 

448 if key in self.fields: 

449 self.fields.remove(key) 

450 

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

452 """ 

453 Set/override the node for a field. 

454 

455 :param key: Name of field. 

456 

457 :param nodeinfo: Should be either a 

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

459 :class:`colander:colander.SchemaType` instance. 

460 

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

462 as-is. Otherwise an 

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

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

465 

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

467 """ 

468 from wuttaweb.forms.schema import ObjectNode 

469 

470 if isinstance(nodeinfo, colander.SchemaNode): 

471 # assume nodeinfo is a complete node 

472 node = nodeinfo 

473 

474 else: # assume nodeinfo is a schema type 

475 kwargs.setdefault('name', key) 

476 node = ObjectNode(nodeinfo, **kwargs) 

477 

478 self.nodes[key] = node 

479 

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

481 if self.schema: 

482 self.schema[key] = node 

483 

484 def set_widget(self, key, widget, **kwargs): 

485 """ 

486 Set/override the widget for a field. 

487 

488 You can specify a widget instance or else a named "type" of 

489 widget, in which case that is passed along to 

490 :meth:`make_widget()`. 

491 

492 :param key: Name of field. 

493 

494 :param widget: Either a :class:`deform:deform.widget.Widget` 

495 instance, or else a widget "type" name. 

496 

497 :param \**kwargs: Any remaining kwargs are passed along to 

498 :meth:`make_widget()` - if applicable. 

499 

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

501 """ 

502 if not isinstance(widget, deform.widget.Widget): 

503 widget_obj = self.make_widget(widget, **kwargs) 

504 if not widget_obj: 

505 raise ValueError(f"widget type not supported: {widget}") 

506 widget = widget_obj 

507 

508 self.widgets[key] = widget 

509 

510 # update schema if necessary 

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

512 self.schema[key].widget = widget 

513 

514 def make_widget(self, widget_type, **kwargs): 

515 """ 

516 Make and return a new field widget of the given type. 

517 

518 This has built-in support for the following types (although 

519 subclass can override as needed): 

520 

521 * ``'notes'`` => :class:`~wuttaweb.forms.widgets.NotesWidget` 

522 

523 See also :meth:`set_widget()` which may call this method 

524 automatically. 

525 

526 :param widget_type: Which of the above (or custom) widget 

527 type to create. 

528 

529 :param \**kwargs: Remaining kwargs are passed as-is to the 

530 widget factory. 

531 

532 :returns: New widget instance, or ``None`` if e.g. it could 

533 not determine how to create the widget. 

534 """ 

535 from wuttaweb.forms import widgets 

536 

537 if widget_type == 'notes': 

538 return widgets.NotesWidget(**kwargs) 

539 

540 def set_default_widgets(self): 

541 """ 

542 Set default field widgets, where applicable. 

543 

544 This will add new entries to :attr:`widgets` for columns 

545 whose data type implies a default widget should be used. 

546 This is generally only possible if :attr:`model_class` is set 

547 to a valid SQLAlchemy mapped class. 

548 

549 This only checks for a couple of data types, with mapping as 

550 follows: 

551 

552 * :class:`sqlalchemy:sqlalchemy.types.Date` -> 

553 :class:`~wuttaweb.forms.widgets.WuttaDateWidget` 

554 * :class:`sqlalchemy:sqlalchemy.types.DateTime` -> 

555 :class:`~wuttaweb.forms.widgets.WuttaDateTimeWidget` 

556 """ 

557 from wuttaweb.forms import widgets 

558 

559 if not self.model_class: 

560 return 

561 

562 for key in self.fields: 

563 if key in self.widgets: 

564 continue 

565 

566 attr = getattr(self.model_class, key, None) 

567 if attr: 

568 prop = getattr(attr, 'prop', None) 

569 if prop and isinstance(prop, orm.ColumnProperty): 

570 column = prop.columns[0] 

571 if isinstance(column.type, sa.Date): 

572 self.set_widget(key, widgets.WuttaDateWidget(self.request)) 

573 elif isinstance(column.type, sa.DateTime): 

574 self.set_widget(key, widgets.WuttaDateTimeWidget(self.request)) 

575 

576 def set_grid(self, key, grid): 

577 """ 

578 Establish a :term:`grid` to be displayed for a field. This 

579 uses a :class:`~wuttaweb.forms.widgets.GridWidget` to wrap the 

580 rendered grid. 

581 

582 :param key: Name of field. 

583 

584 :param widget: :class:`~wuttaweb.grids.base.Grid` instance, 

585 pre-configured and (usually) with data. 

586 """ 

587 from wuttaweb.forms.widgets import GridWidget 

588 

589 widget = GridWidget(self.request, grid) 

590 self.set_widget(key, widget) 

591 self.add_grid_vue_context(grid) 

592 

593 def add_grid_vue_context(self, grid): 

594 """ """ 

595 if not grid.key: 

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

597 

598 if grid.key in self.grid_vue_context: 

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

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

601 

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

603 

604 def set_validator(self, key, validator): 

605 """ 

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

607 

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

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

610 a field. 

611 

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

613 args. For instance:: 

614 

615 def validate_foo(node, value): 

616 if value == 42: 

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

618 

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

620 

621 form.set_validator('foo', validate_foo) 

622 

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

624 """ 

625 self.validators[key] = validator 

626 

627 # nb. must apply to existing schema if present 

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

629 self.schema[key].validator = validator 

630 

631 def set_default(self, key, value): 

632 """ 

633 Set/override the default value for a field. 

634 

635 :param key: Name of field. 

636 

637 :param validator: Default value for the field. 

638 

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

640 """ 

641 self.defaults[key] = value 

642 

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

644 """ 

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

646 

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

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

649 over (not saved) when form is submitted. 

650 

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

652 :attr:`readonly_fields`. 

653 

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

655 

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

657 """ 

658 if readonly: 

659 self.readonly_fields.add(key) 

660 else: 

661 if key in self.readonly_fields: 

662 self.readonly_fields.remove(key) 

663 

664 def is_readonly(self, key): 

665 """ 

666 Returns boolean indicating if the given field is marked as 

667 readonly. 

668 

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

670 

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

672 """ 

673 if self.readonly_fields: 

674 if key in self.readonly_fields: 

675 return True 

676 return False 

677 

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

679 """ 

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

681 

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

683 or else it fails validation. 

684 

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

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

687 

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

689 :attr:`required_fields`. 

690 

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

692 

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

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

695 revert to default behavior for the field. 

696 """ 

697 self.required_fields[key] = required 

698 

699 def is_required(self, key): 

700 """ 

701 Returns boolean indicating if the given field is marked as 

702 required. 

703 

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

705 

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

707 

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

709 present; otherwise ``None``. 

710 """ 

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

712 

713 def set_label(self, key, label): 

714 """ 

715 Set the label for given field name. 

716 

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

718 """ 

719 self.labels[key] = label 

720 

721 # update schema if necessary 

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

723 self.schema[key].title = label 

724 

725 def get_label(self, key): 

726 """ 

727 Get the label for given field name. 

728 

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

730 the label if needed. 

731 

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

733 """ 

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

735 

736 def get_fields(self): 

737 """ 

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

739 ``None``. 

740 

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

742 

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

744 from that. 

745 

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

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

748 

749 Otherwise ``None`` is returned. 

750 """ 

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

752 return self.fields 

753 

754 if self.schema: 

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

756 

757 fields = self.get_model_fields() 

758 if fields: 

759 return fields 

760 

761 return [] 

762 

763 def get_model_fields(self, model_class=None): 

764 """ 

765 This method is a shortcut which calls 

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

767 

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

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

770 assumed. 

771 """ 

772 return get_model_fields(self.config, 

773 model_class=model_class or self.model_class) 

774 

775 def get_schema(self): 

776 """ 

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

778 form, generating it automatically if necessary. 

779 

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

781 returned as-is. 

782 """ 

783 if not self.schema: 

784 

785 ############################## 

786 # create schema 

787 ############################## 

788 

789 # get fields 

790 fields = self.get_fields() 

791 if not fields: 

792 raise NotImplementedError 

793 

794 if self.model_class: 

795 

796 # collect list of field names and/or nodes 

797 includes = [] 

798 for key in fields: 

799 if key in self.nodes: 

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

801 else: 

802 includes.append(key) 

803 

804 # make initial schema with ColanderAlchemy magic 

805 schema = SQLAlchemySchemaNode(self.model_class, 

806 includes=includes) 

807 

808 # fill in the blanks if anything got missed 

809 for key in fields: 

810 if key not in schema: 

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

812 schema.add(node) 

813 

814 else: 

815 

816 # make basic schema 

817 schema = colander.Schema() 

818 for key in fields: 

819 node = None 

820 

821 # use node override if present 

822 if key in self.nodes: 

823 node = self.nodes[key] 

824 if not node: 

825 

826 # otherwise make simple string node 

827 node = colander.SchemaNode( 

828 colander.String(), 

829 name=key) 

830 

831 schema.add(node) 

832 

833 ############################## 

834 # customize schema 

835 ############################## 

836 

837 # apply widget overrides 

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

839 if key in schema: 

840 schema[key].widget = widget 

841 

842 # apply validator overrides 

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

844 if key is None: 

845 # nb. this one is form-wide 

846 schema.validator = validator 

847 elif key in schema: # field-level 

848 schema[key].validator = validator 

849 

850 # apply default value overrides 

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

852 if key in schema: 

853 schema[key].default = value 

854 

855 # apply required flags 

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

857 if key in schema: 

858 if required is False: 

859 schema[key].missing = colander.null 

860 

861 self.schema = schema 

862 

863 return self.schema 

864 

865 def get_deform(self): 

866 """ 

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

868 generating it automatically if necessary. 

869 """ 

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

871 model = self.app.model 

872 schema = self.get_schema() 

873 kwargs = {} 

874 

875 if self.model_instance: 

876 

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

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

879 # dict for model_instance, others will have a proper 

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

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

882 

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

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

885 # to a dict. some options include: 

886 

887 # - check if instance has dictify() method 

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

889 

890 # - check if is instance of model.Base 

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

892 

893 # - check if form has a model_class 

894 # has not been tried yet 

895 

896 # - check if schema is from colanderalchemy 

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

898 

899 if isinstance(schema, SQLAlchemySchemaNode): 

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

901 else: 

902 kwargs['appstruct'] = self.model_instance 

903 

904 # create the Deform instance 

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

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

907 # access the main model instance 

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

909 form.wutta_form = self 

910 self.deform_form = form 

911 

912 return self.deform_form 

913 

914 def render_vue_tag(self, **kwargs): 

915 """ 

916 Render the Vue component tag for the form. 

917 

918 By default this simply returns: 

919 

920 .. code-block:: html 

921 

922 <wutta-form></wutta-form> 

923 

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

925 particular :attr:`vue_tagname`. 

926 """ 

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

928 

929 def render_vue_template( 

930 self, 

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

932 **context): 

933 """ 

934 Render the Vue template block for the form. 

935 

936 This returns something like: 

937 

938 .. code-block:: none 

939 

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

941 <form> 

942 <!-- fields etc. --> 

943 </form> 

944 </script> 

945 

946 <script> 

947 WuttaFormData = {} 

948 WuttaForm = { 

949 template: 'wutta-form-template', 

950 } 

951 </script> 

952 

953 .. todo:: 

954 

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

956 

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

958 

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

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

961 

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

963 the output. 

964 """ 

965 context['form'] = self 

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

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

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

969 

970 # set form method, enctype 

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

972 context['form_attrs'].setdefault('method', self.action_method) 

973 if self.action_method == 'post': 

974 context['form_attrs'].setdefault('enctype', 'multipart/form-data') 

975 

976 # auto disable button on submit 

977 if self.auto_disable_submit: 

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

979 

980 output = render(template, context) 

981 return HTML.literal(output) 

982 

983 def render_vue_field( 

984 self, 

985 fieldname, 

986 readonly=None, 

987 **kwargs, 

988 ): 

989 """ 

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

991 with label and containing a widget. 

992 

993 Actual output will depend on the field attributes etc. 

994 Typical output might look like: 

995 

996 .. code-block:: html 

997 

998 <b-field label="Foo" 

999 horizontal 

1000 type="is-danger" 

1001 message="something went wrong!"> 

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

1003 </b-field> 

1004 

1005 .. warning:: 

1006 

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

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

1009 compatibility. This may change in the future. 

1010 """ 

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

1012 if readonly is None: 

1013 readonly = self.is_readonly(fieldname) 

1014 if not readonly: 

1015 readonly = self.readonly 

1016 

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

1018 dform = self.get_deform() 

1019 if not readonly and fieldname not in dform: 

1020 readonly = True 

1021 

1022 # render the field widget or whatever 

1023 if fieldname in dform: 

1024 

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

1026 field = dform[fieldname] 

1027 kw = {} 

1028 if readonly: 

1029 kw['readonly'] = True 

1030 html = field.serialize(**kw) 

1031 

1032 else: 

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

1034 # TODO: need to abstract this somehow 

1035 if self.model_instance: 

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

1037 else: 

1038 html = '' 

1039 

1040 # mark all that as safe 

1041 html = HTML.literal(html) 

1042 

1043 # render field label 

1044 label = self.get_label(fieldname) 

1045 

1046 # b-field attrs 

1047 attrs = { 

1048 ':horizontal': 'true', 

1049 'label': label, 

1050 } 

1051 

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

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

1054 # validation errors.. 

1055 field_type = None 

1056 messages = [] 

1057 

1058 # show errors if present 

1059 errors = self.get_field_errors(fieldname) 

1060 if errors: 

1061 field_type = 'is-danger' 

1062 messages.extend(errors) 

1063 

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

1065 if field_type: 

1066 attrs['type'] = field_type 

1067 if messages: 

1068 cls = 'is-size-7' 

1069 if field_type == 'is-danger': 

1070 cls += ' has-text-danger' 

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

1072 for msg in messages] 

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

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

1075 

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

1077 

1078 def render_vue_finalize(self): 

1079 """ 

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

1081 

1082 By default this simply returns: 

1083 

1084 .. code-block:: html 

1085 

1086 <script> 

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

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

1089 </script> 

1090 

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

1092 particular :attr:`vue_tagname`. 

1093 """ 

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

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

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

1097 HTML.literal(set_data), 

1098 '\n', 

1099 HTML.literal(make_component), 

1100 '\n']) 

1101 

1102 def get_vue_model_data(self): 

1103 """ 

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

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

1106 

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

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

1109 

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

1111 JSON-serializable. 

1112 

1113 :returns: Dict of field/value items. 

1114 """ 

1115 dform = self.get_deform() 

1116 model_data = {} 

1117 

1118 def assign(field): 

1119 value = field.cstruct 

1120 

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

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

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

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

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

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

1127 

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

1129 

1130 for key in self.fields: 

1131 

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

1133 

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

1135 if key not in dform: 

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

1137 continue 

1138 

1139 field = dform[key] 

1140 

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

1142 # for subfield in field.children: 

1143 # assign(subfield) 

1144 

1145 assign(field) 

1146 

1147 return model_data 

1148 

1149 # TODO: for tailbone compat, should document? 

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

1151 def get_vue_field_value(self, key): 

1152 """ """ 

1153 if key not in self.fields: 

1154 return 

1155 

1156 dform = self.get_deform() 

1157 if key not in dform: 

1158 return 

1159 

1160 field = dform[key] 

1161 return make_json_safe(field.cstruct) 

1162 

1163 def validate(self): 

1164 """ 

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

1166 

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

1168 form data from POST or JSON body. 

1169 

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

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

1172 :attr:`validated` attribute. 

1173 

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

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

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

1177 wrong for the user's sake. See also 

1178 :meth:`get_field_errors()`. 

1179 

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

1181 hood. 

1182 

1183 .. warning:: 

1184 

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

1186 underlying Deform and Colander structures to mutate. In 

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

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

1189 the validation. 

1190 

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

1192 """ 

1193 if hasattr(self, 'validated'): 

1194 del self.validated 

1195 

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

1197 return False 

1198 

1199 # remove all readonly fields from deform / schema 

1200 dform = self.get_deform() 

1201 if self.readonly_fields: 

1202 schema = self.get_schema() 

1203 for field in self.readonly_fields: 

1204 if field in schema: 

1205 del schema[field] 

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

1207 

1208 # let deform do real validation 

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

1210 try: 

1211 self.validated = dform.validate(controls) 

1212 except deform.ValidationFailure: 

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

1214 return False 

1215 

1216 return self.validated 

1217 

1218 def has_global_errors(self): 

1219 """ 

1220 Convenience function to check if the form has any "global" 

1221 (not field-level) errors. 

1222 

1223 See also :meth:`get_global_errors()`. 

1224 

1225 :returns: ``True`` if global errors present, else ``False``. 

1226 """ 

1227 dform = self.get_deform() 

1228 return bool(dform.error) 

1229 

1230 def get_global_errors(self): 

1231 """ 

1232 Returns a list of "global" (not field-level) error messages 

1233 for the form. 

1234 

1235 See also :meth:`has_global_errors()`. 

1236 

1237 :returns: List of error messages (possibly empty). 

1238 """ 

1239 dform = self.get_deform() 

1240 if dform.error is None: 

1241 return [] 

1242 return dform.error.messages() 

1243 

1244 def get_field_errors(self, field): 

1245 """ 

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

1247 

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

1249 """ 

1250 dform = self.get_deform() 

1251 if field in dform: 

1252 field = dform[field] 

1253 if field.error: 

1254 return field.error.messages() 

1255 return []