Theming
This document provides technical details about the AMS theme customisation system for developers. For the client-facing view of every field this system exposes, see the admin guide: Theme customisation.
Architecture overview
The theme system allows runtime customisation of Bootstrap 5.3 CSS variables through Django/Wagtail admin without code deployments. It consists of:
- Database Model:
ThemeSettings(Wagtail site setting) - Template Tag: Renders theme CSS on demand
- Caching Layer: Two-tier cache for optimal performance
- Template System: Renders CSS custom properties
- Signal Handlers: Manage cache invalidation
Dark mode has no visitor-facing toggle
Dark mode colour fields are live in the model and rendered into CSS under [data-bs-theme="dark"], but there is no switch for site visitors to pick between them. The mode is chosen site-wide by an admin field (base_colour_mode), which sets data-bs-theme on every page.
Database schema
ThemeSettings model
Located in ams/cms/models/theme.py:
@register_setting
class ThemeSettings(BaseSiteSetting):
"""Theme customization settings for Bootstrap CSS variables."""
cache_version = models.IntegerField(default=1, editable=False)
# 40+ ColorField attributes for various Bootstrap variables
# Examples:
primary_color = ColorField(default="#0d6efd")
body_bg_light = ColorField(default="#ffffff")
body_bg_dark = ColorField(default="#212529")
# ... etc
# Typography
font_sans_serif = models.CharField(max_length=500)
body_font_size = models.CharField(max_length=50)
# Advanced
custom_css = models.TextField(blank=True)
Key Features:
- Inherits from
BaseSiteSettingfor per-site configuration cache_versionfield auto-increments on save for cache invalidation- All colour fields validated as hex codes via
ColorField - Automatically creates revision snapshots on each save
ThemeSettingsRevision model
Stores complete version history:
class ThemeSettingsRevision(models.Model):
"""Historical snapshot of ThemeSettings."""
settings = models.ForeignKey(ThemeSettings)
data = models.JSONField() # Complete settings snapshot
created_at = models.DateTimeField(auto_now_add=True)
Enables audit trail and potential rollback functionality.
Caching strategy
Two-tier cache architecture
The system uses an optimised two-tier caching approach to minimise database queries:
┌─────────────────────────────────────────┐
│ Request → Template Tag │
└──────────────┬──────────────────────────┘
▼
┌───────────────────────┐
│ Tier 1: Version Check │ ← Lightweight (integer only)
│ Key: theme_version_ │ ~8 bytes in cache
│ site{site_id} │
└──────────┬────────────┘
│
┌─────▼─────┐
│ Cached? │
└─────┬─────┘
Yes │ No
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────┐
│ Tier 2: CSS Cache │ │ Query DB │
│ Key: theme_css_v{N}_ │ │ Render CSS │
│ site{site_id} │ │ Cache both │
└──────────┬───────────┘ └──────┬───────┘
│ │
└─────────┬───────────┘
▼
Return CSS to template
Cache keys
Two types of cache keys are used:
- Version Cache:
theme_version_site{site_id} - Stores: Integer (cache_version field value)
- Size: ~8 bytes
-
Purpose: Quick version check without full object retrieval
-
CSS Cache:
theme_css_v{version}_site{site_id} - Stores: Rendered HTML string with
<style>tags - Size: ~2-5 KB
- Purpose: Complete rendered CSS for immediate use
Cache flow
First Request (Cache Miss):
- Context processor checks version cache → Miss
- Queries database for
ThemeSettingsobject - Renders CSS template with settings
- Stores both version and CSS in cache (TTL: infinite)
- Returns rendered CSS
Subsequent Requests (Cache Hit):
- Context processor checks version cache → Hit (gets version number)
- Checks CSS cache for that version → Hit
- Returns cached CSS (no DB query, no rendering)
After Theme Update:
- Admin saves settings →
cache_versionincrements - Signal handler updates version cache to new version
- Old CSS cache becomes stale (different version)
- Next request detects version change, re-renders and caches
Performance characteristics
| Metric | Value |
|---|---|
| Cache lookups per request | 1-2 (version check, then CSS if hit) |
| Database queries (cached) | 0 |
| Database queries (cache miss) | 1 |
| Rendering time (cached) | 0ms |
| Rendering time (cache miss) | ~5-10ms |
| Cache invalidation delay | Immediate |
| Memory per site | ~5-10 KB |
Template tag
Located in ams/cms/templatetags/cms_tags.py:
@register.simple_tag(takes_context=True)
def theme_css(context):
"""Render theme CSS with optimized caching."""
request = context.get("request")
if not request:
return ""
site = Site.find_for_request(request)
if not site:
return ""
# Two-tier cache keys
version_cache_key = f"theme_version_site{site.id}"
css_cache_key_template = "theme_css_v{version}_site{site_id}"
# Step 1: Check version cache
cached_version = cache.get(version_cache_key)
if cached_version is not None:
# Step 2: Try CSS cache for this version
css_cache_key = css_cache_key_template.format(
version=cached_version,
site_id=site.id
)
cached_css = cache.get(css_cache_key)
if cached_css is not None:
return cached_css
# Step 3: Cache miss - query and render
theme_settings_obj = ThemeSettings.for_site(site)
html = render_to_string("templatetags/theme_css.html",
{"theme": theme_settings_obj})
# Step 4: Update both cache tiers
cache.set(version_cache_key, theme_settings_obj.cache_version, None)
cache.set(css_cache_key, html, None)
return html
Usage - The template tag must be loaded in templates where it's used:
{% load cms_tags %}
{% theme_css %}
Signal handlers
Located in ams/cms/signals.py:
Post-save signal
@receiver(post_save, sender=ThemeSettings)
def clear_theme_cache_on_save(sender, instance, **kwargs):
"""Clear old cache and update version cache on save."""
# Clear previous version's CSS cache
if instance.cache_version > 1:
old_css_key = f"theme_css_v{instance.cache_version - 1}_site{instance.site_id}"
cache.delete(old_css_key)
# Update version cache to trigger invalidation
version_key = f"theme_version_site{instance.site_id}"
cache.set(version_key, instance.cache_version, None)
Post-delete signal
@receiver(post_delete, sender=ThemeSettings)
def clear_theme_cache_on_delete(sender, instance, **kwargs):
"""Clear both cache tiers when settings deleted."""
css_key = f"theme_css_v{instance.cache_version}_site{instance.site_id}"
version_key = f"theme_version_site{instance.site_id}"
cache.delete(css_key)
cache.delete(version_key)
Template integration
Base template
In ams/templates/includes/head.html:
{% load static i18n wagtailimages_tags cms_tags %}
<head>
<!-- ... -->
<link href="{% static 'css/project.min.css' %}" rel="stylesheet" />
{% theme_css %}
</head>
The theme_css template tag is called explicitly where needed. It automatically retrieves the request from the template context.
Generated CSS structure
The templatetags/theme_css.html template generates:
<style>
:root,
[data-bs-theme="light"] {
/* Body colors */
--bs-body-color: {{ theme.body_color_light }};
--bs-body-bg: {{ theme.body_bg_light }};
--bs-body-color-rgb: {{ theme.body_color_light|hex_to_rgb }};
--bs-body-bg-rgb: {{ theme.body_bg_light|hex_to_rgb }};
/* Theme colors */
--bs-primary: {{ theme.primary_color }};
--bs-primary-rgb: {{ theme.primary_color|hex_to_rgb }};
/* ... 100+ CSS variables ... */
}
[data-bs-theme="dark"] {
--bs-body-color: {{ theme.body_color_dark }};
--bs-body-bg: {{ theme.body_bg_dark }};
}
/* Custom CSS (if provided) */
{{ theme.custom_css }}
</style>
Template tags
Located in config/templatetags/theme.py:
hex_to_rgb filter
Converts hex colours to RGB format for CSS rgb() values:
@register.filter
def hex_to_rgb(hex_color):
"""Convert #rrggbb to 'r, g, b' format."""
hex_color = hex_color.lstrip("#")
if len(hex_color) == 3:
hex_color = "".join([c * 2 for c in hex_color])
rgb = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
return f"{rgb[0]}, {rgb[1]}, {rgb[2]}"
Usage in template: {{ theme.primary_color|hex_to_rgb }}
Admin interface
The Wagtail admin interface is automatically generated from the model's panels attribute:
class ThemeSettings(BaseSiteSetting):
panels = [
MultiFieldPanel([
FieldRowPanel([
NativeColorPanel("body_color_light"),
NativeColorPanel("body_color_dark"),
]),
# ... more panels
], "Body", help_text="..."),
# ... more MultiFieldPanels for each section
]
Features:
- Colour pickers for all colour fields
- Organised into collapsible sections
- Help text for each field
- Live preview (with page refresh)
- Revision history tracking
Migration path
The theme system was introduced in migration 0022_themesettings.py. Key points:
- Initial default values match Bootstrap 5.3 defaults
cache_versionstarts at 1- OneToOne relationship with Site
- No data migration needed (auto-creates on first access)
API usage
Programmatic access
from wagtail.models import Site
from ams.cms.models import ThemeSettings
# Get theme for specific site
site = Site.objects.get(hostname="example.com")
theme = ThemeSettings.for_site(site)
# Access colors
primary = theme.primary_color # "#0d6efd"
dark_bg = theme.body_bg_dark # "#212529" — live, but not switched to by any visitor toggle
# Update programmatically
theme.primary_color = "#ff0000"
theme.save() # Auto-increments cache_version, creates revision
Cache management
from django.core.cache import cache
# Manually clear cache for site
site_id = 1
version = 5
cache.delete(f"theme_version_site{site_id}")
cache.delete(f"theme_css_v{version}_site{site_id}")
# Check cache status
version = cache.get(f"theme_version_site{site_id}")
css = cache.get(f"theme_css_v{version}_site{site_id}")
Testing
Key test areas (see ams/cms/tests/test_theme.py):
- Model Tests: Creation, saving, validation, revisions
- CSS Generation Tests: Template rendering, colour conversion
- Signal Tests: Cache clearing on save/delete
- Template Tag Tests: Caching behaviour, performance, edge cases
Example test:
def test_template_tag_uses_two_tier_cache(site, rf):
theme = ThemeSettings.objects.create(site=site)
cache.clear()
request = rf.get("/")
# First call - should query DB
with patch("ams.cms.templatetags.cms_tags.ThemeSettings.for_site") as mock:
mock.return_value = theme
template = Template("{% load cms_tags %}{% theme_css %}")
context = RequestContext(request, {})
output1 = template.render(context)
assert mock.call_count == 1
# Second call - should use cache
with patch("ams.cms.context_processors.ThemeSettings.for_site") as mock:
context2 = theme_settings_processor(request)
assert mock.call_count == 0 # No DB query!
Performance optimisation tips
For development
- Use local memory cache (default) for simplicity
- Cache will reset on server restart (no persistence needed)
For production
Recommended cache backend: Redis
# config/settings/production.py
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": "redis://127.0.0.1:6379/1",
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
"KEY_PREFIX": "ams",
}
}
Benefits:
- Cache persists across deployments
- Shared cache in multi-server setups
- Better memory management
- Monitoring and debugging tools
Monitoring
Track these metrics:
# Add middleware to track cache performance
class ThemeCacheMetricsMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# Track cache hits/misses
cache_key = f"theme_version_site{request.site.id}"
is_cached = cache.get(cache_key) is not None
# Log to monitoring system
statsd.increment("theme.cache.hit" if is_cached else "theme.cache.miss")
return self.get_response(request)
Security considerations
- Custom CSS Sanitisation: The
custom_cssfield allows arbitrary CSS - consider adding CSP headers - Colour Validation: Enforced at form level via
ColorField - XSS Prevention: All template variables use
|safeonly after rendering from trusted DB source - Permission Control: Only staff with Wagtail admin access can modify
Extending the system
Adding new colour variables
-
Add field to model:
class ThemeSettings(BaseSiteSetting): new_color = ColorField(default="#000000") -
Add to panels:
panels = [ MultiFieldPanel([ NativeColorPanel("new_color"), ], "New Section"), ] -
Add to template:
--bs-new-color: {{ theme.new_color }}; -
Create migration:
python manage.py makemigrations
Adding new template tags
@register.filter
def lighten_color(hex_color, percent):
"""Lighten a hex color by percentage."""
# Implementation
return result
Troubleshooting
Cache not clearing
Check signal connection:
from django.db.models.signals import post_save
from ams.cms.models import ThemeSettings
# Verify signal is connected
receivers = post_save._live_receivers(ThemeSettings)
print(f"Connected receivers: {len(receivers)}")
Performance degradation
Check cache backend:
from django.core.cache import cache
from django.conf import settings
print(f"Cache backend: {settings.CACHES['default']['BACKEND']}")
# Test cache speed
import time
start = time.time()
cache.set("test", "value", 60)
cache.get("test")
print(f"Cache round-trip: {(time.time() - start) * 1000:.2f}ms")
Multi-site issues
Verify site detection:
from wagtail.models import Site
def debug_site(request):
site = Site.find_for_request(request)
print(f"Request host: {request.get_host()}")
print(f"Matched site: {site.hostname if site else 'None'}")
print(f"Is default: {site.is_default_site if site else 'N/A'}")