Coverage for .tox/coverage/lib/python3.13/site-packages/wuttaweb/util.py: 100%

354 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-24 16:36 -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""" 

26 

27import decimal 

28import importlib 

29import json 

30import logging 

31import uuid as _uuid 

32import warnings 

33 

34import sqlalchemy as sa 

35from sqlalchemy import orm 

36 

37import colander 

38from pyramid.renderers import get_renderer 

39from webhelpers2.html import HTML, tags 

40 

41from wuttjamaican.util import resource_path 

42 

43log = logging.getLogger(__name__) 

44 

45 

46class FieldList(list): 

47 """ 

48 Convenience wrapper for a form's field list. This is a subclass 

49 of :class:`python:list`. 

50 

51 You normally would not need to instantiate this yourself, but it 

52 is used under the hood for 

53 :attr:`~wuttaweb.forms.base.Form.fields` as well as 

54 :attr:`~wuttaweb.grids.base.Grid.columns`. 

55 """ 

56 

57 def insert_before(self, field, newfield): 

58 """ 

59 Insert a new field, before an existing field. 

60 

61 :param field: String name for the existing field. 

62 

63 :param newfield: String name for the new field, to be inserted 

64 just before the existing ``field``. 

65 """ 

66 if field in self: 

67 i = self.index(field) 

68 self.insert(i, newfield) 

69 else: 

70 log.warning( 

71 "field '%s' not found, will append new field: %s", field, newfield 

72 ) 

73 self.append(newfield) 

74 

75 def insert_after(self, field, newfield): 

76 """ 

77 Insert a new field, after an existing field. 

78 

79 :param field: String name for the existing field. 

80 

81 :param newfield: String name for the new field, to be inserted 

82 just after the existing ``field``. 

83 """ 

84 if field in self: 

85 i = self.index(field) 

86 self.insert(i + 1, newfield) 

87 else: 

88 log.warning( 

89 "field '%s' not found, will append new field: %s", field, newfield 

90 ) 

91 self.append(newfield) 

92 

93 def set_sequence(self, fields): 

94 """ 

95 Sort the list such that it matches the same sequence as the 

96 given fields list. 

97 

98 This does not add or remove any elements, it just 

99 (potentially) rearranges the internal list elements. 

100 Therefore you do not need to explicitly declare *all* fields; 

101 just the ones you care about. 

102 

103 The resulting field list will have the requested fields in 

104 order, at the *beginning* of the list. Any unrequested fields 

105 will remain in the same order as they were previously, but 

106 will be placed *after* the requested fields. 

107 

108 :param fields: List of fields in the desired order. 

109 """ 

110 unimportant = len(self) + 1 

111 

112 def getkey(field): 

113 if field in fields: 

114 return fields.index(field) 

115 return unimportant 

116 

117 self.sort(key=getkey) 

118 

119 

120def get_form_data(request): 

121 """ 

122 Returns the effective form data for the given request. 

123 

124 Mostly this is a convenience, which simply returns one of the 

125 following, depending on various attributes of the request. 

126 

127 * :attr:`pyramid:pyramid.request.Request.POST` 

128 * :attr:`pyramid:pyramid.request.Request.json_body` 

129 """ 

130 # nb. we prefer JSON only if no POST is present 

131 # TODO: this seems to work for our use case at least, but perhaps 

132 # there is a better way? see also 

133 # https://docs.pylonsproject.org/projects/pyramid/en/latest/api/request.html#pyramid.request.Request.is_xhr 

134 if not request.POST and ( 

135 getattr(request, "is_xhr", False) 

136 or getattr(request, "content_type", None) == "application/json" 

137 ): 

138 return request.json_body 

139 return request.POST 

140 

141 

142def get_libver( 

143 request, 

144 key, 

145 configured_only=False, 

146 default_only=False, 

147 prefix="wuttaweb", 

148): # pylint: disable=too-many-return-statements,too-many-branches,too-many-statements 

149 """ 

150 Return the appropriate version string for the web resource library 

151 identified by ``key``. 

152 

153 WuttaWeb makes certain assumptions about which libraries would be 

154 used on the frontend, and which versions for each would be used by 

155 default. But it should also be possible to customize which 

