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

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

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 uses 

261 the custom 

262 :class:`~wuttaweb.forms.widgets.WuttaQuantityInputWidget` by 

263 default. 

264 

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

266 """ 

267 

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

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

270 self.request = request 

271 self.config = self.request.wutta_config 

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

273 

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

275 """ """ 

276 return widgets.WuttaQuantityInputWidget(self.request, **kwargs) 

277 

278 

279class WuttaList(colander.List): 

280 """ 

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

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

283 

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

285 

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

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

288 of this type will have the following attributes: 

289 

290 .. attribute:: request 

291 

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

293 

294 .. attribute:: config 

295 

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

297 

298 .. attribute:: app 

299 

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

301 """ 

302 

303 def __init__(self, request): 

304 super().__init__() 

305 self.request = request 

306 self.config = self.request.wutta_config 

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

308 

309 

310class WuttaSet(colander.Set): 

311 """ 

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

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

314 

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

316 

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

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

319 of this type will have the following attributes: 

320 

321 .. attribute:: request 

322 

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

324 

325 .. attribute:: config 

326 

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

328 

329 .. attribute:: app 

330 

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

332 """ 

333 

334 def __init__(self, request): 

335 super().__init__() 

336 self.request = request 

337 self.config = self.request.wutta_config 

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

339 

340 

341class ObjectRef(colander.SchemaType): 

342 """ 

343 Custom schema type for a model class reference field. 

344 

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

346 record instance, or ``None``. 

347 

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

349 form data should be of the same nature. 

350 

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

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

353 :attr:`model_class` attribute or property. 

354 

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

356 

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

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

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

360 

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

362 * label text for the empty option 

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

364 

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

366 """ 

367 

368 default_empty_option = ("", "(none)") 

369 

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

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

372 # nb. allow session injection for tests 

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

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

375 self.request = request 

376 self.config = self.request.wutta_config 

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

378 self.model_instance = None 

379 

380 if empty_option: 

381 if empty_option is True: 

382 self.empty_option = self.default_empty_option 

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

384 self.empty_option = empty_option 

385 else: 

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

387 else: 

388 self.empty_option = None 

389 

390 @property 

391 def model_class(self): 

392 """ 

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

394 type applies 

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

396 """ 

397 class_name = self.__class__.__name__ 

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

399 

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

401 """ """ 

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

403 # expected 

404 if self.empty_option and not appstruct: 

405 return self.empty_option[0] 

406 

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

408 # value as null 

409 if not appstruct: 

410 return colander.null 

411 

412 # keep a ref to this for later use 

413 node.model_instance = appstruct 

414 

415 # serialize to PK as string 

416 return self.serialize_object(appstruct) 

417 

418 def serialize_object(self, obj): 

419 """ 

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

421 

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

423 override as needed. 

424 

425 :param obj: Object reference for the node. 

426 

427 :returns: Object primary key as string. 

428 """ 

429 return obj.uuid.hex 

430 

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

432 self, node, cstruct 

433 ): 

434 """ """ 

435 if not cstruct: 

436 return colander.null 

437 

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

439 return self.objectify(cstruct) 

440 

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

442 """ """ 

443 

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

445 return obj 

446 

447 def objectify(self, value): 

448 """ 

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

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

451 

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

453 

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

455 ``colander.Invalid``. 

456 """ 

457 if not value: 

458 return None 

459 

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

461 value, self.model_class 

462 ): 

463 return value 

464 

465 # fetch object from DB 

466 obj = None 

467 if isinstance(value, _uuid.UUID): 

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

469 else: 

470 try: 

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

472 except ValueError: 

473 pass 

474 

475 # raise error if not found 

476 if not obj: 

477 class_name = self.model_class.__name__ 

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

479 

480 return obj 

481 

482 def get_query(self): 

483 """ 

484 Returns the main SQLAlchemy query responsible for locating the 

485 dropdown choices for the select widget. 

486 

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

488 """ 

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

490 query = self.sort_query(query) 

491 return query 

492 

493 def sort_query(self, query): 

494 """ 

495 TODO 

496 """ 

497 return query 

498 

499 def widget_maker(self, **kwargs): 

500 """ 

501 This method is responsible for producing the default widget 

502 for the schema node. 

503 

504 Deform calls this method automatically when constructing the 

505 default widget for a field. 

506 

507 :returns: Instance of 

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

