Coverage for .tox/coverage/lib/python3.13/site-packages/wuttaweb/util.py: 100%
354 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-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"""
24Web Utilities
25"""
27import decimal
28import importlib
29import json
30import logging
31import uuid as _uuid
32import warnings
34import sqlalchemy as sa
35from sqlalchemy import orm
37import colander
38from pyramid.renderers import get_renderer
39from webhelpers2.html import HTML, tags
41from wuttjamaican.util import resource_path
43log = logging.getLogger(__name__)
46class FieldList(list):
47 """
48 Convenience wrapper for a form's field list. This is a subclass
49 of :class:`python:list`.
51 You normally would not need to instantiate this yourself, but it
52 is used under the hood for
53 :attr:`~wuttaweb.forms.base.Form.fields` as well as
54 :attr:`~wuttaweb.grids.base.Grid.columns`.
55 """
57 def insert_before(self, field, newfield):
58 """
59 Insert a new field, before an existing field.
61 :param field: String name for the existing field.
63 :param newfield: String name for the new field, to be inserted
64 just before the existing ``field``.
65 """
66 if field in self:
67 i = self.index(field)
68 self.insert(i, newfield)
69 else:
70 log.warning(
71 "field '%s' not found, will append new field: %s", field, newfield
72 )
73 self.append(newfield)
75 def insert_after(self, field, newfield):
76 """
77 Insert a new field, after an existing field.
79 :param field: String name for the existing field.
81 :param newfield: String name for the new field, to be inserted
82 just after the existing ``field``.
83 """
84 if field in self:
85 i = self.index(field)
86 self.insert(i + 1, newfield)
87 else:
88 log.warning(
89 "field '%s' not found, will append new field: %s", field, newfield
90 )
91 self.append(newfield)
93 def set_sequence(self, fields):
94 """
95 Sort the list such that it matches the same sequence as the
96 given fields list.
98 This does not add or remove any elements, it just
99 (potentially) rearranges the internal list elements.
100 Therefore you do not need to explicitly declare *all* fields;
101 just the ones you care about.
103 The resulting field list will have the requested fields in
104 order, at the *beginning* of the list. Any unrequested fields
105 will remain in the same order as they were previously, but
106 will be placed *after* the requested fields.
108 :param fields: List of fields in the desired order.
109 """
110 unimportant = len(self) + 1
112 def getkey(field):
113 if field in fields:
114 return fields.index(field)
115 return unimportant
117 self.sort(key=getkey)
120def get_form_data(request):
121 """
122 Returns the effective form data for the given request.
124 Mostly this is a convenience, which simply returns one of the
125 following, depending on various attributes of the request.
127 * :attr:`pyramid:pyramid.request.Request.POST`
128 * :attr:`pyramid:pyramid.request.Request.json_body`
129 """
130 # nb. we prefer JSON only if no POST is present
131 # TODO: this seems to work for our use case at least, but perhaps
132 # there is a better way? see also
133 # https://docs.pylonsproject.org/projects/pyramid/en/latest/api/request.html#pyramid.request.Request.is_xhr
134 if not request.POST and (
135 getattr(request, "is_xhr", False)
136 or getattr(request, "content_type", None) == "application/json"
137 ):
138 return request.json_body
139 return request.POST
142def get_libver(
143 request,
144 key,
145 configured_only=False,
146 default_only=False,
147 prefix="wuttaweb",
148): # pylint: disable=too-many-return-statements,too-many-branches,too-many-statements
149 """
150 Return the appropriate version string for the web resource library
151 identified by ``key``.
153 WuttaWeb makes certain assumptions about which libraries would be
154 used on the frontend, and which versions for each would be used by
155 default. But it should also be possible to customize which
156 versions are used, hence this function.
158 Each library has a built-in default version but your config can
159 override them, e.g.:
161 .. code-block:: ini
163 [wuttaweb]
164 libver.bb_vue = 3.4.29
166 :param request: Current request.
168 :param key: Unique key for the library, as string. Possibilities
169 are the same as for :func:`get_liburl()`.
171 :param configured_only: Pass ``True`` here if you only want the
172 configured version and ignore the default version.
174 :param default_only: Pass ``True`` here if you only want the
175 default version and ignore the configured version.
177 :param prefix: If specified, will override the prefix used for
178 config lookups.
180 .. warning::
182 This ``prefix`` param is for backward compatibility and may
183 be removed in the future.
185 :returns: The appropriate version string, e.g. ``'1.2.3'`` or
186 ``'latest'`` etc. Can also return ``None`` in some cases.
187 """
188 config = request.wutta_config
190 if key == "buefy.css":
191 warnings.warn(
192 "libver key 'buefy.css' is deprecated; please use 'buefy_css' instead",
193 DeprecationWarning,
194 stacklevel=2,
195 )
196 key = "buefy_css"
198 # nb. we prefer a setting to be named like: wuttaweb.libver.vue
199 # but for back-compat this also can work: tailbone.libver.vue
200 # and for more back-compat this can work: wuttaweb.vue_version
201 # however that compat only works for some of the settings...
203 if not default_only:
205 # nb. new/preferred setting
206 version = config.get(f"wuttaweb.libver.{key}")
207 if version:
208 return version
210 # maybe try deprecated key for buefy.css
211 if key == "buefy_css":
212 version = config.get("wuttaweb.libver.buefy.css")
213 if version:
214 warnings.warn(
215 "config for wuttaweb.libver.buefy.css is deprecated; "
216 "please set wuttaweb.libver.buefy_css instead",
217 DeprecationWarning,
218 )
219 return version
221 # fallback to caller-specified prefix
222 if prefix != "wuttaweb":
223 version = config.get(f"{prefix}.libver.{key}")
224 if version:
225 warnings.warn(
226 f"config for {prefix}.libver.{key} is deprecated; "
227 f"please set wuttaweb.libver.{key} instead",
228 DeprecationWarning,
229 )
230 return version
232 # maybe try deprecated key for buefy.css
233 if key == "buefy_css":
234 version = config.get(f"{prefix}.libver.buefy.css")
235 if version:
236 warnings.warn(
237 f"config for {prefix}.libver.buefy.css is deprecated; "
238 "please set wuttaweb.libver.buefy_css instead",
239 DeprecationWarning,
240 )
241 return version
243 if key == "buefy":
244 if not default_only:
245 # nb. old/legacy setting
246 version = config.get(f"{prefix}.buefy_version")
247 if version:
248 warnings.warn(
249 f"config for {prefix}.buefy_version is deprecated; "
250 "please set wuttaweb.libver.buefy instead",
251 DeprecationWarning,
252 )
253 return version
254 if not configured_only:
255 return "0.9.25"
257 elif key == "buefy_css":
258 # nb. this always returns something
259 return get_libver(
260 request, "buefy", default_only=default_only, configured_only=configured_only
261 )
263 elif key == "vue":
264 if not default_only:
265 # nb. old/legacy setting
266 version = config.get(f"{prefix}.vue_version")
267 if version:
268 warnings.warn(
269 f"config for {prefix}.vue_version is deprecated; "
270 "please set wuttaweb.libver.vue instead",
271 DeprecationWarning,
272 )
273 return version
274 if not configured_only:
275 return "2.6.14"
277 elif key == "vue_resource":
278 if not configured_only:
279 return "1.5.3"
281 elif key == "fontawesome":
282 if not configured_only:
283 return "5.3.1"
285 elif key == "bb_vue":
286 if not configured_only:
287 return "3.5.18"
289 elif key == "bb_oruga":
290 if not configured_only:
291 return "0.11.4"
293 elif key in ("bb_oruga_bulma", "bb_oruga_bulma_css"):
294 if not configured_only:
295 return "0.7.3"
297 elif key == "bb_fontawesome_svg_core":
298 if not configured_only:
299 return "7.0.0"
301 elif key == "bb_free_solid_svg_icons":
302 if not configured_only:
303 return "7.0.0"
305 elif key == "bb_vue_fontawesome":
306 if not configured_only:
307 return "3.1.1"
309 elif key == "cc_vue":
310 if not configured_only:
311 return "3.5.43"
313 elif key == "cc_buefy":
314 if not configured_only:
315 return "3.1.0"
317 elif key == "cc_buefy_css":
318 if not configured_only:
319 return "3.1.0"
321 elif key == "cc_fontawesome_svg_core":
322 if not configured_only:
323 return "7.3.1"
325 elif key == "cc_free_solid_svg_icons":
326 if not configured_only:
327 return "7.3.1"
329 elif key == "cc_vue_fontawesome":
330 if not configured_only:
331 return "3.1.1"
333 return None
336def get_liburl(
337 request,
338 key,
339 configured_only=False,
340 default_only=False,
341 prefix="wuttaweb",
342): # pylint: disable=too-many-return-statements,too-many-branches,too-many-statements
343 """
344 Return the appropriate URL for the web resource library identified
345 by ``key``.
347 WuttaWeb makes certain assumptions about which libraries would be
348 used on the frontend, and which versions for each would be used by
349 default. But ultimately a URL must be determined for each, hence
350 this function.
352 Each library has a built-in default URL which references a public
353 Internet (i.e. CDN) resource, but your config can override the
354 final URL in two ways:
356 The simplest way is to just override the *version* but otherwise
357 let the default logic construct the URL. See :func:`get_libver()`
358 for more on that approach.
360 The most flexible way is to override the URL explicitly, e.g.:
362 .. code-block:: ini
364 [wuttaweb]
365 liburl.bb_vue = https://example.com/cache/vue-3.4.31.js
367 :param request: Current request.
369 :param key: Unique key for the library, as string. Possibilities
370 are:
372 Vue 2 + Buefy 0.9 (e.g. for "default" theme)
374 * ``vue``
375 * ``vue_resource``
376 * ``buefy``
377 * ``buefy_css``
378 * ``fontawesome``
380 Vue 3 + Oruga 0.x (e.g. for "butterfly" theme)
382 * ``bb_vue``
383 * ``bb_oruga``
384 * ``bb_oruga_bulma``
385 * ``bb_oruga_bulma_css``
386 * ``bb_fontawesome_svg_core``
387 * ``bb_free_solid_svg_icons``
388 * ``bb_vue_fontawesome``
390 Vue 3 + Buefy 3.x (e.g. for "caraway" theme)
392 * ``cc_vue``
393 * ``cc_buefy``
394 * ``cc_buefy_css``
395 * ``cc_fontawesome_svg_core``
396 * ``cc_free_solid_svg_icons``
397 * ``cc_vue_fontawesome``
399 :param configured_only: Pass ``True`` here if you only want the
400 configured URL and ignore the default URL.
402 :param default_only: Pass ``True`` here if you only want the
403 default URL and ignore the configured URL.
405 :param prefix: If specified, will override the prefix used for
406 config lookups.
408 .. warning::
410 This ``prefix`` param is for backward compatibility and may
411 be removed in the future.
413 :returns: The appropriate URL as string. Can also return ``None``
414 in some cases.
415 """
416 config = request.wutta_config
418 if key == "buefy.css":
419 warnings.warn(
420 "liburl key 'buefy.css' is deprecated; please use 'buefy_css' instead",
421 DeprecationWarning,
422 stacklevel=2,
423 )
424 key = "buefy_css"
426 if not default_only:
428 # nb. new/preferred setting
429 url = config.get(f"wuttaweb.liburl.{key}")
430 if url:
431 return url
433 # maybe try deprecated key for buefy.css
434 if key == "buefy_css":
435 version = config.get("wuttaweb.liburl.buefy.css")
436 if version:
437 warnings.warn(
438 "config for wuttaweb.liburl.buefy.css is deprecated; "
439 "please set wuttaweb.liburl.buefy_css instead",
440 DeprecationWarning,
441 )
442 return version
444 # fallback to caller-specified prefix
445 url = config.get(f"{prefix}.liburl.{key}")
446 if url:
447 warnings.warn(
448 f"config for {prefix}.liburl.{key} is deprecated; "
449 f"please set wuttaweb.liburl.{key} instead",
450 DeprecationWarning,
451 )
452 return url
454 # maybe try deprecated key for buefy.css
455 if key == "buefy_css":
456 version = config.get(f"{prefix}.liburl.buefy.css")
457 if version:
458 warnings.warn(
459 f"config for {prefix}.liburl.buefy.css is deprecated; "
460 "please set wuttaweb.liburl.buefy_css instead",
461 DeprecationWarning,
462 )
463 return version
465 if configured_only:
466 return None
468 version = get_libver(
469 request, key, prefix=prefix, configured_only=False, default_only=default_only
470 )
472 # load fanstatic libcache if configured
473 static = config.get("wuttaweb.static_libcache.module")
474 if not static:
475 static = config.get(f"{prefix}.static_libcache.module")
476 if static:
477 warnings.warn(
478 f"config for {prefix}.static_libcache.module is deprecated; "
479 "please set wuttaweb.static_libcache.module instead",
480 DeprecationWarning,
481 )
482 if static:
483 static = importlib.import_module(static)
484 needed = request.environ["fanstatic.needed"]
485 liburl = needed.library_url(static.libcache) + "/"
486 # nb. add custom url prefix if needed, e.g. /wutta
487 if request.script_name:
488 liburl = request.script_name + liburl
490 if key == "buefy":
491 if static and hasattr(static, "buefy_js"):
492 return liburl + static.buefy_js.relpath
493 return f"https://unpkg.com/buefy@{version}/dist/buefy.min.js"
495 if key == "buefy_css":
496 if static and hasattr(static, "buefy_css"):
497 return liburl + static.buefy_css.relpath
498 return f"https://unpkg.com/buefy@{version}/dist/buefy.min.css"
500 if key == "vue":
501 if static and hasattr(static, "vue_js"):
502 return liburl + static.vue_js.relpath
503 return f"https://unpkg.com/vue@{version}/dist/vue.min.js"
505 if key == "vue_resource":
506 if static and hasattr(static, "vue_resource_js"):
507 return liburl + static.vue_resource_js.relpath
508 return f"https://cdn.jsdelivr.net/npm/vue-resource@{version}"
510 if key == "fontawesome":
511 if static and hasattr(static, "fontawesome_js"):
512 return liburl + static.fontawesome_js.relpath
513 return f"https://use.fontawesome.com/releases/v{version}/js/all.js"
515 if key == "bb_vue":
516 if static and hasattr(static, "bb_vue_js"):
517 return liburl + static.bb_vue_js.relpath
518 return f"https://unpkg.com/vue@{version}/dist/vue.esm-browser.prod.js"
520 if key == "bb_oruga":
521 if static and hasattr(static, "bb_oruga_js"):
522 return liburl + static.bb_oruga_js.relpath
523 return f"https://unpkg.com/@oruga-ui/oruga-next@{version}/dist/oruga.mjs"
525 if key == "bb_oruga_bulma":
526 if static and hasattr(static, "bb_oruga_bulma_js"):
527 return liburl + static.bb_oruga_bulma_js.relpath
528 return f"https://unpkg.com/@oruga-ui/theme-bulma@{version}/dist/bulma.js"
530 if key == "bb_oruga_bulma_css":
531 if static and hasattr(static, "bb_oruga_bulma_css"):
532 return liburl + static.bb_oruga_bulma_css.relpath
533 return f"https://unpkg.com/@oruga-ui/theme-bulma@{version}/dist/bulma.css"
535 if key == "bb_fontawesome_svg_core":
536 if static and hasattr(static, "bb_fontawesome_svg_core_js"):
537 return liburl + static.bb_fontawesome_svg_core_js.relpath
538 return f"https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-svg-core@{version}/+esm"
540 if key == "bb_free_solid_svg_icons":
541 if static and hasattr(static, "bb_free_solid_svg_icons_js"):
542 return liburl + static.bb_free_solid_svg_icons_js.relpath
543 return f"https://cdn.jsdelivr.net/npm/@fortawesome/free-solid-svg-icons@{version}/+esm"
545 if key == "bb_vue_fontawesome":
546 if static and hasattr(static, "bb_vue_fontawesome_js"):
547 return liburl + static.bb_vue_fontawesome_js.relpath
548 return (
549 f"https://cdn.jsdelivr.net/npm/@fortawesome/vue-fontawesome@{version}/+esm"
550 )
552 if key == "cc_vue":
553 if static and hasattr(static, "cc_vue_js"):
554 return liburl + static.cc_vue_js.relpath
555 return f"https://unpkg.com/vue@{version}/dist/vue.esm-browser.prod.js"
557 if key == "cc_buefy":
558 if static and hasattr(static, "cc_buefy_js"):
559 return liburl + static.cc_buefy_js.relpath
560 return f"https://unpkg.com/buefy@{version}/dist/buefy.esm.min.js"
562 if key == "cc_buefy_css":
563 if static and hasattr(static, "cc_buefy_css"):
564 return liburl + static.cc_buefy_css.relpath
565 return f"https://unpkg.com/buefy@{version}/dist/css/buefy.min.css"
567 if key == "cc_fontawesome_svg_core":
568 if static and hasattr(static, "cc_fontawesome_svg_core_js"):
569 return liburl + static.cc_fontawesome_svg_core_js.relpath
570 return f"https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-svg-core@{version}/+esm"
572 if key == "cc_free_solid_svg_icons":
573 if static and hasattr(static, "cc_free_solid_svg_icons_js"):
574 return liburl + static.cc_free_solid_svg_icons_js.relpath
575 return f"https://cdn.jsdelivr.net/npm/@fortawesome/free-solid-svg-icons@{version}/+esm"
577 if key == "cc_vue_fontawesome":
578 if static and hasattr(static, "cc_vue_fontawesome_js"):
579 return liburl + static.cc_vue_fontawesome_js.relpath
580 return (
581 f"https://cdn.jsdelivr.net/npm/@fortawesome/vue-fontawesome@{version}/+esm"
582 )
584 return None
587def get_csrf_token(request):
588 """
589 Convenience function, returns the effective CSRF token (raw
590 string) for the given request.
592 See also :func:`render_csrf_token()`.
593 """
594 token = request.session.get_csrf_token()
595 if token is None:
596 token = request.session.new_csrf_token()
597 return token
600def render_csrf_token(request, name="_csrf"):
601 """
602 Convenience function, returns CSRF hidden input inside hidden div,
603 e.g.:
605 .. code-block:: html
607 <div style="display: none;">
608 <input type="hidden" name="_csrf" value="TOKEN" />
609 </div>
611 This function is part of :mod:`wuttaweb.helpers` (as
612 :func:`~wuttaweb.helpers.csrf_token()`) which means you can do
613 this in page templates:
615 .. code-block:: mako
617 ${h.form(request.current_route_url())}
618 ${h.csrf_token(request)}
619 <!-- other fields etc. -->
620 ${h.end_form()}
622 See also :func:`get_csrf_token()`.
623 """
624 token = get_csrf_token(request)
625 return HTML.tag(
626 "div", tags.hidden(name, value=token, id=None), style="display:none;"
627 )
630def get_model_fields(config, model_class, include_fk=False):
631 """
632 Convenience function to return a list of field names for the given
633 :term:`data model` class.
635 This logic only supports SQLAlchemy mapped classes and will use
636 that to determine the field listing if applicable. Otherwise this
637 returns ``None``.
639 :param config: App :term:`config object`.
641 :param model_class: Data model class.
643 :param include_fk: Whether to include foreign key column names in
644 the result. They are excluded by default, since the
645 relationship names are also included and generally preferred.
647 :returns: List of field names, or ``None`` if it could not be
648 determined.
649 """
650 try:
651 mapper = sa.inspect(model_class)
652 except sa.exc.NoInspectionAvailable:
653 return None
655 if include_fk:
656 fields = [prop.key for prop in mapper.iterate_properties]
657 else:
658 fields = [
659 prop.key for prop in mapper.iterate_properties if not prop_is_fk(prop)
660 ]
662 # nb. we never want the continuum 'versions' prop
663 app = config.get_app()
664 if app.continuum_is_enabled() and "versions" in fields:
665 fields.remove("versions")
667 return fields
670def prop_is_fk(prop):
671 """
672 Determine if the given mapper property represents the "foreign
673 key" side of a relationship.
675 This *may* be used by :meth:`get_model_fields()` to strip out
676 foreign key columns, in favor of the corresponding relationships.
678 :param prop: SQLAlchemy mapper property for the model class,
679 e.g. ``Person.uuid`` or ``User.person_uuid``.
681 :returns: ``True`` if property represents the "foreign key" side
682 of a relationship.
683 """
684 if not isinstance(prop, orm.ColumnProperty):
685 return False
687 mapper = prop.parent
688 prop_columns = [col.name for col in prop.columns]
689 for rel in mapper.relationships:
690 if [col.name for col in rel.local_columns] == prop_columns:
691 for col in rel.local_columns:
692 if col.foreign_keys:
693 return True
695 return False
698def make_json_safe(value, key=None, warn=True): # pylint: disable=too-many-branches
699 """
700 Convert a Python value as needed, to ensure it is compatible with
701 :func:`python:json.dumps()`.
703 :param value: Python value.
705 :param key: Optional key for the value, if known. This is used
706 when logging warnings, if applicable.
708 :param warn: Whether warnings should be logged if the value is not
709 already JSON-compatible.
711 :returns: A (possibly new) Python value which is guaranteed to be
712 JSON-serializable.
713 """
715 # convert null => None
716 if value is colander.null:
717 return None
719 if isinstance(value, dict):
720 # recursively convert dict
721 parent = dict(value)
722 for k, v in parent.items():
723 parent[k] = make_json_safe(v, key=k, warn=warn)
724 value = parent
726 elif isinstance(value, list):
727 # recursively convert list
728 parent = list(value)
729 for i, v in enumerate(parent):
730 parent[i] = make_json_safe(v, key=key, warn=warn)
731 value = parent
733 elif isinstance(value, set):
734 # recursively convert set (as list)
735 parent = list(value)
736 for i, v in enumerate(parent):
737 parent[i] = make_json_safe(v, key=key, warn=warn)
738 value = parent
740 elif isinstance(value, _uuid.UUID):
741 # convert UUID to str
742 value = value.hex
744 elif isinstance(value, decimal.Decimal):
745 # convert decimal to float
746 value = float(value)
748 # ensure JSON-compatibility, warn if problems
749 try:
750 json.dumps(value)
751 except TypeError:
752 if warn:
753 prefix = "value"
754 if key:
755 prefix += f" for '{key}'"
756 log.warning("%s is not json-friendly: %s", prefix, repr(value))
757 value = str(value)
758 if warn:
759 log.warning("forced value to: %s", value)
761 return value
764def render_vue_finalize(vue_tagname, vue_component):
765 """
766 Render the Vue "finalize" script for a form or grid component.
768 This is a convenience for shared logic; it returns e.g.:
770 .. code-block:: html
772 <script>
773 WuttaGrid.data = function() { return WuttaGridData }
774 Vue.component('wutta-grid', WuttaGrid)
775 </script>
776 """
777 set_data = f"{vue_component}.data = function() {{ return {vue_component}Data }}"
778 make_component = f"Vue.component('{vue_tagname}', {vue_component})"
779 return HTML.tag(
780 "script",
781 c=["\n", HTML.literal(set_data), "\n", HTML.literal(make_component), "\n"],
782 )
785def make_users_grid(request, **kwargs):
786 """
787 Make and return a users (sub)grid.
789 This grid is shown for the Users field when viewing a Person or
790 Role, for instance. It is called by the following methods:
792 * :meth:`wuttaweb.views.people.PersonView.make_users_grid()`
793 * :meth:`wuttaweb.views.roles.RoleView.make_users_grid()`
795 :returns: Fully configured :class:`~wuttaweb.grids.base.Grid`
796 instance.
797 """
798 config = request.wutta_config
799 app = config.get_app()
800 model = app.model
801 web = app.get_web_handler()
803 if "key" not in kwargs:
804 route_prefix = kwargs.pop("route_prefix")
805 kwargs["key"] = f"{route_prefix}.view.users"
807 kwargs.setdefault("model_class", model.User)
808 grid = web.make_grid(request, **kwargs)
810 if request.has_perm("users.view"):
812 def view_url(user, i): # pylint: disable=unused-argument
813 return request.route_url("users.view", uuid=user.uuid)
815 grid.add_action("view", icon="eye", url=view_url)
816 grid.set_link("person")
817 grid.set_link("username")
819 if request.has_perm("users.edit"):
821 def edit_url(user, i): # pylint: disable=unused-argument
822 return request.route_url("users.edit", uuid=user.uuid)
824 grid.add_action("edit", url=edit_url)
826 return grid
829##############################
830# theme functions
831##############################
834def get_available_themes(config):
835 """
836 Returns the official list of theme names which are available for
837 use in the app. Privileged users may choose among these when
838 changing the global theme.
840 See also :func:`get_effective_theme()`.
842 By default the list will include these built-in themes:
844 * **default** (Vue 2 + Buefy 0.9)
845 * **butterfly** (Vue 3 + Oruga 0.x)
846 * **caraway** (Vue 3 + Buefy 3.x)
848 You can override the list via config:
850 .. code-block:: ini
852 [wuttaweb]
853 themes.keys = default, caraway, my-other-one
855 (TODO: link to elsewhere for how to *define* a custom theme)
857 :param config: App :term:`config object`.
859 :returns: List of theme names
860 """
861 # get available list from config, if it has one
862 available = config.get_list(
863 "wuttaweb.themes.keys", default=["default", "butterfly", "caraway"]
864 )
866 # sort the list by name
867 available.sort()
869 # make default theme the first option
870 if "default" in available:
871 available.remove("default")
872 available.insert(0, "default")
874 return available
877def get_effective_theme(config, theme=None, session=None):
878 """
879 Validate and return the "effective" theme.
881 If caller specifies a ``theme`` then it will be returned (if
882 "available" - see below).
884 Otherwise the current theme will be read from db setting. (Note
885 we do not read simply from config object, we always read from db
886 setting - this allows for the theme setting to change dynamically
887 while app is running.)
889 In either case if the theme is not listed in
890 :func:`get_available_themes()` then a ``ValueError`` is raised.
892 :param config: App :term:`config object`.
894 :param theme: Optional name of desired theme, instead of getting
895 current theme per db setting.
897 :param session: Optional :term:`db session`.
899 :returns: Name of theme.
900 """
901 app = config.get_app()
903 if not theme:
904 with app.short_session(session=session) as s:
905 theme = app.get_setting(s, "wuttaweb.theme") or "default"
907 # confirm requested theme is available
908 available = get_available_themes(config)
909 if theme not in available:
910 raise ValueError(f"theme not available: {theme}")
912 return theme
915def get_theme_template_path(config, theme=None, session=None):
916 """
917 Return the template path for effective theme.
919 If caller specifies a ``theme`` then it will be used; otherwise
920 the current theme will be read from db setting. The logic for
921 that happens in :func:`get_effective_theme()`, which this function
922 will call first.
924 Once we have the valid theme name, we check config in case it
925 specifies a template path override for it. But if not, a default
926 template path is assumed.
928 The default path would be expected to live under
929 ``wuttaweb:templates/themes``; for instance the ``butterfly``
930 theme has a default template path of
931 ``wuttaweb:templates/themes/butterfly``.
933 :param config: App :term:`config object`.
935 :param theme: Optional name of desired theme, instead of getting
936 current theme per db setting.
938 :param session: Optional :term:`db session`.
940 :returns: Path on disk to theme template folder.
941 """
942 theme = get_effective_theme(config, theme=theme, session=session)
943 theme_path = config.get(
944 f"wuttaweb.theme.{theme}", default=f"wuttaweb:templates/themes/{theme}"
945 )
946 return resource_path(theme_path)
949def set_app_theme(request, theme, session=None):
950 """
951 Set the effective theme for the running app.
953 This will modify the *global* Mako template lookup directories,
954 i.e. app templates will change for all users immediately.
956 This will first validate the theme by calling
957 :func:`get_effective_theme()`. It then retrieves the template
958 path via :func:`get_theme_template_path()`.
960 The theme template path is then injected into the app settings
961 registry such that it overrides the Mako lookup directories.
963 It also will persist the theme name within db settings, so as to
964 ensure it survives app restart.
965 """
966 config = request.wutta_config
967 app = config.get_app()
969 theme = get_effective_theme(config, theme=theme, session=session)
970 theme_path = get_theme_template_path(config, theme=theme, session=session)
972 # there's only one global template lookup; can get to it via any renderer
973 # but should *not* use /base.mako since that one is about to get volatile
974 renderer = get_renderer("/page.mako")
975 lookup = renderer.lookup
977 # overwrite first entry in lookup's directory list
978 lookup.directories[0] = theme_path
980 # clear template cache for lookup object, so it will reload each (as needed)
981 lookup._collection.clear() # pylint: disable=protected-access
983 # persist current theme in db settings
984 with app.short_session(session=session) as s:
985 app.save_setting(s, "wuttaweb.theme", theme)
987 # and cache in live app settings
988 request.registry.settings["wuttaweb.theme"] = theme