Multitenant implementation with custom Domain, theme per vendor
This commit is contained in:
@@ -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"
|
||||
]
|
||||
|
||||
|
||||
@@ -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})>"
|
||||
|
||||
84
models/database/vendor_domain.py
Normal file
84
models/database/vendor_domain.py
Normal 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
|
||||
115
models/database/vendor_theme.py
Normal file
115
models/database/vendor_theme.py
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user