Add template "theme" feature, albeit global

would be even better to let each user session have something different, but
alas this is all-or-nothing for now
This commit is contained in:
Lance Edgar 2018-11-27 17:52:02 -06:00
parent f05d50bce3
commit ea0dc1ea19
8 changed files with 377 additions and 20 deletions

View file

@ -43,6 +43,8 @@ from pyramid.authentication import SessionAuthenticationPolicy
import tailbone.db import tailbone.db
from tailbone.auth import TailboneAuthorizationPolicy from tailbone.auth import TailboneAuthorizationPolicy
from tailbone.util import get_effective_theme, get_theme_template_path
def make_rattail_config(settings): def make_rattail_config(settings):
""" """
@ -123,6 +125,10 @@ def make_pyramid_config(settings, configure_csrf=True):
if config: if config:
config.set_root_factory(Root) config.set_root_factory(Root)
else: else:
# we want the new themes feature!
establish_theme(settings)
settings.setdefault('pyramid_deform.template_search_path', 'tailbone:templates/deform') settings.setdefault('pyramid_deform.template_search_path', 'tailbone:templates/deform')
config = Configurator(settings=settings, root_factory=Root) config = Configurator(settings=settings, root_factory=Root)
@ -156,6 +162,16 @@ def make_pyramid_config(settings, configure_csrf=True):
return config return config
def establish_theme(settings):
rattail_config = settings['rattail_config']
theme = get_effective_theme(rattail_config)
settings['tailbone.theme'] = theme
path = get_theme_template_path(rattail_config)
settings['mako.directories'].insert(0, path)
def configure_postgresql(pyramid_config): def configure_postgresql(pyramid_config):
""" """
Add some PostgreSQL-specific tweaks to the final app config. Specifically, Add some PostgreSQL-specific tweaks to the final app config. Specifically,

View file

