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

356 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2025-12-28 15:23 -0600

1# -*- coding: utf-8; -*- 

2################################################################################ 

3# 

4# wuttaweb -- Web App for Wutta Framework 

5# Copyright © 2024-2025 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# pylint: disable=too-many-lines 

27 

28import logging 

29from collections import OrderedDict 

30 

31import sqlalchemy as sa 

32from sqlalchemy import orm 

33 

34import colander 

35import deform 

36from colanderalchemy import SQLAlchemySchemaNode 

37from pyramid.renderers import render 

38from webhelpers2.html import HTML 

39 

40from wuttaweb.util import ( 

41 FieldList, 

42 get_form_data, 

43 get_model_fields, 

44 make_json_safe, 

45 render_vue_finalize, 

46) 

47 

48 

49log = logging.getLogger(__name__) 

50 

51 

52class Form: # pylint: disable=too-many-instance-attributes,too-many-public-methods 

53 """ 

54 Base class for all forms. 

55 

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

57 

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

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

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

61 

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

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

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

65 

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

67 

68 .. note:: 

69 

70 Some parameters are not explicitly described above. However 

71 their corresponding attributes are described below. 

72 

73 Form instances contain the following attributes: 

74 

75 .. attribute:: request 

76 

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

78 

79 .. attribute:: fields 

80 

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

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

83 the same order as they are in this list. 

84 

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

86 

87 .. attribute:: schema 

88 

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

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

91 one automatically. 

92 

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

94 

95 .. attribute:: model_class 

96 

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

98 usually a SQLAlchemy mapped class. This (or 

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

100 :attr:`schema`. 

101 

102 .. attribute:: model_instance 

103 

104 Optional instance from which initial form data should be 

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

106 instance of :attr:`model_class`. 

107 

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

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

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

111 determined automatically.) 

112 

113 .. attribute:: nodes 

114 

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

116 :meth:`get_schema()`. 

117 

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

119 

120 .. attribute:: widgets 

121 

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

123 :meth:`get_schema()`. 

124 

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

126 

127 .. attribute:: validators 

128 

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

130 :meth:`get_schema()`. 

131 

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

133 

134 .. attribute:: defaults 

135 

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

137 :meth:`get_schema()`. 

138 

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

140 

141 .. attribute:: readonly 

142 

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

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

145 

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

147 will exist and submit is allowed. 

148 

149 .. attribute:: readonly_fields 

150 

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

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

153 widget. 

154 

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

156 

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

158 

159 .. attribute:: required_fields 

160 

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

162 values are boolean flags indicating whether the field is 

163 required. 

164 

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

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

167 of any "overrides" per field. 

168 

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

170 

171 .. attribute:: action_method 

172 

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

174 

175 .. attribute:: action_url 

176 

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

178 

179 .. attribute:: reset_url 

180 

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

182 if applicable. 

183 

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

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

186 also :attr:`show_button_reset`. 

187 

188 .. attribute:: cancel_url 

189 

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

191 if applicable. 

192 

193 Code should not access this directly, but instead call 

194 :meth:`get_cancel_url()`. 

195 

196 .. attribute:: cancel_url_fallback 

197 

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

199 referrer cannot be determined from request. 

200 

201 Code should not access this directly, but instead call 

202 :meth:`get_cancel_url()`. 

203 

204 .. attribute:: vue_tagname 

205 

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

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

208 

209 See also :attr:`vue_component`. 

210 

211 .. attribute:: align_buttons_right 

212 

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

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

215 not set, the buttons are left-aligned. 

216 

217 .. attribute:: auto_disable_submit 

218 

219 Flag indicating whether the submit button should be 

220 auto-disabled, whenever the form is submitted. 

221 

222 .. attribute:: button_label_submit 

223 

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

225 

226 .. attribute:: button_icon_submit 

227 

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

229 

230 .. attribute:: button_type_submit 

231 

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

233 so for example: 

234 

235 .. code-block:: html 

236 

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

238 native-type="submit"> 

239 Save 

240 </b-button> 

241 

242 See also the `Buefy docs 

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

244 

245 .. attribute:: show_button_reset 

246 

247 Flag indicating whether a Reset button should be shown. 

248 Default is ``False``. 

249 

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

251 standard behavior per the browser. 

252 

253 .. attribute:: show_button_cancel 

254 

255 Flag indicating whether a Cancel button should be shown. 

256 Default is ``True``. 

257 

258 .. attribute:: button_label_cancel 

259 

260 String label for the form cancel button. Default is 

261 ``"Cancel"``. 

262 

263 .. attribute:: auto_disable_cancel 

264 

265 Flag indicating whether the cancel button should be 

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

267 ``True``. 

268 

269 .. attribute:: validated 

270 

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

272 this will be set to the validated data dict. 

273 """ 

