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

352 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-21 13:06 -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 

43 

44log = logging.getLogger(__name__) 

45 

46 

47class FieldList(list): 

48 """ 

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

50 of :class:`python:list`. 

51 

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 """ 

57 

58 def insert_before(self, field, newfield): 

59 """ 

60 Insert a new field, before an existing field. 

61 

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

63 

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) 

75 

76 def insert_after(self, field, newfield): 

77 """ 

78 Insert a new field, after an existing field. 

79 

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

81 

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) 

93 

94 def set_sequence(self, fields): 

95 """ 

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

97 given fields list. 

98 

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. 

103 

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. 

108 

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

110 """ 

111 unimportant = len(self) + 1 

112 

113 def getkey(field): 

114 if field in fields: 

115 return fields.index(field) 

116 return unimportant 

117 

118 self.sort(key=getkey) 

119 

120 

121def get_form_data(request): 

122 """ 

123 Returns the effective form data for the given request. 

124 

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

126 following, depending on various attributes of the request. 

127 

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 

141 

142 

143def get_libver( 

144 request, 

145 key, 

146 configured_only=False, 

147 default_only=False, 

148 prefix="wuttaweb", 

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

150 """ 

151 Return the appropriate version string for the web resource library 

152 identified by ``key``. 

153 

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. 

158 

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

160 override them, e.g.: 

161 

162 .. code-block:: ini 

163 

164 [wuttaweb] 

165 libver.bb_vue = 3.4.29 

166 

167 :param request: Current request. 

168 

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

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

171 

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

173 configured version and ignore the default version. 

174 

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

176 default version and ignore the configured version. 

177 

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

179 config lookups. 

180 

181 .. warning:: 

182 

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

184 be removed in the future. 

185 

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 

190 

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" 

198 

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... 

203 

204 if not default_only: 

205 

206 # nb. new/preferred setting 

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

208 if version: 

209 return version 

210 

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 

221 

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 

232 

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 

243 

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" 

257 

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 ) 

263 

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" 

277 

278 elif key == "vue_resource": 

279 if not configured_only: 

280 return "1.5.3" 

281 

282 elif key == "fontawesome": 

283 if not configured_only: 

284 return "5.3.1" 

285 

286 elif key == "bb_vue": 

287 if not configured_only: 

288 return "3.5.18" 

289 

290 elif key == "bb_oruga": 

291 if not configured_only: 

292 return "0.11.4" 

293 

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

295 if not configured_only: 

296 return "0.7.3" 

297 

298 elif key == "bb_fontawesome_svg_core": 

299 if not configured_only: 

300 return "7.0.0" 

301 

302 elif key == "bb_free_solid_svg_icons": 

303 if not configured_only: 

304 return "7.0.0" 

305 

306 elif key == "bb_vue_fontawesome": 

307 if not configured_only: 

308 return "3.1.1" 

309 

310 elif key == "cc_vue": 

311 if not configured_only: 

312 return "3.5.43" 

313 

314 elif key == "cc_buefy": 

315 if not configured_only: 

316 return "3.1.0" 

317 

318 elif key == "cc_buefy_css": 

319 if not configured_only: 

320 return "3.1.0" 

321 

322 elif key == "cc_fontawesome_svg_core": 

323 if not configured_only: 

324 return "7.3.1" 

325 

326 elif key == "cc_free_solid_svg_icons": 

327 if not configured_only: 

328 return "7.3.1" 

329 

330 elif key == "cc_vue_fontawesome": 

331 if not configured_only: 

332 return "3.1.1" 

333 

334 return None 

335 

336 

