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

279 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2025-12-28 15:23 -0600

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( # pylint: disable=too-many-return-statements,too-many-branches 

144 request, 

145 key, 

146 configured_only=False, 

147 default_only=False, 

148 prefix="wuttaweb", 

149): 

150 """ 

151 Return the appropriate version string for the web resource library 

152 identified by ``key``. 

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 # nb. we prefer a setting to be named like: wuttaweb.libver.vue 

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

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

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

195 

196 if not default_only: 

197 

198 # nb. new/preferred setting 

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

200 if version: 

201 return version 

202 

203 # fallback to caller-specified prefix 

204 if prefix != "wuttaweb": 

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

206 if version: 

207 warnings.warn( 

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

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

210 DeprecationWarning, 

211 ) 

212 return version 

213 

214 if key == "buefy": 

215 if not default_only: 

216 # nb. old/legacy setting 

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

218 if version: 

219 warnings.warn( 

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

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

222 DeprecationWarning, 

223 ) 

224 return version 

225 if not configured_only: 

226 return "0.9.25" 

227 

228 elif key == "buefy.css": 

229 # nb. this always returns something 

230 return get_libver( 

231 request, "buefy", default_only=default_only, configured_only=configured_only 

232 ) 

233 

234 elif key == "vue": 

235 if not default_only: 

236 # nb. old/legacy setting 

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

238 if version: 

239 warnings.warn( 

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

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

242 DeprecationWarning, 

243 ) 

244 return version 

245 if not configured_only: 

246 return "2.6.14" 

247 

248 elif key == "vue_resource": 

249 if not configured_only: 

250 return "1.5.3" 

251 

252 elif key == "fontawesome": 

253 if not configured_only: 

254 return "5.3.1" 

255 

256 elif key == "bb_vue": 

257 if not configured_only: 

258 return "3.5.18" 

259 

260 elif key == "bb_oruga": 

261 if not configured_only: 

262 return "0.11.4" 

263 

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

265 if not configured_only: 

266 return "0.7.3" 

267 

268 elif key == "bb_fontawesome_svg_core": 

269 if not configured_only: 

270 return "7.0.0" 

271 

272 elif key == "bb_free_solid_svg_icons": 

273 if not configured_only: 

274 return "7.0.0" 

275 

276 elif key == "bb_vue_fontawesome": 

277 if not configured_only: 

278 return "3.1.1" 

279 

280 return None 

281 

282 

283def get_liburl( # pylint: disable=too-many-return-statements,too-many-branches 

284 request, 

285 key, 

286 configured_only=False, 

287 default_only=False, 

288 prefix="wuttaweb", 

289): 

290 """ 

291 Return the appropriate URL for the web resource library identified 

292 by ``key``. 

293 

294 WuttaWeb makes certain assumptions about which libraries would be 

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

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

297 this function. 

298 

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

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

301 final URL in two ways: 

302 

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

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

305 for more on that approach. 

306 

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

308 

309 .. code-block:: ini 

310 

311 [wuttaweb] 

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

313 

314 :param request: Current request. 

315 

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

317 are: 

318 

319 Vue 2 + Buefy 

320 

321 * ``vue`` 

322 * ``vue_resource`` 

323 * ``buefy`` 

324 * ``buefy.css`` 

325 * ``fontawesome`` 

326 

327 Vue 3 + Oruga 

328 

329 * ``bb_vue`` 

330 * ``bb_oruga`` 

331 * ``bb_oruga_bulma`` 

332 * ``bb_oruga_bulma_css`` 

333 * ``bb_fontawesome_svg_core`` 

334 * ``bb_free_solid_svg_icons`` 

335 * ``bb_vue_fontawesome`` 

336 

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

338 configured URL and ignore the default URL. 

339 

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

341 default URL and ignore the configured URL. 

342 

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

344 config lookups. 

345 

346 .. warning:: 

347 

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

349 be removed in the future. 

350 

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

352 in some cases. 

