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

197 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-11-19 20:23 -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 importlib 

28import json 

29import logging 

30import warnings 

31 

32import sqlalchemy as sa 

33 

34import colander 

35from webhelpers2.html import HTML, tags 

36 

37 

38log = logging.getLogger(__name__) 

39 

40 

41class FieldList(list): 

42 """ 

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

44 of :class:`python:list`. 

45 

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

47 is used under the hood for 

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

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

50 """ 

51 

52 def insert_before(self, field, newfield): 

53 """ 

54 Insert a new field, before an existing field. 

55 

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

57 

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

59 just before the existing ``field``. 

60 """ 

61 if field in self: 

62 i = self.index(field) 

63 self.insert(i, newfield) 

64 else: 

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

66 field, newfield) 

67 self.append(newfield) 

68 

69 def insert_after(self, field, newfield): 

70 """ 

71 Insert a new field, after an existing field. 

72 

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

74 

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

76 just after the existing ``field``. 

77 """ 

78 if field in self: 

79 i = self.index(field) 

80 self.insert(i + 1, newfield) 

81 else: 

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

83 field, newfield) 

84 self.append(newfield) 

85 

86 def set_sequence(self, fields): 

87 """ 

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

89 given fields list. 

90 

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

92 (potentially) rearranges the internal list elements. 

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

94 just the ones you care about. 

95 

96 The resulting field list will have the requested fields in 

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

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

99 will be placed *after* the requested fields. 

100 

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

102 """ 

103 unimportant = len(self) + 1 

104 

105 def getkey(field): 

106 if field in fields: 

107 return fields.index(field) 

108 return unimportant 

109 

110 self.sort(key=getkey) 

111 

112 

113def get_form_data(request): 

114 """ 

115 Returns the effective form data for the given request. 

116 

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

118 following, depending on various attributes of the request. 

119 

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

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

122 """ 

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

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

125 # there is a better way? see also 

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

127 if not request.POST and ( 

128 getattr(request, 'is_xhr', False) 

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

130 return request.json_body 

131 return request.POST 

132 

133 

134def get_libver( 

135 request, 

136 key, 

137 configured_only=False, 

138 default_only=False, 

139 prefix='wuttaweb', 

140): 

141 """ 

142 Return the appropriate version string for the web resource library 

143 identified by ``key``. 

144 

145 WuttaWeb makes certain assumptions about which libraries would be 

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

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

148 versions are used, hence this function. 

149 

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

151 override them, e.g.: 

152 

153 .. code-block:: ini 

154 

155 [wuttaweb] 

156 libver.bb_vue = 3.4.29 

157 

158 :param request: Current request. 

159 

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

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

162 

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

164 configured version and ignore the default version. 

165 

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

167 default version and ignore the configured version. 

168 

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

170 config lookups. 

171 

172 .. warning:: 

173 

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

175 be removed in the future. 

176 

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

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

179 """ 

180 config = request.wutta_config 

181 

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

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

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

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

186 

187 if not default_only: 

188 

189 # nb. new/preferred setting 

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

191 if version: 

192 return version 

193 

194 # fallback to caller-specified prefix 

195 if prefix != 'wuttaweb': 

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

197 if version: 

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

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

200 DeprecationWarning) 

201 return version 

202 

203 if key == 'buefy': 

204 if not default_only: 

205 # nb. old/legacy setting 

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

207 if version: 

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

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

210 DeprecationWarning) 

211 return version 

212 if not configured_only: 

213 return '0.9.25' 

214 

215 elif key == 'buefy.css': 

216 # nb. this always returns something 

217 return get_libver(request, 'buefy', 

218 default_only=default_only, 

219 configured_only=configured_only) 

220 

221 elif key == 'vue': 

222 if not default_only: 

223 # nb. old/legacy setting 

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

225 if version: 

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

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

228 DeprecationWarning) 

229 return version 

230 if not configured_only: 

231 return '2.6.14' 

232 

233 elif key == 'vue_resource': 

234 if not configured_only: 

235 return '1.5.3' 

236 

237 elif key == 'fontawesome': 

238 if not configured_only: 

239 return '5.3.1' 

240 

241 elif key == 'bb_vue': 

242 if not configured_only: 

243 return '3.4.31' 

244 

245 elif key == 'bb_oruga': 

246 if not configured_only: 

247 return '0.8.12' 

248 

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

250 if not configured_only: 

251 return '0.3.0' 

252 

253 elif key == 'bb_fontawesome_svg_core': 

254 if not configured_only: 

255 return '6.5.2' 

256 

257 elif key == 'bb_free_solid_svg_icons': 

258 if not configured_only: 

259 return '6.5.2' 

260 

261 elif key == 'bb_vue_fontawesome': 

262 if not configured_only: 

263 return '3.0.6' 

264 

265 

266def get_liburl( 

267 request, 

268 key, 

269 configured_only=False, 

270 default_only=False, 

271 prefix='wuttaweb', 

272): 

