# -*- 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 schema types
"""
import datetime
import uuid as _uuid
import colander
import sqlalchemy as sa
from wuttjamaican.conf import parse_list
from wuttjamaican.util import localtime
from wuttaweb.db import Session
from wuttaweb.forms import widgets
[docs]
class WuttaDateTime(colander.DateTime):
"""
Custom schema type for :class:`~python:datetime.datetime` fields.
This should be used automatically for
:class:`~sqlalchemy:sqlalchemy.types.DateTime` ORM columns unless
you register another default.
This schema type exists for sake of convenience, when working with
the Buefy datepicker + timepicker widgets.
It also follows the datetime handling "rules" as outlined in
:doc:`wuttjamaican:narr/datetime`. On the Python side, values
should be naive/UTC datetime objects. On the HTTP side, values
will be ISO-format strings representing aware/local time.
"""
def serialize(self, node, appstruct):
if not appstruct:
return colander.null
# nb. request should be present when it matters
if node.widget and node.widget.request:
request = node.widget.request
config = request.wutta_config
app = config.get_app()
appstruct = app.localtime(appstruct)
else:
# but if not, fallback to config-less logic
appstruct = localtime(appstruct)
if self.format:
return appstruct.strftime(self.format)
return appstruct.isoformat()
def deserialize( # pylint: disable=inconsistent-return-statements
self, node, cstruct
):
if not cstruct:
return colander.null
formats = [
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%dT%I:%M %p",
]
# nb. request is always assumed to be present here
request = node.widget.request
config = request.wutta_config
app = config.get_app()
for fmt in formats:
try:
dt = datetime.datetime.strptime(cstruct, fmt)
if not dt.tzinfo:
dt = app.localtime(dt, from_utc=False)
return app.make_utc(dt)
except Exception: # pylint: disable=broad-exception-caught
pass
node.raise_invalid("Invalid date and/or time")
[docs]
class ObjectNode(colander.SchemaNode): # pylint: disable=abstract-method
"""
Custom schema node class which adds methods for compatibility with
ColanderAlchemy. This is a direct subclass of
:class:`colander:colander.SchemaNode`.
ColanderAlchemy will call certain methods on any node found in the
schema. However these methods are not "standard" and only exist
for ColanderAlchemy nodes.
So we must add nodes using this class, to ensure the node has all
methods needed by ColanderAlchemy.
"""
[docs]
def dictify(self, obj):
"""
This method is called by ColanderAlchemy when translating the
in-app Python object to a value suitable for use in the form
data dict.
The logic here will look for a ``dictify()`` method on the
node's "type" instance (``self.typ``; see also
:class:`colander:colander.SchemaNode`) and invoke it if found.
For an example type which is supported in this way, see
:class:`ObjectRef`.
If the node's type does not have a ``dictify()`` method, this
will just convert the object to a string and return that.
"""
if hasattr(self.typ, "dictify"):
return self.typ.dictify(obj)
# TODO: this is better than raising an error, as it previously
# did, but seems like troubleshooting problems may often lead
# one here.. i suspect this needs to do something smarter but
# not sure what that is yet
return str(obj)
[docs]
def objectify(self, value):
"""
This method is called by ColanderAlchemy when translating form
data to the final Python representation.
The logic here will look for an ``objectify()`` method on the
node's "type" instance (``self.typ``; see also
:class:`colander:colander.SchemaNode`) and invoke it if found.
For an example type which is supported in this way, see
:class:`ObjectRef`.
If the node's type does not have an ``objectify()`` method,
this will raise ``NotImplementeError``.
"""
if hasattr(self.typ, "objectify"):
return self.typ.objectify(value)
class_name = self.typ.__class__.__name__
raise NotImplementedError(f"you must define {class_name}.objectify()")
[docs]
class WuttaEnum(colander.Enum):
"""
Custom schema type for enum fields.
This is a subclass of :class:`colander.Enum`, but adds a
default widget (``SelectWidget``) with enum choices.
:param request: Current :term:`request` object.
"""
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 widget_maker(self, **kwargs): # pylint: disable=empty-docstring
""" """
if "values" not in kwargs:
kwargs["values"] = [
(getattr(e, self.attr), getattr(e, self.attr)) for e in self.enum_cls
]
return widgets.SelectWidget(**kwargs)
[docs]
class WuttaDictEnum(colander.String):
"""
Schema type for "pseudo-enum" fields which reference a dict for
known values instead of a true enum class.
This is primarily for use with "status" fields such as
:attr:`~wuttjamaican:wuttjamaican.db.model.batch.BatchRowMixin.status_code`.
This is a subclass of :class:`colander.String`, but adds a default
widget (``SelectWidget``) with enum choices.
:param request: Current :term:`request` object.
:param enum_dct: Dict with possible enum values and labels.
"""
def __init__(self, request, enum_dct, *args, **kwargs):
self.null_value = kwargs.pop("null_value", "")
super().__init__(*args, **kwargs)
self.request = request
self.config = self.request.wutta_config
self.app = self.config.get_app()
self.enum_dct = enum_dct
def serialize(self, node, appstruct):
if appstruct is colander.null:
return self.null_value
return super().serialize(node, appstruct)
def widget_maker(self, **kwargs): # pylint: disable=empty-docstring
""" """
if "values" not in kwargs:
kwargs["values"] = list(self.enum_dct.items())
kwargs.setdefault("null_value", self.null_value)
return widgets.SelectWidget(**kwargs)
[docs]
class WuttaMoney(colander.Money):
"""
Custom schema type for "money" fields.
This is a subclass of :class:`colander:colander.Money`, but uses
the custom :class:`~wuttaweb.forms.widgets.WuttaMoneyInputWidget`
by default.
:param request: Current :term:`request` object.
:param scale: If this kwarg is specified, it will be passed along
to the widget constructor.
"""
def __init__(self, request, *args, **kwargs):
self.scale = kwargs.pop("scale", None)
super().__init__(*args, **kwargs)
self.request = request
self.config = self.request.wutta_config
self.app = self.config.get_app()
def widget_maker(self, **kwargs): # pylint: disable=empty-docstring
""" """
if self.scale:
kwargs.setdefault("scale", self.scale)
return widgets.WuttaMoneyInputWidget(self.request, **kwargs)
[docs]
class WuttaQuantity(colander.Decimal):
"""
Custom schema type for "quantity" fields.
This is a subclass of :class:`colander:colander.Decimal` but will
serialize values via
:meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_quantity()`.
:param request: Current :term:`request` object.
"""
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, node, appstruct): # pylint: disable=empty-docstring
""" """
if appstruct in (colander.null, None):
return colander.null
# nb. we render as quantity here to avoid values like 12.0000,
# so we just show value like 12 instead
return self.app.render_quantity(appstruct)
[docs]
class WuttaList(colander.List):
"""
Custom schema type for :class:`python:list` fields; this is a
subclass of :class:`colander.List`.
:param request: Current :term:`request` object.
As of now this merely provides a way (in fact, requires you) to
pass the request in, so it can be leveraged as needed. Instances
of this type will have the following attributes:
.. attribute:: request
Reference to the current :term:`request`.
.. attribute:: config
Reference to the app :term:`config object`.
.. attribute:: app
Reference to the :term:`app handler` instance.
"""
def __init__(self, request):
super().__init__()
self.request = request
self.config = self.request.wutta_config
self.app = self.config.get_app()
[docs]
class WuttaSet(colander.Set):
"""
Custom schema type for :class:`python:set` fields; this is a
subclass of :class:`colander.Set`.
:param request: Current :term:`request` object.
As of now this merely provides a way (in fact, requires you) to
pass the request in, so it can be leveraged as needed. Instances
of this type will have the following attributes:
.. attribute:: request
Reference to the current :term:`request`.
.. attribute:: config
Reference to the app :term:`config object`.
.. attribute:: app
Reference to the :term:`app handler` instance.
"""
def __init__(self, request):
super().__init__()
self.request = request
self.config = self.request.wutta_config
self.app = self.config.get_app()
[docs]
class ObjectRef(colander.SchemaType):
"""
Custom schema type for a model class reference field.
This expects the incoming ``appstruct`` to be either a model
record instance, or ``None``.
Serializes to the instance UUID as string, or ``colander.null``;
form data should be of the same nature.
This schema type is not useful directly, but various other types
will subclass it. Each should define (at least) the
:attr:`model_class` attribute or property.
:param request: Current :term:`request` object.
:param empty_option: If a select widget is used, this determines
whether an empty option is included for the dropdown. Set
this to one of the following to add an empty option:
* ``True`` to add the default empty option
* label text for the empty option
* tuple of ``(value, label)`` for the empty option
Note that in the latter, ``value`` must be a string.
"""
default_empty_option = ("", "(none)")
def __init__(self, request, *args, **kwargs):
empty_option = kwargs.pop("empty_option", None)
# nb. allow session injection for tests
self.session = kwargs.pop("session", Session())
super().__init__(*args, **kwargs)
self.request = request
self.config = self.request.wutta_config
self.app = self.config.get_app()
self.model_instance = None
if empty_option:
if empty_option is True:
self.empty_option = self.default_empty_option
elif isinstance(empty_option, tuple) and len(empty_option) == 2:
self.empty_option = empty_option
else:
self.empty_option = ("", str(empty_option))
else:
self.empty_option = None
@property
def model_class(self):
"""
Should be a reference to the model class to which this schema
type applies
(e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`).
"""
class_name = self.__class__.__name__
raise NotImplementedError(f"you must define {class_name}.model_class")
def serialize(self, node, appstruct): # pylint: disable=empty-docstring
""" """
# normalize to empty option if no object ref, so that works as
# expected
if self.empty_option and not appstruct:
return self.empty_option[0]
# even if there is no empty option, still treat any false-ish
# value as null
if not appstruct:
return colander.null
# keep a ref to this for later use
node.model_instance = appstruct
# serialize to PK as string
return self.serialize_object(appstruct)
[docs]
def serialize_object(self, obj):
"""
Serialize the given object to its primary key as string.
Default logic assumes the object has a UUID; subclass can
override as needed.
:param obj: Object reference for the node.
:returns: Object primary key as string.
"""
return obj.uuid.hex
def deserialize( # pylint: disable=empty-docstring,unused-argument
self, node, cstruct
):
""" """
if not cstruct:
return colander.null
# nb. use shortcut to fetch model instance from DB
return self.objectify(cstruct)
def dictify(self, obj): # pylint: disable=empty-docstring
""" """
# TODO: would we ever need to do something else?
return obj
[docs]
def objectify(self, value):
"""
For the given UUID value, returns the object it represents
(based on :attr:`model_class`).
If the value is empty, returns ``None``.
If the value is not empty but object cannot be found, raises
``colander.Invalid``.
"""
if not value:
return None
if isinstance( # pylint: disable=isinstance-second-argument-not-valid-type
value, self.model_class
):
return value
# fetch object from DB
obj = None
if isinstance(value, _uuid.UUID):
obj = self.session.get(self.model_class, value)
else:
try:
obj = self.session.get(self.model_class, _uuid.UUID(value))
except ValueError:
pass
# raise error if not found
if not obj:
class_name = self.model_class.__name__
raise ValueError(f"{class_name} not found: {value}")
return obj
[docs]
def get_query(self):
"""
Returns the main SQLAlchemy query responsible for locating the
dropdown choices for the select widget.
This is called by :meth:`widget_maker()`.
"""
query = self.session.query(self.model_class)
query = self.sort_query(query)
return query
[docs]
def sort_query(self, query):
"""
TODO
"""
return query
[docs]
def get_object_url(self, obj):
"""
Returns the "view" URL for the given object, if applicable.
This is used when rendering the field readonly. If this
method returns a URL then the field text will be wrapped with
a hyperlink, otherwise it will be shown as-is.
Default logic always returns ``None``; subclass should
override as needed.
"""
[docs]
class PersonRef(ObjectRef):
"""
Custom schema type for a
:class:`~wuttjamaican:wuttjamaican.db.model.base.Person` reference
field.
This is a subclass of :class:`ObjectRef`.
"""
@property
def model_class(self): # pylint: disable=empty-docstring
""" """
model = self.app.model
return model.Person
def sort_query(self, query): # pylint: disable=empty-docstring
""" """
return query.order_by(self.model_class.full_name)
def get_object_url(self, obj): # pylint: disable=empty-docstring
""" """
person = obj
return self.request.route_url("people.view", uuid=person.uuid)
[docs]
class RoleRef(ObjectRef):
"""
Custom schema type for a
:class:`~wuttjamaican:wuttjamaican.db.model.auth.Role` reference
field.
This is a subclass of :class:`ObjectRef`.
"""
@property
def model_class(self): # pylint: disable=empty-docstring
""" """
model = self.app.model
return model.Role
def sort_query(self, query): # pylint: disable=empty-docstring
""" """
return query.order_by(self.model_class.name)
def get_object_url(self, obj): # pylint: disable=empty-docstring
""" """
role = obj
return self.request.route_url("roles.view", uuid=role.uuid)
[docs]
class UserRef(ObjectRef):
"""
Custom schema type for a
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` reference
field.
This is a subclass of :class:`ObjectRef`.
"""
@property
def model_class(self): # pylint: disable=empty-docstring
""" """
model = self.app.model
return model.User
def sort_query(self, query): # pylint: disable=empty-docstring
""" """
return query.order_by(self.model_class.username)
def get_object_url(self, obj): # pylint: disable=empty-docstring
""" """
user = obj
return self.request.route_url("users.view", uuid=user.uuid)
[docs]
class RoleRefs(WuttaSet):
"""
Form schema type for the User
:attr:`~wuttjamaican:wuttjamaican.db.model.auth.User.roles`
association proxy field.
This is a subclass of :class:`WuttaSet`. It uses a ``set`` of
:class:`~wuttjamaican:wuttjamaican.db.model.auth.Role` ``uuid``
values for underlying data format.
"""
[docs]
class Permissions(WuttaSet):
"""
Form schema type for the Role
:attr:`~wuttjamaican:wuttjamaican.db.model.auth.Role.permissions`
association proxy field.
This is a subclass of :class:`WuttaSet`. It uses a ``set`` of
:attr:`~wuttjamaican:wuttjamaican.db.model.auth.Permission.permission`
values for underlying data format.
:param permissions: Dict with all possible permissions. Should be
in the same format as returned by
:meth:`~wuttaweb.views.roles.RoleView.get_available_permissions()`.
"""
def __init__(self, request, permissions, *args, **kwargs):
super().__init__(request, *args, **kwargs)
self.permissions = permissions
[docs]
class FileDownload(colander.String):
"""
Custom schema type for a file download field.
This field is only meant for readonly use, it does not handle file
uploads.
It expects the incoming ``appstruct`` to be the path to a file on
disk (or null).
Uses the :class:`~wuttaweb.forms.widgets.FileDownloadWidget` by
default.
:param request: Current :term:`request` object.
:param url: Optional URL for hyperlink. If not specified, file
name/size is shown with no hyperlink.
"""
# 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 widget_maker(self, **kwargs): # pylint: disable=empty-docstring
""" """
kwargs.setdefault("url", self.url)
return widgets.FileDownloadWidget(self.request, **kwargs)
[docs]
class EmailRecipients(colander.String):
"""
Custom schema type for :term:`email setting` recipient fields
(``To``, ``Cc``, ``Bcc``).
"""
def serialize(self, node, appstruct): # pylint: disable=empty-docstring
""" """
if appstruct is colander.null:
return colander.null
return "\n".join(parse_list(appstruct))
def deserialize(self, node, cstruct): # pylint: disable=empty-docstring
""" """
if cstruct is colander.null:
return colander.null
values = [value for value in parse_list(cstruct) if value]
return ", ".join(values)
# nb. colanderalchemy schema overrides
sa.DateTime.__colanderalchemy_config__ = {"typ": WuttaDateTime}