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

218 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2024-12-28 21:19 -0600

1# -*- coding: utf-8; -*- 

2################################################################################ 

3# 

4# wuttaweb -- Web App for Wutta Framework 

5# Copyright © 2024 Lance Edgar 

6# 

7# This file is part of Wutta Framework. 

8# 

9# Wutta Framework is free software: you can redistribute it and/or modify it 

10# under the terms of the GNU General Public License as published by the Free 

11# Software Foundation, either version 3 of the License, or (at your option) any 

12# later version. 

13# 

14# Wutta Framework is distributed in the hope that it will be useful, but 

15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 

16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for 

17# more details. 

18# 

19# You should have received a copy of the GNU General Public License along with 

20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>. 

21# 

22################################################################################ 

23""" 

24Web Utilities 

25""" 

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 webhelpers2.html import HTML, tags 

39 

40 

41log = logging.getLogger(__name__) 

42 

43 

44class FieldList(list): 

45 """ 

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

47 of :class:`python:list`. 

48 

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

50 is used under the hood for 

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

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

53 """ 

54 

55 def insert_before(self, field, newfield): 

56 """ 

57 Insert a new field, before an existing field. 

58 

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

60 

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

62 just before the existing ``field``. 

63 """ 

64 if field in self: 

65 i = self.index(field) 

66 self.insert(i, newfield) 

67 else: 

68 log.warning("field '%s' not found, will append new field: %s", 

69 field, newfield) 

70 self.append(newfield) 

71 

72 def insert_after(self, field, newfield): 

73 """ 

74 Insert a new field, after an existing field. 

75 

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

77 

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

79 just after the existing ``field``. 

80 """ 

81 if field in self: 

82 i = self.index(field) 

83 self.insert(i + 1, newfield) 

84 else: 

85 log.warning("field '%s' not found, will append new field: %s", 

86 field, newfield) 

87 self.append(newfield) 

88 

89 def set_sequence(self, fields): 

90 """ 

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

92 given fields list. 

93 

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

95 (potentially) rearranges the internal list elements. 

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

97 just the ones you care about. 

98 

99 The resulting field list will have the requested fields in 

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

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

102 will be placed *after* the requested fields. 

103 

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

105 """ 

106 unimportant = len(self) + 1 

107 

108 def getkey(field): 

109 if field in fields: 

110 return fields.index(field) 

111 return unimportant 

112 

113 self.sort(key=getkey) 

114 

115 

116def get_form_data(request): 

117 """ 

118 Returns the effective form data for the given request. 

119 

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

121 following, depending on various attributes of the request. 

122 

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

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

125 """ 

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

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

128 # there is a better way? see also 

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

130 if not request.POST and ( 

131 getattr(request, 'is_xhr', False) 

132 or getattr(request, 'content_type', None) == 'application/json'): 

133 return request.json_body 

134 return request.POST 

135 

136 

137def get_libver( 

138 request, 

139 key, 

140 configured_only=False, 

141 default_only=False, 

142 prefix='wuttaweb', 

143): 

144 """ 

145 Return the appropriate version string for the web resource library 

146 identified by ``key``. 

147 

148 WuttaWeb makes certain assumptions about which libraries would be 

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

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

151 versions are used, hence this function. 

152 

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

154 override them, e.g.: 

155 

156 .. code-block:: ini 

157 

158 [wuttaweb] 

159 libver.bb_vue = 3.4.29 

160 

161 :param request: Current request. 

162 

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

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

165 

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

167 configured version and ignore the default version. 

168 

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

170 default version and ignore the configured version. 

171 

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

173 config lookups. 

174 

175 .. warning:: 

176 

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

178 be removed in the future. 

179 

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

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

182 """ 

183 config = request.wutta_config 

184 

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

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

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

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

189 

190 if not default_only: 

191 

192 # nb. new/preferred setting 

193 version = config.get(f'wuttaweb.libver.{key}') 

194 if version: 

195 return version 

196 

197 # fallback to caller-specified prefix 

198 if prefix != 'wuttaweb': 

199 version = config.get(f'{prefix}.libver.{key}') 

200 if version: 

201 warnings.warn(f"config for {prefix}.libver.{key} is deprecated; " 

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

203 DeprecationWarning) 

204 return version 

205 

206 if key == 'buefy': 

207 if not default_only: 

208 # nb. old/legacy setting 

209 version = config.get(f'{prefix}.buefy_version') 

210 if version: 

211 warnings.warn(f"config for {prefix}.buefy_version is deprecated; " 

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

213 DeprecationWarning) 

214 return version 

215 if not configured_only: 

216 return '0.9.25' 

217 

218 elif key == 'buefy.css': 

219 # nb. this always returns something 

220 return get_libver(request, 'buefy', 

221 default_only=default_only, 

222 configured_only=configured_only) 

223 

224 elif key == 'vue': 

