Files
orion/app/services/vendor_theme_service.py

517 lines
16 KiB
Python

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