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

117 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2025-12-28 15:23 -0600

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

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

3# 

4# wuttaweb -- Web App for Wutta Framework 

5# Copyright © 2024-2025 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_grid_data( # pylint: disable=empty-docstring 

77 self, columns=None, session=None 

78 ): 

79 """ """ 

80 

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

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

83 return [] 

84 

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

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

87 # have a pip handler for all that? 

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

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

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

91 

92 # must avoid null values for sort to work right 

93 for pkg in data: 

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

95 

96 return data 

97 

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

99 """ """ 

100 g = grid 

101 super().configure_grid(g) 

102 

103 g.sort_multiple = False 

104 

105 # name 

106 g.set_searchable("name") 

107 

108 # editable_project_location 

109 g.set_searchable("editable_project_location") 

110 

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

112 """ """ 

113 return OrderedDict( 

114 [ 

115 ("vue", "(Vue2) Vue"), 

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

117 ("buefy", "(Vue2) Buefy"), 

118 ("buefy.css", "(Vue2) Buefy CSS"), 

119 ("fontawesome", "(Vue2) FontAwesome"), 

120 ("bb_vue", "(Vue3) vue"), 

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

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

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

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

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

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

127 ] 

128 ) 

129 

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

131 """ """ 

132 simple_settings = [ 

133 # basics 

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

135 {"name": f"{self.config.appname}.node_type"}, 

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

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

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

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

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

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

142 # when we replace with newer setting 

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

144 # user/auth 

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

146 # email 

147 { 

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

149 "type": bool, 

150 "default": False, 

151 }, 

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

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

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

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

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

157 ] 

158 

159 def getval(key): 

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

161 

162 weblibs = self.get_weblibs() 

163 for key in weblibs: 

164 

165 simple_settings.append( 

166 { 

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

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

169 } 

170 ) 

171 simple_settings.append( 

172 { 

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

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

175 } 

176 ) 

177 

178 return simple_settings 

179 

180 def configure_check_timezone(self): 

181 """ 

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

183 

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

185 """ 

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

187 if not tzname: 

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

189 try: 

190 get_timezone_by_name(tzname) 

191 return {"invalid": False} 

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

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

194 

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

196 self, **kwargs 

197 ): 

198 """ """ 

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

200 

201 # default system timezone 

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

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

204 

205 # add registered menu handlers 

206 web = self.app.get_web_handler() 

207 handlers = web.get_menu_handler_specs() 

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

209 context["menu_handlers"] = handlers 

210 

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

212 weblibs = self.get_weblibs() 

213 for key in weblibs: 

214 title = weblibs[key] 

215 weblibs[key] = { 

216 "key": key, 

217 "title": title, 

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

219 # used for editing the settings 

220 "configured_version": get_libver( 

221 self.request, 

222 key, 

223 prefix=self.weblib_config_prefix, 

224 configured_only=True, 

225 ), 

226 "configured_url": get_liburl( 

227 self.request, 

228 key, 

229 prefix=self.weblib_config_prefix, 

230 configured_only=True, 

231 ), 

232 # nb. these are for display only 

233 "default_version": get_libver( 

234 self.request, 

235 key, 

236 prefix=self.weblib_config_prefix, 

237 default_only=True, 

238 ), 

239 "live_url": get_liburl( 

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

241 ), 

242 } 

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

244 

245 return context 

246 

247 @classmethod 

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

249 """ """ 

250 cls._defaults(config) 

251 cls._appinfo_defaults(config) 

252 

253 @classmethod 

254 def _appinfo_defaults(cls, config): 

255 route_prefix = cls.get_route_prefix() 

256 permission_prefix = cls.get_permission_prefix() 

257 url_prefix = cls.get_url_prefix() 

258 

259 # check timezone 

260 config.add_route( 

261 f"{route_prefix}.check_timezone", 

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

263 request_method="GET", 

264 ) 

265 config.add_view( 

266 cls, 

267 attr="configure_check_timezone", 

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

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

270 renderer="json", 

271 ) 

272 

273 

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

275 """ 

276 Master view for the "raw" settings table. 

277 

278 Default route prefix is ``settings``. 

279 

280 Notable URLs provided by this class: 

281 

282 * ``/settings/`` 

283 

284 See also :class:`AppInfoView`. 

285 """ 

286 

287 model_class = Setting 

288 model_title = "Raw Setting" 

289 deletable_bulk = True 

290 filter_defaults = { 

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

292 } 

293 sort_defaults = "name" 

294 

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

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

297 """ """ 

298 g = grid 

299 super().configure_grid(g) 

300 

301 # name 

302 g.set_link("name") 

303 

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

305 """ """ 

306 f = form 

307 super().configure_form(f) 

308 

309 # name 

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

311 

312 # value 

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

314 f.set_required("value", False) 

315 

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

317 """ """ 

318 model = self.app.model 

319 session = self.Session() 

320 

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

322 

323 if self.editing: 

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

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

326 

327 if query.count(): 

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

329 

330 

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

332 base = globals() 

333 

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

335 "AppInfoView", base["AppInfoView"] 

336 ) 

337 AppInfoView.defaults(config) 

338 

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

340 "SettingView", base["SettingView"] 

341 ) 

342 SettingView.defaults(config) 

343 

344 

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

346 defaults(config)