273 """ 

274 Return the appropriate URL for the web resource library identified 

275 by ``key``. 

276 

277 WuttaWeb makes certain assumptions about which libraries would be 

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

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

280 this function. 

281 

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

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

284 final URL in two ways: 

285 

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

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

288 for more on that approach. 

289 

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

291 

292 .. code-block:: ini 

293 

294 [wuttaweb] 

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

296 

297 :param request: Current request. 

298 

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

300 are: 

301 

302 Vue 2 + Buefy 

303 

304 * ``vue`` 

305 * ``vue_resource`` 

306 * ``buefy`` 

307 * ``buefy.css`` 

308 * ``fontawesome`` 

309 

310 Vue 3 + Oruga 

311 

312 * ``bb_vue`` 

313 * ``bb_oruga`` 

314 * ``bb_oruga_bulma`` 

315 * ``bb_oruga_bulma_css`` 

316 * ``bb_fontawesome_svg_core`` 

317 * ``bb_free_solid_svg_icons`` 

318 * ``bb_vue_fontawesome`` 

319 

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

321 configured URL and ignore the default URL. 

322 

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

324 default URL and ignore the configured URL. 

325 

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

327 config lookups. 

328 

329 .. warning:: 

330 

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

332 be removed in the future. 

333 

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

335 in some cases. 

336 """ 

337 config = request.wutta_config 

338 

339 if not default_only: 

340 

341 # nb. new/preferred setting 

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

343 if url: 

344 return url 

345 

346 # fallback to caller-specified prefix 

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

348 if url: 

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

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

351 DeprecationWarning) 

352 return url 

353 

354 if configured_only: 

355 return 

356 

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

358 configured_only=False, 

359 default_only=default_only) 

360 

361 # load fanstatic libcache if configured 

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

363 if not static: 

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

365 if static: 

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

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

368 DeprecationWarning) 

369 if static: 

370 static = importlib.import_module(static) 

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

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

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

374 if request.script_name: 

375 liburl = request.script_name + liburl 

376 

377 if key == 'buefy': 

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

379 return liburl + static.buefy_js.relpath 

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

381 

382 elif key == 'buefy.css': 

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

384 return liburl + static.buefy_css.relpath 

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

386 

387 elif key == 'vue': 

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

389 return liburl + static.vue_js.relpath 

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

391 

392 elif key == 'vue_resource': 

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

394 return liburl + static.vue_resource_js.relpath 

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

396 

397 elif key == 'fontawesome': 

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

399 return liburl + static.fontawesome_js.relpath 

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

401 

402 elif key == 'bb_vue': 

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

404 return liburl + static.bb_vue_js.relpath 

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

406 

407 elif key == 'bb_oruga': 

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

409 return liburl + static.bb_oruga_js.relpath 

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

411 

412 elif key == 'bb_oruga_bulma': 

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

414 return liburl + static.bb_oruga_bulma_js.relpath 

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

416 

417 elif key == 'bb_oruga_bulma_css': 

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

419 return liburl + static.bb_oruga_bulma_css.relpath 

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

421 

422 elif key == 'bb_fontawesome_svg_core': 

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

424 return liburl + static.bb_fontawesome_svg_core_js.relpath 

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

426 

427 elif key == 'bb_free_solid_svg_icons': 

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

429 return liburl + static.bb_free_solid_svg_icons_js.relpath 

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

431 

432 elif key == 'bb_vue_fontawesome': 

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

434 return liburl + static.bb_vue_fontawesome_js.relpath 

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

436 

437 

438def get_csrf_token(request): 

439 """ 

440 Convenience function, returns the effective CSRF token (raw 

441 string) for the given request. 

442 

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

444 """ 

445 token = request.session.get_csrf_token() 

446 if token is None: 

447 token = request.session.new_csrf_token() 

448 return token 

449 

450 

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

452 """ 

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

454 e.g.: 

455 

456 .. code-block:: html 

457 

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

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

460 </div> 

461 

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

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

464 this in page templates: 

465 

466 .. code-block:: mako 

467 

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

469 ${h.csrf_token(request)} 

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

471 ${h.end_form()} 

472 

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

474 """ 

475 token = get_csrf_token(request) 

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

477 

478 

479def get_model_fields(config, model_class=None): 

480 """ 

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

482 model class. 

483 

484 This logic only supports SQLAlchemy mapped classes and will use 

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

486 returns ``None``. 

487 """ 

488 if not model_class: 

489 return 

490 

491 try: 

492 mapper = sa.inspect(model_class) 

493 except sa.exc.NoInspectionAvailable: 

494 return 

495 

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

497 

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

499 app = config.get_app() 

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

501 fields.remove('versions') 

502 

503 return fields 

504 

505 

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

507 """ 

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

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

510 

511 :param value: Python value. 

512 

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

514 when logging warnings, if applicable. 

515 

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

517 already JSON-compatible. 

518 

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

520 JSON-serializable. 

521 """ 

522 

523 # convert null => None 

524 if value is colander.null: 

525 return None 

526 

527 # recursively convert dict 

528 if isinstance(value, dict): 

529 parent = dict(value) 

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

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

532 value = parent 

533 

534 # ensure JSON-compatibility, warn if problems 

535 try: 

536 json.dumps(value) 

537 except TypeError as error: 

538 if warn: 

539 prefix = "value" 

540 if key: 

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

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

543 value = str(value) 

544 if warn: 

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

546 

547 return value