Coverage for .tox / coverage / lib / python3.11 / site-packages / wuttaweb / forms / schema.py: 100%
259 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-10 11:05 -0500
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-10 11:05 -0500
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 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 will
261 serialize values via
262 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_quantity()`.
264 :param request: Current :term:`request` object.
265 """
267 def __init__(self, request, *args, **kwargs):
268 super().__init__(*args, **kwargs)
269 self.request = request
270 self.config = self.request.wutta_config
271 self.app = self.config.get_app()
273 def serialize(self, node, appstruct): # pylint: disable=empty-docstring
274 """ """
275 if appstruct in (colander.null, None):
276 return colander.null
278 # nb. we render as quantity here to avoid values like 12.0000,
279 # so we just show value like 12 instead
280 return self.app.render_quantity(appstruct)
283class WuttaList(colander.List):
284 """
285 Custom schema type for :class:`python:list` fields; this is a
286 subclass of :class:`colander.List`.
288 :param request: Current :term:`request` object.
290 As of now this merely provides a way (in fact, requires you) to
291 pass the request in, so it can be leveraged as needed. Instances
292 of this type will have the following attributes:
294 .. attribute:: request
296 Reference to the current :term:`request`.
298 .. attribute:: config
300 Reference to the app :term:`config object`.
302 .. attribute:: app
304 Reference to the :term:`app handler` instance.
305 """
307 def __init__(self, request):
308 super().__init__()
309 self.request = request
310 self.config = self.request.wutta_config
311 self.app = self.config.get_app()
314class WuttaSet(colander.Set):
315 """
316 Custom schema type for :class:`python:set` fields; this is a
317 subclass of :class:`colander.Set`.
319 :param request: Current :term:`request` object.
321 As of now this merely provides a way (in fact, requires you) to
322 pass the request in, so it can be leveraged as needed. Instances
323 of this type will have the following attributes:
325 .. attribute:: request
327 Reference to the current :term:`request`.
329 .. attribute:: config
331 Reference to the app :term:`config object`.
333 .. attribute:: app
335 Reference to the :term:`app handler` instance.
336 """
338 def __init__(self, request):
339 super().__init__()
340 self.request = request
341 self.config = self.request.wutta_config
342 self.app = self.config.get_app()
345class ObjectRef(colander.SchemaType):
346 """
347 Custom schema type for a model class reference field.
349 This expects the incoming ``appstruct`` to be either a model
350 record instance, or ``None``.
352 Serializes to the instance UUID as string, or ``colander.null``;
353 form data should be of the same nature.
355 This schema type is not useful directly, but various other types
356 will subclass it. Each should define (at least) the
357 :attr:`model_class` attribute or property.
359 :param request: Current :term:`request` object.
361 :param empty_option: If a select widget is used, this determines
362 whether an empty option is included for the dropdown. Set
363 this to one of the following to add an empty option:
365 * ``True`` to add the default empty option
366 * label text for the empty option
367 * tuple of ``(value, label)`` for the empty option
369 Note that in the latter, ``value`` must be a string.
370 """
372 default_empty_option = ("", "(none)")
374 def __init__(self, request, *args, **kwargs):
375 empty_option = kwargs.pop("empty_option", None)
376 # nb. allow session injection for tests
377 self.session = kwargs.pop("session", Session())
378 super().__init__(*args, **kwargs)
379 self.request = request
380 self.config = self.request.wutta_config
381 self.app = self.config.get_app()
382 self.model_instance = None
384 if empty_option:
385 if empty_option is True:
386 self.empty_option = self.default_empty_option
387 elif isinstance(empty_option, tuple) and len(empty_option) == 2:
388 self.empty_option = empty_option
389 else:
390 self.empty_option = ("", str(empty_option))
391 else:
392 self.empty_option = None
394 @property
395 def model_class(self):
396 """
397 Should be a reference to the model class to which this schema
398 type applies
399 (e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`).
400 """
401 class_name = self.__class__.__name__
402 raise NotImplementedError(f"you must define {class_name}.model_class")
404 def serialize(self, node, appstruct): # pylint: disable=empty-docstring
405 """ """
406 # normalize to empty option if no object ref, so that works as
407 # expected
408 if self.empty_option and not appstruct:
409 return self.empty_option[0]
411 # even if there is no empty option, still treat any false-ish
412 # value as null
413 if not appstruct:
414 return colander.null
416 # keep a ref to this for later use
417 node.model_instance = appstruct
419 # serialize to PK as string
420 return self.serialize_object(appstruct)
422 def serialize_object(self, obj):
423 """
424 Serialize the given object to its primary key as string.
426 Default logic assumes the object has a UUID; subclass can
427 override as needed.
429 :param obj: Object reference for the node.
431 :returns: Object primary key as string.
432 """
433 return obj.uuid.hex
435 def deserialize( # pylint: disable=empty-docstring,unused-argument
436 self, node, cstruct
437 ):
438 """ """
439 if not cstruct:
440 return colander.null
442 # nb. use shortcut to fetch model instance from DB
443 return self.objectify(cstruct)
445 def dictify(self, obj): # pylint: disable=empty-docstring
446 """ """
448 # TODO: would we ever need to do something else?
449 return obj
451 def objectify(self, value):
452 """
453 For the given UUID value, returns the object it represents
454 (based on :attr:`model_class`).
456 If the value is empty, returns ``None``.
458 If the value is not empty but object cannot be found, raises
459 ``colander.Invalid``.
460 """
461 if not value:
462 return None
464 if isinstance( # pylint: disable=isinstance-second-argument-not-valid-type
465 value, self.model_class
466 ):
467 return value
469 # fetch object from DB
470 obj = None
471 if isinstance(value, _uuid.UUID):
472 obj = self.session.get(self.model_class, value)
473 else:
474 try:
475 obj = self.session.get(self.model_class, _uuid.UUID(value))
476 except ValueError:
477 pass
479 # raise error if not found
480 if not obj:
481 class_name = self.model_class.__name__
482 raise ValueError(f"{class_name} not found: {value}")
484 return obj
486 def get_query(self):
487 """
488 Returns the main SQLAlchemy query responsible for locating the
489 dropdown choices for the select widget.
491 This is called by :meth:`widget_maker()`.
492 """
493 query = self.session.query(self.model_class)
494 query = self.sort_query(query)
495 return query
497 def sort_query(self, query):
498 """
499 TODO
500 """
501 return query
503 def widget_maker(self, **kwargs):
504 """
505 This method is responsible for producing the default widget
506 for the schema node.
508 Deform calls this method automatically when constructing the
509 default widget for a field.
511 :returns: Instance of
512 :class:`~wuttaweb.forms.widgets.ObjectRefWidget`.
513 """
514 factory = kwargs.pop("factory", widgets.ObjectRefWidget)
516 if "values" not in kwargs:
517 query = self.get_query()
518 objects = query.all()
519 values = [(self.serialize_object(obj), str(obj)) for obj in objects]
520 if self.empty_option:
521 values.insert(0, self.empty_option)
522 kwargs["values"] = values
524 if "url" not in kwargs:
525 kwargs["url"] = self.get_object_url
527 return factory(self.request, **kwargs)
529 def get_object_url(self, obj):
530 """
531 Returns the "view" URL for the given object, if applicable.
533 This is used when rendering the field readonly. If this
534 method returns a URL then the field text will be wrapped with
535 a hyperlink, otherwise it will be shown as-is.
537 Default logic always returns ``None``; subclass should
538 override as needed.
539 """
542class PersonRef(ObjectRef):
543 """
544 Custom schema type for a
545 :class:`~wuttjamaican:wuttjamaican.db.model.base.Person` reference
546 field.
548 This is a subclass of :class:`ObjectRef`.
549 """
551 @property
552 def model_class(self): # pylint: disable=empty-docstring
553 """ """
554 model = self.app.model
555 return model.Person
557 def sort_query(self, query): # pylint: disable=empty-docstring
558 """ """
559 return query.order_by(self.model_class.full_name)
561 def get_object_url(self, obj): # pylint: disable=empty-docstring
562 """ """
563 person = obj
564 return self.request.route_url("people.view", uuid=person.uuid)
567class RoleRef(ObjectRef):
568 """
569 Custom schema type for a
570 :class:`~wuttjamaican:wuttjamaican.db.model.auth.Role` reference
571 field.
573 This is a subclass of :class:`ObjectRef`.
574 """
576 @property
577 def model_class(self): # pylint: disable=empty-docstring
578 """ """
579 model = self.app.model
580 return model.Role
582 def sort_query(self, query): # pylint: disable=empty-docstring
583 """ """
584 return query.order_by(self.model_class.name)
586 def get_object_url(self, obj): # pylint: disable=empty-docstring
587 """ """
588 role = obj
589 return self.request.route_url("roles.view", uuid=role.uuid)
592class UserRef(ObjectRef):
593 """
594 Custom schema type for a
595 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` reference
596 field.
598 This is a subclass of :class:`ObjectRef`.
599 """
601 @property
602 def model_class(self): # pylint: disable=empty-docstring
603 """ """
604 model = self.app.model
605 return model.User
607 def sort_query(self, query): # pylint: disable=empty-docstring
608 """ """
609 return query.order_by(self.model_class.username)
611 def get_object_url(self, obj): # pylint: disable=empty-docstring
612 """ """
613 user = obj
614 return self.request.route_url("users.view", uuid=user.uuid)
617class RoleRefs(WuttaSet):
618 """
619 Form schema type for the User
620 :attr:`~wuttjamaican:wuttjamaican.db.model.auth.User.roles`
621 association proxy field.
623 This is a subclass of :class:`WuttaSet`. It uses a ``set`` of
624 :class:`~wuttjamaican:wuttjamaican.db.model.auth.Role` ``uuid``
625 values for underlying data format.
626 """
628 def widget_maker(self, **kwargs):
629 """
630 Constructs a default widget for the field.
632 :returns: Instance of
633 :class:`~wuttaweb.forms.widgets.RoleRefsWidget`.
634 """
635 session = kwargs.setdefault("session", Session())
637 if "values" not in kwargs:
638 model = self.app.model
639 auth = self.app.get_auth_handler()
641 # avoid built-ins which cannot be assigned to users
642 avoid = {
643 auth.get_role_authenticated(session),
644 auth.get_role_anonymous(session),
645 }
646 avoid = {role.uuid for role in avoid}
648 # also avoid admin unless current user is root
649 if not self.request.is_root:
650 avoid.add(auth.get_role_administrator(session).uuid)
652 # everything else can be (un)assigned for users
653 roles = (
654 session.query(model.Role)
655 .filter(~model.Role.uuid.in_(avoid))
656 .order_by(model.Role.name)
657 .all()
658 )
659 values = [(role.uuid.hex, role.name) for role in roles]
660 kwargs["values"] = values
662 return widgets.RoleRefsWidget(self.request, **kwargs)
665class Permissions(WuttaSet):
666 """
667 Form schema type for the Role
668 :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Role.permissions`
669 association proxy field.
671 This is a subclass of :class:`WuttaSet`. It uses a ``set`` of
672 :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Permission.permission`
673 values for underlying data format.
675 :param permissions: Dict with all possible permissions. Should be
676 in the same format as returned by
677 :meth:`~wuttaweb.views.roles.RoleView.get_available_permissions()`.
678 """
680 def __init__(self, request, permissions, *args, **kwargs):
681 super().__init__(request, *args, **kwargs)
682 self.permissions = permissions
684 def widget_maker(self, **kwargs):
685 """
686 Constructs a default widget for the field.
688 :returns: Instance of
689 :class:`~wuttaweb.forms.widgets.PermissionsWidget`.
690 """
691 kwargs.setdefault("session", Session())
692 kwargs.setdefault("permissions", self.permissions)
694 if "values" not in kwargs:
695 values = []
696 for group in self.permissions.values():
697 for pkey, perm in group["perms"].items():
698 values.append((pkey, perm["label"]))
699 kwargs["values"] = values
701 return widgets.PermissionsWidget(self.request, **kwargs)
704class FileDownload(colander.String):
705 """
706 Custom schema type for a file download field.
708 This field is only meant for readonly use, it does not handle file
709 uploads.
711 It expects the incoming ``appstruct`` to be the path to a file on
712 disk (or null).
714 Uses the :class:`~wuttaweb.forms.widgets.FileDownloadWidget` by
715 default.
717 :param request: Current :term:`request` object.
719 :param url: Optional URL for hyperlink. If not specified, file
720 name/size is shown with no hyperlink.
721 """
723 # pylint: disable=duplicate-code
724 def __init__(self, request, *args, **kwargs):
725 self.url = kwargs.pop("url", None)
726 super().__init__(*args, **kwargs)
727 self.request = request
728 self.config = self.request.wutta_config
729 self.app = self.config.get_app()
731 # pylint: enable=duplicate-code
733 def widget_maker(self, **kwargs): # pylint: disable=empty-docstring
734 """ """
735 kwargs.setdefault("url", self.url)
736 return widgets.FileDownloadWidget(self.request, **kwargs)
739class EmailRecipients(colander.String):
740 """
741 Custom schema type for :term:`email setting` recipient fields
742 (``To``, ``Cc``, ``Bcc``).
743 """
745 def serialize(self, node, appstruct): # pylint: disable=empty-docstring
746 """ """
747 if appstruct is colander.null:
748 return colander.null
750 return "\n".join(parse_list(appstruct))
752 def deserialize(self, node, cstruct): # pylint: disable=empty-docstring
753 """ """
754 if cstruct is colander.null:
755 return colander.null
757 values = [value for value in parse_list(cstruct) if value]
758 return ", ".join(values)
760 def widget_maker(self, **kwargs):
761 """
762 Constructs a default widget for the field.
764 :returns: Instance of
765 :class:`~wuttaweb.forms.widgets.EmailRecipientsWidget`.
766 """
767 return widgets.EmailRecipientsWidget(**kwargs)
770# nb. colanderalchemy schema overrides
771sa.DateTime.__colanderalchemy_config__ = {"typ": WuttaDateTime}