Coverage for .tox/coverage/lib/python3.11/site-packages/wuttaweb/util.py: 100%
197 statements
« prev ^ index » next coverage.py v7.6.1, created at 2024-11-19 20:23 -0600
« prev ^ index » next coverage.py v7.6.1, created at 2024-11-19 20:23 -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 importlib
28import json
29import logging
30import warnings
32import sqlalchemy as sa
34import colander
35from webhelpers2.html import HTML, tags
38log = logging.getLogger(__name__)
41class FieldList(list):
42 """
43 Convenience wrapper for a form's field list. This is a subclass
44 of :class:`python:list`.
46 You normally would not need to instantiate this yourself, but it
47 is used under the hood for
48 :attr:`~wuttaweb.forms.base.Form.fields` as well as
49 :attr:`~wuttaweb.grids.base.Grid.columns`.
50 """
52 def insert_before(self, field, newfield):
53 """
54 Insert a new field, before an existing field.
56 :param field: String name for the existing field.
58 :param newfield: String name for the new field, to be inserted
59 just before the existing ``field``.
60 """
61 if field in self:
62 i = self.index(field)
63 self.insert(i, newfield)
64 else:
65 log.warning("field '%s' not found, will append new field: %s",
66 field, newfield)
67 self.append(newfield)
69 def insert_after(self, field, newfield):
70 """
71 Insert a new field, after an existing field.
73 :param field: String name for the existing field.
75 :param newfield: String name for the new field, to be inserted
76 just after the existing ``field``.
77 """
78 if field in self:
79 i = self.index(field)
80 self.insert(i + 1, newfield)
81 else:
82 log.warning("field '%s' not found, will append new field: %s",
83 field, newfield)
84 self.append(newfield)
86 def set_sequence(self, fields):
87 """
88 Sort the list such that it matches the same sequence as the
89 given fields list.
91 This does not add or remove any elements, it just
92 (potentially) rearranges the internal list elements.
93 Therefore you do not need to explicitly declare *all* fields;
94 just the ones you care about.
96 The resulting field list will have the requested fields in
97 order, at the *beginning* of the list. Any unrequested fields
98 will remain in the same order as they were previously, but
99 will be placed *after* the requested fields.
101 :param fields: List of fields in the desired order.
102 """
103 unimportant = len(self) + 1
105 def getkey(field):
106 if field in fields:
107 return fields.index(field)
108 return unimportant
110 self.sort(key=getkey)
113def get_form_data(request):
114 """
115 Returns the effective form data for the given request.
117 Mostly this is a convenience, which simply returns one of the
118 following, depending on various attributes of the request.
120 * :attr:`pyramid:pyramid.request.Request.POST`
121 * :attr:`pyramid:pyramid.request.Request.json_body`
122 """
123 # nb. we prefer JSON only if no POST is present
124 # TODO: this seems to work for our use case at least, but perhaps
125 # there is a better way? see also
126 # https://docs.pylonsproject.org/projects/pyramid/en/latest/api/request.html#pyramid.request.Request.is_xhr
127 if not request.POST and (
128 getattr(request, 'is_xhr', False)
129 or getattr(request, 'content_type', None) == 'application/json'):
130 return request.json_body
131 return request.POST
134def get_libver(
135 request,
136 key,
137 configured_only=False,
138 default_only=False,
139 prefix='wuttaweb',
140):
141 """
142 Return the appropriate version string for the web resource library
143 identified by ``key``.
145 WuttaWeb makes certain assumptions about which libraries would be
146 used on the frontend, and which versions for each would be used by
147 default. But it should also be possible to customize which
148 versions are used, hence this function.
150 Each library has a built-in default version but your config can
151 override them, e.g.:
153 .. code-block:: ini
155 [wuttaweb]
156 libver.bb_vue = 3.4.29
158 :param request: Current request.
160 :param key: Unique key for the library, as string. Possibilities
161 are the same as for :func:`get_liburl()`.
163 :param configured_only: Pass ``True`` here if you only want the
164 configured version and ignore the default version.
166 :param default_only: Pass ``True`` here if you only want the
167 default version and ignore the configured version.
169 :param prefix: If specified, will override the prefix used for
170 config lookups.
172 .. warning::
174 This ``prefix`` param is for backward compatibility and may
175 be removed in the future.
177 :returns: The appropriate version string, e.g. ``'1.2.3'`` or
178 ``'latest'`` etc. Can also return ``None`` in some cases.
179 """
180 config = request.wutta_config
182 # nb. we prefer a setting to be named like: wuttaweb.libver.vue
183 # but for back-compat this also can work: tailbone.libver.vue
184 # and for more back-compat this can work: wuttaweb.vue_version
185 # however that compat only works for some of the settings...
187 if not default_only:
189 # nb. new/preferred setting
190 version = config.get(f'wuttaweb.libver.{key}')
191 if version:
192 return version
194 # fallback to caller-specified prefix
195 if prefix != 'wuttaweb':
196 version = config.get(f'{prefix}.libver.{key}')
197 if version:
198 warnings.warn(f"config for {prefix}.libver.{key} is deprecated; "
199 f"please set wuttaweb.libver.{key} instead",
200 DeprecationWarning)
201 return version
203 if key == 'buefy':
204 if not default_only:
205 # nb. old/legacy setting
206 version = config.get(f'{prefix}.buefy_version')
207 if version:
208 warnings.warn(f"config for {prefix}.buefy_version is deprecated; "
209 "please set wuttaweb.libver.buefy instead",
210 DeprecationWarning)
211 return version
212 if not configured_only:
213 return '0.9.25'
215 elif key == 'buefy.css':
216 # nb. this always returns something
217 return get_libver(request, 'buefy',
218 default_only=default_only,
219 configured_only=configured_only)
221 elif key == 'vue':
222 if not default_only:
223 # nb. old/legacy setting
224 version = config.get(f'{prefix}.vue_version')
225 if version:
226 warnings.warn(f"config for {prefix}.vue_version is deprecated; "
227 "please set wuttaweb.libver.vue instead",
228 DeprecationWarning)
229 return version
230 if not configured_only:
231 return '2.6.14'
233 elif key == 'vue_resource':
234 if not configured_only:
235 return '1.5.3'
237 elif key == 'fontawesome':
238 if not configured_only:
239 return '5.3.1'
241 elif key == 'bb_vue':
242 if not configured_only:
243 return '3.4.31'
245 elif key == 'bb_oruga':
246 if not configured_only:
247 return '0.8.12'
249 elif key in ('bb_oruga_bulma', 'bb_oruga_bulma_css'):
250 if not configured_only:
251 return '0.3.0'
253 elif key == 'bb_fontawesome_svg_core':
254 if not configured_only:
255 return '6.5.2'
257 elif key == 'bb_free_solid_svg_icons':
258 if not configured_only:
259 return '6.5.2'
261 elif key == 'bb_vue_fontawesome':
262 if not configured_only:
263 return '3.0.6'
266def get_liburl(
267 request,
268 key,
269 configured_only=False,
270 default_only=False,
271 prefix='wuttaweb',
272):
273 """
274 Return the appropriate URL for the web resource library identified
275 by ``key``.
277 WuttaWeb makes certain assumptions about which libraries would be
278 used on the frontend, and which versions for each would be used by
279 default. But ultimately a URL must be determined for each, hence
280 this function.
282 Each library has a built-in default URL which references a public
283 Internet (i.e. CDN) resource, but your config can override the
284 final URL in two ways:
286 The simplest way is to just override the *version* but otherwise
287 let the default logic construct the URL. See :func:`get_libver()`
288 for more on that approach.
290 The most flexible way is to override the URL explicitly, e.g.:
292 .. code-block:: ini
294 [wuttaweb]
295 liburl.bb_vue = https://example.com/cache/vue-3.4.31.js
297 :param request: Current request.
299 :param key: Unique key for the library, as string. Possibilities
300 are:
302 Vue 2 + Buefy
304 * ``vue``
305 * ``vue_resource``
306 * ``buefy``
307 * ``buefy.css``
308 * ``fontawesome``
310 Vue 3 + Oruga
312 * ``bb_vue``
313 * ``bb_oruga``
314 * ``bb_oruga_bulma``
315 * ``bb_oruga_bulma_css``
316 * ``bb_fontawesome_svg_core``
317 * ``bb_free_solid_svg_icons``
318 * ``bb_vue_fontawesome``
320 :param configured_only: Pass ``True`` here if you only want the
321 configured URL and ignore the default URL.
323 :param default_only: Pass ``True`` here if you only want the
324 default URL and ignore the configured URL.
326 :param prefix: If specified, will override the prefix used for
327 config lookups.
329 .. warning::
331 This ``prefix`` param is for backward compatibility and may
332 be removed in the future.
334 :returns: The appropriate URL as string. Can also return ``None``
335 in some cases.
336 """
337 config = request.wutta_config
339 if not default_only:
341 # nb. new/preferred setting
342 url = config.get(f'wuttaweb.liburl.{key}')
343 if url:
344 return url
346 # fallback to caller-specified prefix
347 url = config.get(f'{prefix}.liburl.{key}')
348 if url:
349 warnings.warn(f"config for {prefix}.liburl.{key} is deprecated; "
350 f"please set wuttaweb.liburl.{key} instead",
351 DeprecationWarning)
352 return url
354 if configured_only:
355 return
357 version = get_libver(request, key, prefix=prefix,
358 configured_only=False,
359 default_only=default_only)
361 # load fanstatic libcache if configured
362 static = config.get('wuttaweb.static_libcache.module')
363 if not static:
364 static = config.get(f'{prefix}.static_libcache.module')
365 if static:
366 warnings.warn(f"config for {prefix}.static_libcache.module is deprecated; "
367 "please set wuttaweb.static_libcache.module instead",
368 DeprecationWarning)
369 if static:
370 static = importlib.import_module(static)
371 needed = request.environ['fanstatic.needed']
372 liburl = needed.library_url(static.libcache) + '/'
373 # nb. add custom url prefix if needed, e.g. /wutta
374 if request.script_name:
375 liburl = request.script_name + liburl
377 if key == 'buefy':
378 if static and hasattr(static, 'buefy_js'):
379 return liburl + static.buefy_js.relpath
380 return f'https://unpkg.com/buefy@{version}/dist/buefy.min.js'
382 elif key == 'buefy.css':
383 if static and hasattr(static, 'buefy_css'):
384 return liburl + static.buefy_css.relpath
385 return f'https://unpkg.com/buefy@{version}/dist/buefy.min.css'
387 elif key == 'vue':
388 if static and hasattr(static, 'vue_js'):
389 return liburl + static.vue_js.relpath
390 return f'https://unpkg.com/vue@{version}/dist/vue.min.js'
392 elif key == 'vue_resource':
393 if static and hasattr(static, 'vue_resource_js'):
394 return liburl + static.vue_resource_js.relpath
395 return f'https://cdn.jsdelivr.net/npm/vue-resource@{version}'
397 elif key == 'fontawesome':
398 if static and hasattr(static, 'fontawesome_js'):
399 return liburl + static.fontawesome_js.relpath
400 return f'https://use.fontawesome.com/releases/v{version}/js/all.js'
402 elif key == 'bb_vue':
403 if static and hasattr(static, 'bb_vue_js'):
404 return liburl + static.bb_vue_js.relpath
405 return f'https://unpkg.com/vue@{version}/dist/vue.esm-browser.prod.js'
407 elif key == 'bb_oruga':
408 if static and hasattr(static, 'bb_oruga_js'):
409 return liburl + static.bb_oruga_js.relpath
410 return f'https://unpkg.com/@oruga-ui/oruga-next@{version}/dist/oruga.mjs'
412 elif key == 'bb_oruga_bulma':
413 if static and hasattr(static, 'bb_oruga_bulma_js'):
414 return liburl + static.bb_oruga_bulma_js.relpath
415 return f'https://unpkg.com/@oruga-ui/theme-bulma@{version}/dist/bulma.mjs'
417 elif key == 'bb_oruga_bulma_css':
418 if static and hasattr(static, 'bb_oruga_bulma_css'):
419 return liburl + static.bb_oruga_bulma_css.relpath
420 return f'https://unpkg.com/@oruga-ui/theme-bulma@{version}/dist/bulma.css'
422 elif key == 'bb_fontawesome_svg_core':
423 if static and hasattr(static, 'bb_fontawesome_svg_core_js'):
424 return liburl + static.bb_fontawesome_svg_core_js.relpath
425 return f'https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-svg-core@{version}/+esm'
427 elif key == 'bb_free_solid_svg_icons':
428 if static and hasattr(static, 'bb_free_solid_svg_icons_js'):
429 return liburl + static.bb_free_solid_svg_icons_js.relpath
430 return f'https://cdn.jsdelivr.net/npm/@fortawesome/free-solid-svg-icons@{version}/+esm'
432 elif key == 'bb_vue_fontawesome':
433 if static and hasattr(static, 'bb_vue_fontawesome_js'):
434 return liburl + static.bb_vue_fontawesome_js.relpath
435 return f'https://cdn.jsdelivr.net/npm/@fortawesome/vue-fontawesome@{version}/+esm'
438def get_csrf_token(request):
439 """
440 Convenience function, returns the effective CSRF token (raw
441 string) for the given request.
443 See also :func:`render_csrf_token()`.
444 """
445 token = request.session.get_csrf_token()
446 if token is None:
447 token = request.session.new_csrf_token()
448 return token
451def render_csrf_token(request, name='_csrf'):
452 """
453 Convenience function, returns CSRF hidden input inside hidden div,
454 e.g.:
456 .. code-block:: html
458 <div style="display: none;">
459 <input type="hidden" name="_csrf" value="TOKEN" />
460 </div>
462 This function is part of :mod:`wuttaweb.helpers` (as
463 :func:`~wuttaweb.helpers.csrf_token()`) which means you can do
464 this in page templates:
466 .. code-block:: mako
468 ${h.form(request.current_route_url())}
469 ${h.csrf_token(request)}
470 <!-- other fields etc. -->
471 ${h.end_form()}
473 See also :func:`get_csrf_token()`.
474 """
475 token = get_csrf_token(request)
476 return HTML.tag('div', tags.hidden(name, value=token, id=None), style='display:none;')
479def get_model_fields(config, model_class=None):
480 """
481 Convenience function to return a list of field names for the given
482 model class.
484 This logic only supports SQLAlchemy mapped classes and will use
485 that to determine the field listing if applicable. Otherwise this
486 returns ``None``.
487 """
488 if not model_class:
489 return
491 try:
492 mapper = sa.inspect(model_class)
493 except sa.exc.NoInspectionAvailable:
494 return
496 fields = [prop.key for prop in mapper.iterate_properties]
498 # nb. we never want the continuum 'versions' prop
499 app = config.get_app()
500 if app.continuum_is_enabled() and 'versions' in fields:
501 fields.remove('versions')
503 return fields
506def make_json_safe(value, key=None, warn=True):
507 """
508 Convert a Python value as needed, to ensure it is compatible with
509 :func:`python:json.dumps()`.
511 :param value: Python value.
513 :param key: Optional key for the value, if known. This is used
514 when logging warnings, if applicable.
516 :param warn: Whether warnings should be logged if the value is not
517 already JSON-compatible.
519 :returns: A (possibly new) Python value which is guaranteed to be
520 JSON-serializable.
521 """
523 # convert null => None
524 if value is colander.null:
525 return None
527 # recursively convert dict
528 if isinstance(value, dict):
529 parent = dict(value)
530 for key, value in parent.items():
531 parent[key] = make_json_safe(value, key=key, warn=warn)
532 value = parent
534 # ensure JSON-compatibility, warn if problems
535 try:
536 json.dumps(value)
537 except TypeError as error:
538 if warn:
539 prefix = "value"
540 if key:
541 prefix += f" for '{key}'"
542 log.warning("%s is not json-friendly: %s", prefix, repr(value))
543 value = str(value)
544 if warn:
545 log.warning("forced value to: %s", value)
547 return value