156 versions are used, hence this function. 

157 

158 Each library has a built-in default version but your config can 

159 override them, e.g.: 

160 

161 .. code-block:: ini 

162 

163 [wuttaweb] 

164 libver.bb_vue = 3.4.29 

165 

166 :param request: Current request. 

167 

168 :param key: Unique key for the library, as string. Possibilities 

169 are the same as for :func:`get_liburl()`. 

170 

171 :param configured_only: Pass ``True`` here if you only want the 

172 configured version and ignore the default version. 

173 

174 :param default_only: Pass ``True`` here if you only want the 

175 default version and ignore the configured version. 

176 

177 :param prefix: If specified, will override the prefix used for 

178 config lookups. 

179 

180 .. warning:: 

181 

182 This ``prefix`` param is for backward compatibility and may 

183 be removed in the future. 

184 

185 :returns: The appropriate version string, e.g. ``'1.2.3'`` or 

186 ``'latest'`` etc. Can also return ``None`` in some cases. 

187 """ 

188 config = request.wutta_config 

189 

190 if key == "buefy.css": 

191 warnings.warn( 

192 "libver key 'buefy.css' is deprecated; please use 'buefy_css' instead", 

193 DeprecationWarning, 

194 stacklevel=2, 

195 ) 

196 key = "buefy_css" 

197 

198 # nb. we prefer a setting to be named like: wuttaweb.libver.vue 

199 # but for back-compat this also can work: tailbone.libver.vue 

200 # and for more back-compat this can work: wuttaweb.vue_version 

201 # however that compat only works for some of the settings... 

202 

203 if not default_only: 

204 

205 # nb. new/preferred setting 

206 version = config.get(f"wuttaweb.libver.{key}") 

207 if version: 

208 return version 

209 

210 # maybe try deprecated key for buefy.css 

211 if key == "buefy_css": 

212 version = config.get("wuttaweb.libver.buefy.css") 

213 if version: 

214 warnings.warn( 

215 "config for wuttaweb.libver.buefy.css is deprecated; " 

216 "please set wuttaweb.libver.buefy_css instead", 

217 DeprecationWarning, 

218 ) 

219 return version 

220 

221 # fallback to caller-specified prefix 

222 if prefix != "wuttaweb": 

223 version = config.get(f"{prefix}.libver.{key}") 

224 if version: 

225 warnings.warn( 

226 f"config for {prefix}.libver.{key} is deprecated; " 

227 f"please set wuttaweb.libver.{key} instead", 

228 DeprecationWarning, 

229 ) 

230 return version 

231 

232 # maybe try deprecated key for buefy.css 

233 if key == "buefy_css": 

234 version = config.get(f"{prefix}.libver.buefy.css") 

235 if version: 

236 warnings.warn( 

237 f"config for {prefix}.libver.buefy.css is deprecated; " 

238 "please set wuttaweb.libver.buefy_css instead", 

239 DeprecationWarning, 

240 ) 

241 return version 

242 

243 if key == "buefy": 

244 if not default_only: 

245 # nb. old/legacy setting 

246 version = config.get(f"{prefix}.buefy_version") 

247 if version: 

248 warnings.warn( 

249 f"config for {prefix}.buefy_version is deprecated; " 

250 "please set wuttaweb.libver.buefy instead", 

251 DeprecationWarning, 

252 ) 

253 return version 

254 if not configured_only: 

255 return "0.9.25" 

256 

257 elif key == "buefy_css": 

258 # nb. this always returns something 

259 return get_libver( 

260 request, "buefy", default_only=default_only, configured_only=configured_only 

261 ) 

262 

263 elif key == "vue": 

264 if not default_only: 

265 # nb. old/legacy setting 

266 version = config.get(f"{prefix}.vue_version") 

267 if version: 

268 warnings.warn( 

269 f"config for {prefix}.vue_version is deprecated; " 

270 "please set wuttaweb.libver.vue instead", 

271 DeprecationWarning, 

272 ) 

273 return version 

274 if not configured_only: 

275 return "2.6.14" 

276 

277 elif key == "vue_resource": 

278 if not configured_only: 

279 return "1.5.3" 

280 

281 elif key == "fontawesome": 

282 if not configured_only: 

283 return "5.3.1" 

284 

285 elif key == "bb_vue": 

286 if not configured_only: 

287 return "3.5.18" 

288 

289 elif key == "bb_oruga": 

290 if not configured_only: 

291 return "0.11.4" 

292 

293 elif key in ("bb_oruga_bulma", "bb_oruga_bulma_css"): 

294 if not configured_only: 

295 return "0.7.3" 

296 

297 elif key == "bb_fontawesome_svg_core": 

298 if not configured_only: 

299 return "7.0.0" 

300 

301 elif key == "bb_free_solid_svg_icons": 

302 if not configured_only: 

303 return "7.0.0" 

304 

305 elif key == "bb_vue_fontawesome": 

306 if not configured_only: 

307 return "3.1.1" 

308 

309 elif key == "cc_vue": 

310 if not configured_only: 

311 return "3.5.43" 

312 

313 elif key == "cc_buefy": 

314 if not configured_only: 

315 return "3.1.0" 

316 

317 elif key == "cc_buefy_css": 

318 if not configured_only: 

319 return "3.1.0" 

320 

321 elif key == "cc_fontawesome_svg_core": 

322 if not configured_only: 

323 return "7.3.1" 

324 

325 elif key == "cc_free_solid_svg_icons": 

326 if not configured_only: 

327 return "7.3.1" 

328 

329 elif key == "cc_vue_fontawesome": 

330 if not configured_only: 

331 return "3.1.1" 

332 

333 return None 

334 

335 

336def get_liburl( 

337 request, 

338 key, 

339 configured_only=False, 

340 default_only=False, 

341 prefix="wuttaweb", 

342): # pylint: disable=too-many-return-statements,too-many-branches,too-many-statements 

343 """ 

