diff --git a/tailbone/app.py b/tailbone/app.py index d8dfa937..651612b0 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -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, diff --git a/tailbone/static/css/layout.css b/tailbone/static/css/layout.css index a49f31a5..22d505a0 100644 --- a/tailbone/static/css/layout.css +++ b/tailbone/static/css/layout.css @@ -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; diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index f6e83ad4..2203487c 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -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): """ diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 1c5ed8fe..f1becabd 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -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" /> - ${self.global_title()} » ${capture(self.title)|n} - ${self.favicon()} + ${base_meta.global_title()} » ${capture(self.title)|n} + ${base_meta.favicon()} ${self.header_core()} % if not request.rattail_config.production(): @@ -16,7 +17,7 @@ % endif - ${self.head_tags()} + ${base_meta.head_tags()} @@ -31,8 +32,8 @@
- ${self.header_logo()} - ${self.global_title()} + ${base_meta.header_logo()} + ${base_meta.global_title()} % if master: » @@ -63,6 +64,16 @@
+ % if expose_theme_picker and request.has_perm('common.change_app_theme'): +
+ ${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()} +
+ % endif +
@@ -107,7 +118,7 @@
@@ -116,18 +127,12 @@ -<%def name="app_title()">Rattail - -<%def name="global_title()">${"[STAGE] " if not request.rattail_config.production() else ''}${self.app_title()} - <%def name="title()"> <%def name="content_title()">

${self.title()}

-<%def name="favicon()"> - <%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 ${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 name="head_tags()"> - -<%def name="header_logo()"> - -<%def name="footer()"> - powered by ${h.link_to("Rattail", url('about'))} - - <%def name="wtfield(form, name, **kwargs)">
diff --git a/tailbone/templates/base_meta.mako b/tailbone/templates/base_meta.mako new file mode 100644 index 00000000..5ff48fad --- /dev/null +++ b/tailbone/templates/base_meta.mako @@ -0,0 +1,17 @@ +## -*- coding: utf-8; -*- + +<%def name="app_title()">Rattail + +<%def name="global_title()">${"[STAGE] " if not request.rattail_config.production() else ''}${self.app_title()} + +<%def name="favicon()"> + + + +<%def name="head_tags()"> + +<%def name="header_logo()"> + +<%def name="footer()"> + powered by ${h.link_to("Rattail", url('about'))} + diff --git a/tailbone/templates/themes/bobcat/base.mako b/tailbone/templates/themes/bobcat/base.mako new file mode 100644 index 00000000..53be3c40 --- /dev/null +++ b/tailbone/templates/themes/bobcat/base.mako @@ -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" /> + + + + + ${base_meta.global_title()} » ${capture(self.title)|n} + ${base_meta.favicon()} + ${self.header_core()} + + % if not request.rattail_config.production(): + + % endif + + ${base_meta.head_tags()} + + + +
+ +
+ + +
+ + ${base_meta.header_logo()} + ${base_meta.global_title()} + + % if master: + » + % if master.listing: + ${index_title} + % else: + ${h.link_to(index_title, index_url, class_='global')} + % if parent_url is not Undefined: + » + ${h.link_to(parent_title, parent_url, class_='global')} + % elif instance_url is not Undefined: + » + ${h.link_to(instance_title, instance_url, class_='global')} + % endif + % if master.viewing and grid_index: + ${grid_index_nav()} + % endif + % endif + % elif index_title: + » + ${index_title} + % endif + + + + % if expose_theme_picker and request.has_perm('common.change_app_theme'): +
+ ${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()} +
+ % endif + +
+ +
+ ${self.content_title()} +
+
+ +
+ +
+
+
+ + % if request.session.peek_flash('error'): +
+ % for error in request.session.pop_flash('error'): +
+ + ${error} +
+ % endfor +
+ % endif + + % if request.session.peek_flash(): +
+ % for msg in request.session.pop_flash(): +
+ + ${msg|n} +
+ % endfor +
+ % endif + + ${self.body()} + +
+
+
+ +
+ + + +
+ + ${feedback_dialog()} + + + +<%def name="title()"> + +<%def name="content_title()"> +

${self.title()}

+ + +<%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 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'))} + + ${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 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 name="extra_javascript()"> + +<%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 name="jquery_theme()"> + ${h.stylesheet_link('https://code.jquery.com/ui/1.11.4/themes/excite-bike/jquery-ui.css')} + + +<%def name="extra_styles()"> + +<%def name="wtfield(form, name, **kwargs)"> +
+ +
+ ${form[name](**kwargs)} +
+
+ diff --git a/tailbone/util.py b/tailbone/util.py index 890cd778..bc891292 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -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 diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 22401a04..14370ad4 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -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')