Coverage for .tox/coverage/lib/python3.11/site-packages/wuttaweb/util.py: 100%
218 statements
« prev ^ index » next coverage.py v7.6.10, created at 2024-12-28 21:19 -0600
« prev ^ index » next coverage.py v7.6.10, created at 2024-12-28 21:19 -0600
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# wuttaweb -- Web App for Wutta Framework
5# Copyright © 2024 Lance Edgar
6#
7# This file is part of Wutta Framework.
8#
9# Wutta Framework is free software: you can redistribute it and/or modify it
10# under the terms of the GNU General Public License as published by the Free
11# Software Foundation, either version 3 of the License, or (at your option) any
12# later version.
13#
14# Wutta Framework is distributed in the hope that it will be useful, but
15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
17# more details.
18#
19# You should have received a copy of the GNU General Public License along with
20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
21#
22################################################################################
23"""
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 webhelpers2.html import HTML, tags
41log = logging.getLogger(__name__)
44class FieldList(list):
45 """
46 Convenience wrapper for a form's field list. This is a subclass
47 of :class:`python:list`.
49 You normally would not need to instantiate this yourself, but it
50 is used under the hood for
51 :attr:`~wuttaweb.forms.base.Form.fields` as well as
52 :attr:`~wuttaweb.grids.base.Grid.columns`.
53 """
55 def insert_before(self, field, newfield):
56 """
57 Insert a new field, before an existing field.
59 :param field: String name for the existing field.
61 :param newfield: String name for the new field, to be inserted
62 just before the existing ``field``.
63 """
64 if field in self:
65 i = self.index(field)
66 self.insert(i, newfield)
67 else:
68 log.warning("field '%s' not found, will append new field: %s",
69 field, newfield)
70 self.append(newfield)
72 def insert_after(self, field, newfield):
73 """
74 Insert a new field, after an existing field.
76 :param field: String name for the existing field.
78 :param newfield: String name for the new field, to be inserted
79 just after the existing ``field``.
80 """
81 if field in self:
82 i = self.index(field)
83 self.insert(i + 1, newfield)
84 else:
85 log.warning("field '%s' not found, will append new field: %s",
86 field, newfield)
87 self.append(newfield)
89 def set_sequence(self, fields):
90 """
91 Sort the list such that it matches the same sequence as the
92 given fields list.
94 This does not add or remove any elements, it just
95 (potentially) rearranges the internal list elements.
96 Therefore you do not need to explicitly declare *all* fields;
97 just the ones you care about.
99 The resulting field list will have the requested fields in
100 order, at the *beginning* of the list. Any unrequested fields
101 will remain in the same order as they were previously, but
102 will be placed *after* the requested fields.
104 :param fields: List of fields in the desired order.
105 """
106 unimportant = len(self) + 1
108 def getkey(field):
109 if field in fields:
110 return fields.index(field)
111 return unimportant
113 self.sort(key=getkey)
116def get_form_data(request):
117 """
118 Returns the effective form data for the given request.
120 Mostly this is a convenience, which simply returns one of the
121 following, depending on various attributes of the request.
123 * :attr:`pyramid:pyramid.request.Request.POST`
124 * :attr:`pyramid:pyramid.request.Request.json_body`
125 """
126 # nb. we prefer JSON only if no POST is present
127 # TODO: this seems to work for our use case at least, but perhaps
128 # there is a better way? see also
129 # https://docs.pylonsproject.org/projects/pyramid/en/latest/api/request.html#pyramid.request.Request.is_xhr
130 if not request.POST and (
131 getattr(request, 'is_xhr', False)
132 or getattr(request, 'content_type', None) == 'application/json'):
133 return request.json_body
134 return request.POST
137def get_libver(
138 request,
139 key,
140 configured_only=False,
141 default_only=False,
142 prefix='wuttaweb',
143):
144 """
145 Return the appropriate version string for the web resource library
146 identified by ``key``.
148 WuttaWeb makes certain assumptions about which libraries would be
149 used on the frontend, and which versions for each would be used by
150 default. But it should also be possible to customize which
151 versions are used, hence this function.
153 Each library has a built-in default version but your config can
154 override them, e.g.:
156 .. code-block:: ini
158 [wuttaweb]
159 libver.bb_vue = 3.4.29
161 :param request: Current request.
163 :param key: Unique key for the library, as string. Possibilities
164 are the same as for :func:`get_liburl()`.
166 :param configured_only: Pass ``True`` here if you only want the
167 configured version and ignore the default version.
169 :param default_only: Pass ``True`` here if you only want the
170 default version and ignore the configured version.
172 :param prefix: If specified, will override the prefix used for
173 config lookups.
175 .. warning::
177 This ``prefix`` param is for backward compatibility and may
178 be removed in the future.
180 :returns: The appropriate version string, e.g. ``'1.2.3'`` or
181 ``'latest'`` etc. Can also return ``None`` in some cases.
182 """
183 config = request.wutta_config
185 # nb. we prefer a setting to be named like: wuttaweb.libver.vue
186 # but for back-compat this also can work: tailbone.libver.vue
187 # and for more back-compat this can work: wuttaweb.vue_version
188 # however that compat only works for some of the settings...
190 if not default_only:
192 # nb. new/preferred setting
193 version = config.get(f'wuttaweb.libver.{key}')
194 if version:
195 return version
197 # fallback to caller-specified prefix
198 if prefix != 'wuttaweb':
199 version = config.get(f'{prefix}.libver.{key}')
200 if version:
201 warnings.warn(f"config for {prefix}.libver.{key} is deprecated; "
202 f"please set wuttaweb.libver.{key} instead",
203 DeprecationWarning)
204 return version
206 if key == 'buefy':
207 if not default_only:
208 # nb. old/legacy setting
209 version = config.get(f'{prefix}.buefy_version')
210 if version:
211 warnings.warn(f"config for {prefix}.buefy_version is deprecated; "
212 "please set wuttaweb.libver.buefy instead",
213 DeprecationWarning)
214 return version
215 if not configured_only:
216 return '0.9.25'
218 elif key == 'buefy.css':
219 # nb. this always returns something
220 return get_libver(request, 'buefy',
221 default_only=default_only,
222 configured_only=configured_only)
224 elif key == 'vue':
225 if not default_only:
226 # nb. old/legacy setting
227 version = config.get(f'{prefix}.vue_version')
228 if version:
229 warnings.warn(f"config for {prefix}.vue_version is deprecated; "
230 "please set wuttaweb.libver.vue instead",
231 DeprecationWarning)
232 return version
233 if not configured_only:
234 return '2.6.14'
236 elif key == 'vue_resource':
237 if not configured_only:
238 return '1.5.3'
240 elif key == 'fontawesome':
241 if not configured_only:
242 return '5.3.1'
244 elif key == 'bb_vue':
245 if not configured_only:
246 return '3.4.31'
248 elif key == 'bb_oruga':
249 if not configured_only:
250 return '0.8.12'
252 elif key in ('bb_oruga_bulma', 'bb_oruga_bulma_css'):
253 if not configured_only:
254 return '0.3.0'
256 elif key == 'bb_fontawesome_svg_core':
257 if not configured_only:
258 return '6.5.2'
260 elif key == 'bb_free_solid_svg_icons':
261 if not configured_only:
262 return '6.5.2'
264 elif key == 'bb_vue_fontawesome':
265 if not configured_only:
266 return '3.0.6'
269def get_liburl(
270 request,
271 key,
272 configured_only=False,
273 default_only=False,
274 prefix='wuttaweb',
275):
276 """
277 Return the appropriate URL for the web resource library identified
278 by ``key``.
280 WuttaWeb makes certain assumptions about which libraries would be
281 used on the frontend, and which versions for each would be used by
282 default. But ultimately a URL must be determined for each, hence
283 this function.
285 Each library has a built-in default URL which references a public
286 Internet (i.e. CDN) resource, but your config can override the
287 final URL in two ways:
289 The simplest way is to just override the *version* but otherwise
290 let the default logic construct the URL. See :func:`get_libver()`
291 for more on that approach.
293 The most flexible way is to override the URL explicitly, e.g.:
295 .. code-block:: ini
297 [wuttaweb]
298 liburl.bb_vue = https://example.com/cache/vue-3.4.31.js
300 :param request: Current request.
302 :param key: Unique key for the library, as string. Possibilities
303 are:
305 Vue 2 + Buefy
307 * ``vue``
308 * ``vue_resource``
309 * ``buefy``
310 * ``buefy.css``
311 * ``fontawesome``
313 Vue 3 + Oruga
315 * ``bb_vue``
316 * ``bb_oruga``
317 * ``bb_oruga_bulma``
318 * ``bb_oruga_bulma_css``
319 * ``bb_fontawesome_svg_core``
320 * ``bb_free_solid_svg_icons``
321 * ``bb_vue_fontawesome``
323 :param configured_only: Pass ``True`` here if you only want the
324 configured URL and ignore the default URL.
326 :param default_only: Pass ``True`` here if you only want the
327 default URL and ignore the configured URL.
329 :param prefix: If specified, will override the prefix used for
330 config lookups.
332 .. warning::
334 This ``prefix`` param is for backward compatibility and may
335 be removed in the future.
337 :returns: The appropriate URL as string. Can also return ``None``
338 in some cases.
339 """
340 config = request.wutta_config
342 if not default_only:
344 # nb. new/preferred setting
345 url = config.get(f'wuttaweb.liburl.{key}')
346 if url:
347 return url
349 # fallback to caller-specified prefix
350 url = config.get(f'{prefix}.liburl.{key}')
351 if url:
352 warnings.warn(f"config for {prefix}.liburl.{key} is deprecated; "
353 f"please set wuttaweb.liburl.{key} instead",
354 DeprecationWarning)
355 return url
357 if configured_only:
358 return
360 version = get_libver(request, key, prefix=prefix,
361 configured_only=False,
362 default_only=default_only)
364 # load fanstatic libcache if configured
365 static = config.get('wuttaweb.static_libcache.module')
366 if not static:
367 static = config.get(f'{prefix}.static_libcache.module')
368 if static:
369 warnings.warn(f"config for {prefix}.static_libcache.module is deprecated; "
370 "please set wuttaweb.static_libcache.module instead",
371 DeprecationWarning)
372 if static:
373 static = importlib.import_module(static)
374 needed = request.environ['fanstatic.needed']
375 liburl = needed.library_url(static.libcache) + '/'
376 # nb. add custom url prefix if needed, e.g. /wutta
377 if request.script_name:
378 liburl = request.script_name + liburl
380 if key == 'buefy':
381 if static and hasattr(static, 'buefy_js'):
382 return liburl + static.buefy_js.relpath
383 return f'https://unpkg.com/buefy@{version}/dist/buefy.min.js'
385 elif key == 'buefy.css':
386 if static and hasattr(static, 'buefy_css'):
387 return liburl + static.buefy_css.relpath
388 return f'https://unpkg.com/buefy@{version}/dist/buefy.min.css'
390 elif key == 'vue':
391 if static and hasattr(static, 'vue_js'):
392 return liburl + static.vue_js.relpath
393 return f'https://unpkg.com/vue@{version}/dist/vue.min.js'
395 elif key == 'vue_resource':
396 if static and hasattr(static, 'vue_resource_js'):
397 return liburl + static.vue_resource_js.relpath
398 return f'https://cdn.jsdelivr.net/npm/vue-resource@{version}'
400 elif key == 'fontawesome':
401 if static and hasattr(static, 'fontawesome_js'):
402 return liburl + static.fontawesome_js.relpath
403 return f'https://use.fontawesome.com/releases/v{version}/js/all.js'
405 elif key == 'bb_vue':
406 if static and hasattr(static, 'bb_vue_js'):
407 return liburl + static.bb_vue_js.relpath
408 return f'https://unpkg.com/vue@{version}/dist/vue.esm-browser.prod.js'
410 elif key == 'bb_oruga':
411 if static and hasattr(static, 'bb_oruga_js'):
412 return liburl + static.bb_oruga_js.relpath
413 return f'https://unpkg.com/@oruga-ui/oruga-next@{version}/dist/oruga.mjs'
415 elif key == 'bb_oruga_bulma':
416 if static and hasattr(static, 'bb_oruga_bulma_js'):
417 return liburl + static.bb_oruga_bulma_js.relpath
418 return f'https://unpkg.com/@oruga-ui/theme-bulma@{version}/dist/bulma.mjs'
420 elif key == 'bb_oruga_bulma_css':
421 if static and hasattr(static, 'bb_oruga_bulma_css'):
422 return liburl + static.bb_oruga_bulma_css.relpath
423 return f'https://unpkg.com/@oruga-ui/theme-bulma@{version}/dist/bulma.css'
425 elif key == 'bb_fontawesome_svg_core':
426 if static and hasattr(static, 'bb_fontawesome_svg_core_js'):
427 return liburl + static.bb_fontawesome_svg_core_js.relpath
428 return f'https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-svg-core@{version}/+esm'
430 elif key == 'bb_free_solid_svg_icons':
431 if static and hasattr(static, 'bb_free_solid_svg_icons_js'):
432 return liburl + static.bb_free_solid_svg_icons_js.relpath
433 return f'https://cdn.jsdelivr.net/npm/@fortawesome/free-solid-svg-icons@{version}/+esm'
435 elif key == 'bb_vue_fontawesome':
436 if static and hasattr(static, 'bb_vue_fontawesome_js'):
437 return liburl + static.bb_vue_fontawesome_js.relpath
438 return f'https://cdn.jsdelivr.net/npm/@fortawesome/vue-fontawesome@{version}/+esm'
441def get_csrf_token(request):
442 """
443 Convenience function, returns the effective CSRF token (raw
444 string) for the given request.
446 See also :func:`render_csrf_token()`.
447 """
448 token = request.session.get_csrf_token()
449 if token is None:
450 token = request.session.new_csrf_token()
451 return token
454def render_csrf_token(request, name='_csrf'):
455 """
456 Convenience function, returns CSRF hidden input inside hidden div,
457 e.g.:
459 .. code-block:: html
461 <div style="display: none;">
462 <input type="hidden" name="_csrf" value="TOKEN" />
463 </div>
465 This function is part of :mod:`wuttaweb.helpers` (as
466 :func:`~wuttaweb.helpers.csrf_token()`) which means you can do
467 this in page templates:
469 .. code-block:: mako
471 ${h.form(request.current_route_url())}
472 ${h.csrf_token(request)}
473 <!-- other fields etc. -->
474 ${h.end_form()}
476 See also :func:`get_csrf_token()`.
477 """
478 token = get_csrf_token(request)
479 return HTML.tag('div', tags.hidden(name, value=token, id=None), style='display:none;')
482def get_model_fields(config, model_class, include_fk=False):
483 """
484 Convenience function to return a list of field names for the given
485 :term:`data model` class.
487 This logic only supports SQLAlchemy mapped classes and will use
488 that to determine the field listing if applicable. Otherwise this
489 returns ``None``.
491 :param config: App :term:`config object`.
493 :param model_class: Data model class.
495 :param include_fk: Whether to include foreign key column names in
496 the result. They are excluded by default, since the
497 relationship names are also included and generally preferred.
499 :returns: List of field names, or ``None`` if it could not be
500 determined.
501 """
502 try:
503 mapper = sa.inspect(model_class)
504 except sa.exc.NoInspectionAvailable:
505 return
507 if include_fk:
508 fields = [prop.key for prop in mapper.iterate_properties]
509 else:
510 fields = [prop.key for prop in mapper.iterate_properties
511 if not prop_is_fk(mapper, prop)]
513 # nb. we never want the continuum 'versions' prop
514 app = config.get_app()
515 if app.continuum_is_enabled() and 'versions' in fields:
516 fields.remove('versions')
518 return fields
521def prop_is_fk(mapper, prop):
522 """ """
523 if not isinstance(prop, orm.ColumnProperty):
524 return False
526 prop_columns = [col.name for col in prop.columns]
527 for rel in mapper.relationships:
528 rel_columns = [col.name for col in rel.local_columns]
529 if rel_columns == prop_columns:
530 return True
532 return False
535def make_json_safe(value, key=None, warn=True):
536 """
537 Convert a Python value as needed, to ensure it is compatible with
538 :func:`python:json.dumps()`.
540 :param value: Python value.
542 :param key: Optional key for the value, if known. This is used
543 when logging warnings, if applicable.
545 :param warn: Whether warnings should be logged if the value is not
546 already JSON-compatible.
548 :returns: A (possibly new) Python value which is guaranteed to be
549 JSON-serializable.
550 """
552 # convert null => None
553 if value is colander.null:
554 return None
556 elif isinstance(value, dict):
557 # recursively convert dict
558 parent = dict(value)
559 for key, value in parent.items():
560 parent[key] = make_json_safe(value, key=key, warn=warn)
561 value = parent
563 elif isinstance(value, list):
564 # recursively convert list
565 parent = list(value)
566 for i, value in enumerate(parent):
567 parent[i] = make_json_safe(value, key=key, warn=warn)
568 value = parent
570 elif isinstance(value, _uuid.UUID):
571 # convert UUID to str
572 value = value.hex
574 elif isinstance(value, decimal.Decimal):
575 # convert decimal to float
576 value = float(value)
578 # ensure JSON-compatibility, warn if problems
579 try:
580 json.dumps(value)
581 except TypeError as error:
582 if warn:
583 prefix = "value"
584 if key:
585 prefix += f" for '{key}'"
586 log.warning("%s is not json-friendly: %s", prefix, repr(value))
587 value = str(value)
588 if warn:
589 log.warning("forced value to: %s", value)
591 return value