344 Return the appropriate URL for the web resource library identified 

345 by ``key``. 

346 

347 WuttaWeb makes certain assumptions about which libraries would be 

348 used on the frontend, and which versions for each would be used by 

349 default. But ultimately a URL must be determined for each, hence 

350 this function. 

351 

352 Each library has a built-in default URL which references a public 

353 Internet (i.e. CDN) resource, but your config can override the 

354 final URL in two ways: 

355 

356 The simplest way is to just override the *version* but otherwise 

357 let the default logic construct the URL. See :func:`get_libver()` 

358 for more on that approach. 

359 

360 The most flexible way is to override the URL explicitly, e.g.: 

361 

362 .. code-block:: ini 

363 

364 [wuttaweb] 

365 liburl.bb_vue = https://example.com/cache/vue-3.4.31.js 

366 

367 :param request: Current request. 

368 

369 :param key: Unique key for the library, as string. Possibilities 

370 are: 

371 

372 Vue 2 + Buefy 0.9 (e.g. for "default" theme) 

373 

374 * ``vue`` 

375 * ``vue_resource`` 

376 * ``buefy`` 

377 * ``buefy_css`` 

378 * ``fontawesome`` 

379 

380 Vue 3 + Oruga 0.x (e.g. for "butterfly" theme) 

381 

382 * ``bb_vue`` 

383 * ``bb_oruga`` 

384 * ``bb_oruga_bulma`` 

385 * ``bb_oruga_bulma_css`` 

386 * ``bb_fontawesome_svg_core`` 

387 * ``bb_free_solid_svg_icons`` 

388 * ``bb_vue_fontawesome`` 

389 

390 Vue 3 + Buefy 3.x (e.g. for "caraway" theme) 

391 

392 * ``cc_vue`` 

393 * ``cc_buefy`` 

394 * ``cc_buefy_css`` 

395 * ``cc_fontawesome_svg_core`` 

396 * ``cc_free_solid_svg_icons`` 

397 * ``cc_vue_fontawesome`` 

398 

399 :param configured_only: Pass ``True`` here if you only want the 

400 configured URL and ignore the default URL. 

401 

402 :param default_only: Pass ``True`` here if you only want the 

403 default URL and ignore the configured URL. 

404 

405 :param prefix: If specified, will override the prefix used for 

406 config lookups. 

407 

408 .. warning:: 

409 

410 This ``prefix`` param is for backward compatibility and may 

411 be removed in the future. 

412 

