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

259 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-10 11:05 -0500

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

24Form schema types 

25""" 

26 

27import datetime 

28import uuid as _uuid 

29 

30import colander 

31import sqlalchemy as sa 

32 

33from wuttjamaican.conf import parse_list 

34from wuttjamaican.util import localtime 

35 

36from wuttaweb.db import Session 

37from wuttaweb.forms import widgets 

38 

39 

40class WuttaDateTime(colander.DateTime): 

41 """ 

42 Custom schema type for :class:`~python:datetime.datetime` fields. 

43 

44 This should be used automatically for 

45 :class:`~sqlalchemy:sqlalchemy.types.DateTime` ORM columns unless 

46 you register another default. 

47 

48 This schema type exists for sake of convenience, when working with 

49 the Buefy datepicker + timepicker widgets. 

50 

51 It also follows the datetime handling "rules" as outlined in 

52 :doc:`wuttjamaican:narr/datetime`. On the Python side, values 

53 should be naive/UTC datetime objects. On the HTTP side, values 

54 will be ISO-format strings representing aware/local time. 

55 """ 

56 

57 def serialize(self, node, appstruct): 

58 if not appstruct: 

59 return colander.null 

60 

61 # nb. request should be present when it matters 

62 if node.widget and node.widget.request: 

63 request = node.widget.request 

64 config = request.wutta_config 

65 app = config.get_app() 

66 appstruct = app.localtime(appstruct) 

67 else: 

68 # but if not, fallback to config-less logic 

69 appstruct = localtime(appstruct) 

70 

71 if self.format: 

72 return appstruct.strftime(self.format) 

73 return appstruct.isoformat() 

74 

75 def deserialize( # pylint: disable=inconsistent-return-statements 

76 self, node, cstruct 

77 ): 

78 if not cstruct: 

79 return colander.null 

80 

81 formats = [ 

82 "%Y-%m-%dT%H:%M:%S", 

83 "%Y-%m-%dT%I:%M %p", 

84 ] 

85 

86 # nb. request is always assumed to be present here 

87 request = node.widget.request 

88 config = request.wutta_config 

89 app = config.get_app() 

90 

91 for fmt in formats: 

92 try: 

93 dt = datetime.datetime.strptime(cstruct, fmt) 

94 if not dt.tzinfo: 

95 dt = app.localtime(dt, from_utc=False) 

96 return app.make_utc(dt) 

97 except Exception: # pylint: disable=broad-exception-caught 

98 pass 

99 

100 node.raise_invalid("Invalid date and/or time") 

101 

102 

103class ObjectNode(colander.SchemaNode): # pylint: disable=abstract-method 

104 """ 

105 Custom schema node class which adds methods for compatibility with 

106 ColanderAlchemy. This is a direct subclass of 

107 :class:`colander:colander.SchemaNode`. 

108 

109 ColanderAlchemy will call certain methods on any node found in the 

110 schema. However these methods are not "standard" and only exist 

111 for ColanderAlchemy nodes. 

112 

113 So we must add nodes using this class, to ensure the node has all 

114 methods needed by ColanderAlchemy. 

115 """ 

116 

117 def dictify(self, obj): 

118 """ 

119 This method is called by ColanderAlchemy when translating the 

120 in-app Python object to a value suitable for use in the form 

121 data dict. 

122 

123 The logic here will look for a ``dictify()`` method on the 

124 node's "type" instance (``self.typ``; see also 

125 :class:`colander:colander.SchemaNode`) and invoke it if found. 

126 

127 For an example type which is supported in this way, see 

128 :class:`ObjectRef`. 

129 

130 If the node's type does not have a ``dictify()`` method, this 

131 will just convert the object to a string and return that. 

132 """ 

133 if hasattr(self.typ, "dictify"): 

134 return self.typ.dictify(obj) 

135 

136 # TODO: this is better than raising an error, as it previously 

137 # did, but seems like troubleshooting problems may often lead 

138 # one here.. i suspect this needs to do something smarter but 

139 # not sure what that is yet 

140 return str(obj) 

141 

142 def objectify(self, value): 

143 """ 

