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

138 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-24 16:36 -0500

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

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

3# 

4# wuttaweb -- Web App for Wutta Framework 

5# Copyright © 2024-2026 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""" 

24Views for app settings 

25""" 

26 

27import datetime 

28import json 

29import os 

30import sys 

31import subprocess 

32from collections import OrderedDict 

33 

34from wuttjamaican.db.model import Setting 

35from wuttjamaican.util import get_timezone_by_name 

36from wuttaweb.views import MasterView 

37from wuttaweb.util import get_libver, get_liburl 

38 

39 

40class AppInfoView(MasterView): # pylint: disable=abstract-method 

41 """ 

42 Master view for the core app info, to show/edit config etc. 

43 

44 Default route prefix is ``appinfo``. 

45 

46 Notable URLs provided by this class: 

47 

48 * ``/appinfo/`` 

49 * ``/appinfo/configure`` 

50 

51 See also :class:`SettingView`. 

52 """ 

53 

54 model_name = "AppInfo" 

55 model_title_plural = "App Info" 

56 route_prefix = "appinfo" 

57 filterable = False 

58 sort_on_backend = False 

59 sort_defaults = "name" 

60 paginated = False 

61 creatable = False 

62 viewable = False 

63 editable = False 

64 deletable = False 

65 configurable = True 

66 

67 grid_columns = [ 

68 "name", 

69 "version", 

70 "editable_project_location", 

71 ] 

72 

73 # TODO: for tailbone backward compat with get_liburl() etc. 

74 weblib_config_prefix = None 

75 

76 def get_template_context(self, context): # pylint: disable=empty-docstring 

77 """ """ 

78 context = super().get_template_context(context) 

79 if self.listing: 

80 context["appinfo"] = self.get_appinfo_dict() 

81 return context 

82 

83 def get_appinfo_dict(self): # pylint: disable=missing-function-docstring 

84 appinfo = OrderedDict( 

85 [ 

86 ( 

87 "distribution", 

88 { 

89 "label": "Distribution", 

90 "value": self.app.get_distribution() 

91 or f"?? - set config for `{self.app.appname}.app_dist`", 

92 }, 

93 ), 

94 ( 

95 "version", 

96 { 

97 "label": "Version", 

98 "value": self.app.get_version() 

99 or f"?? - set config for `{self.app.appname}.app_dist`", 

100 }, 

101 ), 

102 ( 

103 "app_title", 

104 { 

105 "label": "App Title", 

106 "value": self.app.get_title(), 

107 }, 

108 ), 

109 ( 

110 "node_type", 

111 { 

112 "label": "Node Type", 

113 "value": self.app.get_node_type(), 

114 }, 

115 ), 

116 ( 

117 "node_title", 

118 { 

119 "label": "Node Title", 

120 "value": self.app.get_node_title(), 

121 }, 

122 ), 

123 ( 

124 "db_backend", 

125 { 

126 "label": "DB Backend", 

127 "value": self.config.appdb_engine.dialect.name, 

128 }, 

129 ), 

130 ( 

131 "timezone", 

132 { 

133 "label": "Timezone", 

134 "value": self.app.get_timezone_name(), 

135 }, 

136 ), 

137 ( 

138 "production", 

139 { 

140 "label": "Production Mode", 

141 "value": "Yes" if self.config.production() else "No", 

142 }, 

143 ), 

144 ( 

145 "email_enabled", 

146 { 

147 "label": "Email Enabled", 

148 "value": ( 

149 "Yes" 

150 if self.app.get_email_handler().sending_is_enabled() 

151 else "No" 

152 ), 

153 }, 

154 ), 

155 ] 

156 ) 

157 

158 if not appinfo["node_type"]["value"]: 

159 del appinfo["node_type"] 

160 

161 if appinfo["app_title"]["value"] == appinfo["node_title"]["value"]: 

162 del appinfo["node_title"] 

163 

164 return appinfo 

165 

166 def get_grid_data( # pylint: disable=empty-docstring 

167 self, columns=None, session=None 

168 ): 

169 """ """ 

170 

171 # nb. init with empty data, only load it upon user request 

172 if not self.request.GET.get("partial"): 

173 return [] 

174 

175 # TODO: pretty sure this is not cross-platform. probably some 

176 # sort of pip methods belong on the app handler? or it should 

177 # have a pip handler for all that? 

178 pip = os.path.join(sys.prefix, "bin", "pip") 

179 output = subprocess.check_output([pip, "list", "--format=json"], text=True) 

180 data = json.loads(output.strip()) 

181 

182 # must avoid null values for sort to work right 

183 for pkg in data: 

184 pkg.setdefault("editable_project_location", "") 

185 

186 return data 

187 

188 def configure_grid(self, grid): # pylint: disable=empty-docstring 

189 """ """ 

190 g = grid 

191 super().configure_grid(g) 

192 

193 g.sort_multiple = False 

194 

195 # name 

196 g.set_searchable("name") 

197 

198 # editable_project_location 

199 g.set_searchable("editable_project_location") 

200 

201 def get_weblibs(self): # pylint: disable=empty-docstring 

202 """ """ 

203 return OrderedDict( 

204 [ 

205 ( 

206 "vue", 

207 { 

208 "theme": "default", 

209 "title": "Vue", 

210 }, 

211 ), 

212 ( 

213 "vue_resource", 

214 { 

215 "theme": "default", 

216 "title": "vue-resource", 

217 }, 

218 ), 

219 ( 

220 "buefy", 

221 { 

222 "theme": "default", 

223 "title": "Buefy", 

224 }, 

225 ), 

226 ( 

227 "buefy_css", 

228 { 

229 "theme": "default", 

230 "title": "Buefy CSS", 

231 }, 

232 ), 

233 ( 

234 "fontawesome", 

235 { 

236 "theme": "default", 

237 "title": "FontAwesome", 

238 }, 

239 ), 

240 ( 

241 "bb_vue", 

242 { 

243 "theme": "butterfly", 

244 "title": "vue", 

245 }, 

246 ), 

247 ( 

248 "bb_oruga", 

249 { 

250 "theme": "butterfly", 

251 "title": "@oruga-ui/oruga-next", 

252 }, 

253 ), 

254 ( 

255 "bb_oruga_bulma", 

256 { 

257 "theme": "butterfly", 

258 "title": "@oruga-ui/theme-bulma (JS)", 

259 }, 

260 ), 

261 ( 

262 "bb_oruga_bulma_css", 

263 { 

264 "theme": "butterfly", 

265 "title": "@oruga-ui/theme-bulma (CSS)", 

266 }, 

267 ), 

268 ( 

269 "bb_fontawesome_svg_core", 

270 { 

271 "theme": "butterfly", 

272 "title": "@fortawesome/fontawesome-svg-core", 

273 }, 

274 ), 

275 ( 

276 "bb_free_solid_svg_icons", 

277 { 

278 "theme": "butterfly", 

279 "title": "@fortawesome/free-solid-svg-icons", 

280 }, 

281 ), 

282 ( 

283 "bb_vue_fontawesome", 

284 { 

285 "theme": "butterfly", 

286 "title": "@fortawesome/vue-fontawesome", 

287 }, 

288 ), 

289 ( 

290 "cc_vue", 

291 { 

292 "theme": "caraway", 

293 "title": "vue", 

294 }, 

295 ), 

296 ( 

297 "cc_buefy", 

298 { 

299 "theme": "caraway", 

300 "title": "buefy", 

301 }, 

302 ), 

303 ( 

304 "cc_buefy_css", 

305 { 

306 "theme": "caraway", 

307 "title": "buefy (CSS)", 

308 }, 

309 ), 

310 ( 

311 "cc_fontawesome_svg_core", 

312 { 

313 "theme": "caraway", 

314 "title": "@fortawesome/fontawesome-svg-core", 

315 }, 

316 ), 

317 ( 

318 "cc_free_solid_svg_icons", 

319 { 

320 "theme": "caraway", 

321 "title": "@fortawesome/free-solid-svg-icons", 

322 }, 

323 ), 

324 ( 

325 "cc_vue_fontawesome", 

326 { 

327 "theme": "caraway", 

328 "title": "@fortawesome/vue-fontawesome", 

329 }, 

330 ), 

331 ] 

332 ) 

333 

334 def configure_get_simple_settings(self): # pylint: disable=empty-docstring 

335 """ """ 

336 simple_settings = [ 

337 # basics 

338 {"name": f"{self.config.appname}.app_title"}, 

339 {"name": f"{self.config.appname}.node_title"}, 

340 {"name": f"{self.config.appname}.production", "type": bool}, 

341 {"name": "wuttaweb.themes.expose_picker", "type": bool}, 

342 {"name": f"{self.config.appname}.timezone.default"}, 

343 {"name": f"{self.config.appname}.web.menus.handler.spec"}, 

344 # nb. this is deprecated; we define so it is auto-deleted 

345 # when we replace with newer setting 

346 {"name": f"{self.config.appname}.web.menus.handler_spec"}, 

347 # user/auth 

348 {"name": "wuttaweb.home_redirect_to_login", "type": bool, "default": False}, 

349 # grids 

350 {"name": "wuttaweb.grids.default_pagesize", "type": int}, 

351 ] 

352 

353 def getval(key): 

354 return self.config.get(f"wuttaweb.{key}") 

355 

356 weblibs = self.get_weblibs() 

357 for key in weblibs: 

358 

359 simple_settings.append( 

360 { 

361 "name": f"wuttaweb.libver.{key}", 

362 "default": getval(f"libver.{key}"), 

363 } 

364 ) 

365 simple_settings.append( 

366 { 

367 "name": f"wuttaweb.liburl.{key}", 

368 "default": getval(f"liburl.{key}"), 

369 } 

370 ) 

371 

372 return simple_settings 

373 

374 def configure_check_timezone(self): 

375 """ 