413 :returns: The appropriate URL as string. Can also return ``None`` 

414 in some cases. 

415 """ 

416 config = request.wutta_config 

417 

418 if key == "buefy.css": 

419 warnings.warn( 

420 "liburl key 'buefy.css' is deprecated; please use 'buefy_css' instead", 

421 DeprecationWarning, 

422 stacklevel=2, 

423 ) 

424 key = "buefy_css" 

425 

426 if not default_only: 

427 

428 # nb. new/preferred setting 

429 url = config.get(f"wuttaweb.liburl.{key}") 

430 if url: 

431 return url 

432 

433 # maybe try deprecated key for buefy.css 

434 if key == "buefy_css": 

435 version = config.get("wuttaweb.liburl.buefy.css") 

436 if version: 

437 warnings.warn( 

438 "config for wuttaweb.liburl.buefy.css is deprecated; " 

439 "please set wuttaweb.liburl.buefy_css instead", 

440 DeprecationWarning, 

441 ) 

442 return version 

443 

444 # fallback to caller-specified prefix 

445 url = config.get(f"{prefix}.liburl.{key}") 

446 if url: 

447 warnings.warn( 

448 f"config for {prefix}.liburl.{key} is deprecated; " 

449 f"please set wuttaweb.liburl.{key} instead", 

450 DeprecationWarning, 

451 ) 

452 return url 

453 

454 # maybe try deprecated key for buefy.css 

455 if key == "buefy_css": 

456 version = config.get(f"{prefix}.liburl.buefy.css") 

457 if version: 

458 warnings.warn( 

459 f"config for {prefix}.liburl.buefy.css is deprecated; " 

460 "please set wuttaweb.liburl.buefy_css instead", 

461 DeprecationWarning, 

462 ) 

463 return version 

464 

465 if configured_only: 

466 return None 

467 

468 version = get_libver( 

469 request, key, prefix=prefix, configured_only=False, default_only=default_only 

470 ) 

471 

472 # load fanstatic libcache if configured 

473 static = config.get("wuttaweb.static_libcache.module") 

474 if not static: 

475 static = config.get(f"{prefix}.static_libcache.module") 

476 if static: 

477 warnings.warn( 

478 f"config for {prefix}.static_libcache.module is deprecated; " 

479 "please set wuttaweb.static_libcache.module instead", 

480 DeprecationWarning, 

481 ) 

482 if static: 

483 static = importlib.import_module(static) 

484 needed = request.environ["fanstatic.needed"] 

485 liburl = needed.library_url(static.libcache) + "/" 

486 # nb. add custom url prefix if needed, e.g. /wutta 

487 if request.script_name: 

488 liburl = request.script_name + liburl 

489 

490 if key == "buefy": 

491 if static and hasattr(static, "buefy_js"): 

492 return liburl + static.buefy_js.relpath 

493 return f"https://unpkg.com/buefy@{version}/dist/buefy.min.js" 

494 

495 if key == "buefy_css": 

496 if static and hasattr(static, "buefy_css"): 

497 return liburl + static.buefy_css.relpath 

498 return f"https://unpkg.com/buefy@{version}/dist/buefy.min.css" 

499 

500 if key == "vue": 

501 if static and hasattr(static, "vue_js"): 

502 return liburl + static.vue_js.relpath 

503 return f"https://unpkg.com/vue@{version}/dist/vue.min.js" 

504 

505 if key == "vue_resource": 

506 if static and hasattr(static, "vue_resource_js"): 

507 return liburl + static.vue_resource_js.relpath 

508 return f"https://cdn.jsdelivr.net/npm/vue-resource@{version}" 

509 

510 if key == "fontawesome": 

511 if static and hasattr(static, "fontawesome_js"): 

512 return liburl + static.fontawesome_js.relpath 

513 return f"https://use.fontawesome.com/releases/v{version}/js/all.js" 

514 

515 if key == "bb_vue": 

516 if static and hasattr(static, "bb_vue_js"): 

517 return liburl + static.bb_vue_js.relpath 

518 return f"https://unpkg.com/vue@{version}/dist/vue.esm-browser.prod.js" 

519 

520 if key == "bb_oruga": 

521 if static and hasattr(static, "bb_oruga_js"): 

522 return liburl + static.bb_oruga_js.relpath 

523 return f"https://unpkg.com/@oruga-ui/oruga-next@{version}/dist/oruga.mjs" 

524 

525 if key == "bb_oruga_bulma": 

526 if static and hasattr(static, "bb_oruga_bulma_js"): 

527 return liburl + static.bb_oruga_bulma_js.relpath 

528 return f"https://unpkg.com/@oruga-ui/theme-bulma@{version}/dist/bulma.js" 

529 

530 if key == "bb_oruga_bulma_css": 

531 if static and hasattr(static, "bb_oruga_bulma_css"): 

532 return liburl + static.bb_oruga_bulma_css.relpath 

533 return f"https://unpkg.com/@oruga-ui/theme-bulma@{version}/dist/bulma.css" 

534 

535 if key == "bb_fontawesome_svg_core": 

536 if static and hasattr(static, "bb_fontawesome_svg_core_js"): 

537 return liburl + static.bb_fontawesome_svg_core_js.relpath 

538 return f"https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-svg-core@{version}/+esm" 

539 

540 if key == "bb_free_solid_svg_icons": 

541 if static and hasattr(static, "bb_free_solid_svg_icons_js"): 

542 return liburl + static.bb_free_solid_svg_icons_js.relpath 

543 return f"https://cdn.jsdelivr.net/npm/@fortawesome/free-solid-svg-icons@{version}/+esm" 

544 

545 if key == "bb_vue_fontawesome": 

546 if static and hasattr(static, "bb_vue_fontawesome_js"): 

547 return liburl + static.bb_vue_fontawesome_js.relpath 

548 return ( 

549 f"https://cdn.jsdelivr.net/npm/@fortawesome/vue-fontawesome@{version}/+esm" 

550 ) 

551 

552 if key == "cc_vue": 

553 if static and hasattr(static, "cc_vue_js"): 

554 return liburl + static.cc_vue_js.relpath 

555 return f"https://unpkg.com/vue@{version}/dist/vue.esm-browser.prod.js" 

556 

557 if key == "cc_buefy": 

558 if static and hasattr(static, "cc_buefy_js"): 

559 return liburl + static.cc_buefy_js.relpath 

560 return f"https://unpkg.com/buefy@{version}/dist/buefy.esm.min.js" 

561 

562 if key == "cc_buefy_css": 

563 if static and hasattr(static, "cc_buefy_css"): 

564 return liburl + static.cc_buefy_css.relpath 

565 return f"https://unpkg.com/buefy@{version}/dist/css/buefy.min.css" 

566 

567 if key == "cc_fontawesome_svg_core": 

568 if static and hasattr(static, "cc_fontawesome_svg_core_js"): 

569 return liburl + static.cc_fontawesome_svg_core_js.relpath 

570 return f"https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-svg-core@{version}/+esm" 

571 

572 if key == "cc_free_solid_svg_icons": 

573 if static and hasattr(static, "cc_free_solid_svg_icons_js"): 

574 return liburl + static.cc_free_solid_svg_icons_js.relpath 

575 return f"https://cdn.jsdelivr.net/npm/@fortawesome/free-solid-svg-icons@{version}/+esm" 

576 

577 if key == "cc_vue_fontawesome": 

578 if static and hasattr(static, "cc_vue_fontawesome_js"): 

579 return liburl + static.cc_vue_fontawesome_js.relpath 

580 return ( 

581 f"https://cdn.jsdelivr.net/npm/@fortawesome/vue-fontawesome@{version}/+esm" 

582 ) 

583 

584 return None 

585 

586 

587def get_csrf_token(request): 

588 """ 

