# 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. """ 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 from app.modules.tenancy.models import Store 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 """ store = ( db.query(Store).filter(Store.store_code == store_code.upper()).first() ) 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()