376 AJAX view to validate a user-specified timezone name. 

377 

378 Route name for this is: ``appinfo.check_timezone`` 

379 """ 

380 tzname = self.request.GET.get("tzname") 

381 if not tzname: 

382 return {"invalid": "Must provide 'tzname' parameter."} 

383 try: 

384 get_timezone_by_name(tzname) 

385 return {"invalid": False} 

386 except Exception as err: # pylint: disable=broad-exception-caught 

387 return {"invalid": str(err)} 

388 

389 def configure_get_context( # pylint: disable=empty-docstring,arguments-differ 

390 self, **kwargs 

391 ): 

392 """ """ 

393 context = super().configure_get_context(**kwargs) 

394 

395 # default system timezone 

396 dt = datetime.datetime.now().astimezone() 

397 context["default_timezone"] = dt.tzname() 

398 

399 # add registered menu handlers 

400 web = self.app.get_web_handler() 

401 handlers = web.get_menu_handler_specs() 

402 handlers = [{"spec": spec} for spec in handlers] 

403 context["menu_handlers"] = handlers 

404 

405 # add pagesize options 

406 g = self.make_grid() 

407 context["grid_pagesize_options"] = g.get_pagesize_options() 

408 context["grid_pagesize_default"] = g.get_pagesize(configured=False) 

409 

410 # add `weblibs` to context, based on config values 

411 context["weblibs"] = [] 

412 for key, weblib in self.get_weblibs().items(): 

413 context["weblibs"].append( 

414 { 

415 "key": key, 

416 "theme": weblib["theme"], 

417 "title": weblib["title"], 

418 # nb. these values are exactly as configured, and are 

419 # used for editing the settings 

420 "configured_version": get_libver( 

421 self.request, 

422 key, 

423 prefix=self.weblib_config_prefix, 

424 configured_only=True, 

425 ), 

426 "configured_url": get_liburl( 

427 self.request, 

428 key, 

429 prefix=self.weblib_config_prefix, 

430 configured_only=True, 

431 ), 

432 # nb. these are for display only 

433 "default_version": get_libver( 

434 self.request, 

435 key, 

436 prefix=self.weblib_config_prefix, 

437 default_only=True, 

438 ), 

439 "live_url": get_liburl( 

440 self.request, key, prefix=self.weblib_config_prefix 

441 ), 

442 } 

443 ) 

444 

445 return context 

446 

447 def configure_gather_settings( 

448 self, data, simple_settings=None 

449 ): # pylint: disable=empty-docstring 

450 """ """ 

451 settings = super().configure_gather_settings( 

452 data, simple_settings=simple_settings 

453 ) 

454 

455 # nb. as a special case, we use "0" to indicate the "default" 

456 # grid page size. but we don't actually want to *save* that 

457 # value in settings, hence we remove it here 

458 for setting in list(settings): 

459 if setting["name"] == "wuttaweb.grids.default_pagesize": 

460 if setting["value"] == "0": 

461 settings.remove(setting) 

462 break 

463 

464 return settings 

465 

466 @classmethod 

467 def defaults(cls, config): # pylint: disable=empty-docstring 

468 """ """ 

469 cls._defaults(config) 

470 cls._appinfo_defaults(config) 

471 

472 @classmethod 

473 def _appinfo_defaults(cls, config): 

474 route_prefix = cls.get_route_prefix() 

475 permission_prefix = cls.get_permission_prefix() 

476 url_prefix = cls.get_url_prefix() 

477 

478 # check timezone 

479 config.add_route( 

480 f"{route_prefix}.check_timezone", 

481 f"{url_prefix}/check-timezone", 

482 request_method="GET", 

483 ) 

484 config.add_view( 

485 cls, 

486 attr="configure_check_timezone", 

487 route_name=f"{route_prefix}.check_timezone", 

488 permission=f"{permission_prefix}.configure", 

489 renderer="json", 

490 ) 

491 

492 

493class SettingView(MasterView): # pylint: disable=abstract-method 

494 """ 