589 Convenience function, returns the effective CSRF token (raw 

590 string) for the given request. 

591 

592 See also :func:`render_csrf_token()`. 

593 """ 

594 token = request.session.get_csrf_token() 

595 if token is None: 

596 token = request.session.new_csrf_token() 

597 return token 

598 

599 

600def render_csrf_token(request, name="_csrf"): 

601 """ 

602 Convenience function, returns CSRF hidden input inside hidden div, 

603 e.g.: 

604 

605 .. code-block:: html 

606 

607 <div style="display: none;"> 

608 <input type="hidden" name="_csrf" value="TOKEN" /> 

609 </div> 

610 

611 This function is part of :mod:`wuttaweb.helpers` (as 

612 :func:`~wuttaweb.helpers.csrf_token()`) which means you can do 

613 this in page templates: 

614 

615 .. code-block:: mako 

616 

617 ${h.form(request.current_route_url())} 

618 ${h.csrf_token(request)} 

619 <!-- other fields etc. --> 

620 ${h.end_form()} 

621 

622 See also :func:`get_csrf_token()`. 

623 """ 

624 token = get_csrf_token(request) 

625 return HTML.tag( 

626 "div", tags.hidden(name, value=token, id=None), style="display:none;" 

627 ) 

628 

629 

630def get_model_fields(config, model_class, include_fk=False): 

631 """ 