144 This method is called by ColanderAlchemy when translating form 

145 data to the final Python representation. 

146 

147 The logic here will look for an ``objectify()`` method on the 

148 node's "type" instance (``self.typ``; see also 

149 :class:`colander:colander.SchemaNode`) and invoke it if found. 

150 

151 For an example type which is supported in this way, see 

152 :class:`ObjectRef`. 

153 

154 If the node's type does not have an ``objectify()`` method, 

155 this will raise ``NotImplementeError``. 

156 """ 

157 if hasattr(self.typ, "objectify"): 

158 return self.typ.objectify(value) 

159 

160 class_name = self.typ.__class__.__name__ 

161 raise NotImplementedError(f"you must define {class_name}.objectify()") 

162 

163 

164class WuttaEnum(colander.Enum): 

165 """ 

166 Custom schema type for enum fields. 

167 

168 This is a subclass of :class:`colander.Enum`, but adds a 

169 default widget (``SelectWidget``) with enum choices. 

170 

171 :param request: Current :term:`request` object. 

172 """ 

173 

174 def __init__(self, request, *args, **kwargs): 

175 super().__init__(*args, **kwargs) 

176 self.request = request 

177 self.config = self.request.wutta_config 

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

179 

180 def widget_maker(self, **kwargs): # pylint: disable=empty-docstring 

181 """ """ 

182 

183 if "values" not in kwargs: 

184 kwargs["values"] = [ 

185 (getattr(e, self.attr), getattr(e, self.attr)) for e in self.enum_cls 

186 ] 

187 

188 return widgets.SelectWidget(**kwargs) 

189 

190 

191class WuttaDictEnum(colander.String): 

192 """ 

193 Schema type for "pseudo-enum" fields which reference a dict for 

194 known values instead of a true enum class. 

195 

196 This is primarily for use with "status" fields such as 

197 :attr:`~wuttjamaican:wuttjamaican.db.model.batch.BatchRowMixin.status_code`. 

198 

199 This is a subclass of :class:`colander.String`, but adds a default 

200 widget (``SelectWidget``) with enum choices. 

201 

202 :param request: Current :term:`request` object. 

203 

204 :param enum_dct: Dict with possible enum values and labels. 

205 """ 

206 

207 def __init__(self, request, enum_dct, *args, **kwargs): 

208 self.null_value = kwargs.pop("null_value", "") 

209 super().__init__(*args, **kwargs) 

210 self.request = request 

211 self.config = self.request.wutta_config 

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

213 self.enum_dct = enum_dct 

214 

215 def serialize(self, node, appstruct): 

216 if appstruct is colander.null: 

217 return self.null_value 

218 return super().serialize(node, appstruct) 

219 

220 def widget_maker(self, **kwargs): # pylint: disable=empty-docstring 

221 """ """ 

222 if "values" not in kwargs: 

223 kwargs["values"] = list(self.enum_dct.items()) 

224 kwargs.setdefault("null_value", self.null_value) 

225 return widgets.SelectWidget(**kwargs) 

226 

227 

228class WuttaMoney(colander.Money): 

229 """ 

230 Custom schema type for "money" fields. 

231 

232 This is a subclass of :class:`colander:colander.Money`, but uses 

233 the custom :class:`~wuttaweb.forms.widgets.WuttaMoneyInputWidget` 

234 by default. 

235 

236 :param request: Current :term:`request` object. 

237 

238 :param scale: If this kwarg is specified, it will be passed along 

239 to the widget constructor. 

240 """ 

241 

242 def __init__(self, request, *args, **kwargs): 

243 self.scale = kwargs.pop("scale", None) 

244 super().__init__(*args, **kwargs) 

245 self.request = request 

246 self.config = self.request.wutta_config 

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

248 

249 def widget_maker(self, **kwargs): # pylint: disable=empty-docstring 

250 """ """ 

251 if self.scale: 

252 kwargs.setdefault("scale", self.scale) 

253 return widgets.WuttaMoneyInputWidget(self.request, **kwargs) 

254 

255 

256class WuttaQuantity(colander.Decimal): 

257 """ 

