Files
orion/app/modules/cms/services/store_theme_service.py
Samir Boulahtit 481deaa67d refactor: fix all 177 architecture validator warnings
- Replace 153 broad `except Exception` with specific types (SQLAlchemyError,
  TemplateError, OSError, SMTPException, ClientError, etc.) across 37 services
- Break catalog↔inventory circular dependency (IMPORT-004)
- Create 19 skeleton test files for MOD-024 coverage
- Exclude aggregator services from MOD-024 (false positives)
- Update test mocks to match narrowed exception types

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 11:59:44 +01:00

490 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.
"""
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()