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

310 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-10 11:22 -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( # 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 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 return None 

311 

312 

313def get_liburl( 

314 request, 

315 key, 

316 configured_only=False, 

317 default_only=False, 

318 prefix="wuttaweb", 

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

320 """ 

321 Return the appropriate URL for the web resource library identified 

322 by ``key``. 

323 

324 WuttaWeb makes certain assumptions about which libraries would be 

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

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

327 this function. 

328 

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

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

331 final URL in two ways: 

332 

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

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

335 for more on that approach. 

336 

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

338 

339 .. code-block:: ini 

340 

341 [wuttaweb] 

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

343 

344 :param request: Current request. 

345 

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

347 are: 

348 

349 Vue 2 + Buefy 

350 

351 * ``vue`` 

352 * ``vue_resource`` 

353 * ``buefy`` 

354 * ``buefy_css`` 

355 * ``fontawesome`` 

356 

357 Vue 3 + Oruga 

358 

359 * ``bb_vue`` 

360 * ``bb_oruga`` 

361 * ``bb_oruga_bulma`` 

362 * ``bb_oruga_bulma_css`` 

363 * ``bb_fontawesome_svg_core`` 

364 * ``bb_free_solid_svg_icons`` 

365 * ``bb_vue_fontawesome`` 

366 

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

368 configured URL and ignore the default URL. 

369 

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

371 default URL and ignore the configured URL. 

372 

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

374 config lookups. 

375 

376 .. warning:: 

377 

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

379 be removed in the future. 

380 

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

382 in some cases. 

383 """ 

384 config = request.wutta_config 

385 

386 if key == "buefy.css": 

387 warnings.warn( 

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

389 DeprecationWarning, 

390 stacklevel=2, 

391 ) 

392 key = "buefy_css" 

393 

394 if not default_only: 

395 

396 # nb. new/preferred setting 

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

398 if url: 

399 return url 

400 

401 # maybe try deprecated key for buefy.css 

402 if key == "buefy_css": 

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

404 if version: 

405 warnings.warn( 

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

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

408 DeprecationWarning, 

409 ) 

410 return version 

411 

412 # fallback to caller-specified prefix 

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

414 if url: 

415 warnings.warn( 

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

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

418 DeprecationWarning, 

419 ) 

420 return url 

421 

422 # maybe try deprecated key for buefy.css 

423 if key == "buefy_css": 

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

425 if version: 

426 warnings.warn( 

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

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

429 DeprecationWarning, 

430 ) 

431 return version 

432 

433 if configured_only: 

434 return None 

435 

436 version = get_libver( 

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

438 ) 

439 

440 # load fanstatic libcache if configured 

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

442 if not static: 

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

444 if static: 

445 warnings.warn( 

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

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

448 DeprecationWarning, 

449 ) 

450 if static: 

451 static = importlib.import_module(static) 

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

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

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

455 if request.script_name: 

456 liburl = request.script_name + liburl 

457 

458 if key == "buefy": 

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

460 return liburl + static.buefy_js.relpath 

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

462 

463 if key == "buefy_css": 

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

465 return liburl + static.buefy_css.relpath 

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

467 

468 if key == "vue": 

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

470 return liburl + static.vue_js.relpath 

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

472 

473 if key == "vue_resource": 

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

475 return liburl + static.vue_resource_js.relpath 

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

477 

478 if key == "fontawesome": 

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

480 return liburl + static.fontawesome_js.relpath 

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

482 

483 if key == "bb_vue": 

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

485 return liburl + static.bb_vue_js.relpath 

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

487 

488 if key == "bb_oruga": 

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

490 return liburl + static.bb_oruga_js.relpath 

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

492 

493 if key == "bb_oruga_bulma": 

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

495 return liburl + static.bb_oruga_bulma_js.relpath 

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

497 

498 if key == "bb_oruga_bulma_css": 

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

500 return liburl + static.bb_oruga_bulma_css.relpath 

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

502 

503 if key == "bb_fontawesome_svg_core": 

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

505 return liburl + static.bb_fontawesome_svg_core_js.relpath 

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

507 

508 if key == "bb_free_solid_svg_icons": 

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

510 return liburl + static.bb_free_solid_svg_icons_js.relpath 

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

512 

513 if key == "bb_vue_fontawesome": 

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

515 return liburl + static.bb_vue_fontawesome_js.relpath 

516 return ( 

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

518 ) 

519 

520 return None 

521 

522 

523def get_csrf_token(request): 

524 """ 

525 Convenience function, returns the effective CSRF token (raw 

526 string) for the given request. 

527 

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

529 """ 

530 token = request.session.get_csrf_token() 

531 if token is None: 

532 token = request.session.new_csrf_token() 

533 return token 

534 

535 

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

537 """ 

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

539 e.g.: 

540 

541 .. code-block:: html 

542 

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

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

545 </div> 

546 

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

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

549 this in page templates: 

550 

551 .. code-block:: mako 

552 

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

554 ${h.csrf_token(request)} 

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

556 ${h.end_form()} 

557 

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

559 """ 

560 token = get_csrf_token(request) 

561 return HTML.tag( 

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

563 ) 

564 

565 

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

567 """ 

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

569 :term:`data model` class. 

570 

571 This logic only supports SQLAlchemy mapped classes and will use 

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

573 returns ``None``. 

574 

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

576 

577 :param model_class: Data model class. 

578 

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

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

581 relationship names are also included and generally preferred. 

582 

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

584 determined. 

585 """ 

586 try: 

587 mapper = sa.inspect(model_class) 

588 except sa.exc.NoInspectionAvailable: 

589 return None 

590 

591 if include_fk: 

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

593 else: 

594 fields = [ 

595 prop.key 

596 for prop in mapper.iterate_properties 

597 if not prop_is_fk(mapper, prop) 

598 ] 

599 

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

601 app = config.get_app() 

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

603 fields.remove("versions") 

604 

605 return fields 

606 

607 

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

609 """ """ 

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

611 return False 

612 

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

614 for rel in mapper.relationships: 

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

616 if rel_columns == prop_columns: 

617 return True 

618 

619 return False 

620 

621 

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

623 """ 

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

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

626 

627 :param value: Python value. 

628 

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

630 when logging warnings, if applicable. 

631 

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

633 already JSON-compatible. 

634 

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

636 JSON-serializable. 

637 """ 

638 

639 # convert null => None 

640 if value is colander.null: 

641 return None 

642 

643 if isinstance(value, dict): 

644 # recursively convert dict 

645 parent = dict(value) 

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

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

648 value = parent 

649 

650 elif isinstance(value, list): 

651 # recursively convert list 

652 parent = list(value) 

653 for i, v in enumerate(parent): 

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

655 value = parent 

656 

657 elif isinstance(value, set): 

658 # recursively convert set (as list) 

659 parent = list(value) 

660 for i, v in enumerate(parent): 

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

662 value = parent 

663 

664 elif isinstance(value, _uuid.UUID): 

665 # convert UUID to str 

666 value = value.hex 

667 

668 elif isinstance(value, decimal.Decimal): 

669 # convert decimal to float 

670 value = float(value) 

671 

672 # ensure JSON-compatibility, warn if problems 

673 try: 

674 json.dumps(value) 

675 except TypeError: 

676 if warn: 

677 prefix = "value" 

678 if key: 

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

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

681 value = str(value) 

682 if warn: 

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

684 

685 return value 

686 

687 

688def render_vue_finalize(vue_tagname, vue_component): 

689 """ 

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

691 

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

693 

694 .. code-block:: html 

695 

696 <script> 

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

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

699 </script> 

700 """ 

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

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

703 return HTML.tag( 

704 "script", 

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

706 ) 

707 

708 

709def make_users_grid(request, **kwargs): 

710 """ 

711 Make and return a users (sub)grid. 

712 

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

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

715 

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

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

718 

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

720 instance. 

721 """ 

722 config = request.wutta_config 

723 app = config.get_app() 

724 model = app.model 

725 web = app.get_web_handler() 

726 

727 if "key" not in kwargs: 

728 route_prefix = kwargs.pop("route_prefix") 

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

730 

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

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

733 

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

735 

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

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

738 

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

740 grid.set_link("person") 

741 grid.set_link("username") 

742 

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

744 

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

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

747 

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

749 

750 return grid 

751 

752 

753############################## 

754# theme functions 

755############################## 

756 

757 

758def get_available_themes(config): 

759 """ 

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

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

762 changing the global theme. 

763 

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

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

766 

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

768 is Vue 3 + Oruga. 

769 

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

771 

772 .. code-block:: ini 

773 

774 [wuttaweb] 

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

776 

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

778 """ 

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

780 available = config.get_list( 

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

782 ) 

783 

784 # sort the list by name 

785 available.sort() 

786 

787 # make default theme the first option 

788 if "default" in available: 

789 available.remove("default") 

790 available.insert(0, "default") 

791 

792 return available 

793 

794 

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

796 """ 

797 Validate and return the "effective" theme. 

798 

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

800 "available" - see below). 

801 

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

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

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

805 while app is running.) 

806 

807 In either case if the theme is not listed in 

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

809 

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

811 

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

813 current theme per db setting. 

814 

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

816 

817 :returns: Name of theme. 

818 """ 

819 app = config.get_app() 

820 

821 if not theme: 

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

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

824 

825 # confirm requested theme is available 

826 available = get_available_themes(config) 

827 if theme not in available: 

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

829 

830 return theme 

831 

832 

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

834 """ 

835 Return the template path for effective theme. 

836 

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

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

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

840 will call first. 

841 

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

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

844 template path is assumed. 

845 

846 The default path would be expected to live under 

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

848 theme has a default template path of 

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

850 

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

852 

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

854 current theme per db setting. 

855 

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

857 

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

859 """ 

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

861 theme_path = config.get( 

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

863 ) 

864 return resource_path(theme_path) 

865 

866 

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

868 """ 

869 Set the effective theme for the running app. 

870 

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

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

873 

874 This will first validate the theme by calling 

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

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

877 

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

879 registry such that it overrides the Mako lookup directories. 

880 

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

882 ensure it survives app restart. 

883 """ 

884 config = request.wutta_config 

885 app = config.get_app() 

886 

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

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

889 

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

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

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

893 lookup = renderer.lookup 

894 

895 # overwrite first entry in lookup's directory list 

896 lookup.directories[0] = theme_path 

897 

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

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

900 

901 # persist current theme in db settings 

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

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

904 

905 # and cache in live app settings 

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