632 Convenience function to return a list of field names for the given 

633 :term:`data model` class. 

634 

635 This logic only supports SQLAlchemy mapped classes and will use 

636 that to determine the field listing if applicable. Otherwise this 

637 returns ``None``. 

638 

639 :param config: App :term:`config object`. 

640 

641 :param model_class: Data model class. 

642 

643 :param include_fk: Whether to include foreign key column names in 

644 the result. They are excluded by default, since the 

645 relationship names are also included and generally preferred. 

646 

647 :returns: List of field names, or ``None`` if it could not be 

648 determined. 

649 """ 

650 try: 

651 mapper = sa.inspect(model_class) 

652 except sa.exc.NoInspectionAvailable: 

653 return None 

654 

655 if include_fk: 

656 fields = [prop.key for prop in mapper.iterate_properties] 

657 else: 

658 fields = [ 

659 prop.key for prop in mapper.iterate_properties if not prop_is_fk(prop) 

660 ] 

661 

662 # nb. we never want the continuum 'versions' prop 

663 app = config.get_app() 

664 if app.continuum_is_enabled() and "versions" in fields: 

665 fields.remove("versions") 

666 

667 return fields 

668 

669 

670def prop_is_fk(prop): 

671 """ 

672 Determine if the given mapper property represents the "foreign 

673 key" side of a relationship. 

674 

675 This *may* be used by :meth:`get_model_fields()` to strip out 

676 foreign key columns, in favor of the corresponding relationships. 

677 

678 :param prop: SQLAlchemy mapper property for the model class, 

679 e.g. ``Person.uuid`` or ``User.person_uuid``. 

680 

681 :returns: ``True`` if property represents the "foreign key" side 

682 of a relationship. 

683 """ 

684 if not isinstance(prop, orm.ColumnProperty): 

685 return False 

686 

687 mapper = prop.parent 

688 prop_columns = [col.name for col in prop.columns] 

689 for rel in mapper.relationships: 

690 if [col.name for col in rel.local_columns] == prop_columns: 

691 for col in rel.local_columns: 

692 if col.foreign_keys: 

693 return True 

694 

695 return False 

696 

697 

698def make_json_safe(value, key=None, warn=True): # pylint: disable=too-many-branches 

699 """ 

700 Convert a Python value as needed, to ensure it is compatible with 

701 :func:`python:json.dumps()`. 

702 

703 :param value: Python value. 

704 

705 :param key: Optional key for the value, if known. This is used 

706 when logging warnings, if applicable. 

707 

708 :param warn: Whether warnings should be logged if the value is not 

709 already JSON-compatible. 

710 

711 :returns: A (possibly new) Python value which is guaranteed to be 

712 JSON-serializable. 

