Coverage for .tox/coverage/lib/python3.13/site-packages/wuttaweb/forms/schema.py: 100%
257 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 schema types
25"""
27import datetime
28import uuid as _uuid
30import colander
31import sqlalchemy as sa
33from wuttjamaican.conf import parse_list
34from wuttjamaican.util import localtime
36from wuttaweb.db import Session
37from wuttaweb.forms import widgets
40class WuttaDateTime(colander.DateTime):
41 """
42 Custom schema type for :class:`~python:datetime.datetime` fields.
44 This should be used automatically for
45 :class:`~sqlalchemy:sqlalchemy.types.DateTime` ORM columns unless
46 you register another default.
48 This schema type exists for sake of convenience, when working with
49 the Buefy datepicker + timepicker widgets.
51 It also follows the datetime handling "rules" as outlined in
52 :doc:`wuttjamaican:narr/datetime`. On the Python side, values
53 should be naive/UTC datetime objects. On the HTTP side, values
54 will be ISO-format strings representing aware/local time.
55 """
57 def serialize(self, node, appstruct):
58 if not appstruct:
59 return colander.null
61 # nb. request should be present when it matters
62 if node.widget and node.widget.request:
63 request = node.widget.request
64 config = request.wutta_config
65 app = config.get_app()
66 appstruct = app.localtime(appstruct)
67 else:
68 # but if not, fallback to config-less logic
69 appstruct = localtime(appstruct)
71 if self.format:
72 return appstruct.strftime(self.format)
73 return appstruct.isoformat()
75 def deserialize( # pylint: disable=inconsistent-return-statements
76 self, node, cstruct
77 ):
78 if not cstruct:
79 return colander.null
81 formats = [
82 "%Y-%m-%dT%H:%M:%S",
83 "%Y-%m-%dT%I:%M %p",
84 ]
86 # nb. request is always assumed to be present here
87 request = node.widget.request
88 config = request.wutta_config
89 app = config.get_app()
91 for fmt in formats:
92 try:
93 dt = datetime.datetime.strptime(cstruct, fmt)
94 if not dt.tzinfo:
95 dt = app.localtime(dt, from_utc=False)
96 return app.make_utc(dt)
97 except Exception: # pylint: disable=broad-exception-caught
98 pass
100 node.raise_invalid("Invalid date and/or time")
103class ObjectNode(colander.SchemaNode): # pylint: disable=abstract-method
104 """
105 Custom schema node class which adds methods for compatibility with
106 ColanderAlchemy. This is a direct subclass of
107 :class:`colander:colander.SchemaNode`.
109 ColanderAlchemy will call certain methods on any node found in the
110 schema. However these methods are not "standard" and only exist
111 for ColanderAlchemy nodes.
113 So we must add nodes using this class, to ensure the node has all
114 methods needed by ColanderAlchemy.
115 """
117 def dictify(self, obj):
118 """
119 This method is called by ColanderAlchemy when translating the
120 in-app Python object to a value suitable for use in the form
121 data dict.
123 The logic here will look for a ``dictify()`` method on the
124 node's "type" instance (``self.typ``; see also
125 :class:`colander:colander.SchemaNode`) and invoke it if found.
127 For an example type which is supported in this way, see
128 :class:`ObjectRef`.
130 If the node's type does not have a ``dictify()`` method, this
131 will just convert the object to a string and return that.
132 """
133 if hasattr(self.typ, "dictify"):
134 return self.typ.dictify(obj)
136 # TODO: this is better than raising an error, as it previously
137 # did, but seems like troubleshooting problems may often lead
138 # one here.. i suspect this needs to do something smarter but
139 # not sure what that is yet
140 return str(obj)
142 def objectify(self, value):
143 """
144 This method is called by ColanderAlchemy when translating form
145 data to the final Python representation.
147 The logic here will look for an ``objectify()`` method on the
148 node's "type" instance (``self.typ``; see also
149 :class:`colander:colander.SchemaNode`) and invoke it if found.
151 For an example type which is supported in this way, see
152 :class:`ObjectRef`.
154 If the node's type does not have an ``objectify()`` method,
155 this will raise ``NotImplementeError``.
156 """
157 if hasattr(self.typ, "objectify"):
158 return self.typ.objectify(value)
160 class_name = self.typ.__class__.__name__
161 raise NotImplementedError(f"you must define {class_name}.objectify()")
164class WuttaEnum(colander.Enum):
165 """
166 Custom schema type for enum fields.
168 This is a subclass of :class:`colander.Enum`, but adds a
169 default widget (``SelectWidget``) with enum choices.
171 :param request: Current :term:`request` object.
172 """
174 def __init__(self, request, *args, **kwargs):
175 super().__init__(*args, **kwargs)
176 self.request = request
177 self.config = self.request.wutta_config
178 self.app = self.config.get_app()
180 def widget_maker(self, **kwargs): # pylint: disable=empty-docstring
181 """ """
183 if "values" not in kwargs:
184 kwargs["values"] = [
185 (getattr(e, self.attr), getattr(e, self.attr)) for e in self.enum_cls
186 ]
188 return widgets.SelectWidget(**kwargs)
191class WuttaDictEnum(colander.String):
192 """
193 Schema type for "pseudo-enum" fields which reference a dict for
194 known values instead of a true enum class.
196 This is primarily for use with "status" fields such as
197 :attr:`~wuttjamaican:wuttjamaican.db.model.batch.BatchRowMixin.status_code`.
199 This is a subclass of :class:`colander.String`, but adds a default
200 widget (``SelectWidget``) with enum choices.
202 :param request: Current :term:`request` object.
204 :param enum_dct: Dict with possible enum values and labels.
205 """
207 def __init__(self, request, enum_dct, *args, **kwargs):
208 self.null_value = kwargs.pop("null_value", "")
209 super().__init__(*args, **kwargs)
210 self.request = request
211 self.config = self.request.wutta_config
212 self.app = self.config.get_app()
213 self.enum_dct = enum_dct
215 def serialize(self, node, appstruct):
216 if appstruct is colander.null:
217 return self.null_value
218 return super().serialize(node, appstruct)
220 def widget_maker(self, **kwargs): # pylint: disable=empty-docstring
221 """ """
222 if "values" not in kwargs:
223 kwargs["values"] = list(self.enum_dct.items())
224 kwargs.setdefault("null_value", self.null_value)
225 return widgets.SelectWidget(**kwargs)
228class WuttaMoney(colander.Money):
229 """
230 Custom schema type for "money" fields.
232 This is a subclass of :class:`colander:colander.Money`, but uses
233 the custom :class:`~wuttaweb.forms.widgets.WuttaMoneyInputWidget`
234 by default.
236 :param request: Current :term:`request` object.
238 :param scale: If this kwarg is specified, it will be passed along
239 to the widget constructor.
240 """
242 def __init__(self, request, *args, **kwargs):
243 self.scale = kwargs.pop("scale", None)
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 widget_maker(self, **kwargs): # pylint: disable=empty-docstring
250 """ """
251 if self.scale:
252 kwargs.setdefault("scale", self.scale)
253 return widgets.WuttaMoneyInputWidget(self.request, **kwargs)
256class WuttaQuantity(colander.Decimal):
257 """
258 Custom schema type for "quantity" fields.
260 This is a subclass of :class:`colander:colander.Decimal`, but uses
261 the custom
262 :class:`~wuttaweb.forms.widgets.WuttaQuantityInputWidget` by
263 default.
265 :param request: Current :term:`request` object.
266 """
268 def __init__(self, request, *args, **kwargs):
269 super().__init__(*args, **kwargs)
270 self.request = request
271 self.config = self.request.wutta_config
272 self.app = self.config.get_app()
274 def widget_maker(self, **kwargs): # pylint: disable=empty-docstring
275 """ """
276 return widgets.WuttaQuantityInputWidget(self.request, **kwargs)
279class WuttaList(colander.List):
280 """
281 Custom schema type for :class:`python:list` fields; this is a
282 subclass of :class:`colander.List`.
284 :param request: Current :term:`request` object.
286 As of now this merely provides a way (in fact, requires you) to
287 pass the request in, so it can be leveraged as needed. Instances
288 of this type will have the following attributes:
290 .. attribute:: request
292 Reference to the current :term:`request`.
294 .. attribute:: config
296 Reference to the app :term:`config object`.
298 .. attribute:: app
300 Reference to the :term:`app handler` instance.
301 """
303 def __init__(self, request):
304 super().__init__()
305 self.request = request
306 self.config = self.request.wutta_config
307 self.app = self.config.get_app()
310class WuttaSet(colander.Set):
311 """
312 Custom schema type for :class:`python:set` fields; this is a
313 subclass of :class:`colander.Set`.
315 :param request: Current :term:`request` object.
317 As of now this merely provides a way (in fact, requires you) to
318 pass the request in, so it can be leveraged as needed. Instances
319 of this type will have the following attributes:
321 .. attribute:: request
323 Reference to the current :term:`request`.
325 .. attribute:: config
327 Reference to the app :term:`config object`.
329 .. attribute:: app
331 Reference to the :term:`app handler` instance.
332 """
334 def __init__(self, request):
335 super().__init__()
336 self.request = request
337 self.config = self.request.wutta_config
338 self.app = self.config.get_app()
341class ObjectRef(colander.SchemaType):
342 """
343 Custom schema type for a model class reference field.
345 This expects the incoming ``appstruct`` to be either a model
346 record instance, or ``None``.
348 Serializes to the instance UUID as string, or ``colander.null``;
349 form data should be of the same nature.
351 This schema type is not useful directly, but various other types
352 will subclass it. Each should define (at least) the
353 :attr:`model_class` attribute or property.
355 :param request: Current :term:`request` object.
357 :param empty_option: If a select widget is used, this determines
358 whether an empty option is included for the dropdown. Set
359 this to one of the following to add an empty option:
361 * ``True`` to add the default empty option
362 * label text for the empty option
363 * tuple of ``(value, label)`` for the empty option
365 Note that in the latter, ``value`` must be a string.
366 """
368 default_empty_option = ("", "(none)")
370 def __init__(self, request, *args, **kwargs):
371 empty_option = kwargs.pop("empty_option", None)
372 # nb. allow session injection for tests
373 self.session = kwargs.pop("session", Session())
374 super().__init__(*args, **kwargs)
375 self.request = request
376 self.config = self.request.wutta_config
377 self.app = self.config.get_app()
378 self.model_instance = None
380 if empty_option:
381 if empty_option is True:
382 self.empty_option = self.default_empty_option
383 elif isinstance(empty_option, tuple) and len(empty_option) == 2:
384 self.empty_option = empty_option
385 else:
386 self.empty_option = ("", str(empty_option))
387 else:
388 self.empty_option = None
390 @property
391 def model_class(self):
392 """
393 Should be a reference to the model class to which this schema
394 type applies
395 (e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`).
396 """
397 class_name = self.__class__.__name__
398 raise NotImplementedError(f"you must define {class_name}.model_class")
400 def serialize(self, node, appstruct): # pylint: disable=empty-docstring
401 """ """
402 # normalize to empty option if no object ref, so that works as
403 # expected
404 if self.empty_option and not appstruct:
405 return self.empty_option[0]
407 # even if there is no empty option, still treat any false-ish
408 # value as null
409 if not appstruct:
410 return colander.null
412 # keep a ref to this for later use
413 node.model_instance = appstruct
415 # serialize to PK as string
416 return self.serialize_object(appstruct)
418 def serialize_object(self, obj):
419 """
420 Serialize the given object to its primary key as string.
422 Default logic assumes the object has a UUID; subclass can
423 override as needed.
425 :param obj: Object reference for the node.
427 :returns: Object primary key as string.
428 """
429 return obj.uuid.hex
431 def deserialize( # pylint: disable=empty-docstring,unused-argument
432 self, node, cstruct
433 ):
434 """ """
435 if not cstruct:
436 return colander.null
438 # nb. use shortcut to fetch model instance from DB
439 return self.objectify(cstruct)
441 def dictify(self, obj): # pylint: disable=empty-docstring
442 """ """
444 # TODO: would we ever need to do something else?
445 return obj
447 def objectify(self, value):
448 """
449 For the given UUID value, returns the object it represents
450 (based on :attr:`model_class`).
452 If the value is empty, returns ``None``.
454 If the value is not empty but object cannot be found, raises
455 ``colander.Invalid``.
456 """
457 if not value:
458 return None
460 if isinstance( # pylint: disable=isinstance-second-argument-not-valid-type
461 value, self.model_class
462 ):
463 return value
465 # fetch object from DB
466 obj = None
467 if isinstance(value, _uuid.UUID):
468 obj = self.session.get(self.model_class, value)
469 else:
470 try:
471 obj = self.session.get(self.model_class, _uuid.UUID(value))
472 except ValueError:
473 pass
475 # raise error if not found
476 if not obj:
477 class_name = self.model_class.__name__
478 raise ValueError(f"{class_name} not found: {value}")
480 return obj
482 def get_query(self):
483 """
484 Returns the main SQLAlchemy query responsible for locating the
485 dropdown choices for the select widget.
487 This is called by :meth:`widget_maker()`.
488 """
489 query = self.session.query(self.model_class)
490 query = self.sort_query(query)
491 return query
493 def sort_query(self, query):
494 """
495 TODO
496 """
497 return query
499 def widget_maker(self, **kwargs):
500 """
501 This method is responsible for producing the default widget
502 for the schema node.
504 Deform calls this method automatically when constructing the
505 default widget for a field.
507 :returns: Instance of
508 :class:`~wuttaweb.forms.widgets.ObjectRefWidget`.
509 """
510 factory = kwargs.pop("factory", widgets.ObjectRefWidget)
512 if "values" not in kwargs:
513 query = self.get_query()
514 objects = query.all()
515 values = [(self.serialize_object(obj), str(obj)) for obj in objects]
516 if self.empty_option:
517 values.insert(0, self.empty_option)
518 kwargs["values"] = values
520 if "url" not in kwargs:
521 kwargs["url"] = self.get_object_url
523 return factory(self.request, **kwargs)
525 def get_object_url(self, obj):
526 """
527 Returns the "view" URL for the given object, if applicable.
529 This is used when rendering the field readonly. If this
530 method returns a URL then the field text will be wrapped with
531 a hyperlink, otherwise it will be shown as-is.
533 Default logic always returns ``None``; subclass should
534 override as needed.
535 """
538class PersonRef(ObjectRef):
539 """
540 Custom schema type for a
541 :class:`~wuttjamaican:wuttjamaican.db.model.base.Person` reference
542 field.
544 This is a subclass of :class:`ObjectRef`.
545 """
547 @property
548 def model_class(self): # pylint: disable=empty-docstring
549 """ """
550 model = self.app.model
551 return model.Person
553 def sort_query(self, query): # pylint: disable=empty-docstring
554 """ """
555 return query.order_by(self.model_class.full_name)
557 def get_object_url(self, obj): # pylint: disable=empty-docstring
558 """ """
559 person = obj
560 return self.request.route_url("people.view", uuid=person.uuid)
563class RoleRef(ObjectRef):
564 """
565 Custom schema type for a
566 :class:`~wuttjamaican:wuttjamaican.db.model.auth.Role` reference
567 field.
569 This is a subclass of :class:`ObjectRef`.
570 """
572 @property
573 def model_class(self): # pylint: disable=empty-docstring
574 """ """
575 model = self.app.model
576 return model.Role
578 def sort_query(self, query): # pylint: disable=empty-docstring
579 """ """
580 return query.order_by(self.model_class.name)
582 def get_object_url(self, obj): # pylint: disable=empty-docstring
583 """ """
584 role = obj
585 return self.request.route_url("roles.view", uuid=role.uuid)
588class UserRef(ObjectRef):
589 """
590 Custom schema type for a
591 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` reference
592 field.
594 This is a subclass of :class:`ObjectRef`.
595 """
597 @property
598 def model_class(self): # pylint: disable=empty-docstring
599 """ """
600 model = self.app.model
601 return model.User
603 def sort_query(self, query): # pylint: disable=empty-docstring
604 """ """
605 return query.order_by(self.model_class.username)
607 def get_object_url(self, obj): # pylint: disable=empty-docstring
608 """ """
609 user = obj
610 return self.request.route_url("users.view", uuid=user.uuid)
613class RoleRefs(WuttaSet):
614 """
615 Form schema type for the User
616 :attr:`~wuttjamaican:wuttjamaican.db.model.auth.User.roles`
617 association proxy field.
619 This is a subclass of :class:`WuttaSet`. It uses a ``set`` of
620 :class:`~wuttjamaican:wuttjamaican.db.model.auth.Role` ``uuid``
621 values for underlying data format.
622 """
624 def widget_maker(self, **kwargs):
625 """
626 Constructs a default widget for the field.
628 :returns: Instance of
629 :class:`~wuttaweb.forms.widgets.RoleRefsWidget`.
630 """
631 session = kwargs.setdefault("session", Session())
633 if "values" not in kwargs:
634 model = self.app.model
635 auth = self.app.get_auth_handler()
637 # avoid built-ins which cannot be assigned to users
638 avoid = {
639 auth.get_role_authenticated(session),
640 auth.get_role_anyone(session),
641 }
642 avoid = {role.uuid for role in avoid}
644 # also avoid admin unless current user is root
645 if not self.request.is_root:
646 avoid.add(auth.get_role_administrator(session).uuid)
648 # everything else can be (un)assigned for users
649 roles = (
650 session.query(model.Role)
651 .filter(~model.Role.uuid.in_(avoid))
652 .order_by(model.Role.name)
653 .all()
654 )
655 values = [(role.uuid.hex, role.name) for role in roles]
656 kwargs["values"] = values
658 return widgets.RoleRefsWidget(self.request, **kwargs)
661class Permissions(WuttaSet):
662 """
663 Form schema type for the Role
664 :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Role.permissions`
665 association proxy field.
667 This is a subclass of :class:`WuttaSet`. It uses a ``set`` of
668 :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Permission.permission`
669 values for underlying data format.
671 :param permissions: Dict with all possible permissions. Should be
672 in the same format as returned by
673 :meth:`~wuttaweb.views.roles.RoleView.get_available_permissions()`.
674 """
676 def __init__(self, request, permissions, *args, **kwargs):
677 super().__init__(request, *args, **kwargs)
678 self.permissions = permissions
680 def widget_maker(self, **kwargs):
681 """
682 Constructs a default widget for the field.
684 :returns: Instance of
685 :class:`~wuttaweb.forms.widgets.PermissionsWidget`.
686 """
687 kwargs.setdefault("session", Session())
688 kwargs.setdefault("permissions", self.permissions)
690 if "values" not in kwargs:
691 values = []
692 for group in self.permissions.values():
693 for pkey, perm in group["perms"].items():
694 values.append((pkey, perm["label"]))
695 kwargs["values"] = values
697 return widgets.PermissionsWidget(self.request, **kwargs)
700class FileDownload(colander.String):
701 """
702 Custom schema type for a file download field.
704 This field is only meant for readonly use, it does not handle file
705 uploads.
707 It expects the incoming ``appstruct`` to be the path to a file on
708 disk (or null).
710 Uses the :class:`~wuttaweb.forms.widgets.FileDownloadWidget` by
711 default.
713 :param request: Current :term:`request` object.
715 :param url: Optional URL for hyperlink. If not specified, file
716 name/size is shown with no hyperlink.
717 """
719 # pylint: disable=duplicate-code
720 def __init__(self, request, *args, **kwargs):
721 self.url = kwargs.pop("url", None)
722 super().__init__(*args, **kwargs)
723 self.request = request
724 self.config = self.request.wutta_config
725 self.app = self.config.get_app()
727 # pylint: enable=duplicate-code
729 def widget_maker(self, **kwargs): # pylint: disable=empty-docstring
730 """ """
731 kwargs.setdefault("url", self.url)
732 return widgets.FileDownloadWidget(self.request, **kwargs)
735class EmailRecipients(colander.String):
736 """
737 Custom schema type for :term:`email setting` recipient fields
738 (``To``, ``Cc``, ``Bcc``).
740 Note that this inherits from :class:`colander.String` and assumes
741 the Python value is a "simple" string. Multiple recipients are
742 represented as comma-delimited for instance; this is for use
743 "as-is" with the raw :term:`config setting` values.
745 The value is serialized as a "multiline" string with one recipient
746 per line. The assumption being, the input widget would display as
747 a ``<textarea>`` element.
748 """
750 def serialize(self, node, appstruct): # pylint: disable=empty-docstring
751 """ """
752 if appstruct is colander.null:
753 return colander.null
755 return "\n".join(parse_list(appstruct))
757 def deserialize(self, node, cstruct): # pylint: disable=empty-docstring
758 """ """
759 if cstruct is colander.null:
760 return colander.null
762 values = [value for value in parse_list(cstruct) if value]
763 return ", ".join(values)
765 def widget_maker(self, **kwargs):
766 """
767 Constructs a default widget for the field.
769 :returns: Instance of
770 :class:`~wuttaweb.forms.widgets.EmailRecipientsWidget`.
771 """
772 return widgets.EmailRecipientsWidget(**kwargs)
775# nb. colanderalchemy schema overrides
776sa.DateTime.__colanderalchemy_config__ = {"typ": WuttaDateTime}