258 Custom schema type for "quantity" fields. 

259 

260 This is a subclass of :class:`colander:colander.Decimal` but will 

261 serialize values via 

262 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_quantity()`. 

263 

264 :param request: Current :term:`request` object. 

265 """ 

266 

267 def __init__(self, request, *args, **kwargs): 

268 super().__init__(*args, **kwargs) 

269 self.request = request 

270 self.config = self.request.wutta_config 

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

272 

273 def serialize(self, node, appstruct): # pylint: disable=empty-docstring 

274 """ """ 

275 if appstruct in (colander.null, None): 

276 return colander.null 

277 

278 # nb. we render as quantity here to avoid values like 12.0000, 

279 # so we just show value like 12 instead 

280 return self.app.render_quantity(appstruct) 

281 

282 

283class WuttaList(colander.List): 

284 """ 

285 Custom schema type for :class:`python:list` fields; this is a 

286 subclass of :class:`colander.List`. 

287 

288 :param request: Current :term:`request` object. 

289 

290 As of now this merely provides a way (in fact, requires you) to 

291 pass the request in, so it can be leveraged as needed. Instances 

292 of this type will have the following attributes: 

293 

294 .. attribute:: request 

295 

296 Reference to the current :term:`request`. 

297 

298 .. attribute:: config 

299 

300 Reference to the app :term:`config object`. 

301 

302 .. attribute:: app 

303 

304 Reference to the :term:`app handler` instance. 

305 """ 

306 

307 def __init__(self, request): 

308 super().__init__() 

309 self.request = request 

310 self.config = self.request.wutta_config 

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

312 

313 

314class WuttaSet(colander.Set): 

315 """ 

316 Custom schema type for :class:`python:set` fields; this is a 

317 subclass of :class:`colander.Set`. 

318 

319 :param request: Current :term:`request` object. 

320 

321 As of now this merely provides a way (in fact, requires you) to 

322 pass the request in, so it can be leveraged as needed. Instances 

323 of this type will have the following attributes: 

324 

325 .. attribute:: request 

326 

327 Reference to the current :term:`request`. 

328 

329 .. attribute:: config 

330 

331 Reference to the app :term:`config object`. 

332 

333 .. attribute:: app 

334 

335 Reference to the :term:`app handler` instance. 

336 """ 

337 

338 def __init__(self, request): 

339 super().__init__() 

340 self.request = request 

341 self.config = self.request.wutta_config 

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

343 

344 

345class ObjectRef(colander.SchemaType): 

346 """ 

347 Custom schema type for a model class reference field. 

348 

349 This expects the incoming ``appstruct`` to be either a model 

350 record instance, or ``None``. 

351 

352 Serializes to the instance UUID as string, or ``colander.null``; 

353 form data should be of the same nature. 

354 

355 This schema type is not useful directly, but various other types 

356 will subclass it. Each should define (at least) the 

357 :attr:`model_class` attribute or property. 

358 

359 :param request: Current :term:`request` object. 

360 

361 :param empty_option: If a select widget is used, this determines 

362 whether an empty option is included for the dropdown. Set 

363 this to one of the following to add an empty option: 

364 

365 * ``True`` to add the default empty option 

366 * label text for the empty option 

367 * tuple of ``(value, label)`` for the empty option 

368 

369 Note that in the latter, ``value`` must be a string. 

370 """ 

371 

372 default_empty_option = ("", "(none)") 

373 

374 def __init__(self, request, *args, **kwargs): 

375 empty_option = kwargs.pop("empty_option", None) 

376 # nb. allow session injection for tests 

377 self.session = kwargs.pop("session", Session()) 

378 super().__init__(*args, **kwargs) 

379 self.request = request 

380 self.config = self.request.wutta_config 

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

382 self.model_instance = None 

383 

384 if empty_option: 

385 if empty_option is True: 

386 self.empty_option = self.default_empty_option 

387 elif isinstance(empty_option, tuple) and len(empty_option) == 2: 

388 self.empty_option = empty_option 

389 else: 

390 self.empty_option = ("", str(empty_option)) 

391 else: 

392 self.empty_option = None 

393 

394 @property 

395 def model_class(self): 

396 """ 

397 Should be a reference to the model class to which this schema 

398 type applies 

399 (e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`). 