713 """ 

714 

715 # convert null => None 

716 if value is colander.null: 

717 return None 

718 

719 if isinstance(value, dict): 

720 # recursively convert dict 

721 parent = dict(value) 

722 for k, v in parent.items(): 

723 parent[k] = make_json_safe(v, key=k, warn=warn) 

724 value = parent 

725 

726 elif isinstance(value, list): 

727 # recursively convert list 

728 parent = list(value) 

729 for i, v in enumerate(parent): 

730 parent[i] = make_json_safe(v, key=key, warn=warn) 

731 value = parent 

732 

733 elif isinstance(value, set): 

734 # recursively convert set (as list) 

735 parent = list(value) 

736 for i, v in enumerate(parent): 

737 parent[i] = make_json_safe(v, key=key, warn=warn) 

738 value = parent 

739 

740 elif isinstance(value, _uuid.UUID): 

741 # convert UUID to str 

742 value = value.hex 

743 

744 elif isinstance(value, decimal.Decimal): 

745 # convert decimal to float 

746 value = float(value) 

747 

748 # ensure JSON-compatibility, warn if problems 

749 try: 

750 json.dumps(value) 

751 except TypeError: 

752 if warn: 

753 prefix = "value" 

754 if key: 

755 prefix += f" for '{key}'" 

756 log.warning("%s is not json-friendly: %s", prefix, repr(value)) 

757 value = str(value) 

758 if warn: 

759 log.warning("forced value to: %s", value) 

760 

761 return value 

762 

763 

764def render_vue_finalize(vue_tagname, vue_component): 

765 """ 

766 Render the Vue "finalize" script for a form or grid component. 

767 

768 This is a convenience for shared logic; it returns e.g.: 

769 

770 .. code-block:: html 

771 

772 <script> 

773 WuttaGrid.data = function() { return WuttaGridData } 

774 Vue.component('wutta-grid', WuttaGrid) 

775 </script> 

776 """ 

777 set_data = f"{vue_component}.data = function() {{ return {vue_component}Data }}" 

778 make_component = f"Vue.component('{vue_tagname}', {vue_component})" 

779 return HTML.tag( 

780 "script", 

781 c=["\n", HTML.literal(set_data), "\n", HTML.literal(make_component), "\n"], 

782 ) 

783 

784 

785def make_users_grid(request, **kwargs): 

786 """ 

787 Make and return a users (sub)grid. 

788 

789 This grid is shown for the Users field when viewing a Person or 

790 Role, for instance. It is called by the following methods: 

791 

792 * :meth:`wuttaweb.views.people.PersonView.make_users_grid()` 

793 * :meth:`wuttaweb.views.roles.RoleView.make_users_grid()` 

794 

795 :returns: Fully configured :class:`~wuttaweb.grids.base.Grid` 

796 instance. 

797 """ 

798 config = request.wutta_config 

799 app = config.get_app() 

800 model = app.model 

801 web = app.get_web_handler() 

802 

803 if "key" not in kwargs: 

804 route_prefix = kwargs.pop("route_prefix") 

805 kwargs["key"] = f"{route_prefix}.view.users" 

806 

807 kwargs.setdefault("model_class", model.User) 

808 grid = web.make_grid(request, **kwargs) 

809 

810 if request.has_perm("users.view"): 

811 

812 def view_url(user, i): # pylint: disable=unused-argument 

813 return request.route_url("users.view", uuid=user.uuid) 

814 

815 grid.add_action("view", icon="eye", url=view_url) 

816 grid.set_link("person") 

817 grid.set_link("username") 

818 

819 if request.has_perm("users.edit"): 

820 

821 def edit_url(user, i): # pylint: disable=unused-argument 

822 return request.route_url("users.edit", uuid=user.uuid) 

823 

824 grid.add_action("edit", url=edit_url) 

825 

826 return grid 

827 

828 

829############################## 

830# theme functions 

831############################## 

832 

833 

834def get_available_themes(config): 

835 """ 

836 Returns the official list of theme names which are available for 

837 use in the app. Privileged users may choose among these when 

838 changing the global theme. 

839 

840 See also :func:`get_effective_theme()`. 

841 

842 By default the list will include these built-in themes: 

843 

844 * **default** (Vue 2 + Buefy 0.9) 

845 * **butterfly** (Vue 3 + Oruga 0.x) 

846 * **caraway** (Vue 3 + Buefy 3.x) 

847 

848 You can override the list via config: 

849 

850 .. code-block:: ini 

851 

852 [wuttaweb] 

853 themes.keys = default, caraway, my-other-one 

854 

855 (TODO: link to elsewhere for how to *define* a custom theme) 

856 

857 :param config: App :term:`config object`. 

858 

859 :returns: List of theme names 

