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

129 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-21 13:06 -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 if self.listing: 

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

80 return context 

81 

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

83 appinfo = OrderedDict( 

84 [ 

85 ( 

86 "distribution", 

87 { 

88 "label": "Distribution", 

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

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

91 }, 

92 ), 

93 ( 

94 "version", 

95 { 

96 "label": "Version", 

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

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

99 }, 

100 ), 

101 ( 

102 "app_title", 

103 { 

104 "label": "App Title", 

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

106 }, 

107 ), 

108 ( 

109 "node_type", 

110 { 

111 "label": "Node Type", 

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

113 }, 

114 ), 

115 ( 

116 "node_title", 

117 { 

118 "label": "Node Title", 

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

120 }, 

121 ), 

122 ( 

123 "db_backend", 

124 { 

125 "label": "DB Backend", 

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

127 }, 

128 ), 

129 ( 

130 "timezone", 

131 { 

132 "label": "Timezone", 

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

134 }, 

135 ), 

136 ( 

137 "production", 

138 { 

139 "label": "Production Mode", 

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

141 }, 

142 ), 

143 ( 

144 "email_enabled", 

145 { 

146 "label": "Email Enabled", 

147 "value": ( 

148 "Yes" 

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

150 else "No" 

151 ), 

152 }, 

153 ), 

154 ] 

155 ) 

156 

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

158 del appinfo["node_type"] 

159 

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

161 del appinfo["node_title"] 

162 

163 return appinfo 

164 

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

166 self, columns=None, session=None 

167 ): 

168 """ """ 

169 

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

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

172 return [] 

173 

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

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

176 # have a pip handler for all that? 

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

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

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

180 

181 # must avoid null values for sort to work right 

182 for pkg in data: 

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

184 

185 return data 

186 

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

188 """ """ 

189 g = grid 

190 super().configure_grid(g) 

191 

192 g.sort_multiple = False 

193 

194 # name 

195 g.set_searchable("name") 

196 

197 # editable_project_location 

198 g.set_searchable("editable_project_location") 

199 

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

201 """ """ 

202 return OrderedDict( 

203 [ 

204 ( 

205 "vue", 

206 { 

207 "theme": "default", 

208 "title": "Vue", 

209 }, 

210 ), 

211 ( 

212 "vue_resource", 

213 { 

214 "theme": "default", 

215 "title": "vue-resource", 

216 }, 

217 ), 

218 ( 

219 "buefy", 

220 { 

221 "theme": "default", 

222 "title": "Buefy", 

223 }, 

224 ), 

225 ( 

226 "buefy_css", 

227 { 

228 "theme": "default", 

229 "title": "Buefy CSS", 

230 }, 

231 ), 

232 ( 

233 "fontawesome", 

234 { 

235 "theme": "default", 

236 "title": "FontAwesome", 

237 }, 

238 ), 

239 ( 

240 "bb_vue", 

241 { 

242 "theme": "butterfly", 

243 "title": "vue", 

244 }, 

245 ), 

246 ( 

247 "bb_oruga", 

248 { 

249 "theme": "butterfly", 

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

251 }, 

252 ), 

253 ( 

254 "bb_oruga_bulma", 

255 { 

256 "theme": "butterfly", 

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

258 }, 

259 ), 

260 ( 

261 "bb_oruga_bulma_css", 

262 { 

263 "theme": "butterfly", 

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

265 }, 

266 ), 

267 ( 

268 "bb_fontawesome_svg_core", 

269 { 

270 "theme": "butterfly", 

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

272 }, 

273 ), 

274 ( 

275 "bb_free_solid_svg_icons", 

276 { 

277 "theme": "butterfly", 

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

279 }, 

280 ), 

281 ( 

282 "bb_vue_fontawesome", 

283 { 

284 "theme": "butterfly", 

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

286 }, 

287 ), 

288 ( 

289 "cc_vue", 

290 { 

291 "theme": "caraway", 

292 "title": "vue", 

293 }, 

294 ), 

295 ( 

296 "cc_buefy", 

297 { 

298 "theme": "caraway", 

299 "title": "buefy", 

300 }, 

301 ), 

302 ( 

303 "cc_buefy_css", 

304 { 

305 "theme": "caraway", 

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

307 }, 

308 ), 

309 ( 

310 "cc_fontawesome_svg_core", 

311 { 

312 "theme": "caraway", 

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

314 }, 

315 ), 

316 ( 

317 "cc_free_solid_svg_icons", 

318 { 

319 "theme": "caraway", 

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

321 }, 

322 ), 

323 ( 

324 "cc_vue_fontawesome", 

325 { 

326 "theme": "caraway", 

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

328 }, 

329 ), 

330 ] 

331 ) 