274 

275 deform_form = None 

276 validated = None 

277 

278 vue_template = "/forms/vue_template.mako" 

279 fields_template = "/forms/vue_fields.mako" 

280 buttons_template = "/forms/vue_buttons.mako" 

281 

282 def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals 

283 self, 

284 request, 

285 fields=None, 

286 schema=None, 

287 model_class=None, 

288 model_instance=None, 

289 nodes=None, 

290 widgets=None, 

291 validators=None, 

292 defaults=None, 

293 readonly=False, 

294 readonly_fields=None, 

295 required_fields=None, 

296 labels=None, 

297 action_method="post", 

298 action_url=None, 

299 reset_url=None, 

300 cancel_url=None, 

301 cancel_url_fallback=None, 

302 vue_tagname="wutta-form", 

303 align_buttons_right=False, 

304 auto_disable_submit=True, 

305 button_label_submit="Save", 

306 button_icon_submit="save", 

307 button_type_submit="is-primary", 

308 show_button_reset=False, 

309 show_button_cancel=True, 

310 button_label_cancel="Cancel", 

311 auto_disable_cancel=True, 

312 ): 

313 self.request = request 

314 self.schema = schema 

315 self.nodes = nodes or {} 

316 self.widgets = widgets or {} 

317 self.validators = validators or {} 

318 self.defaults = defaults or {} 

319 self.readonly = readonly 

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

321 self.required_fields = required_fields or {} 

322 self.labels = labels or {} 

323 self.action_method = action_method 

324 self.action_url = action_url 

325 self.cancel_url = cancel_url 

326 self.cancel_url_fallback = cancel_url_fallback 

327 self.reset_url = reset_url 

328 self.vue_tagname = vue_tagname 

329 self.align_buttons_right = align_buttons_right 

330 self.auto_disable_submit = auto_disable_submit 

331 self.button_label_submit = button_label_submit 

332 self.button_icon_submit = button_icon_submit 

333 self.button_type_submit = button_type_submit 

334 self.show_button_reset = show_button_reset 

335 self.show_button_cancel = show_button_cancel 

336 self.button_label_cancel = button_label_cancel 

337 self.auto_disable_cancel = auto_disable_cancel 

338 self.form_attrs = {} 

339 

340 self.config = self.request.wutta_config 

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

342 

343 self.model_class = model_class 

344 self.model_instance = model_instance 

345 if self.model_instance and not self.model_class: 

346 if not isinstance(self.model_instance, dict): 

347 self.model_class = type(self.model_instance) 

348 

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

350 self.set_default_widgets() 

351 

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

353 self.grid_vue_context = OrderedDict() 

354 

355 def __contains__(self, name): 

356 """ 

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

358 if the form contains a given field:: 

359 

360 myform = Form() 

361 if 'somefield' in myform: 

362 print("my form has some field") 

363 """ 

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

365 

366 def __iter__(self): 

367 """ 

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

369 

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

371 for fieldname in myform: 

372 print(fieldname) 

373 """ 

374 return iter(self.fields) 

