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
« 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
26This module defines some custom widgets for use with WuttaWeb.
28However for convenience it also makes other Deform widgets available
29in the namespace:
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"""
44import datetime
45import decimal
46import os
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
65from wuttjamaican.conf import parse_list
68class ObjectRefWidget(SelectWidget):
69 """
70 Widget for use with model "object reference" fields, e.g. foreign
71 key UUID => TargetModel instance.
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()`.
79 In readonly mode, this renders a ``<span>`` tag around the
80 :attr:`model_instance` (converted to string).
82 Otherwise it renders a select (dropdown) element allowing user to
83 choose from available records.
85 This is a subclass of :class:`deform:deform.widget.SelectWidget`
86 and uses these Deform templates:
88 * ``select``
89 * ``readonly/objectref``
91 .. attribute:: model_instance
93 Reference to the model record instance, i.e. the "far side" of
94 the foreign key relationship.
96 .. note::
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 """
104 readonly_template = "readonly/objectref"
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
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)
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)
128 return values
131class NotesWidget(TextAreaWidget):
132 """
133 Widget for use with "notes" fields.
135 In readonly mode, this shows the notes with a background to make
136 them stand out a bit more.
138 Otherwise it effectively shows a ``<textarea>`` input element.
140 This is a subclass of :class:`deform:deform.widget.TextAreaWidget`
141 and uses these Deform templates:
143 * ``textarea``
144 * ``readonly/notes``
145 """
147 readonly_template = "readonly/notes"
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 """
156 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
157 """ """
158 if not cstruct:
159 return colander.null
161 return HTML.tag("wutta-copyable-text", **{"text": cstruct})
163 def deserialize(self, field, pstruct): # pylint: disable=empty-docstring
164 """ """
165 raise NotImplementedError
168class WuttaCheckboxChoiceWidget(CheckboxChoiceWidget):
169 """
170 Custom widget for :class:`python:set` fields.
172 This is a subclass of
173 :class:`deform:deform.widget.CheckboxChoiceWidget`.
175 :param request: Current :term:`request` object.
177 It uses these Deform templates:
179 * ``checkbox_choice``
180 * ``readonly/checkbox_choice``
181 """
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()
190class WuttaCheckedPasswordWidget(PasswordWidget):
191 """
192 Custom widget for password+confirmation field.
194 This widget is used only for Vue 3 + Oruga, but is *not* used for
195 Vue 2 + Buefy.
197 This is a subclass of :class:`deform:deform.widget.PasswordWidget`
198 and uses these Deform templates:
200 * ``wutta_checked_password``
201 """
203 template = "wutta_checked_password"
206class WuttaDateWidget(DateInputWidget):
207 """
208 Custom widget for :class:`python:datetime.date` fields.
210 The main purpose of this widget is to leverage
211 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_date()`
212 for the readonly display.
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()`.
220 This is a subclass of
221 :class:`deform:deform.widget.DateInputWidget` and uses these
222 Deform templates:
224 * ``dateinput``
225 """
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()
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)
243 return super().serialize(field, cstruct, **kw)
246class WuttaDateTimeWidget(DateTimeInputWidget):
247 """
248 Custom widget for :class:`python:datetime.datetime` fields.
250 The main purpose of this widget is to leverage
251 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_datetime()`
252 for the readonly display.
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()`.
260 This is a subclass of
261 :class:`deform:deform.widget.DateTimeInputWidget` and uses these
262 Deform templates:
264 * ``datetimeinput``
265 """
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()
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)
282 return super().serialize(field, cstruct, **kw)
285class WuttaMoneyInputWidget(MoneyInputWidget):
286 """
287 Custom widget for "money" fields. This is used by default for
288 :class:`~wuttaweb.forms.schema.WuttaMoney` type nodes.
290 The main purpose of this widget is to leverage
291 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_currency()`
292 for the readonly display.
294 This is a subclass of
295 :class:`deform:deform.widget.MoneyInputWidget` and uses these
296 Deform templates:
298 * ``moneyinput``
300 :param request: Current :term:`request` object.
302 :param scale: If this kwarg is specified, it will be passed along
303 to ``render_currency()`` call.
304 """
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()
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])
323 return super().serialize(field, cstruct, **kw)
326class FileDownloadWidget(Widget): # pylint: disable=abstract-method
327 """
328 Widget for use with :class:`~wuttaweb.forms.schema.FileDownload`
329 fields.
331 This only supports readonly, and shows a hyperlink to download the
332 file. Link text is the filename plus file size.
334 This is a subclass of :class:`deform:deform.widget.Widget` and
335 uses these Deform templates:
337 * ``readonly/filedownload``
339 :param request: Current :term:`request` object.
341 :param url: Optional URL for hyperlink. If not specified, file
342 name/size is shown with no hyperlink.
343 """
345 readonly_template = "readonly/filedownload"
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()
355 # pylint: enable=duplicate-code
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
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)
370 else:
371 kw.setdefault("filename", None)
372 kw.setdefault("filesize", None)
374 kw.setdefault("url", None)
375 values = self.get_template_values(field, cstruct, kw)
376 return field.renderer(template, **values)
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)
387class GridWidget(Widget): # pylint: disable=abstract-method
388 """
389 Widget for fields whose data is represented by a :term:`grid`.
391 This is a subclass of :class:`deform:deform.widget.Widget` but
392 does not use any Deform templates.
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.
398 Instead of creating this widget directly you probably should call
399 :meth:`~wuttaweb.forms.base.Form.set_grid()` on your form.
401 :param request: Current :term:`request` object.
403 :param grid: :class:`~wuttaweb.grids.base.Grid` instance, used to
404 display the field data.
405 """
407 def __init__(self, request, grid, *args, **kwargs):
408 super().__init__(*args, **kwargs)
409 self.request = request
410 self.grid = grid
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")
422 return self.grid.render_table_element()
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.
432 This is a subclass of :class:`WuttaCheckboxChoiceWidget`.
433 """
435 readonly_template = "readonly/rolerefs"
436 session = None
438 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
439 """ """
440 model = self.app.model
442 # special logic when field is editable
443 readonly = kw.get("readonly", self.readonly)
444 if not readonly:
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)
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
457 else: # readonly
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)
468 # url
469 def url(role):
470 return self.request.route_url("roles.view", uuid=role.uuid)
472 kw["url"] = url
474 # default logic from here
475 return super().serialize(field, cstruct, **kw)
478class PermissionsWidget(WuttaCheckboxChoiceWidget):
479 """
480 Widget for use with Role
481 :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Role.permissions`
482 field.
484 This is a subclass of :class:`WuttaCheckboxChoiceWidget`. It uses
485 these Deform templates:
487 * ``permissions``
488 * ``readonly/permissions``
489 """
491 template = "permissions"
492 readonly_template = "readonly/permissions"
493 permissions = None
495 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
496 """ """
497 kw.setdefault("permissions", self.permissions)
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
506 return super().serialize(field, cstruct, **kw)
509class EmailRecipientsWidget(TextAreaWidget):
510 """
511 Widget for :term:`email setting` recipient fields (``To``, ``Cc``,
512 ``Bcc``).
514 This is a subclass of
515 :class:`deform:deform.widget.TextAreaWidget`. It uses these
516 Deform templates:
518 * ``textarea``
519 * ``readonly/email_recips``
521 See also the :class:`~wuttaweb.forms.schema.EmailRecipients`
522 schema type, which uses this widget.
523 """
525 readonly_template = "readonly/email_recips"
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 "")
533 return super().serialize(field, cstruct, **kw)
535 def deserialize(self, field, pstruct): # pylint: disable=empty-docstring
536 """ """
537 if pstruct is colander.null:
538 return colander.null
540 values = [value for value in parse_list(pstruct) if value]
541 return ", ".join(values)
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.
550 This widget is "always" read-only and renders the Batch ID as
551 zero-padded 8-char string
552 """
554 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
555 """ """
556 if cstruct is colander.null:
557 return colander.null
559 batch_id = int(cstruct)
560 return f"{batch_id:08d}"
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 """
569 def __init__(self, request, *args, **kwargs):
570 super().__init__(*args, **kwargs)
571 self.request = request
573 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
574 """ """
575 if not cstruct:
576 return colander.null
578 return tags.link_to(
579 cstruct, self.request.route_url("alembic.migrations.view", revision=cstruct)
580 )
582 def deserialize(self, field, pstruct): # pylint: disable=empty-docstring
583 """ """
584 raise NotImplementedError
587class AlembicRevisionsWidget(Widget):
588 """
589 Widget to show list of Alembic revision identifiers, with links to
590 view each revision.
591 """
593 def __init__(self, request, *args, **kwargs):
594 super().__init__(*args, **kwargs)
595 self.request = request
596 self.config = self.request.wutta_config
598 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
599 """ """
600 if not cstruct:
601 return colander.null
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 )
611 return ", ".join(revisions)
613 def deserialize(self, field, pstruct): # pylint: disable=empty-docstring
614 """ """
615 raise NotImplementedError