Coverage for .tox / coverage / lib / python3.11 / site-packages / wuttaweb / util.py: 100%
279 statements
« prev ^ index » next coverage.py v7.13.1, created at 2025-12-28 15:23 -0600
« prev ^ index » next coverage.py v7.13.1, created at 2025-12-28 15:23 -0600
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 # nb. we prefer a setting to be named like: wuttaweb.libver.vue
192 # but for back-compat this also can work: tailbone.libver.vue
193 # and for more back-compat this can work: wuttaweb.vue_version
194 # however that compat only works for some of the settings...
196 if not default_only:
198 # nb. new/preferred setting
199 version = config.get(f"wuttaweb.libver.{key}")
200 if version:
201 return version
203 # fallback to caller-specified prefix
204 if prefix != "wuttaweb":
205 version = config.get(f"{prefix}.libver.{key}")
206 if version:
207 warnings.warn(
208 f"config for {prefix}.libver.{key} is deprecated; "
209 f"please set wuttaweb.libver.{key} instead",
210 DeprecationWarning,
211 )
212 return version
214 if key == "buefy":
215 if not default_only:
216 # nb. old/legacy setting
217 version = config.get(f"{prefix}.buefy_version")
218 if version:
219 warnings.warn(
220 f"config for {prefix}.buefy_version is deprecated; "
221 "please set wuttaweb.libver.buefy instead",
222 DeprecationWarning,
223 )
224 return version
225 if not configured_only:
226 return "0.9.25"
228 elif key == "buefy.css":
229 # nb. this always returns something
230 return get_libver(
231 request, "buefy", default_only=default_only, configured_only=configured_only
232 )
234 elif key == "vue":
235 if not default_only:
236 # nb. old/legacy setting
237 version = config.get(f"{prefix}.vue_version")
238 if version:
239 warnings.warn(
240 f"config for {prefix}.vue_version is deprecated; "
241 "please set wuttaweb.libver.vue instead",
242 DeprecationWarning,
243 )
244 return version
245 if not configured_only:
246 return "2.6.14"
248 elif key == "vue_resource":
249 if not configured_only:
250 return "1.5.3"
252 elif key == "fontawesome":
253 if not configured_only:
254 return "5.3.1"
256 elif key == "bb_vue":
257 if not configured_only:
258 return "3.5.18"
260 elif key == "bb_oruga":
261 if not configured_only:
262 return "0.11.4"
264 elif key in ("bb_oruga_bulma", "bb_oruga_bulma_css"):
265 if not configured_only:
266 return "0.7.3"
268 elif key == "bb_fontawesome_svg_core":
269 if not configured_only:
270 return "7.0.0"
272 elif key == "bb_free_solid_svg_icons":
273 if not configured_only:
274 return "7.0.0"
276 elif key == "bb_vue_fontawesome":
277 if not configured_only:
278 return "3.1.1"
280 return None
283def get_liburl( # pylint: disable=too-many-return-statements,too-many-branches
284 request,
285 key,
286 configured_only=False,
287 default_only=False,
288 prefix="wuttaweb",
289):
290 """
291 Return the appropriate URL for the web resource library identified
292 by ``key``.
294 WuttaWeb makes certain assumptions about which libraries would be
295 used on the frontend, and which versions for each would be used by
296 default. But ultimately a URL must be determined for each, hence
297 this function.
299 Each library has a built-in default URL which references a public
300 Internet (i.e. CDN) resource, but your config can override the
301 final URL in two ways:
303 The simplest way is to just override the *version* but otherwise
304 let the default logic construct the URL. See :func:`get_libver()`
305 for more on that approach.
307 The most flexible way is to override the URL explicitly, e.g.:
309 .. code-block:: ini
311 [wuttaweb]
312 liburl.bb_vue = https://example.com/cache/vue-3.4.31.js
314 :param request: Current request.
316 :param key: Unique key for the library, as string. Possibilities
317 are:
319 Vue 2 + Buefy
321 * ``vue``
322 * ``vue_resource``
323 * ``buefy``
324 * ``buefy.css``
325 * ``fontawesome``
327 Vue 3 + Oruga
329 * ``bb_vue``
330 * ``bb_oruga``
331 * ``bb_oruga_bulma``
332 * ``bb_oruga_bulma_css``
333 * ``bb_fontawesome_svg_core``
334 * ``bb_free_solid_svg_icons``
335 * ``bb_vue_fontawesome``
337 :param configured_only: Pass ``True`` here if you only want the
338 configured URL and ignore the default URL.
340 :param default_only: Pass ``True`` here if you only want the
341 default URL and ignore the configured URL.
343 :param prefix: If specified, will override the prefix used for
344 config lookups.
346 .. warning::
348 This ``prefix`` param is for backward compatibility and may
349 be removed in the future.
351 :returns: The appropriate URL as string. Can also return ``None``
352 in some cases.
353 """
354 config = request.wutta_config
356 if not default_only:
358 # nb. new/preferred setting
359 url = config.get(f"wuttaweb.liburl.{key}")
360 if url:
361 return url
363 # fallback to caller-specified prefix
364 url = config.get(f"{prefix}.liburl.{key}")
365 if url:
366 warnings.warn(
367 f"config for {prefix}.liburl.{key} is deprecated; "
368 f"please set wuttaweb.liburl.{key} instead",
369 DeprecationWarning,
370 )
371 return url
373 if configured_only:
374 return None
376 version = get_libver(
377 request, key, prefix=prefix, configured_only=False, default_only=default_only
378 )
380 # load fanstatic libcache if configured
381 static = config.get("wuttaweb.static_libcache.module")
382 if not static:
383 static = config.get(f"{prefix}.static_libcache.module")
384 if static:
385 warnings.warn(
386 f"config for {prefix}.static_libcache.module is deprecated; "
387 "please set wuttaweb.static_libcache.module instead",
388 DeprecationWarning,
389 )
390 if static:
391 static = importlib.import_module(static)
392 needed = request.environ["fanstatic.needed"]
393 liburl = needed.library_url(static.libcache) + "/"
394 # nb. add custom url prefix if needed, e.g. /wutta
395 if request.script_name:
396 liburl = request.script_name + liburl
398 if key == "buefy":
399 if static and hasattr(static, "buefy_js"):
400 return liburl + static.buefy_js.relpath
401 return f"https://unpkg.com/buefy@{version}/dist/buefy.min.js"
403 if key == "buefy.css":
404 if static and hasattr(static, "buefy_css"):
405 return liburl + static.buefy_css.relpath
406 return f"https://unpkg.com/buefy@{version}/dist/buefy.min.css"
408 if key == "vue":
409 if static and hasattr(static, "vue_js"):
410 return liburl + static.vue_js.relpath
411 return f"https://unpkg.com/vue@{version}/dist/vue.min.js"
413 if key == "vue_resource":
414 if static and hasattr(static, "vue_resource_js"):
415 return liburl + static.vue_resource_js.relpath
416 return f"https://cdn.jsdelivr.net/npm/vue-resource@{version}"
418 if key == "fontawesome":
419 if static and hasattr(static, "fontawesome_js"):
420 return liburl + static.fontawesome_js.relpath
421 return f"https://use.fontawesome.com/releases/v{version}/js/all.js"
423 if key == "bb_vue":
424 if static and hasattr(static, "bb_vue_js"):
425 return liburl + static.bb_vue_js.relpath
426 return f"https://unpkg.com/vue@{version}/dist/vue.esm-browser.prod.js"
428 if key == "bb_oruga":
429 if static and hasattr(static, "bb_oruga_js"):
430 return liburl + static.bb_oruga_js.relpath
431 return f"https://unpkg.com/@oruga-ui/oruga-next@{version}/dist/oruga.mjs"
433 if key == "bb_oruga_bulma":
434 if static and hasattr(static, "bb_oruga_bulma_js"):
435 return liburl + static.bb_oruga_bulma_js.relpath
436 return f"https://unpkg.com/@oruga-ui/theme-bulma@{version}/dist/bulma.js"
438 if key == "bb_oruga_bulma_css":
439 if static and hasattr(static, "bb_oruga_bulma_css"):
440 return liburl + static.bb_oruga_bulma_css.relpath
441 return f"https://unpkg.com/@oruga-ui/theme-bulma@{version}/dist/bulma.css"
443 if key == "bb_fontawesome_svg_core":
444 if static and hasattr(static, "bb_fontawesome_svg_core_js"):
445 return liburl + static.bb_fontawesome_svg_core_js.relpath
446 return f"https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-svg-core@{version}/+esm"
448 if key == "bb_free_solid_svg_icons":
449 if static and hasattr(static, "bb_free_solid_svg_icons_js"):
450 return liburl + static.bb_free_solid_svg_icons_js.relpath
451 return f"https://cdn.jsdelivr.net/npm/@fortawesome/free-solid-svg-icons@{version}/+esm"
453 if key == "bb_vue_fontawesome":
454 if static and hasattr(static, "bb_vue_fontawesome_js"):
455 return liburl + static.bb_vue_fontawesome_js.relpath
456 return (
457 f"https://cdn.jsdelivr.net/npm/@fortawesome/vue-fontawesome@{version}/+esm"
458 )
460 return None
463def get_csrf_token(request):
464 """
465 Convenience function, returns the effective CSRF token (raw
466 string) for the given request.
468 See also :func:`render_csrf_token()`.
469 """
470 token = request.session.get_csrf_token()
471 if token is None:
472 token = request.session.new_csrf_token()
473 return token
476def render_csrf_token(request, name="_csrf"):
477 """
478 Convenience function, returns CSRF hidden input inside hidden div,
479 e.g.:
481 .. code-block:: html
483 <div style="display: none;">
484 <input type="hidden" name="_csrf" value="TOKEN" />
485 </div>
487 This function is part of :mod:`wuttaweb.helpers` (as
488 :func:`~wuttaweb.helpers.csrf_token()`) which means you can do
489 this in page templates:
491 .. code-block:: mako
493 ${h.form(request.current_route_url())}
494 ${h.csrf_token(request)}
495 <!-- other fields etc. -->
496 ${h.end_form()}
498 See also :func:`get_csrf_token()`.
499 """
500 token = get_csrf_token(request)
501 return HTML.tag(
502 "div", tags.hidden(name, value=token, id=None), style="display:none;"
503 )
506def get_model_fields(config, model_class, include_fk=False):
507 """
508 Convenience function to return a list of field names for the given
509 :term:`data model` class.
511 This logic only supports SQLAlchemy mapped classes and will use
512 that to determine the field listing if applicable. Otherwise this
513 returns ``None``.
515 :param config: App :term:`config object`.
517 :param model_class: Data model class.
519 :param include_fk: Whether to include foreign key column names in
520 the result. They are excluded by default, since the
521 relationship names are also included and generally preferred.
523 :returns: List of field names, or ``None`` if it could not be
524 determined.
525 """
526 try:
527 mapper = sa.inspect(model_class)
528 except sa.exc.NoInspectionAvailable:
529 return None
531 if include_fk:
532 fields = [prop.key for prop in mapper.iterate_properties]
533 else:
534 fields = [
535 prop.key
536 for prop in mapper.iterate_properties
537 if not prop_is_fk(mapper, prop)
538 ]
540 # nb. we never want the continuum 'versions' prop
541 app = config.get_app()
542 if app.continuum_is_enabled() and "versions" in fields:
543 fields.remove("versions")
545 return fields
548def prop_is_fk(mapper, prop): # pylint: disable=empty-docstring
549 """ """
550 if not isinstance(prop, orm.ColumnProperty):
551 return False
553 prop_columns = [col.name for col in prop.columns]
554 for rel in mapper.relationships:
555 rel_columns = [col.name for col in rel.local_columns]
556 if rel_columns == prop_columns:
557 return True
559 return False
562def make_json_safe(value, key=None, warn=True):
563 """
564 Convert a Python value as needed, to ensure it is compatible with
565 :func:`python:json.dumps()`.
567 :param value: Python value.
569 :param key: Optional key for the value, if known. This is used
570 when logging warnings, if applicable.
572 :param warn: Whether warnings should be logged if the value is not
573 already JSON-compatible.
575 :returns: A (possibly new) Python value which is guaranteed to be
576 JSON-serializable.
577 """
579 # convert null => None
580 if value is colander.null:
581 return None
583 if isinstance(value, dict):
584 # recursively convert dict
585 parent = dict(value)
586 for k, v in parent.items():
587 parent[k] = make_json_safe(v, key=k, warn=warn)
588 value = parent
590 elif isinstance(value, list):
591 # recursively convert list
592 parent = list(value)
593 for i, v in enumerate(parent):
594 parent[i] = make_json_safe(v, key=key, warn=warn)
595 value = parent
597 elif isinstance(value, _uuid.UUID):
598 # convert UUID to str
599 value = value.hex
601 elif isinstance(value, decimal.Decimal):
602 # convert decimal to float
603 value = float(value)
605 # ensure JSON-compatibility, warn if problems
606 try:
607 json.dumps(value)
608 except TypeError:
609 if warn:
610 prefix = "value"
611 if key:
612 prefix += f" for '{key}'"
613 log.warning("%s is not json-friendly: %s", prefix, repr(value))
614 value = str(value)
615 if warn:
616 log.warning("forced value to: %s", value)
618 return value
621def render_vue_finalize(vue_tagname, vue_component):
622 """
623 Render the Vue "finalize" script for a form or grid component.
625 This is a convenience for shared logic; it returns e.g.:
627 .. code-block:: html
629 <script>
630 WuttaGrid.data = function() { return WuttaGridData }
631 Vue.component('wutta-grid', WuttaGrid)
632 </script>
633 """
634 set_data = f"{vue_component}.data = function() {{ return {vue_component}Data }}"
635 make_component = f"Vue.component('{vue_tagname}', {vue_component})"
636 return HTML.tag(
637 "script",
638 c=["\n", HTML.literal(set_data), "\n", HTML.literal(make_component), "\n"],
639 )
642def make_users_grid(request, **kwargs):
643 """
644 Make and return a users (sub)grid.
646 This grid is shown for the Users field when viewing a Person or
647 Role, for instance. It is called by the following methods:
649 * :meth:`wuttaweb.views.people.PersonView.make_users_grid()`
650 * :meth:`wuttaweb.views.roles.RoleView.make_users_grid()`
652 :returns: Fully configured :class:`~wuttaweb.grids.base.Grid`
653 instance.
654 """
655 config = request.wutta_config
656 app = config.get_app()
657 model = app.model
658 web = app.get_web_handler()
660 if "key" not in kwargs:
661 route_prefix = kwargs.pop("route_prefix")
662 kwargs["key"] = f"{route_prefix}.view.users"
664 kwargs.setdefault("model_class", model.User)
665 grid = web.make_grid(request, **kwargs)
667 if request.has_perm("users.view"):
669 def view_url(user, i): # pylint: disable=unused-argument
670 return request.route_url("users.view", uuid=user.uuid)
672 grid.add_action("view", icon="eye", url=view_url)
673 grid.set_link("person")
674 grid.set_link("username")
676 if request.has_perm("users.edit"):
678 def edit_url(user, i): # pylint: disable=unused-argument
679 return request.route_url("users.edit", uuid=user.uuid)
681 grid.add_action("edit", url=edit_url)
683 return grid
686##############################
687# theme functions
688##############################
691def get_available_themes(config):
692 """
693 Returns the official list of theme names which are available for
694 use in the app. Privileged users may choose among these when
695 changing the global theme.
697 If config specifies a list, that will be honored. Otherwise the
698 default list is: ``['default', 'butterfly']``
700 Note that the 'default' theme is Vue 2 + Buefy, while 'butterfly'
701 is Vue 3 + Oruga.
703 You can specify via config by setting e.g.:
705 .. code-block:: ini
707 [wuttaweb]
708 themes.keys = default, butterfly, my-other-one
710 :param config: App :term:`config object`.
711 """
712 # get available list from config, if it has one
713 available = config.get_list(
714 "wuttaweb.themes.keys", default=["default", "butterfly"]
715 )
717 # sort the list by name
718 available.sort()
720 # make default theme the first option
721 if "default" in available:
722 available.remove("default")
723 available.insert(0, "default")
725 return available
728def get_effective_theme(config, theme=None, session=None):
729 """
730 Validate and return the "effective" theme.
732 If caller specifies a ``theme`` then it will be returned (if
733 "available" - see below).
735 Otherwise the current theme will be read from db setting. (Note
736 we do not read simply from config object, we always read from db
737 setting - this allows for the theme setting to change dynamically
738 while app is running.)
740 In either case if the theme is not listed in
741 :func:`get_available_themes()` then a ``ValueError`` is raised.
743 :param config: App :term:`config object`.
745 :param theme: Optional name of desired theme, instead of getting
746 current theme per db setting.
748 :param session: Optional :term:`db session`.
750 :returns: Name of theme.
751 """
752 app = config.get_app()
754 if not theme:
755 with app.short_session(session=session) as s:
756 theme = app.get_setting(s, "wuttaweb.theme") or "default"
758 # confirm requested theme is available
759 available = get_available_themes(config)
760 if theme not in available:
761 raise ValueError(f"theme not available: {theme}")
763 return theme
766def get_theme_template_path(config, theme=None, session=None):
767 """
768 Return the template path for effective theme.
770 If caller specifies a ``theme`` then it will be used; otherwise
771 the current theme will be read from db setting. The logic for
772 that happens in :func:`get_effective_theme()`, which this function
773 will call first.
775 Once we have the valid theme name, we check config in case it
776 specifies a template path override for it. But if not, a default
777 template path is assumed.
779 The default path would be expected to live under
780 ``wuttaweb:templates/themes``; for instance the ``butterfly``
781 theme has a default template path of
782 ``wuttaweb:templates/themes/butterfly``.
784 :param config: App :term:`config object`.
786 :param theme: Optional name of desired theme, instead of getting
787 current theme per db setting.
789 :param session: Optional :term:`db session`.
791 :returns: Path on disk to theme template folder.
792 """
793 theme = get_effective_theme(config, theme=theme, session=session)
794 theme_path = config.get(
795 f"wuttaweb.theme.{theme}", default=f"wuttaweb:templates/themes/{theme}"
796 )
797 return resource_path(theme_path)
800def set_app_theme(request, theme, session=None):
801 """
802 Set the effective theme for the running app.
804 This will modify the *global* Mako template lookup directories,
805 i.e. app templates will change for all users immediately.
807 This will first validate the theme by calling
808 :func:`get_effective_theme()`. It then retrieves the template
809 path via :func:`get_theme_template_path()`.
811 The theme template path is then injected into the app settings
812 registry such that it overrides the Mako lookup directories.
814 It also will persist the theme name within db settings, so as to
815 ensure it survives app restart.
816 """
817 config = request.wutta_config
818 app = config.get_app()
820 theme = get_effective_theme(config, theme=theme, session=session)
821 theme_path = get_theme_template_path(config, theme=theme, session=session)
823 # there's only one global template lookup; can get to it via any renderer
824 # but should *not* use /base.mako since that one is about to get volatile
825 renderer = get_renderer("/page.mako")
826 lookup = renderer.lookup
828 # overwrite first entry in lookup's directory list
829 lookup.directories[0] = theme_path
831 # clear template cache for lookup object, so it will reload each (as needed)
832 lookup._collection.clear() # pylint: disable=protected-access
834 # persist current theme in db settings
835 with app.short_session(session=session) as s:
836 app.save_setting(s, "wuttaweb.theme", theme)
838 # and cache in live app settings
839 request.registry.settings["wuttaweb.theme"] = theme