375 

376 @property 

377 def vue_component(self): 

378 """ 

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

380 

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

382 """ 

383 words = self.vue_tagname.split("-") 

384 return "".join([word.capitalize() for word in words]) 

385 

386 def get_cancel_url(self): 

387 """ 

388 Returns the URL for the Cancel button. 

389 

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

391 

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

393 returned. 

394 

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

396 returned. 

397 

398 As a last resort the "default" URL from 

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

400 returned. 

401 """ 

402 # use "permanent" URL if set 

403 if self.cancel_url: 

404 return self.cancel_url 

405 

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

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

408 url = self.request.get_referrer(default="NOPE") 

409 if url and url != "NOPE": 

410 return url 

411 

412 # use fallback URL if set 

413 if self.cancel_url_fallback: 

414 return self.cancel_url_fallback 

415 

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

417 return self.request.get_referrer() 

418 

419 def set_fields(self, fields): 

420 """ 

421 Explicitly set the list of form fields. 

422 

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

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

425 

426 :param fields: List of string field names. 

427 """ 

428 self.fields = FieldList(fields) 

429 

430 def append(self, *keys): 

431 """ 

432 Add some fields(s) to the form. 

433 

434 This is a convenience to allow adding multiple fields at 

435 once:: 

436 

437 form.append('first_field', 

438 'second_field', 

439 'third_field') 

440 

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

442 """ 

443 for key in keys: 

444 if key not in self.fields: 

445 self.fields.append(key) 

446 

447 def remove(self, *keys): 

448 """ 

449 Remove some fields(s) from the form. 

450 

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

452 once:: 

453 

454 form.remove('first_field', 

455 'second_field', 

456 'third_field') 

457 

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

459 """ 

460 for key in keys: 

461 if key in self.fields: 

462 self.fields.remove(key) 

463 

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

465 """ 

466 Set/override the node for a field. 

467 

468 :param key: Name of field. 

469 

470 :param nodeinfo: Should be either a 

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

472 :class:`colander:colander.SchemaType` instance. 

473 

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

475 as-is. Otherwise an 

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

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

478 

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

480 """ 

481 from wuttaweb.forms.schema import ( # pylint: disable=import-outside-toplevel 

482 ObjectNode, 

483 ) 

484 

485 if isinstance(nodeinfo, colander.SchemaNode): 

486 # assume nodeinfo is a complete node 

487 node = nodeinfo 

488 

489 else: # assume nodeinfo is a schema type 

490 kwargs.setdefault("name", key) 

491 node = ObjectNode(nodeinfo, **kwargs) 

492 

493 self.nodes[key] = node 

494 

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

496 if self.schema: 

497 self.schema[key] = node 

498 

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

500 """ 

501 Set/override the widget for a field. 

502 

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

504 widget, in which case that is passed along to 

505 :meth:`make_widget()`. 

506 

507 :param key: Name of field. 

508 

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

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

511 

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

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

514 

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

516 """ 

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

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

519 if not widget_obj: 

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

521 widget = widget_obj 

522 

523 self.widgets[key] = widget 

524 

525 # update schema if necessary 

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

527 self.schema[key].widget = widget 

528 

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

530 """ 

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

532 

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

534 subclass can override as needed): 

535 

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

537 

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

539 automatically. 

540 

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

542 type to create. 

543 

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

545 widget factory. 

546 

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

548 not determine how to create the widget. 

549 """ 

550 from wuttaweb.forms import widgets # pylint: disable=import-outside-toplevel 

551 

552 if widget_type == "notes": 

553 return widgets.NotesWidget(**kwargs) 

554 

555 return None 

556 

557 def set_default_widgets(self): 

558 """ 

559 Set default field widgets, where applicable. 

560 

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

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

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

564 to a valid SQLAlchemy mapped class. 

565 

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

567 follows: 

568 

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

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

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

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

573 """ 

574 from wuttaweb.forms import widgets # pylint: disable=import-outside-toplevel 