337def get_liburl( 

338 request, 

339 key, 

340 configured_only=False, 

341 default_only=False, 

342 prefix="wuttaweb", 

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

344 """ 

345 Return the appropriate URL for the web resource library identified 

346 by ``key``. 

347 

348 WuttaWeb makes certain assumptions about which libraries would be 

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

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

351 this function. 

352 

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

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

355 final URL in two ways: 

356 

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

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

359 for more on that approach. 

360 

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

362 

363 .. code-block:: ini 

364 

365 [wuttaweb] 

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

367 

368 :param request: Current request. 

369 

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

371 are: 

372 

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

374 

375 * ``vue`` 

376 * ``vue_resource`` 

377 * ``buefy`` 

378 * ``buefy_css`` 

379 * ``fontawesome`` 

380 

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

382 

383 * ``bb_vue`` 

384 * ``bb_oruga`` 

385 * ``bb_oruga_bulma`` 

386 * ``bb_oruga_bulma_css`` 

387 * ``bb_fontawesome_svg_core`` 

388 * ``bb_free_solid_svg_icons`` 

389 * ``bb_vue_fontawesome`` 

390 

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

392 

393 * ``cc_vue`` 

394 * ``cc_buefy`` 

395 * ``cc_buefy_css`` 

396 * ``cc_fontawesome_svg_core`` 

397 * ``cc_free_solid_svg_icons`` 

398 * ``cc_vue_fontawesome`` 

399 

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

401 configured URL and ignore the default URL. 

402 

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

404 default URL and ignore the configured URL. 

405 

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

407 config lookups. 

408 

409 .. warning:: 

410 

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

412 be removed in the future. 

413 

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

415 in some cases. 

416 """ 

417 config = request.wutta_config 

418 

419 if key == "buefy.css": 

420 warnings.warn( 

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

422 DeprecationWarning, 

423 stacklevel=2, 

424 ) 

425 key = "buefy_css" 

426 

427 if not default_only: 

428 

429 # nb. new/preferred setting 

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

431 if url: 

432 return url 

433 

434 # maybe try deprecated key for buefy.css 

435 if key == "buefy_css": 

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

437 if version: 

438 warnings.warn( 

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

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

441 DeprecationWarning, 

442 ) 

443 return version 

444 

445 # fallback to caller-specified prefix 

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

447 if url: 

448 warnings.warn( 

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

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

451 DeprecationWarning, 

452 ) 

453 return url 

454 

455 # maybe try deprecated key for buefy.css 

456 if key == "buefy_css": 

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

458 if version: 

459 warnings.warn( 

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

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

462 DeprecationWarning, 

463 ) 

464 return version 

465 

466 if configured_only: 

467 return None 

468 

469 version = get_libver( 

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

471 ) 

472 

473 # load fanstatic libcache if configured 

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

475 if not static: 

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

477 if static: 

478 warnings.warn( 

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

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

481 DeprecationWarning, 

482 ) 

483 if static: 

484 static = importlib.import_module(static) 

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

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

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

488 if request.script_name: 

489 liburl = request.script_name + liburl 

490 

491 if key == "buefy": 

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

493 return liburl + static.buefy_js.relpath 

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

495 

496 if key == "buefy_css": 

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

498 return liburl + static.buefy_css.relpath 

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

500 

501 if key == "vue": 

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

503 return liburl + static.vue_js.relpath 

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

505 

506 if key == "vue_resource": 

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

508 return liburl + static.vue_resource_js.relpath 

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

510 

511 if key == "fontawesome": 

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

513 return liburl + static.fontawesome_js.relpath 

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

515 

516 if key == "bb_vue": 

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

518 return liburl + static.bb_vue_js.relpath 

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

520 

521 if key == "bb_oruga": 

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

523 return liburl + static.bb_oruga_js.relpath 

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

525 

526 if key == "bb_oruga_bulma": 

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

528 return liburl + static.bb_oruga_bulma_js.relpath 

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

530 

531 if key == "bb_oruga_bulma_css": 

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

533 return liburl + static.bb_oruga_bulma_css.relpath 

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

535 

536 if key == "bb_fontawesome_svg_core": 

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

538 return liburl + static.bb_fontawesome_svg_core_js.relpath 

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

540 

541 if key == "bb_free_solid_svg_icons": 

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

543 return liburl + static.bb_free_solid_svg_icons_js.relpath 

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

545 

546 if key == "bb_vue_fontawesome": 

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

548 return liburl + static.bb_vue_fontawesome_js.relpath 

549 return ( 

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

551 ) 

552 

553 if key == "cc_vue": 

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

555 return liburl + static.cc_vue_js.relpath 

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

557 

558 if key == "cc_buefy": 

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

560 return liburl + static.cc_buefy_js.relpath 

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

562 

563 if key == "cc_buefy_css": 

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

565 return liburl + static.cc_buefy_css.relpath 

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

567 

568 if key == "cc_fontawesome_svg_core": 

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

570 return liburl + static.cc_fontawesome_svg_core_js.relpath 

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

572 

573 if key == "cc_free_solid_svg_icons": 

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

575 return liburl + static.cc_free_solid_svg_icons_js.relpath 

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

577 

578 if key == "cc_vue_fontawesome": 

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

580 return liburl + static.cc_vue_fontawesome_js.relpath 

581 return ( 

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

583 ) 

584 

585 return None 

586 

587 

588def get_csrf_token(request): 

589 """ 

590 Convenience function, returns the effective CSRF token (raw 

591 string) for the given request. 

592 

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

594 """ 

595 token = request.session.get_csrf_token() 

596 if token is None: 

597 token = request.session.new_csrf_token() 

598 return token 

599 

600 

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

602 """ 

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

604 e.g.: 

605 

606 .. code-block:: html 

607 

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

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

610 </div> 

611 

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

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

614 this in page templates: 

615 

616 .. code-block:: mako 

617 

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

619 ${h.csrf_token(request)} 

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

621 ${h.end_form()} 

622 

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

624 """ 

625 token = get_csrf_token(request) 

626 return HTML.tag( 

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

628 ) 

629 

630 

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

632 """ 

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

634 :term:`data model` class. 

635 

636 This logic only supports SQLAlchemy mapped classes and will use 

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

638 returns ``None``. 

639 

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

641 

642 :param model_class: Data model class. 

643 

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

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

646 relationship names are also included and generally preferred. 

647 

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

649 determined. 

650 """ 

651 try: 

652 mapper = sa.inspect(model_class) 

653 except sa.exc.NoInspectionAvailable: 

654 return None 

655 

656 if include_fk: 

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

658 else: 

659 fields = [ 

660 prop.key 

661 for prop in mapper.iterate_properties 

662 if not prop_is_fk(mapper, prop) 

663 ] 

664 

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

666 app = config.get_app() 

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

668 fields.remove("versions") 

669 

670 return fields 

671 

672 

673def prop_is_fk(mapper, prop): # pylint: disable=empty-docstring 

674 """ """ 

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

676 return False 

677 

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

679 for rel in mapper.relationships: 

680 rel_columns = [col.name for col in rel.local_columns] 

681 if rel_columns == prop_columns: 

682 return True 

683 

684 return False 

685 

686 

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

688 """ 

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

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

691 

692 :param value: Python value. 

693 

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

695 when logging warnings, if applicable. 

696 

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

698 already JSON-compatible. 

699 

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

701 JSON-serializable. 

702 """ 

703 

704 # convert null => None 

705 if value is colander.null: 

706 return None 

707 

708 if isinstance(value, dict): 

709 # recursively convert dict 

710 parent = dict(value) 

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

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

713 value = parent 

714 

715 elif isinstance(value, list): 

716 # recursively convert list 

717 parent = list(value) 

718 for i, v in enumerate(parent): 

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

720 value = parent 

721 

722 elif isinstance(value, set): 

723 # recursively convert set (as list) 

724 parent = list(value) 

725 for i, v in enumerate(parent): 

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

727 value = parent 

728 

729 elif isinstance(value, _uuid.UUID): 

730 # convert UUID to str 

731 value = value.hex 

732 

733 elif isinstance(value, decimal.Decimal): 

734 # convert decimal to float 

735 value = float(value) 

736 

737 # ensure JSON-compatibility, warn if problems 

738 try: 

739 json.dumps(value) 

740 except TypeError: 

741 if warn: 

742 prefix = "value" 

743 if key: 

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

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

746 value = str(value) 

747 if warn: 

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

749 

750 return value 

751 

752 

753def render_vue_finalize(vue_tagname, vue_component): 

754 """ 

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

756 

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

758 

759 .. code-block:: html 

760 

761 <script> 

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

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

764 </script> 

765 """ 

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

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

768 return HTML.tag( 

769 "script", 

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

771 ) 

772 

773 

774def make_users_grid(request, **kwargs): 

775 """ 

776 Make and return a users (sub)grid. 

777 

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

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

780 

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

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

783 

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

785 instance. 

786 """ 

787 config = request.wutta_config 

788 app = config.get_app() 

789 model = app.model 

790 web = app.get_web_handler() 

791 

792 if "key" not in kwargs: 

793 route_prefix = kwargs.pop("route_prefix") 

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

795 

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

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

798 

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

800 

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

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

803 

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

805 grid.set_link("person") 

806 grid.set_link("username") 

807 

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

809 

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

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

812 

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

814 

815 return grid 

816 

817 

818############################## 

819# theme functions 

820############################## 

821 

822 

823def get_available_themes(config): 

824 """ 

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

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

827 changing the global theme. 

828 

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

830 

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

832 

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

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

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

836 

837 You can override the list via config: 

838 

839 .. code-block:: ini 

840 

841 [wuttaweb] 

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

843 

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

845 

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

847 

848 :returns: List of theme names 

849 """ 

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

851 available = config.get_list( 

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

853 ) 

854 

855 # sort the list by name 

856 available.sort() 

857 

858 # make default theme the first option 

859 if "default" in available: 

860 available.remove("default") 

861 available.insert(0, "default") 

862 

863 return available 

864 

865 

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

867 """ 

868 Validate and return the "effective" theme. 

869 

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

871 "available" - see below). 

872 

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

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

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

876 while app is running.) 

877 

878 In either case if the theme is not listed in 

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

880 

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

882 

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

884 current theme per db setting. 

885 

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

887 

888 :returns: Name of theme. 

889 """ 

890 app = config.get_app() 

891 

892 if not theme: 

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

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

895 

896 # confirm requested theme is available 

897 available = get_available_themes(config) 

898 if theme not in available: 

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

900 

901 return theme 

902 

903 

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

905 """ 

906 Return the template path for effective theme. 

907 

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

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

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

911 will call first. 

912 

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

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

915 template path is assumed. 

916 

917 The default path would be expected to live under 

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

919 theme has a default template path of 

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

921 

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

923 

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

925 current theme per db setting. 

926 

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

928 

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

930 """ 

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

932 theme_path = config.get( 

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

934 ) 

935 return resource_path(theme_path) 

936 

937 

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

939 """ 

940 Set the effective theme for the running app. 

941 

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

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

944 

945 This will first validate the theme by calling 

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

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

948 

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

950 registry such that it overrides the Mako lookup directories. 

951 

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

953 ensure it survives app restart. 

954 """ 

955 config = request.wutta_config 

956 app = config.get_app() 

957 

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

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

960 

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

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

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

964 lookup = renderer.lookup 

965 

966 # overwrite first entry in lookup's directory list 

967 lookup.directories[0] = theme_path 

968 

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

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

971 

972 # persist current theme in db settings 

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

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

975 

976 # and cache in live app settings 

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