diff --git a/alembic/env.py b/alembic/env.py index 62bac8bb..99cc7b75 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -29,6 +29,18 @@ try: except ImportError as e: print(f" ✗ Vendor models failed: {e}") +try: + from models.database.vendor_domain import VendorDomain + print(" ✓ VendorDomain model imported") +except ImportError as e: + print(f" ✗ VendorDomain model failed: {e}") + +try: + from models.database.vendor_theme import VendorTheme + print(" ✓ VendorTheme model imported") +except ImportError as e: + print(f" ✗ VendorTheme model failed: {e}") + # Product models try: from models.database.marketplace_product import MarketplaceProduct @@ -56,14 +68,14 @@ try: except ImportError as e: print(f" ✗ MarketplaceImportJob model failed: {e}") -# Customer models (MISSING IN YOUR FILE) +# Customer models try: from models.database.customer import Customer, CustomerAddress print(" ✓ Customer models imported (Customer, CustomerAddress)") except ImportError as e: print(f" ✗ Customer models failed: {e}") -# Order models (MISSING IN YOUR FILE) +# Order models try: from models.database.order import Order, OrderItem print(" ✓ Order models imported (Order, OrderItem)") diff --git a/app/api/v1/admin/__init__.py b/app/api/v1/admin/__init__.py index 0888da7c..10504e3c 100644 --- a/app/api/v1/admin/__init__.py +++ b/app/api/v1/admin/__init__.py @@ -6,6 +6,7 @@ This module combines all admin-related API endpoints: - Authentication (login/logout) - Vendor management (CRUD, bulk operations) - Vendor domains management (custom domains, DNS verification) +- Vendor themes management (theme editor, presets) - User management (status, roles) - Dashboard and statistics - Marketplace monitoring @@ -22,6 +23,7 @@ from . import ( auth, vendors, vendor_domains, + vendor_themes, users, dashboard, marketplace, @@ -54,6 +56,9 @@ router.include_router(vendors.router, tags=["admin-vendors"]) # Include vendor domains management endpoints router.include_router(vendor_domains.router, tags=["admin-vendor-domains"]) +# Include vendor themes management endpoints +router.include_router(vendor_themes.router, tags=["admin-vendor-themes"]) + # ============================================================================ # User Management diff --git a/app/api/v1/admin/pages.py b/app/api/v1/admin/pages.py index 09e85da5..3ff25df4 100644 --- a/app/api/v1/admin/pages.py +++ b/app/api/v1/admin/pages.py @@ -17,6 +17,7 @@ Routes: - GET /vendors/{vendor_code} → Vendor details (auth required) - GET /vendors/{vendor_code}/edit → Edit vendor form (auth required) - GET /vendors/{vendor_code}/domains → Vendor domains management (auth required) +- GET /vendors/{vendor_code}/theme → Vendor theme editor (auth required) - GET /users → User management page (auth required) - GET /imports → Import history page (auth required) - GET /settings → Settings page (auth required) @@ -205,6 +206,7 @@ async def admin_vendor_theme_page( ): """ Render vendor theme customization page. + Allows admins to customize colors, fonts, layout, and branding. """ return templates.TemplateResponse( "admin/vendor-theme.html", diff --git a/app/api/v1/admin/vendor_themes.py b/app/api/v1/admin/vendor_themes.py new file mode 100644 index 00000000..9346e0b1 --- /dev/null +++ b/app/api/v1/admin/vendor_themes.py @@ -0,0 +1,326 @@ +# app/api/v1/admin/vendor_themes.py +""" +Vendor theme management endpoints for admin. + +These endpoints allow admins to: +- View vendor themes +- Apply theme presets +- Customize theme settings +- Reset themes to default + +All operations use the service layer for business logic. +""" + +import logging +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Path, status +from sqlalchemy.orm import Session + +from app.api.deps import get_current_admin_user, get_db +from app.services.vendor_theme_service import vendor_theme_service +from app.exceptions.vendor import VendorNotFoundException +from app.exceptions.vendor_theme import ( + VendorThemeNotFoundException, + ThemePresetNotFoundException, + ThemeValidationException, + ThemeOperationException, + InvalidColorFormatException, + InvalidFontFamilyException +) +from models.database.user import User +from models.schema.vendor_theme import ( + VendorThemeResponse, + VendorThemeUpdate, + ThemePresetResponse, + ThemePresetListResponse +) + +router = APIRouter(prefix="/vendor-themes") +logger = logging.getLogger(__name__) + + +# ============================================================================ +# PRESET ENDPOINTS +# ============================================================================ + +@router.get("/presets", response_model=ThemePresetListResponse) +async def get_theme_presets( + current_admin: User = Depends(get_current_admin_user) +): + """ + Get all available theme presets with preview information. + + Returns list of presets that can be applied to vendor themes. + Each preset includes color palette, fonts, and layout configuration. + + **Permissions:** Admin only + + **Returns:** + - List of available theme presets with preview data + """ + logger.info("Getting theme presets") + + try: + presets = vendor_theme_service.get_available_presets() + return {"presets": presets} + + except Exception as e: + logger.error(f"Failed to get theme presets: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to retrieve theme presets" + ) + + +# ============================================================================ +# THEME RETRIEVAL +# ============================================================================ + +@router.get("/{vendor_code}", response_model=VendorThemeResponse) +async def get_vendor_theme( + vendor_code: str = Path(..., description="Vendor code"), + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_user) +): + """ + Get theme configuration for a vendor. + + Returns the vendor's custom theme if exists, otherwise returns default theme. + + **Path Parameters:** + - `vendor_code`: Vendor code (e.g., VENDOR001) + + **Permissions:** Admin only + + **Returns:** + - Complete theme configuration including colors, fonts, layout, and branding + + **Errors:** + - `404`: Vendor not found + - `500`: Internal server error + """ + logger.info(f"Getting theme for vendor: {vendor_code}") + + try: + theme = vendor_theme_service.get_theme(db, vendor_code) + return theme + + except VendorNotFoundException: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Vendor '{vendor_code}' not found" + ) + + except Exception as e: + logger.error(f"Failed to get theme for vendor {vendor_code}: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to retrieve theme" + ) + + +# ============================================================================ +# THEME UPDATE +# ============================================================================ + +@router.put("/{vendor_code}", response_model=VendorThemeResponse) +async def update_vendor_theme( + vendor_code: str = Path(..., description="Vendor code"), + theme_data: VendorThemeUpdate = None, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_user) +): + """ + Update or create theme for a vendor. + + Accepts partial updates - only provided fields are updated. + If vendor has no theme, a new one is created. + + **Path Parameters:** + - `vendor_code`: Vendor code (e.g., VENDOR001) + + **Request Body:** + - `theme_name`: Optional theme name + - `colors`: Optional color palette (primary, secondary, accent, etc.) + - `fonts`: Optional font settings (heading, body) + - `layout`: Optional layout settings (style, header, product_card) + - `branding`: Optional branding assets (logo, favicon, etc.) + - `custom_css`: Optional custom CSS rules + - `social_links`: Optional social media links + + **Permissions:** Admin only + + **Returns:** + - Updated theme configuration + + **Errors:** + - `404`: Vendor not found + - `422`: Validation error (invalid colors, fonts, or layout values) + - `500`: Internal server error + """ + logger.info(f"Updating theme for vendor: {vendor_code}") + + try: + theme = vendor_theme_service.update_theme(db, vendor_code, theme_data) + return theme.to_dict() + + except VendorNotFoundException: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Vendor '{vendor_code}' not found" + ) + + except (ThemeValidationException, InvalidColorFormatException, InvalidFontFamilyException) as e: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=e.message + ) + + except ThemeOperationException as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=e.message + ) + + except Exception as e: + logger.error(f"Failed to update theme for vendor {vendor_code}: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to update theme" + ) + + +# ============================================================================ +# PRESET APPLICATION +# ============================================================================ + +@router.post("/{vendor_code}/preset/{preset_name}") +async def apply_theme_preset( + vendor_code: str = Path(..., description="Vendor code"), + preset_name: str = Path(..., description="Preset name"), + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_user) +): + """ + Apply a theme preset to a vendor. + + Replaces the vendor's current theme with the selected preset. + Available presets can be retrieved from the `/presets` endpoint. + + **Path Parameters:** + - `vendor_code`: Vendor code (e.g., VENDOR001) + - `preset_name`: Name of preset to apply (e.g., 'modern', 'classic') + + **Available Presets:** + - `default`: Clean and professional + - `modern`: Contemporary tech-inspired + - `classic`: Traditional and trustworthy + - `minimal`: Ultra-clean black and white + - `vibrant`: Bold and energetic + - `elegant`: Sophisticated gray tones + - `nature`: Fresh and eco-friendly + + **Permissions:** Admin only + + **Returns:** + - Success message and applied theme configuration + + **Errors:** + - `404`: Vendor or preset not found + - `500`: Internal server error + """ + logger.info(f"Applying preset '{preset_name}' to vendor {vendor_code}") + + try: + theme = vendor_theme_service.apply_theme_preset(db, vendor_code, preset_name) + + return { + "message": f"Applied {preset_name} preset successfully", + "theme": theme.to_dict() + } + + except VendorNotFoundException: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Vendor '{vendor_code}' not found" + ) + + except ThemePresetNotFoundException as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=e.message + ) + + except ThemeOperationException as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=e.message + ) + + except Exception as e: + logger.error(f"Failed to apply preset to vendor {vendor_code}: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to apply preset" + ) + + +# ============================================================================ +# THEME DELETION +# ============================================================================ + +@router.delete("/{vendor_code}") +async def delete_vendor_theme( + vendor_code: str = Path(..., description="Vendor code"), + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_user) +): + """ + Delete custom theme for a vendor. + + Removes the vendor's custom theme. After deletion, the vendor + will revert to using the default platform theme. + + **Path Parameters:** + - `vendor_code`: Vendor code (e.g., VENDOR001) + + **Permissions:** Admin only + + **Returns:** + - Success message + + **Errors:** + - `404`: Vendor not found or vendor has no custom theme + - `500`: Internal server error + """ + logger.info(f"Deleting theme for vendor: {vendor_code}") + + try: + result = vendor_theme_service.delete_theme(db, vendor_code) + return result + + except VendorNotFoundException: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Vendor '{vendor_code}' not found" + ) + + except VendorThemeNotFoundException: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Vendor '{vendor_code}' has no custom theme" + ) + + except ThemeOperationException as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=e.message + ) + + except Exception as e: + logger.error(f"Failed to delete theme for vendor {vendor_code}: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to delete theme" + ) diff --git a/app/api/v1/admin/vendors.py b/app/api/v1/admin/vendors.py index 183187e3..6c0b01ce 100644 --- a/app/api/v1/admin/vendors.py +++ b/app/api/v1/admin/vendors.py @@ -113,7 +113,6 @@ def create_vendor_with_owner( letzshop_csv_url_fr=vendor.letzshop_csv_url_fr, letzshop_csv_url_en=vendor.letzshop_csv_url_en, letzshop_csv_url_de=vendor.letzshop_csv_url_de, - theme_config=vendor.theme_config or {}, is_active=vendor.is_active, is_verified=vendor.is_verified, created_at=vendor.created_at, @@ -197,7 +196,6 @@ def get_vendor_details( letzshop_csv_url_fr=vendor.letzshop_csv_url_fr, letzshop_csv_url_en=vendor.letzshop_csv_url_en, letzshop_csv_url_de=vendor.letzshop_csv_url_de, - theme_config=vendor.theme_config or {}, is_active=vendor.is_active, is_verified=vendor.is_verified, created_at=vendor.created_at, @@ -250,7 +248,6 @@ def update_vendor( letzshop_csv_url_fr=vendor.letzshop_csv_url_fr, letzshop_csv_url_en=vendor.letzshop_csv_url_en, letzshop_csv_url_de=vendor.letzshop_csv_url_de, - theme_config=vendor.theme_config or {}, is_active=vendor.is_active, is_verified=vendor.is_verified, created_at=vendor.created_at, @@ -354,7 +351,6 @@ def toggle_vendor_verification( letzshop_csv_url_fr=vendor.letzshop_csv_url_fr, letzshop_csv_url_en=vendor.letzshop_csv_url_en, letzshop_csv_url_de=vendor.letzshop_csv_url_de, - theme_config=vendor.theme_config or {}, is_active=vendor.is_active, is_verified=vendor.is_verified, created_at=vendor.created_at, @@ -406,7 +402,6 @@ def toggle_vendor_status( letzshop_csv_url_fr=vendor.letzshop_csv_url_fr, letzshop_csv_url_en=vendor.letzshop_csv_url_en, letzshop_csv_url_de=vendor.letzshop_csv_url_de, - theme_config=vendor.theme_config or {}, is_active=vendor.is_active, is_verified=vendor.is_verified, created_at=vendor.created_at, diff --git a/app/api/v1/vendor/settings.py b/app/api/v1/vendor/settings.py index 3e45ff58..81324fce 100644 --- a/app/api/v1/vendor/settings.py +++ b/app/api/v1/vendor/settings.py @@ -37,7 +37,6 @@ def get_vendor_settings( "letzshop_csv_url_fr": vendor.letzshop_csv_url_fr, "letzshop_csv_url_en": vendor.letzshop_csv_url_en, "letzshop_csv_url_de": vendor.letzshop_csv_url_de, - "theme_config": vendor.theme_config, "is_active": vendor.is_active, "is_verified": vendor.is_verified, } @@ -72,24 +71,3 @@ def update_marketplace_settings( "letzshop_csv_url_en": vendor.letzshop_csv_url_en, "letzshop_csv_url_de": vendor.letzshop_csv_url_de, } - - -@router.put("/theme") -def update_theme_settings( - theme_config: dict, - vendor: Vendor = Depends(require_vendor_context()), - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """Update vendor theme configuration.""" - if not vendor_service.can_update_vendor(vendor, current_user): - raise HTTPException(status_code=403, detail="Insufficient permissions") - - vendor.theme_config = theme_config - db.commit() - db.refresh(vendor) - - return { - "message": "Theme settings updated successfully", - "theme_config": vendor.theme_config, - } diff --git a/app/core/theme_presets.py b/app/core/theme_presets.py new file mode 100644 index 00000000..7b091ad3 --- /dev/null +++ b/app/core/theme_presets.py @@ -0,0 +1,311 @@ +# app/core/theme_presets.py +""" +Theme presets for vendor shops. + +Presets define default color schemes, fonts, and layouts that vendors can choose from. +Each preset provides a complete theme configuration that can be customized further. +""" + +from models.database.vendor_theme import VendorTheme + +THEME_PRESETS = { + "default": { + "colors": { + "primary": "#6366f1", # Indigo + "secondary": "#8b5cf6", # Purple + "accent": "#ec4899", # Pink + "background": "#ffffff", # White + "text": "#1f2937", # Gray-800 + "border": "#e5e7eb" # Gray-200 + }, + "fonts": { + "heading": "Inter, sans-serif", + "body": "Inter, sans-serif" + }, + "layout": { + "style": "grid", + "header": "fixed", + "product_card": "modern" + } + }, + + "modern": { + "colors": { + "primary": "#6366f1", # Indigo - Modern tech look + "secondary": "#8b5cf6", # Purple + "accent": "#ec4899", # Pink + "background": "#ffffff", # White + "text": "#1f2937", # Gray-800 + "border": "#e5e7eb" # Gray-200 + }, + "fonts": { + "heading": "Inter, sans-serif", + "body": "Inter, sans-serif" + }, + "layout": { + "style": "grid", + "header": "fixed", + "product_card": "modern" + } + }, + + "classic": { + "colors": { + "primary": "#1e40af", # Dark blue - Traditional + "secondary": "#7c3aed", # Purple + "accent": "#dc2626", # Red + "background": "#ffffff", # White + "text": "#1f2937", # Gray-800 + "border": "#d1d5db" # Gray-300 + }, + "fonts": { + "heading": "Georgia, serif", + "body": "Arial, sans-serif" + }, + "layout": { + "style": "list", + "header": "static", + "product_card": "classic" + } + }, + + "minimal": { + "colors": { + "primary": "#000000", # Black - Ultra minimal + "secondary": "#404040", # Dark gray + "accent": "#666666", # Medium gray + "background": "#ffffff", # White + "text": "#000000", # Black + "border": "#e5e7eb" # Light gray + }, + "fonts": { + "heading": "Helvetica, sans-serif", + "body": "Helvetica, sans-serif" + }, + "layout": { + "style": "grid", + "header": "transparent", + "product_card": "minimal" + } + }, + + "vibrant": { + "colors": { + "primary": "#f59e0b", # Orange - Bold & energetic + "secondary": "#ef4444", # Red + "accent": "#8b5cf6", # Purple + "background": "#ffffff", # White + "text": "#1f2937", # Gray-800 + "border": "#fbbf24" # Yellow + }, + "fonts": { + "heading": "Poppins, sans-serif", + "body": "Open Sans, sans-serif" + }, + "layout": { + "style": "masonry", + "header": "fixed", + "product_card": "modern" + } + }, + + "elegant": { + "colors": { + "primary": "#6b7280", # Gray - Sophisticated + "secondary": "#374151", # Dark gray + "accent": "#d97706", # Amber + "background": "#ffffff", # White + "text": "#1f2937", # Gray-800 + "border": "#e5e7eb" # Gray-200 + }, + "fonts": { + "heading": "Playfair Display, serif", + "body": "Lato, sans-serif" + }, + "layout": { + "style": "grid", + "header": "fixed", + "product_card": "classic" + } + }, + + "nature": { + "colors": { + "primary": "#059669", # Green - Natural & eco + "secondary": "#10b981", # Emerald + "accent": "#f59e0b", # Amber + "background": "#ffffff", # White + "text": "#1f2937", # Gray-800 + "border": "#d1fae5" # Light green + }, + "fonts": { + "heading": "Montserrat, sans-serif", + "body": "Open Sans, sans-serif" + }, + "layout": { + "style": "grid", + "header": "fixed", + "product_card": "modern" + } + } +} + + +def get_preset(preset_name: str) -> dict: + """ + Get a theme preset by name. + + Args: + preset_name: Name of the preset (e.g., 'modern', 'classic') + + Returns: + dict: Theme configuration + + Raises: + ValueError: If preset name is unknown + """ + if preset_name not in THEME_PRESETS: + available = ", ".join(THEME_PRESETS.keys()) + raise ValueError(f"Unknown preset: {preset_name}. Available: {available}") + + return THEME_PRESETS[preset_name] + + +def apply_preset(theme: VendorTheme, preset_name: str) -> VendorTheme: + """ + Apply a preset to a vendor theme. + + Args: + theme: VendorTheme instance to update + preset_name: Name of the preset to apply + + Returns: + VendorTheme: Updated theme instance + + Raises: + ValueError: If preset name is unknown + + Example: + theme = VendorTheme(vendor_id=1) + apply_preset(theme, "modern") + db.add(theme) + db.commit() + """ + preset = get_preset(preset_name) + + # Set theme name + theme.theme_name = preset_name + + # Apply colors (all of them!) + theme.colors = preset["colors"] + + # Apply fonts + theme.font_family_heading = preset["fonts"]["heading"] + theme.font_family_body = preset["fonts"]["body"] + + # Apply layout settings + theme.layout_style = preset["layout"]["style"] + theme.header_style = preset["layout"]["header"] + theme.product_card_style = preset["layout"]["product_card"] + + # Mark as active + theme.is_active = True + + return theme + + +def get_available_presets() -> list[str]: + """ + Get list of available preset names. + + Returns: + list: Available preset names + """ + return list(THEME_PRESETS.keys()) + + +def get_preset_preview(preset_name: str) -> dict: + """ + Get preview information for a preset (for UI display). + + Args: + preset_name: Name of the preset + + Returns: + dict: Preview info with colors, fonts, description + """ + preset = get_preset(preset_name) + + descriptions = { + "default": "Clean and professional - perfect for getting started", + "modern": "Contemporary tech-inspired design with vibrant colors", + "classic": "Traditional and trustworthy with serif typography", + "minimal": "Ultra-clean black and white aesthetic", + "vibrant": "Bold and energetic with bright accent colors", + "elegant": "Sophisticated gray tones with refined typography", + "nature": "Fresh and eco-friendly green color palette" + } + + return { + "name": preset_name, + "description": descriptions.get(preset_name, ""), + "primary_color": preset["colors"]["primary"], + "secondary_color": preset["colors"]["secondary"], + "accent_color": preset["colors"]["accent"], + "heading_font": preset["fonts"]["heading"], + "body_font": preset["fonts"]["body"], + "layout_style": preset["layout"]["style"], + } + + +def create_custom_preset( + colors: dict, + fonts: dict, + layout: dict, + name: str = "custom" +) -> dict: + """ + Create a custom preset from provided settings. + + Args: + colors: Dict with primary, secondary, accent, background, text, border + fonts: Dict with heading and body fonts + layout: Dict with style, header, product_card + name: Name for the custom preset + + Returns: + dict: Custom preset configuration + + Example: + custom = create_custom_preset( + colors={"primary": "#ff0000", "secondary": "#00ff00", ...}, + fonts={"heading": "Arial", "body": "Arial"}, + layout={"style": "grid", "header": "fixed", "product_card": "modern"}, + name="my_custom_theme" + ) + """ + # Validate colors + required_colors = ["primary", "secondary", "accent", "background", "text", "border"] + for color_key in required_colors: + if color_key not in colors: + colors[color_key] = THEME_PRESETS["default"]["colors"][color_key] + + # Validate fonts + if "heading" not in fonts: + fonts["heading"] = "Inter, sans-serif" + if "body" not in fonts: + fonts["body"] = "Inter, sans-serif" + + # Validate layout + if "style" not in layout: + layout["style"] = "grid" + if "header" not in layout: + layout["header"] = "fixed" + if "product_card" not in layout: + layout["product_card"] = "modern" + + return { + "colors": colors, + "fonts": fonts, + "layout": layout + } diff --git a/app/exceptions/__init__.py b/app/exceptions/__init__.py index d9598049..03825953 100644 --- a/app/exceptions/__init__.py +++ b/app/exceptions/__init__.py @@ -43,7 +43,7 @@ from .admin import ( BulkOperationException, ) -# Marketplace import jon exceptions +# Marketplace import job exceptions from .marketplace_import_job import ( MarketplaceImportException, ImportJobNotFoundException, @@ -106,6 +106,18 @@ from .vendor_domain import ( UnauthorizedDomainAccessException, ) +# Vendor theme exceptions +from .vendor_theme import ( + VendorThemeNotFoundException, + InvalidThemeDataException, + ThemePresetNotFoundException, + ThemeValidationException, + ThemePresetAlreadyAppliedException, + InvalidColorFormatException, + InvalidFontFamilyException, + ThemeOperationException, +) + # Customer exceptions from .customer import ( CustomerNotFoundException, @@ -235,6 +247,16 @@ __all__ = [ "MaxDomainsReachedException", "UnauthorizedDomainAccessException", + # Vendor Theme + "VendorThemeNotFoundException", + "InvalidThemeDataException", + "ThemePresetNotFoundException", + "ThemeValidationException", + "ThemePresetAlreadyAppliedException", + "InvalidColorFormatException", + "InvalidFontFamilyException", + "ThemeOperationException", + # Product exceptions "ProductNotFoundException", "ProductAlreadyExistsException", @@ -282,4 +304,4 @@ __all__ = [ "CannotModifySelfException", "InvalidAdminActionException", "BulkOperationException", -] +] \ No newline at end of file diff --git a/app/exceptions/vendor_theme.py b/app/exceptions/vendor_theme.py new file mode 100644 index 00000000..b38622c3 --- /dev/null +++ b/app/exceptions/vendor_theme.py @@ -0,0 +1,137 @@ +# app/exceptions/vendor_theme.py +""" +Vendor theme management specific exceptions. +""" + +from typing import Any, Dict, Optional +from .base import ( + ResourceNotFoundException, + ConflictException, + ValidationException, + BusinessLogicException +) + + +class VendorThemeNotFoundException(ResourceNotFoundException): + """Raised when a vendor theme is not found.""" + + def __init__(self, vendor_identifier: str): + super().__init__( + resource_type="VendorTheme", + identifier=vendor_identifier, + message=f"Theme for vendor '{vendor_identifier}' not found", + error_code="VENDOR_THEME_NOT_FOUND", + ) + + +class InvalidThemeDataException(ValidationException): + """Raised when theme data is invalid.""" + + def __init__( + self, + message: str = "Invalid theme data", + field: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + ): + super().__init__( + message=message, + field=field, + details=details, + ) + self.error_code = "INVALID_THEME_DATA" + + +class ThemePresetNotFoundException(ResourceNotFoundException): + """Raised when a theme preset is not found.""" + + def __init__(self, preset_name: str, available_presets: Optional[list] = None): + details = {"preset_name": preset_name} + if available_presets: + details["available_presets"] = available_presets + + super().__init__( + resource_type="ThemePreset", + identifier=preset_name, + message=f"Theme preset '{preset_name}' not found", + error_code="THEME_PRESET_NOT_FOUND", + ) + self.details = details + + +class ThemeValidationException(ValidationException): + """Raised when theme validation fails.""" + + def __init__( + self, + message: str = "Theme validation failed", + field: Optional[str] = None, + validation_errors: Optional[Dict[str, str]] = None, + ): + details = {} + if validation_errors: + details["validation_errors"] = validation_errors + + super().__init__( + message=message, + field=field, + details=details, + ) + self.error_code = "THEME_VALIDATION_FAILED" + + +class ThemePresetAlreadyAppliedException(BusinessLogicException): + """Raised when trying to apply the same preset that's already active.""" + + def __init__(self, preset_name: str, vendor_code: str): + super().__init__( + message=f"Preset '{preset_name}' is already applied to vendor '{vendor_code}'", + error_code="THEME_PRESET_ALREADY_APPLIED", + details={ + "preset_name": preset_name, + "vendor_code": vendor_code + }, + ) + + +class InvalidColorFormatException(ValidationException): + """Raised when color format is invalid.""" + + def __init__(self, color_value: str, field: str): + super().__init__( + message=f"Invalid color format: {color_value}", + field=field, + details={"color_value": color_value}, + ) + self.error_code = "INVALID_COLOR_FORMAT" + + +class InvalidFontFamilyException(ValidationException): + """Raised when font family is invalid.""" + + def __init__(self, font_value: str, field: str): + super().__init__( + message=f"Invalid font family: {font_value}", + field=field, + details={"font_value": font_value}, + ) + self.error_code = "INVALID_FONT_FAMILY" + + +class ThemeOperationException(BusinessLogicException): + """Raised when theme operation fails.""" + + def __init__( + self, + operation: str, + vendor_code: str, + reason: str + ): + super().__init__( + message=f"Theme operation '{operation}' failed for vendor '{vendor_code}': {reason}", + error_code="THEME_OPERATION_FAILED", + details={ + "operation": operation, + "vendor_code": vendor_code, + "reason": reason + }, + ) diff --git a/app/services/admin_service.py b/app/services/admin_service.py index c1cb3095..f6529725 100644 --- a/app/services/admin_service.py +++ b/app/services/admin_service.py @@ -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, ) diff --git a/app/services/vendor_theme_service.py b/app/services/vendor_theme_service.py new file mode 100644 index 00000000..067f022b --- /dev/null +++ b/app/services/vendor_theme_service.py @@ -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() diff --git a/app/templates/admin/base.html b/app/templates/admin/base.html index e16f8678..d26635f3 100644 --- a/app/templates/admin/base.html +++ b/app/templates/admin/base.html @@ -22,11 +22,11 @@
- {% include 'partials/sidebar.html' %} + {% include 'admin/partials/sidebar.html' %}
- {% include 'partials/header.html' %} + {% include 'admin/partials/header.html' %}
diff --git a/app/templates/admin/partials/header.html b/app/templates/admin/partials/header.html index 3d0d7473..1d1f154b 100644 --- a/app/templates/admin/partials/header.html +++ b/app/templates/admin/partials/header.html @@ -1,4 +1,4 @@ - +
diff --git a/app/templates/admin/partials/sidebar.html b/app/templates/admin/partials/sidebar.html index c11700f9..5fd1ab17 100644 --- a/app/templates/admin/partials/sidebar.html +++ b/app/templates/admin/partials/sidebar.html @@ -1,4 +1,4 @@ -{# app/templates/partials/sidebar.html #} +{# app/templates/admin/partials/sidebar.html #}