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

229 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-01-15 08:54 -0600

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

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

3# 

4# wuttaweb -- Web App for Wutta Framework 

5# Copyright © 2024 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.db.model import Person 

34from wuttjamaican.conf import parse_list 

35 

36from wuttaweb.db import Session 

37from wuttaweb.forms import widgets 

38 

39 

40class WuttaDateTime(colander.DateTime): 

41 """ 

42 Custom schema type for ``datetime`` fields. 

43 

44 This should be used automatically for 

45 :class:`sqlalchemy:sqlalchemy.types.DateTime` columns unless you 

46 register another default. 

47 

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

49 the Buefy datepicker + timepicker widgets. 

50 """ 

51 

52 def deserialize(self, node, cstruct): 

53 """ """ 

54 if not cstruct: 

55 return colander.null 

56 

57 formats = [ 

58 '%Y-%m-%dT%H:%M:%S', 

59 '%Y-%m-%dT%I:%M %p', 

60 ] 

61 

62 for fmt in formats: 

63 try: 

64 return datetime.datetime.strptime(cstruct, fmt) 

65 except: 

66 pass 

67 

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

69 

70 

71class ObjectNode(colander.SchemaNode): 

72 """ 

73 Custom schema node class which adds methods for compatibility with 

74 ColanderAlchemy. This is a direct subclass of 

75 :class:`colander:colander.SchemaNode`. 

76 

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

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

79 for ColanderAlchemy nodes. 

80 

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

82 methods needed by ColanderAlchemy. 

83 """ 

84 

85 def dictify(self, obj): 

86 """ 

87 This method is called by ColanderAlchemy when translating the 

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

89 data dict. 

90 

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

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

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

94 

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

96 :class:`ObjectRef`. 

97 

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

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

100 """ 

101 if hasattr(self.typ, 'dictify'): 

102 return self.typ.dictify(obj) 

103 

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

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

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

107 # not sure what that is yet 

108 return str(obj) 

109 

110 def objectify(self, value): 

111 """ 

112 This method is called by ColanderAlchemy when translating form 

113 data to the final Python representation. 

114 

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

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

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

118 

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

120 :class:`ObjectRef`. 

121 

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

123 this will raise ``NotImplementeError``. 

124 """ 

125 if hasattr(self.typ, 'objectify'): 

126 return self.typ.objectify(value) 

127 

128 class_name = self.typ.__class__.__name__ 

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

130 

131 

132class WuttaEnum(colander.Enum): 

133 """ 

134 Custom schema type for enum fields. 

135 

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

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

138 

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

140 """ 

141 

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

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

144 self.request = request 

145 self.config = self.request.wutta_config 

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

147 

148 def widget_maker(self, **kwargs): 

149 """ """ 

150 

151 if 'values' not in kwargs: 

152 kwargs['values'] = [(getattr(e, self.attr), getattr(e, self.attr)) 

153 for e in self.enum_cls] 

154 

155 return widgets.SelectWidget(**kwargs) 

156 

157 

158class WuttaDictEnum(colander.String): 

159 """ 

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

161 known values instead of a true enum class. 

162 

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

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

165 

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

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

168 

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

170 

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

172 """ 

173 

