Multitenant implementation with custom Domain, theme per vendor
This commit is contained in:
@@ -184,7 +184,6 @@ class AdminService:
|
||||
letzshop_csv_url_fr=vendor_data.letzshop_csv_url_fr,
|
||||
letzshop_csv_url_en=vendor_data.letzshop_csv_url_en,
|
||||
letzshop_csv_url_de=vendor_data.letzshop_csv_url_de,
|
||||
theme_config=vendor_data.theme_config or {},
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
|
||||
516
app/services/vendor_theme_service.py
Normal file
516
app/services/vendor_theme_service.py
Normal file
@@ -0,0 +1,516 @@
|
||||
# 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()
|
||||
Reference in New Issue
Block a user