Coverage for .tox/coverage/lib/python3.13/site-packages/wuttaweb/forms/widgets.py: 100%
254 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-24 16:36 -0500
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-24 16:36 -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
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
67from wuttaweb.diffs import WebDiff
70class ObjectRefWidget(SelectWidget):
71 """
72 Widget for use with model "object reference" fields, e.g. foreign
73 key UUID => TargetModel instance.
75 While you may create instances of this widget directly, it
76 normally happens automatically when schema nodes of the
77 :class:`~wuttaweb.forms.schema.ObjectRef` (sub)type are part of
78 the form schema; via
79 :meth:`~wuttaweb.forms.schema.ObjectRef.widget_maker()`.
81 In readonly mode, this renders a ``<span>`` tag around the
82 :attr:`model_instance` (converted to string).
84 Otherwise it renders a select (dropdown) element allowing user to
85 choose from available records.
87 This is a subclass of :class:`deform:deform.widget.SelectWidget`
88 and uses these Deform templates:
90 * ``select``
91 * ``readonly/objectref``
93 .. attribute:: model_instance
95 Reference to the model record instance, i.e. the "far side" of
96 the foreign key relationship.
98 .. note::
100 You do not need to provide the ``model_instance`` when
101 constructing the widget. Rather, it is set automatically
102 when the :class:`~wuttaweb.forms.schema.ObjectRef` type
103 instance (associated with the node) is serialized.
104 """
106 readonly_template = "readonly/objectref"
108 def __init__(self, request, *args, **kwargs):
109 url = kwargs.pop("url", None)
110 super().__init__(*args, **kwargs)
111 self.request = request
112 self.url = url
114 def get_template_values( # pylint: disable=empty-docstring
115 self, field, cstruct, kw
116 ):
117 """ """
118 values = super().get_template_values(field, cstruct, kw)
120 # add url, only if rendering readonly
121 readonly = kw.get("readonly", self.readonly)
122 if readonly:
123 if (
124 "url" not in values
125 and self.url
126 and getattr(field.schema, "model_instance", None)
127 ):
128 values["url"] = self.url(field.schema.model_instance)
130 return values
133class NotesWidget(TextAreaWidget):
134 """
135 Widget for use with "notes" fields.
137 In readonly mode, this shows the notes with a background to make
138 them stand out a bit more.
140 Otherwise it effectively shows a ``<textarea>`` input element.
142 This is a subclass of :class:`deform:deform.widget.TextAreaWidget`
143 and uses these Deform templates:
145 * ``textarea``
146 * ``readonly/notes``
147 """
149 readonly_template = "readonly/notes"
152class CopyableTextWidget(Widget): # pylint: disable=abstract-method
153 """
154 A readonly text widget which adds a "copy" icon/link just after
155 the text.
156 """
158 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
159 """ """
160 if not cstruct:
161 return colander.null
163 return HTML.tag("wutta-copyable-text", **{"text": cstruct})
165 def deserialize(self, field, pstruct): # pylint: disable=empty-docstring
166 """ """
167 raise NotImplementedError
170class ExternalLinkWidget(TextInputWidget):
171 """
172 Widget for use with "external link" URL fields. In readonly mode,
173 displays a hyperlink with ``_blank`` window target.
175 This is a subclass of
176 :class:`deform:deform.widget.TextInputWidget` and uses these
177 Deform templates:
179 * ``textinput``
180 * ``readonly/external_link``
181 """
183 readonly_template = "readonly/external_link"
186class WuttaCheckboxChoiceWidget(CheckboxChoiceWidget):
187 """
188 Custom widget for :class:`python:set` fields.
190 This is a subclass of
191 :class:`deform:deform.widget.CheckboxChoiceWidget`.
193 :param request: Current :term:`request` object.
195 It uses these Deform templates:
197 * ``checkbox_choice``
198 * ``readonly/checkbox_choice``
199 """
201 def __init__(self, request, *args, **kwargs):
202 super().__init__(*args, **kwargs)
203 self.request = request
204 self.config = self.request.wutta_config
205 self.app = self.config.get_app()
208class WuttaCheckedPasswordWidget(PasswordWidget):
209 """
210 Custom widget for password+confirmation field.
212 This widget is used only for Vue 3 + Oruga, but is *not* used for
213 Vue 2 + Buefy.
215 This is a subclass of :class:`deform:deform.widget.PasswordWidget`
216 and uses these Deform templates:
218 * ``wutta_checked_password``
219 """
221 template = "wutta_checked_password"
224class WuttaDateWidget(DateInputWidget):
225 """
226 Custom widget for :class:`python:datetime.date` fields.
228 The main purpose of this widget is to leverage
229 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_date()`
230 for the readonly display.
232 It is automatically used for SQLAlchemy mapped classes where the
233 field maps to a :class:`sqlalchemy:sqlalchemy.types.Date` column.
234 For other (non-mapped) date fields, or mapped datetime fields for
235 which a date widget is preferred, use
236 :meth:`~wuttaweb.forms.base.Form.set_widget()`.
238 This is a subclass of
239 :class:`deform:deform.widget.DateInputWidget` and uses these
240 Deform templates:
242 * ``dateinput``
243 """
245 def __init__(self, request, *args, **kwargs):
246 super().__init__(*args, **kwargs)
247 self.request = request
248 self.config = self.request.wutta_config
249 self.app = self.config.get_app()
251 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
252 """ """
253 readonly = kw.get("readonly", self.readonly)
254 if readonly and cstruct:
255 try:
256 dt = datetime.date.fromisoformat(cstruct)
257 except ValueError:
258 dt = datetime.datetime.fromisoformat(cstruct)
259 return self.app.render_date(dt)
261 return super().serialize(field, cstruct, **kw)
264class WuttaDateTimeWidget(DateTimeInputWidget):
265 """
266 Custom widget for :class:`python:datetime.datetime` fields.
268 The main purpose of this widget is to leverage
269 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_datetime()`
270 for the readonly display.
272 It is automatically used for SQLAlchemy mapped classes where the
273 field maps to a :class:`sqlalchemy:sqlalchemy.types.DateTime`
274 column. For other (non-mapped) datetime fields, you may have to
275 use it explicitly via
276 :meth:`~wuttaweb.forms.base.Form.set_widget()`.
278 This is a subclass of
279 :class:`deform:deform.widget.DateTimeInputWidget` and uses these
280 Deform templates:
282 * ``datetimeinput``
283 """
285 def __init__(self, request, *args, **kwargs):
286 super().__init__(*args, **kwargs)
287 self.request = request
288 self.config = self.request.wutta_config
289 self.app = self.config.get_app()
291 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
292 """ """
293 readonly = kw.get("readonly", self.readonly)
294 if readonly:
295 if not cstruct:
296 return ""
297 dt = datetime.datetime.fromisoformat(cstruct)
298 return self.app.render_datetime(dt, html=True)
300 return super().serialize(field, cstruct, **kw)
303class WuttaMoneyInputWidget(MoneyInputWidget):
304 """
305 Custom widget for "money" fields. This is used by default for
306 :class:`~wuttaweb.forms.schema.WuttaMoney` type nodes.
308 The main purpose of this widget is to leverage
309 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_currency()`
310 for the readonly display.
312 This is a subclass of
313 :class:`deform:deform.widget.MoneyInputWidget` and uses these
314 Deform templates:
316 * ``moneyinput``
318 :param request: Current :term:`request` object.
320 :param scale: If this kwarg is specified, it will be passed along
321 to ``render_currency()`` call.
322 """
324 def __init__(self, request, *args, **kwargs):
325 self.scale = kwargs.pop("scale", 2)
326 super().__init__(*args, **kwargs)
327 self.request = request
328 self.config = self.request.wutta_config
329 self.app = self.config.get_app()
331 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
332 """ """
333 readonly = kw.get("readonly", self.readonly)
334 if readonly:
335 if cstruct in (colander.null, None):
336 return HTML.tag("span")
337 cstruct = decimal.Decimal(cstruct)
338 text = self.app.render_currency(cstruct, scale=self.scale)
339 return HTML.tag("span", c=[text])
341 return super().serialize(field, cstruct, **kw)
344class WuttaQuantityInputWidget(TextInputWidget):
345 """
346 Custom widget for "quantity" fields. This is used by default for
347 :class:`~wuttaweb.forms.schema.WuttaQuantity` type nodes.
349 The main purpose of this widget is to leverage
350 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_quantity()`
351 for the readonly display.
353 This is a subclass of
354 :class:`deform:deform.widget.TextInputWidget` and uses these
355 Deform templates:
357 * ``textinput``
359 :param request: Current :term:`request` object.
360 """
362 def __init__(self, request, *args, **kwargs):
363 super().__init__(*args, **kwargs)
364 self.request = request
365 self.config = self.request.wutta_config
366 self.app = self.config.get_app()
368 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
369 """ """
370 readonly = kw.get("readonly", self.readonly)
371 if readonly:
372 if cstruct in (colander.null, None):
373 return HTML.tag("span")
375 cstruct = decimal.Decimal(cstruct)
376 text = self.app.render_quantity(cstruct)
377 return HTML.tag("span", c=[text])
379 return super().serialize(field, cstruct, **kw)
382class FileDownloadWidget(Widget): # pylint: disable=abstract-method
383 """
384 Widget for use with :class:`~wuttaweb.forms.schema.FileDownload`
385 fields.
387 This only supports readonly, and shows a hyperlink to download the
388 file. Link text is the filename plus file size.
390 This is a subclass of :class:`deform:deform.widget.Widget` and
391 uses these Deform templates:
393 * ``readonly/filedownload``
395 :param request: Current :term:`request` object.
397 :param url: Optional URL for hyperlink. If not specified, file
398 name/size is shown with no hyperlink.
399 """
401 readonly_template = "readonly/filedownload"
403 # pylint: disable=duplicate-code
404 def __init__(self, request, *args, **kwargs):
405 self.url = kwargs.pop("url", None)
406 super().__init__(*args, **kwargs)
407 self.request = request
408 self.config = self.request.wutta_config
409 self.app = self.config.get_app()
411 # pylint: enable=duplicate-code
413 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
414 """ """
415 # nb. readonly is the only way this rolls
416 kw["readonly"] = True
417 template = self.readonly_template
419 path = cstruct or None
420 if path:
421 kw.setdefault("filename", os.path.basename(path))
422 kw.setdefault("filesize", self.readable_size(path))
423 if self.url:
424 kw.setdefault("url", self.url)
426 else:
427 kw.setdefault("filename", None)
428 kw.setdefault("filesize", None)
430 kw.setdefault("url", None)
431 values = self.get_template_values(field, cstruct, kw)
432 return field.renderer(template, **values)
434 def readable_size(self, path): # pylint: disable=empty-docstring
435 """ """
436 try:
437 size = os.path.getsize(path)
438 except os.error:
439 size = 0
440 return humanize.naturalsize(size)
443class GridWidget(Widget): # pylint: disable=abstract-method
444 """
445 Widget for fields whose data is represented by a :term:`grid`.
447 This is a subclass of :class:`deform:deform.widget.Widget` but
448 does not use any Deform templates.
450 This widget only supports "readonly" mode, is not editable. It is
451 merely a convenience around the grid itself, which does the heavy
452 lifting.
454 Instead of creating this widget directly you probably should call
455 :meth:`~wuttaweb.forms.base.Form.set_grid()` on your form.
457 :param request: Current :term:`request` object.
459 :param grid: :class:`~wuttaweb.grids.base.Grid` instance, used to
460 display the field data.
461 """
463 def __init__(self, request, grid, *args, **kwargs):
464 super().__init__(*args, **kwargs)
465 self.request = request
466 self.grid = grid
468 def serialize(self, field, cstruct, **kw):
469 """
470 This widget simply calls
471 :meth:`~wuttaweb.grids.base.Grid.render_table_element()` on
472 the ``grid`` to serialize.
473 """
474 readonly = kw.get("readonly", self.readonly)
475 if not readonly:
476 raise NotImplementedError("edit not allowed for this widget")
478 return self.grid.render_table_element()
481class DiffWidget(Widget): # pylint: disable=abstract-method
482 """
483 Widget for fields whose data is represented by a diff table.
485 This is a subclass of :class:`deform:deform.widget.Widget` and
486 uses this Deform template:
488 * ``readonly/diff``
490 This widget only supports "readonly" mode, is not editable. It is
491 mostly a convenience around the ``<wutta-diff>`` Vue.js component,
492 and ultimately the :class:`~wuttaweb.diffs.WebDiff` class.
494 :param request: Current :term:`request` object.
496 :param diff: Optional :class:`~wuttaweb.diffs.WebDiff` (or
497 subclass) instance.
499 :param \\*args: Will be passed along to the parent class
500 constructor if you provide the ``diff`` object, otherwise to
501 the ``WebDiff`` constructor.
503 :param \\**kwargs: Will be passed along to the parent class
504 constructor if you provide the ``diff`` object, otherwise to
505 the ``WebDiff`` constructor.
506 """
508 readonly_template = "readonly/diff"
510 def __init__(self, request, *args, **kwargs):
511 self.diff = kwargs.pop("diff", None)
512 if self.diff:
513 super().__init__(*args, **kwargs)
514 else:
515 super().__init__()
517 self.request = request
518 self.config = self.request.wutta_config
520 self.show_filter = kwargs.pop("show_filter", False)
521 self.filtered_by_default = kwargs.pop("filtered_by_default", False)
523 if not self.diff:
525 if self.show_filter:
527 def extra_row_attrs(
528 field, is_diff, attrs
529 ): # pylint: disable=unused-argument
530 if not is_diff:
531 attrs["v-show"] = "!slotProps.filtered"
532 return attrs
534 kwargs["extra_row_attrs"] = extra_row_attrs
536 self.diff = WebDiff(self.config, *args, **kwargs)
538 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
539 """ """
540 readonly = kw.get("readonly", self.readonly)
541 if not readonly:
542 raise NotImplementedError("edit not allowed for this widget")
544 kw.update(
545 {
546 "diff": self.diff.render_html(),
547 "show_filter": self.show_filter,
548 "filtered_by_default": self.filtered_by_default,
549 }
550 )
552 template = self.readonly_template
553 values = self.get_template_values(field, cstruct, kw)
554 return field.renderer(template, **values)
557class RoleRefsWidget(WuttaCheckboxChoiceWidget):
558 """
559 Widget for use with User
560 :attr:`~wuttjamaican:wuttjamaican.db.model.auth.User.roles` field.
561 This is the default widget for the
562 :class:`~wuttaweb.forms.schema.RoleRefs` type.
564 This is a subclass of :class:`WuttaCheckboxChoiceWidget`.
565 """
567 readonly_template = "readonly/rolerefs"
568 session = None
570 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
571 """ """
572 model = self.app.model
574 # special logic when field is editable
575 readonly = kw.get("readonly", self.readonly)
576 if not readonly:
578 # but does not apply if current user is root
579 if not self.request.is_root:
580 auth = self.app.get_auth_handler()
581 admin = auth.get_role_administrator(self.session)
583 # prune admin role from values list; it should not be
584 # one of the options since current user is not admin
585 values = kw.get("values", self.values)
586 values = [val for val in values if val[0] != admin.uuid]
587 kw["values"] = values
589 else: # readonly
591 # roles
592 roles = []
593 if cstruct:
594 for uuid in cstruct:
595 role = self.session.get(model.Role, uuid)
596 if role:
597 roles.append(role)
598 kw["roles"] = sorted(roles, key=lambda r: r.name)
600 # url
601 def url(role):
602 return self.request.route_url("roles.view", uuid=role.uuid)
604 kw["url"] = url
606 # default logic from here
607 return super().serialize(field, cstruct, **kw)
610class PermissionsWidget(WuttaCheckboxChoiceWidget):
611 """
612 Widget for use with Role
613 :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Role.permissions`
614 field.
616 This is a subclass of :class:`WuttaCheckboxChoiceWidget`. It uses
617 these Deform templates:
619 * ``permissions``
620 * ``readonly/permissions``
621 """
623 template = "permissions"
624 readonly_template = "readonly/permissions"
625 permissions = None
627 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
628 """ """
629 kw.setdefault("permissions", self.permissions)
631 if "values" not in kw:
632 values = []
633 for group in self.permissions.values():
634 for pkey, perm in group["perms"].items():
635 values.append((pkey, perm["label"]))
636 kw["values"] = values
638 return super().serialize(field, cstruct, **kw)
641class EmailSettingRefWidget(TextInputWidget):
642 """
643 Widget for an :term:`email setting` reference field.
645 This is mainly used for the readonly form when viewing a Problem
646 Check definition. The email setting key is shown as a hyperlink
647 to the corresponding Email Setting view.
649 This is a subclass of
650 :class:`deform:deform.widget.TextInputWidget`. It uses these
651 Deform templates (technically only the latter is intended for
652 use):
654 * ``textinput``
655 * ``readonly/textinput``
656 """
658 def __init__(self, request, *args, **kwargs):
659 super().__init__(*args, **kwargs)
660 self.request = request
662 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
663 """ """
664 if cstruct:
665 cstruct = tags.link_to(
666 cstruct, self.request.route_url("email_settings.view", key=cstruct)
667 )
668 return super().serialize(field, cstruct, **kw)
671class EmailRecipientsWidget(TextAreaWidget):
672 """
673 Widget for :term:`email setting` recipient fields (``To``, ``Cc``,
674 ``Bcc``).
676 This is a subclass of
677 :class:`deform:deform.widget.TextAreaWidget`. It uses these
678 Deform templates:
680 * ``textarea``
681 * ``readonly/email_recips``
683 See also the :class:`~wuttaweb.forms.schema.EmailRecipients`
684 schema type, which uses this widget.
685 """
687 readonly_template = "readonly/email_recips"
689 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
690 """ """
691 readonly = kw.get("readonly", self.readonly)
692 if readonly:
693 kw["recips"] = parse_list(cstruct or "")
695 return super().serialize(field, cstruct, **kw)
697 def deserialize(self, field, pstruct): # pylint: disable=empty-docstring
698 """ """
699 if pstruct is colander.null:
700 return colander.null
702 values = [value for value in parse_list(pstruct) if value]
703 return ", ".join(values)
706class BatchIdWidget(Widget): # pylint: disable=abstract-method
707 """
708 Widget for use with the
709 :attr:`~wuttjamaican:wuttjamaican.db.model.batch.BatchMixin.id`
710 field of a :term:`batch` model.
712 This widget is "always" read-only and renders the Batch ID as
713 zero-padded 8-char string
714 """
716 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
717 """ """
718 if cstruct is colander.null:
719 return colander.null
721 batch_id = int(cstruct)
722 return f"{batch_id:08d}"
725class AlembicRevisionWidget(Widget): # pylint: disable=missing-class-docstring
726 """
727 Widget to show an Alembic revision identifier, with link to view
728 the revision.
729 """
731 def __init__(self, request, *args, **kwargs):
732 super().__init__(*args, **kwargs)
733 self.request = request
735 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
736 """ """
737 if not cstruct:
738 return colander.null
740 return tags.link_to(
741 cstruct, self.request.route_url("alembic.migrations.view", revision=cstruct)
742 )
744 def deserialize(self, field, pstruct): # pylint: disable=empty-docstring
745 """ """
746 raise NotImplementedError
749class AlembicRevisionsWidget(Widget):
750 """
751 Widget to show list of Alembic revision identifiers, with links to
752 view each revision.
753 """
755 def __init__(self, request, *args, **kwargs):
756 super().__init__(*args, **kwargs)
757 self.request = request
758 self.config = self.request.wutta_config
760 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
761 """ """
762 if not cstruct:
763 return colander.null
765 revisions = []
766 for rev in self.config.parse_list(cstruct):
767 revisions.append(
768 tags.link_to(
769 rev, self.request.route_url("alembic.migrations.view", revision=rev)
770 )
771 )
773 return ", ".join(revisions)
775 def deserialize(self, field, pstruct): # pylint: disable=empty-docstring
776 """ """
777 raise NotImplementedError