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

203 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-20 12:06 -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 widgets 

25 

26This module defines some custom widgets for use with WuttaWeb. 

27 

28However for convenience it also makes other Deform widgets available 

29in the namespace: 

30 

31* :class:`deform:deform.widget.Widget` (base class) 

32* :class:`deform:deform.widget.TextInputWidget` 

33* :class:`deform:deform.widget.TextAreaWidget` 

34* :class:`deform:deform.widget.PasswordWidget` 

35* :class:`deform:deform.widget.CheckedPasswordWidget` 

36* :class:`deform:deform.widget.CheckboxWidget` 

37* :class:`deform:deform.widget.SelectWidget` 

38* :class:`deform:deform.widget.CheckboxChoiceWidget` 

39* :class:`deform:deform.widget.DateInputWidget` 

40* :class:`deform:deform.widget.DateTimeInputWidget` 

41* :class:`deform:deform.widget.MoneyInputWidget` 

42""" 

43 

44import datetime 

45import decimal 

46import os 

47 

48import colander 

49import humanize 

50from deform.widget import ( # pylint: disable=unused-import 

51 Widget, 

52 TextInputWidget, 

53 TextAreaWidget, 

54 PasswordWidget, 

55 CheckedPasswordWidget, 

56 CheckboxWidget, 

57 SelectWidget, 

58 CheckboxChoiceWidget, 

59 DateInputWidget, 

60 DateTimeInputWidget, 

61 MoneyInputWidget, 

62) 

63from webhelpers2.html import HTML, tags 

64 

65from wuttjamaican.conf import parse_list 

66 

67 

68class ObjectRefWidget(SelectWidget): 

69 """ 

70 Widget for use with model "object reference" fields, e.g. foreign 

71 key UUID => TargetModel instance. 

72 

73 While you may create instances of this widget directly, it 

74 normally happens automatically when schema nodes of the 

75 :class:`~wuttaweb.forms.schema.ObjectRef` (sub)type are part of 

76 the form schema; via 

77 :meth:`~wuttaweb.forms.schema.ObjectRef.widget_maker()`. 

78 

79 In readonly mode, this renders a ``<span>`` tag around the 

80 :attr:`model_instance` (converted to string). 

81 

82 Otherwise it renders a select (dropdown) element allowing user to 

83 choose from available records. 

84 

85 This is a subclass of :class:`deform:deform.widget.SelectWidget` 

86 and uses these Deform templates: 

87 

88 * ``select`` 

89 * ``readonly/objectref`` 

90 

91 .. attribute:: model_instance 

92 

93 Reference to the model record instance, i.e. the "far side" of 

94 the foreign key relationship. 

95 

96 .. note:: 

97 

98 You do not need to provide the ``model_instance`` when 

99 constructing the widget. Rather, it is set automatically 

100 when the :class:`~wuttaweb.forms.schema.ObjectRef` type 

101 instance (associated with the node) is serialized. 

102 """ 

103 

104 readonly_template = "readonly/objectref" 

105 

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

107 url = kwargs.pop("url", None) 

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

109 self.request = request 

110 self.url = url 

111 

112 def get_template_values( # pylint: disable=empty-docstring 

113 self, field, cstruct, kw 

114 ): 

115 """ """ 

116 values = super().get_template_values(field, cstruct, kw) 

117 

118 # add url, only if rendering readonly 

119 readonly = kw.get("readonly", self.readonly) 

120 if readonly: 

121 if ( 

122 "url" not in values 

123 and self.url 

124 and getattr(field.schema, "model_instance", None) 

125 ): 

126 values["url"] = self.url(field.schema.model_instance) 

127 

128 return values 

129 

130 

131class NotesWidget(TextAreaWidget): 

132 """ 

133 Widget for use with "notes" fields. 

134 

135 In readonly mode, this shows the notes with a background to make 

136 them stand out a bit more. 

137 

138 Otherwise it effectively shows a ``<textarea>`` input element. 

139 

140 This is a subclass of :class:`deform:deform.widget.TextAreaWidget` 

141 and uses these Deform templates: 

142 

143 * ``textarea`` 

144 * ``readonly/notes`` 

145 """ 

146 

147 readonly_template = "readonly/notes" 

148 

149 

150class CopyableTextWidget(Widget): # pylint: disable=abstract-method 

151 """ 

152 A readonly text widget which adds a "copy" icon/link just after 

153 the text. 

154 """ 

155 

156 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring 

157 """ """ 

158 if not cstruct: 

159 return colander.null 

160 

161 return HTML.tag("wutta-copyable-text", **{"text": cstruct}) 

162 

163 def deserialize(self, field, pstruct): # pylint: disable=empty-docstring 

164 """ """ 

165 raise NotImplementedError 

166 

167 

168class ExternalLinkWidget(TextInputWidget): 

169 """ 

