Coverage for .tox/coverage/lib/python3.13/site-packages/wuttaweb/util.py: 100%
352 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-21 13:06 -0500
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-21 13:06 -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
44log = logging.getLogger(__name__)
47class FieldList(list):
48 """
49 Convenience wrapper for a form's field list. This is a subclass
50 of :class:`python:list`.
52 You normally would not need to instantiate this yourself, but it
53 is used under the hood for
54 :attr:`~wuttaweb.forms.base.Form.fields` as well as
55 :attr:`~wuttaweb.grids.base.Grid.columns`.
56 """
58 def insert_before(self, field, newfield):
59 """
60 Insert a new field, before an existing field.
62 :param field: String name for the existing field.
64 :param newfield: String name for the new field, to be inserted
65 just before the existing ``field``.
66 """
67 if field in self:
68 i = self.index(field)
69 self.insert(i, newfield)
70 else:
71 log.warning(
72 "field '%s' not found, will append new field: %s", field, newfield
73 )
74 self.append(newfield)
76 def insert_after(self, field, newfield):
77 """
78 Insert a new field, after an existing field.
80 :param field: String name for the existing field.
82 :param newfield: String name for the new field, to be inserted
83 just after the existing ``field``.
84 """
85 if field in self:
86 i = self.index(field)
87 self.insert(i + 1, newfield)
88 else:
89 log.warning(
90 "field '%s' not found, will append new field: %s", field, newfield
91 )
92 self.append(newfield)
94 def set_sequence(self, fields):
95 """
96 Sort the list such that it matches the same sequence as the
97 given fields list.
99 This does not add or remove any elements, it just
100 (potentially) rearranges the internal list elements.
101 Therefore you do not need to explicitly declare *all* fields;
102 just the ones you care about.
104 The resulting field list will have the requested fields in
105 order, at the *beginning* of the list. Any unrequested fields
106 will remain in the same order as they were previously, but
107 will be placed *after* the requested fields.
109 :param fields: List of fields in the desired order.
110 """
111 unimportant = len(self) + 1
113 def getkey(field):
114 if field in fields:
115 return fields.index(field)
116 return unimportant
118 self.sort(key=getkey)
121def get_form_data(request):
122 """
123 Returns the effective form data for the given request.
125 Mostly this is a convenience, which simply returns one of the
126 following, depending on various attributes of the request.
128 * :attr:`pyramid:pyramid.request.Request.POST`
129 * :attr:`pyramid:pyramid.request.Request.json_body`
130 """
131 # nb. we prefer JSON only if no POST is present
132 # TODO: this seems to work for our use case at least, but perhaps
133 # there is a better way? see also
134 # https://docs.pylonsproject.org/projects/pyramid/en/latest/api/request.html#pyramid.request.Request.is_xhr
135 if not request.POST and (
136 getattr(request, "is_xhr", False)
137 or getattr(request, "content_type", None) == "application/json"
138 ):
139 return request.json_body
140 return request.POST
143def get_libver(
144 request,
145 key,
146 configured_only=False,
147 default_only=False,
148 prefix="wuttaweb",
149): # pylint: disable=too-many-return-statements,too-many-branches,too-many-statements
150 """
151 Return the appropriate version string for the web resource library
152 identified by ``key``.
154 WuttaWeb makes certain assumptions about which libraries would be
155 used on the frontend, and which versions for each would be used by
156 default. But it should also be possible to customize which
157 versions are used, hence this function.
159 Each library has a built-in default version but your config can
160 override them, e.g.:
162 .. code-block:: ini
164 [wuttaweb]
165 libver.bb_vue = 3.4.29
167 :param request: Current request.
169 :param key: Unique key for the library, as string. Possibilities
170 are the same as for :func:`get_liburl()`.
172 :param configured_only: Pass ``True`` here if you only want the
173 configured version and ignore the default version.
175 :param default_only: Pass ``True`` here if you only want the
176 default version and ignore the configured version.
178 :param prefix: If specified, will override the prefix used for
179 config lookups.
181 .. warning::
183 This ``prefix`` param is for backward compatibility and may
184 be removed in the future.
186 :returns: The appropriate version string, e.g. ``'1.2.3'`` or
187 ``'latest'`` etc. Can also return ``None`` in some cases.
188 """
189 config = request.wutta_config
191 if key == "buefy.css":
192 warnings.warn(
193 "libver key 'buefy.css' is deprecated; please use 'buefy_css' instead",
194 DeprecationWarning,
195 stacklevel=2,
196 )
197 key = "buefy_css"
199 # nb. we prefer a setting to be named like: wuttaweb.libver.vue
200 # but for back-compat this also can work: tailbone.libver.vue
201 # and for more back-compat this can work: wuttaweb.vue_version
202 # however that compat only works for some of the settings...
204 if not default_only:
206 # nb. new/preferred setting
207 version = config.get(f"wuttaweb.libver.{key}")
208 if version:
209 return version
211 # maybe try deprecated key for buefy.css
212 if key == "buefy_css":
213 version = config.get("wuttaweb.libver.buefy.css")
214 if version:
215 warnings.warn(
216 "config for wuttaweb.libver.buefy.css is deprecated; "
217 "please set wuttaweb.libver.buefy_css instead",
218 DeprecationWarning,
219 )
220 return version
222 # fallback to caller-specified prefix
223 if prefix != "wuttaweb":
224 version = config.get(f"{prefix}.libver.{key}")
225 if version:
226 warnings.warn(
227 f"config for {prefix}.libver.{key} is deprecated; "
228 f"please set wuttaweb.libver.{key} instead",
229 DeprecationWarning,
230 )
231 return version
233 # maybe try deprecated key for buefy.css
234 if key == "buefy_css":
235 version = config.get(f"{prefix}.libver.buefy.css")
236 if version:
237 warnings.warn(
238 f"config for {prefix}.libver.buefy.css is deprecated; "
239 "please set wuttaweb.libver.buefy_css instead",
240 DeprecationWarning,
241 )
242 return version
244 if key == "buefy":
245 if not default_only:
246 # nb. old/legacy setting
247 version = config.get(f"{prefix}.buefy_version")
248 if version:
249 warnings.warn(
250 f"config for {prefix}.buefy_version is deprecated; "
251 "please set wuttaweb.libver.buefy instead",
252 DeprecationWarning,
253 )
254 return version
255 if not configured_only:
256 return "0.9.25"
258 elif key == "buefy_css":
259 # nb. this always returns something
260 return get_libver(
261 request, "buefy", default_only=default_only, configured_only=configured_only
262 )
264 elif key == "vue":
265 if not default_only:
266 # nb. old/legacy setting
267 version = config.get(f"{prefix}.vue_version")
268 if version:
269 warnings.warn(
270 f"config for {prefix}.vue_version is deprecated; "
271 "please set wuttaweb.libver.vue instead",
272 DeprecationWarning,
273 )
274 return version
275 if not configured_only:
276 return "2.6.14"
278 elif key == "vue_resource":
279 if not configured_only:
280 return "1.5.3"
282 elif key == "fontawesome":
283 if not configured_only:
284 return "5.3.1"
286 elif key == "bb_vue":
287 if not configured_only:
288 return "3.5.18"
290 elif key == "bb_oruga":
291 if not configured_only:
292 return "0.11.4"
294 elif key in ("bb_oruga_bulma", "bb_oruga_bulma_css"):
295 if not configured_only:
296 return "0.7.3"
298 elif key == "bb_fontawesome_svg_core":
299 if not configured_only:
300 return "7.0.0"
302 elif key == "bb_free_solid_svg_icons":
303 if not configured_only:
304 return "7.0.0"
306 elif key == "bb_vue_fontawesome":
307 if not configured_only:
308 return "3.1.1"
310 elif key == "cc_vue":
311 if not configured_only:
312 return "3.5.43"
314 elif key == "cc_buefy":
315 if not configured_only:
316 return "3.1.0"
318 elif key == "cc_buefy_css":
319 if not configured_only:
320 return "3.1.0"
322 elif key == "cc_fontawesome_svg_core":
323 if not configured_only:
324 return "7.3.1"
326 elif key == "cc_free_solid_svg_icons":
327 if not configured_only:
328 return "7.3.1"
330 elif key == "cc_vue_fontawesome":
331 if not configured_only:
332 return "3.1.1"
334 return None
337def get_liburl(
338 request,
339 key,
340 configured_only=False,
341 default_only=False,
342 prefix="wuttaweb",
343): # pylint: disable=too-many-return-statements,too-many-branches,too-many-statements
344 """
345 Return the appropriate URL for the web resource library identified
346 by ``key``.
348 WuttaWeb makes certain assumptions about which libraries would be
349 used on the frontend, and which versions for each would be used by
350 default. But ultimately a URL must be determined for each, hence
351 this function.
353 Each library has a built-in default URL which references a public
354 Internet (i.e. CDN) resource, but your config can override the
355 final URL in two ways:
357 The simplest way is to just override the *version* but otherwise
358 let the default logic construct the URL. See :func:`get_libver()`
359 for more on that approach.
361 The most flexible way is to override the URL explicitly, e.g.:
363 .. code-block:: ini
365 [wuttaweb]
366 liburl.bb_vue = https://example.com/cache/vue-3.4.31.js
368 :param request: Current request.
370 :param key: Unique key for the library, as string. Possibilities
371 are:
373 Vue 2 + Buefy 0.9 (e.g. for "default" theme)
375 * ``vue``
376 * ``vue_resource``
377 * ``buefy``
378 * ``buefy_css``
379 * ``fontawesome``
381 Vue 3 + Oruga 0.x (e.g. for "butterfly" theme)
383 * ``bb_vue``
384 * ``bb_oruga``
385 * ``bb_oruga_bulma``
386 * ``bb_oruga_bulma_css``
387 * ``bb_fontawesome_svg_core``
388 * ``bb_free_solid_svg_icons``
389 * ``bb_vue_fontawesome``
391 Vue 3 + Buefy 3.x (e.g. for "caraway" theme)
393 * ``cc_vue``
394 * ``cc_buefy``
395 * ``cc_buefy_css``
396 * ``cc_fontawesome_svg_core``
397 * ``cc_free_solid_svg_icons``
398 * ``cc_vue_fontawesome``
400 :param configured_only: Pass ``True`` here if you only want the
401 configured URL and ignore the default URL.
403 :param default_only: Pass ``True`` here if you only want the
404 default URL and ignore the configured URL.
406 :param prefix: If specified, will override the prefix used for
407 config lookups.
409 .. warning::
411 This ``prefix`` param is for backward compatibility and may
412 be removed in the future.
414 :returns: The appropriate URL as string. Can also return ``None``
415 in some cases.
416 """
417 config = request.wutta_config
419 if key == "buefy.css":
420 warnings.warn(
421 "liburl key 'buefy.css' is deprecated; please use 'buefy_css' instead",
422 DeprecationWarning,
423 stacklevel=2,
424 )
425 key = "buefy_css"
427 if not default_only:
429 # nb. new/preferred setting
430 url = config.get(f"wuttaweb.liburl.{key}")
431 if url:
432 return url
434 # maybe try deprecated key for buefy.css
435 if key == "buefy_css":
436 version = config.get("wuttaweb.liburl.buefy.css")
437 if version:
438 warnings.warn(
439 "config for wuttaweb.liburl.buefy.css is deprecated; "
440 "please set wuttaweb.liburl.buefy_css instead",
441 DeprecationWarning,
442 )
443 return version
445 # fallback to caller-specified prefix
446 url = config.get(f"{prefix}.liburl.{key}")
447 if url:
448 warnings.warn(
449 f"config for {prefix}.liburl.{key} is deprecated; "
450 f"please set wuttaweb.liburl.{key} instead",
451 DeprecationWarning,
452 )
453 return url
455 # maybe try deprecated key for buefy.css
456 if key == "buefy_css":
457 version = config.get(f"{prefix}.liburl.buefy.css")
458 if version:
459 warnings.warn(
460 f"config for {prefix}.liburl.buefy.css is deprecated; "
461 "please set wuttaweb.liburl.buefy_css instead",
462 DeprecationWarning,
463 )
464 return version
466 if configured_only:
467 return None
469 version = get_libver(
470 request, key, prefix=prefix, configured_only=False, default_only=default_only
471 )
473 # load fanstatic libcache if configured
474 static = config.get("wuttaweb.static_libcache.module")
475 if not static:
476 static = config.get(f"{prefix}.static_libcache.module")
477 if static:
478 warnings.warn(
479 f"config for {prefix}.static_libcache.module is deprecated; "
480 "please set wuttaweb.static_libcache.module instead",
481 DeprecationWarning,
482 )
483 if static:
484 static = importlib.import_module(static)
485 needed = request.environ["fanstatic.needed"]
486 liburl = needed.library_url(static.libcache) + "/"
487 # nb. add custom url prefix if needed, e.g. /wutta
488 if request.script_name:
489 liburl = request.script_name + liburl
491 if key == "buefy":
492 if static and hasattr(static, "buefy_js"):
493 return liburl + static.buefy_js.relpath
494 return f"https://unpkg.com/buefy@{version}/dist/buefy.min.js"
496 if key == "buefy_css":
497 if static and hasattr(static, "buefy_css"):
498 return liburl + static.buefy_css.relpath
499 return f"https://unpkg.com/buefy@{version}/dist/buefy.min.css"
501 if key == "vue":
502 if static and hasattr(static, "vue_js"):
503 return liburl + static.vue_js.relpath
504 return f"https://unpkg.com/vue@{version}/dist/vue.min.js"
506 if key == "vue_resource":
507 if static and hasattr(static, "vue_resource_js"):
508 return liburl + static.vue_resource_js.relpath
509 return f"https://cdn.jsdelivr.net/npm/vue-resource@{version}"
511 if key == "fontawesome":
512 if static and hasattr(static, "fontawesome_js"):
513 return liburl + static.fontawesome_js.relpath
514 return f"https://use.fontawesome.com/releases/v{version}/js/all.js"
516 if key == "bb_vue":
517 if static and hasattr(static, "bb_vue_js"):
518 return liburl + static.bb_vue_js.relpath
519 return f"https://unpkg.com/vue@{version}/dist/vue.esm-browser.prod.js"
521 if key == "bb_oruga":
522 if static and hasattr(static, "bb_oruga_js"):
523 return liburl + static.bb_oruga_js.relpath
524 return f"https://unpkg.com/@oruga-ui/oruga-next@{version}/dist/oruga.mjs"
526 if key == "bb_oruga_bulma":
527 if static and hasattr(static, "bb_oruga_bulma_js"):
528 return liburl + static.bb_oruga_bulma_js.relpath
529 return f"https://unpkg.com/@oruga-ui/theme-bulma@{version}/dist/bulma.js"
531 if key == "bb_oruga_bulma_css":
532 if static and hasattr(static, "bb_oruga_bulma_css"):
533 return liburl + static.bb_oruga_bulma_css.relpath
534 return f"https://unpkg.com/@oruga-ui/theme-bulma@{version}/dist/bulma.css"
536 if key == "bb_fontawesome_svg_core":
537 if static and hasattr(static, "bb_fontawesome_svg_core_js"):
538 return liburl + static.bb_fontawesome_svg_core_js.relpath
539 return f"https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-svg-core@{version}/+esm"
541 if key == "bb_free_solid_svg_icons":
542 if static and hasattr(static, "bb_free_solid_svg_icons_js"):
543 return liburl + static.bb_free_solid_svg_icons_js.relpath
544 return f"https://cdn.jsdelivr.net/npm/@fortawesome/free-solid-svg-icons@{version}/+esm"
546 if key == "bb_vue_fontawesome":
547 if static and hasattr(static, "bb_vue_fontawesome_js"):
548 return liburl + static.bb_vue_fontawesome_js.relpath
549 return (
550 f"https://cdn.jsdelivr.net/npm/@fortawesome/vue-fontawesome@{version}/+esm"
551 )
553 if key == "cc_vue":
554 if static and hasattr(static, "cc_vue_js"):
555 return liburl + static.cc_vue_js.relpath
556 return f"https://unpkg.com/vue@{version}/dist/vue.esm-browser.prod.js"
558 if key == "cc_buefy":
559 if static and hasattr(static, "cc_buefy_js"):
560 return liburl + static.cc_buefy_js.relpath
561 return f"https://unpkg.com/buefy@{version}/dist/buefy.esm.min.js"
563 if key == "cc_buefy_css":
564 if static and hasattr(static, "cc_buefy_css"):
565 return liburl + static.cc_buefy_css.relpath
566 return f"https://unpkg.com/buefy@{version}/dist/css/buefy.min.css"
568 if key == "cc_fontawesome_svg_core":
569 if static and hasattr(static, "cc_fontawesome_svg_core_js"):
570 return liburl + static.cc_fontawesome_svg_core_js.relpath
571 return f"https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-svg-core@{version}/+esm"
573 if key == "cc_free_solid_svg_icons":
574 if static and hasattr(static, "cc_free_solid_svg_icons_js"):
575 return liburl + static.cc_free_solid_svg_icons_js.relpath
576 return f"https://cdn.jsdelivr.net/npm/@fortawesome/free-solid-svg-icons@{version}/+esm"
578 if key == "cc_vue_fontawesome":
579 if static and hasattr(static, "cc_vue_fontawesome_js"):
580 return liburl + static.cc_vue_fontawesome_js.relpath
581 return (
582 f"https://cdn.jsdelivr.net/npm/@fortawesome/vue-fontawesome@{version}/+esm"
583 )
585 return None
588def get_csrf_token(request):
589 """
590 Convenience function, returns the effective CSRF token (raw
591 string) for the given request.
593 See also :func:`render_csrf_token()`.
594 """
595 token = request.session.get_csrf_token()
596 if token is None:
597 token = request.session.new_csrf_token()
598 return token
601def render_csrf_token(request, name="_csrf"):
602 """
603 Convenience function, returns CSRF hidden input inside hidden div,
604 e.g.:
606 .. code-block:: html
608 <div style="display: none;">
609 <input type="hidden" name="_csrf" value="TOKEN" />
610 </div>
612 This function is part of :mod:`wuttaweb.helpers` (as
613 :func:`~wuttaweb.helpers.csrf_token()`) which means you can do
614 this in page templates:
616 .. code-block:: mako
618 ${h.form(request.current_route_url())}
619 ${h.csrf_token(request)}
620 <!-- other fields etc. -->
621 ${h.end_form()}
623 See also :func:`get_csrf_token()`.
624 """
625 token = get_csrf_token(request)
626 return HTML.tag(
627 "div", tags.hidden(name, value=token, id=None), style="display:none;"
628 )
631def get_model_fields(config, model_class, include_fk=False):
632 """
633 Convenience function to return a list of field names for the given
634 :term:`data model` class.
636 This logic only supports SQLAlchemy mapped classes and will use
637 that to determine the field listing if applicable. Otherwise this
638 returns ``None``.
640 :param config: App :term:`config object`.
642 :param model_class: Data model class.
644 :param include_fk: Whether to include foreign key column names in
645 the result. They are excluded by default, since the
646 relationship names are also included and generally preferred.
648 :returns: List of field names, or ``None`` if it could not be
649 determined.
650 """
651 try:
652 mapper = sa.inspect(model_class)
653 except sa.exc.NoInspectionAvailable:
654 return None
656 if include_fk:
657 fields = [prop.key for prop in mapper.iterate_properties]
658 else:
659 fields = [
660 prop.key
661 for prop in mapper.iterate_properties
662 if not prop_is_fk(mapper, prop)
663 ]
665 # nb. we never want the continuum 'versions' prop
666 app = config.get_app()
667 if app.continuum_is_enabled() and "versions" in fields:
668 fields.remove("versions")
670 return fields
673def prop_is_fk(mapper, prop): # pylint: disable=empty-docstring
674 """ """
675 if not isinstance(prop, orm.ColumnProperty):
676 return False
678 prop_columns = [col.name for col in prop.columns]
679 for rel in mapper.relationships:
680 rel_columns = [col.name for col in rel.local_columns]
681 if rel_columns == prop_columns:
682 return True
684 return False
687def make_json_safe(value, key=None, warn=True): # pylint: disable=too-many-branches
688 """
689 Convert a Python value as needed, to ensure it is compatible with
690 :func:`python:json.dumps()`.
692 :param value: Python value.
694 :param key: Optional key for the value, if known. This is used
695 when logging warnings, if applicable.
697 :param warn: Whether warnings should be logged if the value is not
698 already JSON-compatible.
700 :returns: A (possibly new) Python value which is guaranteed to be
701 JSON-serializable.
702 """
704 # convert null => None
705 if value is colander.null:
706 return None
708 if isinstance(value, dict):
709 # recursively convert dict
710 parent = dict(value)
711 for k, v in parent.items():
712 parent[k] = make_json_safe(v, key=k, warn=warn)
713 value = parent
715 elif isinstance(value, list):
716 # recursively convert list
717 parent = list(value)
718 for i, v in enumerate(parent):
719 parent[i] = make_json_safe(v, key=key, warn=warn)
720 value = parent
722 elif isinstance(value, set):
723 # recursively convert set (as list)
724 parent = list(value)
725 for i, v in enumerate(parent):
726 parent[i] = make_json_safe(v, key=key, warn=warn)
727 value = parent
729 elif isinstance(value, _uuid.UUID):
730 # convert UUID to str
731 value = value.hex
733 elif isinstance(value, decimal.Decimal):
734 # convert decimal to float
735 value = float(value)
737 # ensure JSON-compatibility, warn if problems
738 try:
739 json.dumps(value)
740 except TypeError:
741 if warn:
742 prefix = "value"
743 if key:
744 prefix += f" for '{key}'"
745 log.warning("%s is not json-friendly: %s", prefix, repr(value))
746 value = str(value)
747 if warn:
748 log.warning("forced value to: %s", value)
750 return value
753def render_vue_finalize(vue_tagname, vue_component):
754 """
755 Render the Vue "finalize" script for a form or grid component.
757 This is a convenience for shared logic; it returns e.g.:
759 .. code-block:: html
761 <script>
762 WuttaGrid.data = function() { return WuttaGridData }
763 Vue.component('wutta-grid', WuttaGrid)
764 </script>
765 """
766 set_data = f"{vue_component}.data = function() {{ return {vue_component}Data }}"
767 make_component = f"Vue.component('{vue_tagname}', {vue_component})"
768 return HTML.tag(
769 "script",
770 c=["\n", HTML.literal(set_data), "\n", HTML.literal(make_component), "\n"],
771 )
774def make_users_grid(request, **kwargs):
775 """
776 Make and return a users (sub)grid.
778 This grid is shown for the Users field when viewing a Person or
779 Role, for instance. It is called by the following methods:
781 * :meth:`wuttaweb.views.people.PersonView.make_users_grid()`
782 * :meth:`wuttaweb.views.roles.RoleView.make_users_grid()`
784 :returns: Fully configured :class:`~wuttaweb.grids.base.Grid`
785 instance.
786 """
787 config = request.wutta_config
788 app = config.get_app()
789 model = app.model
790 web = app.get_web_handler()
792 if "key" not in kwargs:
793 route_prefix = kwargs.pop("route_prefix")
794 kwargs["key"] = f"{route_prefix}.view.users"
796 kwargs.setdefault("model_class", model.User)
797 grid = web.make_grid(request, **kwargs)
799 if request.has_perm("users.view"):
801 def view_url(user, i): # pylint: disable=unused-argument
802 return request.route_url("users.view", uuid=user.uuid)
804 grid.add_action("view", icon="eye", url=view_url)
805 grid.set_link("person")
806 grid.set_link("username")
808 if request.has_perm("users.edit"):
810 def edit_url(user, i): # pylint: disable=unused-argument
811 return request.route_url("users.edit", uuid=user.uuid)
813 grid.add_action("edit", url=edit_url)
815 return grid
818##############################
819# theme functions
820##############################
823def get_available_themes(config):
824 """
825 Returns the official list of theme names which are available for
826 use in the app. Privileged users may choose among these when
827 changing the global theme.
829 See also :func:`get_effective_theme()`.
831 By default the list will include these built-in themes:
833 * **default** (Vue 2 + Buefy 0.9)
834 * **butterfly** (Vue 3 + Oruga 0.x)
835 * **caraway** (Vue 3 + Buefy 3.x)
837 You can override the list via config:
839 .. code-block:: ini
841 [wuttaweb]
842 themes.keys = default, caraway, my-other-one
844 (TODO: link to elsewhere for how to *define* a custom theme)
846 :param config: App :term:`config object`.
848 :returns: List of theme names
849 """
850 # get available list from config, if it has one
851 available = config.get_list(
852 "wuttaweb.themes.keys", default=["default", "butterfly", "caraway"]
853 )
855 # sort the list by name
856 available.sort()
858 # make default theme the first option
859 if "default" in available:
860 available.remove("default")
861 available.insert(0, "default")
863 return available
866def get_effective_theme(config, theme=None, session=None):
867 """
868 Validate and return the "effective" theme.
870 If caller specifies a ``theme`` then it will be returned (if
871 "available" - see below).
873 Otherwise the current theme will be read from db setting. (Note
874 we do not read simply from config object, we always read from db
875 setting - this allows for the theme setting to change dynamically
876 while app is running.)
878 In either case if the theme is not listed in
879 :func:`get_available_themes()` then a ``ValueError`` is raised.
881 :param config: App :term:`config object`.
883 :param theme: Optional name of desired theme, instead of getting
884 current theme per db setting.
886 :param session: Optional :term:`db session`.
888 :returns: Name of theme.
889 """
890 app = config.get_app()
892 if not theme:
893 with app.short_session(session=session) as s:
894 theme = app.get_setting(s, "wuttaweb.theme") or "default"
896 # confirm requested theme is available
897 available = get_available_themes(config)
898 if theme not in available:
899 raise ValueError(f"theme not available: {theme}")
901 return theme
904def get_theme_template_path(config, theme=None, session=None):
905 """
906 Return the template path for effective theme.
908 If caller specifies a ``theme`` then it will be used; otherwise
909 the current theme will be read from db setting. The logic for
910 that happens in :func:`get_effective_theme()`, which this function
911 will call first.
913 Once we have the valid theme name, we check config in case it
914 specifies a template path override for it. But if not, a default
915 template path is assumed.
917 The default path would be expected to live under
918 ``wuttaweb:templates/themes``; for instance the ``butterfly``
919 theme has a default template path of
920 ``wuttaweb:templates/themes/butterfly``.
922 :param config: App :term:`config object`.
924 :param theme: Optional name of desired theme, instead of getting
925 current theme per db setting.
927 :param session: Optional :term:`db session`.
929 :returns: Path on disk to theme template folder.
930 """
931 theme = get_effective_theme(config, theme=theme, session=session)
932 theme_path = config.get(
933 f"wuttaweb.theme.{theme}", default=f"wuttaweb:templates/themes/{theme}"
934 )
935 return resource_path(theme_path)
938def set_app_theme(request, theme, session=None):
939 """
940 Set the effective theme for the running app.
942 This will modify the *global* Mako template lookup directories,
943 i.e. app templates will change for all users immediately.
945 This will first validate the theme by calling
946 :func:`get_effective_theme()`. It then retrieves the template
947 path via :func:`get_theme_template_path()`.
949 The theme template path is then injected into the app settings
950 registry such that it overrides the Mako lookup directories.
952 It also will persist the theme name within db settings, so as to
953 ensure it survives app restart.
954 """
955 config = request.wutta_config
956 app = config.get_app()
958 theme = get_effective_theme(config, theme=theme, session=session)
959 theme_path = get_theme_template_path(config, theme=theme, session=session)
961 # there's only one global template lookup; can get to it via any renderer
962 # but should *not* use /base.mako since that one is about to get volatile
963 renderer = get_renderer("/page.mako")
964 lookup = renderer.lookup
966 # overwrite first entry in lookup's directory list
967 lookup.directories[0] = theme_path
969 # clear template cache for lookup object, so it will reload each (as needed)
970 lookup._collection.clear() # pylint: disable=protected-access
972 # persist current theme in db settings
973 with app.short_session(session=session) as s:
974 app.save_setting(s, "wuttaweb.theme", theme)
976 # and cache in live app settings
977 request.registry.settings["wuttaweb.theme"] = theme