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,
}

View File

@@ -0,0 +1,127 @@
# models/schema/vendor_domain.py
"""
Pydantic schemas for Vendor Domain operations.
Schemas include:
- VendorDomainCreate: For adding custom domains
- VendorDomainUpdate: For updating domain settings
- VendorDomainResponse: Standard domain response
- VendorDomainListResponse: Paginated domain list
- DomainVerificationInstructions: DNS verification instructions
"""
import re
from datetime import datetime
from typing import List, Optional, Dict
from pydantic import BaseModel, ConfigDict, Field, field_validator
class VendorDomainCreate(BaseModel):
"""Schema for adding a custom domain to vendor."""
domain: str = Field(
...,
description="Custom domain (e.g., myshop.com or shop.mybrand.com)",
min_length=3,
max_length=255
)
is_primary: bool = Field(
default=False,
description="Set as primary domain for the vendor"
)
@field_validator('domain')
@classmethod
def validate_domain(cls, v: str) -> str:
"""Validate and normalize domain."""
# Remove protocol if present
domain = v.replace("https://", "").replace("http://", "")
# Remove trailing slash
domain = domain.rstrip("/")
# Convert to lowercase
domain = domain.lower().strip()
# Basic validation
if not domain or '/' in domain:
raise ValueError("Invalid domain format")
if '.' not in domain:
raise ValueError("Domain must have at least one dot")
# Check for reserved subdomains
reserved = ['www', 'admin', 'api', 'mail', 'smtp', 'ftp', 'cpanel', 'webmail']
first_part = domain.split('.')[0]
if first_part in reserved:
raise ValueError(f"Domain cannot start with reserved subdomain: {first_part}")
# Validate domain format (basic regex)
domain_pattern = r'^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$'
if not re.match(domain_pattern, domain):
raise ValueError("Invalid domain format")
return domain
class VendorDomainUpdate(BaseModel):
"""Schema for updating vendor domain settings."""
is_primary: Optional[bool] = Field(None, description="Set as primary domain")
is_active: Optional[bool] = Field(None, description="Activate or deactivate domain")
model_config = ConfigDict(from_attributes=True)
class VendorDomainResponse(BaseModel):
"""Standard schema for vendor domain response."""
model_config = ConfigDict(from_attributes=True)
id: int
vendor_id: int
domain: str
is_primary: bool
is_active: bool
is_verified: bool
ssl_status: str
verification_token: Optional[str] = None
verified_at: Optional[datetime] = None
ssl_verified_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
class VendorDomainListResponse(BaseModel):
"""Schema for paginated vendor domain list."""
domains: List[VendorDomainResponse]
total: int
class DomainVerificationInstructions(BaseModel):
"""DNS verification instructions for domain ownership."""
domain: str
verification_token: str
instructions: Dict[str, str]
txt_record: Dict[str, str]
common_registrars: Dict[str, str]
model_config = ConfigDict(from_attributes=True)
class DomainVerificationResponse(BaseModel):
"""Response after domain verification."""
message: str
domain: str
verified_at: datetime
is_verified: bool
class DomainDeletionResponse(BaseModel):
"""Response after domain deletion."""
message: str
domain: str
vendor_id: int