170 Widget for use with "external link" URL fields. In readonly mode, 

171 displays a hyperlink with ``_blank`` window target. 

172 

173 This is a subclass of 

174 :class:`deform:deform.widget.TextInputWidget` and uses these 

175 Deform templates: 

176 

177 * ``textinput`` 

178 * ``readonly/external_link`` 

179 """ 

180 

181 readonly_template = "readonly/external_link" 

182 

183 

184class WuttaCheckboxChoiceWidget(CheckboxChoiceWidget): 

185 """ 

186 Custom widget for :class:`python:set` fields. 

187 

188 This is a subclass of 

189 :class:`deform:deform.widget.CheckboxChoiceWidget`. 

190 

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

192 

193 It uses these Deform templates: 

194 

195 * ``checkbox_choice`` 

196 * ``readonly/checkbox_choice`` 

197 """ 

198 

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

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

201 self.request = request 

202 self.config = self.request.wutta_config 

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

204 

205 

206class WuttaCheckedPasswordWidget(PasswordWidget): 

207 """ 

208 Custom widget for password+confirmation field. 

209 

210 This widget is used only for Vue 3 + Oruga, but is *not* used for 

211 Vue 2 + Buefy. 

212 

213 This is a subclass of :class:`deform:deform.widget.PasswordWidget` 

214 and uses these Deform templates: 

215 

216 * ``wutta_checked_password`` 

217 """ 

218 

219 template = "wutta_checked_password" 

220 

221 

222class WuttaDateWidget(DateInputWidget): 

223 """ 

224 Custom widget for :class:`python:datetime.date` fields. 

225 

226 The main purpose of this widget is to leverage 

227 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_date()` 

228 for the readonly display. 

229 

230 It is automatically used for SQLAlchemy mapped classes where the 

231 field maps to a :class:`sqlalchemy:sqlalchemy.types.Date` column. 

232 For other (non-mapped) date fields, or mapped datetime fields for 

233 which a date widget is preferred, use 

234 :meth:`~wuttaweb.forms.base.Form.set_widget()`. 

235 

236 This is a subclass of 

237 :class:`deform:deform.widget.DateInputWidget` and uses these 

238 Deform templates: 

239 

240 * ``dateinput`` 

241 """ 

242 

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

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 serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring 

250 """ """ 

251 readonly = kw.get("readonly", self.readonly) 

252 if readonly and cstruct: 

253 try: 

254 dt = datetime.date.fromisoformat(cstruct) 

255 except ValueError: 

256 dt = datetime.datetime.fromisoformat(cstruct) 

257 return self.app.render_date(dt) 

258 

259 return super().serialize(field, cstruct, **kw) 

260 

261 

262class WuttaDateTimeWidget(DateTimeInputWidget): 

263 """ 

264 Custom widget for :class:`python:datetime.datetime` fields. 

265 

266 The main purpose of this widget is to leverage 

267 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_datetime()` 

268 for the readonly display. 

269 

270 It is automatically used for SQLAlchemy mapped classes where the 

271 field maps to a :class:`sqlalchemy:sqlalchemy.types.DateTime` 

272 column. For other (non-mapped) datetime fields, you may have to 

273 use it explicitly via 

274 :meth:`~wuttaweb.forms.base.Form.set_widget()`. 

275 

276 This is a subclass of 

277 :class:`deform:deform.widget.DateTimeInputWidget` and uses these 

278 Deform templates: 

279 

280 * ``datetimeinput`` 

281 """ 

282 

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

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

285 self.request = request 

286 self.config = self.request.wutta_config 

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

288 

289 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring 

290 """ """ 

291 readonly = kw.get("readonly", self.readonly) 

292 if readonly: 

293 if not cstruct: 

294 return "" 

295 dt = datetime.datetime.fromisoformat(cstruct) 

296 return self.app.render_datetime(dt, html=True) 

297 

298 return super().serialize(field, cstruct, **kw) 

299 

300 

301class WuttaMoneyInputWidget(MoneyInputWidget): 

302 """ 

303 Custom widget for "money" fields. This is used by default for 

304 :class:`~wuttaweb.forms.schema.WuttaMoney` type nodes. 

305 

306 The main purpose of this widget is to leverage 

