# app/services/vendor_theme_service.py """ Vendor Theme Service Business logic for vendor theme management. Handles theme CRUD operations, preset application, and validation. """ import logging import re from typing import Optional, Dict, List from sqlalchemy.orm import Session from models.database.vendor import Vendor from models.database.vendor_theme import VendorTheme from models.schema.vendor_theme import ( VendorThemeUpdate, ThemePresetPreview ) from app.exceptions.vendor import VendorNotFoundException from app.exceptions.vendor_theme import ( VendorThemeNotFoundException, InvalidThemeDataException, ThemePresetNotFoundException, ThemeValidationException, InvalidColorFormatException, InvalidFontFamilyException, ThemePresetAlreadyAppliedException, ThemeOperationException ) from app.core.theme_presets import ( apply_preset, get_available_presets, get_preset_preview, THEME_PRESETS ) logger = logging.getLogger(__name__) class VendorThemeService: """ Service for managing vendor themes. This service handles: - Theme retrieval and creation - Theme updates and validation - Preset application - Default theme generation """ def __init__(self): """Initialize the vendor theme service.""" self.logger = logging.getLogger(__name__) # ============================================================================ # VENDOR RETRIEVAL # ============================================================================ def _get_vendor_by_code(self, db: Session, vendor_code: str) -> Vendor: """ Get vendor by code or raise exception. Args: db: Database session vendor_code: Vendor code to lookup Returns: Vendor object Raises: VendorNotFoundException: If vendor not found """ vendor = db.query(Vendor).filter( Vendor.vendor_code == vendor_code.upper() ).first() if not vendor: self.logger.warning(f"Vendor not found: {vendor_code}") raise VendorNotFoundException(vendor_code, identifier_type="code") return vendor # ============================================================================ # THEME RETRIEVAL # ============================================================================ def get_theme(self, db: Session, vendor_code: str) -> Dict: """ Get theme for vendor. Returns default if no custom theme exists. Args: db: Database session vendor_code: Vendor code Returns: Theme dictionary Raises: VendorNotFoundException: If vendor not found """ self.logger.info(f"Getting theme for vendor: {vendor_code}") # Verify vendor exists vendor = self._get_vendor_by_code(db, vendor_code) # Get theme theme = db.query(VendorTheme).filter( VendorTheme.vendor_id == vendor.id ).first() if not theme: self.logger.info(f"No custom theme for vendor {vendor_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, vendor_code: str, theme_data: VendorThemeUpdate ) -> VendorTheme: """ Update or create theme for vendor. Args: db: Database session vendor_code: Vendor code theme_data: Theme update data Returns: Updated VendorTheme object Raises: VendorNotFoundException: If vendor not found ThemeValidationException: If theme data invalid ThemeOperationException: If update fails """ self.logger.info(f"Updating theme for vendor: {vendor_code}") try: # Verify vendor exists vendor = self._get_vendor_by_code(db, vendor_code) # Get or create theme theme = db.query(VendorTheme).filter( VendorTheme.vendor_id == vendor.id ).first() if not theme: self.logger.info(f"Creating new theme for vendor {vendor_code}") theme = VendorTheme(vendor_id=vendor.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) # Commit changes db.commit() db.refresh(theme) self.logger.info(f"Theme updated successfully for vendor {vendor_code}") return theme except (VendorNotFoundException, ThemeValidationException): # Re-raise custom exceptions raise except Exception as e: db.rollback() self.logger.error(f"Failed to update theme for vendor {vendor_code}: {e}") raise ThemeOperationException( operation="update", vendor_code=vendor_code, reason=str(e) ) def _apply_theme_updates( self, theme: VendorTheme, theme_data: VendorThemeUpdate ) -> None: """ Apply theme updates to theme object. Args: theme: VendorTheme 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, vendor_code: str, preset_name: str ) -> VendorTheme: """ Apply a theme preset to vendor. Args: db: Database session vendor_code: Vendor code preset_name: Name of preset to apply Returns: Updated VendorTheme object Raises: VendorNotFoundException: If vendor not found ThemePresetNotFoundException: If preset not found ThemeOperationException: If application fails """ self.logger.info(f"Applying preset '{preset_name}' to vendor {vendor_code}") try: # Validate preset name if preset_name not in THEME_PRESETS: available = get_available_presets() raise ThemePresetNotFoundException(preset_name, available) # Verify vendor exists vendor = self._get_vendor_by_code(db, vendor_code) # Get or create theme theme = db.query(VendorTheme).filter( VendorTheme.vendor_id == vendor.id ).first() if not theme: self.logger.info(f"Creating new theme for vendor {vendor_code}") theme = VendorTheme(vendor_id=vendor.id) db.add(theme) # Apply preset using helper function apply_preset(theme, preset_name) # Commit changes db.commit() db.refresh(theme) self.logger.info(f"Preset '{preset_name}' applied successfully to vendor {vendor_code}") return theme except (VendorNotFoundException, ThemePresetNotFoundException): # Re-raise custom exceptions raise except Exception as e: db.rollback() self.logger.error(f"Failed to apply preset to vendor {vendor_code}: {e}") raise ThemeOperationException( operation="apply_preset", vendor_code=vendor_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, vendor_code: str) -> Dict: """ Delete custom theme for vendor (reverts to default). Args: db: Database session vendor_code: Vendor code Returns: Success message dictionary Raises: VendorNotFoundException: If vendor not found VendorThemeNotFoundException: If no custom theme exists ThemeOperationException: If deletion fails """ self.logger.info(f"Deleting theme for vendor: {vendor_code}") try: # Verify vendor exists vendor = self._get_vendor_by_code(db, vendor_code) # Get theme theme = db.query(VendorTheme).filter( VendorTheme.vendor_id == vendor.id ).first() if not theme: raise VendorThemeNotFoundException(vendor_code) # Delete theme db.delete(theme) db.commit() self.logger.info(f"Theme deleted for vendor {vendor_code}") return { "message": "Theme deleted successfully. Vendor will use default theme." } except (VendorNotFoundException, VendorThemeNotFoundException): # Re-raise custom exceptions raise except Exception as e: db.rollback() self.logger.error(f"Failed to delete theme for vendor {vendor_code}: {e}") raise ThemeOperationException( operation="delete", vendor_code=vendor_code, reason=str(e) ) # ============================================================================ # VALIDATION # ============================================================================ def _validate_theme_data(self, theme_data: VendorThemeUpdate) -> 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 # ============================================================================ vendor_theme_service = VendorThemeService()