Multitenant implementation with custom Domain, theme per vendor

This commit is contained in:
2025-10-26 20:05:02 +01:00
parent 091067a729
commit c88775134d
27 changed files with 3267 additions and 838 deletions

View File

@@ -8,6 +8,8 @@ from .user import User
from .marketplace_product import MarketplaceProduct
from .inventory import Inventory
from .vendor import Vendor
from .vendor_domain import VendorDomain
from .vendor_theme import VendorTheme
from .product import Product
from .marketplace_import_job import MarketplaceImportJob
@@ -21,4 +23,7 @@ __all__ = [
"Vendor",
"Product",
"MarketplaceImportJob",
"VendorDomain",
"VendorTheme"
]

View File

@@ -1,6 +1,16 @@
# models/database/vendor.py - ENHANCED VERSION
"""
Enhanced 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
"""
from sqlalchemy import (Boolean, Column, ForeignKey, Integer, String, Text, JSON)
from sqlalchemy.orm import relationship
from app.core.config import settings
from app.core.database import Base
from models.database.base import TimestampMixin
@@ -11,11 +21,14 @@ class Vendor(Base, TimestampMixin):
id = Column(Integer, primary_key=True, index=True)
vendor_code = Column(
String, unique=True, index=True, nullable=False
) # e.g., "TECHSTORE", "FASHIONHUB"
)
subdomain = Column(String(100), unique=True, nullable=False, index=True)
name = Column(String, nullable=False) # Display name
name = Column(String, nullable=False)
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
@@ -43,11 +56,208 @@ class Vendor(Base, TimestampMixin):
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",
cascade="all, delete-orphan",
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(
"VendorTheme",
back_populates="vendor",
uselist=False,
cascade="all, delete-orphan"
)
def __repr__(self):
return f"<Vendor(id={self.id}, vendor_code='{self.vendor_code}', name='{self.name}', subdomain='{self.subdomain}')>"
# ========================================================================
# 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):
"""
Get theme configuration for this vendor.
Priority:
1. Advanced theme (VendorTheme) if configured
2. theme_config JSON field
3. Default theme
Returns dict with theme configuration.
"""
# Priority 1: Advanced theme
if self.advanced_theme and self.advanced_theme.is_active:
return self.advanced_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 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 {
"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
},
"layout": {
"style": "grid",
"header": "fixed"
},
"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",
}
}
@property
def primary_color(self):
"""Get primary color from theme"""
return self.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"]
# ========================================================================
# Domain Helper Methods
# ========================================================================
@property
def primary_domain(self):
"""Get the primary custom domain for this vendor"""
for domain in self.domains:
if domain.is_primary and domain.is_active:
return domain.domain
return None
@property
def all_domains(self):
"""Get all active domains (subdomain + custom domains)"""
domains = [f"{self.subdomain}.{settings.platform_domain}"]
for domain in self.domains:
if domain.is_active:
domains.append(domain.domain)
return domains
# Keep your existing VendorUser and Role models unchanged
class VendorUser(Base, TimestampMixin):
__tablename__ = "vendor_users"
@@ -58,7 +268,6 @@ class VendorUser(Base, TimestampMixin):
invited_by = Column(Integer, ForeignKey("users.id"))
is_active = Column(Boolean, default=True, nullable=False)
# Relationships
vendor = relationship("Vendor", back_populates="vendor_users")
user = relationship("User", foreign_keys=[user_id], back_populates="vendor_memberships")
inviter = relationship("User", foreign_keys=[invited_by])
@@ -73,12 +282,11 @@ class Role(Base, TimestampMixin):
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
name = Column(String(100), nullable=False) # "Owner", "Manager", "Editor", "Viewer"
permissions = Column(JSON, default=list) # ["products.create", "orders.view", etc.]
name = Column(String(100), nullable=False)
permissions = Column(JSON, default=list)
# Relationships
vendor = relationship("Vendor")
vendor_users = relationship("VendorUser", back_populates="role")
def __repr__(self):
return f"<Role(id={self.id}, name='{self.name}', vendor_id={self.vendor_id})>"
return f"<Role(id={self.id}, name='{self.name}', vendor_id={self.vendor_id})>"

View File