@ -91,6 +91,12 @@ header .global .feedback {
margin-right: 1em; margin-right: 1em;
} }
header .global .after-feedback {
float: right;
line-height: 60px;
margin-right: 1em;
}
header .page { header .page {
border-bottom: 1px solid lightgrey; border-bottom: 1px solid lightgrey;
padding: 0.5em; padding: 0.5em;

View file

@ -35,6 +35,7 @@ from rattail.db import model
from rattail.db.auth import cache_permissions from rattail.db.auth import cache_permissions
from pyramid import threadlocal from pyramid import threadlocal
from webhelpers2.html import tags
import tailbone import tailbone
from tailbone import helpers from tailbone import helpers
@ -96,6 +97,22 @@ def before_render(event):
renderer_globals['json'] = json renderer_globals['json'] = json
renderer_globals['datetime'] = datetime renderer_globals['datetime'] = datetime
# theme - we only want do this for classic web app, *not* API
# TODO: so, clearly we need a better way to distinguish the two
if 'tailbone.theme' in request.registry.settings:
renderer_globals['theme'] = request.registry.settings['tailbone.theme']
# note, this is just a global flag; user still needs permission to see picker
expose_picker = request.rattail_config.getbool('tailbone', 'themes.expose_picker',
default=False)
renderer_globals['expose_theme_picker'] = expose_picker
if expose_picker:
available = request.rattail_config.getlist('tailbone', 'themes',
default=['bobcat'])
if 'default' not in available:
available.insert(0, 'default')
options = [tags.Option(theme) for theme in available]
renderer_globals['theme_picker_options'] = options
def add_inbox_count(event): def add_inbox_count(event):
""" """

View file

@ -2,12 +2,13 @@
<%namespace file="/menu.mako" import="main_menu_items" /> <%namespace file="/menu.mako" import="main_menu_items" />
<%namespace file="/grids/nav.mako" import="grid_index_nav" /> <%namespace file="/grids/nav.mako" import="grid_index_nav" />
<%namespace file="/feedback_dialog.mako" import="feedback_dialog" /> <%namespace file="/feedback_dialog.mako" import="feedback_dialog" />
<%namespace name="base_meta" file="/base_meta.mako" />
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8" /> <meta http-equiv="content-type" content="text/html; charset=UTF-8" />
<title>${self.global_title()} &raquo; ${capture(self.title)|n}</title> <title>${base_meta.global_title()} &raquo; ${capture(self.title)|n}</title>
${self.favicon()} ${base_meta.favicon()}
${self.header_core()} ${self.header_core()}
% if not request.rattail_config.production(): % if not request.rattail_config.production():
@ -16,7 +17,7 @@
</style> </style>
% endif % endif
${self.head_tags()} ${base_meta.head_tags()}
</head> </head>
<body> <body>
@ -31,8 +32,8 @@
<div class="global"> <div class="global">
<a class="home" href="${url('home')}"> <a class="home" href="${url('home')}">
${self.header_logo()} ${base_meta.header_logo()}
<span class="global-title">${self.global_title()}</span> <span class="global-title">${base_meta.global_title()}</span>
</a> </a>
% if master: % if master:
<span class="global">&raquo;</span> <span class="global">&raquo;</span>
@ -63,6 +64,16 @@
<button type="button" id="feedback">Feedback</button> <button type="button" id="feedback">Feedback</button>
</div> </div>
% if expose_theme_picker and request.has_perm('common.change_app_theme'):
<div class="after-feedback">
${h.form(url('change_theme'), name="theme_changer", method="post")}
${h.csrf_token(request)}
Theme:
${h.select('theme', theme, options=theme_picker_options, id='theme-picker')}
${h.end_form()}
</div>
% endif
</div><!-- global --> </div><!-- global -->
<div class="page"> <div class="page">
@ -107,7 +118,7 @@
</div><!-- content-wrapper --> </div><!-- content-wrapper -->
<div id="footer"> <div id="footer">
${self.footer()} ${base_meta.footer()}
</div> </div>
</div><!-- body-wrapper --> </div><!-- body-wrapper -->
@ -116,18 +127,12 @@
</body> </body>
</html> </html>
<%def name="app_title()">Rattail</%def>
<%def name="global_title()">${"[STAGE] " if not request.rattail_config.production() else ''}${self.app_title()}</%def>
<%def name="title()"></%def> <%def name="title()"></%def>
<%def name="content_title()"> <%def name="content_title()">
<h1>${self.title()}</h1> <h1>${self.title()}</h1>
</%def> </%def>
<%def name="favicon()"></%def>
<%def name="header_core()"> <%def name="header_core()">
${self.core_javascript()} ${self.core_javascript()}
${self.extra_javascript()} ${self.extra_javascript()}
@ -155,6 +160,13 @@
var session_timeout = ${request.get_session_timeout() or 'null'}; var session_timeout = ${request.get_session_timeout() or 'null'};
var logout_url = '${request.route_url('logout')}'; var logout_url = '${request.route_url('logout')}';
var noop_url = '${request.route_url('noop')}'; var noop_url = '${request.route_url('noop')}';
% if expose_theme_picker and request.has_perm('common.change_app_theme'):
$(function() {
$('#theme-picker').change(function() {
$(this).parents('form:first').submit();
});
});
% endif
</script> </script>
${h.javascript_link(request.static_url('tailbone:static/js/tailbone.js') + '?ver={}'.format(tailbone.__version__))} ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.js') + '?ver={}'.format(tailbone.__version__))}
${h.javascript_link(request.static_url('tailbone:static/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))} ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))}
@ -189,14 +201,6 @@
<%def name="extra_styles()"></%def> <%def name="extra_styles()"></%def>
<%def name="head_tags()"></%def>
<%def name="header_logo()"></%def>
<%def name="footer()">
powered by ${h.link_to("Rattail", url('about'))}
</%def>
<%def name="wtfield(form, name, **kwargs)"> <%def name="wtfield(form, name, **kwargs)">
<div class="field-wrapper${' error' if form[name].errors else ''}"> <div class="field-wrapper${' error' if form[name].errors else ''}">
<label for="${name}">${form[name].label}</label> <label for="${name}">${form[name].label}</label>

View file

@ -0,0 +1,17 @@
## -*- coding: utf-8; -*-
<%def name="app_title()">Rattail</%def>
<%def name="global_title()">${"[STAGE] " if not request.rattail_config.production() else ''}${self.app_title()}</%def>
<%def name="favicon()">
<link rel="icon" type="image/x-icon" href="${request.static_url('tailbone:static/img/rattail.ico')}" />
</%def>
<%def name="head_tags()"></%def>
<%def name="header_logo()"></%def>
<%def name="footer()">
powered by ${h.link_to("Rattail", url('about'))}
</%def>

View file

@ -0,0 +1,214 @@
## -*- coding: utf-8; -*-
<%namespace file="/menu.mako" import="main_menu_items" />
<%namespace file="/grids/nav.mako" import="grid_index_nav" />
<%namespace file="/feedback_dialog.mako" import="feedback_dialog" />
<%namespace name="base_meta" file="/base_meta.mako" />
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8" />
<title>${base_meta.global_title()} &raquo; ${capture(self.title)|n}</title>
${base_meta.favicon()}
${self.header_core()}
% if not request.rattail_config.production():
<style type="text/css">
body { background-image: url(${request.static_url('tailbone:static/img/testing.png')}); }
</style>
% endif
${base_meta.head_tags()}
</head>
<body>
<div id="body-wrapper">
<header>
<nav>
<ul class="menubar">
${main_menu_items()}
</ul>
</nav>
<div class="global">
<a class="home" href="${url('home')}">
${base_meta.header_logo()}
<span class="global-title">${base_meta.global_title()}</span>
</a>
% if master:
<span class="global">&raquo;</span>
% if master.listing:
<span class="global">${index_title}</span>
% else:
${h.link_to(index_title, index_url, class_='global')}
% if parent_url is not Undefined:
<span class="global">&raquo;</span>
${h.link_to(parent_title, parent_url, class_='global')}
% elif instance_url is not Undefined:
<span class="global">&raquo;</span>
${h.link_to(instance_title, instance_url, class_='global')}
% endif
% if master.viewing and grid_index:
${grid_index_nav()}
% endif
% endif
% elif index_title:
<span class="global">&raquo;</span>
<span class="global">${index_title}</span>
% endif
<div class="feedback">
% if help_url is not Undefined and help_url:
${h.link_to("Help", help_url, target='_blank', class_='button')}
% endif
<button type="button" id="feedback">Feedback</button>
</div>
% if expose_theme_picker and request.has_perm('common.change_app_theme'):
<div class="after-feedback">
${h.form(url('change_theme'), name="theme_changer", method="post")}
${h.csrf_token(request)}
Theme:
${h.select('theme', theme, options=theme_picker_options, id='theme-picker')}
${h.end_form()}
</div>
% endif
</div><!-- global -->
<div class="page">
${self.content_title()}
</div>
</header>
<div class="content-wrapper">
<div id="scrollpane">
<div id="content">
<div class="inner-content">
% if request.session.peek_flash('error'):
<div class="error-messages">
% for error in request.session.pop_flash('error'):
<div class="ui-state-error ui-corner-all">
<span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-alert"></span>
${error}
</div>
% endfor
</div>
% endif
% if request.session.peek_flash():
<div class="flash-messages">
% for msg in request.session.pop_flash():
<div class="ui-state-highlight ui-corner-all">
<span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-info"></span>
${msg|n}
</div>
% endfor
</div>
% endif
${self.body()}
</div><!-- inner-content -->
</div><!-- content -->
</div><!-- scrollpane -->
</div><!-- content-wrapper -->
<div id="footer">
${base_meta.footer()}
</div>
</div><!-- body-wrapper -->
${feedback_dialog()}
</body>
</html>
<%def name="title()"></%def>
<%def name="content_title()">
<h1>${self.title()}</h1>
</%def>
<%def name="header_core()">
## ${h.stylesheet_link('https://cdnjs.cloudflare.com/ajax/libs/bulma/0.7.2/css/bulma.min.css')}
${self.core_javascript()}
${self.extra_javascript()}
${self.core_styles()}
${self.extra_styles()}
## TODO: should this be elsewhere / more customizable?
% if dform is not Undefined:
<% resources = dform.get_widget_resources() %>
% for path in resources['js']:
${h.javascript_link(request.static_url(path))}
% endfor
% for path in resources['css']:
${h.stylesheet_link(request.static_url(path))}
% endfor
% endif
</%def>
<%def name="core_javascript()">
${self.jquery()}
${h.javascript_link(request.static_url('tailbone:static/js/lib/jquery.ui.menubar.js'))}
${h.javascript_link(request.static_url('tailbone:static/js/lib/jquery.loadmask.min.js'))}
${h.javascript_link(request.static_url('tailbone:static/js/lib/jquery.ui.timepicker.js'))}
<script type="text/javascript">
var session_timeout = ${request.get_session_timeout() or 'null'};
var logout_url = '${request.route_url('logout')}';
var noop_url = '${request.route_url('noop')}';
% if expose_theme_picker and request.has_perm('common.change_app_theme'):
$(function() {
$('#theme-picker').change(function() {
$(this).parents('form:first').submit();
});
});
% endif
</script>
${h.javascript_link(request.static_url('tailbone:static/js/tailbone.js') + '?ver={}'.format(tailbone.__version__))}
${h.javascript_link(request.static_url('tailbone:static/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))}
${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.js') + '?ver={}'.format(tailbone.__version__))}
</%def>
<%def name="jquery()">
${h.javascript_link('https://code.jquery.com/jquery-1.12.4.min.js')}
${h.javascript_link('https://code.jquery.com/ui/{}/jquery-ui.min.js'.format(request.rattail_config.get('tailbone', 'jquery_ui.version', default='1.11.4')))}
</%def>
<%def name="extra_javascript()"></%def>
<%def name="core_styles()">
${h.stylesheet_link(request.static_url('tailbone:static/css/normalize.css'))}
${self.jquery_theme()}
${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.ui.menubar.css'))}
${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.loadmask.css'))}
${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.ui.timepicker.css'))}
${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.ui.tailbone.css') + '?ver={}'.format(tailbone.__version__))}
${h.stylesheet_link(request.static_url('tailbone:static/css/base.css') + '?ver={}'.format(tailbone.__version__))}
${h.stylesheet_link(request.static_url('tailbone:static/css/layout.css') + '?ver={}'.format(tailbone.__version__))}
${h.stylesheet_link(request.static_url('tailbone:static/css/grids.css') + '?ver={}'.format(tailbone.__version__))}
${h.stylesheet_link(request.static_url('tailbone:static/css/filters.css') + '?ver={}'.format(tailbone.__version__))}
${h.stylesheet_link(request.static_url('tailbone:static/css/forms.css') + '?ver={}'.format(tailbone.__version__))}
${h.stylesheet_link(request.static_url('tailbone:static/css/diffs.css') + '?ver={}'.format(tailbone.__version__))}
</%def>
<%def name="jquery_theme()">
${h.stylesheet_link('https://code.jquery.com/ui/1.11.4/themes/excite-bike/jquery-ui.css')}
</%def>
<%def name="extra_styles()"></%def>
<%def name="wtfield(form, name, **kwargs)">
<div class="field-wrapper${' error' if form[name].errors else ''}">
<label for="${name}">${form[name].label}</label>
<div class="field">
${form[name](**kwargs)}
</div>
</div>
</%def>

View file

@ -33,7 +33,9 @@ import pytz
import humanize import humanize
from rattail.time import timezone, make_utc from rattail.time import timezone, make_utc
from rattail.files import resource_path
from pyramid.renderers import get_renderer
from webhelpers2.html import HTML, tags from webhelpers2.html import HTML, tags
@ -115,3 +117,61 @@ def raw_datetime(config, value):
kwargs['title'] = humanize.naturaltime(time_ago) kwargs['title'] = humanize.naturaltime(time_ago)
return HTML.tag('span', **kwargs) return HTML.tag('span', **kwargs)
def set_app_theme(request, theme, session=None):
"""
Set the app theme. This modifies the *global* Mako template lookup
directory path, i.e. theme for all users will change immediately.
This also saves the setting for the new theme, and updates the running app
registry settings with the new theme.
"""
from rattail.db import api
theme = get_effective_theme(request.rattail_config, theme=theme, session=session)
theme_path = get_theme_template_path(request.rattail_config, theme=theme, session=session)
# there's only one global template lookup; can get to it via any renderer
# but should *not* use /base.mako since that one is about to get volatile
renderer = get_renderer('/menu.mako')
lookup = renderer.lookup
# overwrite first entry in lookup's directory list
lookup.directories[0] = theme_path
# remove base template from lookup cache, so it will reload from new theme path
lookup._collection.pop('/base.mako', None)
api.save_setting(session, 'tailbone.theme', theme)
request.registry.settings['tailbone.theme'] = theme
def get_theme_template_path(rattail_config, theme=None, session=None):
"""
Retrieves the template path for the given theme.
"""
theme = get_effective_theme(rattail_config, theme=theme, session=session)
theme_path = rattail_config.get('tailbone', 'theme.{}'.format(theme),
default='tailbone:templates/themes/{}'.format(theme))
return resource_path(theme_path)
def get_effective_theme(rattail_config, theme=None, session=None):
"""
Validates and returns the "effective" theme. If you provide a theme, that
will be used; otherwise it is read from database setting.
"""
from rattail.db import api
if not theme:
theme = api.get_setting(session, 'tailbone.theme') or 'default'
# confirm requested theme is available
available = rattail_config.getlist('tailbone', 'themes',
default=['bobcat'])
available.append('default')
if theme not in available:
raise ValueError("theme not available: {}".format(theme))
return theme

View file

@ -42,6 +42,7 @@ import tailbone
from tailbone import forms from tailbone import forms
from tailbone.db import Session from tailbone.db import Session
from tailbone.views import View from tailbone.views import View
from tailbone.util import set_app_theme
class Feedback(colander.Schema): class Feedback(colander.Schema):
@ -125,6 +126,22 @@ class CommonView(View):
('Tailbone', tailbone.__version__), ('Tailbone', tailbone.__version__),
]) ])
def change_theme(self):
"""
Simple view which can change user's visible UI theme, then redirect
user back to referring page.
"""
theme = self.request.params.get('theme')
if theme:
try:
set_app_theme(self.request, theme, session=Session())
except Exception as error:
msg = "Failed to set theme: {}: {}".format(error.__class__.__name__, error)
self.request.session.flash(msg, 'error')
else:
self.request.session.flash("App theme has been changed to: {}".format(theme))
return self.redirect(self.request.get_referrer())
def feedback(self): def feedback(self):
""" """
Generic view to present/handle the user feedback form. Generic view to present/handle the user feedback form.
@ -188,6 +205,12 @@ class CommonView(View):
config.add_route('mobile.about', '/mobile/about') config.add_route('mobile.about', '/mobile/about')
config.add_view(cls, attr='about', route_name='mobile.about', renderer='/mobile/about.mako') config.add_view(cls, attr='about', route_name='mobile.about', renderer='/mobile/about.mako')
# change theme
config.add_tailbone_permission('common', 'common.change_app_theme',
"Change global App Template Theme")
config.add_route('change_theme', '/change-theme', request_method='POST')
config.add_view(cls, attr='change_theme', route_name='change_theme')
# feedback # feedback
config.add_route('feedback', '/feedback', request_method='POST') config.add_route('feedback', '/feedback', request_method='POST')
config.add_view(cls, attr='feedback', route_name='feedback', renderer='json') config.add_view(cls, attr='feedback', route_name='feedback', renderer='json')