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

131 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-20 21:14 -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 ("vue", "(Vue2) Vue"), 

205 ("vue_resource", "(Vue2) vue-resource"), 

206 ("buefy", "(Vue2) Buefy"), 

207 ("buefy_css", "(Vue2) Buefy CSS"), 

208 ("fontawesome", "(Vue2) FontAwesome"), 

209 ("bb_vue", "(Vue3) vue"), 

210 ("bb_oruga", "(Vue3) @oruga-ui/oruga-next"), 

211 ("bb_oruga_bulma", "(Vue3) @oruga-ui/theme-bulma (JS)"), 

212 ("bb_oruga_bulma_css", "(Vue3) @oruga-ui/theme-bulma (CSS)"), 

213 ("bb_fontawesome_svg_core", "(Vue3) @fortawesome/fontawesome-svg-core"), 

214 ("bb_free_solid_svg_icons", "(Vue3) @fortawesome/free-solid-svg-icons"), 

215 ("bb_vue_fontawesome", "(Vue3) @fortawesome/vue-fontawesome"), 

216 ] 

217 ) 

218 

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

220 """ """ 

221 simple_settings = [ 

222 # basics 

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

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

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

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

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

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

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

230 # when we replace with newer setting 

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

232 # user/auth 

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

234 # email 

235 { 

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

237 "type": bool, 

238 "default": False, 

239 }, 

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

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

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

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

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

245 # grids 

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

247 ] 

248 

249 def getval(key): 

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

251 

252 weblibs = self.get_weblibs() 

253 for key in weblibs: 

254 

255 simple_settings.append( 

256 { 

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

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

259 } 

260 ) 

261 simple_settings.append( 

262 { 

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

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

265 } 

266 ) 

267 

268 return simple_settings 

269 

270 def configure_check_timezone(self): 

271 """ 

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

273 

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

275 """ 

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

277 if not tzname: 

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

279 try: 

280 get_timezone_by_name(tzname) 

281 return {"invalid": False} 

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

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

284 

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

286 self, **kwargs 

287 ): 

288 """ """ 

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

290 

291 # default system timezone 

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

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

294 

295 # add registered menu handlers 

296 web = self.app.get_web_handler() 

297 handlers = web.get_menu_handler_specs() 

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

299 context["menu_handlers"] = handlers 

300 

301 # add pagesize options 

302 g = self.make_grid() 

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

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

305 

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

307 weblibs = self.get_weblibs() 

308 for key in weblibs: 

309 title = weblibs[key] 

310 weblibs[key] = { 

311 "key": key, 

312 "title": title, 

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

314 # used for editing the settings 

315 "configured_version": get_libver( 

316 self.request, 

317 key, 

318 prefix=self.weblib_config_prefix, 

319 configured_only=True, 

320 ), 

321 "configured_url": get_liburl( 

322 self.request, 

323 key, 

324 prefix=self.weblib_config_prefix, 

325 configured_only=True, 

326 ), 

327 # nb. these are for display only 

328 "default_version": get_libver( 

329 self.request, 

330 key, 

331 prefix=self.weblib_config_prefix, 

332 default_only=True, 

333 ), 

334 "live_url": get_liburl( 

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

336 ), 

337 } 

338 context["weblibs"] = list(weblibs.values()) 

339 

340 return context 

341 

342 @classmethod 

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

344 """ """ 

345 cls._defaults(config) 

346 cls._appinfo_defaults(config) 

347 

348 @classmethod 

349 def _appinfo_defaults(cls, config): 

350 route_prefix = cls.get_route_prefix() 

351 permission_prefix = cls.get_permission_prefix() 

352 url_prefix = cls.get_url_prefix() 

353 

354 # check timezone 

355 config.add_route( 

356 f"{route_prefix}.check_timezone", 

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

358 request_method="GET", 

359 ) 

360 config.add_view( 

361 cls, 

362 attr="configure_check_timezone", 

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

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

365 renderer="json", 

366 ) 

367 

368 

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

370 """ 

371 Master view for the "raw" settings table. 

372 

373 Default route prefix is ``settings``. 

374 

375 Notable URLs provided by this class: 

376 

377 * ``/settings/`` 

378 

379 See also :class:`AppInfoView`. 

380 """ 

381 

382 model_class = Setting 

383 model_title = "Raw Setting" 

384 deletable_bulk = True 

385 filter_defaults = { 

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

387 } 

388 sort_defaults = "name" 

389 

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

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

392 """ """ 

393 g = grid 

394 super().configure_grid(g) 

395 

396 # name 

397 g.set_link("name") 

398 

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

400 """ """ 

401 f = form 

402 super().configure_form(f) 

403 

404 # name 

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

406 

407 # value 

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

409 f.set_required("value", False) 

410 

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

412 """ """ 

413 model = self.app.model 

414 session = self.Session() 

415 

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

417 

418 if self.editing: 

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

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

421 

422 if query.count(): 

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

424 

425 

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

427 base = globals() 

428 

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

430 "AppInfoView", base["AppInfoView"] 

431 ) 

432 AppInfoView.defaults(config) 

433 

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

435 "SettingView", base["SettingView"] 

436 ) 

437 SettingView.defaults(config) 

438 

439 

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

441 defaults(config)