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
« 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"""
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 (
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 )
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 ]
363 def getval(key):
364 return self.config.get(f"wuttaweb.{key}")
366 weblibs = self.get_weblibs()
367 for key in weblibs:
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 )
382 return simple_settings
384 def configure_check_timezone(self):
385 """
386 AJAX view to validate a user-specified timezone name.
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)}
399 def configure_get_context( # pylint: disable=empty-docstring,arguments-differ
400 self, **kwargs
401 ):
402 """ """
403 context = super().configure_get_context(**kwargs)
405 # default system timezone
406 dt = datetime.datetime.now().astimezone()
407 context["default_timezone"] = dt.tzname()
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
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()
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 )
455 return context
457 @classmethod
458 def defaults(cls, config): # pylint: disable=empty-docstring
459 """ """
460 cls._defaults(config)
461 cls._appinfo_defaults(config)
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()
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 )
484class SettingView(MasterView): # pylint: disable=abstract-method
485 """
486 Master view for the "raw" settings table.
488 Default route prefix is ``settings``.
490 Notable URLs provided by this class:
492 * ``/settings/``
494 See also :class:`AppInfoView`.
495 """
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"
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)
511 # name
512 g.set_link("name")
514 def configure_form(self, form): # pylint: disable=empty-docstring
515 """ """
516 f = form
517 super().configure_form(f)
519 # name
520 f.set_validator("name", self.unique_name)
522 # value
523 # TODO: master should handle this (per column nullable)
524 f.set_required("value", False)
526 def unique_name(self, node, value): # pylint: disable=empty-docstring
527 """ """
528 model = self.app.model
529 session = self.Session()
531 query = session.query(model.Setting).filter(model.Setting.name == value)
533 if self.editing:
534 name = self.request.matchdict["name"]
535 query = query.filter(model.Setting.name != name)
537 if query.count():
538 node.raise_invalid("Setting name must be unique")
541def defaults(config, **kwargs): # pylint: disable=missing-function-docstring
542 base = globals()
544 AppInfoView = kwargs.get( # pylint: disable=invalid-name,redefined-outer-name
545 "AppInfoView", base["AppInfoView"]
546 )
547 AppInfoView.defaults(config)
549 SettingView = kwargs.get( # pylint: disable=invalid-name,redefined-outer-name
550 "SettingView", base["SettingView"]
551 )
552 SettingView.defaults(config)
555def includeme(config): # pylint: disable=missing-function-docstring
556 defaults(config)