Coverage for .tox / coverage / lib / python3.11 / site-packages / wuttaweb / util.py: 100%
310 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-10 11:22 -0500
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-10 11:22 -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( # pylint: disable=too-many-return-statements,too-many-branches
144 request,
145 key,
146 configured_only=False,
147 default_only=False,
148 prefix="wuttaweb",
149):
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 return None
313def get_liburl(
314 request,
315 key,
316 configured_only=False,
317 default_only=False,
318 prefix="wuttaweb",
319): # pylint: disable=too-many-return-statements,too-many-branches,too-many-statements
320 """
321 Return the appropriate URL for the web resource library identified
322 by ``key``.
324 WuttaWeb makes certain assumptions about which libraries would be
325 used on the frontend, and which versions for each would be used by
326 default. But ultimately a URL must be determined for each, hence
327 this function.
329 Each library has a built-in default URL which references a public
330 Internet (i.e. CDN) resource, but your config can override the
331 final URL in two ways:
333 The simplest way is to just override the *version* but otherwise
334 let the default logic construct the URL. See :func:`get_libver()`
335 for more on that approach.
337 The most flexible way is to override the URL explicitly, e.g.:
339 .. code-block:: ini
341 [wuttaweb]
342 liburl.bb_vue = https://example.com/cache/vue-3.4.31.js
344 :param request: Current request.
346 :param key: Unique key for the library, as string. Possibilities
347 are:
349 Vue 2 + Buefy
351 * ``vue``
352 * ``vue_resource``
353 * ``buefy``
354 * ``buefy_css``
355 * ``fontawesome``
357 Vue 3 + Oruga
359 * ``bb_vue``
360 * ``bb_oruga``
361 * ``bb_oruga_bulma``
362 * ``bb_oruga_bulma_css``
363 * ``bb_fontawesome_svg_core``
364 * ``bb_free_solid_svg_icons``
365 * ``bb_vue_fontawesome``
367 :param configured_only: Pass ``True`` here if you only want the
368 configured URL and ignore the default URL.
370 :param default_only: Pass ``True`` here if you only want the
371 default URL and ignore the configured URL.
373 :param prefix: If specified, will override the prefix used for
374 config lookups.
376 .. warning::
378 This ``prefix`` param is for backward compatibility and may
379 be removed in the future.
381 :returns: The appropriate URL as string. Can also return ``None``
382 in some cases.
383 """
384 config = request.wutta_config
386 if key == "buefy.css":
387 warnings.warn(
388 "liburl key 'buefy.css' is deprecated; please use 'buefy_css' instead",
389 DeprecationWarning,
390 stacklevel=2,
391 )
392 key = "buefy_css"
394 if not default_only:
396 # nb. new/preferred setting
397 url = config.get(f"wuttaweb.liburl.{key}")
398 if url:
399 return url
401 # maybe try deprecated key for buefy.css
402 if key == "buefy_css":
403 version = config.get("wuttaweb.liburl.buefy.css")
404 if version:
405 warnings.warn(
406 "config for wuttaweb.liburl.buefy.css is deprecated; "
407 "please set wuttaweb.liburl.buefy_css instead",
408 DeprecationWarning,
409 )
410 return version
412 # fallback to caller-specified prefix
413 url = config.get(f"{prefix}.liburl.{key}")
414 if url:
415 warnings.warn(
416 f"config for {prefix}.liburl.{key} is deprecated; "
417 f"please set wuttaweb.liburl.{key} instead",
418 DeprecationWarning,
419 )
420 return url
422 # maybe try deprecated key for buefy.css
423 if key == "buefy_css":
424 version = config.get(f"{prefix}.liburl.buefy.css")
425 if version:
426 warnings.warn(
427 f"config for {prefix}.liburl.buefy.css is deprecated; "
428 "please set wuttaweb.liburl.buefy_css instead",
429 DeprecationWarning,
430 )
431 return version
433 if configured_only:
434 return None
436 version = get_libver(
437 request, key, prefix=prefix, configured_only=False, default_only=default_only
438 )
440 # load fanstatic libcache if configured
441 static = config.get("wuttaweb.static_libcache.module")
442 if not static:
443 static = config.get(f"{prefix}.static_libcache.module")
444 if static:
445 warnings.warn(
446 f"config for {prefix}.static_libcache.module is deprecated; "
447 "please set wuttaweb.static_libcache.module instead",
448 DeprecationWarning,
449 )
450 if static:
451 static = importlib.import_module(static)
452 needed = request.environ["fanstatic.needed"]
453 liburl = needed.library_url(static.libcache) + "/"
454 # nb. add custom url prefix if needed, e.g. /wutta
455 if request.script_name:
456 liburl = request.script_name + liburl
458 if key == "buefy":
459 if static and hasattr(static, "buefy_js"):
460 return liburl + static.buefy_js.relpath
461 return f"https://unpkg.com/buefy@{version}/dist/buefy.min.js"
463 if key == "buefy_css":
464 if static and hasattr(static, "buefy_css"):
465 return liburl + static.buefy_css.relpath
466 return f"https://unpkg.com/buefy@{version}/dist/buefy.min.css"
468 if key == "vue":
469 if static and hasattr(static, "vue_js"):
470 return liburl + static.vue_js.relpath
471 return f"https://unpkg.com/vue@{version}/dist/vue.min.js"
473 if key == "vue_resource":
474 if static and hasattr(static, "vue_resource_js"):
475 return liburl + static.vue_resource_js.relpath
476 return f"https://cdn.jsdelivr.net/npm/vue-resource@{version}"
478 if key == "fontawesome":
479 if static and hasattr(static, "fontawesome_js"):
480 return liburl + static.fontawesome_js.relpath
481 return f"https://use.fontawesome.com/releases/v{version}/js/all.js"
483 if key == "bb_vue":
484 if static and hasattr(static, "bb_vue_js"):
485 return liburl + static.bb_vue_js.relpath
486 return f"https://unpkg.com/vue@{version}/dist/vue.esm-browser.prod.js"
488 if key == "bb_oruga":
489 if static and hasattr(static, "bb_oruga_js"):
490 return liburl + static.bb_oruga_js.relpath
491 return f"https://unpkg.com/@oruga-ui/oruga-next@{version}/dist/oruga.mjs"
493 if key == "bb_oruga_bulma":
494 if static and hasattr(static, "bb_oruga_bulma_js"):
495 return liburl + static.bb_oruga_bulma_js.relpath
496 return f"https://unpkg.com/@oruga-ui/theme-bulma@{version}/dist/bulma.js"
498 if key == "bb_oruga_bulma_css":
499 if static and hasattr(static, "bb_oruga_bulma_css"):
500 return liburl + static.bb_oruga_bulma_css.relpath
501 return f"https://unpkg.com/@oruga-ui/theme-bulma@{version}/dist/bulma.css"
503 if key == "bb_fontawesome_svg_core":
504 if static and hasattr(static, "bb_fontawesome_svg_core_js"):
505 return liburl + static.bb_fontawesome_svg_core_js.relpath
506 return f"https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-svg-core@{version}/+esm"
508 if key == "bb_free_solid_svg_icons":
509 if static and hasattr(static, "bb_free_solid_svg_icons_js"):
510 return liburl + static.bb_free_solid_svg_icons_js.relpath
511 return f"https://cdn.jsdelivr.net/npm/@fortawesome/free-solid-svg-icons@{version}/+esm"
513 if key == "bb_vue_fontawesome":
514 if static and hasattr(static, "bb_vue_fontawesome_js"):
515 return liburl + static.bb_vue_fontawesome_js.relpath
516 return (
517 f"https://cdn.jsdelivr.net/npm/@fortawesome/vue-fontawesome@{version}/+esm"
518 )
520 return None
523def get_csrf_token(request):
524 """
525 Convenience function, returns the effective CSRF token (raw
526 string) for the given request.
528 See also :func:`render_csrf_token()`.
529 """
530 token = request.session.get_csrf_token()
531 if token is None:
532 token = request.session.new_csrf_token()
533 return token
536def render_csrf_token(request, name="_csrf"):
537 """
538 Convenience function, returns CSRF hidden input inside hidden div,
539 e.g.:
541 .. code-block:: html
543 <div style="display: none;">
544 <input type="hidden" name="_csrf" value="TOKEN" />
545 </div>
547 This function is part of :mod:`wuttaweb.helpers` (as
548 :func:`~wuttaweb.helpers.csrf_token()`) which means you can do
549 this in page templates:
551 .. code-block:: mako
553 ${h.form(request.current_route_url())}
554 ${h.csrf_token(request)}
555 <!-- other fields etc. -->
556 ${h.end_form()}
558 See also :func:`get_csrf_token()`.
559 """
560 token = get_csrf_token(request)
561 return HTML.tag(
562 "div", tags.hidden(name, value=token, id=None), style="display:none;"
563 )
566def get_model_fields(config, model_class, include_fk=False):
567 """
568 Convenience function to return a list of field names for the given
569 :term:`data model` class.
571 This logic only supports SQLAlchemy mapped classes and will use
572 that to determine the field listing if applicable. Otherwise this
573 returns ``None``.
575 :param config: App :term:`config object`.
577 :param model_class: Data model class.
579 :param include_fk: Whether to include foreign key column names in
580 the result. They are excluded by default, since the
581 relationship names are also included and generally preferred.
583 :returns: List of field names, or ``None`` if it could not be
584 determined.
585 """
586 try:
587 mapper = sa.inspect(model_class)
588 except sa.exc.NoInspectionAvailable:
589 return None
591 if include_fk:
592 fields = [prop.key for prop in mapper.iterate_properties]
593 else:
594 fields = [
595 prop.key
596 for prop in mapper.iterate_properties
597 if not prop_is_fk(mapper, prop)
598 ]
600 # nb. we never want the continuum 'versions' prop
601 app = config.get_app()
602 if app.continuum_is_enabled() and "versions" in fields:
603 fields.remove("versions")
605 return fields
608def prop_is_fk(mapper, prop): # pylint: disable=empty-docstring
609 """ """
610 if not isinstance(prop, orm.ColumnProperty):
611 return False
613 prop_columns = [col.name for col in prop.columns]
614 for rel in mapper.relationships:
615 rel_columns = [col.name for col in rel.local_columns]
616 if rel_columns == prop_columns:
617 return True
619 return False
622def make_json_safe(value, key=None, warn=True): # pylint: disable=too-many-branches
623 """
624 Convert a Python value as needed, to ensure it is compatible with
625 :func:`python:json.dumps()`.
627 :param value: Python value.
629 :param key: Optional key for the value, if known. This is used
630 when logging warnings, if applicable.
632 :param warn: Whether warnings should be logged if the value is not
633 already JSON-compatible.
635 :returns: A (possibly new) Python value which is guaranteed to be
636 JSON-serializable.
637 """
639 # convert null => None
640 if value is colander.null:
641 return None
643 if isinstance(value, dict):
644 # recursively convert dict
645 parent = dict(value)
646 for k, v in parent.items():
647 parent[k] = make_json_safe(v, key=k, warn=warn)
648 value = parent
650 elif isinstance(value, list):
651 # recursively convert list
652 parent = list(value)
653 for i, v in enumerate(parent):
654 parent[i] = make_json_safe(v, key=key, warn=warn)
655 value = parent
657 elif isinstance(value, set):
658 # recursively convert set (as list)
659 parent = list(value)
660 for i, v in enumerate(parent):
661 parent[i] = make_json_safe(v, key=key, warn=warn)
662 value = parent
664 elif isinstance(value, _uuid.UUID):
665 # convert UUID to str
666 value = value.hex
668 elif isinstance(value, decimal.Decimal):
669 # convert decimal to float
670 value = float(value)
672 # ensure JSON-compatibility, warn if problems
673 try:
674 json.dumps(value)
675 except TypeError:
676 if warn:
677 prefix = "value"
678 if key:
679 prefix += f" for '{key}'"
680 log.warning("%s is not json-friendly: %s", prefix, repr(value))
681 value = str(value)
682 if warn:
683 log.warning("forced value to: %s", value)
685 return value
688def render_vue_finalize(vue_tagname, vue_component):
689 """
690 Render the Vue "finalize" script for a form or grid component.
692 This is a convenience for shared logic; it returns e.g.:
694 .. code-block:: html
696 <script>
697 WuttaGrid.data = function() { return WuttaGridData }
698 Vue.component('wutta-grid', WuttaGrid)
699 </script>
700 """
701 set_data = f"{vue_component}.data = function() {{ return {vue_component}Data }}"
702 make_component = f"Vue.component('{vue_tagname}', {vue_component})"
703 return HTML.tag(
704 "script",
705 c=["\n", HTML.literal(set_data), "\n", HTML.literal(make_component), "\n"],
706 )
709def make_users_grid(request, **kwargs):
710 """
711 Make and return a users (sub)grid.
713 This grid is shown for the Users field when viewing a Person or
714 Role, for instance. It is called by the following methods:
716 * :meth:`wuttaweb.views.people.PersonView.make_users_grid()`
717 * :meth:`wuttaweb.views.roles.RoleView.make_users_grid()`
719 :returns: Fully configured :class:`~wuttaweb.grids.base.Grid`
720 instance.
721 """
722 config = request.wutta_config
723 app = config.get_app()
724 model = app.model
725 web = app.get_web_handler()
727 if "key" not in kwargs:
728 route_prefix = kwargs.pop("route_prefix")
729 kwargs["key"] = f"{route_prefix}.view.users"
731 kwargs.setdefault("model_class", model.User)
732 grid = web.make_grid(request, **kwargs)
734 if request.has_perm("users.view"):
736 def view_url(user, i): # pylint: disable=unused-argument
737 return request.route_url("users.view", uuid=user.uuid)
739 grid.add_action("view", icon="eye", url=view_url)
740 grid.set_link("person")
741 grid.set_link("username")
743 if request.has_perm("users.edit"):
745 def edit_url(user, i): # pylint: disable=unused-argument
746 return request.route_url("users.edit", uuid=user.uuid)
748 grid.add_action("edit", url=edit_url)
750 return grid
753##############################
754# theme functions
755##############################
758def get_available_themes(config):
759 """
760 Returns the official list of theme names which are available for
761 use in the app. Privileged users may choose among these when
762 changing the global theme.
764 If config specifies a list, that will be honored. Otherwise the
765 default list is: ``['default', 'butterfly']``
767 Note that the 'default' theme is Vue 2 + Buefy, while 'butterfly'
768 is Vue 3 + Oruga.
770 You can specify via config by setting e.g.:
772 .. code-block:: ini
774 [wuttaweb]
775 themes.keys = default, butterfly, my-other-one
777 :param config: App :term:`config object`.
778 """
779 # get available list from config, if it has one
780 available = config.get_list(
781 "wuttaweb.themes.keys", default=["default", "butterfly"]
782 )
784 # sort the list by name
785 available.sort()
787 # make default theme the first option
788 if "default" in available:
789 available.remove("default")
790 available.insert(0, "default")
792 return available
795def get_effective_theme(config, theme=None, session=None):
796 """
797 Validate and return the "effective" theme.
799 If caller specifies a ``theme`` then it will be returned (if
800 "available" - see below).
802 Otherwise the current theme will be read from db setting. (Note
803 we do not read simply from config object, we always read from db
804 setting - this allows for the theme setting to change dynamically
805 while app is running.)
807 In either case if the theme is not listed in
808 :func:`get_available_themes()` then a ``ValueError`` is raised.
810 :param config: App :term:`config object`.
812 :param theme: Optional name of desired theme, instead of getting
813 current theme per db setting.
815 :param session: Optional :term:`db session`.
817 :returns: Name of theme.
818 """
819 app = config.get_app()
821 if not theme:
822 with app.short_session(session=session) as s:
823 theme = app.get_setting(s, "wuttaweb.theme") or "default"
825 # confirm requested theme is available
826 available = get_available_themes(config)
827 if theme not in available:
828 raise ValueError(f"theme not available: {theme}")
830 return theme
833def get_theme_template_path(config, theme=None, session=None):
834 """
835 Return the template path for effective theme.
837 If caller specifies a ``theme`` then it will be used; otherwise
838 the current theme will be read from db setting. The logic for
839 that happens in :func:`get_effective_theme()`, which this function
840 will call first.
842 Once we have the valid theme name, we check config in case it
843 specifies a template path override for it. But if not, a default
844 template path is assumed.
846 The default path would be expected to live under
847 ``wuttaweb:templates/themes``; for instance the ``butterfly``
848 theme has a default template path of
849 ``wuttaweb:templates/themes/butterfly``.
851 :param config: App :term:`config object`.
853 :param theme: Optional name of desired theme, instead of getting
854 current theme per db setting.
856 :param session: Optional :term:`db session`.
858 :returns: Path on disk to theme template folder.
859 """
860 theme = get_effective_theme(config, theme=theme, session=session)
861 theme_path = config.get(
862 f"wuttaweb.theme.{theme}", default=f"wuttaweb:templates/themes/{theme}"
863 )
864 return resource_path(theme_path)
867def set_app_theme(request, theme, session=None):
868 """
869 Set the effective theme for the running app.
871 This will modify the *global* Mako template lookup directories,
872 i.e. app templates will change for all users immediately.
874 This will first validate the theme by calling
875 :func:`get_effective_theme()`. It then retrieves the template
876 path via :func:`get_theme_template_path()`.
878 The theme template path is then injected into the app settings
879 registry such that it overrides the Mako lookup directories.
881 It also will persist the theme name within db settings, so as to
882 ensure it survives app restart.
883 """
884 config = request.wutta_config
885 app = config.get_app()
887 theme = get_effective_theme(config, theme=theme, session=session)
888 theme_path = get_theme_template_path(config, theme=theme, session=session)
890 # there's only one global template lookup; can get to it via any renderer
891 # but should *not* use /base.mako since that one is about to get volatile
892 renderer = get_renderer("/page.mako")
893 lookup = renderer.lookup
895 # overwrite first entry in lookup's directory list
896 lookup.directories[0] = theme_path
898 # clear template cache for lookup object, so it will reload each (as needed)
899 lookup._collection.clear() # pylint: disable=protected-access
901 # persist current theme in db settings
902 with app.short_session(session=session) as s:
903 app.save_setting(s, "wuttaweb.theme", theme)
905 # and cache in live app settings
906 request.registry.settings["wuttaweb.theme"] = theme