225 if not default_only: 

226 # nb. old/legacy setting 

227 version = config.get(f'{prefix}.vue_version') 

228 if version: 

229 warnings.warn(f"config for {prefix}.vue_version is deprecated; " 

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

231 DeprecationWarning) 

232 return version 

233 if not configured_only: 

234 return '2.6.14' 

235 

236 elif key == 'vue_resource': 

237 if not configured_only: 

238 return '1.5.3' 

239 

240 elif key == 'fontawesome': 

241 if not configured_only: 

242 return '5.3.1' 

243 

244 elif key == 'bb_vue': 

245 if not configured_only: 

246 return '3.4.31' 

247 

248 elif key == 'bb_oruga': 

249 if not configured_only: 

250 return '0.8.12' 

251 

252 elif key in ('bb_oruga_bulma', 'bb_oruga_bulma_css'): 

253 if not configured_only: 

254 return '0.3.0' 

255 

256 elif key == 'bb_fontawesome_svg_core': 

257 if not configured_only: 

258 return '6.5.2' 

259 

260 elif key == 'bb_free_solid_svg_icons': 

261 if not configured_only: 

262 return '6.5.2' 

263 

264 elif key == 'bb_vue_fontawesome': 

265 if not configured_only: 

266 return '3.0.6' 

267 

268 

269def get_liburl( 

270 request, 

271 key, 

272 configured_only=False, 

273 default_only=False, 

274 prefix='wuttaweb', 

275): 

276 """ 

277 Return the appropriate URL for the web resource library identified 

278 by ``key``. 

279 

280 WuttaWeb makes certain assumptions about which libraries would be 

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

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

283 this function. 

284 

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

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

287 final URL in two ways: 

288 

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

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

291 for more on that approach. 

292 

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

294 

295 .. code-block:: ini 

296 

297 [wuttaweb] 

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

299 

300 :param request: Current request. 

301 

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

303 are: 

304 

305 Vue 2 + Buefy 

306 

307 * ``vue`` 

308 * ``vue_resource`` 

309 * ``buefy`` 

310 * ``buefy.css`` 

311 * ``fontawesome`` 

312 

313 Vue 3 + Oruga 

314 

315 * ``bb_vue`` 

316 * ``bb_oruga`` 

317 * ``bb_oruga_bulma`` 

318 * ``bb_oruga_bulma_css`` 

319 * ``bb_fontawesome_svg_core`` 

320 * ``bb_free_solid_svg_icons`` 

321 * ``bb_vue_fontawesome`` 

322 

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

324 configured URL and ignore the default URL. 

325 

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

327 default URL and ignore the configured URL. 

328 

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

330 config lookups. 

331 

332 .. warning:: 

333 

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

335 be removed in the future. 

336 

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

338 in some cases. 

339 """ 

340 config = request.wutta_config 

341 

342 if not default_only: 

343 

344 # nb. new/preferred setting 

345 url = config.get(f'wuttaweb.liburl.{key}') 

346 if url: 

347 return url 

348 

349 # fallback to caller-specified prefix 

350 url = config.get(f'{prefix}.liburl.{key}') 

351 if url: 

352 warnings.warn(f"config for {prefix}.liburl.{key} is deprecated; " 

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

354 DeprecationWarning) 

355 return url 

356 

357 if configured_only: 

358 return 

359 

360 version = get_libver(request, key, prefix=prefix, 

361 configured_only=False, 

362 default_only=default_only) 

363 

364 # load fanstatic libcache if configured 

365 static = config.get('wuttaweb.static_libcache.module') 

366 if not static: 

367 static = config.get(f'{prefix}.static_libcache.module') 

368 if static: 

369 warnings.warn(f"config for {prefix}.static_libcache.module is deprecated; " 

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

371 DeprecationWarning) 

372 if static: 

373 static = importlib.import_module(static) 

374 needed = request.environ['fanstatic.needed'] 

375 liburl = needed.library_url(static.libcache) + '/' 

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

377 if request.script_name: 

378 liburl = request.script_name + liburl 

379 

380 if key == 'buefy': 

381 if static and hasattr(static, 'buefy_js'): 

382 return liburl + static.buefy_js.relpath 

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

384 

385 elif key == 'buefy.css': 

386 if static and hasattr(static, 'buefy_css'): 

387 return liburl + static.buefy_css.relpath 

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

389 

390 elif key == 'vue': 

391 if static and hasattr(static, 'vue_js'): 

392 return liburl + static.vue_js.relpath 

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

394 

395 elif key == 'vue_resource': 

396 if static and hasattr(static, 'vue_resource_js'): 

397 return liburl + static.vue_resource_js.relpath 

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

399 

400 elif key == 'fontawesome': 

401 if static and hasattr(static, 'fontawesome_js'): 

402 return liburl + static.fontawesome_js.relpath 

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

404 

405 elif key == 'bb_vue': 

