Coverage for .tox/coverage/lib/python3.13/site-packages/wuttaweb/forms/widgets.py: 100%
203 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-20 15:24 -0500
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-20 15:24 -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
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 ExternalLinkWidget(TextInputWidget):
169 """
170 Widget for use with "external link" URL fields. In readonly mode,
171 displays a hyperlink with ``_blank`` window target.
173 This is a subclass of
174 :class:`deform:deform.widget.TextInputWidget` and uses these
175 Deform templates:
177 * ``textinput``
178 * ``readonly/external_link``
179 """
181 readonly_template = "readonly/external_link"
184class WuttaCheckboxChoiceWidget(CheckboxChoiceWidget):
185 """
186 Custom widget for :class:`python:set` fields.
188 This is a subclass of
189 :class:`deform:deform.widget.CheckboxChoiceWidget`.
191 :param request: Current :term:`request` object.
193 It uses these Deform templates:
195 * ``checkbox_choice``
196 * ``readonly/checkbox_choice``
197 """
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()
206class WuttaCheckedPasswordWidget(PasswordWidget):
207 """
208 Custom widget for password+confirmation field.
210 This widget is used only for Vue 3 + Oruga, but is *not* used for
211 Vue 2 + Buefy.
213 This is a subclass of :class:`deform:deform.widget.PasswordWidget`
214 and uses these Deform templates:
216 * ``wutta_checked_password``
217 """
219 template = "wutta_checked_password"
222class WuttaDateWidget(DateInputWidget):
223 """
224 Custom widget for :class:`python:datetime.date` fields.
226 The main purpose of this widget is to leverage
227 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_date()`
228 for the readonly display.
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()`.
236 This is a subclass of
237 :class:`deform:deform.widget.DateInputWidget` and uses these
238 Deform templates:
240 * ``dateinput``
241 """
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()
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)
259 return super().serialize(field, cstruct, **kw)
262class WuttaDateTimeWidget(DateTimeInputWidget):
263 """
264 Custom widget for :class:`python:datetime.datetime` fields.
266 The main purpose of this widget is to leverage
267 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_datetime()`
268 for the readonly display.
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()`.
276 This is a subclass of
277 :class:`deform:deform.widget.DateTimeInputWidget` and uses these
278 Deform templates:
280 * ``datetimeinput``
281 """
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()
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)
298 return super().serialize(field, cstruct, **kw)
301class WuttaMoneyInputWidget(MoneyInputWidget):
302 """
303 Custom widget for "money" fields. This is used by default for
304 :class:`~wuttaweb.forms.schema.WuttaMoney` type nodes.
306 The main purpose of this widget is to leverage
307 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_currency()`
308 for the readonly display.
310 This is a subclass of
311 :class:`deform:deform.widget.MoneyInputWidget` and uses these
312 Deform templates:
314 * ``moneyinput``
316 :param request: Current :term:`request` object.
318 :param scale: If this kwarg is specified, it will be passed along
319 to ``render_currency()`` call.
320 """
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()
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])
339 return super().serialize(field, cstruct, **kw)
342class FileDownloadWidget(Widget): # pylint: disable=abstract-method
343 """
344 Widget for use with :class:`~wuttaweb.forms.schema.FileDownload`
345 fields.
347 This only supports readonly, and shows a hyperlink to download the
348 file. Link text is the filename plus file size.
350 This is a subclass of :class:`deform:deform.widget.Widget` and
351 uses these Deform templates:
353 * ``readonly/filedownload``
355 :param request: Current :term:`request` object.
357 :param url: Optional URL for hyperlink. If not specified, file
358 name/size is shown with no hyperlink.
359 """
361 readonly_template = "readonly/filedownload"
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()
371 # pylint: enable=duplicate-code
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
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)
386 else:
387 kw.setdefault("filename", None)
388 kw.setdefault("filesize", None)
390 kw.setdefault("url", None)
391 values = self.get_template_values(field, cstruct, kw)
392 return field.renderer(template, **values)
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)
403class GridWidget(Widget): # pylint: disable=abstract-method
404 """
405 Widget for fields whose data is represented by a :term:`grid`.
407 This is a subclass of :class:`deform:deform.widget.Widget` but
408 does not use any Deform templates.
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.
414 Instead of creating this widget directly you probably should call
415 :meth:`~wuttaweb.forms.base.Form.set_grid()` on your form.
417 :param request: Current :term:`request` object.
419 :param grid: :class:`~wuttaweb.grids.base.Grid` instance, used to
420 display the field data.
421 """
423 def __init__(self, request, grid, *args, **kwargs):
424 super().__init__(*args, **kwargs)
425 self.request = request
426 self.grid = grid
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")
438 return self.grid.render_table_element()
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.
448 This is a subclass of :class:`WuttaCheckboxChoiceWidget`.
449 """
451 readonly_template = "readonly/rolerefs"
452 session = None
454 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
455 """ """
456 model = self.app.model
458 # special logic when field is editable
459 readonly = kw.get("readonly", self.readonly)
460 if not readonly:
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)
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
473 else: # readonly
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)
484 # url
485 def url(role):
486 return self.request.route_url("roles.view", uuid=role.uuid)
488 kw["url"] = url
490 # default logic from here
491 return super().serialize(field, cstruct, **kw)
494class PermissionsWidget(WuttaCheckboxChoiceWidget):
495 """
496 Widget for use with Role
497 :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Role.permissions`
498 field.
500 This is a subclass of :class:`WuttaCheckboxChoiceWidget`. It uses
501 these Deform templates:
503 * ``permissions``
504 * ``readonly/permissions``
505 """
507 template = "permissions"
508 readonly_template = "readonly/permissions"
509 permissions = None
511 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
512 """ """
513 kw.setdefault("permissions", self.permissions)
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
522 return super().serialize(field, cstruct, **kw)
525class EmailRecipientsWidget(TextAreaWidget):
526 """
527 Widget for :term:`email setting` recipient fields (``To``, ``Cc``,
528 ``Bcc``).
530 This is a subclass of
531 :class:`deform:deform.widget.TextAreaWidget`. It uses these
532 Deform templates:
534 * ``textarea``
535 * ``readonly/email_recips``
537 See also the :class:`~wuttaweb.forms.schema.EmailRecipients`
538 schema type, which uses this widget.
539 """
541 readonly_template = "readonly/email_recips"
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 "")
549 return super().serialize(field, cstruct, **kw)
551 def deserialize(self, field, pstruct): # pylint: disable=empty-docstring
552 """ """
553 if pstruct is colander.null:
554 return colander.null
556 values = [value for value in parse_list(pstruct) if value]
557 return ", ".join(values)
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.
566 This widget is "always" read-only and renders the Batch ID as
567 zero-padded 8-char string
568 """
570 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
571 """ """
572 if cstruct is colander.null:
573 return colander.null
575 batch_id = int(cstruct)
576 return f"{batch_id:08d}"
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 """
585 def __init__(self, request, *args, **kwargs):
586 super().__init__(*args, **kwargs)
587 self.request = request
589 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
590 """ """
591 if not cstruct:
592 return colander.null
594 return tags.link_to(
595 cstruct, self.request.route_url("alembic.migrations.view", revision=cstruct)
596 )
598 def deserialize(self, field, pstruct): # pylint: disable=empty-docstring
599 """ """
600 raise NotImplementedError
603class AlembicRevisionsWidget(Widget):
604 """
605 Widget to show list of Alembic revision identifiers, with links to
606 view each revision.
607 """
609 def __init__(self, request, *args, **kwargs):
610 super().__init__(*args, **kwargs)
611 self.request = request
612 self.config = self.request.wutta_config
614 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
615 """ """
616 if not cstruct:
617 return colander.null
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 )
627 return ", ".join(revisions)
629 def deserialize(self, field, pstruct): # pylint: disable=empty-docstring
630 """ """
631 raise NotImplementedError