Coverage for .tox/coverage/lib/python3.11/site-packages/wuttaweb/forms/schema.py: 100%
229 statements
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-15 08:54 -0600
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-15 08:54 -0600
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# wuttaweb -- Web App for Wutta Framework
5# Copyright © 2024 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.db.model import Person
34from wuttjamaican.conf import parse_list
36from wuttaweb.db import Session
37from wuttaweb.forms import widgets
40class WuttaDateTime(colander.DateTime):
41 """
42 Custom schema type for ``datetime`` fields.
44 This should be used automatically for
45 :class:`sqlalchemy:sqlalchemy.types.DateTime` columns unless you
46 register another default.
48 This schema type exists for sake of convenience, when working with
49 the Buefy datepicker + timepicker widgets.
50 """
52 def deserialize(self, node, cstruct):
53 """ """
54 if not cstruct:
55 return colander.null
57 formats = [
58 '%Y-%m-%dT%H:%M:%S',
59 '%Y-%m-%dT%I:%M %p',
60 ]
62 for fmt in formats:
63 try:
64 return datetime.datetime.strptime(cstruct, fmt)
65 except:
66 pass
68 node.raise_invalid("Invalid date and/or time")
71class ObjectNode(colander.SchemaNode):
72 """
73 Custom schema node class which adds methods for compatibility with
74 ColanderAlchemy. This is a direct subclass of
75 :class:`colander:colander.SchemaNode`.
77 ColanderAlchemy will call certain methods on any node found in the
78 schema. However these methods are not "standard" and only exist
79 for ColanderAlchemy nodes.
81 So we must add nodes using this class, to ensure the node has all
82 methods needed by ColanderAlchemy.
83 """
85 def dictify(self, obj):
86 """
87 This method is called by ColanderAlchemy when translating the
88 in-app Python object to a value suitable for use in the form
89 data dict.
91 The logic here will look for a ``dictify()`` method on the
92 node's "type" instance (``self.typ``; see also
93 :class:`colander:colander.SchemaNode`) and invoke it if found.
95 For an example type which is supported in this way, see
96 :class:`ObjectRef`.
98 If the node's type does not have a ``dictify()`` method, this
99 will just convert the object to a string and return that.
100 """
101 if hasattr(self.typ, 'dictify'):
102 return self.typ.dictify(obj)
104 # TODO: this is better than raising an error, as it previously
105 # did, but seems like troubleshooting problems may often lead
106 # one here.. i suspect this needs to do something smarter but
107 # not sure what that is yet
108 return str(obj)
110 def objectify(self, value):
111 """
112 This method is called by ColanderAlchemy when translating form
113 data to the final Python representation.
115 The logic here will look for an ``objectify()`` method on the
116 node's "type" instance (``self.typ``; see also
117 :class:`colander:colander.SchemaNode`) and invoke it if found.
119 For an example type which is supported in this way, see
120 :class:`ObjectRef`.
122 If the node's type does not have an ``objectify()`` method,
123 this will raise ``NotImplementeError``.
124 """
125 if hasattr(self.typ, 'objectify'):
126 return self.typ.objectify(value)
128 class_name = self.typ.__class__.__name__
129 raise NotImplementedError(f"you must define {class_name}.objectify()")
132class WuttaEnum(colander.Enum):
133 """
134 Custom schema type for enum fields.
136 This is a subclass of :class:`colander.Enum`, but adds a
137 default widget (``SelectWidget``) with enum choices.
139 :param request: Current :term:`request` object.
140 """
142 def __init__(self, request, *args, **kwargs):
143 super().__init__(*args, **kwargs)
144 self.request = request
145 self.config = self.request.wutta_config
146 self.app = self.config.get_app()
148 def widget_maker(self, **kwargs):
149 """ """
151 if 'values' not in kwargs:
152 kwargs['values'] = [(getattr(e, self.attr), getattr(e, self.attr))
153 for e in self.enum_cls]
155 return widgets.SelectWidget(**kwargs)
158class WuttaDictEnum(colander.String):
159 """
160 Schema type for "pseudo-enum" fields which reference a dict for
161 known values instead of a true enum class.
163 This is primarily for use with "status" fields such as
164 :attr:`~wuttjamaican:wuttjamaican.db.model.batch.BatchRowMixin.status_code`.
166 This is a subclass of :class:`colander.String`, but adds a default
167 widget (``SelectWidget``) with enum choices.
169 :param request: Current :term:`request` object.
171 :param enum_dct: Dict with possible enum values and labels.
172 """
174 def __init__(self, request, enum_dct, *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()
179 self.enum_dct = enum_dct
181 def widget_maker(self, **kwargs):
182 """ """
183 if 'values' not in kwargs:
184 kwargs['values'] = [(k, v) for k, v in self.enum_dct.items()]
186 return widgets.SelectWidget(**kwargs)
189class WuttaMoney(colander.Money):
190 """
191 Custom schema type for "money" fields.
193 This is a subclass of :class:`colander:colander.Money`, but uses
194 the custom :class:`~wuttaweb.forms.widgets.WuttaMoneyInputWidget`
195 by default.
197 :param request: Current :term:`request` object.
199 :param scale: If this kwarg is specified, it will be passed along
200 to the widget constructor.
201 """
203 def __init__(self, request, *args, **kwargs):
204 self.scale = kwargs.pop('scale', None)
205 super().__init__(*args, **kwargs)
206 self.request = request
207 self.config = self.request.wutta_config
208 self.app = self.config.get_app()
210 def widget_maker(self, **kwargs):
211 """ """
212 if self.scale:
213 kwargs.setdefault('scale', self.scale)
214 return widgets.WuttaMoneyInputWidget(self.request, **kwargs)
217class WuttaQuantity(colander.Decimal):
218 """
219 Custom schema type for "quantity" fields.
221 This is a subclass of :class:`colander:colander.Decimal` but will
222 serialize values via
223 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_quantity()`.
225 :param request: Current :term:`request` object.
226 """
228 def __init__(self, request, *args, **kwargs):
229 super().__init__(*args, **kwargs)
230 self.request = request
231 self.config = self.request.wutta_config
232 self.app = self.config.get_app()
234 def serialize(self, node, appstruct):
235 """ """
236 if appstruct in (colander.null, None):
237 return colander.null
239 # nb. we render as quantity here to avoid values like 12.0000,
240 # so we just show value like 12 instead
241 return self.app.render_quantity(appstruct)
244class WuttaSet(colander.Set):
245 """
246 Custom schema type for :class:`python:set` fields.
248 This is a subclass of :class:`colander.Set`.
250 :param request: Current :term:`request` object.
251 """
253 def __init__(self, request):
254 super().__init__()
255 self.request = request
256 self.config = self.request.wutta_config
257 self.app = self.config.get_app()
260class ObjectRef(colander.SchemaType):
261 """
262 Custom schema type for a model class reference field.
264 This expects the incoming ``appstruct`` to be either a model
265 record instance, or ``None``.
267 Serializes to the instance UUID as string, or ``colander.null``;
268 form data should be of the same nature.
270 This schema type is not useful directly, but various other types
271 will subclass it. Each should define (at least) the
272 :attr:`model_class` attribute or property.
274 :param request: Current :term:`request` object.
276 :param empty_option: If a select widget is used, this determines
277 whether an empty option is included for the dropdown. Set
278 this to one of the following to add an empty option:
280 * ``True`` to add the default empty option
281 * label text for the empty option
282 * tuple of ``(value, label)`` for the empty option
284 Note that in the latter, ``value`` must be a string.
285 """
287 default_empty_option = ('', "(none)")
289 def __init__(
290 self,
291 request,
292 empty_option=None,
293 *args,
294 **kwargs,
295 ):
296 # nb. allow session injection for tests
297 self.session = kwargs.pop('session', Session())
298 super().__init__(*args, **kwargs)
299 self.request = request
300 self.config = self.request.wutta_config
301 self.app = self.config.get_app()
302 self.model_instance = None
304 if empty_option:
305 if empty_option is True:
306 self.empty_option = self.default_empty_option
307 elif isinstance(empty_option, tuple) and len(empty_option) == 2:
308 self.empty_option = empty_option
309 else:
310 self.empty_option = ('', str(empty_option))
311 else:
312 self.empty_option = None
314 @property
315 def model_class(self):
316 """
317 Should be a reference to the model class to which this schema
318 type applies
319 (e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`).
320 """
321 class_name = self.__class__.__name__
322 raise NotImplementedError(f"you must define {class_name}.model_class")
324 def serialize(self, node, appstruct):
325 """ """
326 # nb. normalize to empty option if no object ref, so that
327 # works as expected
328 if self.empty_option and not appstruct:
329 return self.empty_option[0]
331 if appstruct is colander.null:
332 return colander.null
334 # nb. keep a ref to this for later use
335 node.model_instance = appstruct
337 # serialize to PK as string
338 return self.serialize_object(appstruct)
340 def serialize_object(self, obj):
341 """
342 Serialize the given object to its primary key as string.
344 Default logic assumes the object has a UUID; subclass can
345 override as needed.
347 :param obj: Object reference for the node.
349 :returns: Object primary key as string.
350 """
351 return obj.uuid.hex
353 def deserialize(self, node, cstruct):
354 """ """
355 if not cstruct:
356 return colander.null
358 # nb. use shortcut to fetch model instance from DB
359 return self.objectify(cstruct)
361 def dictify(self, obj):
362 """ """
364 # TODO: would we ever need to do something else?
365 return obj
367 def objectify(self, value):
368 """
369 For the given UUID value, returns the object it represents
370 (based on :attr:`model_class`).
372 If the value is empty, returns ``None``.
374 If the value is not empty but object cannot be found, raises
375 ``colander.Invalid``.
376 """
377 if not value:
378 return
380 if isinstance(value, self.model_class):
381 return value
383 # fetch object from DB
384 model = self.app.model
385 obj = None
386 if isinstance(value, _uuid.UUID):
387 obj = self.session.get(self.model_class, value)
388 else:
389 try:
390 obj = self.session.get(self.model_class, _uuid.UUID(value))
391 except ValueError:
392 pass
394 # raise error if not found
395 if not obj:
396 class_name = self.model_class.__name__
397 raise ValueError(f"{class_name} not found: {value}")
399 return obj
401 def get_query(self):
402 """
403 Returns the main SQLAlchemy query responsible for locating the
404 dropdown choices for the select widget.
406 This is called by :meth:`widget_maker()`.
407 """
408 query = self.session.query(self.model_class)
409 query = self.sort_query(query)
410 return query
412 def sort_query(self, query):
413 """
414 TODO
415 """
416 return query
418 def widget_maker(self, **kwargs):
419 """
420 This method is responsible for producing the default widget
421 for the schema node.
423 Deform calls this method automatically when constructing the
424 default widget for a field.
426 :returns: Instance of
427 :class:`~wuttaweb.forms.widgets.ObjectRefWidget`.
428 """
430 if 'values' not in kwargs:
431 query = self.get_query()
432 objects = query.all()
433 values = [(self.serialize_object(obj), str(obj))
434 for obj in objects]
435 if self.empty_option:
436 values.insert(0, self.empty_option)
437 kwargs['values'] = values
439 if 'url' not in kwargs:
440 kwargs['url'] = self.get_object_url
442 return widgets.ObjectRefWidget(self.request, **kwargs)
444 def get_object_url(self, obj):
445 """
446 Returns the "view" URL for the given object, if applicable.
448 This is used when rendering the field readonly. If this
449 method returns a URL then the field text will be wrapped with
450 a hyperlink, otherwise it will be shown as-is.
452 Default logic always returns ``None``; subclass should
453 override as needed.
454 """
457class PersonRef(ObjectRef):
458 """
459 Custom schema type for a
460 :class:`~wuttjamaican:wuttjamaican.db.model.base.Person` reference
461 field.
463 This is a subclass of :class:`ObjectRef`.
464 """
466 @property
467 def model_class(self):
468 """ """
469 model = self.app.model
470 return model.Person
472 def sort_query(self, query):
473 """ """
474 return query.order_by(self.model_class.full_name)
476 def get_object_url(self, person):
477 """ """
478 return self.request.route_url('people.view', uuid=person.uuid)
481class RoleRef(ObjectRef):
482 """
483 Custom schema type for a
484 :class:`~wuttjamaican:wuttjamaican.db.model.auth.Role` reference
485 field.
487 This is a subclass of :class:`ObjectRef`.
488 """
490 @property
491 def model_class(self):
492 """ """
493 model = self.app.model
494 return model.Role
496 def sort_query(self, query):
497 """ """
498 return query.order_by(self.model_class.name)
500 def get_object_url(self, role):
501 """ """
502 return self.request.route_url('roles.view', uuid=role.uuid)
505class UserRef(ObjectRef):
506 """
507 Custom schema type for a
508 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` reference
509 field.
511 This is a subclass of :class:`ObjectRef`.
512 """
514 @property
515 def model_class(self):
516 """ """
517 model = self.app.model
518 return model.User
520 def sort_query(self, query):
521 """ """
522 return query.order_by(self.model_class.username)
524 def get_object_url(self, user):
525 """ """
526 return self.request.route_url('users.view', uuid=user.uuid)
529class RoleRefs(WuttaSet):
530 """
531 Form schema type for the User
532 :attr:`~wuttjamaican:wuttjamaican.db.model.auth.User.roles`
533 association proxy field.
535 This is a subclass of :class:`WuttaSet`. It uses a ``set`` of
536 :class:`~wuttjamaican:wuttjamaican.db.model.auth.Role` ``uuid``
537 values for underlying data format.
538 """
540 def widget_maker(self, **kwargs):
541 """
542 Constructs a default widget for the field.
544 :returns: Instance of
545 :class:`~wuttaweb.forms.widgets.RoleRefsWidget`.
546 """
547 session = kwargs.setdefault('session', Session())
549 if 'values' not in kwargs:
550 model = self.app.model
551 auth = self.app.get_auth_handler()
553 # avoid built-ins which cannot be assigned to users
554 avoid = {
555 auth.get_role_authenticated(session),
556 auth.get_role_anonymous(session),
557 }
558 avoid = set([role.uuid for role in avoid])
560 # also avoid admin unless current user is root
561 if not self.request.is_root:
562 avoid.add(auth.get_role_administrator(session).uuid)
564 # everything else can be (un)assigned for users
565 roles = session.query(model.Role)\
566 .filter(~model.Role.uuid.in_(avoid))\
567 .order_by(model.Role.name)\
568 .all()
569 values = [(role.uuid.hex, role.name) for role in roles]
570 kwargs['values'] = values
572 return widgets.RoleRefsWidget(self.request, **kwargs)
575class UserRefs(WuttaSet):
576 """
577 Form schema type for the Role
578 :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Role.users`
579 association proxy field.
581 This is a subclass of :class:`WuttaSet`. It uses a ``set`` of
582 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` ``uuid``
583 values for underlying data format.
584 """
586 def widget_maker(self, **kwargs):
587 """
588 Constructs a default widget for the field.
590 :returns: Instance of
591 :class:`~wuttaweb.forms.widgets.UserRefsWidget`.
592 """
593 kwargs.setdefault('session', Session())
594 return widgets.UserRefsWidget(self.request, **kwargs)
597class Permissions(WuttaSet):
598 """
599 Form schema type for the Role
600 :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Role.permissions`
601 association proxy field.
603 This is a subclass of :class:`WuttaSet`. It uses a ``set`` of
604 :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Permission.permission`
605 values for underlying data format.
607 :param permissions: Dict with all possible permissions. Should be
608 in the same format as returned by
609 :meth:`~wuttaweb.views.roles.RoleView.get_available_permissions()`.
610 """
612 def __init__(self, request, permissions, *args, **kwargs):
613 super().__init__(request, *args, **kwargs)
614 self.permissions = permissions
616 def widget_maker(self, **kwargs):
617 """
618 Constructs a default widget for the field.
620 :returns: Instance of
621 :class:`~wuttaweb.forms.widgets.PermissionsWidget`.
622 """
623 kwargs.setdefault('session', Session())
624 kwargs.setdefault('permissions', self.permissions)
626 if 'values' not in kwargs:
627 values = []
628 for gkey, group in self.permissions.items():
629 for pkey, perm in group['perms'].items():
630 values.append((pkey, perm['label']))
631 kwargs['values'] = values
633 return widgets.PermissionsWidget(self.request, **kwargs)
636class FileDownload(colander.String):
637 """
638 Custom schema type for a file download field.
640 This field is only meant for readonly use, it does not handle file
641 uploads.
643 It expects the incoming ``appstruct`` to be the path to a file on
644 disk (or null).
646 Uses the :class:`~wuttaweb.forms.widgets.FileDownloadWidget` by
647 default.
649 :param request: Current :term:`request` object.
651 :param url: Optional URL for hyperlink. If not specified, file
652 name/size is shown with no hyperlink.
653 """
655 def __init__(self, request, *args, **kwargs):
656 self.url = kwargs.pop('url', None)
657 super().__init__(*args, **kwargs)
658 self.request = request
659 self.config = self.request.wutta_config
660 self.app = self.config.get_app()
662 def widget_maker(self, **kwargs):
663 """ """
664 kwargs.setdefault('url', self.url)
665 return widgets.FileDownloadWidget(self.request, **kwargs)
668class EmailRecipients(colander.String):
669 """
670 Custom schema type for :term:`email setting` recipient fields
671 (``To``, ``Cc``, ``Bcc``).
672 """
674 def serialize(self, node, appstruct):
675 if appstruct is colander.null:
676 return colander.null
678 return '\n'.join(parse_list(appstruct))
680 def deserialize(self, node, cstruct):
681 """ """
682 if cstruct is colander.null:
683 return colander.null
685 values = [value for value in parse_list(cstruct)
686 if value]
687 return ', '.join(values)
689 def widget_maker(self, **kwargs):
690 """
691 Constructs a default widget for the field.
693 :returns: Instance of
694 :class:`~wuttaweb.forms.widgets.EmailRecipientsWidget`.
695 """
696 return widgets.EmailRecipientsWidget(**kwargs)
699# nb. colanderalchemy schema overrides
700sa.DateTime.__colanderalchemy_config__ = {'typ': WuttaDateTime}