Coverage for .tox/coverage/lib/python3.11/site-packages/wuttaweb/forms/schema.py: 100%
149 statements
« prev ^ index » next coverage.py v7.6.1, created at 2024-08-27 21:18 -0500
« prev ^ index » next coverage.py v7.6.1, created at 2024-08-27 21:18 -0500
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 colander
29from wuttaweb.db import Session
30from wuttaweb.forms import widgets
31from wuttjamaican.db.model import Person
34class ObjectNode(colander.SchemaNode):
35 """
36 Custom schema node class which adds methods for compatibility with
37 ColanderAlchemy. This is a direct subclass of
38 :class:`colander:colander.SchemaNode`.
40 ColanderAlchemy will call certain methods on any node found in the
41 schema. However these methods are not "standard" and only exist
42 for ColanderAlchemy nodes.
44 So we must add nodes using this class, to ensure the node has all
45 methods needed by ColanderAlchemy.
46 """
48 def dictify(self, obj):
49 """
50 This method is called by ColanderAlchemy when translating the
51 in-app Python object to a value suitable for use in the form
52 data dict.
54 The logic here will look for a ``dictify()`` method on the
55 node's "type" instance (``self.typ``; see also
56 :class:`colander:colander.SchemaNode`) and invoke it if found.
58 For an example type which is supported in this way, see
59 :class:`ObjectRef`.
61 If the node's type does not have a ``dictify()`` method, this
62 will just convert the object to a string and return that.
63 """
64 if hasattr(self.typ, 'dictify'):
65 return self.typ.dictify(obj)
67 # TODO: this is better than raising an error, as it previously
68 # did, but seems like troubleshooting problems may often lead
69 # one here.. i suspect this needs to do something smarter but
70 # not sure what that is yet
71 return str(obj)
73 def objectify(self, value):
74 """
75 This method is called by ColanderAlchemy when translating form
76 data to the final Python representation.
78 The logic here will look for an ``objectify()`` method on the
79 node's "type" instance (``self.typ``; see also
80 :class:`colander:colander.SchemaNode`) and invoke it if found.
82 For an example type which is supported in this way, see
83 :class:`ObjectRef`.
85 If the node's type does not have an ``objectify()`` method,
86 this will raise ``NotImplementeError``.
87 """
88 if hasattr(self.typ, 'objectify'):
89 return self.typ.objectify(value)
91 class_name = self.typ.__class__.__name__
92 raise NotImplementedError(f"you must define {class_name}.objectify()")
95class WuttaEnum(colander.Enum):
96 """
97 Custom schema type for enum fields.
99 This is a subclass of :class:`colander.Enum`, but adds a
100 default widget (``SelectWidget``) with enum choices.
102 :param request: Current :term:`request` object.
103 """
105 def __init__(self, request, *args, **kwargs):
106 super().__init__(*args, **kwargs)
107 self.request = request
108 self.config = self.request.wutta_config
109 self.app = self.config.get_app()
111 def widget_maker(self, **kwargs):
112 """ """
114 if 'values' not in kwargs:
115 kwargs['values'] = [(getattr(e, self.attr), getattr(e, self.attr))
116 for e in self.enum_cls]
118 return widgets.SelectWidget(**kwargs)
121class WuttaSet(colander.Set):
122 """
123 Custom schema type for :class:`python:set` fields.
125 This is a subclass of :class:`colander.Set`, but adds
126 Wutta-related params to the constructor.
128 :param request: Current :term:`request` object.
130 :param session: Optional :term:`db session` to use instead of
131 :class:`wuttaweb.db.sess.Session`.
132 """
134 def __init__(self, request, session=None):
135 super().__init__()
136 self.request = request
137 self.config = self.request.wutta_config
138 self.app = self.config.get_app()
139 self.session = session or Session()
142class ObjectRef(colander.SchemaType):
143 """
144 Custom schema type for a model class reference field.
146 This expects the incoming ``appstruct`` to be either a model
147 record instance, or ``None``.
149 Serializes to the instance UUID as string, or ``colander.null``;
150 form data should be of the same nature.
152 This schema type is not useful directly, but various other types
153 will subclass it. Each should define (at least) the
154 :attr:`model_class` attribute or property.
156 :param request: Current :term:`request` object.
158 :param empty_option: If a select widget is used, this determines
159 whether an empty option is included for the dropdown. Set
160 this to one of the following to add an empty option:
162 * ``True`` to add the default empty option
163 * label text for the empty option
164 * tuple of ``(value, label)`` for the empty option
166 Note that in the latter, ``value`` must be a string.
167 """
169 default_empty_option = ('', "(none)")
171 def __init__(
172 self,
173 request,
174 empty_option=None,
175 session=None,
176 *args,
177 **kwargs,
178 ):
179 super().__init__(*args, **kwargs)
180 self.request = request
181 self.config = self.request.wutta_config
182 self.app = self.config.get_app()
183 self.model_instance = None
184 self.session = session or Session()
186 if empty_option:
187 if empty_option is True:
188 self.empty_option = self.default_empty_option
189 elif isinstance(empty_option, tuple) and len(empty_option) == 2:
190 self.empty_option = empty_option
191 else:
192 self.empty_option = ('', str(empty_option))
193 else:
194 self.empty_option = None
196 @property
197 def model_class(self):
198 """
199 Should be a reference to the model class to which this schema
200 type applies
201 (e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`).
202 """
203 class_name = self.__class__.__name__
204 raise NotImplementedError(f"you must define {class_name}.model_class")
206 def serialize(self, node, appstruct):
207 """ """
208 if appstruct is colander.null:
209 return colander.null
211 # nb. keep a ref to this for later use
212 node.model_instance = appstruct
214 # serialize to uuid
215 return appstruct.uuid
217 def deserialize(self, node, cstruct):
218 """ """
219 if not cstruct:
220 return colander.null
222 # nb. use shortcut to fetch model instance from DB
223 return self.objectify(cstruct)
225 def dictify(self, obj):
226 """ """
228 # TODO: would we ever need to do something else?
229 return obj
231 def objectify(self, value):
232 """
233 For the given UUID value, returns the object it represents
234 (based on :attr:`model_class`).
236 If the value is empty, returns ``None``.
238 If the value is not empty but object cannot be found, raises
239 ``colander.Invalid``.
240 """
241 if not value:
242 return
244 if isinstance(value, self.model_class):
245 return value
247 # fetch object from DB
248 model = self.app.model
249 obj = self.session.get(self.model_class, value)
251 # raise error if not found
252 if not obj:
253 class_name = self.model_class.__name__
254 raise ValueError(f"{class_name} not found: {value}")
256 return obj
258 def get_query(self):
259 """
260 Returns the main SQLAlchemy query responsible for locating the
261 dropdown choices for the select widget.
263 This is called by :meth:`widget_maker()`.
264 """
265 query = self.session.query(self.model_class)
266 query = self.sort_query(query)
267 return query
269 def sort_query(self, query):
270 """
271 TODO
272 """
273 return query
275 def widget_maker(self, **kwargs):
276 """
277 This method is responsible for producing the default widget
278 for the schema node.
280 Deform calls this method automatically when constructing the
281 default widget for a field.
283 :returns: Instance of
284 :class:`~wuttaweb.forms.widgets.ObjectRefWidget`.
285 """
287 if 'values' not in kwargs:
288 query = self.get_query()
289 objects = query.all()
290 values = [(obj.uuid, str(obj))
291 for obj in objects]
292 if self.empty_option:
293 values.insert(0, self.empty_option)
294 kwargs['values'] = values
296 if 'url' not in kwargs:
297 kwargs['url'] = self.get_object_url
299 return widgets.ObjectRefWidget(self.request, **kwargs)
301 def get_object_url(self, obj):
302 """
303 Returns the "view" URL for the given object, if applicable.
305 This is used when rendering the field readonly. If this
306 method returns a URL then the field text will be wrapped with
307 a hyperlink, otherwise it will be shown as-is.
309 Default logic always returns ``None``; subclass should
310 override as needed.
311 """
314class PersonRef(ObjectRef):
315 """
316 Custom schema type for a
317 :class:`~wuttjamaican:wuttjamaican.db.model.base.Person` reference
318 field.
320 This is a subclass of :class:`ObjectRef`.
321 """
323 @property
324 def model_class(self):
325 """ """
326 model = self.app.model
327 return model.Person
329 def sort_query(self, query):
330 """ """
331 return query.order_by(self.model_class.full_name)
333 def get_object_url(self, person):
334 """ """
335 return self.request.route_url('people.view', uuid=person.uuid)
338class UserRef(ObjectRef):
339 """
340 Custom schema type for a
341 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` reference
342 field.
344 This is a subclass of :class:`ObjectRef`.
345 """
347 @property
348 def model_class(self):
349 """ """
350 model = self.app.model
351 return model.User
353 def sort_query(self, query):
354 """ """
355 return query.order_by(self.model_class.username)
357 def get_object_url(self, user):
358 """ """
359 return self.request.route_url('users.view', uuid=user.uuid)
362class RoleRefs(WuttaSet):
363 """
364 Form schema type for the User
365 :attr:`~wuttjamaican:wuttjamaican.db.model.auth.User.roles`
366 association proxy field.
368 This is a subclass of :class:`WuttaSet`. It uses a ``set`` of
369 :class:`~wuttjamaican:wuttjamaican.db.model.auth.Role` ``uuid``
370 values for underlying data format.
371 """
373 def widget_maker(self, **kwargs):
374 """
375 Constructs a default widget for the field.
377 :returns: Instance of
378 :class:`~wuttaweb.forms.widgets.RoleRefsWidget`.
379 """
380 kwargs.setdefault('session', self.session)
382 if 'values' not in kwargs:
383 model = self.app.model
384 auth = self.app.get_auth_handler()
385 avoid = {
386 auth.get_role_authenticated(self.session),
387 auth.get_role_anonymous(self.session),
388 }
389 avoid = set([role.uuid for role in avoid])
390 roles = self.session.query(model.Role)\
391 .filter(~model.Role.uuid.in_(avoid))\
392 .order_by(model.Role.name)\
393 .all()
394 values = [(role.uuid, role.name) for role in roles]
395 kwargs['values'] = values
397 return widgets.RoleRefsWidget(self.request, **kwargs)
400class UserRefs(WuttaSet):
401 """
402 Form schema type for the Role
403 :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Role.users`
404 association proxy field.
406 This is a subclass of :class:`WuttaSet`. It uses a ``set`` of
407 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` ``uuid``
408 values for underlying data format.
409 """
411 def widget_maker(self, **kwargs):
412 """
413 Constructs a default widget for the field.
415 :returns: Instance of
416 :class:`~wuttaweb.forms.widgets.UserRefsWidget`.
417 """
418 kwargs.setdefault('session', self.session)
419 return widgets.UserRefsWidget(self.request, **kwargs)
422class Permissions(WuttaSet):
423 """
424 Form schema type for the Role
425 :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Role.permissions`
426 association proxy field.
428 This is a subclass of :class:`WuttaSet`. It uses a ``set`` of
429 :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Permission.permission`
430 values for underlying data format.
432 :param permissions: Dict with all possible permissions. Should be
433 in the same format as returned by
434 :meth:`~wuttaweb.views.roles.RoleView.get_available_permissions()`.
435 """
437 def __init__(self, request, permissions, *args, **kwargs):
438 super().__init__(request, *args, **kwargs)
439 self.permissions = permissions
441 def widget_maker(self, **kwargs):
442 """
443 Constructs a default widget for the field.
445 :returns: Instance of
446 :class:`~wuttaweb.forms.widgets.PermissionsWidget`.
447 """
448 kwargs.setdefault('session', self.session)
449 kwargs.setdefault('permissions', self.permissions)
451 if 'values' not in kwargs:
452 values = []
453 for gkey, group in self.permissions.items():
454 for pkey, perm in group['perms'].items():
455 values.append((pkey, perm['label']))
456 kwargs['values'] = values
458 return widgets.PermissionsWidget(self.request, **kwargs)
461class FileDownload(colander.String):
462 """
463 Custom schema type for a file download field.
465 This field is only meant for readonly use, it does not handle file
466 uploads.
468 It expects the incoming ``appstruct`` to be the path to a file on
469 disk (or null).
471 Uses the :class:`~wuttaweb.forms.widgets.FileDownloadWidget` by
472 default.
474 :param request: Current :term:`request` object.
476 :param url: Optional URL for hyperlink. If not specified, file
477 name/size is shown with no hyperlink.
478 """
480 def __init__(self, request, *args, **kwargs):
481 self.url = kwargs.pop('url', None)
482 super().__init__(*args, **kwargs)
483 self.request = request
484 self.config = self.request.wutta_config
485 self.app = self.config.get_app()
487 def widget_maker(self, **kwargs):
488 """ """
489 kwargs.setdefault('url', self.url)
490 return widgets.FileDownloadWidget(self.request, **kwargs)