@@ -0,0 +1,84 @@
# models/database/vendor_domain.py
"""
Vendor Domain Model - Maps custom domains to vendors
"""
from datetime import datetime, timezone
from sqlalchemy import (
Column, Integer, String, Boolean, DateTime,
ForeignKey, UniqueConstraint, Index
)
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class VendorDomain(Base, TimestampMixin):
"""
Maps custom domains to vendors for multi-domain routing.
Examples:
- customdomain1.com → Vendor 1
- shop.mybusiness.com → Vendor 2
- www.customdomain1.com → Vendor 1 (www is stripped)
"""
__tablename__ = "vendor_domains"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id", ondelete="CASCADE"), nullable=False)
# Domain configuration
domain = Column(String(255), nullable=False, unique=True, index=True)
is_primary = Column(Boolean, default=False, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
# SSL/TLS status (for monitoring)
ssl_status = Column(String(50), default="pending") # pending, active, expired, error
ssl_verified_at = Column(DateTime(timezone=True), nullable=True)
# DNS verification (to confirm domain ownership)
verification_token = Column(String(100), unique=True, nullable=True)
is_verified = Column(Boolean, default=False, nullable=False)
verified_at = Column(DateTime(timezone=True), nullable=True)
# Relationships
vendor = relationship("Vendor", back_populates="domains")
# Constraints
__table_args__ = (
UniqueConstraint('vendor_id', 'domain', name='uq_vendor_domain'),
Index('idx_domain_active', 'domain', 'is_active'),
Index('idx_vendor_primary', 'vendor_id', 'is_primary'),
)
def __repr__(self):
return f"<VendorDomain(domain='{self.domain}', vendor_id={self.vendor_id})>"
@property
def full_url(self):
"""Return full URL with https"""
return f"https://{self.domain}"
@classmethod
def normalize_domain(cls, domain: str) -> str:
"""
Normalize domain for consistent storage.
Examples:
- https://example.com → example.com
- www.example.com → example.com
- EXAMPLE.COM → example.com
"""
# Remove protocol
domain = domain.replace("https://", "").replace("http://", "")
# Remove trailing slash
domain = domain.rstrip("/")
# Remove www prefix (optional - depends on your preference)
# if domain.startswith("www."):
# domain = domain[4:]
# Convert to lowercase
domain = domain.lower()
return domain

View File

@@ -0,0 +1,115 @@
# models/database/vendor_theme.py
"""
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.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:
- Custom colors (primary, secondary, accent)
- Custom fonts
- Custom logo and favicon
- Custom CSS overrides
- Layout preferences
"""
__tablename__ = "vendor_themes"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id", ondelete="CASCADE"), nullable=False, unique=True)
# Basic Theme Settings
theme_name = Column(String(100), default="default") # e.g., "modern", "classic", "minimal"
is_active = Column(Boolean, default=True)
# Color Scheme (JSON for flexibility)
colors = Column(JSON, default={
"primary": "#6366f1", # Indigo
"secondary": "#8b5cf6", # Purple
"accent": "#ec4899", # Pink
"background": "#ffffff", # White
"text": "#1f2937", # Gray-800
"border": "#e5e7eb" # Gray-200
})
# Typography
font_family_heading = Column(String(100), default="Inter, sans-serif")
font_family_body = Column(String(100), default="Inter, sans-serif")
# Branding Assets
logo_url = Column(String(500), nullable=True) # Path to vendor logo
logo_dark_url = Column(String(500), nullable=True) # Dark mode logo
favicon_url = Column(String(500), nullable=True) # Favicon
banner_url = Column(String(500), nullable=True) # Homepage banner
# Layout Preferences
layout_style = Column(String(50), default="grid") # grid, list, masonry
header_style = Column(String(50), default="fixed") # fixed, static, transparent
product_card_style = Column(String(50), default="modern") # modern, classic, minimal
# Custom CSS (for advanced customization)
custom_css = Column(Text, nullable=True)
# Social Media Links
social_links = Column(JSON, default={}) # {facebook: "url", instagram: "url", etc.}
# SEO & Meta
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")
def __repr__(self):
return f"<VendorTheme(vendor_id={self.vendor_id}, theme_name='{self.theme_name}')>"
@property
def primary_color(self):
"""Get primary color from JSON"""
return self.colors.get("primary", "#6366f1")
@property
def css_variables(self):
"""Generate CSS custom properties from theme config"""
return {
"--color-primary": self.colors.get("primary", "#6366f1"),
"--color-secondary": self.colors.get("secondary", "#8b5cf6"),
"--color-accent": self.colors.get("accent", "#ec4899"),
"--color-background": self.colors.get("background", "#ffffff"),
"--color-text": self.colors.get("text", "#1f2937"),
"--color-border": self.colors.get("border", "#e5e7eb"),
"--font-heading": self.font_family_heading,
"--font-body": self.font_family_body,
}
def to_dict(self):
"""Convert theme to dictionary for template rendering"""
return {
"theme_name": self.theme_name,
"colors": self.colors,
"fonts": {
"heading": self.font_family_heading,
"body": self.font_family_body,
},
"branding": {
"logo": self.logo_url,
"logo_dark": self.logo_dark_url,
"favicon": self.favicon_url,
"banner": self.banner_url,
},
"layout": {
"style": self.layout_style,
"header": self.header_style,
"product_card": self.product_card_style,
},
"social_links": self.social_links,
"custom_css": self.custom_css,
"css_variables": self.css_variables,
}