3
0
Fork 0

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.
This commit is contained in:
Lance Edgar 2025-06-29 09:16:44 -05:00
parent 749aca560a
commit 796e793547
20 changed files with 1604 additions and 52 deletions

View file

@ -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

View file

@ -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
<script type="text/x-template" id="my-example-template">
<div>my example</div>
</script>
<script>
## define component logic
const MyExample = {
template: 'my-example-template'
}
## register the component both ways here..
## this is for Vue 2 - note the lack of quotes for classname
Vue.component('my-example', MyExample)
## this is for Vue 3 - note the classname must be quoted
<% request.register_component('my-example', 'MyExample') %>
</script>
"""
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')

View file

@ -39,6 +39,13 @@
@input="settingsNeedSaved = true">
Production Mode
</b-checkbox>
<span style="width: 1rem;" />
<b-checkbox name="wuttaweb.themes.expose_picker"
v-model="simpleSettings['wuttaweb.themes.expose_picker']"
native-value="true"
@input="settingsNeedSaved = true">
Expose Theme Picker
</b-checkbox>
</b-field>
<b-field label="Menu Handler">

View file

@ -440,7 +440,27 @@
</div>
</%def>
<%def name="render_theme_picker()"></%def>
<%def name="render_theme_picker()">
% if expose_theme_picker and request.has_perm('common.change_theme'):
<div class="level-item">
${h.form(url('change_theme'), method='POST', ref='themePickerForm')}
${h.csrf_token(request)}
<input type="hidden" name="referrer" :value="referrer" />
<div style="display: flex; align-items: center; gap: 0.5rem;">
<span>Theme:</span>
<${b}-select name="theme"
v-model="globalTheme"
@input="changeTheme()">
% for name in available_themes:
<option value="${name}">${name}</option>
% endfor
</${b}-select>
</div>
${h.end_form()}
</div>
% endif
</%def>
<%def name="render_feedback_button()">
% if request.has_perm('common.feedback'):
@ -459,8 +479,8 @@
Feedback
</b-button>
<b-modal has-modal-card
:active.sync="showDialog">
<${b}-modal has-modal-card
:active.sync="showDialog">
<div class="modal-card">
<header class="modal-card-head">
@ -507,7 +527,7 @@
</b-button>
</footer>
</div>
</b-modal>
</${b}-modal>
</div>
</script>
@ -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 @@
<script>
WholePage.data = function() { return WholePageData }
Vue.component('whole-page', WholePage)
<% request.register_component('whole-page', 'WholePage') %>
</script>
% if request.has_perm('common.feedback'):
<script>
WuttaFeedbackForm.data = function() { return WuttaFeedbackFormData }
Vue.component('wutta-feedback-form', WuttaFeedbackForm)
<% request.register_component('wutta-feedback-form', 'WuttaFeedbackForm') %>
</script>
% endif
</%def>

View file

@ -95,3 +95,5 @@
}
</script>
<% request.register_component(form.vue_tagname, form.vue_component) %>

View file

@ -739,3 +739,5 @@
}
</script>
<% request.register_component(grid.vue_tagname, grid.vue_component) %>

View file

@ -50,6 +50,6 @@
<script>
ThisPage.data = function() { return ThisPageData }
Vue.component('this-page', ThisPage)
## <% request.register_component('this-page', 'ThisPage') %>
<% request.register_component('this-page', 'ThisPage') %>
</script>
</%def>

View file

@ -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()">
<script type="importmap">
{
"imports": {
"vue": "${h.get_liburl(request, 'bb_vue')}",
"@oruga-ui/oruga-next": "${h.get_liburl(request, 'bb_oruga')}",
"@oruga-ui/theme-bulma": "${h.get_liburl(request, 'bb_oruga_bulma')}",
"@fortawesome/fontawesome-svg-core": "${h.get_liburl(request, 'bb_fontawesome_svg_core')}",
"@fortawesome/free-solid-svg-icons": "${h.get_liburl(request, 'bb_free_solid_svg_icons')}",
"@fortawesome/vue-fontawesome": "${h.get_liburl(request, 'bb_vue_fontawesome')}"
}
}
</script>
<script>
## nb. empty stub to avoid errors for older buefy templates
const Vue = {
component(tagname, classname) {},
}
</script>
</%def>
<%def name="core_styles()">
${h.stylesheet_link(h.get_liburl(request, 'bb_oruga_bulma_css'))}
</%def>
<%def name="render_vue_templates()">
${parent.render_vue_templates()}
${make_buefy_components()}
</%def>
<%def name="extra_styles()">
${parent.extra_styles()}
<style>
html, body, .navbar, .footer {
background-color: LightYellow;
}
</style>
</%def>
<%def name="make_vue_app()">
${make_http_plugin()}
${make_buefy_plugin()}
<script type="module">
import { createApp } from 'vue'
import { Oruga } from '@oruga-ui/oruga-next'
import { bulmaConfig } from '@oruga-ui/theme-bulma'
import { library } from "@fortawesome/fontawesome-svg-core"
import { fas } from "@fortawesome/free-solid-svg-icons"
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"
library.add(fas)
const app = createApp()
app.component('vue-fontawesome', FontAwesomeIcon)
% if hasattr(request, '_wuttaweb_registered_components'):
% for tagname, classname in request._wuttaweb_registered_components.items():
app.component('${tagname}', ${classname})
% endfor
% endif
app.use(Oruga, {
...bulmaConfig,
iconComponent: 'vue-fontawesome',
iconPack: 'fas',
})
app.use(HttpPlugin)
app.use(BuefyPlugin)
app.mount('#app')
</script>
</%def>

View file

@ -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>
<%def name="make_b_autocomplete_component()">
<script type="text/x-template" id="b-autocomplete-template">
<o-autocomplete v-model="orugaValue"
:data="data"
:field="field"
:open-on-focus="openOnFocus"
:keep-first="keepFirst"
:clearable="clearable"
:clear-on-select="clearOnSelect"
:formatter="customFormatter"
:placeholder="placeholder"
@update:model-value="orugaValueUpdated"
ref="autocomplete">
</o-autocomplete>
</script>
<script>
const BAutocomplete = {
template: '#b-autocomplete-template',
props: {
modelValue: String,
data: Array,
field: String,
openOnFocus: Boolean,
keepFirst: Boolean,
clearable: Boolean,
clearOnSelect: Boolean,
customFormatter: null,
placeholder: String,
},
data() {
return {
orugaValue: this.modelValue,
}
},
watch: {
modelValue(to, from) {
if (this.orugaValue != to) {
this.orugaValue = to
}
},
},
methods: {
focus() {
const input = this.$refs.autocomplete.$el.querySelector('input')
input.focus()
},
orugaValueUpdated(value) {
this.$emit('update:modelValue', value)
},
},
}
</script>
<% request.register_component('b-autocomplete', 'BAutocomplete') %>
</%def>
<%def name="make_b_button_component()">
<script type="text/x-template" id="b-button-template">
<o-button :variant="variant"
:size="orugaSize"
:native-type="nativeType"
:tag="tag"
:href="href"
:icon-left="iconLeft">
<slot />
</o-button>
</script>
<script>
const BButton = {
template: '#b-button-template',
props: {
type: String,
nativeType: String,
tag: String,
href: String,
size: String,
iconPack: String, // ignored
iconLeft: String,
},
computed: {
orugaSize() {
if (this.size) {
return this.size.replace(/^is-/, '')
}
},
variant() {
if (this.type) {
return this.type.replace(/^is-/, '')
}
},
},
}
</script>
<% request.register_component('b-button', 'BButton') %>
</%def>
<%def name="make_b_checkbox_component()">
<script type="text/x-template" id="b-checkbox-template">
<o-checkbox v-model="orugaValue"
@update:model-value="orugaValueUpdated"
:name="name"
:native-value="nativeValue">
<slot />
</o-checkbox>
</script>
<script>
const BCheckbox = {
template: '#b-checkbox-template',
props: {
modelValue: null,
name: String,
nativeValue: null,
value: null,
},
data() {
return {
orugaValue: this.modelValue || this.value,
}
},
watch: {
modelValue(to, from) {
this.orugaValue = to
},
},
methods: {
orugaValueUpdated(value) {
this.$emit('update:modelValue', value)
},
},
}
</script>
<% request.register_component('b-checkbox', 'BCheckbox') %>
</%def>
<%def name="make_b_collapse_component()">
<script type="text/x-template" id="b-collapse-template">
<o-collapse :open="open">
<slot name="trigger" />
<slot />
</o-collapse>
</script>
<script>
const BCollapse = {
template: '#b-collapse-template',
props: {
open: Boolean,
},
}
</script>
<% request.register_component('b-collapse', 'BCollapse') %>
</%def>
<%def name="make_b_datepicker_component()">
<script type="text/x-template" id="b-datepicker-template">
<o-datepicker :name="name"
v-model="orugaValue"
@update:model-value="orugaValueUpdated"
:value="value"
:placeholder="placeholder"
:date-formatter="dateFormatter"
:date-parser="dateParser"
:disabled="disabled"
:editable="editable"
:icon="icon"
:close-on-click="false">
</o-datepicker>
</script>
<script>
const BDatepicker = {
template: '#b-datepicker-template',
props: {
dateFormatter: null,
dateParser: null,
disabled: Boolean,
editable: Boolean,
icon: String,
// iconPack: String, // ignored
modelValue: Date,
name: String,
placeholder: String,
value: null,
},
data() {
return {
orugaValue: this.modelValue,
}
},
watch: {
modelValue(to, from) {
if (this.orugaValue != to) {
this.orugaValue = to
}
},
},
methods: {
orugaValueUpdated(value) {
if (this.modelValue != value) {
this.$emit('update:modelValue', value)
}
},
},
}
</script>
<% request.register_component('b-datepicker', 'BDatepicker') %>
</%def>
<%def name="make_b_dropdown_component()">
<script type="text/x-template" id="b-dropdown-template">
<o-dropdown :position="buefyPosition"
:triggers="triggers">
<slot name="trigger" />
<slot />
</o-dropdown>
</script>
<script>
const BDropdown = {
template: '#b-dropdown-template',
props: {
position: String,
triggers: Array,
},
computed: {
buefyPosition() {
if (this.position) {
return this.position.replace(/^is-/, '')
}
},
},
}
</script>
<% request.register_component('b-dropdown', 'BDropdown') %>
</%def>
<%def name="make_b_dropdown_item_component()">
<script type="text/x-template" id="b-dropdown-item-template">
<o-dropdown-item :label="label">
<slot />
</o-dropdown-item>
</script>
<script>
const BDropdownItem = {
template: '#b-dropdown-item-template',
props: {
label: String,
},
}
</script>
<% request.register_component('b-dropdown-item', 'BDropdownItem') %>
</%def>
<%def name="make_b_field_component()">
<script type="text/x-template" id="b-field-template">
<o-field :grouped="grouped"
:label="label"
:horizontal="horizontal"
:expanded="expanded"
:variant="variant">
<slot />
</o-field>
</script>
<script>
const BField = {
template: '#b-field-template',
props: {
expanded: Boolean,
grouped: Boolean,
horizontal: Boolean,
label: String,
type: String,
},
computed: {
variant() {
if (this.type) {
return this.type.replace(/^is-/, '')
}
},
},
}
</script>
<% request.register_component('b-field', 'BField') %>
</%def>
<%def name="make_b_icon_component()">
<script type="text/x-template" id="b-icon-template">
<o-icon :icon="icon"
:size="orugaSize" />
</script>
<script>
const BIcon = {
template: '#b-icon-template',
props: {
icon: String,
size: String,
},
computed: {
orugaSize() {
if (this.size) {
return this.size.replace(/^is-/, '')
}
},
},
}
</script>
<% request.register_component('b-icon', 'BIcon') %>
</%def>
<%def name="make_b_input_component()">
<script type="text/x-template" id="b-input-template">
<o-input :type="type"
:disabled="disabled"
v-model="orugaValue"
@update:modelValue="val => $emit('update:modelValue', val)"
:autocomplete="autocomplete"
ref="input"
:expanded="expanded">
<slot />
</o-input>
</script>
<script>
const BInput = {
template: '#b-input-template',
props: {
modelValue: null,
type: String,
autocomplete: String,
disabled: Boolean,
expanded: Boolean,
},
data() {
return {
orugaValue: this.modelValue
}
},
watch: {
modelValue(to, from) {
if (this.orugaValue != to) {
this.orugaValue = to
}
},
},
methods: {
focus() {
if (this.type == 'textarea') {
// TODO: this does not always work right?
this.$refs.input.$el.querySelector('textarea').focus()
} else {
// TODO: pretty sure we can rely on the <o-input> focus()
// here, but not sure why we weren't already doing that?
//this.$refs.input.$el.querySelector('input').focus()
this.$refs.input.focus()
}
},
},
}
</script>
<% request.register_component('b-input', 'BInput') %>
</%def>
<%def name="make_b_loading_component()">
<script type="text/x-template" id="b-loading-template">
<o-loading :full-page="isFullPage">
<slot />
</o-loading>
</script>
<script>
const BLoading = {
template: '#b-loading-template',
props: {
isFullPage: Boolean,
},
}
</script>
<% request.register_component('b-loading', 'BLoading') %>
</%def>
<%def name="make_b_modal_component()">
<script type="text/x-template" id="b-modal-template">
<o-modal v-model:active="trueActive"
@update:active="activeChanged">
<slot />
</o-modal>
</script>
<script>
const BModal = {
template: '#b-modal-template',
props: {
active: Boolean,
hasModalCard: Boolean, // nb. this is ignored
},
data() {
return {
trueActive: this.active,
}
},
watch: {
active(to, from) {
this.trueActive = to
},
trueActive(to, from) {
if (this.active != to) {
this.tellParent(to)
}
},
},
methods: {
tellParent(active) {
// TODO: this does not work properly
this.$emit('update:active', active)
},
activeChanged(active) {
this.tellParent(active)
},
},
}
</script>
<% request.register_component('b-modal', 'BModal') %>
</%def>
<%def name="make_b_notification_component()">
<script type="text/x-template" id="b-notification-template">
<o-notification :variant="variant"
:closable="closable">
<slot />
</o-notification>
</script>
<script>
const BNotification = {
template: '#b-notification-template',
props: {
type: String,
closable: {
type: Boolean,
default: true,
},
},
computed: {
variant() {
if (this.type) {
return this.type.replace(/^is-/, '')
}
},
},
}
</script>
<% request.register_component('b-notification', 'BNotification') %>
</%def>
<%def name="make_b_radio_component()">
<script type="text/x-template" id="b-radio-template">
<o-radio v-model="orugaValue"
@update:model-value="orugaValueUpdated"
:native-value="nativeValue">
<slot />
</o-radio>
</script>
<script>
const BRadio = {
template: '#b-radio-template',
props: {
modelValue: null,
nativeValue: null,
},
data() {
return {
orugaValue: this.modelValue,
}
},
watch: {
modelValue(to, from) {
this.orugaValue = to
},
},
methods: {
orugaValueUpdated(value) {
this.$emit('update:modelValue', value)
},
},
}
</script>
<% request.register_component('b-radio', 'BRadio') %>
</%def>
<%def name="make_b_select_component()">
<script type="text/x-template" id="b-select-template">
<o-select :name="name"
ref="select"
v-model="orugaValue"
@update:model-value="orugaValueUpdated"
:expanded="expanded"
:multiple="multiple"
:size="orugaSize"
:native-size="nativeSize">
<slot />
</o-select>
</script>
<script>
const BSelect = {
template: '#b-select-template',
props: {
expanded: Boolean,
modelValue: null,
multiple: Boolean,
name: String,
nativeSize: null,
size: null,
},
data() {
return {
orugaValue: this.modelValue,
}
},
watch: {
modelValue(to, from) {
this.orugaValue = to
},
},
computed: {
orugaSize() {
if (this.size) {
return this.size.replace(/^is-/, '')
}
},
},
methods: {
focus() {
this.$refs.select.focus()
},
orugaValueUpdated(value) {
this.$emit('update:modelValue', value)
this.$emit('input', value)
},
},
}
</script>
<% request.register_component('b-select', 'BSelect') %>
</%def>
<%def name="make_b_steps_component()">
<script type="text/x-template" id="b-steps-template">
<o-steps v-model="orugaValue"
@update:model-value="orugaValueUpdated"
:animated="animated"
:rounded="rounded"
:has-navigation="hasNavigation"
:vertical="vertical">
<slot />
</o-steps>
</script>
<script>
const BSteps = {
template: '#b-steps-template',
props: {
modelValue: null,
animated: Boolean,
rounded: Boolean,
hasNavigation: Boolean,
vertical: Boolean,
},
data() {
return {
orugaValue: this.modelValue,
}
},
watch: {
modelValue(to, from) {
this.orugaValue = to
},
},
methods: {
orugaValueUpdated(value) {
this.$emit('update:modelValue', value)
this.$emit('input', value)
},
},
}
</script>
<% request.register_component('b-steps', 'BSteps') %>
</%def>
<%def name="make_b_step_item_component()">
<script type="text/x-template" id="b-step-item-template">
<o-step-item :step="step"
:value="value"
:label="label"
:clickable="clickable">
<slot />
</o-step-item>
</script>
<script>
const BStepItem = {
template: '#b-step-item-template',
props: {
step: null,
value: null,
label: String,
clickable: Boolean,
},
}
</script>
<% request.register_component('b-step-item', 'BStepItem') %>
</%def>
<%def name="make_b_table_component()">
<script type="text/x-template" id="b-table-template">
<o-table :data="data">
<slot />
</o-table>
</script>
<script>
const BTable = {
template: '#b-table-template',
props: {
data: Array,
},
}
</script>
<% request.register_component('b-table', 'BTable') %>
</%def>
<%def name="make_b_table_column_component()">
<script type="text/x-template" id="b-table-column-template">
<o-table-column :field="field"
:label="label"
v-slot="props">
## TODO: this does not seem to really work for us...
<slot :props="props" />
</o-table-column>
</script>
<script>
const BTableColumn = {
template: '#b-table-column-template',
props: {
field: String,
label: String,
},
}
</script>
<% request.register_component('b-table-column', 'BTableColumn') %>
</%def>
<%def name="make_b_tooltip_component()">
<script type="text/x-template" id="b-tooltip-template">
<o-tooltip :label="label"
:position="orugaPosition"
:multiline="multilined">
<slot />
</o-tooltip>
</script>
<script>
const BTooltip = {
template: '#b-tooltip-template',
props: {
label: String,
multilined: Boolean,
position: String,
},
computed: {
orugaPosition() {
if (this.position) {
return this.position.replace(/^is-/, '')
}
},
},
}
</script>
<% request.register_component('b-tooltip', 'BTooltip') %>
</%def>
<%def name="make_once_button_component()">
<script type="text/x-template" id="once-button-template">
<b-button :type="type"
:native-type="nativeType"
:tag="tag"
:href="href"
:title="title"
:disabled="buttonDisabled"
@click="clicked"
icon-pack="fas"
:icon-left="iconLeft">
{{ buttonText }}
</b-button>
</script>
<script>
const OnceButton = {
template: '#once-button-template',
props: {
type: String,
nativeType: String,
tag: String,
href: String,
text: String,
title: String,
iconLeft: String,
working: String,
workingText: String,
disabled: Boolean,
},
data() {
return {
currentText: null,
currentDisabled: null,
}
},
computed: {
buttonText: function() {
return this.currentText || this.text
},
buttonDisabled: function() {
if (this.currentDisabled !== null) {
return this.currentDisabled
}
return this.disabled
},
},
methods: {
clicked(event) {
this.currentDisabled = true
if (this.workingText) {
this.currentText = this.workingText
} else if (this.working) {
this.currentText = this.working + ", please wait..."
} else {
this.currentText = "Working, please wait..."
}
// this.$nextTick(function() {
// this.$emit('click', event)
// })
}
},
}
</script>
<% request.register_component('once-button', 'OnceButton') %>
</%def>

View file

@ -0,0 +1,32 @@
<%def name="make_buefy_plugin()">
<script>
const BuefyPlugin = {
install(app, options) {
app.config.globalProperties.$buefy = {
toast: {
open(options) {
let variant = null
if (options.type) {
variant = options.type.replace(/^is-/, '')
}
const opts = {
duration: options.duration,
message: options.message,
position: 'top',
variant,
}
const oruga = app.config.globalProperties.$oruga
oruga.notification.open(opts)
},
},
}
},
}
</script>
</%def>

View file

@ -0,0 +1,100 @@
<%def name="make_http_plugin()">
<script>
const HttpPlugin = {
install(app, options) {
app.config.globalProperties.$http = {
get(url, options) {
if (options === undefined) {
options = {}
}
if (options.params) {
// convert params to query string
const data = new URLSearchParams()
for (let [key, value] of Object.entries(options.params)) {
// nb. all values get converted to string here, so
// fallback to empty string to avoid null value
// from being interpreted as "null" string
if (value === null) {
value = ''
}
data.append(key, value)
}
// TODO: this should be smarter in case query string already exists
url += '?' + data.toString()
// params is not a valid arg for options to fetch()
delete options.params
}
return new Promise((resolve, reject) => {
fetch(url, options).then(response => {
// original response does not contain 'data'
// attribute, so must use a "mock" response
// which does contain everything
response.json().then(json => {
resolve({
data: json,
headers: response.headers,
ok: response.ok,
redirected: response.redirected,
status: response.status,
statusText: response.statusText,
type: response.type,
url: response.url,
})
}, json => {
reject(response)
})
}, response => {
reject(response)
})
})
},
post(url, params, options) {
if (params) {
// attach params as json
options.body = JSON.stringify(params)
// and declare content-type
options.headers = new Headers(options.headers)
options.headers.append('Content-Type', 'application/json')
}
options.method = 'POST'
return new Promise((resolve, reject) => {
fetch(url, options).then(response => {
// original response does not contain 'data'
// attribute, so must use a "mock" response
// which does contain everything
response.json().then(json => {
resolve({
data: json,
headers: response.headers,
ok: response.ok,
redirected: response.redirected,
status: response.status,
statusText: response.statusText,
type: response.type,
url: response.url,
})
}, json => {
reject(response)
})
}, response => {
reject(response)
})
})
},
}
},
}
</script>
</%def>

View file

@ -228,6 +228,7 @@
}
Vue.component('wutta-autocomplete', WuttaAutocomplete)
<% request.register_component('wutta-autocomplete', 'WuttaAutocomplete') %>
</script>
</%def>
@ -295,6 +296,7 @@
},
}
Vue.component('wutta-button', WuttaButton)
<% request.register_component('wutta-button', 'WuttaButton') %>
</script>
</%def>
@ -367,6 +369,7 @@
},
}
Vue.component('wutta-datepicker', WuttaDatepicker)
<% request.register_component('wutta-datepicker', 'WuttaDatepicker') %>
</script>
</%def>
@ -444,6 +447,7 @@
},
}
Vue.component('wutta-timepicker', WuttaTimepicker)
<% request.register_component('wutta-timepicker', 'WuttaTimepicker') %>
</script>
</%def>
@ -616,7 +620,7 @@
}
Vue.component('wutta-filter', WuttaFilter)
<% request.register_component('wutta-filter', 'WuttaFilter') %>
</script>
</%def>
@ -655,7 +659,7 @@
}
Vue.component('wutta-filter-value', WuttaFilterValue)
<% request.register_component('wutta-filter-value', 'WuttaFilterValue') %>
</script>
</%def>
@ -702,7 +706,7 @@
}
Vue.component('wutta-filter-date-value', WuttaFilterDateValue)
<% request.register_component('wutta-filter-date-value', 'WuttaFilterDateValue') %>
</script>
</%def>
@ -727,6 +731,6 @@
}
Vue.component('wutta-tool-panel', WuttaToolPanel)
<% request.register_component('wutta-tool-panel', 'WuttaToolPanel') %>
</script>
</%def>

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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()

View file

@ -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',
])

View file

@ -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):

View file

@ -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')

View file

@ -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])