353 """ 

354 config = request.wutta_config 

355 

356 if not default_only: 

357 

358 # nb. new/preferred setting 

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

360 if url: 

361 return url 

362 

363 # fallback to caller-specified prefix 

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

365 if url: 

366 warnings.warn( 

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

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

369 DeprecationWarning, 

370 ) 

371 return url 

372 

373 if configured_only: 

374 return None 

375 

376 version = get_libver( 

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

378 ) 

379 

380 # load fanstatic libcache if configured 

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

382 if not static: 

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

384 if static: 

385 warnings.warn( 

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

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

388 DeprecationWarning, 

389 ) 

390 if static: 

391 static = importlib.import_module(static) 

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

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

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

395 if request.script_name: 

396 liburl = request.script_name + liburl 

397 

398 if key == "buefy": 

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

400 return liburl + static.buefy_js.relpath 

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

402 

403 if key == "buefy.css": 

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

405 return liburl + static.buefy_css.relpath 

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

407 

408 if key == "vue": 

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

410 return liburl + static.vue_js.relpath 

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

412 

413 if key == "vue_resource": 

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

415 return liburl + static.vue_resource_js.relpath 

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

417 

418 if key == "fontawesome": 

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

420 return liburl + static.fontawesome_js.relpath 

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

422 

423 if key == "bb_vue": 

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

425 return liburl + static.bb_vue_js.relpath 

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

427 

428 if key == "bb_oruga": 

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

430 return liburl + static.bb_oruga_js.relpath 

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

432 

433 if key == "bb_oruga_bulma": 

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

435 return liburl + static.bb_oruga_bulma_js.relpath 

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

437 

438 if key == "bb_oruga_bulma_css": 

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

440 return liburl + static.bb_oruga_bulma_css.relpath 

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

442 

443 if key == "bb_fontawesome_svg_core": 

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

445 return liburl + static.bb_fontawesome_svg_core_js.relpath 

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

447 

448 if key == "bb_free_solid_svg_icons": 

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

450 return liburl + static.bb_free_solid_svg_icons_js.relpath 

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

452 

453 if key == "bb_vue_fontawesome": 

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

455 return liburl + static.bb_vue_fontawesome_js.relpath 

456 return ( 

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

458 ) 

459 

460 return None 

461 

462 

463def get_csrf_token(request): 

464 """ 

465 Convenience function, returns the effective CSRF token (raw 

466 string) for the given request. 

467 

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

469 """ 

470 token = request.session.get_csrf_token() 

471 if token is None: 

472 token = request.session.new_csrf_token() 

473 return token 

474 

475 

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

477 """ 

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

479 e.g.: 

480 

481 .. code-block:: html 

482 

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

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

485 </div> 

486 

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

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

489 this in page templates: 

490 

491 .. code-block:: mako 

492 

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

494 ${h.csrf_token(request)} 

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

496 ${h.end_form()} 

497 

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

499 """ 

500 token = get_csrf_token(request) 

501 return HTML.tag( 

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

503 ) 

504 

505 

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

507 """ 

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

509 :term:`data model` class. 

510 

511 This logic only supports SQLAlchemy mapped classes and will use 

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

513 returns ``None``. 

514 

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

516 

517 :param model_class: Data model class. 

518 

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

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

521 relationship names are also included and generally preferred. 

522 

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

524 determined. 

525 """ 

526 try: 

527 mapper = sa.inspect(model_class) 

528 except sa.exc.NoInspectionAvailable: 

529 return None 

530 

531 if include_fk: 

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

533 else: 

534 fields = [ 

535 prop.key 

536 for prop in mapper.iterate_properties 

537 if not prop_is_fk(mapper, prop) 

538 ] 

539 

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

541 app = config.get_app() 

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

543 fields.remove("versions") 

544 

545 return fields 

546 

547 

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

549 """ """ 

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

551 return False 

552 

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

554 for rel in mapper.relationships: 

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

556 if rel_columns == prop_columns: 

557 return True 

558 

559 return False 

560 

561 

562def make_json_safe(value, key=None, warn=True): 

563 """ 

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

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

566 

567 :param value: Python value. 

568 

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

570 when logging warnings, if applicable. 

571 

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

573 already JSON-compatible. 

574 

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

576 JSON-serializable. 