495 Master view for the "raw" settings table. 

496 

497 Default route prefix is ``settings``. 

498 

499 Notable URLs provided by this class: 

500 

501 * ``/settings/`` 

502 

503 See also :class:`AppInfoView`. 

504 """ 

505 

506 model_class = Setting 

507 model_title = "Raw Setting" 

508 deletable_bulk = True 

509 filter_defaults = { 

510 "name": {"active": True}, 

511 } 

512 sort_defaults = "name" 

513 

514 # TODO: master should handle this (per model key) 

515 def configure_grid(self, grid): # pylint: disable=empty-docstring 

516 """ """ 

517 g = grid 

518 super().configure_grid(g) 

519 

520 # name 

521 g.set_link("name") 

522 

523 def configure_form(self, form): # pylint: disable=empty-docstring 

524 """ """ 

525 f = form 

526 super().configure_form(f) 

527 

528 # name 

529 f.set_validator("name", self.unique_name) 

530 

531 # value 

532 # TODO: master should handle this (per column nullable) 

533 f.set_required("value", False) 

534 

535 def unique_name(self, node, value): # pylint: disable=empty-docstring 

536 """ """ 

537 model = self.app.model 

538 session = self.Session() 

539 

540 query = session.query(model.Setting).filter(model.Setting.name == value) 

541 

542 if self.editing: 

543 name = self.request.matchdict["name"] 

544 query = query.filter(model.Setting.name != name) 

545 

546 if query.count(): 

547 node.raise_invalid("Setting name must be unique") 

548 

549 

550def defaults(config, **kwargs): # pylint: disable=missing-function-docstring 

551 base = globals() 

552 

553 AppInfoView = kwargs.get( # pylint: disable=invalid-name,redefined-outer-name 

554 "AppInfoView", base["AppInfoView"] 

555 ) 

556 AppInfoView.defaults(config) 

557 

558 SettingView = kwargs.get( # pylint: disable=invalid-name,redefined-outer-name 

559 "SettingView", base["SettingView"] 

560 ) 

561 SettingView.defaults(config) 

562 

563 

564def includeme(config): # pylint: disable=missing-function-docstring 

565 defaults(config)