174 def __init__(self, request, enum_dct, *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 self.enum_dct = enum_dct 

180 

181 def widget_maker(self, **kwargs): 

182 """ """ 

183 if 'values' not in kwargs: 

184 kwargs['values'] = [(k, v) for k, v in self.enum_dct.items()] 

185 

186 return widgets.SelectWidget(**kwargs) 

187 

188 

189class WuttaMoney(colander.Money): 

190 """ 

191 Custom schema type for "money" fields. 

192 

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

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

195 by default. 

196 

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

198 

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

200 to the widget constructor. 

201 """ 

202 

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

204 self.scale = kwargs.pop('scale', None) 

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

206 self.request = request 

207 self.config = self.request.wutta_config 

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

209 

210 def widget_maker(self, **kwargs): 

211 """ """ 

212 if self.scale: 

213 kwargs.setdefault('scale', self.scale) 

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

215 

216 

217class WuttaQuantity(colander.Decimal): 

218 """ 

219 Custom schema type for "quantity" fields. 

220 

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

222 serialize values via 

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

224 

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

226 """ 

227 

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

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

230 self.request = request 

231 self.config = self.request.wutta_config 

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

233 

234 def serialize(self, node, appstruct): 

235 """ """ 

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

237 return colander.null 

238 

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

240 # so we just show value like 12 instead 

241 return self.app.render_quantity(appstruct) 

242 

243 

244class WuttaSet(colander.Set): 

245 """ 

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

247 

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

249 

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

251 """ 

252 

253 def __init__(self, request): 

254 super().__init__() 

255 self.request = request 

256 self.config = self.request.wutta_config 

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

258 

259 

260class ObjectRef(colander.SchemaType): 

261 """ 

262 Custom schema type for a model class reference field. 

263 

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

265 record instance, or ``None``. 

266 

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

268 form data should be of the same nature. 

269 

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

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

272 :attr:`model_class` attribute or property. 

273 

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

275 

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

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

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

279 

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

281 * label text for the empty option 

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

283 

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

285 """ 

286 

287 default_empty_option = ('', "(none)") 

288 

289 def __init__( 

290 self, 

291 request, 

292 empty_option=None, 

293 *args, 

294 **kwargs, 

295 ): 

296 # nb. allow session injection for tests 

297 self.session = kwargs.pop('session', Session()) 

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

299 self.request = request 

300 self.config = self.request.wutta_config 

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

302 self.model_instance = None 

303 

304 if empty_option: 

305 if empty_option is True: 

306 self.empty_option = self.default_empty_option 

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

308 self.empty_option = empty_option 

309 else: 

310 self.empty_option = ('', str(empty_option)) 

311 else: 

312 self.empty_option = None 

313 

314 @property 

315 def model_class(self): 

316 """ 

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

318 type applies 

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

320 """ 

321 class_name = self.__class__.__name__ 

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

323 

324 def serialize(self, node, appstruct): 

325 """ """ 

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

327 # works as expected 

328 if self.empty_option and not appstruct: 

329 return self.empty_option[0] 

330 

331 if appstruct is colander.null: 

332 return colander.null 

333 

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

335 node.model_instance = appstruct 

336 

337 # serialize to PK as string 

338 return self.serialize_object(appstruct) 

339 

340 def serialize_object(self, obj): 

341 """ 

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

343 

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

345 override as needed. 

346 

347 :param obj: Object reference for the node. 

348 

349 :returns: Object primary key as string. 

350 """ 

351 return obj.uuid.hex 

352 

353 def deserialize(self, node, cstruct): 

354 """ """ 

355 if not cstruct: 

356 return colander.null 

357 

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

359 return self.objectify(cstruct) 

360 

361 def dictify(self, obj): 

362 """ """ 

363 

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

365 return obj 

366 

367 def objectify(self, value): 

368 """ 

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

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

371 

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

373 

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

375 ``colander.Invalid``. 

376 """ 

377 if not value: 

378 return 

379 

380 if isinstance(value, self.model_class): 

381 return value 

382 

383 # fetch object from DB 

384 model = self.app.model 

385 obj = None 

386 if isinstance(value, _uuid.UUID): 

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

388 else: 

389 try: 

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

391 except ValueError: 

392 pass 

393 

394 # raise error if not found 

395 if not obj: 

396 class_name = self.model_class.__name__ 

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

398 

399 return obj 

400 

401 def get_query(self): 

402 """ 

403 Returns the main SQLAlchemy query responsible for locating the 

404 dropdown choices for the select widget. 

405 

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

407 """ 

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

409 query = self.sort_query(query) 

410 return query 

411 

412 def sort_query(self, query): 

413 """ 

414 TODO 

415 """ 

416 return query 

417 

418 def widget_maker(self, **kwargs): 

419 """ 

420 This method is responsible for producing the default widget 

421 for the schema node. 

422 

423 Deform calls this method automatically when constructing the 

424 default widget for a field. 

425 

426 :returns: Instance of 

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

428 """ 

429 

430 if 'values' not in kwargs: 

431 query = self.get_query() 

432 objects = query.all() 

433 values = [(self.serialize_object(obj), str(obj)) 

434 for obj in objects] 

435 if self.empty_option: 

436 values.insert(0, self.empty_option) 

437 kwargs['values'] = values 

438 

439 if 'url' not in kwargs: 

440 kwargs['url'] = self.get_object_url 

441 

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

443 

444 def get_object_url(self, obj): 

445 """ 

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

447 

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

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

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

451 

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

453 override as needed. 

454 """ 

455 

456 

457class PersonRef(ObjectRef): 

458 """ 

459 Custom schema type for a 

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

461 field. 

462 

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

464 """ 

465 

466 @property 

467 def model_class(self): 

468 """ """ 

469 model = self.app.model 

470 return model.Person 

471 

472 def sort_query(self, query): 

473 """ """ 

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

475 

476 def get_object_url(self, person): 

477 """ """ 

478 return self.request.route_url('people.view', uuid=person.uuid) 

479 

480 

481class RoleRef(ObjectRef): 

482 """ 

483 Custom schema type for a 

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

485 field. 

486 

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

488 """ 

489 

490 @property 

491 def model_class(self): 

492 """ """ 

493 model = self.app.model 

494 return model.Role 

495 

496 def sort_query(self, query): 

497 """ """ 

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

499 

500 def get_object_url(self, role): 

501 """ """ 

502 return self.request.route_url('roles.view', uuid=role.uuid) 

503 

504 

505class UserRef(ObjectRef): 

506 """ 

507 Custom schema type for a 

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

509 field. 

510 

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

512 """ 

513 

514 @property 

515 def model_class(self): 

516 """ """ 

517 model = self.app.model 

518 return model.User 

519 

520 def sort_query(self, query): 

521 """ """ 

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

523 

524 def get_object_url(self, user): 

525 """ """ 

526 return self.request.route_url('users.view', uuid=user.uuid) 

527 

528 

529class RoleRefs(WuttaSet): 

530 """ 

531 Form schema type for the User 

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

533 association proxy field. 

534 

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

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

537 values for underlying data format. 

538 """ 

539 

540 def widget_maker(self, **kwargs): 

541 """ 

542 Constructs a default widget for the field. 

543 

544 :returns: Instance of 

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

546 """ 

547 session = kwargs.setdefault('session', Session()) 

548 

549 if 'values' not in kwargs: 

550 model = self.app.model 

551 auth = self.app.get_auth_handler() 

552 

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

554 avoid = { 

555 auth.get_role_authenticated(session), 

556 auth.get_role_anonymous(session), 

557 } 

558 avoid = set([role.uuid for role in avoid]) 

559 

560 # also avoid admin unless current user is root 

561 if not self.request.is_root: 

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

563 

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

565 roles = session.query(model.Role)\ 

566 .filter(~model.Role.uuid.in_(avoid))\ 

567 .order_by(model.Role.name)\ 

568 .all() 

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

570 kwargs['values'] = values 

571 

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

573 

574 

575class UserRefs(WuttaSet): 

576 """ 

577 Form schema type for the Role 

578 :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Role.users` 

579 association proxy field. 

580 

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

582 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` ``uuid`` 

583 values for underlying data format. 

584 """ 

585 

586 def widget_maker(self, **kwargs): 

587 """ 

588 Constructs a default widget for the field. 

589 

590 :returns: Instance of 

591 :class:`~wuttaweb.forms.widgets.UserRefsWidget`. 

592 """ 

593 kwargs.setdefault('session', Session()) 

594 return widgets.UserRefsWidget(self.request, **kwargs) 

595 

596 

597class Permissions(WuttaSet): 

598 """ 

599 Form schema type for the Role 

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

601 association proxy field. 

602 

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

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

605 values for underlying data format. 

606 

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

608 in the same format as returned by 

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

610 """ 

611 

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

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

614 self.permissions = permissions 

615 

616 def widget_maker(self, **kwargs): 

617 """ 

618 Constructs a default widget for the field. 

619 

620 :returns: Instance of 

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

622 """ 

623 kwargs.setdefault('session', Session()) 

624 kwargs.setdefault('permissions', self.permissions) 

625 

626 if 'values' not in kwargs: 

627 values = [] 

628 for gkey, group in self.permissions.items(): 

629 for pkey, perm in group['perms'].items(): 

630 values.append((pkey, perm['label'])) 

631 kwargs['values'] = values 

632 

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

634 

635 

636class FileDownload(colander.String): 

637 """ 

638 Custom schema type for a file download field. 

639 

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

641 uploads. 

642 

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

644 disk (or null). 

645 

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

647 default. 

648 

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

650 

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

652 name/size is shown with no hyperlink. 

653 """ 

654 

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

656 self.url = kwargs.pop('url', None) 

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

658 self.request = request 

659 self.config = self.request.wutta_config 

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

661 

662 def widget_maker(self, **kwargs): 

663 """ """ 

664 kwargs.setdefault('url', self.url) 

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

666 

667 

668class EmailRecipients(colander.String): 

669 """ 

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

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

672 """ 

673 

674 def serialize(self, node, appstruct): 

675 if appstruct is colander.null: 

676 return colander.null 

677 

678 return '\n'.join(parse_list(appstruct)) 

679 

680 def deserialize(self, node, cstruct): 

681 """ """ 

682 if cstruct is colander.null: 

683 return colander.null 

684 

685 values = [value for value in parse_list(cstruct) 

686 if value] 

687 return ', '.join(values) 

688 

689 def widget_maker(self, **kwargs): 

690 """ 

691 Constructs a default widget for the field. 

692 

693 :returns: Instance of 

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

695 """ 

696 return widgets.EmailRecipientsWidget(**kwargs) 

697 

698 

699# nb. colanderalchemy schema overrides 

700sa.DateTime.__colanderalchemy_config__ = {'typ': WuttaDateTime}