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

448 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-24 16:36 -0500

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

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

3# 

4# wuttaweb -- Web App for Wutta Framework 

5# Copyright © 2024-2026 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 

27# pylint: disable=too-many-lines 

28 

29import logging 

30from collections import OrderedDict 

31 

32import sqlalchemy as sa 

33from sqlalchemy import orm 

34 

35import colander 

36import deform 

37from colanderalchemy import SQLAlchemySchemaNode 

38from pyramid.renderers import render 

39from webhelpers2.html import HTML 

40 

41from wuttaweb.util import ( 

42 FieldList, 

43 get_form_data, 

44 get_model_fields, 

45 make_json_safe, 

46 render_vue_finalize, 

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:: autofocus 

172 

173 Dict of "autofocus" field flags. Keys are field names and 

174 values should be boolean, where ``True`` means the field widget 

175 should be rendered with the ``autofocus`` HTML attribute. 

176 

177 See also :meth:`set_autofocus()`. 

178 

179 .. attribute:: action_method 

180 

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

182 

183 .. attribute:: action_url 

184 

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

186 

187 .. attribute:: reset_url 

188 

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

190 if applicable. 

191 

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

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

194 also :attr:`show_button_reset`. 

195 

196 .. attribute:: cancel_url 

197 

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

199 if applicable. 

200 

201 Code should not access this directly, but instead call 

202 :meth:`get_cancel_url()`. 

203 

204 .. attribute:: cancel_url_fallback 

205 

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

207 referrer cannot be determined from request. 

208 

209 Code should not access this directly, but instead call 

210 :meth:`get_cancel_url()`. 

211 

212 .. attribute:: vue_tagname 

213 

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

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

216 

217 See also :attr:`vue_component`. 

218 

219 .. attribute:: align_buttons_right 

220 

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

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

223 not set, the buttons are left-aligned. 

224 

225 .. attribute:: auto_disable_submit 

226 

227 Flag indicating whether the submit button should be 

228 auto-disabled, whenever the form is submitted. 

229 

230 .. attribute:: button_label_submit 

231 

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

233 

234 .. attribute:: button_icon_submit 

235 

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

237 

238 .. attribute:: button_type_submit 

239 

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

241 so for example: 

242 

243 .. code-block:: html 

244 

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

246 native-type="submit"> 

247 Save 

248 </b-button> 

249 

250 See also the `Buefy docs 

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

252 

253 .. attribute:: show_button_reset 

254 

255 Flag indicating whether a Reset button should be shown. 

256 Default is ``False``. 

257 

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

259 standard behavior per the browser. 

260 

261 .. attribute:: show_button_cancel 

262 

263 Flag indicating whether a Cancel button should be shown. 

264 Default is ``True``. 

265 

266 .. attribute:: button_label_cancel 

267 

268 String label for the form cancel button. Default is 

269 ``"Cancel"``. 

270 

271 .. attribute:: auto_disable_cancel 

272 

273 Flag indicating whether the cancel button should be 

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

275 ``True``. 

276 

277 .. attribute:: validated 

278 

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

280 this will be set to the validated data dict. 

281 """ 

282 

283 deform_form = None 

284 validated = None 

285 

286 vue_template = "/forms/vue_template.mako" 

287 fields_template = "/forms/vue_fields.mako" 

288 buttons_template = "/forms/vue_buttons.mako" 

289 

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

291 self, 

292 request, 

293 fields=None, 

294 schema=None, 

295 model_class=None, 

296 model_instance=None, 

297 nodes=None, 

298 widgets=None, 

299 validators=None, 

300 defaults=None, 

301 readonly=False, 

302 readonly_fields=None, 

303 required_fields=None, 

304 labels=None, 

305 autofocus=None, 

306 action_method="post", 

307 action_url=None, 

308 reset_url=None, 

309 cancel_url=None, 

310 cancel_url_fallback=None, 

311 vue_tagname="wutta-form", 

312 align_buttons_right=False, 

313 auto_disable_submit=True, 

314 button_label_submit="Save", 

315 button_icon_submit="save", 

316 button_type_submit="is-primary", 

317 show_button_reset=False, 

318 show_button_cancel=True, 

319 button_label_cancel="Cancel", 

320 auto_disable_cancel=True, 

321 ): 

322 self.request = request 

323 self.schema = schema 

324 self.nodes = nodes or {} 

325 self.widgets = widgets or {} 

326 self.validators = validators or {} 

327 self.defaults = defaults or {} 

328 self.readonly = readonly 

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

330 self.required_fields = required_fields or {} 

331 self.labels = labels or {} 

332 self.action_method = action_method 

333 self.action_url = action_url 

334 self.cancel_url = cancel_url 

335 self.cancel_url_fallback = cancel_url_fallback 

336 self.reset_url = reset_url 

337 self.vue_tagname = vue_tagname 

338 self.align_buttons_right = align_buttons_right 

339 self.auto_disable_submit = auto_disable_submit 

340 self.button_label_submit = button_label_submit 

341 self.button_icon_submit = button_icon_submit 

342 self.button_type_submit = button_type_submit 

343 self.show_button_reset = show_button_reset 

344 self.show_button_cancel = show_button_cancel 

345 self.button_label_cancel = button_label_cancel 

346 self.auto_disable_cancel = auto_disable_cancel 

347 self.form_attrs = {} 

348 

349 self.config = self.request.wutta_config 

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

351 

352 self.model_class = model_class 

353 self.model_instance = model_instance 

354 if self.model_instance and not self.model_class: 

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

356 self.model_class = type(self.model_instance) 

357 

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

359 self.set_default_widgets() 

360 self.set_autofocus(autofocus) 

361 

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

363 self.grid_vue_context = OrderedDict() 

364 

365 def __contains__(self, name): 

366 """ 

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

368 if the form contains a given field:: 

369 

370 myform = Form() 

371 if 'somefield' in myform: 

372 print("my form has some field") 

373 """ 

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

375 

376 def __iter__(self): 

377 """ 

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

379 

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

381 for fieldname in myform: 

382 print(fieldname) 

383 """ 

384 return iter(self.fields) 

385 

386 @property 

387 def vue_component(self): 

388 """ 

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

390 

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

392 """ 

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

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

395 

396 def get_cancel_url(self): 

397 """ 

398 Returns the URL for the Cancel button. 

399 

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

401 

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

403 returned. 

404 

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

406 returned. 

407 

408 As a last resort the "default" URL from 

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

410 returned. 

411 """ 

412 # use "permanent" URL if set 

413 if self.cancel_url: 

414 return self.cancel_url 

415 

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

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

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

419 if url and url != "NOPE": 

420 return url 

421 

422 # use fallback URL if set 

423 if self.cancel_url_fallback: 

424 return self.cancel_url_fallback 

425 

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

427 return self.request.get_referrer() 

428 

429 def set_fields(self, fields): 

430 """ 

431 Explicitly set the list of form fields. 

432 

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

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

435 

436 :param fields: List of string field names. 

437 """ 

438 self.fields = FieldList(fields) 

439 

440 def set_autofocus(self, field, focus=True): 

441 """ 

442 Set field autofocus flag(s). 

443 

444 The method signature seems obvious enough, you can toggle on/off 

445 the "autofocus" flag for a given field. 

446 

447 However in practice the ``field`` param is special. Instead 

448 of specifying a field name you can pass a full dict (or 

449 ``None``) in which case this will overwrite :attr:`autofocus` 

450 entirely:: 

451 

452 # turn on autofocus for a field 

453 form.set_autofocus("description") 

454 

455 # turn off autofocus for a field 

456 form.set_autofocus("description", False) 

457 

458 # clear out all autofocus info 

459 form.set_autofocus(None) 

460 

461 # set explicit autofocus info 

462 form.set_autofocus({ 

463 "description": True, 

464 "foo": True, 

465 "bar": False, 

466 }) 

467 

468 :param field: String field name, or else a dict or ``None`` as 

469 shown above. 

470 

471 :param focus: Whether the field should be considered for 

472 autofocus. Specifying ``False`` here is useful only when 

473 you need to turn *off* autofocus which may have previously 

474 been turned *on* e.g. by default logic. This param is only 

475 relevant when ``field`` is just a field name. 

476 """ 

477 

478 # nb. caller may provide full dict instead of just a field 

479 if field is None: 

480 self.autofocus = {} 

481 elif not isinstance(field, str): 

482 self.autofocus = field 

483 else: 

484 self.autofocus[field] = focus 

485 

486 def append(self, *keys): 

487 """ 

488 Add some fields(s) to the form. 

489 

490 This is a convenience to allow adding multiple fields at 

491 once:: 

492 

493 form.append('first_field', 

494 'second_field', 

495 'third_field') 

496 

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

498 """ 

499 for key in keys: 

500 if key not in self.fields: 

501 self.fields.append(key) 

502 

503 def remove(self, *keys): 

504 """ 

505 Remove some fields(s) from the form. 

506 

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

508 once:: 

509 

510 form.remove('first_field', 

511 'second_field', 

512 'third_field') 

513 

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

515 """ 

516 for key in keys: 

517 if key in self.fields: 

518 self.fields.remove(key) 

519 

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

521 """ 

522 Set/override the node for a field. 

523 

524 :param key: Name of field. 

525 

526 :param nodeinfo: Should be either a 

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

528 :class:`colander:colander.SchemaType` instance. 

529 

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

531 as-is. Otherwise an 

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

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

534 

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

536 """ 

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

538 ObjectNode, 

539 ) 

540 

541 if isinstance(nodeinfo, colander.SchemaNode): 

542 # assume nodeinfo is a complete node 

543 node = nodeinfo 

544 

545 else: # assume nodeinfo is a schema type 

546 

547 # we will construct a new node for this type, which may 

548 # override a (future) automatic node from ColanderAlchemy. 

549 # we also must effectively auto-set the "required" flag, 

550 # since normally ColanderAlchemy handles that. 

551 if self.model_class and hasattr(self.model_class, key): 

552 if attr := getattr(self.model_class, key): 

553 if getattr(attr, "nullable", False): 

554 kwargs.setdefault("missing", colander.null) 

555 

556 kwargs.setdefault("name", key) 

557 node = ObjectNode(nodeinfo, **kwargs) 

558 

559 self.nodes[key] = node 

560 

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

562 if self.schema: 

563 self.schema[key] = node 

564 

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

566 """ 

567 Set/override the widget for a field. 

568 

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

570 widget, in which case that is passed along to 

571 :meth:`make_widget()`. 

572 

573 :param key: Name of field. 

574 

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

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

577 

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

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

580 

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

582 """ 

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

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

585 if not widget_obj: 

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

587 widget = widget_obj 

588 

589 self.widgets[key] = widget 

590 

591 # update schema if necessary 

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

593 self.schema[key].widget = widget 

594 

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

596 """ 

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

598 

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

600 subclass can override as needed): 

601 

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

603 

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

605 automatically. 

606 

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

608 type to create. 

609 

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

611 widget factory. 

612 

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

614 not determine how to create the widget. 

615 """ 

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

617 

618 if widget_type == "notes": 

619 return widgets.NotesWidget(**kwargs) 

620 

621 return None 

622 

623 def set_default_widgets(self): 

624 """ 

625 Set default field widgets, where applicable. 

626 

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

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

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

630 to a valid SQLAlchemy mapped class. 

631 

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

633 follows: 

634 

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

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

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

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

639 """ 

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

641 

642 if not self.model_class: 

643 return 

644 

645 for key in self.fields: 

646 if key in self.widgets: 

647 continue 

648 

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

650 if attr: 

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

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

653 column = prop.columns[0] 

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

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

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

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

658 

659 def set_grid(self, key, grid): 

660 """ 

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

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

663 rendered grid. 

664 

665 :param key: Name of field. 

666 

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

668 pre-configured and (usually) with data. 

669 """ 

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

671 GridWidget, 

672 ) 

673 

674 widget = GridWidget(self.request, grid) 

675 self.set_widget(key, widget) 

676 self.add_grid_vue_context(grid) 

677 

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

679 """ """ 

680 if not grid.key: 

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

682 

683 if grid.key in self.grid_vue_context: 

684 log.warning( 

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

686 grid.key, 

687 ) 

688 

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

690 

691 def set_validator(self, key, validator): 

692 """ 

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

694 

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

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

697 a field. 

698 

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

700 args. For instance:: 

701 

702 def validate_foo(node, value): 

703 if value == 42: 

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

705 

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

707 

708 form.set_validator('foo', validate_foo) 

709 

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

711 """ 

712 self.validators[key] = validator 

713 

714 # nb. must apply to existing schema if present 

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

716 self.schema[key].validator = validator 

717 

718 def set_default(self, key, value): 

719 """ 

720 Set/override the default value for a field. 

721 

722 :param key: Name of field. 

723 

724 :param validator: Default value for the field. 

725 

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

727 """ 

728 self.defaults[key] = value 

729 

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

731 """ 

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

733 

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

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

736 over (not saved) when form is submitted. 

737 

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

739 :attr:`readonly_fields`. 

740 

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

742 

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

744 """ 

745 if readonly: 

746 self.readonly_fields.add(key) 

747 else: 

748 if key in self.readonly_fields: 

749 self.readonly_fields.remove(key) 

750 

751 if self.deform_form and key in self.deform_form: 

752 self.deform_form[key].widget.readonly = readonly 

753 

754 def is_readonly(self, key): 

755 """ 

756 Returns boolean indicating if the given field is marked as 

757 readonly. 

758 

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

760 

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

762 """ 

763 if self.readonly_fields: 

764 if key in self.readonly_fields: 

765 return True 

766 return False 

767 

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

769 """ 

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

771 

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

773 or else it fails validation. 

774 

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

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

777 

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

779 :attr:`required_fields`. 

780 

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

782 

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

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

785 revert to default behavior for the field. 

786 """ 

787 self.required_fields[key] = required 

788 

789 def is_required(self, key): 

790 """ 

791 Returns boolean indicating if the given field is marked as 

792 required. 

793 

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

795 

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

797 

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

799 present; otherwise ``None``. 

800 """ 

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

802 

803 def set_label(self, key, label): 

804 """ 

805 Set the label for given field name. 

806 

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

808 """ 

809 self.labels[key] = label 

810 

811 # update schema if necessary 

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

813 self.schema[key].title = label 

814 

815 def get_label(self, key): 

816 """ 

817 Get the label for given field name. 

818 

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

820 the label if needed. 

821 

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

823 """ 

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

825 

826 def get_fields(self): 

827 """ 

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

829 ``None``. 

830 

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

832 

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

834 from that. 

835 

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

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

838 

839 Otherwise ``None`` is returned. 

840 """ 

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

842 return self.fields 

843 

844 if self.schema: 

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

846 

847 fields = self.get_model_fields() 

848 if fields: 

849 return fields 

850 

851 return [] 

852 

853 def get_model_fields(self, model_class=None): 

854 """ 

855 This method is a shortcut which calls 

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

857 

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

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

860 assumed. 

861 """ 

862 return get_model_fields( 

863 self.config, model_class=model_class or self.model_class 

864 ) 

865 

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

867 """ 

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

869 form, generating it automatically if necessary. 

870 

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

872 returned as-is. 

873 """ 

874 if not self.schema: 

875 

876 ############################## 

877 # create schema 

878 ############################## 

879 

880 # get fields 

881 fields = self.get_fields() 

882 if not fields: 

883 raise ValueError( 

884 "could not determine fields list; " 

885 "please set model_class or fields explicitly" 

886 ) 

887 

888 if self.model_class: 

889 

890 # collect list of field names and/or nodes 

891 includes = [] 

892 for key in fields: 

893 if key in self.nodes: 

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

895 else: 

896 includes.append(key) 

897 

898 # make initial schema with ColanderAlchemy magic 

899 schema = WuttaSchemaNode(self.model_class, includes=includes) 

900 

901 # fill in the blanks if anything got missed 

902 for key in fields: 

903 if key not in schema: 

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

905 schema.add(node) 

906 

907 else: 

908 

909 # make basic schema 

910 schema = colander.Schema() 

911 for key in fields: 

912 node = None 

913 

914 # use node override if present 

915 if key in self.nodes: 

916 node = self.nodes[key] 

917 if not node: 

918 

919 # otherwise make simple string node 

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

921 

922 schema.add(node) 

923 

924 ############################## 

925 # customize schema 

926 ############################## 

927 

928 # apply widget overrides 

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

930 if key in schema: 

931 schema[key].widget = widget 

932 

933 # apply validator overrides 

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

935 if key is None: 

936 # nb. this one is form-wide 

937 schema.validator = validator 

938 elif key in schema: # field-level 

939 schema[key].validator = validator 

940 

941 # apply default value overrides 

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

943 if key in schema: 

944 schema[key].default = value 

945 

946 # apply required flags 

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

948 if key in schema: 

949 if required is False: 

950 schema[key].missing = colander.null 

951 elif required: 

952 schema[key].missing = colander.required 

953 

954 # apply autofocus 

955 for key, focus in self.autofocus.items(): 

956 if not focus: 

957 continue 

958 if node := schema.get(key): 

959 node.autofocus = "autofocus" 

960 

961 self.schema = schema 

962 

963 return self.schema 

964 

965 def get_deform(self): 

966 """ 

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

968 generating it automatically if necessary. 

969 """ 

970 if not self.deform_form: 

971 schema = self.get_schema() 

972 kwargs = {} 

973 

974 if self.model_instance: 

975 

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

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

978 # dict for model_instance, others will have a proper 

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

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

981 

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

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

984 # to a dict. some options include: 

985 

986 # - check if instance has dictify() method 

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

988 

989 # - check if is instance of model.Base 

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

991 

992 # - check if form has a model_class 

993 # has not been tried yet 

994 

995 # - check if schema is from colanderalchemy 

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

997 

998 if isinstance(schema, SQLAlchemySchemaNode): 

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

1000 else: 

1001 kwargs["appstruct"] = self.model_instance 

1002 

1003 # create the Deform instance 

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

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

1006 # access the main model instance 

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

1008 form.wutta_form = self 

1009 self.deform_form = form 

1010 

1011 return self.deform_form 

1012 

1013 def render_vue_tag(self, **kwargs): 

1014 """ 

1015 Render the Vue component tag for the form. 

1016 

1017 By default this simply returns: 

1018 

1019 .. code-block:: html 

1020 

1021 <wutta-form></wutta-form> 

1022 

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

1024 particular :attr:`vue_tagname`. 

1025 """ 

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

1027 

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

1029 """ 

1030 Render the Vue template block for the form. 

1031 

1032 This returns something like: 

1033 

1034 .. code-block:: none 

1035 

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

1037 <form> 

1038 <!-- fields etc. --> 

1039 </form> 

1040 </script> 

1041 

1042 <script> 

1043 const WuttaFormData = {} 

1044 const WuttaForm = { 

1045 template: 'wutta-form-template', 

1046 } 

1047 </script> 

1048 

1049 .. todo:: 

1050 

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

1052 

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

1054 

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

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

1057 

1058 Default logic will also invoke (indirectly): 

1059 

1060 * :meth:`render_vue_fields()` 

1061 * :meth:`render_vue_buttons()` 

1062 

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

1064 default. 

1065 

1066 :returns: HTML literal 

1067 """ 

1068 context = self.get_vue_context(**context) 

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

1070 return HTML.literal(html) 

1071 

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

1073 context["form"] = self 

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

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

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

1077 

1078 # set form method, enctype 

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

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

1081 if self.action_method == "post": 

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

1083 

1084 # auto disable button on submit 

1085 if self.auto_disable_submit: 

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

1087 

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

1089 context["form_context"] = context 

1090 

1091 return context 

1092 

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

1094 """ 

1095 Render the fields section within the form template. 

1096 

1097 This is normally invoked from within the form's 

1098 ``vue_template`` like this: 

1099 

1100 .. code-block:: none 

1101 

1102 ${form.render_vue_fields(form_context)} 

1103 

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

1105 last resort. Logic will first look for a 

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

1107 rendered for the page. 

1108 

1109 An example will surely help: 

1110 

1111 .. code-block:: mako 

1112 

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

1114 

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

1116 

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

1118 

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

1120 

1121 </%def> 

1122 

1123 This keeps the custom fields section within the main page 

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

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

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

1127 is specified.) 

1128 

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

1130 :meth:`render_vue_buttons()`. 

1131 

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

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

1134 

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

1136 defaults described above. 

1137 

1138 :returns: HTML literal 

1139 """ 

1140 context.update(kwargs) 

1141 html = False 

1142 

1143 if not template: 

1144 

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

1146 try: 

1147 vue_fields = main_template.get_def("form_vue_fields") 

1148 except AttributeError: 

1149 pass 

1150 else: 

1151 html = vue_fields.render(**context) 

1152 

1153 if html is False: 

1154 template = self.fields_template 

1155 

1156 if html is False: 

1157 html = render(template, context) 

1158 

1159 return HTML.literal(html) 

1160 

1161 def render_vue_field( 

1162 self, 

1163 fieldname, 

1164 readonly=None, 

1165 label=True, 

1166 horizontal=True, 

1167 **kwargs, 

1168 ): # pylint: disable=unused-argument,too-many-locals,too-many-branches 

1169 """ 

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

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

1172 needed. 

1173 

1174 Actual output will depend on the field attributes etc. 

1175 Typical output might look like: 

1176 

1177 .. code-block:: html 

1178 

1179 <b-field label="Foo" 

1180 horizontal 

1181 type="is-danger" 

1182 message="something went wrong!"> 

1183 <b-input name="foo" 

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

1185 </b-field> 

1186 

1187 Note that this method can render a *readonly* field as well as 

1188 one with input widget as shown above. Some fields are always 

1189 readonly, and caller can force readonly via method param. 

1190 

1191 However some fields really can only be rendered as readonly, 

1192 so you can't necessarily force it to render a sane input 

1193 widget unless the field supports it. 

1194 

1195 :param fieldname: Name of field to render. 

1196 

1197 :param readonly: Optional override for readonly flag. 

1198 

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

1200 

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

1202 on the field. 

1203 

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

1205 ``serialize()`` method. 

1206 

1207 :returns: HTML literal 

1208 """ 

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

1210 if readonly is None: 

1211 readonly = self.is_readonly(fieldname) 

1212 if not readonly: 

1213 readonly = self.readonly 

1214 

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

1216 dform = self.get_deform() 

1217 if not readonly and fieldname not in dform: 

1218 readonly = True 

1219 

1220 # render the field widget or whatever 

1221 if fieldname in dform: 

1222 

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

1224 field = dform[fieldname] 

1225 if readonly: 

1226 kwargs["readonly"] = True 

1227 

1228 try: 

1229 html = field.serialize(**kwargs) 

1230 except Exception as exc: 

1231 log.warning( 

1232 "widget serialization failed for field: %s", 

1233 fieldname, 

1234 exc_info=True, 

1235 ) 

1236 raise RuntimeError( 

1237 f"widget serialization failed for field: {fieldname}" 

1238 ) from exc 

1239 

1240 else: 

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

1242 # TODO: need to abstract this somehow 

1243 if self.model_instance: 

1244 value = self.app.get_value(self.model_instance, fieldname) 

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

1246 else: 

1247 html = "" 

1248 

1249 # mark all that as safe 

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

1251 

1252 # render field label 

1253 if label: 

1254 label = self.get_label(fieldname) 

1255 

1256 # b-field attrs 

1257 attrs = { 

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

1259 "label": label or "", 

1260 } 

1261 

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

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

1264 # validation errors.. 

1265 field_type = None 

1266 messages = [] 

1267 

1268 # show errors if present 

1269 errors = self.get_field_errors(fieldname) 

1270 if errors: 

1271 field_type = "is-danger" 

1272 messages.extend(errors) 

1273 

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

1275 if field_type: 

1276 attrs["type"] = field_type 

1277 if messages: 

1278 cls = "is-size-7" 

1279 if field_type == "is-danger": 

1280 cls += " has-text-danger" 

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

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

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

1284 

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

1286 

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

1288 """ 

1289 Render the buttons section within the form template. 

1290 

1291 This is normally invoked from within the form's 

1292 ``vue_template`` like this: 

1293 

1294 .. code-block:: none 

1295 

1296 ${form.render_vue_buttons(form_context)} 

1297 

1298 .. note:: 

1299 

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

1301 unlike :meth:`render_vue_fields()`. 

1302 

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

1304 

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

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

1307 

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

1309 default. 

1310 

1311 :returns: HTML literal 

1312 """ 

1313 context.update(kwargs) 

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

1315 return HTML.literal(html) 

1316 

1317 def render_vue_finalize(self): 

1318 """ 

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

1320 

1321 By default this simply returns: 

1322 

1323 .. code-block:: html 

1324 

1325 <script> 

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

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

1328 </script> 

1329 

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

1331 particular :attr:`vue_tagname`. 

1332 """ 

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

1334 

1335 def get_field_vmodel(self, field): 

1336 """ 

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

1338 given field. For instance: 

1339 

1340 .. code-block:: none 

1341 

1342 <b-input name="myfield" 

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

1344 

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

1346 easter egg! 

1347 </div> 

1348 

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

1350 """ 

1351 dform = self.get_deform() 

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

1353 

1354 def get_vue_model_data(self): 

1355 """ 

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

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

1358 

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

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

1361 

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

1363 JSON-serializable. 

1364 

1365 :returns: Dict of field/value items. 

1366 """ 

1367 dform = self.get_deform() 

1368 model_data = {} 

1369 

1370 def assign(field): 

1371 value = field.cstruct 

1372 

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

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

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

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

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

1378 value = value == field.typ.true_val 

1379 

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

1381 

1382 for key in self.fields: 

1383 

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

1385 

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

1387 if key not in dform: 

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

1389 continue 

1390 

1391 field = dform[key] 

1392 

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

1394 # for subfield in field.children: 

1395 # assign(subfield) 

1396 

1397 assign(field) 

1398 

1399 return model_data 

1400 

1401 # TODO: for tailbone compat, should document? 

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

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

1404 """ """ 

1405 if key not in self.fields: 

1406 return None 

1407 

1408 dform = self.get_deform() 

1409 if key not in dform: 

1410 return None 

1411 

1412 field = dform[key] 

1413 return make_json_safe(field.cstruct) 

1414 

1415 def validate(self): 

1416 """ 

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

1418 

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

1420 form data from POST or JSON body. 

1421 

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

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

1424 :attr:`validated` attribute. 

1425 

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

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

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

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

1430 :meth:`get_field_errors()`. 

1431 

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

1433 hood. 

1434 

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

1436 """ 

1437 self.validated = None 

1438 

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

1440 return False 

1441 

1442 schema = self.get_schema() 

1443 dform = self.get_deform() 

1444 

1445 # remove readonly fields to avoid validation mess 

1446 removed_fields = [] 

1447 removed_nodes = [] 

1448 for key in self.readonly_fields: 

1449 if key not in schema: 

1450 continue 

1451 

1452 field = dform[key] 

1453 i = dform.children.index(field) 

1454 dform.children.pop(i) 

1455 removed_fields.append((i, field)) 

1456 

1457 node = schema[key] 

1458 i = schema.children.index(node) 

1459 schema.children.pop(i) 

1460 removed_nodes.append((i, node)) 

1461 

1462 # let deform do real validation 

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

1464 try: 

1465 self.validated = dform.validate(controls) 

1466 except deform.ValidationFailure: 

1467 log.warning("form did not validate: %s", dform.error) 

1468 return False 

1469 finally: 

1470 

1471 # re-attach readonly fields 

1472 for i, node in removed_nodes: 

1473 schema.children.insert(i, node) 

1474 for i, field in removed_fields: 

1475 dform.children.insert(i, field) 

1476 

1477 return self.validated 

1478 

1479 def has_global_errors(self): 

1480 """ 

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

1482 (not field-level) errors. 

1483 

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

1485 

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

1487 """ 

1488 dform = self.get_deform() 

1489 return bool(dform.error) 

1490 

1491 def get_global_errors(self): 

1492 """ 

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

1494 for the form. 

1495 

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

1497 

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

1499 """ 

1500 dform = self.get_deform() 

1501 if dform.error is None: 

1502 return [] 

1503 return dform.error.messages() 

1504 

1505 def get_field_errors(self, field): 

1506 """ 

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

1508 

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

1510 """ 

1511 dform = self.get_deform() 

1512 if field in dform: 

1513 field = dform[field] 

1514 if field.error: 

1515 return field.error.messages() 

1516 return [] 

1517 

1518 

1519class WuttaSchemaNode(SQLAlchemySchemaNode): # pylint: disable=abstract-method 

1520 """ 

1521 Custom schema node type based on ColanderAlchemy, but adding 

1522 support for association proxy fields. 

1523 

1524 This class is used under the hood but you will not normally 

1525 interact with it directly. 

1526 

1527 It's a subclass of 

1528 :class:`colanderalchemy:colanderalchemy.SQLAlchemySchemaNode`. 

1529 """ 

1530 

1531 def add_nodes(self, includes, excludes, overrides): 

1532 super().add_nodes(includes, excludes, overrides) 

1533 

1534 for name in includes or []: 

1535 prop = self.inspector.attrs.get(name, name) 

1536 if isinstance(prop, str): 

1537 

1538 # add node for association proxy field 

1539 if column := get_association_proxy_column(self.inspector, name): 

1540 name_overrides_copy = overrides.get(name, {}).copy() 

1541 node = self.get_schema_from_column(column, name_overrides_copy) 

1542 if node is not None: 

1543 self.add(node) 

1544 

1545 def dictify(self, obj): # pylint: disable=empty-docstring 

1546 """ """ 

1547 dct = super().dictify(obj) 

1548 

1549 # loop thru all fields to add the association proxies 

1550 for node in self: 

1551 name = node.name 

1552 if name in dct: 

1553 continue # value already set 

1554 

1555 # we only care about association proxies here 

1556 if not get_association_proxy_column(self.inspector, name): 

1557 continue 

1558 

1559 dct[name] = getattr(obj, name) 

1560 

1561 # special handling when value is None 

1562 if dct[name] is None: 

1563 

1564 # nb. colander/deform know how to behave when using 

1565 # their dedicated colander.null value, but ``None`` 

1566 # seems to cause issues for string fields, so swap 

1567 # that out here if applicable 

1568 if isinstance(node.typ, colander.String): 

1569 dct[name] = colander.null 

1570 

1571 return dct 

1572 

1573 def objectify(self, dict_, context=None): # pylint: disable=empty-docstring 

1574 """ """ 

1575 context = super().objectify(dict_, context=context) 

1576 

1577 for attr in dict_: 

1578 if self.inspector.has_property(attr): 

1579 continue # upstream logic handles these 

1580 

1581 # try to process association proxy field 

1582 if get_association_proxy_column(self.inspector, attr): 

1583 value = dict_[attr] 

1584 if value is colander.null: 

1585 # `colander.null` is never an appropriate 

1586 # value to be placed on an SQLAlchemy object 

1587 # so we translate it into `None`. 

1588 value = None 

1589 setattr(context, attr, value) 

1590 

1591 return context 

1592 

1593 

1594def get_association_proxy(mapper, field): 

1595 """ 

1596 Return the association proxy "descriptor" corresponding to the 

1597 given field name, if it exists. 

1598 

1599 :param mapper: SQLAlchemy mapper for the main class. 

1600 

1601 :param field: Field name on the main class, which may (or may not) 

1602 be proxied via association. 

1603 

1604 :returns: 

1605 :class:`~sqlalchemy:sqlalchemy.ext.associationproxy.AssociationProxy` 

1606 instance, or ``None``. 

1607 

1608 Using the ``User.first_name`` (which proxies to 

1609 ``User.person.first_name``) example, this would return the proxy 

1610 descriptor for ``User.first_name``. 

1611 """ 

1612 try: 

1613 desc = getattr(mapper.all_orm_descriptors, field) 

1614 except AttributeError: 

1615 pass 

1616 else: 

1617 if desc.extension_type.name == "ASSOCIATION_PROXY": 

1618 return desc 

1619 return None 

1620 

1621 

1622def get_association_proxy_target(mapper, field): 

1623 """ 

1624 Return the relationship property involved in the association proxy 

1625 for the given field, if applicable. 

1626 

1627 :param mapper: SQLAlchemy mapper for the main class. 

1628 

1629 :param field: Field name on the main class, which may (or may not) 

1630 be proxied via association. 

1631 

1632 :returns: :class:`~sqlalchemy:sqlalchemy.orm.RelationshipProperty` 

1633 instance, or ``None``. 

1634 

1635 Using the ``User.first_name`` (which proxies to 

1636 ``User.person.first_name``) example, this would return the 

1637 ``User.person`` relationship property. 

1638 """ 

1639 if proxy := get_association_proxy(mapper, field): 

1640 proxy_target = mapper.get_property(proxy.target_collection) 

1641 if ( 

1642 isinstance(proxy_target, orm.RelationshipProperty) 

1643 and not proxy_target.uselist 

1644 ): 

1645 return proxy_target 

1646 return None 

1647 

1648 

1649def get_association_proxy_column(mapper, field): 

1650 """ 

1651 Return the target column property involved in the association 

1652 proxy for the given field, if applicable. 

1653 

1654 :param mapper: SQLAlchemy mapper for the main class. 

1655 

1656 :param field: Field name on the main class, which may (or may not) 

1657 be proxied via association. 

1658 

1659 :returns: :class:`~sqlalchemy:sqlalchemy.orm.ColumnProperty` 

1660 instance, or ``None``. 

1661 

1662 Using the ``User.first_name`` (which proxies to 

1663 ``User.person.first_name``) example, this would return the 

1664 ``Person.first_name`` column property. 

1665 """ 

1666 if proxy_target := get_association_proxy_target(mapper, field): 

1667 if proxy_target.mapper.has_property(field): 

1668 prop = proxy_target.mapper.get_property(field) 

1669 if isinstance(prop, orm.ColumnProperty) and isinstance( 

1670 prop.columns[0], sa.Column 

1671 ): 

1672 return prop 

1673 return None