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

201 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-04 14:18 -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 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 WuttaCheckboxChoiceWidget(CheckboxChoiceWidget): 

169 """ 

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

171 

172 This is a subclass of 

173 :class:`deform:deform.widget.CheckboxChoiceWidget`. 

174 

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

176 

177 It uses these Deform templates: 

178 

179 * ``checkbox_choice`` 

180 * ``readonly/checkbox_choice`` 

181 """ 

182 

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

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

185 self.request = request 

186 self.config = self.request.wutta_config 

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

188 

189 

190class WuttaCheckedPasswordWidget(PasswordWidget): 

191 """ 

192 Custom widget for password+confirmation field. 

193 

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

195 Vue 2 + Buefy. 

196 

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

198 and uses these Deform templates: 

199 

200 * ``wutta_checked_password`` 

201 """ 

202 

203 template = "wutta_checked_password" 

204 

205 

206class WuttaDateWidget(DateInputWidget): 

207 """ 

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

209 

210 The main purpose of this widget is to leverage 

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

212 for the readonly display. 

213 

214 It is automatically used for SQLAlchemy mapped classes where the 

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

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

217 which a date widget is preferred, use 

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

219 

220 This is a subclass of 

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

222 Deform templates: 

223 

224 * ``dateinput`` 

225 """ 

226 

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

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

229 self.request = request 

230 self.config = self.request.wutta_config 

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

232 

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

234 """ """ 

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

236 if readonly and cstruct: 

237 try: 

238 dt = datetime.date.fromisoformat(cstruct) 

239 except ValueError: 

240 dt = datetime.datetime.fromisoformat(cstruct) 

241 return self.app.render_date(dt) 

242 

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

244 

245 

246class WuttaDateTimeWidget(DateTimeInputWidget): 

247 """ 

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

249 

250 The main purpose of this widget is to leverage 

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

252 for the readonly display. 

253 

254 It is automatically used for SQLAlchemy mapped classes where the 

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

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

257 use it explicitly via 

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

259 

260 This is a subclass of 

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

262 Deform templates: 

263 

264 * ``datetimeinput`` 

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

274 """ """ 

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

276 if readonly: 

277 if not cstruct: 

278 return "" 

279 dt = datetime.datetime.fromisoformat(cstruct) 

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

281 

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

283 

284 

285class WuttaMoneyInputWidget(MoneyInputWidget): 

286 """ 

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

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

289 

290 The main purpose of this widget is to leverage 

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

292 for the readonly display. 

293 

294 This is a subclass of 

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

296 Deform templates: 

297 

298 * ``moneyinput`` 

299 

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

301 

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

303 to ``render_currency()`` call. 

304 """ 

305 

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

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

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

309 self.request = request 

310 self.config = self.request.wutta_config 

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

312 

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

314 """ """ 

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

316 if readonly: 

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

318 return HTML.tag("span") 

319 cstruct = decimal.Decimal(cstruct) 

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

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

322 

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

324 

325 

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

327 """ 

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

329 fields. 

330 

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

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

333 

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

335 uses these Deform templates: 

336 

337 * ``readonly/filedownload`` 

338 

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

340 

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

342 name/size is shown with no hyperlink. 

343 """ 

344 

345 readonly_template = "readonly/filedownload" 

346 

347 # pylint: disable=duplicate-code 

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

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

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

351 self.request = request 

352 self.config = self.request.wutta_config 

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

354 

355 # pylint: enable=duplicate-code 

356 

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

358 """ """ 

359 # nb. readonly is the only way this rolls 

360 kw["readonly"] = True 

361 template = self.readonly_template 

362 

363 path = cstruct or None 

364 if path: 

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

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

367 if self.url: 

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

369 

370 else: 

371 kw.setdefault("filename", None) 

372 kw.setdefault("filesize", None) 

373 

374 kw.setdefault("url", None) 

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

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

377 

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

379 """ """ 

380 try: 

381 size = os.path.getsize(path) 

382 except os.error: 

383 size = 0 

384 return humanize.naturalsize(size) 

385 

386 

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

388 """ 

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