406 if static and hasattr(static, 'bb_vue_js'): 

407 return liburl + static.bb_vue_js.relpath 

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

409 

410 elif key == 'bb_oruga': 

411 if static and hasattr(static, 'bb_oruga_js'): 

412 return liburl + static.bb_oruga_js.relpath 

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

414 

415 elif key == 'bb_oruga_bulma': 

416 if static and hasattr(static, 'bb_oruga_bulma_js'): 

417 return liburl + static.bb_oruga_bulma_js.relpath 

418 return f'https://unpkg.com/@oruga-ui/theme-bulma@{version}/dist/bulma.mjs' 

419 

420 elif key == 'bb_oruga_bulma_css': 

421 if static and hasattr(static, 'bb_oruga_bulma_css'): 

422 return liburl + static.bb_oruga_bulma_css.relpath 

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

424 

425 elif key == 'bb_fontawesome_svg_core': 

426 if static and hasattr(static, 'bb_fontawesome_svg_core_js'): 

427 return liburl + static.bb_fontawesome_svg_core_js.relpath 

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

429 

430 elif key == 'bb_free_solid_svg_icons': 

431 if static and hasattr(static, 'bb_free_solid_svg_icons_js'): 

432 return liburl + static.bb_free_solid_svg_icons_js.relpath 

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

434 

435 elif key == 'bb_vue_fontawesome': 

436 if static and hasattr(static, 'bb_vue_fontawesome_js'): 

437 return liburl + static.bb_vue_fontawesome_js.relpath 

438 return f'https://cdn.jsdelivr.net/npm/@fortawesome/vue-fontawesome@{version}/+esm' 

439 

440 

441def get_csrf_token(request): 

442 """ 

443 Convenience function, returns the effective CSRF token (raw 

444 string) for the given request. 

445 

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

447 """ 

448 token = request.session.get_csrf_token() 

449 if token is None: 

450 token = request.session.new_csrf_token() 

451 return token 

452 

453 

454def render_csrf_token(request, name='_csrf'): 

455 """ 

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

457 e.g.: 

458 

459 .. code-block:: html 

460 

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

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

463 </div> 

464 

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

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

467 this in page templates: 

468 

469 .. code-block:: mako 

470 

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

472 ${h.csrf_token(request)} 

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

474 ${h.end_form()} 

475 

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

477 """ 

478 token = get_csrf_token(request) 

479 return HTML.tag('div', tags.hidden(name, value=token, id=None), style='display:none;') 

480 

481 

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

483 """ 

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

485 :term:`data model` class. 

486 

487 This logic only supports SQLAlchemy mapped classes and will use 

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

489 returns ``None``. 

490 

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

492 

493 :param model_class: Data model class. 

494 

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

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

497 relationship names are also included and generally preferred. 

498 

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

500 determined. 

501 """ 

502 try: 

503 mapper = sa.inspect(model_class) 

504 except sa.exc.NoInspectionAvailable: 

505 return 

506 

507 if include_fk: 

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

509 else: 

510 fields = [prop.key for prop in mapper.iterate_properties 

511 if not prop_is_fk(mapper, prop)] 

512 

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

514 app = config.get_app() 

515 if app.continuum_is_enabled() and 'versions' in fields: 

516 fields.remove('versions') 

517 

518 return fields 

519 

520 

521def prop_is_fk(mapper, prop): 

522 """ """ 

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

524 return False 

525 

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

527 for rel in mapper.relationships: 

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

529 if rel_columns == prop_columns: 

530 return True 

531 

532 return False 

533 

534 

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

536 """ 

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

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

539 

540 :param value: Python value. 

541 

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

543 when logging warnings, if applicable. 

544 

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

546 already JSON-compatible. 

547 

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

549 JSON-serializable. 

550 """ 

551 

552 # convert null => None 

553 if value is colander.null: 

554 return None 

555 

556 elif isinstance(value, dict): 

557 # recursively convert dict 

558 parent = dict(value) 

559 for key, value in parent.items(): 

560 parent[key] = make_json_safe(value, key=key, warn=warn) 

561 value = parent 

562 

563 elif isinstance(value, list): 

564 # recursively convert list 

565 parent = list(value) 

566 for i, value in enumerate(parent): 

567 parent[i] = make_json_safe(value, key=key, warn=warn) 

568 value = parent 

569 

570 elif isinstance(value, _uuid.UUID): 

571 # convert UUID to str 

572 value = value.hex 

573 

574 elif isinstance(value, decimal.Decimal): 

575 # convert decimal to float 

576 value = float(value) 

577 

578 # ensure JSON-compatibility, warn if problems 

579 try: 

580 json.dumps(value) 

581 except TypeError as error: 

582 if warn: 

583 prefix = "value" 

584 if key: 

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

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

587 value = str(value) 

588 if warn: 

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

590 

591 return value