
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
512 lines
18 KiB
Python
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)
|