Source code for wuttaweb.forms.widgets
# -*- coding: utf-8; -*-
################################################################################
#
# wuttaweb -- Web App for Wutta Framework
# Copyright © 2024-2025 Lance Edgar
#
# This file is part of Wutta Framework.
#
# Wutta Framework is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# Wutta Framework is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Form widgets
This module defines some custom widgets for use with WuttaWeb.
However for convenience it also makes other Deform widgets available
in the namespace:
* :class:`deform:deform.widget.Widget` (base class)
* :class:`deform:deform.widget.TextInputWidget`
* :class:`deform:deform.widget.TextAreaWidget`
* :class:`deform:deform.widget.PasswordWidget`
* :class:`deform:deform.widget.CheckedPasswordWidget`
* :class:`deform:deform.widget.CheckboxWidget`
* :class:`deform:deform.widget.SelectWidget`
* :class:`deform:deform.widget.CheckboxChoiceWidget`
* :class:`deform:deform.widget.DateInputWidget`
* :class:`deform:deform.widget.DateTimeInputWidget`
* :class:`deform:deform.widget.MoneyInputWidget`
"""
import datetime
import decimal
import os
import colander
import humanize
from deform.widget import ( # pylint: disable=unused-import
Widget,
TextInputWidget,
TextAreaWidget,
PasswordWidget,
CheckedPasswordWidget,
CheckboxWidget,
SelectWidget,
CheckboxChoiceWidget,
DateInputWidget,
DateTimeInputWidget,
MoneyInputWidget,
)
from webhelpers2.html import HTML
from wuttjamaican.conf import parse_list
[docs]
class ObjectRefWidget(SelectWidget):
"""
Widget for use with model "object reference" fields, e.g. foreign
key UUID => TargetModel instance.
While you may create instances of this widget directly, it
normally happens automatically when schema nodes of the
:class:`~wuttaweb.forms.schema.ObjectRef` (sub)type are part of
the form schema; via
:meth:`~wuttaweb.forms.schema.ObjectRef.widget_maker()`.
In readonly mode, this renders a ``<span>`` tag around the
:attr:`model_instance` (converted to string).
Otherwise it renders a select (dropdown) element allowing user to
choose from available records.
This is a subclass of :class:`deform:deform.widget.SelectWidget`
and uses these Deform templates:
* ``select``
* ``readonly/objectref``
.. attribute:: model_instance
Reference to the model record instance, i.e. the "far side" of
the foreign key relationship.
.. note::
You do not need to provide the ``model_instance`` when
constructing the widget. Rather, it is set automatically
when the :class:`~wuttaweb.forms.schema.ObjectRef` type
instance (associated with the node) is serialized.
"""
readonly_template = "readonly/objectref"
def __init__(self, request, *args, **kwargs):
url = kwargs.pop("url", None)
super().__init__(*args, **kwargs)
self.request = request
self.url = url
def get_template_values( # pylint: disable=empty-docstring
self, field, cstruct, kw
):
""" """
values = super().get_template_values(field, cstruct, kw)
# add url, only if rendering readonly
readonly = kw.get("readonly", self.readonly)
if readonly:
if (
"url" not in values
and self.url
and getattr(field.schema, "model_instance", None)
):
values["url"] = self.url(field.schema.model_instance)
return values
[docs]
class NotesWidget(TextAreaWidget):
"""
Widget for use with "notes" fields.
In readonly mode, this shows the notes with a background to make
them stand out a bit more.
Otherwise it effectively shows a ``<textarea>`` input element.
This is a subclass of :class:`deform:deform.widget.TextAreaWidget`
and uses these Deform templates:
* ``textarea``
* ``readonly/notes``
"""
readonly_template = "readonly/notes"
[docs]
class WuttaCheckboxChoiceWidget(CheckboxChoiceWidget):
"""
Custom widget for :class:`python:set` fields.
This is a subclass of
:class:`deform:deform.widget.CheckboxChoiceWidget`.
:param request: Current :term:`request` object.
It uses these Deform templates:
* ``checkbox_choice``
* ``readonly/checkbox_choice``
"""
def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = request
self.config = self.request.wutta_config
self.app = self.config.get_app()
[docs]
class WuttaCheckedPasswordWidget(PasswordWidget):
"""
Custom widget for password+confirmation field.
This widget is used only for Vue 3 + Oruga, but is *not* used for
Vue 2 + Buefy.
This is a subclass of :class:`deform:deform.widget.PasswordWidget`
and uses these Deform templates:
* ``wutta_checked_password``
"""
template = "wutta_checked_password"
[docs]
class WuttaDateWidget(DateInputWidget):
"""
Custom widget for :class:`python:datetime.date` fields.
The main purpose of this widget is to leverage
:meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_date()`
for the readonly display.
It is automatically used for SQLAlchemy mapped classes where the
field maps to a :class:`sqlalchemy:sqlalchemy.types.Date` column.
For other (non-mapped) date fields, or mapped datetime fields for
which a date widget is preferred, use
:meth:`~wuttaweb.forms.base.Form.set_widget()`.
This is a subclass of
:class:`deform:deform.widget.DateInputWidget` and uses these
Deform templates:
* ``dateinput``
"""
def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = request
self.config = self.request.wutta_config
self.app = self.config.get_app()
def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
""" """
readonly = kw.get("readonly", self.readonly)
if readonly and cstruct:
dt = datetime.datetime.fromisoformat(cstruct)
return self.app.render_date(dt)
return super().serialize(field, cstruct, **kw)
[docs]
class WuttaDateTimeWidget(DateTimeInputWidget):
"""
Custom widget for :class:`python:datetime.datetime` fields.
The main purpose of this widget is to leverage
:meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_datetime()`
for the readonly display.
It is automatically used for SQLAlchemy mapped classes where the
field maps to a :class:`sqlalchemy:sqlalchemy.types.DateTime`
column. For other (non-mapped) datetime fields, you may have to
use it explicitly via
:meth:`~wuttaweb.forms.base.Form.set_widget()`.
This is a subclass of
:class:`deform:deform.widget.DateTimeInputWidget` and uses these
Deform templates:
* ``datetimeinput``
"""
def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = request
self.config = self.request.wutta_config
self.app = self.config.get_app()
def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
""" """
readonly = kw.get("readonly", self.readonly)
if readonly and cstruct:
dt = datetime.datetime.fromisoformat(cstruct)
return self.app.render_datetime(dt)
return super().serialize(field, cstruct, **kw)
[docs]
class WuttaMoneyInputWidget(MoneyInputWidget):
"""
Custom widget for "money" fields. This is used by default for
:class:`~wuttaweb.forms.schema.WuttaMoney` type nodes.
The main purpose of this widget is to leverage
:meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_currency()`
for the readonly display.
This is a subclass of
:class:`deform:deform.widget.MoneyInputWidget` and uses these
Deform templates:
* ``moneyinput``
:param request: Current :term:`request` object.
:param scale: If this kwarg is specified, it will be passed along
to ``render_currency()`` call.
"""
def __init__(self, request, *args, **kwargs):
self.scale = kwargs.pop("scale", 2)
super().__init__(*args, **kwargs)
self.request = request
self.config = self.request.wutta_config
self.app = self.config.get_app()
def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
""" """
readonly = kw.get("readonly", self.readonly)
if readonly:
if cstruct in (colander.null, None):
return HTML.tag("span")
cstruct = decimal.Decimal(cstruct)
text = self.app.render_currency(cstruct, scale=self.scale)
return HTML.tag("span", c=[text])
return super().serialize(field, cstruct, **kw)
[docs]
class FileDownloadWidget(Widget): # pylint: disable=abstract-method
"""
Widget for use with :class:`~wuttaweb.forms.schema.FileDownload`
fields.
This only supports readonly, and shows a hyperlink to download the
file. Link text is the filename plus file size.
This is a subclass of :class:`deform:deform.widget.Widget` and
uses these Deform templates:
* ``readonly/filedownload``
:param request: Current :term:`request` object.
:param url: Optional URL for hyperlink. If not specified, file
name/size is shown with no hyperlink.
"""
readonly_template = "readonly/filedownload"
# pylint: disable=duplicate-code
def __init__(self, request, *args, **kwargs):
self.url = kwargs.pop("url", None)
super().__init__(*args, **kwargs)
self.request = request
self.config = self.request.wutta_config
self.app = self.config.get_app()
# pylint: enable=duplicate-code
def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
""" """
# nb. readonly is the only way this rolls
kw["readonly"] = True
template = self.readonly_template
path = cstruct or None
if path:
kw.setdefault("filename", os.path.basename(path))
kw.setdefault("filesize", self.readable_size(path))
if self.url:
kw.setdefault("url", self.url)
else:
kw.setdefault("filename", None)
kw.setdefault("filesize", None)
kw.setdefault("url", None)
values = self.get_template_values(field, cstruct, kw)
return field.renderer(template, **values)
def readable_size(self, path): # pylint: disable=empty-docstring
""" """
try:
size = os.path.getsize(path)
except os.error:
size = 0
return humanize.naturalsize(size)
[docs]
class GridWidget(Widget): # pylint: disable=abstract-method
"""
Widget for fields whose data is represented by a :term:`grid`.
This is a subclass of :class:`deform:deform.widget.Widget` but
does not use any Deform templates.
This widget only supports "readonly" mode, is not editable. It is
merely a convenience around the grid itself, which does the heavy
lifting.
Instead of creating this widget directly you probably should call
:meth:`~wuttaweb.forms.base.Form.set_grid()` on your form.
:param request: Current :term:`request` object.
:param grid: :class:`~wuttaweb.grids.base.Grid` instance, used to
display the field data.
"""
def __init__(self, request, grid, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = request
self.grid = grid
[docs]
def serialize(self, field, cstruct, **kw):
"""
This widget simply calls
:meth:`~wuttaweb.grids.base.Grid.render_table_element()` on
the ``grid`` to serialize.
"""
readonly = kw.get("readonly", self.readonly)
if not readonly:
raise NotImplementedError("edit not allowed for this widget")
return self.grid.render_table_element()
[docs]
class RoleRefsWidget(WuttaCheckboxChoiceWidget):
"""
Widget for use with User
:attr:`~wuttjamaican:wuttjamaican.db.model.auth.User.roles` field.
This is the default widget for the
:class:`~wuttaweb.forms.schema.RoleRefs` type.
This is a subclass of :class:`WuttaCheckboxChoiceWidget`.
"""
readonly_template = "readonly/rolerefs"
session = None
def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
""" """
model = self.app.model
# special logic when field is editable
readonly = kw.get("readonly", self.readonly)
if not readonly:
# but does not apply if current user is root
if not self.request.is_root:
auth = self.app.get_auth_handler()
admin = auth.get_role_administrator(self.session)
# prune admin role from values list; it should not be
# one of the options since current user is not admin
values = kw.get("values", self.values)
values = [val for val in values if val[0] != admin.uuid]
kw["values"] = values
else: # readonly
# roles
roles = []
if cstruct:
for uuid in cstruct:
role = self.session.get(model.Role, uuid)
if role:
roles.append(role)
kw["roles"] = roles
# url
def url(role):
return self.request.route_url("roles.view", uuid=role.uuid)
kw["url"] = url
# default logic from here
return super().serialize(field, cstruct, **kw)
[docs]
class PermissionsWidget(WuttaCheckboxChoiceWidget):
"""
Widget for use with Role
:attr:`~wuttjamaican:wuttjamaican.db.model.auth.Role.permissions`
field.
This is a subclass of :class:`WuttaCheckboxChoiceWidget`. It uses
these Deform templates:
* ``permissions``
* ``readonly/permissions``
"""
template = "permissions"
readonly_template = "readonly/permissions"
permissions = None
def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
""" """
kw.setdefault("permissions", self.permissions)
if "values" not in kw:
values = []
for group in self.permissions.values():
for pkey, perm in group["perms"].items():
values.append((pkey, perm["label"]))
kw["values"] = values
return super().serialize(field, cstruct, **kw)
[docs]
class EmailRecipientsWidget(TextAreaWidget):
"""
Widget for :term:`email setting` recipient fields (``To``, ``Cc``,
``Bcc``).
This is a subclass of
:class:`deform:deform.widget.TextAreaWidget`. It uses these
Deform templates:
* ``textarea``
* ``readonly/email_recips``
See also the :class:`~wuttaweb.forms.schema.EmailRecipients`
schema type, which uses this widget.
"""
readonly_template = "readonly/email_recips"
def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
""" """
readonly = kw.get("readonly", self.readonly)
if readonly:
kw["recips"] = parse_list(cstruct or "")
return super().serialize(field, cstruct, **kw)
def deserialize(self, field, pstruct): # pylint: disable=empty-docstring
""" """
if pstruct is colander.null:
return colander.null
values = [value for value in parse_list(pstruct) if value]
return ", ".join(values)
[docs]
class BatchIdWidget(Widget): # pylint: disable=abstract-method
"""
Widget for use with the
:attr:`~wuttjamaican:wuttjamaican.db.model.batch.BatchMixin.id`
field of a :term:`batch` model.
This widget is "always" read-only and renders the Batch ID as
zero-padded 8-char string
"""
def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
""" """
if cstruct is colander.null:
return colander.null
batch_id = int(cstruct)
return f"{batch_id:08d}"