860 """ 

861 # get available list from config, if it has one 

862 available = config.get_list( 

863 "wuttaweb.themes.keys", default=["default", "butterfly", "caraway"] 

864 ) 

865 

866 # sort the list by name 

867 available.sort() 

868 

869 # make default theme the first option 

870 if "default" in available: 

871 available.remove("default") 

872 available.insert(0, "default") 

873 

874 return available 

875 

876 

877def get_effective_theme(config, theme=None, session=None): 

878 """ 

879 Validate and return the "effective" theme. 

880 

881 If caller specifies a ``theme`` then it will be returned (if 

882 "available" - see below). 

883 

884 Otherwise the current theme will be read from db setting. (Note 

885 we do not read simply from config object, we always read from db 

886 setting - this allows for the theme setting to change dynamically 

887 while app is running.) 

888 

889 In either case if the theme is not listed in 

890 :func:`get_available_themes()` then a ``ValueError`` is raised. 

891 

892 :param config: App :term:`config object`. 

893 

894 :param theme: Optional name of desired theme, instead of getting 

895 current theme per db setting. 

896 

897 :param session: Optional :term:`db session`. 

898 

899 :returns: Name of theme. 

900 """ 

901 app = config.get_app() 

902 

903 if not theme: 

904 with app.short_session(session=session) as s: 

905 theme = app.get_setting(s, "wuttaweb.theme") or "default" 

906 

907 # confirm requested theme is available 

908 available = get_available_themes(config) 

909 if theme not in available: 

910 raise ValueError(f"theme not available: {theme}") 

911 

912 return theme 

913 

914 

915def get_theme_template_path(config, theme=None, session=None): 

916 """ 

917 Return the template path for effective theme. 

918 

919 If caller specifies a ``theme`` then it will be used; otherwise 

920 the current theme will be read from db setting. The logic for 

921 that happens in :func:`get_effective_theme()`, which this function 

922 will call first. 

923 

924 Once we have the valid theme name, we check config in case it 

925 specifies a template path override for it. But if not, a default 

926 template path is assumed. 

927 

928 The default path would be expected to live under 

929 ``wuttaweb:templates/themes``; for instance the ``butterfly`` 

930 theme has a default template path of 

931 ``wuttaweb:templates/themes/butterfly``. 

932 

933 :param config: App :term:`config object`. 

934 

935 :param theme: Optional name of desired theme, instead of getting 

936 current theme per db setting. 

937 

938 :param session: Optional :term:`db session`. 

939 

940 :returns: Path on disk to theme template folder. 

941 """ 

942 theme = get_effective_theme(config, theme=theme, session=session) 

943 theme_path = config.get( 

944 f"wuttaweb.theme.{theme}", default=f"wuttaweb:templates/themes/{theme}" 

945 ) 

946 return resource_path(theme_path) 

947 

948 

949def set_app_theme(request, theme, session=None): 

950 """ 

951 Set the effective theme for the running app. 

952 

953 This will modify the *global* Mako template lookup directories, 

954 i.e. app templates will change for all users immediately. 

955 

956 This will first validate the theme by calling 

957 :func:`get_effective_theme()`. It then retrieves the template 

958 path via :func:`get_theme_template_path()`. 

959 

960 The theme template path is then injected into the app settings 

961 registry such that it overrides the Mako lookup directories. 

962 

963 It also will persist the theme name within db settings, so as to 

964 ensure it survives app restart. 

965 """ 

966 config = request.wutta_config 

967 app = config.get_app() 

968 

969 theme = get_effective_theme(config, theme=theme, session=session) 

970 theme_path = get_theme_template_path(config, theme=theme, session=session) 

971 

972 # there's only one global template lookup; can get to it via any renderer 

973 # but should *not* use /base.mako since that one is about to get volatile 

974 renderer = get_renderer("/page.mako") 

975 lookup = renderer.lookup 

976 

977 # overwrite first entry in lookup's directory list 

978 lookup.directories[0] = theme_path 

979 

980 # clear template cache for lookup object, so it will reload each (as needed) 

981 lookup._collection.clear() # pylint: disable=protected-access 

982 

983 # persist current theme in db settings 

984 with app.short_session(session=session) as s: 

985 app.save_setting(s, "wuttaweb.theme", theme) 

986 

987 # and cache in live app settings 

988 request.registry.settings["wuttaweb.theme"] = theme