400 """ 

401 class_name = self.__class__.__name__ 

402 raise NotImplementedError(f"you must define {class_name}.model_class") 

403 

404 def serialize(self, node, appstruct): # pylint: disable=empty-docstring 

405 """ """ 

406 # normalize to empty option if no object ref, so that works as 

407 # expected 

408 if self.empty_option and not appstruct: 

409 return self.empty_option[0] 

410 

411 # even if there is no empty option, still treat any false-ish 

412 # value as null 

413 if not appstruct: 

414 return colander.null 

415 

416 # keep a ref to this for later use 

417 node.model_instance = appstruct 

418 

419 # serialize to PK as string 

420 return self.serialize_object(appstruct) 

421 

422 def serialize_object(self, obj): 

423 """ 

424 Serialize the given object to its primary key as string. 

425 

426 Default logic assumes the object has a UUID; subclass can 

427 override as needed. 

428 

429 :param obj: Object reference for the node. 

430 

431 :returns: Object primary key as string. 

432 """ 

433 return obj.uuid.hex 

434 

435 def deserialize( # pylint: disable=empty-docstring,unused-argument 

436 self, node, cstruct 

437 ): 

438 """ """ 

439 if not cstruct: 

440 return colander.null 

441 

442 # nb. use shortcut to fetch model instance from DB 

443 return self.objectify(cstruct) 

444 

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

446 """ """ 

447 

448 # TODO: would we ever need to do something else? 

449 return obj 

450 

451 def objectify(self, value): 

452 """ 

453 For the given UUID value, returns the object it represents 

454 (based on :attr:`model_class`). 

455 

456 If the value is empty, returns ``None``. 

457 

458 If the value is not empty but object cannot be found, raises 

459 ``colander.Invalid``. 

460 """ 

461 if not value: 

462 return None 

463 

464 if isinstance( # pylint: disable=isinstance-second-argument-not-valid-type 

465 value, self.model_class 

466 ): 

467 return value 

468 

469 # fetch object from DB 

470 obj = None 

471 if isinstance(value, _uuid.UUID): 

472 obj = self.session.get(self.model_class, value) 

473 else: 

474 try: 

475 obj = self.session.get(self.model_class, _uuid.UUID(value)) 

476 except ValueError: 

477 pass 

478 

479 # raise error if not found 

480 if not obj: 

481 class_name = self.model_class.__name__ 

482 raise ValueError(f"{class_name} not found: {value}") 

483 

484 return obj 

485 

486 def get_query(self): 

487 """ 

488 Returns the main SQLAlchemy query responsible for locating the 

489 dropdown choices for the select widget. 

490 

491 This is called by :meth:`widget_maker()`. 

492 """ 

493 query = self.session.query(self.model_class) 

494 query = self.sort_query(query) 

495 return query 

496 

497 def sort_query(self, query): 

498 """ 

499 TODO 

500 """ 

501 return query 

502 

503 def widget_maker(self, **kwargs): 

504 """ 

505 This method is responsible for producing the default widget 

506 for the schema node. 

507 

508 Deform calls this method automatically when constructing the 

509 default widget for a field. 

510 

511 :returns: Instance of 

512 :class:`~wuttaweb.forms.widgets.ObjectRefWidget`. 

513 """ 

514 factory = kwargs.pop("factory", widgets.ObjectRefWidget) 

515 

516 if "values" not in kwargs: 

517 query = self.get_query() 

518 objects = query.all() 

519 values = [(self.serialize_object(obj), str(obj)) for obj in objects] 

520 if self.empty_option: 

521 values.insert(0, self.empty_option) 

522 kwargs["values"] = values 

523 

524 if "url" not in kwargs: 

525 kwargs["url"] = self.get_object_url 

526 

527 return factory(self.request, **kwargs) 

528 

529 def get_object_url(self, obj): 

530 """ 

