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
« 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"""
27import datetime
28import json
29import os
30import sys
31import subprocess
32from collections import OrderedDict
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
40class AppInfoView(MasterView): # pylint: disable=abstract-method
41 """
42 Master view for the core app info, to show/edit config etc.
44 Default route prefix is ``appinfo``.
46 Notable URLs provided by this class:
48 * ``/appinfo/``
49 * ``/appinfo/configure``
51 See also :class:`SettingView`.
52 """
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
67 grid_columns = [
68 "name",
69 "version",
70 "editable_project_location",
71 ]
73 # TODO: for tailbone backward compat with get_liburl() etc.
74 weblib_config_prefix = None
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
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 )
157 if not appinfo["node_type"]["value"]:
158 del appinfo["node_type"]
160 if appinfo["app_title"]["value"] == appinfo["node_title"]["value"]:
161 del appinfo["node_title"]
163 return appinfo
165 def get_grid_data( # pylint: disable=empty-docstring
166 self, columns=None, session=None
167 ):
168 """ """
170 # nb. init with empty data, only load it upon user request
171 if not self.request.GET.get("partial"):
172 return []
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())
181 # must avoid null values for sort to work right
182 for pkg in data:
183 pkg.setdefault("editable_project_location", "")
185 return data
187 def configure_grid(self, grid): # pylint: disable=empty-docstring
188 """ """
189 g = grid
190 super().configure_grid(g)
192 g.sort_multiple = False
194 # name
195 g.set_searchable("name")
197 # editable_project_location
198 g.set_searchable("editable_project_location")
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 )
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 ]
249 def getval(key):
250 return self.config.get(f"wuttaweb.{key}")
252 weblibs = self.get_weblibs()
253 for key in weblibs:
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 )
268 return simple_settings
270 def configure_check_timezone(self):
271 """
272 AJAX view to validate a user-specified timezone name.
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)}
285 def configure_get_context( # pylint: disable=empty-docstring,arguments-differ
286 self, **kwargs
287 ):
288 """ """
289 context = super().configure_get_context(**kwargs)
291 # default system timezone
292 dt = datetime.datetime.now().astimezone()
293 context["default_timezone"] = dt.tzname()
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
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()
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())
340 return context
342 @classmethod
343 def defaults(cls, config): # pylint: disable=empty-docstring
344 """ """
345 cls._defaults(config)
346 cls._appinfo_defaults(config)
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()
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 )
369class SettingView(MasterView): # pylint: disable=abstract-method
370 """
371 Master view for the "raw" settings table.
373 Default route prefix is ``settings``.
375 Notable URLs provided by this class:
377 * ``/settings/``
379 See also :class:`AppInfoView`.
380 """
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"
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)
396 # name
397 g.set_link("name")
399 def configure_form(self, form): # pylint: disable=empty-docstring
400 """ """
401 f = form
402 super().configure_form(f)
404 # name
405 f.set_validator("name", self.unique_name)
407 # value
408 # TODO: master should handle this (per column nullable)
409 f.set_required("value", False)
411 def unique_name(self, node, value): # pylint: disable=empty-docstring
412 """ """
413 model = self.app.model
414 session = self.Session()
416 query = session.query(model.Setting).filter(model.Setting.name == value)
418 if self.editing:
419 name = self.request.matchdict["name"]
420 query = query.filter(model.Setting.name != name)
422 if query.count():
423 node.raise_invalid("Setting name must be unique")
426def defaults(config, **kwargs): # pylint: disable=missing-function-docstring
427 base = globals()
429 AppInfoView = kwargs.get( # pylint: disable=invalid-name,redefined-outer-name
430 "AppInfoView", base["AppInfoView"]
431 )
432 AppInfoView.defaults(config)
434 SettingView = kwargs.get( # pylint: disable=invalid-name,redefined-outer-name
435 "SettingView", base["SettingView"]
436 )
437 SettingView.defaults(config)
440def includeme(config): # pylint: disable=missing-function-docstring
441 defaults(config)