575 

576 if not self.model_class: 

577 return 

578 

579 for key in self.fields: 

580 if key in self.widgets: 

581 continue 

582 

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

584 if attr: 

585 prop = getattr(attr, "prop", None) 

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

587 column = prop.columns[0] 

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

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

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

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

592 

593 def set_grid(self, key, grid): 

594 """ 

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

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

597 rendered grid. 

598 

599 :param key: Name of field. 

600 

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

602 pre-configured and (usually) with data. 

603 """ 

604 from wuttaweb.forms.widgets import ( # pylint: disable=import-outside-toplevel 

605 GridWidget, 

606 ) 

607 

608 widget = GridWidget(self.request, grid) 

609 self.set_widget(key, widget) 

610 self.add_grid_vue_context(grid) 

611 

612 def add_grid_vue_context(self, grid): # pylint: disable=empty-docstring 

613 """ """ 

614 if not grid.key: 

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

616 

617 if grid.key in self.grid_vue_context: 

618 log.warning( 

619 "grid data with key '%s' already registered, but will be replaced", 

620 grid.key, 

621 ) 

622 

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

624 

625 def set_validator(self, key, validator): 

626 """ 

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

628 

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

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

631 a field. 

632 

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

634 args. For instance:: 

635 

636 def validate_foo(node, value): 

637 if value == 42: 

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

639 

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

641 

642 form.set_validator('foo', validate_foo) 

643 

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

645 """ 

646 self.validators[key] = validator 

647 

648 # nb. must apply to existing schema if present 

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

650 self.schema[key].validator = validator 

651 

652 def set_default(self, key, value): 

653 """ 

654 Set/override the default value for a field. 

655 

656 :param key: Name of field. 

657 

658 :param validator: Default value for the field. 

659 

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

661 """ 

662 self.defaults[key] = value 

663 

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

665 """ 

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

667 

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

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

670 over (not saved) when form is submitted. 

671 

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

673 :attr:`readonly_fields`. 

674 

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

676 

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

678 """ 

679 if readonly: 

680 self.readonly_fields.add(key) 

681 else: 

682 if key in self.readonly_fields: 

683 self.readonly_fields.remove(key) 

684 

685 def is_readonly(self, key): 

686 """ 

687 Returns boolean indicating if the given field is marked as 

688 readonly. 

689 

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

691 

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

693 """ 

694 if self.readonly_fields: 

695 if key in self.readonly_fields: 

696 return True 

697 return False 

698 

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

700 """ 

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

702 

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

704 or else it fails validation. 

705 

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

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

708 

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

710 :attr:`required_fields`. 

711 

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

713 

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

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

716 revert to default behavior for the field. 

717 """ 

718 self.required_fields[key] = required 

719 

720 def is_required(self, key): 

721 """ 

722 Returns boolean indicating if the given field is marked as 

723 required. 

724 

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

726 

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

728 

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

730 present; otherwise ``None``. 

731 """ 

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

733 

734 def set_label(self, key, label): 

735 """ 

736 Set the label for given field name. 

737 

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

739 """ 

740 self.labels[key] = label 

741 

742 # update schema if necessary 

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

744 self.schema[key].title = label 

745 

746 def get_label(self, key): 

747 """ 

748 Get the label for given field name. 

749 

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

751 the label if needed. 

752 

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

754 """ 

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

756 

757 def get_fields(self): 

758 """ 

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

760 ``None``. 

761 

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

763 

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

765 from that. 

766 

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

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

769 

770 Otherwise ``None`` is returned. 

771 """ 

772 if hasattr(self, "fields") and self.fields: 

773 return self.fields 

774 

775 if self.schema: 

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

777 

778 fields = self.get_model_fields() 

779 if fields: 

780 return fields 

781 

782 return [] 

783 

784 def get_model_fields(self, model_class=None): 

