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:
parent
f05d50bce3
commit
ea0dc1ea19
|
@ -43,6 +43,8 @@ from pyramid.authentication import SessionAuthenticationPolicy
|
|||
import tailbone.db
|
||||
from tailbone.auth import TailboneAuthorizationPolicy
|
||||
|
||||
from tailbone.util import get_effective_theme, get_theme_template_path
|
||||
|
||||
|
||||
def make_rattail_config(settings):
|
||||
"""
|
||||
|
@ -123,6 +125,10 @@ def make_pyramid_config(settings, configure_csrf=True):
|
|||
if config:
|
||||
config.set_root_factory(Root)
|
||||
else:
|
||||
|
||||
# we want the new themes feature!
|
||||
establish_theme(settings)
|
||||
|
||||
settings.setdefault('pyramid_deform.template_search_path', 'tailbone:templates/deform')
|
||||
config = Configurator(settings=settings, root_factory=Root)
|
||||
|
||||
|
@ -156,6 +162,16 @@ def make_pyramid_config(settings, configure_csrf=True):
|
|||
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):
|
||||
"""
|
||||
Add some PostgreSQL-specific tweaks to the final app config. Specifically,
|
||||
|
|
|
@ -91,6 +91,12 @@ header .global .feedback {
|
|||
margin-right: 1em;
|
||||
}
|
||||
|
||||
header .global .after-feedback {
|
||||
float: right;
|
||||
line-height: 60px;
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
header .page {
|
||||
border-bottom: 1px solid lightgrey;
|
||||
padding: 0.5em;
|
||||
|
|
|
@ -35,6 +35,7 @@ from rattail.db import model
|
|||
from rattail.db.auth import cache_permissions
|
||||
|
||||
from pyramid import threadlocal
|
||||
from webhelpers2.html import tags
|
||||
|
||||
import tailbone
|
||||
from tailbone import helpers
|
||||
|
@ -96,6 +97,22 @@ def before_render(event):
|
|||
renderer_globals['json'] = json
|
||||
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):
|
||||
"""
|
||||
|
|
|
@ -2,12 +2,13 @@
|
|||
<%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>${self.global_title()} » ${capture(self.title)|n}</title>
|
||||
${self.favicon()}
|
||||
<title>${base_meta.global_title()} » ${capture(self.title)|n}</title>
|
||||
${base_meta.favicon()}
|
||||
${self.header_core()}
|
||||
|
||||
% if not request.rattail_config.production():
|
||||
|
@ -16,7 +17,7 @@
|
|||
</style>
|
||||
% endif
|
||||
|
||||
${self.head_tags()}
|
||||
${base_meta.head_tags()}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
@ -31,8 +32,8 @@
|
|||
|
||||
<div class="global">
|
||||
<a class="home" href="${url('home')}">
|
||||
${self.header_logo()}
|
||||
<span class="global-title">${self.global_title()}</span>
|
||||
${base_meta.header_logo()}
|
||||
<span class="global-title">${base_meta.global_title()}</span>
|
||||
</a>
|
||||
% if master:
|
||||
<span class="global">»</span>
|
||||
|
@ -63,6 +64,16 @@
|
|||
<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">
|
||||
|
@ -107,7 +118,7 @@
|
|||
</div><!-- content-wrapper -->
|
||||
|
||||
<div id="footer">
|
||||
${self.footer()}
|
||||
${base_meta.footer()}
|
||||
</div>
|
||||
|
||||
</div><!-- body-wrapper -->
|
||||
|
@ -116,18 +127,12 @@
|
|||
</body>
|
||||
</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="content_title()">
|
||||
<h1>${self.title()}</h1>
|
||||
</%def>
|
||||
|
||||
<%def name="favicon()"></%def>
|
||||
|
||||
<%def name="header_core()">
|
||||
${self.core_javascript()}
|
||||
${self.extra_javascript()}
|
||||
|
@ -155,6 +160,13 @@
|
|||
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__))}
|
||||
|
@ -189,14 +201,6 @@
|
|||
|
||||
<%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)">
|
||||
<div class="field-wrapper${' error' if form[name].errors else ''}">
|
||||
<label for="${name}">${form[name].label}</label>
|
||||
|
|
17
tailbone/templates/base_meta.mako
Normal file
17
tailbone/templates/base_meta.mako
Normal 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>
|
214
tailbone/templates/themes/bobcat/base.mako
Normal file
214
tailbone/templates/themes/bobcat/base.mako
Normal 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()} » ${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">»</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">»</span>
|
||||
${h.link_to(parent_title, parent_url, class_='global')}
|
||||
% elif instance_url is not Undefined:
|
||||
<span class="global">»</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">»</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>
|
|
@ -33,7 +33,9 @@ import pytz
|
|||
import humanize
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
@ -115,3 +117,61 @@ def raw_datetime(config, value):
|
|||
kwargs['title'] = humanize.naturaltime(time_ago)
|
||||
|
||||
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
|
||||
|
|
|
@ -42,6 +42,7 @@ import tailbone
|
|||
from tailbone import forms
|
||||
from tailbone.db import Session
|
||||
from tailbone.views import View
|
||||
from tailbone.util import set_app_theme
|
||||
|
||||
|
||||
class Feedback(colander.Schema):
|
||||
|
@ -125,6 +126,22 @@ class CommonView(View):
|
|||
('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):
|
||||
"""
|
||||
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_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
|
||||
config.add_route('feedback', '/feedback', request_method='POST')
|
||||
config.add_view(cls, attr='feedback', route_name='feedback', renderer='json')
|
||||
|
|
Loading…
Reference in a new issue