531 Returns the "view" URL for the given object, if applicable. 

532 

533 This is used when rendering the field readonly. If this 

534 method returns a URL then the field text will be wrapped with 

535 a hyperlink, otherwise it will be shown as-is. 

536 

537 Default logic always returns ``None``; subclass should 

538 override as needed. 

539 """ 

540 

541 

542class PersonRef(ObjectRef): 

543 """ 

544 Custom schema type for a 

545 :class:`~wuttjamaican:wuttjamaican.db.model.base.Person` reference 

546 field. 

547 

548 This is a subclass of :class:`ObjectRef`. 

549 """ 

550 

551 @property 

552 def model_class(self): # pylint: disable=empty-docstring 

553 """ """ 

554 model = self.app.model 

555 return model.Person 

556 

557 def sort_query(self, query): # pylint: disable=empty-docstring 

558 """ """ 

559 return query.order_by(self.model_class.full_name) 

560 

561 def get_object_url(self, obj): # pylint: disable=empty-docstring 

562 """ """ 

563 person = obj 

564 return self.request.route_url("people.view", uuid=person.uuid) 

565 

566 

567class RoleRef(ObjectRef): 

568 """ 

569 Custom schema type for a 

570 :class:`~wuttjamaican:wuttjamaican.db.model.auth.Role` reference 

571 field. 

572 

573 This is a subclass of :class:`ObjectRef`. 

574 """ 

575 

576 @property 

577 def model_class(self): # pylint: disable=empty-docstring 

578 """ """ 

579 model = self.app.model 

580 return model.Role 

581 

582 def sort_query(self, query): # pylint: disable=empty-docstring 

583 """ """ 

584 return query.order_by(self.model_class.name) 

585 

586 def get_object_url(self, obj): # pylint: disable=empty-docstring 

587 """ """ 

588 role = obj 

589 return self.request.route_url("roles.view", uuid=role.uuid) 

590 

591 

592class UserRef(ObjectRef): 

593 """ 

594 Custom schema type for a 

595 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` reference 

596 field. 

597 

598 This is a subclass of :class:`ObjectRef`. 

599 """ 

600 

601 @property 

602 def model_class(self): # pylint: disable=empty-docstring 

603 """ """ 

604 model = self.app.model 

605 return model.User 

606 

607 def sort_query(self, query): # pylint: disable=empty-docstring 

608 """ """ 

609 return query.order_by(self.model_class.username) 

610 

611 def get_object_url(self, obj): # pylint: disable=empty-docstring 

612 """ """ 

613 user = obj 

614 return self.request.route_url("users.view", uuid=user.uuid) 

615 

616 

617class RoleRefs(WuttaSet): 

618 """ 

619 Form schema type for the User 

620 :attr:`~wuttjamaican:wuttjamaican.db.model.auth.User.roles` 

621 association proxy field. 

622 

623 This is a subclass of :class:`WuttaSet`. It uses a ``set`` of 

624 :class:`~wuttjamaican:wuttjamaican.db.model.auth.Role` ``uuid`` 

625 values for underlying data format. 

626 """ 

627 

628 def widget_maker(self, **kwargs): 

629 """ 

630 Constructs a default widget for the field. 

631 

632 :returns: Instance of 

633 :class:`~wuttaweb.forms.widgets.RoleRefsWidget`. 

634 """ 

635 session = kwargs.setdefault("session", Session()) 

636 

637 if "values" not in kwargs: 

638 model = self.app.model 

639 auth = self.app.get_auth_handler() 

640 

641 # avoid built-ins which cannot be assigned to users 

642 avoid = { 

643 auth.get_role_authenticated(session), 

644 auth.get_role_anonymous(session), 

645 } 

646 avoid = {role.uuid for role in avoid} 

647 

648 # also avoid admin unless current user is root 

649 if not self.request.is_root: 

650 avoid.add(auth.get_role_administrator(session).uuid) 

651 

652 # everything else can be (un)assigned for users 

653 roles = ( 

654 session.query(model.Role) 

655 .filter(~model.Role.uuid.in_(avoid)) 

656 .order_by(model.Role.name) 

657 .all() 

658 ) 

659 values = [(role.uuid.hex, role.name) for role in roles] 

660 kwargs["values"] = values 

661 

662 return widgets.RoleRefsWidget(self.request, **kwargs) 

663 

664 

665class Permissions(WuttaSet): 

666 """ 

667 Form schema type for the Role 

668 :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Role.permissions` 