785 """ 

786 This method is a shortcut which calls 

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

788 

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

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

791 assumed. 

792 """ 

793 return get_model_fields( 

794 self.config, model_class=model_class or self.model_class 

795 ) 

796 

797 def get_schema(self): # pylint: disable=too-many-branches 

798 """ 

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

800 form, generating it automatically if necessary. 

801 

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

803 returned as-is. 

804 """ 

805 if not self.schema: 

806 

807 ############################## 

808 # create schema 

809 ############################## 

810 

811 # get fields 

812 fields = self.get_fields() 

813 if not fields: 

814 raise ValueError( 

815 "could not determine fields list; " 

816 "please set model_class or fields explicitly" 

817 ) 

818 

819 if self.model_class: 

820 

821 # collect list of field names and/or nodes 

822 includes = [] 

823 for key in fields: 

824 if key in self.nodes: 

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

826 else: 

827 includes.append(key) 

828 

829 # make initial schema with ColanderAlchemy magic 

830 schema = SQLAlchemySchemaNode(self.model_class, includes=includes) 

831 

832 # fill in the blanks if anything got missed 

833 for key in fields: 

834 if key not in schema: 

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

836 schema.add(node) 

837 

838 else: 

839 

840 # make basic schema 

841 schema = colander.Schema() 

842 for key in fields: 

843 node = None 

844 

845 # use node override if present 

846 if key in self.nodes: 

847 node = self.nodes[key] 

848 if not node: 

849 

850 # otherwise make simple string node 

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

852 

853 schema.add(node) 

854 

855 ############################## 

856 # customize schema 

857 ############################## 

858 

859 # apply widget overrides 

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

861 if key in schema: 

862 schema[key].widget = widget 

863 

864 # apply validator overrides 

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

866 if key is None: 

867 # nb. this one is form-wide 

868 schema.validator = validator 

869 elif key in schema: # field-level 

870 schema[key].validator = validator 

871 

872 # apply default value overrides 

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

874 if key in schema: 

875 schema[key].default = value 

876 

877 # apply required flags 

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

879 if key in schema: 

880 if required is False: 

881 schema[key].missing = colander.null 

882 

883 self.schema = schema 

884 

885 return self.schema 

886 

887 def get_deform(self): 

888 """ 

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

890 generating it automatically if necessary. 

891 """ 

892 if not self.deform_form: 

893 schema = self.get_schema() 

894 kwargs = {} 

895 

896 if self.model_instance: 

897 

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

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

900 # dict for model_instance, others will have a proper 

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

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

903 

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

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

906 # to a dict. some options include: 

907 

908 # - check if instance has dictify() method 

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

910 

911 # - check if is instance of model.Base 

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

913 

914 # - check if form has a model_class 

915 # has not been tried yet 

916 

917 # - check if schema is from colanderalchemy 

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

919 

920 if isinstance(schema, SQLAlchemySchemaNode): 

921 kwargs["appstruct"] = schema.dictify(self.model_instance) 

922 else: 

923 kwargs["appstruct"] = self.model_instance 

924 

925 # create the Deform instance 

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

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

928 # access the main model instance 

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

930 form.wutta_form = self 

931 self.deform_form = form 

932 

933 return self.deform_form 

934 

935 def render_vue_tag(self, **kwargs): 

936 """ 

937 Render the Vue component tag for the form. 

938 

939 By default this simply returns: 

940 

941 .. code-block:: html 

942 

943 <wutta-form></wutta-form> 

944 

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

946 particular :attr:`vue_tagname`. 

947 """ 

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

949 

950 def render_vue_template(self, template=None, **context): 

