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>
491 lines
16 KiB
Python
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()
|