Coverage for .tox / coverage / lib / python3.11 / site-packages / wuttaweb / forms / schema.py: 100%
246 statements
« prev ^ index » next coverage.py v7.13.1, created at 2025-12-28 15:23 -0600
« prev ^ index » next coverage.py v7.13.1, created at 2025-12-28 15:23 -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 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 super().__init__(*args, **kwargs)
209 self.request = request
210 self.config = self.request.wutta_config
211 self.app = self.config.get_app()
212 self.enum_dct = enum_dct
214 def widget_maker(self, **kwargs): # pylint: disable=empty-docstring
215 """ """
216 if "values" not in kwargs:
217 kwargs["values"] = list(self.enum_dct.items())
219 return widgets.SelectWidget(**kwargs)
222class WuttaMoney(colander.Money):
223 """
224 Custom schema type for "money" fields.
226 This is a subclass of :class:`colander:colander.Money`, but uses
227 the custom :class:`~wuttaweb.forms.widgets.WuttaMoneyInputWidget`
228 by default.
230 :param request: Current :term:`request` object.
232 :param scale: If this kwarg is specified, it will be passed along
233 to the widget constructor.
234 """
236 def __init__(self, request, *args, **kwargs):
237 self.scale = kwargs.pop("scale", None)
238 super().__init__(*args, **kwargs)
239 self.request = request
240 self.config = self.request.wutta_config
241 self.app = self.config.get_app()
243 def widget_maker(self, **kwargs): # pylint: disable=empty-docstring
244 """ """
245 if self.scale:
246 kwargs.setdefault("scale", self.scale)
247 return widgets.WuttaMoneyInputWidget(self.request, **kwargs)
250class WuttaQuantity(colander.Decimal):
251 """
252 Custom schema type for "quantity" fields.
254 This is a subclass of :class:`colander:colander.Decimal` but will
255 serialize values via
256 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_quantity()`.
258 :param request: Current :term:`request` object.
259 """
261 def __init__(self, request, *args, **kwargs):
262 super().__init__(*args, **kwargs)
263 self.request = request
264 self.config = self.request.wutta_config
265 self.app = self.config.get_app()
267 def serialize(self, node, appstruct): # pylint: disable=empty-docstring
268 """ """
269 if appstruct in (colander.null, None):
270 return colander.null
272 # nb. we render as quantity here to avoid values like 12.0000,
273 # so we just show value like 12 instead
274 return self.app.render_quantity(appstruct)
277class WuttaSet(colander.Set):
278 """
279 Custom schema type for :class:`python:set` fields.
281 This is a subclass of :class:`colander.Set`.
283 :param request: Current :term:`request` object.
284 """
286 def __init__(self, request):
287 super().__init__()
288 self.request = request
289 self.config = self.request.wutta_config
290 self.app = self.config.get_app()
293class ObjectRef(colander.SchemaType):
294 """
295 Custom schema type for a model class reference field.
297 This expects the incoming ``appstruct`` to be either a model
298 record instance, or ``None``.
300 Serializes to the instance UUID as string, or ``colander.null``;
301 form data should be of the same nature.
303 This schema type is not useful directly, but various other types
304 will subclass it. Each should define (at least) the
305 :attr:`model_class` attribute or property.
307 :param request: Current :term:`request` object.
309 :param empty_option: If a select widget is used, this determines
310 whether an empty option is included for the dropdown. Set
311 this to one of the following to add an empty option:
313 * ``True`` to add the default empty option
314 * label text for the empty option
315 * tuple of ``(value, label)`` for the empty option
317 Note that in the latter, ``value`` must be a string.
318 """
320 default_empty_option = ("", "(none)")
322 def __init__(self, request, *args, **kwargs):
323 empty_option = kwargs.pop("empty_option", None)
324 # nb. allow session injection for tests
325 self.session = kwargs.pop("session", Session())
326 super().__init__(*args, **kwargs)
327 self.request = request
328 self.config = self.request.wutta_config
329 self.app = self.config.get_app()
330 self.model_instance = None
332 if empty_option:
333 if empty_option is True:
334 self.empty_option = self.default_empty_option
335 elif isinstance(empty_option, tuple) and len(empty_option) == 2:
336 self.empty_option = empty_option
337 else:
338 self.empty_option = ("", str(empty_option))
339 else:
340 self.empty_option = None
342 @property
343 def model_class(self):
344 """
345 Should be a reference to the model class to which this schema
346 type applies
347 (e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`).
348 """
349 class_name = self.__class__.__name__
350 raise NotImplementedError(f"you must define {class_name}.model_class")
352 def serialize(self, node, appstruct): # pylint: disable=empty-docstring
353 """ """
354 # nb. normalize to empty option if no object ref, so that
355 # works as expected
356 if self.empty_option and not appstruct:
357 return self.empty_option[0]
359 if appstruct is colander.null:
360 return colander.null
362 # nb. keep a ref to this for later use
363 node.model_instance = appstruct
365 # serialize to PK as string
366 return self.serialize_object(appstruct)
368 def serialize_object(self, obj):
369 """
370 Serialize the given object to its primary key as string.
372 Default logic assumes the object has a UUID; subclass can
373 override as needed.
375 :param obj: Object reference for the node.
377 :returns: Object primary key as string.
378 """
379 return obj.uuid.hex
381 def deserialize( # pylint: disable=empty-docstring,unused-argument
382 self, node, cstruct
383 ):
384 """ """
385 if not cstruct:
386 return colander.null
388 # nb. use shortcut to fetch model instance from DB
389 return self.objectify(cstruct)
391 def dictify(self, obj): # pylint: disable=empty-docstring
392 """ """
394 # TODO: would we ever need to do something else?
395 return obj
397 def objectify(self, value):
398 """
399 For the given UUID value, returns the object it represents
400 (based on :attr:`model_class`).
402 If the value is empty, returns ``None``.
404 If the value is not empty but object cannot be found, raises
405 ``colander.Invalid``.
406 """
407 if not value:
408 return None
410 if isinstance( # pylint: disable=isinstance-second-argument-not-valid-type
411 value, self.model_class
412 ):
413 return value
415 # fetch object from DB
416 obj = None
417 if isinstance(value, _uuid.UUID):
418 obj = self.session.get(self.model_class, value)
419 else:
420 try:
421 obj = self.session.get(self.model_class, _uuid.UUID(value))
422 except ValueError:
423 pass
425 # raise error if not found
426 if not obj:
427 class_name = self.model_class.__name__
428 raise ValueError(f"{class_name} not found: {value}")
430 return obj
432 def get_query(self):
433 """
434 Returns the main SQLAlchemy query responsible for locating the
435 dropdown choices for the select widget.
437 This is called by :meth:`widget_maker()`.
438 """
439 query = self.session.query(self.model_class)
440 query = self.sort_query(query)
441 return query
443 def sort_query(self, query):
444 """
445 TODO
446 """
447 return query
449 def widget_maker(self, **kwargs):
450 """
451 This method is responsible for producing the default widget
452 for the schema node.
454 Deform calls this method automatically when constructing the
455 default widget for a field.
457 :returns: Instance of
458 :class:`~wuttaweb.forms.widgets.ObjectRefWidget`.
459 """
461 if "values" not in kwargs:
462 query = self.get_query()
463 objects = query.all()
464 values = [(self.serialize_object(obj), str(obj)) for obj in objects]
465 if self.empty_option:
466 values.insert(0, self.empty_option)
467 kwargs["values"] = values
469 if "url" not in kwargs:
470 kwargs["url"] = self.get_object_url
472 return widgets.ObjectRefWidget(self.request, **kwargs)
474 def get_object_url(self, obj):
475 """
476 Returns the "view" URL for the given object, if applicable.
478 This is used when rendering the field readonly. If this
479 method returns a URL then the field text will be wrapped with
480 a hyperlink, otherwise it will be shown as-is.
482 Default logic always returns ``None``; subclass should
483 override as needed.
484 """
487class PersonRef(ObjectRef):
488 """
489 Custom schema type for a
490 :class:`~wuttjamaican:wuttjamaican.db.model.base.Person` reference
491 field.
493 This is a subclass of :class:`ObjectRef`.
494 """
496 @property
497 def model_class(self): # pylint: disable=empty-docstring
498 """ """
499 model = self.app.model
500 return model.Person
502 def sort_query(self, query): # pylint: disable=empty-docstring
503 """ """
504 return query.order_by(self.model_class.full_name)
506 def get_object_url(self, obj): # pylint: disable=empty-docstring
507 """ """
508 person = obj
509 return self.request.route_url("people.view", uuid=person.uuid)
512class RoleRef(ObjectRef):
513 """
514 Custom schema type for a
515 :class:`~wuttjamaican:wuttjamaican.db.model.auth.Role` reference
516 field.
518 This is a subclass of :class:`ObjectRef`.
519 """
521 @property
522 def model_class(self): # pylint: disable=empty-docstring
523 """ """
524 model = self.app.model
525 return model.Role
527 def sort_query(self, query): # pylint: disable=empty-docstring
528 """ """
529 return query.order_by(self.model_class.name)
531 def get_object_url(self, obj): # pylint: disable=empty-docstring
532 """ """
533 role = obj
534 return self.request.route_url("roles.view", uuid=role.uuid)
537class UserRef(ObjectRef):
538 """
539 Custom schema type for a
540 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` reference
541 field.
543 This is a subclass of :class:`ObjectRef`.
544 """
546 @property
547 def model_class(self): # pylint: disable=empty-docstring
548 """ """
549 model = self.app.model
550 return model.User
552 def sort_query(self, query): # pylint: disable=empty-docstring
553 """ """
554 return query.order_by(self.model_class.username)
556 def get_object_url(self, obj): # pylint: disable=empty-docstring
557 """ """
558 user = obj
559 return self.request.route_url("users.view", uuid=user.uuid)
562class RoleRefs(WuttaSet):
563 """
564 Form schema type for the User
565 :attr:`~wuttjamaican:wuttjamaican.db.model.auth.User.roles`
566 association proxy field.
568 This is a subclass of :class:`WuttaSet`. It uses a ``set`` of
569 :class:`~wuttjamaican:wuttjamaican.db.model.auth.Role` ``uuid``
570 values for underlying data format.
571 """
573 def widget_maker(self, **kwargs):
574 """
575 Constructs a default widget for the field.
577 :returns: Instance of
578 :class:`~wuttaweb.forms.widgets.RoleRefsWidget`.
579 """
580 session = kwargs.setdefault("session", Session())
582 if "values" not in kwargs:
583 model = self.app.model
584 auth = self.app.get_auth_handler()
586 # avoid built-ins which cannot be assigned to users
587 avoid = {
588 auth.get_role_authenticated(session),
589 auth.get_role_anonymous(session),
590 }
591 avoid = {role.uuid for role in avoid}
593 # also avoid admin unless current user is root
594 if not self.request.is_root:
595 avoid.add(auth.get_role_administrator(session).uuid)
597 # everything else can be (un)assigned for users
598 roles = (
599 session.query(model.Role)
600 .filter(~model.Role.uuid.in_(avoid))
601 .order_by(model.Role.name)
602 .all()
603 )
604 values = [(role.uuid.hex, role.name) for role in roles]
605 kwargs["values"] = values
607 return widgets.RoleRefsWidget(self.request, **kwargs)
610class Permissions(WuttaSet):
611 """
612 Form schema type for the Role
613 :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Role.permissions`
614 association proxy field.
616 This is a subclass of :class:`WuttaSet`. It uses a ``set`` of
617 :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Permission.permission`
618 values for underlying data format.
620 :param permissions: Dict with all possible permissions. Should be
621 in the same format as returned by
622 :meth:`~wuttaweb.views.roles.RoleView.get_available_permissions()`.
623 """
625 def __init__(self, request, permissions, *args, **kwargs):
626 super().__init__(request, *args, **kwargs)
627 self.permissions = permissions
629 def widget_maker(self, **kwargs):
630 """
631 Constructs a default widget for the field.
633 :returns: Instance of
634 :class:`~wuttaweb.forms.widgets.PermissionsWidget`.
635 """
636 kwargs.setdefault("session", Session())
637 kwargs.setdefault("permissions", self.permissions)
639 if "values" not in kwargs:
640 values = []
641 for group in self.permissions.values():
642 for pkey, perm in group["perms"].items():
643 values.append((pkey, perm["label"]))
644 kwargs["values"] = values
646 return widgets.PermissionsWidget(self.request, **kwargs)
649class FileDownload(colander.String):
650 """
651 Custom schema type for a file download field.
653 This field is only meant for readonly use, it does not handle file
654 uploads.
656 It expects the incoming ``appstruct`` to be the path to a file on
657 disk (or null).
659 Uses the :class:`~wuttaweb.forms.widgets.FileDownloadWidget` by
660 default.
662 :param request: Current :term:`request` object.
664 :param url: Optional URL for hyperlink. If not specified, file
665 name/size is shown with no hyperlink.
666 """
668 # pylint: disable=duplicate-code
669 def __init__(self, request, *args, **kwargs):
670 self.url = kwargs.pop("url", None)
671 super().__init__(*args, **kwargs)
672 self.request = request
673 self.config = self.request.wutta_config
674 self.app = self.config.get_app()
676 # pylint: enable=duplicate-code
678 def widget_maker(self, **kwargs): # pylint: disable=empty-docstring
679 """ """
680 kwargs.setdefault("url", self.url)
681 return widgets.FileDownloadWidget(self.request, **kwargs)
684class EmailRecipients(colander.String):
685 """
686 Custom schema type for :term:`email setting` recipient fields
687 (``To``, ``Cc``, ``Bcc``).
688 """
690 def serialize(self, node, appstruct): # pylint: disable=empty-docstring
691 """ """
692 if appstruct is colander.null:
693 return colander.null
695 return "\n".join(parse_list(appstruct))
697 def deserialize(self, node, cstruct): # pylint: disable=empty-docstring
698 """ """
699 if cstruct is colander.null:
700 return colander.null
702 values = [value for value in parse_list(cstruct) if value]
703 return ", ".join(values)
705 def widget_maker(self, **kwargs):
706 """
707 Constructs a default widget for the field.
709 :returns: Instance of
710 :class:`~wuttaweb.forms.widgets.EmailRecipientsWidget`.
711 """
712 return widgets.EmailRecipientsWidget(**kwargs)
715# nb. colanderalchemy schema overrides
716sa.DateTime.__colanderalchemy_config__ = {"typ": WuttaDateTime}