951 """ 

952 Render the Vue template block for the form. 

953 

954 This returns something like: 

955 

956 .. code-block:: none 

957 

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

959 <form> 

960 <!-- fields etc. --> 

961 </form> 

962 </script> 

963 

964 <script> 

965 const WuttaFormData = {} 

966 const WuttaForm = { 

967 template: 'wutta-form-template', 

968 } 

969 </script> 

970 

971 .. todo:: 

972 

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

974 

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

976 

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

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

979 

980 Default logic will also invoke (indirectly): 

981 

982 * :meth:`render_vue_fields()` 

983 * :meth:`render_vue_buttons()` 

984 

985 :param template: Optional template path to override the class 

986 default. 

987 

988 :returns: HTML literal 

989 """ 

990 context = self.get_vue_context(**context) 

991 html = render(template or self.vue_template, context) 

992 return HTML.literal(html) 

993 

994 def get_vue_context(self, **context): # pylint: disable=missing-function-docstring 

995 context["form"] = self 

996 context["dform"] = self.get_deform() 

997 context.setdefault("request", self.request) 

998 context["model_data"] = self.get_vue_model_data() 

999 

1000 # set form method, enctype 

1001 form_attrs = context.setdefault("form_attrs", dict(self.form_attrs)) 

1002 form_attrs.setdefault("method", self.action_method) 

1003 if self.action_method == "post": 

1004 form_attrs.setdefault("enctype", "multipart/form-data") 

1005 

1006 # auto disable button on submit 

1007 if self.auto_disable_submit: 

1008 form_attrs["@submit"] = "formSubmitting = true" 

1009 

1010 # duplicate entire context for sake of fields/buttons template 

1011 context["form_context"] = context 

1012 

1013 return context 

1014 

1015 def render_vue_fields(self, context, template=None, **kwargs): 

1016 """ 

1017 Render the fields section within the form template. 

1018 

1019 This is normally invoked from within the form's 

1020 ``vue_template`` like this: 

1021 

1022 .. code-block:: none 

1023 

1024 ${form.render_vue_fields(form_context)} 

1025 

1026 There is a default ``fields_template`` but that is only the 

1027 last resort. Logic will first look for a 

1028 ``form_vue_fields()`` def within the *main template* being 

1029 rendered for the page. 

1030 

1031 An example will surely help: 

1032 

1033 .. code-block:: mako 

1034 

1035 <%inherit file="/master/edit.mako" /> 

1036 

1037 <%def name="form_vue_fields()"> 

1038 

1039 <p>this is my custom fields section:</p> 

1040 

1041 ${form.render_vue_field("myfield")} 

1042 

1043 </%def> 

1044 

1045 This keeps the custom fields section within the main page 

1046 template as opposed to yet another file. But if your page 

1047 template has no ``form_vue_fields()`` def, then the class 

1048 default template is used. (Unless the ``template`` param 

1049 is specified.) 

1050 

1051 See also :meth:`render_vue_template()` and 

1052 :meth:`render_vue_buttons()`. 

1053 

1054 :param context: This must be the original context as provided 

1055 to the form's ``vue_template``. See example above. 

1056 

1057 :param template: Optional template path to use instead of the 

1058 defaults described above. 

1059 

1060 :returns: HTML literal 

1061 """ 

1062 context.update(kwargs) 

1063 html = False 

1064 

1065 if not template: 

1066 

1067 if main_template := context.get("main_template"): 

1068 try: 

1069 vue_fields = main_template.get_def("form_vue_fields") 

1070 except AttributeError: 

1071 pass 

1072 else: 

1073 html = vue_fields.render(**context) 

1074 

1075 if html is False: 

1076 template = self.fields_template 

1077 

1078 if html is False: 

1079 html = render(template, context) 

1080 

1081 return HTML.literal(html) 

1082 

1083 def render_vue_field( # pylint: disable=unused-argument,too-many-locals 

1084 self, 

1085 fieldname, 

1086 readonly=None, 

1087 label=True, 

1088 horizontal=True, 

1089 **kwargs, 

1090 ): 

