Files
orion/app/modules/cms/services/store_theme_service.py
Samir Boulahtit 3044490a3e feat(storefront): section-based homepages, header action partials, fixes
Phase 1 — Section-based store homepages:
- Store defaults use template="full" with per-platform sections JSON
- OMS: shop hero + features + CTA; Loyalty: rewards hero + features + CTA
- Hosting: services hero + features + CTA
- Deep placeholder resolution for {{store_name}} inside sections JSON
- landing-full.html uses resolved page_sections from context

Phase 2 — Module-contributed header actions:
- header_template field on MenuItemDefinition + DiscoveredMenuItem
- Catalog provides header-search.html partial
- Cart provides header-cart.html partial with badge
- Base template iterates storefront_nav.actions with {% include %}
- Generic icon fallback for actions without a template

Fixes:
- Store theme API: get_store_by_code → get_store_by_code_or_subdomain

Docs:
- CMS redesign proposal: menu restructure, page types, translations UI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 23:33:06 +02:00

491 lines
16 KiB
Python

# app/modules/cms/services/store_theme_service.py
"""
Store Theme Service
Business logic for store theme management.
Handles theme CRUD operations, preset application, and validation.
"""
from __future__ import annotations
import logging
import re
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from app.modules.cms.exceptions import (
InvalidColorFormatException,
InvalidFontFamilyException,
StoreThemeNotFoundException,
ThemeOperationException,
ThemePresetNotFoundException,
ThemeValidationException,
)
from app.modules.cms.models import StoreTheme
from app.modules.cms.schemas.store_theme import StoreThemeUpdate, ThemePresetPreview
from app.modules.cms.services.theme_presets import (
THEME_PRESETS,
apply_preset,
get_available_presets,
get_preset_preview,
)
from app.modules.tenancy.exceptions import StoreNotFoundException
logger = logging.getLogger(__name__)
class StoreThemeService:
"""
Service for managing store themes.
This service handles:
- Theme retrieval and creation
- Theme updates and validation
- Preset application
- Default theme generation
"""
def __init__(self):
"""Initialize the store theme service."""
self.logger = logging.getLogger(__name__)
# ============================================================================
# STORE RETRIEVAL
# ============================================================================
def _get_store_by_code(self, db: Session, store_code: str) -> Store:
"""
Get store by code or raise exception.
Args:
db: Database session
store_code: Store code to lookup
Returns:
Store object
Raises:
StoreNotFoundException: If store not found
"""
from app.modules.tenancy.services.store_service import store_service
store = store_service.get_store_by_code_or_subdomain(db, store_code)
if not store:
self.logger.warning(f"Store not found: {store_code}")
raise StoreNotFoundException(store_code, identifier_type="code")
return store
# ============================================================================
# THEME RETRIEVAL
# ============================================================================
def get_theme(self, db: Session, store_code: str) -> dict:
"""
Get theme for store. Returns default if no custom theme exists.
Args:
db: Database session
store_code: Store code
Returns:
Theme dictionary
Raises:
StoreNotFoundException: If store not found
"""
self.logger.info(f"Getting theme for store: {store_code}")
# Verify store exists
store = self._get_store_by_code(db, store_code)
# Get theme
theme = db.query(StoreTheme).filter(StoreTheme.store_id == store.id).first()
if not theme:
self.logger.info(
f"No custom theme for store {store_code}, returning default"
)
return self._get_default_theme()
return theme.to_dict()
def _get_default_theme(self) -> dict:
"""
Get default theme configuration.
Returns:
Default theme dictionary
"""
return {
"theme_name": "default",
"colors": {
"primary": "#6366f1",
"secondary": "#8b5cf6",
"accent": "#ec4899",
"background": "#ffffff",
"text": "#1f2937",
"border": "#e5e7eb",
},
"fonts": {"heading": "Inter, sans-serif", "body": "Inter, sans-serif"},
"branding": {
"logo": None,
"logo_dark": None,
"favicon": None,
"banner": None,
},
"layout": {"style": "grid", "header": "fixed", "product_card": "modern"},
"social_links": {},
"custom_css": None,
"css_variables": {
"--color-primary": "#6366f1",
"--color-secondary": "#8b5cf6",
"--color-accent": "#ec4899",
"--color-background": "#ffffff",
"--color-text": "#1f2937",
"--color-border": "#e5e7eb",
"--font-heading": "Inter, sans-serif",
"--font-body": "Inter, sans-serif",
},
}
# ============================================================================
# THEME UPDATE
# ============================================================================
def update_theme(
self, db: Session, store_code: str, theme_data: StoreThemeUpdate
) -> StoreTheme:
"""
Update or create theme for store.
Args:
db: Database session
store_code: Store code
theme_data: Theme update data
Returns:
Updated StoreTheme object
Raises:
StoreNotFoundException: If store not found
ThemeValidationException: If theme data invalid
ThemeOperationException: If update fails
"""
self.logger.info(f"Updating theme for store: {store_code}")
try:
# Verify store exists
store = self._get_store_by_code(db, store_code)
# Get or create theme
theme = (
db.query(StoreTheme).filter(StoreTheme.store_id == store.id).first()
)
if not theme:
self.logger.info(f"Creating new theme for store {store_code}")
theme = StoreTheme(store_id=store.id, is_active=True)
db.add(theme)
# Validate theme data before applying
self._validate_theme_data(theme_data)
# Update theme fields
self._apply_theme_updates(theme, theme_data)
# Flush changes
db.flush()
db.refresh(theme)
self.logger.info(f"Theme updated successfully for store {store_code}")
return theme
except (StoreNotFoundException, ThemeValidationException):
# Re-raise custom exceptions
raise
except SQLAlchemyError as e:
self.logger.error(f"Failed to update theme for store {store_code}: {e}")
raise ThemeOperationException(
operation="update", store_code=store_code, reason=str(e)
)
def _apply_theme_updates(
self, theme: StoreTheme, theme_data: StoreThemeUpdate
) -> None:
"""
Apply theme updates to theme object.
Args:
theme: StoreTheme object to update
theme_data: Theme update data
"""
# Update theme name
if theme_data.theme_name:
theme.theme_name = theme_data.theme_name
# Update colors
if theme_data.colors:
theme.colors = theme_data.colors
# Update fonts
if theme_data.fonts:
if theme_data.fonts.get("heading"):
theme.font_family_heading = theme_data.fonts["heading"]
if theme_data.fonts.get("body"):
theme.font_family_body = theme_data.fonts["body"]
# Update branding
if theme_data.branding:
if theme_data.branding.get("logo") is not None:
theme.logo_url = theme_data.branding["logo"]
if theme_data.branding.get("logo_dark") is not None:
theme.logo_dark_url = theme_data.branding["logo_dark"]
if theme_data.branding.get("favicon") is not None:
theme.favicon_url = theme_data.branding["favicon"]
if theme_data.branding.get("banner") is not None:
theme.banner_url = theme_data.branding["banner"]
# Update layout
if theme_data.layout:
if theme_data.layout.get("style"):
theme.layout_style = theme_data.layout["style"]
if theme_data.layout.get("header"):
theme.header_style = theme_data.layout["header"]
if theme_data.layout.get("product_card"):
theme.product_card_style = theme_data.layout["product_card"]
# Update custom CSS
if theme_data.custom_css is not None:
theme.custom_css = theme_data.custom_css
# Update social links
if theme_data.social_links:
theme.social_links = theme_data.social_links
# ============================================================================
# PRESET OPERATIONS
# ============================================================================
def apply_theme_preset(
self, db: Session, store_code: str, preset_name: str
) -> StoreTheme:
"""
Apply a theme preset to store.
Args:
db: Database session
store_code: Store code
preset_name: Name of preset to apply
Returns:
Updated StoreTheme object
Raises:
StoreNotFoundException: If store not found
ThemePresetNotFoundException: If preset not found
ThemeOperationException: If application fails
"""
self.logger.info(f"Applying preset '{preset_name}' to store {store_code}")
try:
# Validate preset name
if preset_name not in THEME_PRESETS:
available = get_available_presets()
raise ThemePresetNotFoundException(preset_name, available)
# Verify store exists
store = self._get_store_by_code(db, store_code)
# Get or create theme
theme = (
db.query(StoreTheme).filter(StoreTheme.store_id == store.id).first()
)
if not theme:
self.logger.info(f"Creating new theme for store {store_code}")
theme = StoreTheme(store_id=store.id)
db.add(theme)
# Apply preset using helper function
apply_preset(theme, preset_name)
# Flush changes
db.flush()
db.refresh(theme)
self.logger.info(
f"Preset '{preset_name}' applied successfully to store {store_code}"
)
return theme
except (StoreNotFoundException, ThemePresetNotFoundException):
# Re-raise custom exceptions
raise
except SQLAlchemyError as e:
self.logger.error(f"Failed to apply preset to store {store_code}: {e}")
raise ThemeOperationException(
operation="apply_preset", store_code=store_code, reason=str(e)
)
def get_available_presets(self) -> list[ThemePresetPreview]:
"""
Get list of available theme presets.
Returns:
List of preset preview objects
"""
self.logger.debug("Getting available presets")
preset_names = get_available_presets()
presets = []
for name in preset_names:
preview = get_preset_preview(name)
presets.append(preview)
return presets
# ============================================================================
# THEME DELETION
# ============================================================================
def delete_theme(self, db: Session, store_code: str) -> dict:
"""
Delete custom theme for store (reverts to default).
Args:
db: Database session
store_code: Store code
Returns:
Success message dictionary
Raises:
StoreNotFoundException: If store not found
StoreThemeNotFoundException: If no custom theme exists
ThemeOperationException: If deletion fails
"""
self.logger.info(f"Deleting theme for store: {store_code}")
try:
# Verify store exists
store = self._get_store_by_code(db, store_code)
# Get theme
theme = (
db.query(StoreTheme).filter(StoreTheme.store_id == store.id).first()
)
if not theme:
raise StoreThemeNotFoundException(store_code)
# Delete theme
db.delete(theme)
self.logger.info(f"Theme deleted for store {store_code}")
return {
"message": "Theme deleted successfully. Store will use default theme."
}
except (StoreNotFoundException, StoreThemeNotFoundException):
# Re-raise custom exceptions
raise
except SQLAlchemyError as e:
self.logger.error(f"Failed to delete theme for store {store_code}: {e}")
raise ThemeOperationException(
operation="delete", store_code=store_code, reason=str(e)
)
# ============================================================================
# VALIDATION
# ============================================================================
def _validate_theme_data(self, theme_data: StoreThemeUpdate) -> None:
"""
Validate theme data before applying.
Args:
theme_data: Theme update data
Raises:
ThemeValidationException: If validation fails
InvalidColorFormatException: If color format invalid
InvalidFontFamilyException: If font family invalid
"""
# Validate colors
if theme_data.colors:
for color_key, color_value in theme_data.colors.items():
if not self._is_valid_color(color_value):
raise InvalidColorFormatException(color_value, color_key)
# Validate fonts
if theme_data.fonts:
for font_key, font_value in theme_data.fonts.items():
if not self._is_valid_font(font_value):
raise InvalidFontFamilyException(font_value, font_key)
# Validate layout values
if theme_data.layout:
valid_layouts = {
"style": ["grid", "list", "masonry"],
"header": ["fixed", "static", "transparent"],
"product_card": ["modern", "classic", "minimal"],
}
for layout_key, layout_value in theme_data.layout.items():
if layout_key in valid_layouts:
if layout_value not in valid_layouts[layout_key]:
raise ThemeValidationException(
message=f"Invalid {layout_key} value: {layout_value}",
field=layout_key,
validation_errors={
layout_key: f"Must be one of: {', '.join(valid_layouts[layout_key])}"
},
)
def _is_valid_color(self, color: str) -> bool:
"""
Validate color format (hex color).
Args:
color: Color string to validate
Returns:
True if valid, False otherwise
"""
if not color:
return False
# Check for hex color format (#RGB or #RRGGBB)
hex_pattern = r"^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$"
return bool(re.match(hex_pattern, color))
def _is_valid_font(self, font: str) -> bool:
"""
Validate font family format.
Args:
font: Font family string to validate
Returns:
True if valid, False otherwise
"""
if not font or len(font) < 3:
return False
# Basic validation - font should not be empty and should be reasonable length
return len(font) <= 200
# ============================================================================
# SERVICE INSTANCE
# ============================================================================
store_theme_service = StoreThemeService()