From 796e793547e7cdefdcc532ef0231b2bce85352a4 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 29 Jun 2025 09:16:44 -0500 Subject: [PATCH] feat: add basic theme system This is intended to allow override of look/feel without overriding the logic/structure of templates. In practice the main goal internally is to allow testing of Vue 3 + Oruga, to eventually replace Vue 2 + Buefy as the default theme. --- src/wuttaweb/app.py | 36 +- src/wuttaweb/subscribers.py | 89 +- src/wuttaweb/templates/appinfo/configure.mako | 7 + src/wuttaweb/templates/base.mako | 40 +- .../templates/forms/vue_template.mako | 2 + .../templates/grids/vue_template.mako | 2 + src/wuttaweb/templates/page.mako | 2 +- .../templates/themes/butterfly/base.mako | 78 ++ .../themes/butterfly/buefy-components.mako | 760 ++++++++++++++++++ .../themes/butterfly/buefy-plugin.mako | 32 + .../themes/butterfly/http-plugin.mako | 100 +++ src/wuttaweb/templates/wutta-components.mako | 12 +- src/wuttaweb/util.py | 159 +++- src/wuttaweb/views/common.py | 24 +- src/wuttaweb/views/settings.py | 4 +- tests/forms/test_base.py | 6 +- tests/test_app.py | 132 ++- tests/test_subscribers.py | 50 +- tests/test_util.py | 91 ++- tests/views/test_common.py | 30 + 20 files changed, 1604 insertions(+), 52 deletions(-) create mode 100644 src/wuttaweb/templates/themes/butterfly/base.mako create mode 100644 src/wuttaweb/templates/themes/butterfly/buefy-components.mako create mode 100644 src/wuttaweb/templates/themes/butterfly/buefy-plugin.mako create mode 100644 src/wuttaweb/templates/themes/butterfly/http-plugin.mako diff --git a/src/wuttaweb/app.py b/src/wuttaweb/app.py index 7546228..7c65f86 100644 --- a/src/wuttaweb/app.py +++ b/src/wuttaweb/app.py @@ -2,7 +2,7 @@ ################################################################################ # # wuttaweb -- Web App for Wutta Framework -# Copyright © 2024 Lance Edgar +# Copyright © 2024-2025 Lance Edgar # # This file is part of Wutta Framework. # @@ -35,6 +35,7 @@ from pyramid.config import Configurator import wuttaweb.db from wuttaweb.auth import WuttaSecurityPolicy +from wuttaweb.util import get_effective_theme, get_theme_template_path log = logging.getLogger(__name__) @@ -132,6 +133,9 @@ def make_pyramid_config(settings): settings.setdefault('pyramid_deform.template_search_path', 'wuttaweb:templates/deform') + # update settings per current theme + establish_theme(settings) + pyramid_config = Configurator(settings=settings) # configure user authorization / authentication @@ -248,3 +252,33 @@ def make_asgi_app(main_app=None, config=None): """ wsgi_app = make_wsgi_app(main_app, config=config) return WsgiToAsgi(wsgi_app) + + +def establish_theme(settings): + """ + Establishes initial theme on app startup. This mostly involves + updating the given ``settings`` dict. + + This function is called automatically from within + :func:`make_pyramid_config()`. + + It will first call :func:`~wuttaweb.util.get_effective_theme()` to + read the current theme from the :term:`settings table`, and store + this within ``settings['wuttaweb.theme']``. + + It then calls :func:`~wuttaweb.util.get_theme_template_path()` and + will update ``settings['mako.directories']`` such that the theme's + template path is listed first. + """ + config = settings['wutta_config'] + + theme = get_effective_theme(config) + settings['wuttaweb.theme'] = theme + + directories = settings['mako.directories'] + if isinstance(directories, str): + directories = config.parse_list(directories) + + path = get_theme_template_path(config) + directories.insert(0, path) + settings['mako.directories'] = directories diff --git a/src/wuttaweb/subscribers.py b/src/wuttaweb/subscribers.py index 79fefd2..b0b1cc1 100644 --- a/src/wuttaweb/subscribers.py +++ b/src/wuttaweb/subscribers.py @@ -2,7 +2,7 @@ ################################################################################ # # wuttaweb -- Web App for Wutta Framework -# Copyright © 2024 Lance Edgar +# Copyright © 2024-2025 Lance Edgar # # This file is part of Wutta Framework. # @@ -38,11 +38,13 @@ hooks contained here, depending on the circumstance. import functools import json import logging +from collections import OrderedDict from pyramid import threadlocal from wuttaweb import helpers from wuttaweb.db import Session +from wuttaweb.util import get_available_themes log = logging.getLogger(__name__) @@ -79,6 +81,43 @@ def new_request(event): Flag indicating whether the frontend should be displayed using Vue 3 + Oruga (if ``True``), or else Vue 2 + Buefy (if ``False``). This flag is ``False`` by default. + + .. function:: request.register_component(tagname, classname) + + Request method which registers a Vue component for use within + the app templates. + + :param tagname: Component tag name as string. + + :param classname: Component class name as string. + + This is meant to be analogous to the ``Vue.component()`` call + which is part of Vue 2. It is good practice to always call + both at the same time/place: + + .. code-block:: mako + + ## define component template + + + """ request = event.request config = request.registry.settings['wutta_config'] @@ -104,10 +143,34 @@ def new_request(event): if spec: func = app.load_object(spec) return func(request) + + theme = request.registry.settings.get('wuttaweb.theme') + if theme == 'butterfly': + return True return False request.set_property(use_oruga, reify=True) + def register_component(tagname, classname): + """ + Register a Vue 3 component, so the base template knows to + declare it for use within the app (page). + """ + if not hasattr(request, '_wuttaweb_registered_components'): + request._wuttaweb_registered_components = OrderedDict() + + if tagname in request._wuttaweb_registered_components: + log.warning("component with tagname '%s' already registered " + "with class '%s' but we are replacing that " + "with class '%s'", + tagname, + request._wuttaweb_registered_components[tagname], + classname) + + request._wuttaweb_registered_components[tagname] = classname + + request.register_component = register_component + def default_user_getter(request, db_session=None): """ @@ -290,6 +353,23 @@ def before_render(event): Reference to the request method, :meth:`~pyramid:pyramid.request.Request.route_url()`. + + .. data:: 'theme' + + String name of the current theme. This will be ``'default'`` + unless a custom theme is in effect. + + .. data:: 'expose_theme_picker' + + Boolean indicating whether the theme picker should *ever* be + exposed. For a user to see it, this flag must be true *and* + the user must have permission to change theme. + + .. data:: 'available_themes' + + List of theme names from which user may choose, if they are + allowed to change theme. Only set/relevant if + ``expose_theme_picker`` is true (see above). """ request = event.get('request') or threadlocal.get_current_request() config = request.wutta_config @@ -311,6 +391,13 @@ def before_render(event): menus = web.get_menu_handler() context['menus'] = menus.do_make_menus(request) + # theme + context['theme'] = request.registry.settings.get('wuttaweb.theme', 'default') + context['expose_theme_picker'] = config.get_bool('wuttaweb.themes.expose_picker', + default=False) + if context['expose_theme_picker']: + context['available_themes'] = get_available_themes(config) + def includeme(config): config.add_subscriber(new_request, 'pyramid.events.NewRequest') diff --git a/src/wuttaweb/templates/appinfo/configure.mako b/src/wuttaweb/templates/appinfo/configure.mako index f761ade..9d52a7b 100644 --- a/src/wuttaweb/templates/appinfo/configure.mako +++ b/src/wuttaweb/templates/appinfo/configure.mako @@ -39,6 +39,13 @@ @input="settingsNeedSaved = true"> Production Mode + + + Expose Theme Picker + diff --git a/src/wuttaweb/templates/base.mako b/src/wuttaweb/templates/base.mako index 846843d..3be1d37 100644 --- a/src/wuttaweb/templates/base.mako +++ b/src/wuttaweb/templates/base.mako @@ -440,7 +440,27 @@ -<%def name="render_theme_picker()"> +<%def name="render_theme_picker()"> + % if expose_theme_picker and request.has_perm('common.change_theme'): +
+ ${h.form(url('change_theme'), method='POST', ref='themePickerForm')} + ${h.csrf_token(request)} + +
+ Theme: + <${b}-select name="theme" + v-model="globalTheme" + @input="changeTheme()"> + % for name in available_themes: + + % endfor + +
+ ${h.end_form()} +
+ % endif + + <%def name="render_feedback_button()"> % if request.has_perm('common.feedback'): @@ -459,8 +479,8 @@ Feedback - + <${b}-modal has-modal-card + :active.sync="showDialog"> - + @@ -631,6 +651,12 @@ }, % endif + + % if expose_theme_picker and request.has_perm('common.change_theme'): + changeTheme() { + this.$refs.themePickerForm.submit() + }, + % endif }, } @@ -638,6 +664,10 @@ contentTitleHTML: ${json.dumps(capture(self.content_title))|n}, referrer: location.href, mountedHooks: [], + + % if expose_theme_picker and request.has_perm('common.change_theme'): + globalTheme: ${json.dumps(theme or None)|n}, + % endif } ## declare nested menu visibility toggle flags @@ -774,11 +804,13 @@ % if request.has_perm('common.feedback'): % endif diff --git a/src/wuttaweb/templates/forms/vue_template.mako b/src/wuttaweb/templates/forms/vue_template.mako index d039b76..facc89d 100644 --- a/src/wuttaweb/templates/forms/vue_template.mako +++ b/src/wuttaweb/templates/forms/vue_template.mako @@ -95,3 +95,5 @@ } + +<% request.register_component(form.vue_tagname, form.vue_component) %> diff --git a/src/wuttaweb/templates/grids/vue_template.mako b/src/wuttaweb/templates/grids/vue_template.mako index 85dd468..60a27d2 100644 --- a/src/wuttaweb/templates/grids/vue_template.mako +++ b/src/wuttaweb/templates/grids/vue_template.mako @@ -739,3 +739,5 @@ } + +<% request.register_component(grid.vue_tagname, grid.vue_component) %> diff --git a/src/wuttaweb/templates/page.mako b/src/wuttaweb/templates/page.mako index cd5b1da..1cd8378 100644 --- a/src/wuttaweb/templates/page.mako +++ b/src/wuttaweb/templates/page.mako @@ -50,6 +50,6 @@ diff --git a/src/wuttaweb/templates/themes/butterfly/base.mako b/src/wuttaweb/templates/themes/butterfly/base.mako new file mode 100644 index 0000000..76e9b5b --- /dev/null +++ b/src/wuttaweb/templates/themes/butterfly/base.mako @@ -0,0 +1,78 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/base.mako" /> +<%namespace file="/http-plugin.mako" import="make_http_plugin" /> +<%namespace file="/buefy-plugin.mako" import="make_buefy_plugin" /> +<%namespace file="/buefy-components.mako" import="make_buefy_components" /> + +<%def name="core_javascript()"> + + + + +<%def name="core_styles()"> + ${h.stylesheet_link(h.get_liburl(request, 'bb_oruga_bulma_css'))} + + +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + ${make_buefy_components()} + + +<%def name="extra_styles()"> + ${parent.extra_styles()} + + + +<%def name="make_vue_app()"> + ${make_http_plugin()} + ${make_buefy_plugin()} + + diff --git a/src/wuttaweb/templates/themes/butterfly/buefy-components.mako b/src/wuttaweb/templates/themes/butterfly/buefy-components.mako new file mode 100644 index 0000000..61c4d94 --- /dev/null +++ b/src/wuttaweb/templates/themes/butterfly/buefy-components.mako @@ -0,0 +1,760 @@ + +<%def name="make_buefy_components()"> + ${self.make_b_autocomplete_component()} + ${self.make_b_button_component()} + ${self.make_b_checkbox_component()} + ${self.make_b_collapse_component()} + ${self.make_b_datepicker_component()} + ${self.make_b_dropdown_component()} + ${self.make_b_dropdown_item_component()} + ${self.make_b_field_component()} + ${self.make_b_icon_component()} + ${self.make_b_input_component()} + ${self.make_b_loading_component()} + ${self.make_b_modal_component()} + ${self.make_b_notification_component()} + ${self.make_b_radio_component()} + ${self.make_b_select_component()} + ${self.make_b_steps_component()} + ${self.make_b_step_item_component()} + ${self.make_b_table_component()} + ${self.make_b_table_column_component()} + ${self.make_b_tooltip_component()} + ${self.make_once_button_component()} + + +<%def name="make_b_autocomplete_component()"> + + + <% request.register_component('b-autocomplete', 'BAutocomplete') %> + + +<%def name="make_b_button_component()"> + + + <% request.register_component('b-button', 'BButton') %> + + +<%def name="make_b_checkbox_component()"> + + + <% request.register_component('b-checkbox', 'BCheckbox') %> + + +<%def name="make_b_collapse_component()"> + + + <% request.register_component('b-collapse', 'BCollapse') %> + + +<%def name="make_b_datepicker_component()"> + + + <% request.register_component('b-datepicker', 'BDatepicker') %> + + +<%def name="make_b_dropdown_component()"> + + + <% request.register_component('b-dropdown', 'BDropdown') %> + + +<%def name="make_b_dropdown_item_component()"> + + + <% request.register_component('b-dropdown-item', 'BDropdownItem') %> + + +<%def name="make_b_field_component()"> + + + <% request.register_component('b-field', 'BField') %> + + +<%def name="make_b_icon_component()"> + + + <% request.register_component('b-icon', 'BIcon') %> + + +<%def name="make_b_input_component()"> + + + <% request.register_component('b-input', 'BInput') %> + + +<%def name="make_b_loading_component()"> + + + <% request.register_component('b-loading', 'BLoading') %> + + +<%def name="make_b_modal_component()"> + + + <% request.register_component('b-modal', 'BModal') %> + + +<%def name="make_b_notification_component()"> + + + <% request.register_component('b-notification', 'BNotification') %> + + +<%def name="make_b_radio_component()"> + + + <% request.register_component('b-radio', 'BRadio') %> + + +<%def name="make_b_select_component()"> + + + <% request.register_component('b-select', 'BSelect') %> + + +<%def name="make_b_steps_component()"> + + + <% request.register_component('b-steps', 'BSteps') %> + + +<%def name="make_b_step_item_component()"> + + + <% request.register_component('b-step-item', 'BStepItem') %> + + +<%def name="make_b_table_component()"> + + + <% request.register_component('b-table', 'BTable') %> + + +<%def name="make_b_table_column_component()"> + + + <% request.register_component('b-table-column', 'BTableColumn') %> + + +<%def name="make_b_tooltip_component()"> + + + <% request.register_component('b-tooltip', 'BTooltip') %> + + +<%def name="make_once_button_component()"> + + + <% request.register_component('once-button', 'OnceButton') %> + diff --git a/src/wuttaweb/templates/themes/butterfly/buefy-plugin.mako b/src/wuttaweb/templates/themes/butterfly/buefy-plugin.mako new file mode 100644 index 0000000..4cbedfe --- /dev/null +++ b/src/wuttaweb/templates/themes/butterfly/buefy-plugin.mako @@ -0,0 +1,32 @@ + +<%def name="make_buefy_plugin()"> + + diff --git a/src/wuttaweb/templates/themes/butterfly/http-plugin.mako b/src/wuttaweb/templates/themes/butterfly/http-plugin.mako new file mode 100644 index 0000000..06afc2b --- /dev/null +++ b/src/wuttaweb/templates/themes/butterfly/http-plugin.mako @@ -0,0 +1,100 @@ + +<%def name="make_http_plugin()"> + + diff --git a/src/wuttaweb/templates/wutta-components.mako b/src/wuttaweb/templates/wutta-components.mako index fdc8af0..88847f9 100644 --- a/src/wuttaweb/templates/wutta-components.mako +++ b/src/wuttaweb/templates/wutta-components.mako @@ -228,6 +228,7 @@ } Vue.component('wutta-autocomplete', WuttaAutocomplete) + <% request.register_component('wutta-autocomplete', 'WuttaAutocomplete') %> @@ -295,6 +296,7 @@ }, } Vue.component('wutta-button', WuttaButton) + <% request.register_component('wutta-button', 'WuttaButton') %> @@ -367,6 +369,7 @@ }, } Vue.component('wutta-datepicker', WuttaDatepicker) + <% request.register_component('wutta-datepicker', 'WuttaDatepicker') %> @@ -444,6 +447,7 @@ }, } Vue.component('wutta-timepicker', WuttaTimepicker) + <% request.register_component('wutta-timepicker', 'WuttaTimepicker') %> @@ -616,7 +620,7 @@ } Vue.component('wutta-filter', WuttaFilter) - + <% request.register_component('wutta-filter', 'WuttaFilter') %> @@ -655,7 +659,7 @@ } Vue.component('wutta-filter-value', WuttaFilterValue) - + <% request.register_component('wutta-filter-value', 'WuttaFilterValue') %> @@ -702,7 +706,7 @@ } Vue.component('wutta-filter-date-value', WuttaFilterDateValue) - + <% request.register_component('wutta-filter-date-value', 'WuttaFilterDateValue') %> @@ -727,6 +731,6 @@ } Vue.component('wutta-tool-panel', WuttaToolPanel) - + <% request.register_component('wutta-tool-panel', 'WuttaToolPanel') %> diff --git a/src/wuttaweb/util.py b/src/wuttaweb/util.py index f634c00..ff65f38 100644 --- a/src/wuttaweb/util.py +++ b/src/wuttaweb/util.py @@ -2,7 +2,7 @@ ################################################################################ # # wuttaweb -- Web App for Wutta Framework -# Copyright © 2024 Lance Edgar +# Copyright © 2024-2025 Lance Edgar # # This file is part of Wutta Framework. # @@ -35,8 +35,12 @@ import sqlalchemy as sa from sqlalchemy import orm import colander +from pyramid.renderers import get_renderer from webhelpers2.html import HTML, tags +from wuttjamaican.util import resource_path +from wuttaweb.db import Session + log = logging.getLogger(__name__) @@ -589,3 +593,156 @@ def make_json_safe(value, key=None, warn=True): log.warning("forced value to: %s", value) return value + + +############################## +# theme functions +############################## + +def get_available_themes(config): + """ + Returns the official list of theme names which are available for + use in the app. Privileged users may choose among these when + changing the global theme. + + If config specifies a list, that will be honored. Otherwise the + default list is: ``['default', 'butterfly']`` + + Note that the 'default' theme is Vue 2 + Buefy, while 'butterfly' + is Vue 3 + Oruga. + + You can specify via config by setting e.g.: + + .. code-block:: ini + + [wuttaweb] + themes.keys = default, butterfly, my-other-one + + :param config: App :term:`config object`. + """ + # get available list from config, if it has one + available = config.get_list('wuttaweb.themes.keys', + default=['default', 'butterfly']) + + # sort the list by name + available.sort() + + # make default theme the first option + if 'default' in available: + available.remove('default') + available.insert(0, 'default') + + return available + + +def get_effective_theme(config, theme=None, session=None): + """ + Validate and return the "effective" theme. + + If caller specifies a ``theme`` then it will be returned (if + "available" - see below). + + Otherwise the current theme will be read from db setting. (Note + we do not read simply from config object, we always read from db + setting - this allows for the theme setting to change dynamically + while app is running.) + + In either case if the theme is not listed in + :func:`get_available_themes()` then a ``ValueError`` is raised. + + :param config: App :term:`config object`. + + :param theme: Optional name of desired theme, instead of getting + current theme per db setting. + + :param session: Optional :term:`db session`. + + :returns: Name of theme. + """ + app = config.get_app() + + if not theme: + with app.short_session(session=session) as s: + theme = app.get_setting(s, 'wuttaweb.theme') or 'default' + + # confirm requested theme is available + available = get_available_themes(config) + if theme not in available: + raise ValueError(f"theme not available: {theme}") + + return theme + + +def get_theme_template_path(config, theme=None, session=None): + """ + Return the template path for effective theme. + + If caller specifies a ``theme`` then it will be used; otherwise + the current theme will be read from db setting. The logic for + that happens in :func:`get_effective_theme()`, which this function + will call first. + + Once we have the valid theme name, we check config in case it + specifies a template path override for it. But if not, a default + template path is assumed. + + The default path would be expected to live under + ``wuttaweb:templates/themes``; for instance the ``butterfly`` + theme has a default template path of + ``wuttaweb:templates/themes/butterfly``. + + :param config: App :term:`config object`. + + :param theme: Optional name of desired theme, instead of getting + current theme per db setting. + + :param session: Optional :term:`db session`. + + :returns: Path on disk to theme template folder. + """ + theme = get_effective_theme(config, theme=theme, session=session) + theme_path = config.get(f'wuttaweb.theme.{theme}', + default=f'wuttaweb:templates/themes/{theme}') + return resource_path(theme_path) + + +def set_app_theme(request, theme, session=None): + """ + Set the effective theme for the running app. + + This will modify the *global* Mako template lookup directories, + i.e. app templates will change for all users immediately. + + This will first validate the theme by calling + :func:`get_effective_theme()`. It then retrieves the template + path via :func:`get_theme_template_path()`. + + The theme template path is then injected into the app settings + registry such that it overrides the Mako lookup directories. + + It also will persist the theme name within db settings, so as to + ensure it survives app restart. + """ + config = request.wutta_config + app = config.get_app() + + theme = get_effective_theme(config, theme=theme, session=session) + theme_path = get_theme_template_path(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 + + # clear template cache for lookup object, so it will reload each (as needed) + lookup._collection.clear() + + # persist current theme in db settings + with app.short_session(session=session) as s: + app.save_setting(s, 'wuttaweb.theme', theme) + + # and cache in live app settings + request.registry.settings['wuttaweb.theme'] = theme diff --git a/src/wuttaweb/views/common.py b/src/wuttaweb/views/common.py index f3f27d9..27ca77f 100644 --- a/src/wuttaweb/views/common.py +++ b/src/wuttaweb/views/common.py @@ -2,7 +2,7 @@ ################################################################################ # # wuttaweb -- Web App for Wutta Framework -# Copyright © 2024 Lance Edgar +# Copyright © 2024-2025 Lance Edgar # # This file is part of Wutta Framework. # @@ -32,6 +32,7 @@ from pyramid.renderers import render from wuttaweb.views import View from wuttaweb.forms import widgets from wuttaweb.db import Session +from wuttaweb.util import set_app_theme log = logging.getLogger(__name__) @@ -267,6 +268,21 @@ class CommonView(View): which was just created as part of initial setup. """ + def change_theme(self): + """ + This view will set the global app theme, then redirect back to + the referring page. + """ + theme = self.request.params.get('theme') + if theme: + try: + set_app_theme(self.request, theme, session=Session()) + except Exception as error: + error = self.app.render_error(error) + self.request.session.flash(f"Failed to set theme: {error}", 'error') + referrer = self.request.params.get('referrer') or self.request.get_referrer() + return self.redirect(referrer) + @classmethod def defaults(cls, config): cls._defaults(config) @@ -308,6 +324,12 @@ class CommonView(View): route_name='setup', renderer='/setup.mako') + # change theme + config.add_route('change_theme', '/change-theme', request_method='POST') + config.add_view(cls, attr='change_theme', route_name='change_theme') + config.add_wutta_permission('common', 'common.change_theme', + "Change global theme") + def defaults(config, **kwargs): base = globals() diff --git a/src/wuttaweb/views/settings.py b/src/wuttaweb/views/settings.py index a8b8e0a..5b1ecb5 100644 --- a/src/wuttaweb/views/settings.py +++ b/src/wuttaweb/views/settings.py @@ -2,7 +2,7 @@ ################################################################################ # # wuttaweb -- Web App for Wutta Framework -# Copyright © 2024 Lance Edgar +# Copyright © 2024-2025 Lance Edgar # # This file is part of Wutta Framework. # @@ -129,6 +129,8 @@ class AppInfoView(MasterView): {'name': f'{self.config.appname}.node_title'}, {'name': f'{self.config.appname}.production', 'type': bool}, + {'name': 'wuttaweb.themes.expose_picker', + 'type': bool}, {'name': f'{self.config.appname}.web.menus.handler.spec'}, # nb. this is deprecated; we define so it is auto-deleted # when we replace with newer setting diff --git a/tests/forms/test_base.py b/tests/forms/test_base.py index bc229eb..7b14088 100644 --- a/tests/forms/test_base.py +++ b/tests/forms/test_base.py @@ -11,7 +11,7 @@ from pyramid import testing from wuttjamaican.conf import WuttaConfig from wuttaweb.forms import base, widgets -from wuttaweb import helpers +from wuttaweb import helpers, subscribers from wuttaweb.grids import Grid @@ -25,10 +25,14 @@ class TestForm(TestCase): self.request = testing.DummyRequest(wutta_config=self.config, use_oruga=False) self.pyramid_config = testing.setUp(request=self.request, settings={ + 'wutta_config': self.config, 'mako.directories': ['wuttaweb:templates'], 'pyramid_deform.template_search_path': 'wuttaweb:templates/deform', }) + event = MagicMock(request=self.request) + subscribers.new_request(event) + def tearDown(self): testing.tearDown() diff --git a/tests/test_app.py b/tests/test_app.py index 8b092d8..591651a 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -3,7 +3,7 @@ from unittest import TestCase from unittest.mock import patch -from wuttjamaican.testing import FileTestCase, ConfigTestCase +from wuttjamaican.testing import FileTestCase, ConfigTestCase, DataTestCase from asgiref.wsgi import WsgiToAsgi from pyramid.config import Configurator @@ -11,6 +11,8 @@ from pyramid.router import Router from wuttaweb import app as mod from wuttjamaican.conf import WuttaConfig +from wuttjamaican.app import AppHandler +from wuttjamaican.util import resource_path class TestWebAppProvider(TestCase): @@ -45,22 +47,25 @@ class TestMakeWuttaConfig(FileTestCase): self.assertIs(settings['wutta_config'], config) -class TestMakePyramidConfig(TestCase): +class TestMakePyramidConfig(DataTestCase): def test_basic(self): - settings = {} - config = mod.make_pyramid_config(settings) - self.assertIsInstance(config, Configurator) + with patch.object(AppHandler, 'make_session', return_value=self.session): + settings = {'wutta_config': self.config} + config = mod.make_pyramid_config(settings) + self.assertIsInstance(config, Configurator) + self.assertEqual(settings['wuttaweb.theme'], 'default') -class TestMain(FileTestCase): +class TestMain(DataTestCase): def test_basic(self): - global_config = None - myconf = self.write_file('my.conf', '') - settings = {'wutta.config': myconf} - app = mod.main(global_config, **settings) - self.assertIsInstance(app, Router) + with patch.object(AppHandler, 'make_session', return_value=self.session): + global_config = None + myconf = self.write_file('my.conf', '') + settings = {'wutta.config': myconf} + app = mod.main(global_config, **settings) + self.assertIsInstance(app, Router) def mock_main(global_config, **settings): @@ -75,19 +80,20 @@ def mock_main(global_config, **settings): return pyramid_config.make_wsgi_app() -class TestMakeWsgiApp(ConfigTestCase): +class TestMakeWsgiApp(DataTestCase): def test_with_callable(self): + with patch.object(self.app, 'make_session', return_value=self.session): - # specify config - wsgi = mod.make_wsgi_app(mock_main, config=self.config) - self.assertIsInstance(wsgi, Router) - - # auto config - with patch.object(mod, 'make_config', return_value=self.config): - wsgi = mod.make_wsgi_app(mock_main) + # specify config + wsgi = mod.make_wsgi_app(mock_main, config=self.config) self.assertIsInstance(wsgi, Router) + # auto config + with patch.object(mod, 'make_config', return_value=self.config): + wsgi = mod.make_wsgi_app(mock_main) + self.assertIsInstance(wsgi, Router) + def test_with_spec(self): # specify config @@ -103,29 +109,87 @@ class TestMakeWsgiApp(ConfigTestCase): self.assertRaises(ValueError, mod.make_wsgi_app, 42, config=self.config) -class TestMakeAsgiApp(ConfigTestCase): +class TestMakeAsgiApp(DataTestCase): def test_with_callable(self): + with patch.object(self.app, 'make_session', return_value=self.session): - # specify config - asgi = mod.make_asgi_app(mock_main, config=self.config) - self.assertIsInstance(asgi, WsgiToAsgi) - - # auto config - with patch.object(mod, 'make_config', return_value=self.config): - asgi = mod.make_asgi_app(mock_main) + # specify config + asgi = mod.make_asgi_app(mock_main, config=self.config) self.assertIsInstance(asgi, WsgiToAsgi) + # auto config + with patch.object(mod, 'make_config', return_value=self.config): + asgi = mod.make_asgi_app(mock_main) + self.assertIsInstance(asgi, WsgiToAsgi) + def test_with_spec(self): + with patch.object(self.app, 'make_session', return_value=self.session): - # specify config - asgi = mod.make_asgi_app('tests.test_app:mock_main', config=self.config) - self.assertIsInstance(asgi, WsgiToAsgi) - - # auto config - with patch.object(mod, 'make_config', return_value=self.config): - asgi = mod.make_asgi_app('tests.test_app:mock_main') + # specify config + asgi = mod.make_asgi_app('tests.test_app:mock_main', config=self.config) self.assertIsInstance(asgi, WsgiToAsgi) + # auto config + with patch.object(mod, 'make_config', return_value=self.config): + asgi = mod.make_asgi_app('tests.test_app:mock_main') + self.assertIsInstance(asgi, WsgiToAsgi) + def test_invalid(self): self.assertRaises(ValueError, mod.make_asgi_app, 42, config=self.config) + + +class TestEstablishTheme(DataTestCase): + + def test_default(self): + settings = { + 'wutta_config': self.config, + 'mako.directories': ['wuttaweb:templates'], + } + mod.establish_theme(settings) + self.assertEqual(settings['wuttaweb.theme'], 'default') + self.assertEqual(settings['mako.directories'], [ + resource_path('wuttaweb:templates/themes/default'), + 'wuttaweb:templates', + ]) + + def test_mako_dirs_as_string(self): + settings = { + 'wutta_config': self.config, + 'mako.directories': 'wuttaweb:templates', + } + mod.establish_theme(settings) + self.assertEqual(settings['wuttaweb.theme'], 'default') + self.assertEqual(settings['mako.directories'], [ + resource_path('wuttaweb:templates/themes/default'), + 'wuttaweb:templates', + ]) + + def test_butterfly(self): + settings = { + 'wutta_config': self.config, + 'mako.directories': 'wuttaweb:templates', + } + self.app.save_setting(self.session, 'wuttaweb.theme', 'butterfly') + self.session.commit() + mod.establish_theme(settings) + self.assertEqual(settings['wuttaweb.theme'], 'butterfly') + self.assertEqual(settings['mako.directories'], [ + resource_path('wuttaweb:templates/themes/butterfly'), + 'wuttaweb:templates', + ]) + + def test_custom(self): + settings = { + 'wutta_config': self.config, + 'mako.directories': 'wuttaweb:templates', + } + self.config.setdefault('wuttaweb.themes.keys', 'anotherone') + self.app.save_setting(self.session, 'wuttaweb.theme', 'anotherone') + self.session.commit() + mod.establish_theme(settings) + self.assertEqual(settings['wuttaweb.theme'], 'anotherone') + self.assertEqual(settings['mako.directories'], [ + resource_path('wuttaweb:templates/themes/anotherone'), + 'wuttaweb:templates', + ]) diff --git a/tests/test_subscribers.py b/tests/test_subscribers.py index 09d7437..547bf3a 100644 --- a/tests/test_subscribers.py +++ b/tests/test_subscribers.py @@ -41,13 +41,21 @@ class TestNewRequest(TestCase): self.assertIs(self.request.wutta_config, self.config) def test_use_oruga_default(self): - event = MagicMock(request=self.request) # request gets a new attr, false by default self.assertFalse(hasattr(self.request, 'use_oruga')) + event = MagicMock(request=self.request) subscribers.new_request(event) self.assertFalse(self.request.use_oruga) + # nb. using 'butterfly' theme should cause the 'use_oruga' + # flag to be turned on by default + self.request = self.make_request() + self.request.registry.settings['wuttaweb.theme'] = 'butterfly' + event = MagicMock(request=self.request) + subscribers.new_request(event) + self.assertTrue(self.request.use_oruga) + def test_use_oruga_custom(self): self.config.setdefault('wuttaweb.oruga_detector.spec', 'tests.test_subscribers:custom_oruga_detector') event = MagicMock(request=self.request) @@ -57,6 +65,26 @@ class TestNewRequest(TestCase): subscribers.new_request(event) self.assertTrue(self.request.use_oruga) + def test_register_component(self): + event = MagicMock(request=self.request) + subscribers.new_request(event) + + # component tracking dict is missing at first + self.assertFalse(hasattr(self.request, '_wuttaweb_registered_components')) + + # registering a component + self.request.register_component('foo-example', 'FooExample') + self.assertTrue(hasattr(self.request, '_wuttaweb_registered_components')) + self.assertEqual(len(self.request._wuttaweb_registered_components), 1) + self.assertIn('foo-example', self.request._wuttaweb_registered_components) + self.assertEqual(self.request._wuttaweb_registered_components['foo-example'], 'FooExample') + + # re-registering same name + self.request.register_component('foo-example', 'FooExample') + self.assertEqual(len(self.request._wuttaweb_registered_components), 1) + self.assertIn('foo-example', self.request._wuttaweb_registered_components) + self.assertEqual(self.request._wuttaweb_registered_components['foo-example'], 'FooExample') + def test_get_referrer(self): event = MagicMock(request=self.request) @@ -346,7 +374,7 @@ class TestBeforeRender(TestCase): def setUp(self): self.config = WuttaConfig(defaults={ - 'wutta.web.menus.handler_spec': 'tests.util:NullMenuHandler', + 'wutta.web.menus.handler.spec': 'tests.util:NullMenuHandler', }) def make_request(self): @@ -378,6 +406,24 @@ class TestBeforeRender(TestCase): self.assertIn('json', event) self.assertIs(event['json'], json) + # current theme should be 'default' and picker not exposed + self.assertEqual(event['theme'], 'default') + self.assertFalse(event['expose_theme_picker']) + self.assertNotIn('available_themes', event) + + def test_custom_theme(self): + self.config.setdefault('wuttaweb.themes.expose_picker', 'true') + request = self.make_request() + request.registry.settings['wuttaweb.theme'] = 'butterfly' + event = {'request': request} + + # event dict will get populated with more context + subscribers.before_render(event) + self.assertEqual(event['theme'], 'butterfly') + self.assertTrue(event['expose_theme_picker']) + self.assertIn('available_themes', event) + self.assertEqual(event['available_themes'], ['default', 'butterfly']) + class TestIncludeMe(TestCase): diff --git a/tests/test_util.py b/tests/test_util.py index b8c7ba7..94a7f65 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -11,9 +11,12 @@ from fanstatic import Library, Resource from pyramid import testing from wuttjamaican.conf import WuttaConfig -from wuttjamaican.testing import ConfigTestCase +from wuttjamaican.testing import ConfigTestCase, DataTestCase +from wuttjamaican.util import resource_path from wuttaweb import util as mod +from wuttaweb.app import establish_theme +from wuttaweb.testing import WebTestCase class TestFieldList(TestCase): @@ -621,3 +624,89 @@ class TestMakeJsonSafe(TestCase): 'bar', "Betty Boop", ]) + + +class TestGetAvailableThemes(TestCase): + + def setUp(self): + self.config = WuttaConfig() + self.app = self.config.get_app() + + def test_defaults(self): + themes = mod.get_available_themes(self.config) + self.assertEqual(themes, ['default', 'butterfly']) + + def test_sorting(self): + self.config.setdefault('wuttaweb.themes.keys', 'default, foo2, foo4, foo1') + themes = mod.get_available_themes(self.config) + self.assertEqual(themes, ['default', 'foo1', 'foo2', 'foo4']) + + def test_default_omitted(self): + self.config.setdefault('wuttaweb.themes.keys', 'butterfly, foo') + themes = mod.get_available_themes(self.config) + self.assertEqual(themes, ['default', 'butterfly', 'foo']) + + def test_default_notfirst(self): + self.config.setdefault('wuttaweb.themes.keys', 'butterfly, foo, default') + themes = mod.get_available_themes(self.config) + self.assertEqual(themes, ['default', 'butterfly', 'foo']) + + +class TestGetEffectiveTheme(DataTestCase): + + def test_default(self): + theme = mod.get_effective_theme(self.config) + self.assertEqual(theme, 'default') + + def test_override_config(self): + self.app.save_setting(self.session, 'wuttaweb.theme', 'butterfly') + self.session.commit() + theme = mod.get_effective_theme(self.config) + self.assertEqual(theme, 'butterfly') + + def test_override_param(self): + theme = mod.get_effective_theme(self.config, theme='butterfly') + self.assertEqual(theme, 'butterfly') + + def test_invalid(self): + self.assertRaises(ValueError, mod.get_effective_theme, self.config, theme='invalid') + + +class TestThemeTemplatePath(DataTestCase): + + def test_default(self): + path = mod.get_theme_template_path(self.config, theme='default') + # nb. even though the path does not exist, we still want to + # pretend like it does, hence prev call should return this: + expected = resource_path('wuttaweb:templates/themes/default') + self.assertEqual(path, expected) + + def test_default(self): + path = mod.get_theme_template_path(self.config, theme='butterfly') + expected = resource_path('wuttaweb:templates/themes/butterfly') + self.assertEqual(path, expected) + + def test_custom(self): + self.config.setdefault('wuttaweb.themes.keys', 'default, butterfly, poser') + self.config.setdefault('wuttaweb.theme.poser', '/tmp/poser-theme') + path = mod.get_theme_template_path(self.config, theme='poser') + self.assertEqual(path, '/tmp/poser-theme') + + +class TestSetAppTheme(WebTestCase): + + def test_basic(self): + + # establish default + settings = self.request.registry.settings + self.assertNotIn('wuttaweb.theme', settings) + establish_theme(settings) + self.assertEqual(settings['wuttaweb.theme'], 'default') + + # set to butterfly + mod.set_app_theme(self.request, 'butterfly', session=self.session) + self.assertEqual(settings['wuttaweb.theme'], 'butterfly') + + # set back to default + mod.set_app_theme(self.request, 'default', session=self.session) + self.assertEqual(settings['wuttaweb.theme'], 'default') diff --git a/tests/views/test_common.py b/tests/views/test_common.py index f889c00..c435fbe 100644 --- a/tests/views/test_common.py +++ b/tests/views/test_common.py @@ -6,6 +6,7 @@ import colander from wuttaweb.views import common as mod from wuttaweb.testing import WebTestCase +from wuttaweb.app import establish_theme class TestCommonView(WebTestCase): @@ -180,3 +181,32 @@ class TestCommonView(WebTestCase): self.assertEqual(person.first_name, "Barney") self.assertEqual(person.last_name, "Rubble") self.assertEqual(person.full_name, "Barney Rubble") + + def test_change_theme(self): + self.pyramid_config.add_route('home', '/') + settings = self.request.registry.settings + establish_theme(settings) + view = self.make_view() + + # theme is not changed if not provided by caller + self.assertEqual(settings['wuttaweb.theme'], 'default') + with patch.object(mod, 'set_app_theme') as set_app_theme: + view.change_theme() + set_app_theme.assert_not_called() + self.assertEqual(settings['wuttaweb.theme'], 'default') + + # but theme will change if provided + with patch.object(self.request, 'params', new={'theme': 'butterfly'}): + with patch.object(mod, 'Session', return_value=self.session): + view.change_theme() + self.assertEqual(settings['wuttaweb.theme'], 'butterfly') + + # flash error if invalid theme is provided + self.assertFalse(self.request.session.peek_flash('error')) + with patch.object(self.request, 'params', new={'theme': 'anotherone'}): + with patch.object(mod, 'Session', return_value=self.session): + view.change_theme() + self.assertEqual(settings['wuttaweb.theme'], 'butterfly') + self.assertTrue(self.request.session.peek_flash('error')) + messages = self.request.session.pop_flash('error') + self.assertIn('Failed to set theme', messages[0])