1091 """ 

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

1093 with label and a widget, with validation errors flagged as 

1094 needed. 

1095 

1096 Actual output will depend on the field attributes etc. 

1097 Typical output might look like: 

1098 

1099 .. code-block:: html 

1100 

1101 <b-field label="Foo" 

1102 horizontal 

1103 type="is-danger" 

1104 message="something went wrong!"> 

1105 <b-input name="foo" 

1106 v-model="${form.get_field_vmodel('foo')}" /> 

1107 </b-field> 

1108 

1109 :param fieldname: Name of field to render. 

1110 

1111 :param readonly: Optional override for readonly flag. 

1112 

1113 :param label: Whether to include/set the field label. 

1114 

1115 :param horizontal: Boolean value for the ``horizontal`` flag 

1116 on the field. 

1117 

1118 :param \\**kwargs: Remaining kwargs are passed to widget's 

1119 ``serialize()`` method. 

1120 

1121 :returns: HTML literal 

1122 """ 

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

1124 if readonly is None: 

1125 readonly = self.is_readonly(fieldname) 

1126 if not readonly: 

1127 readonly = self.readonly 

1128 

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

1130 dform = self.get_deform() 

1131 if not readonly and fieldname not in dform: 

1132 readonly = True 

1133 

1134 # render the field widget or whatever 

1135 if fieldname in dform: 

1136 

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

1138 field = dform[fieldname] 

1139 if readonly: 

1140 kwargs["readonly"] = True 

1141 html = field.serialize(**kwargs) 

1142 

1143 else: 

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

1145 # TODO: need to abstract this somehow 

1146 if self.model_instance: 

1147 value = self.model_instance[fieldname] 

1148 html = str(value) if value is not None else "" 

1149 else: 

1150 html = "" 

1151 

1152 # mark all that as safe 

1153 html = HTML.literal(html or "&nbsp;") 

1154 

1155 # render field label 

1156 if label: 

1157 label = self.get_label(fieldname) 

1158 

1159 # b-field attrs 

1160 attrs = { 

1161 ":horizontal": "true" if horizontal else "false", 

1162 "label": label or "", 

1163 } 

1164 

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

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

1167 # validation errors.. 

1168 field_type = None 

1169 messages = [] 

1170 

1171 # show errors if present 

1172 errors = self.get_field_errors(fieldname) 

1173 if errors: 

1174 field_type = "is-danger" 

1175 messages.extend(errors) 

1176 

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

1178 if field_type: 

1179 attrs["type"] = field_type 

1180 if messages: 

1181 cls = "is-size-7" 

1182 if field_type == "is-danger": 

1183 cls += " has-text-danger" 

1184 messages = [HTML.tag("p", c=[msg], class_=cls) for msg in messages] 

1185 slot = HTML.tag("slot", name="messages", c=messages) 

1186 html = HTML.tag("div", c=[html, slot]) 

1187 

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

1189 

1190 def render_vue_buttons(self, context, template=None, **kwargs): 

1191 """ 

1192 Render the buttons section within the form template. 

1193 

1194 This is normally invoked from within the form's 

1195 ``vue_template`` like this: 

1196 

1197 .. code-block:: none 

1198 

1199 ${form.render_vue_buttons(form_context)} 

1200 

1201 .. note:: 

1202 

1203 This method does not yet inspect the main page template, 

1204 unlike :meth:`render_vue_fields()`. 

1205 

1206 See also :meth:`render_vue_template()`. 

1207 

1208 :param context: This must be the original context as provided 

1209 to the form's ``vue_template``. See example above. 

1210 

1211 :param template: Optional template path to override the class 

1212 default. 

1213 

1214 :returns: HTML literal 

1215 """ 

1216 context.update(kwargs) 

1217 html = render(template or self.buttons_template, context) 

1218 return HTML.literal(html) 

1219 

1220 def render_vue_finalize(self): 

1221 """ 

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

1223 

1224 By default this simply returns: 

1225 

1226 .. code-block:: html 

1227 

1228 <script> 

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

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

1231 </script> 

1232 

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

1234 particular :attr:`vue_tagname`. 

1235 """ 

