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