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

246 statements  

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

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

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

3# 

4# wuttaweb -- Web App for Wutta Framework 

5# Copyright © 2024-2025 Lance Edgar 

6# 

7# This file is part of Wutta Framework. 

8# 

9# Wutta Framework is free software: you can redistribute it and/or modify it 

10# under the terms of the GNU General Public License as published by the Free 

11# Software Foundation, either version 3 of the License, or (at your option) any 

12# later version. 

13# 

14# Wutta Framework is distributed in the hope that it will be useful, but 

15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 

16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for 

17# more details. 

18# 

19# You should have received a copy of the GNU General Public License along with 

20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>. 

21# 

22################################################################################ 

23""" 

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 super().__init__(*args, **kwargs) 

209 self.request = request 

210 self.config = self.request.wutta_config 

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

212 self.enum_dct = enum_dct 

213 

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

215 """ """ 

216 if "values" not in kwargs: 

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

218 

219 return widgets.SelectWidget(**kwargs) 

220 

221 

222class WuttaMoney(colander.Money): 

223 """ 

224 Custom schema type for "money" fields. 

225 

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

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

228 by default. 

229 

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

231 

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

233 to the widget constructor. 

234 """ 

235 

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

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

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

239 self.request = request 

240 self.config = self.request.wutta_config 

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

242 

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

244 """ """ 

245 if self.scale: 

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

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

248 

249 

250class WuttaQuantity(colander.Decimal): 

251 """ 

252 Custom schema type for "quantity" fields. 

253 

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

255 serialize values via 

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

257 

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

259 """ 

260 

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

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

263 self.request = request 

264 self.config = self.request.wutta_config 

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

266 

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

268 """ """ 

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

270 return colander.null 

271 

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

273 # so we just show value like 12 instead 

274 return self.app.render_quantity(appstruct) 

275 

276 

277class WuttaSet(colander.Set): 

278 """ 

279 Custom schema type for :class:`python:set` fields. 

280 

281 This is a subclass of :class:`colander.Set`. 

282 

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

284 """ 

285 

286 def __init__(self, request): 

287 super().__init__() 

288 self.request = request 

289 self.config = self.request.wutta_config 

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

291 

292 

293class ObjectRef(colander.SchemaType): 

294 """ 

295 Custom schema type for a model class reference field. 

296 

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

298 record instance, or ``None``. 

299 

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

301 form data should be of the same nature. 

302 

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

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

305 :attr:`model_class` attribute or property. 

306 

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

308 

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

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

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

312 

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

314 * label text for the empty option 

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

316 

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

318 """ 

319 

320 default_empty_option = ("", "(none)") 

321 

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

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

324 # nb. allow session injection for tests 

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

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

327 self.request = request 

328 self.config = self.request.wutta_config 

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

330 self.model_instance = None 

331 

332 if empty_option: 

333 if empty_option is True: 

334 self.empty_option = self.default_empty_option 

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

336 self.empty_option = empty_option 

337 else: 

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

339 else: 

340 self.empty_option = None 

341 

342 @property 

343 def model_class(self): 

344 """ 

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

346 type applies 

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

348 """ 

349 class_name = self.__class__.__name__ 

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

351 

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

353 """ """ 

354 # nb. normalize to empty option if no object ref, so that 

355 # works as expected 

356 if self.empty_option and not appstruct: 

357 return self.empty_option[0] 

358 

359 if appstruct is colander.null: 

360 return colander.null 

361 

362 # nb. keep a ref to this for later use 

363 node.model_instance = appstruct 

364 

365 # serialize to PK as string 

366 return self.serialize_object(appstruct) 

367 

368 def serialize_object(self, obj): 

369 """ 

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

371 

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

373 override as needed. 

374 

375 :param obj: Object reference for the node. 

376 

377 :returns: Object primary key as string. 

378 """ 

379 return obj.uuid.hex 

380 

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

382 self, node, cstruct 

383 ): 

384 """ """ 

385 if not cstruct: 

386 return colander.null 

387 

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

389 return self.objectify(cstruct) 

390 

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

392 """ """ 

393 

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

395 return obj 

396 

397 def objectify(self, value): 

398 """ 

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

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

401 

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

403 

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

405 ``colander.Invalid``. 

406 """ 

407 if not value: 

408 return None 

409 

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

411 value, self.model_class 

412 ): 

413 return value 

414 

415 # fetch object from DB 

416 obj = None 

417 if isinstance(value, _uuid.UUID): 

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

419 else: 

420 try: 

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

422 except ValueError: 

423 pass 

424 

425 # raise error if not found 

426 if not obj: 

427 class_name = self.model_class.__name__ 

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

429 

430 return obj 

431 

432 def get_query(self): 

433 """ 

434 Returns the main SQLAlchemy query responsible for locating the 

