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
« 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
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 dt = datetime.datetime.fromisoformat(cstruct)
238 return self.app.render_date(dt)
240 return super().serialize(field, cstruct, **kw)
243class WuttaDateTimeWidget(DateTimeInputWidget):
244 """
245 Custom widget for :class:`python:datetime.datetime` fields.
247 The main purpose of this widget is to leverage
248 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_datetime()`
249 for the readonly display.
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()`.
257 This is a subclass of
258 :class:`deform:deform.widget.DateTimeInputWidget` and uses these
259 Deform templates:
261 * ``datetimeinput``
262 """
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()
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)
279 return super().serialize(field, cstruct, **kw)
282class WuttaMoneyInputWidget(MoneyInputWidget):
283 """
284 Custom widget for "money" fields. This is used by default for
285 :class:`~wuttaweb.forms.schema.WuttaMoney` type nodes.
287 The main purpose of this widget is to leverage
288 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_currency()`
289 for the readonly display.
291 This is a subclass of
292 :class:`deform:deform.widget.MoneyInputWidget` and uses these
293 Deform templates:
295 * ``moneyinput``
297 :param request: Current :term:`request` object.
299 :param scale: If this kwarg is specified, it will be passed along
300 to ``render_currency()`` call.
301 """
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()
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])
320 return super().serialize(field, cstruct, **kw)
323class FileDownloadWidget(Widget): # pylint: disable=abstract-method
324 """
325 Widget for use with :class:`~wuttaweb.forms.schema.FileDownload`
326 fields.
328 This only supports readonly, and shows a hyperlink to download the
329 file. Link text is the filename plus file size.
331 This is a subclass of :class:`deform:deform.widget.Widget` and
332 uses these Deform templates:
334 * ``readonly/filedownload``
336 :param request: Current :term:`request` object.
338 :param url: Optional URL for hyperlink. If not specified, file
339 name/size is shown with no hyperlink.
340 """
342 readonly_template = "readonly/filedownload"
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()
352 # pylint: enable=duplicate-code
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
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)
367 else:
368 kw.setdefault("filename", None)
369 kw.setdefault("filesize", None)
371 kw.setdefault("url", None)
372 values = self.get_template_values(field, cstruct, kw)
373 return field.renderer(template, **values)
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)
384class GridWidget(Widget): # pylint: disable=abstract-method
385 """
386 Widget for fields whose data is represented by a :term:`grid`.
388 This is a subclass of :class:`deform:deform.widget.Widget` but
389 does not use any Deform templates.
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.
395 Instead of creating this widget directly you probably should call
396 :meth:`~wuttaweb.forms.base.Form.set_grid()` on your form.
398 :param request: Current :term:`request` object.
400 :param grid: :class:`~wuttaweb.grids.base.Grid` instance, used to
401 display the field data.
402 """
404 def __init__(self, request, grid, *args, **kwargs):
405 super().__init__(*args, **kwargs)
406 self.request = request
407 self.grid = grid
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")
419 return self.grid.render_table_element()
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.
429 This is a subclass of :class:`WuttaCheckboxChoiceWidget`.
430 """
432 readonly_template = "readonly/rolerefs"
433 session = None
435 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
436 """ """
437 model = self.app.model
439 # special logic when field is editable
440 readonly = kw.get("readonly", self.readonly)
441 if not readonly:
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)
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
454 else: # readonly
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
465 # url
466 def url(role):
467 return self.request.route_url("roles.view", uuid=role.uuid)
469 kw["url"] = url
471 # default logic from here
472 return super().serialize(field, cstruct, **kw)
475class PermissionsWidget(WuttaCheckboxChoiceWidget):
476 """
477 Widget for use with Role
478 :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Role.permissions`
479 field.
481 This is a subclass of :class:`WuttaCheckboxChoiceWidget`. It uses
482 these Deform templates:
484 * ``permissions``
485 * ``readonly/permissions``
486 """
488 template = "permissions"
489 readonly_template = "readonly/permissions"
490 permissions = None
492 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
493 """ """
494 kw.setdefault("permissions", self.permissions)
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
503 return super().serialize(field, cstruct, **kw)
506class EmailRecipientsWidget(TextAreaWidget):
507 """
508 Widget for :term:`email setting` recipient fields (``To``, ``Cc``,
509 ``Bcc``).
511 This is a subclass of
512 :class:`deform:deform.widget.TextAreaWidget`. It uses these
513 Deform templates:
515 * ``textarea``
516 * ``readonly/email_recips``
518 See also the :class:`~wuttaweb.forms.schema.EmailRecipients`
519 schema type, which uses this widget.
520 """
522 readonly_template = "readonly/email_recips"
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 "")
530 return super().serialize(field, cstruct, **kw)
532 def deserialize(self, field, pstruct): # pylint: disable=empty-docstring
533 """ """
534 if pstruct is colander.null:
535 return colander.null
537 values = [value for value in parse_list(pstruct) if value]
538 return ", ".join(values)
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.
547 This widget is "always" read-only and renders the Batch ID as
548 zero-padded 8-char string
549 """
551 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
552 """ """
553 if cstruct is colander.null:
554 return colander.null
556 batch_id = int(cstruct)
557 return f"{batch_id:08d}"
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 """
566 def __init__(self, request, *args, **kwargs):
567 super().__init__(*args, **kwargs)
568 self.request = request
570 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
571 """ """
572 if not cstruct:
573 return colander.null
575 return tags.link_to(
576 cstruct, self.request.route_url("alembic.migrations.view", revision=cstruct)
577 )
579 def deserialize(self, field, pstruct): # pylint: disable=empty-docstring
580 """ """
581 raise NotImplementedError
584class AlembicRevisionsWidget(Widget):
585 """
586 Widget to show list of Alembic revision identifiers, with links to
587 view each revision.
588 """
590 def __init__(self, request, *args, **kwargs):
591 super().__init__(*args, **kwargs)
592 self.request = request
593 self.config = self.request.wutta_config
595 def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
596 """ """
597 if not cstruct:
598 return colander.null
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 )
608 return ", ".join(revisions)
610 def deserialize(self, field, pstruct): # pylint: disable=empty-docstring
611 """ """
612 raise NotImplementedError