tailbone/tailbone/views/settings.py
Lance Edgar da0f6bd5e1 feat: use wuttaweb for get_liburl() logic
thankfully this is already handled and we can remove from tailbone.
although this adds some new cruft as well, to handle auto-migrating
any existing liburl config for apps.

eventually once all apps have migrated to new settings we can remove
the prefix from our calls here but also in wuttaweb signature
2024-08-15 23:12:02 -05:00

512 lines
18 KiB
Python

# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Settings Views
"""
import json
import os
import re
import subprocess
import sys
from collections import OrderedDict
import colander
from rattail.db.model import Setting
from rattail.settings import Setting as AppSetting
from rattail.util import import_module_path
from tailbone import forms
from tailbone.db import Session
from tailbone.views import MasterView, View
from wuttaweb.util import get_libver, get_liburl
class AppInfoView(MasterView):
"""
Master view for the overall app, to show/edit config etc.
"""
route_prefix = 'appinfo'
model_key = 'UNUSED'
model_title = "UNUSED"
model_title_plural = "App Details"
creatable = False
viewable = False
editable = False
deletable = False
filterable = False
pageable = False
configurable = True
grid_columns = [
'name',
'version',
'editable_project_location',
]
def get_index_title(self):
app = self.get_rattail_app()
return "{} for {}".format(self.get_model_title_plural(),
app.get_title())
def get_data(self, session=None):
pip = os.path.join(sys.prefix, 'bin', 'pip')
output = subprocess.check_output([pip, 'list', '--format=json'])
data = json.loads(output.decode('utf_8').strip())
for pkg in data:
pkg.setdefault('editable_project_location', '')
return data
def configure_grid(self, g):
super().configure_grid(g)
g.sorters['name'] = g.make_simple_sorter('name', foldcase=True)
g.set_sort_defaults('name')
g.set_searchable('name')
g.sorters['version'] = g.make_simple_sorter('version', foldcase=True)
g.sorters['editable_project_location'] = g.make_simple_sorter(
'editable_project_location', foldcase=True)
g.set_searchable('editable_project_location')
def template_kwargs_index(self, **kwargs):
kwargs = super().template_kwargs_index(**kwargs)
kwargs['configure_button_title'] = "Configure App"
return kwargs
def get_weblibs(self):
""" """
return OrderedDict([
('vue', "Vue"),
('vue_resource', "vue-resource"),
('buefy', "Buefy"),
('buefy.css', "Buefy CSS"),
('fontawesome', "FontAwesome"),
('bb_vue', "(BB) vue"),
('bb_oruga', "(BB) @oruga-ui/oruga-next"),
('bb_oruga_bulma', "(BB) @oruga-ui/theme-bulma (JS)"),
('bb_oruga_bulma_css', "(BB) @oruga-ui/theme-bulma (CSS)"),
('bb_fontawesome_svg_core', "(BB) @fortawesome/fontawesome-svg-core"),
('bb_free_solid_svg_icons', "(BB) @fortawesome/free-solid-svg-icons"),
('bb_vue_fontawesome', "(BB) @fortawesome/vue-fontawesome"),
])
def configure_get_context(self, **kwargs):
""" """
context = super().configure_get_context(**kwargs)
simple_settings = context['simple_settings']
weblibs = self.get_weblibs()
for key in weblibs:
title = weblibs[key]
weblibs[key] = {
'key': key,
'title': title,
# nb. these values are exactly as configured, and are
# used for editing the settings
'configured_version': get_libver(self.request, key,
prefix='tailbone',
configured_only=True),
'configured_url': get_liburl(self.request, key,
prefix='tailbone',
configured_only=True),
# these are for informational purposes only
'default_version': get_libver(self.request, key,
prefix='tailbone',
default_only=True),
'live_url': get_liburl(self.request, key,
prefix='tailbone'),
}
# TODO: this is only needed to migrate legacy settings to
# use the newer wutaweb setting names
url = simple_settings[f'wuttaweb.liburl.{key}']
if not url and weblibs[key]['configured_url']:
simple_settings[f'wuttaweb.liburl.{key}'] = weblibs[key]['configured_url']
context['weblibs'] = list(weblibs.values())
return context
def configure_get_simple_settings(self):
""" """
simple_settings = [
# basics
{'section': 'rattail',
'option': 'app_title'},
{'section': 'rattail',
'option': 'node_type'},
{'section': 'rattail',
'option': 'node_title'},
{'section': 'rattail',
'option': 'production',
'type': bool},
{'section': 'rattail',
'option': 'running_from_source',
'type': bool},
{'section': 'rattail',
'option': 'running_from_source.rootpkg'},
# display
{'section': 'tailbone',
'option': 'background_color'},
# grids
{'section': 'tailbone',
'option': 'grid.default_pagesize',
# TODO: seems like should enforce this, but validation is
# not setup yet
# 'type': int
},
# nb. these are no longer used (deprecated), but we keep
# them defined here so the tool auto-deletes them
{'section': 'tailbone',
'option': 'buefy_version'},
{'section': 'tailbone',
'option': 'vue_version'},
]
def getval(key):
return self.config.get(f'tailbone.{key}')
weblibs = self.get_weblibs()
for key, title in weblibs.items():
simple_settings.append({
'section': 'wuttaweb',
'option': f"libver.{key}",
'default': getval(f"libver.{key}"),
})
simple_settings.append({
'section': 'wuttaweb',
'option': f"liburl.{key}",
'default': getval(f"liburl.{key}"),
})
# nb. these are no longer used (deprecated), but we keep
# them defined here so the tool auto-deletes them
simple_settings.append({
'section': 'tailbone',
'option': f"libver.{key}",
})
simple_settings.append({
'section': 'tailbone',
'option': f"liburl.{key}",
})
return simple_settings
class SettingView(MasterView):
"""
Master view for the settings model.
"""
model_class = Setting
model_title = "Raw Setting"
model_title_plural = "Raw Settings"
bulk_deletable = True
feedback = re.compile(r'^rattail\.mail\.user_feedback\..*')
grid_columns = [
'name',
'value',
]
def configure_grid(self, g):
super().configure_grid(g)
g.filters['name'].default_active = True
g.filters['name'].default_verb = 'contains'
g.set_sort_defaults('name')
g.set_link('name')
def configure_form(self, f):
super().configure_form(f)
if self.creating:
f.set_validator('name', self.unique_name)
def unique_name(self, node, value):
model = self.model
setting = self.Session.get(model.Setting, value)
if setting:
raise colander.Invalid(node, "Setting name must be unique")
def editable_instance(self, setting):
if self.rattail_config.demo():
return not bool(self.feedback.match(setting.name))
return True
def after_edit(self, setting):
# nb. force cache invalidation - normally this happens when a
# setting is saved via app handler, but here that is being
# bypassed and it is saved directly via standard ORM calls
self.rattail_config.beaker_invalidate_setting(setting.name)
def deletable_instance(self, setting):
if self.rattail_config.demo():
return not bool(self.feedback.match(setting.name))
return True
def delete_instance(self, setting):
# nb. force cache invalidation
self.rattail_config.beaker_invalidate_setting(setting.name)
# otherwise delete like normal
super().delete_instance(setting)
# TODO: deprecate / remove this
SettingsView = SettingView
class AppSettingsForm(forms.Form):
def get_label(self, key):
return self.labels.get(key, key)
class AppSettingsView(View):
"""
Core view which exposes "app settings" - aka. admin-friendly settings with
descriptions and type-specific form controls etc.
"""
def __call__(self):
settings = sorted(self.iter_known_settings(),
key=lambda setting: (setting.group,
setting.namespace,
setting.name))
groups = sorted(set([setting.group for setting in settings]))
current_group = None
form = self.make_form(settings)
form.cancel_url = self.request.current_route_url()
if form.validate():
self.save_form(form)
group = self.request.POST.get('settings-group')
if group is not None:
self.request.session['appsettings.current_group'] = group
self.request.session.flash("App Settings have been saved.")
return self.redirect(self.request.current_route_url())
if self.request.method == 'POST':
current_group = self.request.POST.get('settings-group')
if not current_group:
current_group = self.request.session.get('appsettings.current_group')
possible_config_options = sorted(
self.request.registry.settings['tailbone_config_pages'],
key=lambda p: p['label'])
config_options = []
for option in possible_config_options:
perm = option.get('perm', option['route'])
if self.request.has_perm(perm):
option['url'] = self.request.route_url(option['route'])
config_options.append(option)
context = {
'index_title': "App Settings",
'form': form,
'dform': form.make_deform_form(),
'groups': groups,
'settings': settings,
'config_options': config_options,
}
context['settings_data'] = self.get_settings_data(form, groups, settings)
# TODO: this seems hacky, and probably only needed if theme changes?
if current_group == '(All)':
current_group = ''
context['current_group'] = current_group
return context
def get_settings_data(self, form, groups, settings):
dform = form.make_deform_form()
grouped = dict([(label, [])
for label in groups])
for setting in settings:
field = dform[setting.node_name]
s = {
'field_name': field.name,
'label': form.get_label(field.name),
'data_type': setting.data_type.__name__,
'choices': setting.choices,
'helptext': form.render_helptext(field.name) if form.has_helptext(field.name) else None,
'error': False, # nb. may set to True below
}
# we want the value from the form, i.e. in case of a POST
# request with validation errors. we also want to make
# sure value is JSON-compatible, but we must represent it
# as Python value here, and it will be JSON-encoded later.
value = form.get_vuejs_model_value(field)
value = json.loads(value)
s['value'] = value
# specify error / message if applicable
# TODO: not entirely clear to me why some field errors are
# represented differently?
if field.error:
s['error'] = True
if isinstance(field.error, colander.Invalid):
s['error_messages'] = [field.errormsg]
else:
s['error_messages'] = field.error_messages()
grouped[setting.group].append(s)
data = []
for label in groups:
group = {'label': label, 'settings': grouped[label]}
data.append(group)
return data
def make_form(self, known_settings):
schema = colander.MappingSchema()
helptext = {}
for setting in known_settings:
kwargs = {
'name': setting.node_name,
'default': self.get_setting_value(setting),
}
if kwargs['default'] is None or kwargs['default'] == '':
kwargs['default'] = colander.null
if not setting.required:
kwargs['missing'] = colander.null
if setting.choices:
kwargs['validator'] = colander.OneOf(setting.choices)
kwargs['widget'] = forms.widgets.JQuerySelectWidget(
values=[(val, val) for val in setting.choices])
schema.add(colander.SchemaNode(self.get_node_type(setting), **kwargs))
helptext[setting.node_name] = setting.__doc__.strip()
return AppSettingsForm(schema=schema, request=self.request, helptext=helptext)
def get_node_type(self, setting):
if setting.data_type is bool:
return colander.Bool()
elif setting.data_type is int:
return colander.Integer()
return colander.String()
def save_form(self, form):
for setting in self.iter_known_settings():
value = form.validated[setting.node_name]
if value is colander.null:
value = ''
self.save_setting_value(setting, value)
def iter_known_settings(self):
"""
Iterate over all known settings.
"""
modules = self.rattail_config.getlist('rattail', 'settings')
if modules:
core_only = False
else:
modules = ['rattail.settings']
core_only = True
for module in modules:
module = import_module_path(module)
for name in dir(module):
obj = getattr(module, name)
if isinstance(obj, type) and issubclass(obj, AppSetting) and obj is not AppSetting:
if core_only and not obj.core:
continue
# NOTE: we set this here, and reference it elsewhere
obj.node_name = self.get_node_name(obj)
yield obj
def get_node_name(self, setting):
return '[{}] {}'.format(setting.namespace, setting.name)
def get_setting_value(self, setting):
if setting.data_type is bool:
return self.rattail_config.getbool(setting.namespace, setting.name)
if setting.data_type is list:
return '\n'.join(
self.rattail_config.getlist(setting.namespace, setting.name,
default=[]))
return self.rattail_config.get(setting.namespace, setting.name)
def save_setting_value(self, setting, value):
existing = self.get_setting_value(setting)
if existing != value:
legacy_name = '{}.{}'.format(setting.namespace, setting.name)
if setting.data_type is bool:
value = 'true' if value else 'false'
elif setting.data_type is list:
entries = [self.clean_list_entry(entry)
for entry in value.split('\n')]
value = ', '.join(entries)
else:
value = str(value)
app = self.get_rattail_app()
app.save_setting(Session(), legacy_name, value)
def clean_list_entry(self, value):
value = value.strip()
if '"' in value and "'" in value:
raise NotImplementedError("don't know how to handle escaping 2 "
"different types of quotes!")
if '"' in value:
return "'{}'".format(value)
if "'" in value:
return '"{}"'.format(value)
return value
@classmethod
def defaults(cls, config):
config.add_route('appsettings', '/settings/app/')
config.add_view(cls, route_name='appsettings',
renderer='/appsettings.mako',
permission='settings.edit')
def defaults(config, **kwargs):
base = globals()
AppInfoView = kwargs.get('AppInfoView', base['AppInfoView'])
AppInfoView.defaults(config)
AppSettingsView = kwargs.get('AppSettingsView', base['AppSettingsView'])
AppSettingsView.defaults(config)
SettingView = kwargs.get('SettingView', base['SettingView'])
SettingView.defaults(config)
def includeme(config):
defaults(config)