Multitenant implementation with custom Domain, theme per vendor

This commit is contained in:
2025-10-26 23:49:29 +01:00
parent c88775134d
commit 1e0cbf5927
24 changed files with 3470 additions and 624 deletions

View File

@@ -1,11 +1,9 @@
# models/database/vendor.py - ENHANCED VERSION
# models/database/vendor.py
"""
Enhanced Vendor model with theme support.
Vendor model with theme support.
Changes from your current version:
1. Keep existing theme_config JSON field
2. Add optional VendorTheme relationship for advanced themes
3. Add helper methods for theme access
A vendor has ONE active theme stored in the vendor_themes table.
Theme presets available: default, modern, classic, minimal, vibrant
"""
from sqlalchemy import (Boolean, Column, ForeignKey, Integer, String, Text, JSON)
from sqlalchemy.orm import relationship
@@ -27,10 +25,6 @@ class Vendor(Base, TimestampMixin):
description = Column(Text)
owner_user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
# Simple theme config (JSON)
# This stores basic theme settings like colors, fonts
theme_config = Column(JSON, default=dict)
# Contact information
contact_email = Column(String)
contact_phone = Column(String)
@@ -49,13 +43,16 @@ class Vendor(Base, TimestampMixin):
is_active = Column(Boolean, default=True)
is_verified = Column(Boolean, default=False)
# ========================================================================
# Relationships
# ========================================================================
owner = relationship("User", back_populates="owned_vendors")
vendor_users = relationship("VendorUser", back_populates="vendor")
products = relationship("Product", back_populates="vendor")
customers = relationship("Customer", back_populates="vendor")
orders = relationship("Order", back_populates="vendor")
marketplace_import_jobs = relationship("MarketplaceImportJob", back_populates="vendor")
domains = relationship(
"VendorDomain",
back_populates="vendor",
@@ -63,16 +60,8 @@ class Vendor(Base, TimestampMixin):
order_by="VendorDomain.is_primary.desc()"
)
theme = relationship(
"VendorTheme",
back_populates="vendor",
uselist=False,
cascade="all, delete-orphan"
)
# Optional advanced theme (for premium vendors)
# This is optional - vendors can use theme_config OR VendorTheme
advanced_theme = relationship(
# Single theme relationship (ONE vendor = ONE theme)
vendor_theme = relationship(
"VendorTheme",
back_populates="vendor",
uselist=False,
@@ -86,81 +75,22 @@ class Vendor(Base, TimestampMixin):
# Theme Helper Methods
# ========================================================================
@property
def active_theme(self):
"""Get vendor's active theme or return default"""
if self.theme and self.theme.is_active:
return self.theme
return None
@property
def theme(self):
def get_effective_theme(self) -> dict:
"""
Get theme configuration for this vendor.
Get active theme for this vendor.
Priority:
1. Advanced theme (VendorTheme) if configured
2. theme_config JSON field
3. Default theme
Returns theme from vendor_themes table, or default theme if not set.
Returns dict with theme configuration.
Returns:
dict: Theme configuration with colors, fonts, layout, etc.
"""
# Priority 1: Advanced theme
if self.advanced_theme and self.advanced_theme.is_active:
return self.advanced_theme.to_dict()
# Check vendor_themes table
if self.vendor_theme and self.vendor_theme.is_active:
return self.vendor_theme.to_dict()
# Priority 2: Basic theme_config
if self.theme_config:
return self._normalize_theme_config(self.theme_config)
# Priority 3: Default theme
# Return default theme
return self._get_default_theme()
def _normalize_theme_config(self, config: dict) -> dict:
"""
Normalize theme_config JSON to standard format.
Ensures backward compatibility with existing theme_config.
"""
return {
"theme_name": config.get("theme_name", "basic"),
"colors": config.get("colors", {
"primary": "#6366f1",
"secondary": "#8b5cf6",
"accent": "#ec4899"
}),
"fonts": config.get("fonts", {
"heading": "Inter, sans-serif",
"body": "Inter, sans-serif"
}),
"branding": config.get("branding", {
"logo": None,
"logo_dark": None,
"favicon": None
}),
"layout": config.get("layout", {
"style": "grid",
"header": "fixed"
}),
"custom_css": config.get("custom_css", None),
"css_variables": self._generate_css_variables(config)
}
def _generate_css_variables(self, config: dict) -> dict:
"""Generate CSS custom properties from theme config"""
colors = config.get("colors", {})
fonts = config.get("fonts", {})
return {
"--color-primary": colors.get("primary", "#6366f1"),
"--color-secondary": colors.get("secondary", "#8b5cf6"),
"--color-accent": colors.get("accent", "#ec4899"),
"--color-background": colors.get("background", "#ffffff"),
"--color-text": colors.get("text", "#1f2937"),
"--color-border": colors.get("border", "#e5e7eb"),
"--font-heading": fonts.get("heading", "Inter, sans-serif"),
"--font-body": fonts.get("body", "Inter, sans-serif"),
}
def _get_default_theme(self) -> dict:
"""Default theme configuration"""
return {
@@ -180,12 +110,15 @@ class Vendor(Base, TimestampMixin):
"branding": {
"logo": None,
"logo_dark": None,
"favicon": None
"favicon": None,
"banner": None
},
"layout": {
"style": "grid",
"header": "fixed"
"header": "fixed",
"product_card": "modern"
},
"social_links": {},
"custom_css": None,
"css_variables": {
"--color-primary": "#6366f1",
@@ -199,42 +132,15 @@ class Vendor(Base, TimestampMixin):
}
}
@property
def primary_color(self):
"""Get primary color from theme"""
return self.theme.get("colors", {}).get("primary", "#6366f1")
def get_primary_color(self) -> str:
"""Get primary color from active theme"""
theme = self.get_effective_theme()
return theme.get("colors", {}).get("primary", "#6366f1")
@property
def logo_url(self):
"""Get logo URL from theme"""
return self.theme.get("branding", {}).get("logo")
def update_theme(self, theme_data: dict):
"""
Update vendor theme configuration.
Args:
theme_data: Dict with theme settings
{colors: {...}, fonts: {...}, etc}
"""
if not self.theme_config:
self.theme_config = {}
# Update theme_config JSON
if "colors" in theme_data:
self.theme_config["colors"] = theme_data["colors"]
if "fonts" in theme_data:
self.theme_config["fonts"] = theme_data["fonts"]
if "branding" in theme_data:
self.theme_config["branding"] = theme_data["branding"]
if "layout" in theme_data:
self.theme_config["layout"] = theme_data["layout"]
if "custom_css" in theme_data:
self.theme_config["custom_css"] = theme_data["custom_css"]
def get_logo_url(self) -> str:
"""Get logo URL from active theme"""
theme = self.get_effective_theme()
return theme.get("branding", {}).get("logo")
# ========================================================================
# Domain Helper Methods
@@ -257,6 +163,7 @@ class Vendor(Base, TimestampMixin):
domains.append(domain.domain)
return domains
# Keep your existing VendorUser and Role models unchanged
class VendorUser(Base, TimestampMixin):
__tablename__ = "vendor_users"

View File

@@ -3,30 +3,37 @@
Vendor Theme Configuration Model
Allows each vendor to customize their shop's appearance
"""
from datetime import datetime, timezone
from sqlalchemy import Column, Integer, String, Boolean, Text, JSON, DateTime, ForeignKey
from sqlalchemy import Column, Integer, String, Boolean, Text, JSON, ForeignKey
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class VendorTheme(Base, TimestampMixin):
"""
Stores theme configuration for each vendor's shop.
Each vendor can have:
Each vendor can have ONE active theme:
- Custom colors (primary, secondary, accent)
- Custom fonts
- Custom logo and favicon
- Custom CSS overrides
- Layout preferences
Theme presets available: default, modern, classic, minimal, vibrant
"""
__tablename__ = "vendor_themes"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id", ondelete="CASCADE"), nullable=False, unique=True)
vendor_id = Column(
Integer,
ForeignKey("vendors.id", ondelete="CASCADE"),
nullable=False,
unique=True # ONE vendor = ONE theme
)
# Basic Theme Settings
theme_name = Column(String(100), default="default") # e.g., "modern", "classic", "minimal"
theme_name = Column(String(100), default="default") # default, modern, classic, minimal, vibrant
is_active = Column(Boolean, default=True)
# Color Scheme (JSON for flexibility)
@@ -64,8 +71,8 @@ class VendorTheme(Base, TimestampMixin):
meta_title_template = Column(String(200), nullable=True) # e.g., "{product_name} - {shop_name}"
meta_description = Column(Text, nullable=True)
# Relationships
vendor = relationship("Vendor", back_populates="theme")
# Relationships - FIXED: back_populates must match the relationship name in Vendor model
vendor = relationship("Vendor", back_populates="vendor_theme")
def __repr__(self):
return f"<VendorTheme(vendor_id={self.vendor_id}, theme_name='{self.theme_name}')>"

View File

@@ -66,9 +66,6 @@ class VendorCreate(BaseModel):
letzshop_csv_url_en: Optional[str] = Field(None, description="English CSV URL")
letzshop_csv_url_de: Optional[str] = Field(None, description="German CSV URL")
# Theme Configuration
theme_config: Optional[Dict] = Field(default_factory=dict, description="Theme settings")
@field_validator("owner_email", "contact_email")
@classmethod
def validate_emails(cls, v):
@@ -122,9 +119,6 @@ class VendorUpdate(BaseModel):
letzshop_csv_url_en: Optional[str] = None
letzshop_csv_url_de: Optional[str] = None
# Theme Configuration
theme_config: Optional[Dict] = None
# Status (Admin only)
is_active: Optional[bool] = None
is_verified: Optional[bool] = None
@@ -171,9 +165,6 @@ class VendorResponse(BaseModel):
letzshop_csv_url_en: Optional[str]
letzshop_csv_url_de: Optional[str]
# Theme Configuration
theme_config: Dict
# Status Flags
is_active: bool
is_verified: bool

View File

@@ -0,0 +1,84 @@
# models/schema/vendor_theme.py
"""
Pydantic schemas for vendor theme operations.
"""
from typing import Dict, Optional, List
from pydantic import BaseModel, Field
class VendorThemeColors(BaseModel):
"""Color scheme for vendor theme."""
primary: Optional[str] = Field(None, description="Primary brand color")
secondary: Optional[str] = Field(None, description="Secondary color")
accent: Optional[str] = Field(None, description="Accent/CTA color")
background: Optional[str] = Field(None, description="Background color")
text: Optional[str] = Field(None, description="Text color")
border: Optional[str] = Field(None, description="Border color")
class VendorThemeFonts(BaseModel):
"""Typography settings for vendor theme."""
heading: Optional[str] = Field(None, description="Font for headings")
body: Optional[str] = Field(None, description="Font for body text")
class VendorThemeBranding(BaseModel):
"""Branding assets for vendor theme."""
logo: Optional[str] = Field(None, description="Logo URL")
logo_dark: Optional[str] = Field(None, description="Dark mode logo URL")
favicon: Optional[str] = Field(None, description="Favicon URL")
banner: Optional[str] = Field(None, description="Banner image URL")
class VendorThemeLayout(BaseModel):
"""Layout settings for vendor theme."""
style: Optional[str] = Field(None, description="Product layout style (grid, list, masonry)")
header: Optional[str] = Field(None, description="Header style (fixed, static, transparent)")
product_card: Optional[str] = Field(None, description="Product card style (modern, classic, minimal)")
class VendorThemeUpdate(BaseModel):
"""Schema for updating vendor theme (partial updates allowed)."""
theme_name: Optional[str] = Field(None, description="Theme preset name")
colors: Optional[Dict[str, str]] = Field(None, description="Color scheme")
fonts: Optional[Dict[str, str]] = Field(None, description="Font settings")
branding: Optional[Dict[str, Optional[str]]] = Field(None, description="Branding assets")
layout: Optional[Dict[str, str]] = Field(None, description="Layout settings")
custom_css: Optional[str] = Field(None, description="Custom CSS rules")
social_links: Optional[Dict[str, str]] = Field(None, description="Social media links")
class VendorThemeResponse(BaseModel):
"""Schema for vendor theme response."""
theme_name: str = Field(..., description="Theme name")
colors: Dict[str, str] = Field(..., description="Color scheme")
fonts: Dict[str, str] = Field(..., description="Font settings")
branding: Dict[str, Optional[str]] = Field(..., description="Branding assets")
layout: Dict[str, str] = Field(..., description="Layout settings")
social_links: Optional[Dict[str, str]] = Field(default_factory=dict, description="Social links")
custom_css: Optional[str] = Field(None, description="Custom CSS")
css_variables: Optional[Dict[str, str]] = Field(None, description="CSS custom properties")
class ThemePresetPreview(BaseModel):
"""Preview information for a theme preset."""
name: str = Field(..., description="Preset name")
description: str = Field(..., description="Preset description")
primary_color: str = Field(..., description="Primary color")
secondary_color: str = Field(..., description="Secondary color")
accent_color: str = Field(..., description="Accent color")
heading_font: str = Field(..., description="Heading font")
body_font: str = Field(..., description="Body font")
layout_style: str = Field(..., description="Layout style")
class ThemePresetResponse(BaseModel):
"""Response after applying a preset."""
message: str = Field(..., description="Success message")
theme: VendorThemeResponse = Field(..., description="Applied theme")
class ThemePresetListResponse(BaseModel):
"""List of available theme presets."""
presets: List[ThemePresetPreview] = Field(..., description="Available presets")