577 """ 

578 

579 # convert null => None 

580 if value is colander.null: 

581 return None 

582 

583 if isinstance(value, dict): 

584 # recursively convert dict 

585 parent = dict(value) 

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

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

588 value = parent 

589 

590 elif isinstance(value, list): 

591 # recursively convert list 

592 parent = list(value) 

593 for i, v in enumerate(parent): 

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

595 value = parent 

596 

597 elif isinstance(value, _uuid.UUID): 

598 # convert UUID to str 

599 value = value.hex 

600 

601 elif isinstance(value, decimal.Decimal): 

602 # convert decimal to float 

603 value = float(value) 

604 

605 # ensure JSON-compatibility, warn if problems 

606 try: 

607 json.dumps(value) 

608 except TypeError: 

609 if warn: 

610 prefix = "value" 

611 if key: 

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

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

614 value = str(value) 

615 if warn: 

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

617 

618 return value 

619 

620 

621def render_vue_finalize(vue_tagname, vue_component): 

622 """ 

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

624 

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

626 

627 .. code-block:: html 

628 

629 <script> 

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

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

632 </script> 

633 """ 

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

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

636 return HTML.tag( 

637 "script", 

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

639 ) 

640 

641 

642def make_users_grid(request, **kwargs): 

643 """ 

644 Make and return a users (sub)grid. 

645 

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

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

648 

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

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

651 

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

653 instance. 

654 """ 

655 config = request.wutta_config 

656 app = config.get_app() 

657 model = app.model 

658 web = app.get_web_handler() 

659 

660 if "key" not in kwargs: 

661 route_prefix = kwargs.pop("route_prefix") 

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

663 

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

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

666 

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

668 

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

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

671 

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

673 grid.set_link("person") 

674 grid.set_link("username") 

675 

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

677 

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

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

680 

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

682 

683 return grid 

684 

685 

686############################## 

687# theme functions 

688############################## 

689 

690 

691def get_available_themes(config): 

692 """ 

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

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

695 changing the global theme. 

696 

697 If config specifies a list, that will be honored. Otherwise the 

698 default list is: ``['default', 'butterfly']`` 

699 

700 Note that the 'default' theme is Vue 2 + Buefy, while 'butterfly' 

701 is Vue 3 + Oruga. 

702 

703 You can specify via config by setting e.g.: 

704 

705 .. code-block:: ini 

706 

707 [wuttaweb] 

708 themes.keys = default, butterfly, my-other-one 

709 

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

711 """ 

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

713 available = config.get_list( 

714 "wuttaweb.themes.keys", default=["default", "butterfly"] 

715 ) 

716 

717 # sort the list by name 

718 available.sort() 

719 

720 # make default theme the first option 

721 if "default" in available: 

722 available.remove("default") 

723 available.insert(0, "default") 

724 

725 return available 

726 

727 

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

729 """ 

730 Validate and return the "effective" theme. 

731 

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

733 "available" - see below). 

734 

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

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

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

738 while app is running.) 

739 

740 In either case if the theme is not listed in 

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

742 

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

744 

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

746 current theme per db setting. 

747 

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

749 

750 :returns: Name of theme. 

751 """ 

752 app = config.get_app() 

753 

754 if not theme: 

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

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

757 

758 # confirm requested theme is available 

759 available = get_available_themes(config) 

760 if theme not in available: 

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

762 

763 return theme 

764 

765 

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

767 """ 

768 Return the template path for effective theme. 

769 

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

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

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

773 will call first. 

774 

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

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

777 template path is assumed. 

778 

779 The default path would be expected to live under 

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

781 theme has a default template path of 

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

783 

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

785 

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

787 current theme per db setting. 

788 

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

790 

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

792 """ 

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

794 theme_path = config.get( 

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

796 ) 

797 return resource_path(theme_path) 

798 

799 

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

801 """ 

802 Set the effective theme for the running app. 

803 

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

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

806 

807 This will first validate the theme by calling 

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

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

810 

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

812 registry such that it overrides the Mako lookup directories. 

813 

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

815 ensure it survives app restart. 

816 """ 

817 config = request.wutta_config 

818 app = config.get_app() 

819 

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

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

822 

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

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

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

826 lookup = renderer.lookup 

827 

828 # overwrite first entry in lookup's directory list 

829 lookup.directories[0] = theme_path 

830 

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

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

833 

834 # persist current theme in db settings 

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

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

837 

838 # and cache in live app settings 

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