509 """ 

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

511 

512 if "values" not in kwargs: 

513 query = self.get_query() 

514 objects = query.all() 

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

516 if self.empty_option: 

517 values.insert(0, self.empty_option) 

518 kwargs["values"] = values 

519 

520 if "url" not in kwargs: 

521 kwargs["url"] = self.get_object_url 

522 

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

524 

525 def get_object_url(self, obj): 

526 """ 

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

528 

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

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

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

532 

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

534 override as needed. 

535 """ 

536 

537 

538class PersonRef(ObjectRef): 

539 """ 

540 Custom schema type for a 

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

542 field. 

543 

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

545 """ 

546 

547 @property 

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

549 """ """ 

550 model = self.app.model 

551 return model.Person 

552 

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

554 """ """ 

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

556 

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

558 """ """ 

559 person = obj 

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

561 

562 

563class RoleRef(ObjectRef): 

564 """ 

565 Custom schema type for a 

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

567 field. 

568 

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

570 """ 

571 

572 @property 

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

574 """ """ 

575 model = self.app.model 

576 return model.Role 

577 

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

579 """ """ 

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

581 

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

583 """ """ 

584 role = obj 

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

586 

587 

588class UserRef(ObjectRef): 

589 """ 

590 Custom schema type for a 

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

592 field. 

593 

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

595 """ 

596 

597 @property 

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

599 """ """ 

600 model = self.app.model 

601 return model.User 

602 

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

604 """ """ 

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

606 

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

608 """ """ 

609 user = obj 

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

611 

612 

613class RoleRefs(WuttaSet): 

614 """ 

615 Form schema type for the User 

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

617 association proxy field. 

618 

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

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

621 values for underlying data format. 

622 """ 

623 

624 def widget_maker(self, **kwargs): 

625 """ 

626 Constructs a default widget for the field. 

627 

628 :returns: Instance of 

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

630 """ 

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

632 

633 if "values" not in kwargs: 

634 model = self.app.model 

635 auth = self.app.get_auth_handler() 

636 

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

638 avoid = { 

639 auth.get_role_authenticated(session), 

640 auth.get_role_anyone(session), 

641 } 

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

643 

644 # also avoid admin unless current user is root 

645 if not self.request.is_root: 

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

647 

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

649 roles = ( 

650 session.query(model.Role) 

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

652 .order_by(model.Role.name) 

653 .all() 

654 ) 

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

656 kwargs["values"] = values 

657 

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

659 

660 

661class Permissions(WuttaSet): 

662 """ 

663 Form schema type for the Role 

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

665 association proxy field. 

666 

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

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

669 values for underlying data format. 

670 

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

672 in the same format as returned by 

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

674 """ 

675 

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

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

678 self.permissions = permissions 

679 

680 def widget_maker(self, **kwargs): 

681 """ 

682 Constructs a default widget for the field. 

683 

684 :returns: Instance of 

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

686 """ 

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

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

689 

690 if "values" not in kwargs: 

691 values = [] 

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

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

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

695 kwargs["values"] = values 

696 

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

698 

699 

700class FileDownload(colander.String): 

701 """ 

702 Custom schema type for a file download field. 

703 

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

705 uploads. 

706 

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

708 disk (or null). 

709 

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

711 default. 

712 

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

714 

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

716 name/size is shown with no hyperlink. 

717 """ 

718 

719 # pylint: disable=duplicate-code 

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

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

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

723 self.request = request 

724 self.config = self.request.wutta_config 

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

726 

727 # pylint: enable=duplicate-code 

728 

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

730 """ """ 

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

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

733 

734 

735class EmailRecipients(colander.String): 

736 """ 

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

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

739 

740 Note that this inherits from :class:`colander.String` and assumes 

741 the Python value is a "simple" string. Multiple recipients are 

742 represented as comma-delimited for instance; this is for use 

743 "as-is" with the raw :term:`config setting` values. 

744 

745 The value is serialized as a "multiline" string with one recipient 

746 per line. The assumption being, the input widget would display as 

747 a ``<textarea>`` element. 

748 """ 

749 

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

751 """ """ 

752 if appstruct is colander.null: 

753 return colander.null 

754 

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

756 

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

758 """ """ 

759 if cstruct is colander.null: 

760 return colander.null 

761 

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

763 return ", ".join(values) 

764 

765 def widget_maker(self, **kwargs): 

766 """ 

767 Constructs a default widget for the field. 

768 

769 :returns: Instance of 

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

771 """ 

772 return widgets.EmailRecipientsWidget(**kwargs) 

773 

774 

775# nb. colanderalchemy schema overrides 

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