1236 return render_vue_finalize(self.vue_tagname, self.vue_component) 

1237 

1238 def get_field_vmodel(self, field): 

1239 """ 

1240 Convenience to return the ``v-model`` data reference for the 

1241 given field. For instance: 

1242 

1243 .. code-block:: none 

1244 

1245 <b-input name="myfield" 

1246 v-model="${form.get_field_vmodel('myfield')}" /> 

1247 

1248 <div v-show="${form.get_field_vmodel('myfield')} == 'easter'"> 

1249 easter egg! 

1250 </div> 

1251 

1252 :returns: JS-valid string referencing the field value 

1253 """ 

1254 dform = self.get_deform() 

1255 return f"modelData.{dform[field].oid}" 

1256 

1257 def get_vue_model_data(self): 

1258 """ 

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

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

1261 

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

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

1264 

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

1266 JSON-serializable. 

1267 

1268 :returns: Dict of field/value items. 

1269 """ 

1270 dform = self.get_deform() 

1271 model_data = {} 

1272 

1273 def assign(field): 

1274 value = field.cstruct 

1275 

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

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

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

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

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

1281 value = value == field.typ.true_val 

1282 

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

1284 

1285 for key in self.fields: 

1286 

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

1288 

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

1290 if key not in dform: 

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

1292 continue 

1293 

1294 field = dform[key] 

1295 

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

1297 # for subfield in field.children: 

1298 # assign(subfield) 

1299 

1300 assign(field) 

1301 

1302 return model_data 

1303 

1304 # TODO: for tailbone compat, should document? 

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

1306 def get_vue_field_value(self, key): # pylint: disable=empty-docstring 

1307 """ """ 

1308 if key not in self.fields: 

1309 return None 

1310 

1311 dform = self.get_deform() 

1312 if key not in dform: 

1313 return None 

1314 

1315 field = dform[key] 

1316 return make_json_safe(field.cstruct) 

1317 

1318 def validate(self): 

1319 """ 

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

1321 

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

1323 form data from POST or JSON body. 

1324 

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

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

1327 :attr:`validated` attribute. 

1328 

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

1330 the :attr:`validated` attribute will be ``None``. In that 

1331 case you should inspect the form errors to learn/display what 

1332 went wrong for the user's sake. See also 

1333 :meth:`get_field_errors()`. 

1334 

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

1336 hood. 

1337 

1338 .. warning:: 

1339 

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

1341 underlying Deform and Colander structures to mutate. In 

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

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

1344 the validation. 

1345 

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

1347 """ 

1348 self.validated = None 

1349 

1350 if self.request.method != "POST": 

1351 return False 

1352 

1353 # remove all readonly fields from deform / schema 

1354 dform = self.get_deform() 

1355 if self.readonly_fields: 

1356 schema = self.get_schema() 

1357 for field in self.readonly_fields: 

1358 if field in schema: 

1359 del schema[field] 

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

1361 

1362 # let deform do real validation 

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

1364 try: 

1365 self.validated = dform.validate(controls) 

1366 except deform.ValidationFailure: 

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

1368 return False 

1369 

1370 return self.validated 

1371 

1372 def has_global_errors(self): 

1373 """ 

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

1375 (not field-level) errors. 

1376 

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

1378 

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

1380 """ 

1381 dform = self.get_deform() 

1382 return bool(dform.error) 

1383 

1384 def get_global_errors(self): 

1385 """ 

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

1387 for the form. 

1388 

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

1390 

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

1392 """ 

1393 dform = self.get_deform() 

1394 if dform.error is None: 

1395 return [] 

1396 return dform.error.messages() 

1397 

1398 def get_field_errors(self, field): 

1399 """ 

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

1401 

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

1403 """ 

1404 dform = self.get_deform() 

1405 if field in dform: 

1406 field = dform[field] 

1407 if field.error: 

1408 return field.error.messages() 

1409 return []