Coverage for .tox/coverage/lib/python3.13/site-packages/wuttaweb/views/settings.py: 100%
138 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-24 16:36 -0500
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-24 16:36 -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 context = super().get_template_context(context)
79 if self.listing:
80 context["appinfo"] = self.get_appinfo_dict()
81 return context
83 def get_appinfo_dict(self): # pylint: disable=missing-function-docstring
84 appinfo = OrderedDict(
85 [
86 (
87 "distribution",
88 {
89 "label": "Distribution",
90 "value": self.app.get_distribution()
91 or f"?? - set config for `{self.app.appname}.app_dist`",
92 },
93 ),
94 (
95 "version",
96 {
97 "label": "Version",
98 "value": self.app.get_version()
99 or f"?? - set config for `{self.app.appname}.app_dist`",
100 },
101 ),
102 (
103 "app_title",
104 {
105 "label": "App Title",
106 "value": self.app.get_title(),
107 },
108 ),
109 (
110 "node_type",
111 {
112 "label": "Node Type",
113 "value": self.app.get_node_type(),
114 },
115 ),
116 (
117 "node_title",
118 {
119 "label": "Node Title",
120 "value": self.app.get_node_title(),
121 },
122 ),
123 (
124 "db_backend",
125 {
126 "label": "DB Backend",
127 "value": self.config.appdb_engine.dialect.name,
128 },
129 ),
130 (
131 "timezone",
132 {
133 "label": "Timezone",
134 "value": self.app.get_timezone_name(),
135 },
136 ),
137 (
138 "production",
139 {
140 "label": "Production Mode",
141 "value": "Yes" if self.config.production() else "No",
142 },
143 ),
144 (
145 "email_enabled",
146 {
147 "label": "Email Enabled",
148 "value": (
149 "Yes"
150 if self.app.get_email_handler().sending_is_enabled()
151 else "No"
152 ),
153 },
154 ),
155 ]
156 )
158 if not appinfo["node_type"]["value"]:
159 del appinfo["node_type"]
161 if appinfo["app_title"]["value"] == appinfo["node_title"]["value"]:
162 del appinfo["node_title"]
164 return appinfo
166 def get_grid_data( # pylint: disable=empty-docstring
167 self, columns=None, session=None
168 ):
169 """ """
171 # nb. init with empty data, only load it upon user request
172 if not self.request.GET.get("partial"):
173 return []
175 # TODO: pretty sure this is not cross-platform. probably some
176 # sort of pip methods belong on the app handler? or it should
177 # have a pip handler for all that?
178 pip = os.path.join(sys.prefix, "bin", "pip")
179 output = subprocess.check_output([pip, "list", "--format=json"], text=True)
180 data = json.loads(output.strip())
182 # must avoid null values for sort to work right
183 for pkg in data:
184 pkg.setdefault("editable_project_location", "")
186 return data
188 def configure_grid(self, grid): # pylint: disable=empty-docstring
189 """ """
190 g = grid
191 super().configure_grid(g)
193 g.sort_multiple = False
195 # name
196 g.set_searchable("name")
198 # editable_project_location
199 g.set_searchable("editable_project_location")
201 def get_weblibs(self): # pylint: disable=empty-docstring
202 """ """
203 return OrderedDict(
204 [
205 (
206 "vue",
207 {
208 "theme": "default",
209 "title": "Vue",
210 },
211 ),
212 (
213 "vue_resource",
214 {
215 "theme": "default",
216 "title": "vue-resource",
217 },
218 ),
219 (
220 "buefy",
221 {
222 "theme": "default",
223 "title": "Buefy",
224 },
225 ),
226 (
227 "buefy_css",
228 {
229 "theme": "default",
230 "title": "Buefy CSS",
231 },
232 ),
233 (
234 "fontawesome",
235 {
236 "theme": "default",
237 "title": "FontAwesome",
238 },
239 ),
240 (
241 "bb_vue",
242 {
243 "theme": "butterfly",
244 "title": "vue",
245 },
246 ),
247 (
248 "bb_oruga",
249 {
250 "theme": "butterfly",
251 "title": "@oruga-ui/oruga-next",
252 },
253 ),
254 (
255 "bb_oruga_bulma",
256 {
257 "theme": "butterfly",
258 "title": "@oruga-ui/theme-bulma (JS)",
259 },
260 ),
261 (
262 "bb_oruga_bulma_css",
263 {
264 "theme": "butterfly",
265 "title": "@oruga-ui/theme-bulma (CSS)",
266 },
267 ),
268 (
269 "bb_fontawesome_svg_core",
270 {
271 "theme": "butterfly",
272 "title": "@fortawesome/fontawesome-svg-core",
273 },
274 ),
275 (
276 "bb_free_solid_svg_icons",
277 {
278 "theme": "butterfly",
279 "title": "@fortawesome/free-solid-svg-icons",
280 },
281 ),
282 (
283 "bb_vue_fontawesome",
284 {
285 "theme": "butterfly",
286 "title": "@fortawesome/vue-fontawesome",
287 },
288 ),
289 (
290 "cc_vue",
291 {
292 "theme": "caraway",
293 "title": "vue",
294 },
295 ),
296 (
297 "cc_buefy",
298 {
299 "theme": "caraway",
300 "title": "buefy",
301 },
302 ),
303 (
304 "cc_buefy_css",
305 {
306 "theme": "caraway",
307 "title": "buefy (CSS)",
308 },
309 ),
310 (
311 "cc_fontawesome_svg_core",
312 {
313 "theme": "caraway",
314 "title": "@fortawesome/fontawesome-svg-core",
315 },
316 ),
317 (
318 "cc_free_solid_svg_icons",
319 {
320 "theme": "caraway",
321 "title": "@fortawesome/free-solid-svg-icons",
322 },
323 ),
324 (
325 "cc_vue_fontawesome",
326 {
327 "theme": "caraway",
328 "title": "@fortawesome/vue-fontawesome",
329 },
330 ),
331 ]
332 )
334 def configure_get_simple_settings(self): # pylint: disable=empty-docstring
335 """ """
336 simple_settings = [
337 # basics
338 {"name": f"{self.config.appname}.app_title"},
339 {"name": f"{self.config.appname}.node_title"},
340 {"name": f"{self.config.appname}.production", "type": bool},
341 {"name": "wuttaweb.themes.expose_picker", "type": bool},
342 {"name": f"{self.config.appname}.timezone.default"},
343 {"name": f"{self.config.appname}.web.menus.handler.spec"},
344 # nb. this is deprecated; we define so it is auto-deleted
345 # when we replace with newer setting
346 {"name": f"{self.config.appname}.web.menus.handler_spec"},
347 # user/auth
348 {"name": "wuttaweb.home_redirect_to_login", "type": bool, "default": False},
349 # grids
350 {"name": "wuttaweb.grids.default_pagesize", "type": int},
351 ]
353 def getval(key):
354 return self.config.get(f"wuttaweb.{key}")
356 weblibs = self.get_weblibs()
357 for key in weblibs:
359 simple_settings.append(
360 {
361 "name": f"wuttaweb.libver.{key}",
362 "default": getval(f"libver.{key}"),
363 }
364 )
365 simple_settings.append(
366 {
367 "name": f"wuttaweb.liburl.{key}",
368 "default": getval(f"liburl.{key}"),
369 }
370 )
372 return simple_settings
374 def configure_check_timezone(self):
375 """
376 AJAX view to validate a user-specified timezone name.
378 Route name for this is: ``appinfo.check_timezone``
379 """
380 tzname = self.request.GET.get("tzname")
381 if not tzname:
382 return {"invalid": "Must provide 'tzname' parameter."}
383 try:
384 get_timezone_by_name(tzname)
385 return {"invalid": False}
386 except Exception as err: # pylint: disable=broad-exception-caught
387 return {"invalid": str(err)}
389 def configure_get_context( # pylint: disable=empty-docstring,arguments-differ
390 self, **kwargs
391 ):
392 """ """
393 context = super().configure_get_context(**kwargs)
395 # default system timezone
396 dt = datetime.datetime.now().astimezone()
397 context["default_timezone"] = dt.tzname()
399 # add registered menu handlers
400 web = self.app.get_web_handler()
401 handlers = web.get_menu_handler_specs()
402 handlers = [{"spec": spec} for spec in handlers]
403 context["menu_handlers"] = handlers
405 # add pagesize options
406 g = self.make_grid()
407 context["grid_pagesize_options"] = g.get_pagesize_options()
408 context["grid_pagesize_default"] = g.get_pagesize(configured=False)
410 # add `weblibs` to context, based on config values
411 context["weblibs"] = []
412 for key, weblib in self.get_weblibs().items():
413 context["weblibs"].append(
414 {
415 "key": key,
416 "theme": weblib["theme"],
417 "title": weblib["title"],
418 # nb. these values are exactly as configured, and are
419 # used for editing the settings
420 "configured_version": get_libver(
421 self.request,
422 key,
423 prefix=self.weblib_config_prefix,
424 configured_only=True,
425 ),
426 "configured_url": get_liburl(
427 self.request,
428 key,
429 prefix=self.weblib_config_prefix,
430 configured_only=True,
431 ),
432 # nb. these are for display only
433 "default_version": get_libver(
434 self.request,
435 key,
436 prefix=self.weblib_config_prefix,
437 default_only=True,
438 ),
439 "live_url": get_liburl(
440 self.request, key, prefix=self.weblib_config_prefix
441 ),
442 }
443 )
445 return context
447 def configure_gather_settings(
448 self, data, simple_settings=None
449 ): # pylint: disable=empty-docstring
450 """ """
451 settings = super().configure_gather_settings(
452 data, simple_settings=simple_settings
453 )
455 # nb. as a special case, we use "0" to indicate the "default"
456 # grid page size. but we don't actually want to *save* that
457 # value in settings, hence we remove it here
458 for setting in list(settings):
459 if setting["name"] == "wuttaweb.grids.default_pagesize":
460 if setting["value"] == "0":
461 settings.remove(setting)
462 break
464 return settings
466 @classmethod
467 def defaults(cls, config): # pylint: disable=empty-docstring
468 """ """
469 cls._defaults(config)
470 cls._appinfo_defaults(config)
472 @classmethod
473 def _appinfo_defaults(cls, config):
474 route_prefix = cls.get_route_prefix()
475 permission_prefix = cls.get_permission_prefix()
476 url_prefix = cls.get_url_prefix()
478 # check timezone
479 config.add_route(
480 f"{route_prefix}.check_timezone",
481 f"{url_prefix}/check-timezone",
482 request_method="GET",
483 )
484 config.add_view(
485 cls,
486 attr="configure_check_timezone",
487 route_name=f"{route_prefix}.check_timezone",
488 permission=f"{permission_prefix}.configure",
489 renderer="json",
490 )
493class SettingView(MasterView): # pylint: disable=abstract-method
494 """
495 Master view for the "raw" settings table.
497 Default route prefix is ``settings``.
499 Notable URLs provided by this class:
501 * ``/settings/``
503 See also :class:`AppInfoView`.
504 """
506 model_class = Setting
507 model_title = "Raw Setting"
508 deletable_bulk = True
509 filter_defaults = {
510 "name": {"active": True},
511 }
512 sort_defaults = "name"
514 # TODO: master should handle this (per model key)
515 def configure_grid(self, grid): # pylint: disable=empty-docstring
516 """ """
517 g = grid
518 super().configure_grid(g)
520 # name
521 g.set_link("name")
523 def configure_form(self, form): # pylint: disable=empty-docstring
524 """ """
525 f = form
526 super().configure_form(f)
528 # name
529 f.set_validator("name", self.unique_name)
531 # value
532 # TODO: master should handle this (per column nullable)
533 f.set_required("value", False)
535 def unique_name(self, node, value): # pylint: disable=empty-docstring
536 """ """
537 model = self.app.model
538 session = self.Session()
540 query = session.query(model.Setting).filter(model.Setting.name == value)
542 if self.editing:
543 name = self.request.matchdict["name"]
544 query = query.filter(model.Setting.name != name)
546 if query.count():
547 node.raise_invalid("Setting name must be unique")
550def defaults(config, **kwargs): # pylint: disable=missing-function-docstring
551 base = globals()
553 AppInfoView = kwargs.get( # pylint: disable=invalid-name,redefined-outer-name
554 "AppInfoView", base["AppInfoView"]
555 )
556 AppInfoView.defaults(config)
558 SettingView = kwargs.get( # pylint: disable=invalid-name,redefined-outer-name
559 "SettingView", base["SettingView"]
560 )
561 SettingView.defaults(config)
564def includeme(config): # pylint: disable=missing-function-docstring
565 defaults(config)