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

198 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2025-12-31 19:25 -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 dt = datetime.datetime.fromisoformat(cstruct) 

238 return self.app.render_date(dt) 

239 

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

241 

242 

243class WuttaDateTimeWidget(DateTimeInputWidget): 

244 """ 

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

246 

247 The main purpose of this widget is to leverage 

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

249 for the readonly display. 

250 

251 It is automatically used for SQLAlchemy mapped classes where the 

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

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

254 use it explicitly via 

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

256 

257 This is a subclass of 

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

259 Deform templates: 

260 

261 * ``datetimeinput`` 

262 """ 

263 

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

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

266 self.request = request 

267 self.config = self.request.wutta_config 

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

269 

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

271 """ """ 

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

273 if readonly: 

274 if not cstruct: 

275 return "" 

276 dt = datetime.datetime.fromisoformat(cstruct) 

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

278 

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

280 

281 

282class WuttaMoneyInputWidget(MoneyInputWidget): 

283 """ 

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

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

286 

287 The main purpose of this widget is to leverage 

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

289 for the readonly display. 

290 

291 This is a subclass of 

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

293 Deform templates: 

294 

295 * ``moneyinput`` 

296 

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

298 

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

300 to ``render_currency()`` call. 

301 """ 

302 

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

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

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

306 self.request = request 

307 self.config = self.request.wutta_config 

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

309 

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

311 """ """ 

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

313 if readonly: 

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

315 return HTML.tag("span") 

316 cstruct = decimal.Decimal(cstruct) 

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

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

319 

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

321 

322 

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

324 """ 

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

326 fields. 

327 

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

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

330 

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

332 uses these Deform templates: 

333 

334 * ``readonly/filedownload`` 

335 

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

337 

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

339 name/size is shown with no hyperlink. 

340 """ 

341 

342 readonly_template = "readonly/filedownload" 

343 

344 # pylint: disable=duplicate-code 

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

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

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

348 self.request = request 

349 self.config = self.request.wutta_config 

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

351 

352 # pylint: enable=duplicate-code 

353 

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

355 """ """ 

356 # nb. readonly is the only way this rolls 

357 kw["readonly"] = True 

358 template = self.readonly_template 

359 

360 path = cstruct or None 

361 if path: 

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

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

364 if self.url: 

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

366 

367 else: 

368 kw.setdefault("filename", None) 

369 kw.setdefault("filesize", None) 

370 

371 kw.setdefault("url", None) 

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

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

374 

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

376 """ """ 

377 try: 

378 size = os.path.getsize(path) 

379 except os.error: 

380 size = 0 

381 return humanize.naturalsize(size) 

382 

383 

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

385 """ 

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

387 

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

389 does not use any Deform templates. 

390 

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

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

393 lifting. 

394 

395 Instead of creating this widget directly you probably should call 

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

397 

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

399 

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

401 display the field data. 

402 """ 

403 

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

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

406 self.request = request 

407 self.grid = grid 

408 

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

410 """ 

411 This widget simply calls 

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

413 the ``grid`` to serialize. 

414 """ 

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

416 if not readonly: 

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

418 

419 return self.grid.render_table_element() 

420 

421 

422class RoleRefsWidget(WuttaCheckboxChoiceWidget): 

423 """ 

424 Widget for use with User 

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

426 This is the default widget for the 

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

428 

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

430 """ 

431 

432 readonly_template = "readonly/rolerefs" 

433 session = None 

434 

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

436 """ """ 

437 model = self.app.model 

438 

439 # special logic when field is editable 

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

441 if not readonly: 

442 

443 # but does not apply if current user is root 

444 if not self.request.is_root: 

445 auth = self.app.get_auth_handler() 

446 admin = auth.get_role_administrator(self.session) 

447 

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

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

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

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

452 kw["values"] = values 

453 

454 else: # readonly 

455 

456 # roles 

457 roles = [] 

458 if cstruct: 

459 for uuid in cstruct: 

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

461 if role: 

462 roles.append(role) 

463 kw["roles"] = roles 

464 

465 # url 

466 def url(role): 

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

468 

469 kw["url"] = url 

470 

471 # default logic from here 

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

473 

474 

475class PermissionsWidget(WuttaCheckboxChoiceWidget): 

476 """ 

477 Widget for use with Role 

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

479 field. 

480 

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

482 these Deform templates: 

483 

484 * ``permissions`` 

485 * ``readonly/permissions`` 

486 """ 

487 

488 template = "permissions" 

489 readonly_template = "readonly/permissions" 

490 permissions = None 

491 

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

493 """ """ 

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

495 

496 if "values" not in kw: 

497 values = [] 

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

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

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

501 kw["values"] = values 

502 

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

504 

505 

506class EmailRecipientsWidget(TextAreaWidget): 

507 """ 

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

509 ``Bcc``). 

510 

511 This is a subclass of 

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

513 Deform templates: 

514 

515 * ``textarea`` 

516 * ``readonly/email_recips`` 

517 

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

519 schema type, which uses this widget. 

520 """ 

521 

522 readonly_template = "readonly/email_recips" 

523 

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

525 """ """ 

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

527 if readonly: 

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

529 

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

531 

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

533 """ """ 

534 if pstruct is colander.null: 

535 return colander.null 

536 

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

538 return ", ".join(values) 

539 

540 

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

542 """ 

543 Widget for use with the 

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

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

546 

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

548 zero-padded 8-char string 

549 """ 

550 

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

552 """ """ 

553 if cstruct is colander.null: 

554 return colander.null 

555 

556 batch_id = int(cstruct) 

557 return f"{batch_id:08d}" 

558 

559 

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

561 """ 

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

563 the revision. 

564 """ 

565 

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

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

568 self.request = request 

569 

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

571 """ """ 

572 if not cstruct: 

573 return colander.null 

574 

575 return tags.link_to( 

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

577 ) 

578 

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

580 """ """ 

581 raise NotImplementedError 

582 

583 

584class AlembicRevisionsWidget(Widget): 

585 """ 

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

587 view each revision. 

588 """ 

589 

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

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

592 self.request = request 

593 self.config = self.request.wutta_config 

594 

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

596 """ """ 

597 if not cstruct: 

598 return colander.null 

599 

600 revisions = [] 

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

602 revisions.append( 

603 tags.link_to( 

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

605 ) 

606 ) 

607 

608 return ", ".join(revisions) 

609 

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

611 """ """ 

612 raise NotImplementedError