307 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_currency()` 

308 for the readonly display. 

309 

310 This is a subclass of 

311 :class:`deform:deform.widget.MoneyInputWidget` and uses these 

312 Deform templates: 

313 

314 * ``moneyinput`` 

315 

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

317 

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

319 to ``render_currency()`` call. 

320 """ 

321 

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

323 self.scale = kwargs.pop("scale", 2) 

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

325 self.request = request 

326 self.config = self.request.wutta_config 

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

328 

329 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring 

330 """ """ 

331 readonly = kw.get("readonly", self.readonly) 

332 if readonly: 

333 if cstruct in (colander.null, None): 

334 return HTML.tag("span") 

335 cstruct = decimal.Decimal(cstruct) 

336 text = self.app.render_currency(cstruct, scale=self.scale) 

337 return HTML.tag("span", c=[text]) 

338 

339 return super().serialize(field, cstruct, **kw) 

340 

341 

342class FileDownloadWidget(Widget): # pylint: disable=abstract-method 

343 """ 

344 Widget for use with :class:`~wuttaweb.forms.schema.FileDownload` 

345 fields. 

346 

347 This only supports readonly, and shows a hyperlink to download the 

348 file. Link text is the filename plus file size. 

349 

350 This is a subclass of :class:`deform:deform.widget.Widget` and 

351 uses these Deform templates: 

352 

353 * ``readonly/filedownload`` 

354 

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

356 

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

358 name/size is shown with no hyperlink. 

359 """ 

360 

361 readonly_template = "readonly/filedownload" 

362 

363 # pylint: disable=duplicate-code 

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

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

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

367 self.request = request 

368 self.config = self.request.wutta_config 

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

370 

371 # pylint: enable=duplicate-code 

372 

373 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring 

374 """ """ 

375 # nb. readonly is the only way this rolls 

376 kw["readonly"] = True 

377 template = self.readonly_template 

378 

379 path = cstruct or None 

380 if path: 

381 kw.setdefault("filename", os.path.basename(path)) 

382 kw.setdefault("filesize", self.readable_size(path)) 

383 if self.url: 

384 kw.setdefault("url", self.url) 

385 

386 else: 

387 kw.setdefault("filename", None) 

388 kw.setdefault("filesize", None) 

389 

390 kw.setdefault("url", None) 

391 values = self.get_template_values(field, cstruct, kw) 

392 return field.renderer(template, **values) 

393 

394 def readable_size(self, path): # pylint: disable=empty-docstring 

395 """ """ 

396 try: 

397 size = os.path.getsize(path) 

398 except os.error: 

399 size = 0 

400 return humanize.naturalsize(size) 

401 

402 

403class GridWidget(Widget): # pylint: disable=abstract-method 

404 """ 

405 Widget for fields whose data is represented by a :term:`grid`. 

406 

407 This is a subclass of :class:`deform:deform.widget.Widget` but 

408 does not use any Deform templates. 

409 

410 This widget only supports "readonly" mode, is not editable. It is 

411 merely a convenience around the grid itself, which does the heavy 

412 lifting. 

413 

414 Instead of creating this widget directly you probably should call 

415 :meth:`~wuttaweb.forms.base.Form.set_grid()` on your form. 

416 

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

418 

419 :param grid: :class:`~wuttaweb.grids.base.Grid` instance, used to 

420 display the field data. 

421 """ 

422 

423 def __init__(self, request, grid, *args, **kwargs): 

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

425 self.request = request 

426 self.grid = grid 

427 

428 def serialize(self, field, cstruct, **kw): 

429 """ 

430 This widget simply calls 

431 :meth:`~wuttaweb.grids.base.Grid.render_table_element()` on 

432 the ``grid`` to serialize. 

433 """ 

434 readonly = kw.get("readonly", self.readonly) 

435 if not readonly: 

436 raise NotImplementedError("edit not allowed for this widget") 

437 

438 return self.grid.render_table_element() 

439 

440 

441class RoleRefsWidget(WuttaCheckboxChoiceWidget): 

442 """ 

443 Widget for use with User 

444 :attr:`~wuttjamaican:wuttjamaican.db.model.auth.User.roles` field. 

445 This is the default widget for the 

446 :class:`~wuttaweb.forms.schema.RoleRefs` type. 

447 

448 This is a subclass of :class:`WuttaCheckboxChoiceWidget`. 

449 """ 

450 

451 readonly_template = "readonly/rolerefs" 

452 session = None 

453 

454 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring 

455 """ """ 

456 model = self.app.model 

457 

458 # special logic when field is editable 

459 readonly = kw.get("readonly", self.readonly) 

460 if not readonly: 

461 

462 # but does not apply if current user is root 

463 if not self.request.is_root: 

464 auth = self.app.get_auth_handler() 

465 admin = auth.get_role_administrator(self.session) 

466 

467 # prune admin role from values list; it should not be 

468 # one of the options since current user is not admin 

469 values = kw.get("values", self.values) 

470 values = [val for val in values if val[0] != admin.uuid] 

471 kw["values"] = values 

472 

473 else: # readonly 

474 

475 # roles 

476 roles = [] 

477 if cstruct: 

478 for uuid in cstruct: 

479 role = self.session.get(model.Role, uuid) 

480 if role: 

481 roles.append(role) 

482 kw["roles"] = sorted(roles, key=lambda r: r.name) 

483 

484 # url 

485 def url(role): 

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

487 

488 kw["url"] = url 

489 

490 # default logic from here 

491 return super().serialize(field, cstruct, **kw) 

492 

493 

494class PermissionsWidget(WuttaCheckboxChoiceWidget): 

495 """ 

496 Widget for use with Role 

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

498 field. 

499 

500 This is a subclass of :class:`WuttaCheckboxChoiceWidget`. It uses 

501 these Deform templates: 

502 

503 * ``permissions`` 

504 * ``readonly/permissions`` 

505 """ 

506 

507 template = "permissions" 

508 readonly_template = "readonly/permissions" 

509 permissions = None 

510 

511 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring 

512 """ """ 

513 kw.setdefault("permissions", self.permissions) 

514 

515 if "values" not in kw: 

516 values = [] 

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

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

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

520 kw["values"] = values 

521 

522 return super().serialize(field, cstruct, **kw) 

523 

524 

525class EmailRecipientsWidget(TextAreaWidget): 

526 """ 

527 Widget for :term:`email setting` recipient fields (``To``, ``Cc``, 

528 ``Bcc``). 

529 

530 This is a subclass of 

531 :class:`deform:deform.widget.TextAreaWidget`. It uses these 

532 Deform templates: 

533 

534 * ``textarea`` 

535 * ``readonly/email_recips`` 

536 

537 See also the :class:`~wuttaweb.forms.schema.EmailRecipients` 

538 schema type, which uses this widget. 

539 """ 

540 

541 readonly_template = "readonly/email_recips" 

542 

543 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring 

544 """ """ 

545 readonly = kw.get("readonly", self.readonly) 

546 if readonly: 

547 kw["recips"] = parse_list(cstruct or "") 

548 

549 return super().serialize(field, cstruct, **kw) 

550 

551 def deserialize(self, field, pstruct): # pylint: disable=empty-docstring 

552 """ """ 

553 if pstruct is colander.null: 

554 return colander.null 

555 

556 values = [value for value in parse_list(pstruct) if value] 

557 return ", ".join(values) 

558 

559 

560class BatchIdWidget(Widget): # pylint: disable=abstract-method 

561 """ 

562 Widget for use with the 

563 :attr:`~wuttjamaican:wuttjamaican.db.model.batch.BatchMixin.id` 

564 field of a :term:`batch` model. 

565 

566 This widget is "always" read-only and renders the Batch ID as 

567 zero-padded 8-char string 

568 """ 

569 

570 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring 

571 """ """ 

572 if cstruct is colander.null: 

573 return colander.null 

574 

575 batch_id = int(cstruct) 

576 return f"{batch_id:08d}" 

577 

578 

579class AlembicRevisionWidget(Widget): # pylint: disable=missing-class-docstring 

580 """ 

581 Widget to show an Alembic revision identifier, with link to view 

582 the revision. 

583 """ 

584 

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

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

587 self.request = request 

588 

589 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring 

590 """ """ 

591 if not cstruct: 

592 return colander.null 

593 

594 return tags.link_to( 

595 cstruct, self.request.route_url("alembic.migrations.view", revision=cstruct) 

596 ) 

597 

598 def deserialize(self, field, pstruct): # pylint: disable=empty-docstring 

599 """ """ 

600 raise NotImplementedError 

601 

602 

603class AlembicRevisionsWidget(Widget): 

604 """ 

605 Widget to show list of Alembic revision identifiers, with links to 

606 view each revision. 

607 """ 

608 

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

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

611 self.request = request 

612 self.config = self.request.wutta_config 

613 

614 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring 

615 """ """ 

616 if not cstruct: 

617 return colander.null 

618 

619 revisions = [] 

620 for rev in self.config.parse_list(cstruct): 

621 revisions.append( 

622 tags.link_to( 

623 rev, self.request.route_url("alembic.migrations.view", revision=rev) 

624 ) 

625 ) 

626 

627 return ", ".join(revisions) 

628 

629 def deserialize(self, field, pstruct): # pylint: disable=empty-docstring 

630 """ """ 

631 raise NotImplementedError