diff --git a/tailbone/config.py b/tailbone/config.py
index 1cb6236e..bcdde8a6 100644
--- a/tailbone/config.py
+++ b/tailbone/config.py
@@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
-# Copyright © 2010-2022 Lance Edgar
+# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@@ -26,6 +26,7 @@ Rattail config extension for Tailbone
from __future__ import unicode_literals, absolute_import
+import warnings
from pkg_resources import parse_version
from rattail.config import ConfigExtension as BaseExtension
@@ -64,7 +65,16 @@ def csrf_header_name(config):
def get_buefy_version(config):
- return config.get('tailbone', 'buefy_version') or '0.8.17'
+ warnings.warn("get_buefy_version() is deprecated; please use "
+ "tailbone.util.get_libver() instead",
+ DeprecationWarning, stacklevel=2)
+
+ version = config.get('tailbone', 'libver.buefy')
+ if version:
+ return version
+
+ return config.get('tailbone', 'buefy_version',
+ default='latest')
def get_buefy_0_8(config, version=None):
diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index 78fd2cc6..59ab6018 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
-# Copyright © 2010-2022 Lance Edgar
+# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@@ -453,10 +453,18 @@ class Grid(object):
return pretty_boolean(value)
def obtain_value(self, obj, column_name):
+ """
+ Try to obtain and return the value from the given object, for
+ the given column name.
+
+ :returns: The value, or ``None`` if no value was found.
+ """
try:
return obj[column_name]
+ except KeyError:
+ pass
except TypeError:
- return getattr(obj, column_name)
+ return getattr(obj, column_name, None)
def render_currency(self, obj, column_name):
value = self.obtain_value(obj, column_name)
diff --git a/tailbone/helpers.py b/tailbone/helpers.py
index 750d3f39..aeb6aa01 100644
--- a/tailbone/helpers.py
+++ b/tailbone/helpers.py
@@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
-# Copyright © 2010-2022 Lance Edgar
+# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@@ -41,7 +41,8 @@ from webhelpers2.html.tags import *
from tailbone.util import (csrf_token, get_csrf_token,
pretty_datetime, raw_datetime,
render_markdown,
- route_exists)
+ route_exists,
+ get_liburl)
def pretty_date(date):
diff --git a/tailbone/menus.py b/tailbone/menus.py
index 8b432879..7da22696 100644
--- a/tailbone/menus.py
+++ b/tailbone/menus.py
@@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
-# Copyright © 2010-2022 Lance Edgar
+# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@@ -403,3 +403,103 @@ def mark_allowed(request, menus):
if item['allowed'] and item.get('type') != 'sep':
topitem['allowed'] = True
break
+
+
+def make_admin_menu(request, include_stores=False):
+ """
+ Generate a typical Admin menu
+ """
+ items = []
+
+ if include_stores:
+ items.append({
+ 'title': "Stores",
+ 'route': 'stores',
+ 'perm': 'stores.list',
+ })
+
+ items.extend([
+ {
+ 'title': "Users",
+ 'route': 'users',
+ 'perm': 'users.list',
+ },
+ {
+ 'title': "User Events",
+ 'route': 'userevents',
+ 'perm': 'userevents.list',
+ },
+ {
+ 'title': "Roles",
+ 'route': 'roles',
+ 'perm': 'roles.list',
+ },
+ {'type': 'sep'},
+ {
+ 'title': "App Settings",
+ 'route': 'appsettings',
+ 'perm': 'settings.list',
+ },
+ {
+ 'title': "Email Settings",
+ 'route': 'emailprofiles',
+ 'perm': 'emailprofiles.list',
+ },
+ {
+ 'title': "Email Attempts",
+ 'route': 'email_attempts',
+ 'perm': 'email_attempts.list',
+ },
+ {
+ 'title': "Raw Settings",
+ 'route': 'settings',
+ 'perm': 'settings.list',
+ },
+ {'type': 'sep'},
+ {
+ 'title': "DataSync Changes",
+ 'route': 'datasyncchanges',
+ 'perm': 'datasync_changes.list',
+ },
+ {
+ 'title': "DataSync Status",
+ 'route': 'datasync.status',
+ 'perm': 'datasync.status',
+ },
+ {
+ 'title': "Importing / Exporting",
+ 'route': 'importing',
+ 'perm': 'importing.list',
+ },
+ {
+ 'title': "Luigi Tasks",
+ 'route': 'luigi',
+ 'perm': 'luigi.list',
+ },
+ {
+ 'title': "Tables",
+ 'route': 'tables',
+ 'perm': 'tables.list',
+ },
+ {
+ 'title': "App Info",
+ 'route': 'appinfo',
+ 'perm': 'appinfo.list',
+ },
+ {
+ 'title': "Configure App",
+ 'route': 'appinfo.configure',
+ 'perm': 'appinfo.configure',
+ },
+ {
+ 'title': "Upgrades",
+ 'route': 'upgrades',
+ 'perm': 'upgrades.list',
+ },
+ ])
+
+ return {
+ 'title': "Admin",
+ 'type': 'menu',
+ 'items': items,
+ }
diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py
index 4aed36cd..cbbcb95a 100644
--- a/tailbone/subscribers.py
+++ b/tailbone/subscribers.py
@@ -41,9 +41,9 @@ import tailbone
from tailbone import helpers
from tailbone.db import Session
from tailbone.config import (csrf_header_name, should_expose_websockets,
- get_buefy_version, get_buefy_0_8)
+ get_buefy_0_8)
from tailbone.menus import make_simple_menus
-from tailbone.util import should_use_buefy, get_global_search_options
+from tailbone.util import should_use_buefy, get_global_search_options, get_libver
def new_request(event):
@@ -160,13 +160,8 @@ def before_render(event):
# buefy themes get some extra treatment
if should_use_buefy(request):
- # declare vue.js and buefy versions to use. the default
- # values here are "quite conservative" as of this writing,
- # perhaps too much so, but at least they should work fine.
- renderer_globals['vue_version'] = request.rattail_config.get(
- 'tailbone', 'vue_version') or '2.6.10'
- version = get_buefy_version(rattail_config)
- renderer_globals['buefy_version'] = version
+ # TODO: remove this hack once all nodes safely on buefy 0.9
+ version = get_libver(request, 'buefy')
renderer_globals['buefy_0_8'] = get_buefy_0_8(rattail_config,
version=version)
diff --git a/tailbone/templates/appinfo/configure.mako b/tailbone/templates/appinfo/configure.mako
new file mode 100644
index 00000000..821f937f
--- /dev/null
+++ b/tailbone/templates/appinfo/configure.mako
@@ -0,0 +1,242 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/configure.mako" />
+
+<%def name="form_content()">
+
+
Basics
+
+
+
+
+
+
+
+
+
+
+ ## TODO: should be a dropdown, app handler defines choices
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Production Mode
+
+
+
+
+
+ Display
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Grids
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Web Libraries
+
+
+
+
+ % if buefy_0_8:
+
+ % endif
+
+
+ {{ props.row.title }}
+
+
+
+ {{ props.row.configured_version || props.row.default_version }}
+
+
+
+ {{ props.row.configured_url }}
+
+
+
+
+ save settings and refresh page to see new URL
+
+
+ {{ props.row.live_url }}
+
+
+
+
+
+
+ Edit
+
+
+
+ % if buefy_0_8:
+
+ % endif
+
+
+
+ % for weblib in weblibs:
+ ${h.hidden('tailbone.libver.{}'.format(weblib['key']), **{':value': "simpleSettings['tailbone.libver.{}']".format(weblib['key'])})}
+ ${h.hidden('tailbone.liburl.{}'.format(weblib['key']), **{':value': "simpleSettings['tailbone.liburl.{}']".format(weblib['key'])})}
+ % endfor
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+%def>
+
+<%def name="modify_this_page_vars()">
+ ${parent.modify_this_page_vars()}
+
+%def>
+
+
+${parent.body()}
diff --git a/tailbone/templates/appinfo/index.mako b/tailbone/templates/appinfo/index.mako
new file mode 100644
index 00000000..4bf70354
--- /dev/null
+++ b/tailbone/templates/appinfo/index.mako
@@ -0,0 +1,114 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/index.mako" />
+
+<%def name="render_grid_component()">
+
+
+
+
+
+
+ ## TODO: for some reason buefy will "reuse" the icon
+ ## element in such a way that its display does not
+ ## refresh. so to work around that, we use different
+ ## structure for the two icons, so buefy is forced to
+ ## re-draw
+
+
+
+
+
+
+
+
+
+ Configuration Files
+
+
+
+
+
+
+
+ % if buefy_0_8:
+
+ % endif
+
+
+ {{ props.row.priority }}
+
+
+
+ {{ props.row.path }}
+
+
+ % if buefy_0_8:
+
+ % endif
+
+
+
+
+
+
+
+
+
+
+
+ ## TODO: for some reason buefy will "reuse" the icon
+ ## element in such a way that its display does not
+ ## refresh. so to work around that, we use different
+ ## structure for the two icons, so buefy is forced to
+ ## re-draw
+
+
+
+
+
+
+
+
+
+ Installed Packages
+
+
+
+
+
+ ${parent.render_grid_component()}
+
+
+
+%def>
+
+<%def name="modify_this_page_vars()">
+ ${parent.modify_this_page_vars()}
+
+%def>
+
+
+${parent.body()}
diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako
index 888b3bb6..adbcd893 100644
--- a/tailbone/templates/themes/falafel/base.mako
+++ b/tailbone/templates/themes/falafel/base.mako
@@ -107,21 +107,20 @@
%def>
<%def name="jquery()">
- ${h.javascript_link(request.rattail_config.get('tailbone', 'liburl.jquery', default='https://code.jquery.com/jquery-1.12.4.min.js'))}
+ ${h.javascript_link(h.get_liburl(request, 'jquery'))}
%def>
<%def name="vuejs()">
- ${h.javascript_link(request.rattail_config.get('tailbone', 'liburl.vue', default='https://unpkg.com/vue@{}/dist/vue.min.js'.format(vue_version)))}
- ## TODO: make this version configurable also
- ${h.javascript_link(request.rattail_config.get('tailbone', 'liburl.vue_resource', default='https://cdn.jsdelivr.net/npm/vue-resource@1.5.1'))}
+ ${h.javascript_link(h.get_liburl(request, 'vue'))}
+ ${h.javascript_link(h.get_liburl(request, 'vue_resource'))}
%def>
<%def name="buefy()">
- ${h.javascript_link(request.rattail_config.get('tailbone', 'liburl.buefy', default='https://unpkg.com/buefy@{}/dist/buefy.min.js'.format(buefy_version)))}
+ ${h.javascript_link(h.get_liburl(request, 'buefy'))}
%def>
<%def name="fontawesome()">
-
+
%def>
<%def name="extra_javascript()">%def>
@@ -159,14 +158,14 @@
${h.stylesheet_link(buefy_css)}
% else:
## upstream Buefy CSS
- ${h.stylesheet_link(request.rattail_config.get('tailbone', 'liburl.buefy.css', default='https://unpkg.com/buefy@{}/dist/buefy.min.css'.format(buefy_version)))}
+ ${h.stylesheet_link(h.get_liburl(request, 'buefy.css'))}
% endif
%def>
## TODO: this is only being referenced by the progress template i think?
## (so, should make a Buefy progress page at least)
<%def name="jquery_theme()">
- ${h.stylesheet_link(request.rattail_config.get('tailbone', 'liburl.jquery.css', default='https://code.jquery.com/ui/1.11.4/themes/dark-hive/jquery-ui.css'))}
+ ${h.stylesheet_link(h.get_liburl(request, 'jquery_ui'))}
%def>
<%def name="extra_styles()">%def>
diff --git a/tailbone/util.py b/tailbone/util.py
index a9aa3bf3..ccab81c6 100644
--- a/tailbone/util.py
+++ b/tailbone/util.py
@@ -98,6 +98,106 @@ def get_global_search_options(request):
return options
+def get_libver(request, key, fallback=True, default_only=False):
+ """
+ Return the appropriate URL for the library identified by ``key``.
+ """
+ config = request.rattail_config
+
+ if not default_only:
+ version = config.get('tailbone', 'libver.{}'.format(key))
+ if version:
+ return version
+
+ if not fallback and not default_only:
+
+ if key == 'buefy':
+ version = config.get('tailbone', 'buefy_version')
+ if version:
+ return version
+
+ elif key == 'buefy.css':
+ version = get_libver(request, 'buefy', fallback=False)
+ if version:
+ return version
+
+ elif key == 'vue':
+ version = config.get('tailbone', 'vue_version')
+ if version:
+ return version
+
+ return
+
+ if key == 'buefy':
+ if not default_only:
+ version = config.get('tailbone', 'buefy_version')
+ if version:
+ return version
+ return 'latest'
+
+ elif key == 'buefy.css':
+ version = get_libver(request, 'buefy', default_only=default_only)
+ if version:
+ return version
+ return 'latest'
+
+ elif key == 'vue':
+ if not default_only:
+ version = config.get('tailbone', 'vue_version')
+ if version:
+ return version
+ return '2.6.14'
+
+ elif key == 'vue_resource':
+ return 'latest'
+
+ elif key == 'fontawesome':
+ return '5.3.1'
+
+ elif key == 'jquery':
+ return '1.12.4'
+
+ elif key == 'jquery_ui':
+ return '1.11.4'
+
+
+def get_liburl(request, key, fallback=True):
+ """
+ Return the appropriate URL for the library identified by ``key``.
+ """
+ config = request.rattail_config
+
+ url = config.get('tailbone', 'liburl.{}'.format(key))
+ if url:
+ return url
+
+ if not fallback:
+ return
+
+ version = get_libver(request, key)
+
+ if key == 'buefy':
+ return 'https://unpkg.com/buefy@{}/dist/buefy.min.js'.format(version)
+
+ elif key == 'buefy.css':
+ return 'https://unpkg.com/buefy@{}/dist/buefy.min.css'.format(version)
+
+ elif key == 'vue':
+ return 'https://unpkg.com/vue@{}/dist/vue.min.js'.format(version)
+
+ elif key == 'vue_resource':
+ return 'https://cdn.jsdelivr.net/npm/vue-resource@{}'.format(version)
+
+ elif key == 'fontawesome':
+ return 'https://use.fontawesome.com/releases/v{}/js/all.js'.format(version)
+
+ elif key == 'jquery':
+ return 'https://code.jquery.com/jquery-{}.min.js'.format(version)
+
+ elif key == 'jquery_ui':
+ return 'https://code.jquery.com/ui/{}/themes/dark-hive/jquery-ui.css'.format(version)
+
+
def should_use_buefy(request):
"""
Returns a flag indicating whether or not the current theme supports (and
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index 1771a3b7..a80b6c26 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -2248,6 +2248,8 @@ class MasterView(View):
route = self.get_route_prefix()
return self.request.route_url(route, **kwargs)
+ # TODO: this should not be class method, if possible
+ # (pretty sure overriding as instance method works fine)
@classmethod
def get_index_title(cls):
"""
@@ -4822,6 +4824,8 @@ class MasterView(View):
value = six.text_type(bool(value)).lower()
elif simple.get('type') is int:
value = six.text_type(int(value or '0'))
+ elif value is None:
+ value = ''
else:
value = six.text_type(value)
diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py
index 9a1e8620..f4a213c0 100644
--- a/tailbone/views/settings.py
+++ b/tailbone/views/settings.py
@@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
-# Copyright © 2010-2022 Lance Edgar
+# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@@ -26,14 +26,17 @@ Settings Views
from __future__ import unicode_literals, absolute_import
+import os
import re
+import subprocess
+import sys
import json
import six
from rattail.db import model
from rattail.settings import Setting
-from rattail.util import import_module_path
+from rattail.util import import_module_path, OrderedDict
import colander
from webhelpers2.html import tags
@@ -41,6 +44,153 @@ from webhelpers2.html import tags
from tailbone import forms
from tailbone.db import Session
from tailbone.views import MasterView, View
+from tailbone.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 Info"
+ 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):
+ return "App Info for {}".format(self.rattail_config.app_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(AppInfoView, self).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 configure_get_context(self, **kwargs):
+ context = super(AppInfoView, self).configure_get_context(**kwargs)
+
+ weblibs = OrderedDict([
+ ('vue', "Vue"),
+ ('vue_resource', "vue-resource"),
+ ('buefy', "Buefy"),
+ ('buefy.css', "Buefy CSS"),
+ ('fontawesome', "FontAwesome"),
+ ('jquery', "jQuery"),
+ ('jquery_ui', "jQuery UI"),
+ ])
+
+ 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, fallback=False),
+ 'configured_url': get_liburl(self.request, key, fallback=False),
+
+ # these are for informational purposes only
+ 'default_version': get_libver(self.request, key, default_only=True),
+ 'live_url': get_liburl(self.request, key),
+ }
+
+ context['weblibs'] = list(weblibs.values())
+ return context
+
+ def configure_get_simple_settings(self):
+ return [
+
+ # basics
+ {'section': 'rattail',
+ 'option': 'app_title'},
+ {'section': 'rattail',
+ 'option': 'node_type'},
+ {'section': 'rattail',
+ 'option': 'node_title'},
+ {'section': 'rattail',
+ 'option': 'production',
+ 'type': bool},
+
+ # 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
+ },
+
+ # web libs
+ {'section': 'tailbone',
+ 'option': 'libver.vue'},
+ {'section': 'tailbone',
+ 'option': 'liburl.vue'},
+ {'section': 'tailbone',
+ 'option': 'libver.vue_resource'},
+ {'section': 'tailbone',
+ 'option': 'liburl.vue_resource'},
+ {'section': 'tailbone',
+ 'option': 'libver.buefy'},
+ {'section': 'tailbone',
+ 'option': 'liburl.buefy'},
+ {'section': 'tailbone',
+ 'option': 'libver.buefy.css'},
+ {'section': 'tailbone',
+ 'option': 'liburl.buefy.css'},
+ {'section': 'tailbone',
+ 'option': 'libver.fontawesome'},
+ {'section': 'tailbone',
+ 'option': 'liburl.fontawesome'},
+ {'section': 'tailbone',
+ 'option': 'libver.jquery'},
+ {'section': 'tailbone',
+ 'option': 'liburl.jquery'},
+ {'section': 'tailbone',
+ 'option': 'libver.jquery_ui'},
+ {'section': 'tailbone',
+ 'option': 'liburl.jquery_ui'},
+
+ # 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'},
+
+ ]
class SettingView(MasterView):
@@ -322,6 +472,9 @@ class AppSettingsView(View):
def defaults(config, **kwargs):
base = globals()
+ AppInfoView = kwargs.get('AppInfoView', base['AppInfoView'])
+ AppInfoView.defaults(config)
+
AppSettingsView = kwargs.get('AppSettingsView', base['AppSettingsView'])
AppSettingsView.defaults(config)