Source code for coalition.content.models.theme

"""Theme model for managing site themes and branding."""

from typing import TYPE_CHECKING

from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.db import models

from coalition.content.html_sanitizer import HTMLSanitizer

if TYPE_CHECKING:
    from typing import Any


[docs] class Theme(models.Model): """ Model for managing site themes and branding. Allows organizations to customize colors, typography, and brand assets. """ # Theme identification
[docs] name = models.CharField( max_length=100, unique=True, help_text="Name for this theme (e.g., 'Default', 'Land and Bay Stewards')", )
[docs] description = models.TextField( blank=True, null=True, help_text="Optional description of this theme", )
# Color validators
[docs] hex_color_validator = RegexValidator( regex=r"^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$", message="Color must be a valid hex code (e.g., #FF0000 or #F00)", )
# Primary brand colors
[docs] primary_color = models.CharField( max_length=7, default="#2563eb", validators=[hex_color_validator], help_text="Primary brand color (hex format, e.g., #2563eb)", )
[docs] secondary_color = models.CharField( max_length=7, default="#64748b", validators=[hex_color_validator], help_text="Secondary brand color (hex format)", )
[docs] accent_color = models.CharField( max_length=7, default="#059669", validators=[hex_color_validator], help_text="Accent color for highlights and calls-to-action (hex format)", )
# Background colors
[docs] background_color = models.CharField( max_length=7, default="#ffffff", validators=[hex_color_validator], help_text="Main background color (hex format)", )
[docs] section_background_color = models.CharField( max_length=7, default="#f9fafb", validators=[hex_color_validator], help_text="Alternate section background color (hex format)", )
[docs] card_background_color = models.CharField( max_length=7, default="#ffffff", validators=[hex_color_validator], help_text="Card/content block background color (hex format)", )
# Text colors
[docs] heading_color = models.CharField( max_length=7, default="#111827", validators=[hex_color_validator], help_text="Color for headings and titles (hex format)", )
[docs] body_text_color = models.CharField( max_length=7, default="#374151", validators=[hex_color_validator], help_text="Color for body text (hex format)", )
[docs] muted_text_color = models.CharField( max_length=7, default="#6b7280", validators=[hex_color_validator], help_text="Color for muted/secondary text (hex format)", )
# Typography settings
[docs] heading_font_family = models.CharField( max_length=200, default=( "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, " '"Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif' ), help_text="Font family for headings (CSS font-family value)", )
[docs] body_font_family = models.CharField( max_length=200, default=( "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, " '"Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif' ), help_text="Font family for body text (CSS font-family value)", )
[docs] google_fonts = models.JSONField( default=list, blank=True, help_text=( "List of Google Font family names to load " "(e.g., ['Merriweather', 'Barlow'])" ), )
# Font sizes (in rem units)
[docs] font_size_base = models.DecimalField( max_digits=3, decimal_places=2, default=1.00, help_text="Base font size in rem units (e.g., 1.00 for 16px)", )
[docs] font_size_small = models.DecimalField( max_digits=4, decimal_places=3, default=0.875, help_text="Small font size in rem units", )
[docs] font_size_large = models.DecimalField( max_digits=4, decimal_places=3, default=1.125, help_text="Large font size in rem units", )
# Brand assets
[docs] logo_alt_text = models.CharField( max_length=200, blank=True, null=True, help_text="Alt text for logo (accessibility)", )
[docs] favicon = models.ImageField( upload_to="favicons/", blank=True, null=True, help_text="Favicon image", )
# Custom CSS
[docs] custom_css = models.TextField( blank=True, null=True, help_text="Additional custom CSS for advanced styling (optional)", )
# Status and meta
[docs] is_active = models.BooleanField( default=False, help_text="Whether this theme is currently active", )
[docs] created_at = models.DateTimeField( auto_now_add=True, help_text="When this theme was created", )
[docs] updated_at = models.DateTimeField( auto_now=True, help_text="When this theme was last updated", )
[docs] class Meta:
[docs] db_table = "theme"
[docs] verbose_name = "Theme"
[docs] verbose_name_plural = "Themes"
[docs] ordering = ["-is_active", "-updated_at"]
[docs] def __str__(self) -> str: status = " (Active)" if self.is_active else "" return f"{self.name}{status}"
@property
[docs] def logo_url(self) -> str | None: """Return the URL of the uploaded logo, or None if no logo.""" if self.logo and hasattr(self.logo, "url"): return self.logo.url return None
@property
[docs] def favicon_url(self) -> str | None: """Return the URL of the uploaded favicon, or None if no favicon.""" if self.favicon and hasattr(self.favicon, "url"): return self.favicon.url return None
[docs] def clean(self) -> None: """Ensure only one active theme exists""" if self.is_active: # Check if there's already an active theme that's not this one existing_active = Theme.objects.filter(is_active=True).exclude(pk=self.pk) if existing_active.exists(): raise ValidationError( "Only one theme can be active at a time. " "Please deactivate the current active theme first.", )
[docs] def save(self, *args: "Any", **kwargs: "Any") -> None: """Sanitize custom CSS and validate before saving""" if self.custom_css: # Basic sanitization - remove script tags and dangerous content self.custom_css = HTMLSanitizer.sanitize_plain_text(self.custom_css) self.full_clean() super().save(*args, **kwargs)
@classmethod
[docs] def get_active(cls) -> "Theme | None": """Get the currently active theme""" try: return cls.objects.get(is_active=True) except cls.DoesNotExist: return None except cls.MultipleObjectsReturned: # If somehow multiple active exist, return the most recent return cls.objects.filter(is_active=True).order_by("-updated_at").first()
[docs] def generate_css_variables(self) -> str: """Generate CSS custom properties for this theme""" css_parts = [] # Add Google Fonts import if specified if ( self.google_fonts and isinstance(self.google_fonts, list) and len(self.google_fonts) > 0 ): # Filter out empty strings and format font names font_families = [] for font in self.google_fonts: if font and font.strip(): # Replace spaces with + and add default weights font_name = font.strip().replace(" ", "+") font_families.append(f"{font_name}:400,500,600,700") if font_families: fonts_url = f"https://fonts.googleapis.com/css2?family={'&family='.join(font_families)}&display=swap" css_parts.append(f'@import url("{fonts_url}");') # Add CSS variables css_parts.append( f""" :root {{ /* Brand Colors */ --theme-primary: {self.primary_color}; --theme-secondary: {self.secondary_color}; --theme-accent: {self.accent_color}; /* Background Colors */ --theme-bg: {self.background_color}; --theme-bg-section: {self.section_background_color}; --theme-bg-card: {self.card_background_color}; /* Text Colors */ --theme-text-heading: {self.heading_color}; --theme-text-body: {self.body_text_color}; --theme-text-muted: {self.muted_text_color}; --theme-text-link: {self.link_color}; --theme-text-link-hover: {self.link_hover_color}; /* Typography */ --theme-font-heading: {self.heading_font_family}; --theme-font-body: {self.body_font_family}; --theme-font-size-base: {self.font_size_base}rem; --theme-font-size-small: {self.font_size_small}rem; --theme-font-size-large: {self.font_size_large}rem; }} """.strip(), ) return "\n\n".join(css_parts)