435 dropdown choices for the select widget. 

436 

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

438 """ 

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

440 query = self.sort_query(query) 

441 return query 

442 

443 def sort_query(self, query): 

444 """ 

445 TODO 

446 """ 

447 return query 

448 

449 def widget_maker(self, **kwargs): 

450 """ 

451 This method is responsible for producing the default widget 

452 for the schema node. 

453 

454 Deform calls this method automatically when constructing the 

455 default widget for a field. 

456 

457 :returns: Instance of 

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

459 """ 

460 

461 if "values" not in kwargs: 

462 query = self.get_query() 

463 objects = query.all() 

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

465 if self.empty_option: 

466 values.insert(0, self.empty_option) 

467 kwargs["values"] = values 

468 

469 if "url" not in kwargs: 

470 kwargs["url"] = self.get_object_url 

471 

472 return widgets.ObjectRefWidget(self.request, **kwargs) 

473 

474 def get_object_url(self, obj): 

475 """ 

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

477 

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

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

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

481 

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

483 override as needed. 

484 """ 

485 

486 

487class PersonRef(ObjectRef): 

488 """ 

489 Custom schema type for a 

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

491 field. 

492 

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

494 """ 

495 

496 @property 

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

498 """ """ 

499 model = self.app.model 

500 return model.Person 

501 

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

503 """ """ 

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

505 

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

507 """ """ 

508 person = obj 

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

510 

511 

512class RoleRef(ObjectRef): 

513 """ 

514 Custom schema type for a 

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

516 field. 

517 

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

519 """ 

520 

521 @property 

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

523 """ """ 

524 model = self.app.model 

525 return model.Role 

526 

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

528 """ """ 

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

530 

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

532 """ """ 

533 role = obj 

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

535 

536 

537class UserRef(ObjectRef): 

538 """ 

539 Custom schema type for a 

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

541 field. 

542 

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

544 """ 

545 

546 @property 

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

548 """ """ 

549 model = self.app.model 

550 return model.User 

551 

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

553 """ """ 

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

555 

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

557 """ """ 

558 user = obj 

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

560 

561 

562class RoleRefs(WuttaSet): 

563 """ 

564 Form schema type for the User 

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

566 association proxy field. 

567 

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

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

570 values for underlying data format. 

571 """ 

572 

573 def widget_maker(self, **kwargs): 

574 """ 

575 Constructs a default widget for the field. 

576 

577 :returns: Instance of 

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

579 """ 

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

581 

582 if "values" not in kwargs: 

583 model = self.app.model 

584 auth = self.app.get_auth_handler() 

585 

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

587 avoid = { 

588 auth.get_role_authenticated(session), 

589 auth.get_role_anonymous(session), 

590 } 

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

592 

593 # also avoid admin unless current user is root 

594 if not self.request.is_root: 

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

596 

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

598 roles = ( 

599 session.query(model.Role) 

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

601 .order_by(model.Role.name) 

602 .all() 

603 ) 

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

605 kwargs["values"] = values 

606 

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

608 

609 

610class Permissions(WuttaSet): 

611 """ 

612 Form schema type for the Role 

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

614 association proxy field. 

615 

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

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

618 values for underlying data format. 

619 

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

621 in the same format as returned by 

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

623 """ 

624 

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

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

627 self.permissions = permissions 

628 

629 def widget_maker(self, **kwargs): 

630 """ 

631 Constructs a default widget for the field. 

632 

633 :returns: Instance of 

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

635 """ 

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

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

638 

639 if "values" not in kwargs: 

640 values = [] 

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

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

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

644 kwargs["values"] = values 

645 

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

647 

648 

649class FileDownload(colander.String): 

650 """ 

651 Custom schema type for a file download field. 

652 

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

654 uploads. 

655 

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

657 disk (or null). 

658 

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

660 default. 

661 

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

663 

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

665 name/size is shown with no hyperlink. 

666 """ 

667 

668 # pylint: disable=duplicate-code 

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

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

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

672 self.request = request 

673 self.config = self.request.wutta_config 

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

675 

676 # pylint: enable=duplicate-code 

677 

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

679 """ """ 

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

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

682 

683 

684class EmailRecipients(colander.String): 

685 """ 

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

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

688 """ 

689 

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

691 """ """ 

692 if appstruct is colander.null: 

693 return colander.null 

694 

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

696 

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

698 """ """ 

699 if cstruct is colander.null: 

700 return colander.null 

701 

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

703 return ", ".join(values) 

704 

705 def widget_maker(self, **kwargs): 

706 """ 

707 Constructs a default widget for the field. 

708 

709 :returns: Instance of 

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

711 """ 

712 return widgets.EmailRecipientsWidget(**kwargs) 

713 

714 

715# nb. colanderalchemy schema overrides 

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