669 association proxy field. 

670 

671 This is a subclass of :class:`WuttaSet`. It uses a ``set`` of 

672 :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Permission.permission` 

673 values for underlying data format. 

674 

675 :param permissions: Dict with all possible permissions. Should be 

676 in the same format as returned by 

677 :meth:`~wuttaweb.views.roles.RoleView.get_available_permissions()`. 

678 """ 

679 

680 def __init__(self, request, permissions, *args, **kwargs): 

681 super().__init__(request, *args, **kwargs) 

682 self.permissions = permissions 

683 

684 def widget_maker(self, **kwargs): 

685 """ 

686 Constructs a default widget for the field. 

687 

688 :returns: Instance of 

689 :class:`~wuttaweb.forms.widgets.PermissionsWidget`. 

690 """ 

691 kwargs.setdefault("session", Session()) 

692 kwargs.setdefault("permissions", self.permissions) 

693 

694 if "values" not in kwargs: 

695 values = [] 

696 for group in self.permissions.values(): 

697 for pkey, perm in group["perms"].items(): 

698 values.append((pkey, perm["label"])) 

699 kwargs["values"] = values 

700 

701 return widgets.PermissionsWidget(self.request, **kwargs) 

702 

703 

704class FileDownload(colander.String): 

705 """ 

706 Custom schema type for a file download field. 

707 

708 This field is only meant for readonly use, it does not handle file 

709 uploads. 

710 

711 It expects the incoming ``appstruct`` to be the path to a file on 

712 disk (or null). 

713 

714 Uses the :class:`~wuttaweb.forms.widgets.FileDownloadWidget` by 

715 default. 

716 

717 :param request: Current :term:`request` object. 

718 

719 :param url: Optional URL for hyperlink. If not specified, file 

720 name/size is shown with no hyperlink. 

721 """ 

722 

723 # pylint: disable=duplicate-code 

724 def __init__(self, request, *args, **kwargs): 

725 self.url = kwargs.pop("url", None) 

726 super().__init__(*args, **kwargs) 

727 self.request = request 

728 self.config = self.request.wutta_config 

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

730 

731 # pylint: enable=duplicate-code 

732 

733 def widget_maker(self, **kwargs): # pylint: disable=empty-docstring 

734 """ """ 

735 kwargs.setdefault("url", self.url) 

736 return widgets.FileDownloadWidget(self.request, **kwargs) 

737 

738 

739class EmailRecipients(colander.String): 

740 """ 

741 Custom schema type for :term:`email setting` recipient fields 

742 (``To``, ``Cc``, ``Bcc``). 

743 """ 

744 

745 def serialize(self, node, appstruct): # pylint: disable=empty-docstring 

746 """ """ 

747 if appstruct is colander.null: 

748 return colander.null 

749 

750 return "\n".join(parse_list(appstruct)) 

751 

752 def deserialize(self, node, cstruct): # pylint: disable=empty-docstring 

753 """ """ 

754 if cstruct is colander.null: 

755 return colander.null 

756 

757 values = [value for value in parse_list(cstruct) if value] 

758 return ", ".join(values) 

759 

760 def widget_maker(self, **kwargs): 

761 """ 

762 Constructs a default widget for the field. 

763 

764 :returns: Instance of 

765 :class:`~wuttaweb.forms.widgets.EmailRecipientsWidget`. 

766 """ 

767 return widgets.EmailRecipientsWidget(**kwargs) 

768 

769 

770# nb. colanderalchemy schema overrides 

771sa.DateTime.__colanderalchemy_config__ = {"typ": WuttaDateTime}