332 

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

334 """ """ 

335 simple_settings = [ 

336 # basics 

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

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

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

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

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

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

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

344 # when we replace with newer setting 

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

346 # user/auth 

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

348 # email 

349 { 

350 "name": f"{self.config.appname}.mail.send_emails", 

351 "type": bool, 

352 "default": False, 

353 }, 

354 {"name": f"{self.config.appname}.email.default.sender"}, 

355 {"name": f"{self.config.appname}.email.default.subject"}, 

356 {"name": f"{self.config.appname}.email.default.to"}, 

357 {"name": f"{self.config.appname}.email.feedback.subject"}, 

358 {"name": f"{self.config.appname}.email.feedback.to"}, 

359 # grids 

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

361 ] 

362 

363 def getval(key): 

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

365 

366 weblibs = self.get_weblibs() 

367 for key in weblibs: 

368 

369 simple_settings.append( 

370 { 

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

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

373 } 

374 ) 

375 simple_settings.append( 

376 { 

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

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

379 } 

380 ) 

381 

382 return simple_settings 

383 

384 def configure_check_timezone(self): 

385 """ 

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

387 

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

389 """ 

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

391 if not tzname: 

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

393 try: 

394 get_timezone_by_name(tzname) 

395 return {"invalid": False} 

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

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

398 

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

400 self, **kwargs 

401 ): 

402 """ """ 

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

404 

405 # default system timezone 

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

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

408 

409 # add registered menu handlers 

410 web = self.app.get_web_handler() 

411 handlers = web.get_menu_handler_specs() 

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

413 context["menu_handlers"] = handlers 

414 

415 # add pagesize options 

416 g = self.make_grid() 

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

418 context["grid_pagesize_default"] = g.get_pagesize() 

419 

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

421 context["weblibs"] = [] 

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

423 context["weblibs"].append( 

424 { 

425 "key": key, 

426 "theme": weblib["theme"], 

427 "title": weblib["title"], 

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

429 # used for editing the settings 

430 "configured_version": get_libver( 

431 self.request, 

432 key, 

433 prefix=self.weblib_config_prefix, 

434 configured_only=True, 

435 ), 

436 "configured_url": get_liburl( 

437 self.request, 

438 key, 

439 prefix=self.weblib_config_prefix, 

440 configured_only=True, 

441 ), 

442 # nb. these are for display only 

443 "default_version": get_libver( 

444 self.request, 

445 key, 

446 prefix=self.weblib_config_prefix, 

447 default_only=True, 

448 ), 

449 "live_url": get_liburl( 

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

451 ), 

452 } 

453 ) 

454 

455 return context 

456 

457 @classmethod 

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

459 """ """ 

460 cls._defaults(config) 

461 cls._appinfo_defaults(config) 

462 

463 @classmethod 

464 def _appinfo_defaults(cls, config): 

465 route_prefix = cls.get_route_prefix() 

466 permission_prefix = cls.get_permission_prefix() 

467 url_prefix = cls.get_url_prefix() 

468 

469 # check timezone 

470 config.add_route( 

471 f"{route_prefix}.check_timezone", 

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

473 request_method="GET", 

474 ) 

475 config.add_view( 

476 cls, 

477 attr="configure_check_timezone", 

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

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

480 renderer="json", 

481 ) 

482 

483 

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

485 """ 

486 Master view for the "raw" settings table. 

487 

488 Default route prefix is ``settings``. 

489 

490 Notable URLs provided by this class: 

491 

492 * ``/settings/`` 

493 

494 See also :class:`AppInfoView`. 

495 """ 

496 

497 model_class = Setting 

498 model_title = "Raw Setting" 

499 deletable_bulk = True 

500 filter_defaults = { 

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

502 } 

503 sort_defaults = "name" 

504 

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

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

507 """ """ 

508 g = grid 

509 super().configure_grid(g) 

510 

511 # name 

512 g.set_link("name") 

513 

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

515 """ """ 

516 f = form 

517 super().configure_form(f) 

518 

519 # name 

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

521 

522 # value 

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

524 f.set_required("value", False) 

525 

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

527 """ """ 

528 model = self.app.model 

529 session = self.Session() 

530 

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

532 

533 if self.editing: 

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

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

536 

537 if query.count(): 

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

539 

540 

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

542 base = globals() 

543 

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

545 "AppInfoView", base["AppInfoView"] 

546 ) 

547 AppInfoView.defaults(config) 

548 

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

550 "SettingView", base["SettingView"] 

551 ) 

552 SettingView.defaults(config) 

553 

554 

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

556 defaults(config)