390 

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

392 does not use any Deform templates. 

393 

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

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

396 lifting. 

397 

398 Instead of creating this widget directly you probably should call 

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

400 

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

402 

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

404 display the field data. 

405 """ 

406 

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

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

409 self.request = request 

410 self.grid = grid 

411 

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

413 """ 

414 This widget simply calls 

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

416 the ``grid`` to serialize. 

417 """ 

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

419 if not readonly: 

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

421 

422 return self.grid.render_table_element() 

423 

424 

425class RoleRefsWidget(WuttaCheckboxChoiceWidget): 

426 """ 

427 Widget for use with User 

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

429 This is the default widget for the 

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

431 

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

433 """ 

434 

435 readonly_template = "readonly/rolerefs" 

436 session = None 

437 

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

439 """ """ 

440 model = self.app.model 

441 

442 # special logic when field is editable 

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

444 if not readonly: 

445 

446 # but does not apply if current user is root 

447 if not self.request.is_root: 

448 auth = self.app.get_auth_handler() 

449 admin = auth.get_role_administrator(self.session) 

450 

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

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

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

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

455 kw["values"] = values 

456 

457 else: # readonly 

458 

459 # roles 

460 roles = [] 

461 if cstruct: 

462 for uuid in cstruct: 

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

464 if role: 

465 roles.append(role) 

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

467 

468 # url 

469 def url(role): 

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

471 

472 kw["url"] = url 

473 

474 # default logic from here 

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

476 

477 

478class PermissionsWidget(WuttaCheckboxChoiceWidget): 

479 """ 

480 Widget for use with Role 

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

482 field. 

483 

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

485 these Deform templates: 

486 

487 * ``permissions`` 

488 * ``readonly/permissions`` 

489 """ 

490 

491 template = "permissions" 

492 readonly_template = "readonly/permissions" 

493 permissions = None 

494 

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

496 """ """ 

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

498 

499 if "values" not in kw: 

500 values = [] 

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

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

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

504 kw["values"] = values 

505 

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

507 

508 

509class EmailRecipientsWidget(TextAreaWidget): 

510 """ 

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

512 ``Bcc``). 

513 

514 This is a subclass of 

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

516 Deform templates: 

517 

518 * ``textarea`` 

519 * ``readonly/email_recips`` 

520 

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

522 schema type, which uses this widget. 

523 """ 

524 

525 readonly_template = "readonly/email_recips" 

526 

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

528 """ """ 

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

530 if readonly: 

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

532 

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

534 

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

536 """ """ 

537 if pstruct is colander.null: 

538 return colander.null 

539 

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

541 return ", ".join(values) 

542 

543 

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

545 """ 

546 Widget for use with the 

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

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

549 

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

551 zero-padded 8-char string 

552 """ 

553 

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

555 """ """ 

556 if cstruct is colander.null: 

557 return colander.null 

558 

559 batch_id = int(cstruct) 

560 return f"{batch_id:08d}" 

561 

562 

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

564 """ 

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

566 the revision. 

567 """ 

568 

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

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

571 self.request = request 

572 

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

574 """ """ 

575 if not cstruct: 

576 return colander.null 

577 

578 return tags.link_to( 

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

580 ) 

581 

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

583 """ """ 

584 raise NotImplementedError 

585 

586 

587class AlembicRevisionsWidget(Widget): 

588 """ 

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

590 view each revision. 

591 """ 

592 

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

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

595 self.request = request 

596 self.config = self.request.wutta_config 

597 

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

599 """ """ 

600 if not cstruct: 

601 return colander.null 

602 

603 revisions = [] 

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

605 revisions.append( 

606 tags.link_to( 

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

608 ) 

609 ) 

610 

611 return ", ".join(revisions) 

612 

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

614 """ """ 

615 raise NotImplementedError