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

117
middleware/theme_context.py Normal file
View File

@@ -0,0 +1,117 @@
# middleware/theme_context.py
"""
Theme Context Middleware
Injects vendor-specific theme into request context
"""
import logging
from fastapi import Request
from sqlalchemy.orm import Session
from app.core.database import get_db
from models.database.vendor_theme import VendorTheme
logger = logging.getLogger(__name__)
class ThemeContextManager:
"""Manages theme context for vendor shops."""
@staticmethod
def get_vendor_theme(db: Session, vendor_id: int) -> dict:
"""
Get theme configuration for vendor.
Returns default theme if no custom theme is configured.
"""
theme = db.query(VendorTheme).filter(
VendorTheme.vendor_id == vendor_id,
VendorTheme.is_active == True
).first()
if theme:
return theme.to_dict()
# Return default theme
return get_default_theme()
@staticmethod
def get_default_theme() -> 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,
"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",
}
}
async def theme_context_middleware(request: Request, call_next):
"""
Middleware to inject theme context into request state.
This runs AFTER vendor_context_middleware has set request.state.vendor
"""
# Only inject theme for shop pages (not admin or API)
if hasattr(request.state, 'vendor') and request.state.vendor:
vendor = request.state.vendor
# Get database session
db_gen = get_db()
db = next(db_gen)
try:
# Get vendor theme
theme = ThemeContextManager.get_vendor_theme(db, vendor.id)
request.state.theme = theme
logger.debug(
f"Theme loaded for vendor {vendor.name}: {theme['theme_name']}"
)
except Exception as e:
logger.error(f"Failed to load theme for vendor {vendor.id}: {e}")
# Fallback to default theme
request.state.theme = ThemeContextManager.get_default_theme()
finally:
db.close()
else:
# No vendor context, use default theme
request.state.theme = ThemeContextManager.get_default_theme()
response = await call_next(request)
return response
def get_current_theme(request: Request) -> dict:
"""Helper function to get current theme from request state."""
return getattr(request.state, "theme", ThemeContextManager.get_default_theme())

View File

@@ -3,10 +3,11 @@ import logging
from typing import Optional
from fastapi import Request
from sqlalchemy.orm import Session
from sqlalchemy import func
from sqlalchemy import func, or_
from app.core.database import get_db
from models.database.vendor import Vendor
from models.database.vendor_domain import VendorDomain
logger = logging.getLogger(__name__)
@@ -19,14 +20,47 @@ class VendorContextManager:
"""
Detect vendor context from request.
Priority order:
1. Custom domain (customdomain1.com)
2. Subdomain (vendor1.platform.com)
3. Path-based (/vendor/vendor1/)
Returns dict with vendor info or None if not found.
"""
host = request.headers.get("host", "")
path = request.url.path
# Method 1: Subdomain detection (production)
# Remove port from host if present (e.g., localhost:8000 → localhost)
if ":" in host:
host = host.split(":")[0]
# Method 1: Custom domain detection (HIGHEST PRIORITY)
# Check if this is a custom domain (not platform.com and not localhost)
from app.core.config import settings
platform_domain = getattr(settings, 'platform_domain', 'platform.com')
is_custom_domain = (
host and
not host.endswith(f".{platform_domain}") and
host != platform_domain and
host not in ["localhost", "127.0.0.1", "admin.localhost", "admin.127.0.0.1"] and
not host.startswith("admin.")
)
if is_custom_domain:
# This could be a custom domain like customdomain1.com
normalized_domain = VendorDomain.normalize_domain(host)
return {
"domain": normalized_domain,
"detection_method": "custom_domain",
"host": host,
"original_host": request.headers.get("host", "")
}
# Method 2: Subdomain detection (vendor1.platform.com)
if "." in host:
parts = host.split(".")
# Check if it's a valid subdomain (not www, admin, api)
if len(parts) >= 2 and parts[0] not in ["www", "admin", "api"]:
subdomain = parts[0]
return {
@@ -35,7 +69,7 @@ class VendorContextManager:
"host": host
}
# Method 2: Path-based detection (development)
# Method 3: Path-based detection (/vendor/vendorname/) - for development
if path.startswith("/vendor/"):
path_parts = path.split("/")
if len(path_parts) >= 3:
@@ -51,17 +85,61 @@ class VendorContextManager:
@staticmethod
def get_vendor_from_context(db: Session, context: dict) -> Optional[Vendor]:
"""Get vendor from database using context information."""
if not context or "subdomain" not in context:
"""
Get vendor from database using context information.
Supports three methods:
1. Custom domain lookup (VendorDomain table)
2. Subdomain lookup (Vendor.subdomain)
3. Path-based lookup (Vendor.subdomain)
"""
if not context:
return None
# Query vendor by subdomain (case-insensitive)
vendor = (
db.query(Vendor)
.filter(func.lower(Vendor.subdomain) == context["subdomain"].lower())
.filter(Vendor.is_active == True) # Only active vendors
.first()
)
vendor = None
# Method 1: Custom domain lookup
if context.get("detection_method") == "custom_domain":
domain = context.get("domain")
if domain:
# Look up vendor by custom domain
vendor_domain = (
db.query(VendorDomain)
.filter(VendorDomain.domain == domain)
.filter(VendorDomain.is_active == True)
.filter(VendorDomain.is_verified == True)
.first()
)
if vendor_domain:
vendor = vendor_domain.vendor
# Check if vendor is active
if not vendor or not vendor.is_active:
logger.warning(f"Vendor for domain {domain} is not active")
return None
logger.info(f"✓ Vendor found via custom domain: {domain}{vendor.name}")
return vendor
else:
logger.warning(f"No active vendor found for custom domain: {domain}")
return None
# Method 2 & 3: Subdomain or path-based lookup
if "subdomain" in context:
subdomain = context["subdomain"]
# Query vendor by subdomain (case-insensitive)
vendor = (
db.query(Vendor)
.filter(func.lower(Vendor.subdomain) == subdomain.lower())
.filter(Vendor.is_active == True)
.first()
)
if vendor:
method = context.get("detection_method", "unknown")
logger.info(f"✓ Vendor found via {method}: {subdomain}{vendor.name}")
else:
logger.warning(f"No active vendor found for subdomain: {subdomain}")
return vendor
@@ -71,6 +149,7 @@ class VendorContextManager:
if not vendor_context:
return request.url.path
# Only strip path prefix for path-based detection
if vendor_context.get("detection_method") == "path":
path_prefix = vendor_context.get("path_prefix", "")
path = request.url.path
@@ -86,10 +165,16 @@ class VendorContextManager:
host = request.headers.get("host", "")
path = request.url.path
# Remove port from host
if ":" in host:
host = host.split(":")[0]
# Check for admin subdomain
if host.startswith("admin."):
return True
if "/admin" in path:
# Check for admin path
if path.startswith("/admin"):
return True
return False
@@ -103,6 +188,11 @@ class VendorContextManager:
async def vendor_context_middleware(request: Request, call_next):
"""
Middleware to inject vendor context into request state.
Handles three routing modes:
1. Custom domains (customdomain1.com → Vendor 1)
2. Subdomains (vendor1.platform.com → Vendor 1)
3. Path-based (/vendor/vendor1/ → Vendor 1)
"""
# Skip vendor detection for admin, API, and system requests
if (VendorContextManager.is_admin_request(request) or
@@ -127,12 +217,12 @@ async def vendor_context_middleware(request: Request, call_next):
)
logger.debug(
f"Vendor context: {vendor.name} ({vendor.subdomain}) "
f"🏪 Vendor context: {vendor.name} ({vendor.subdomain}) "
f"via {vendor_context['detection_method']}"
)
else:
logger.warning(
f"Vendor not found for subdomain: {vendor_context['subdomain']}"
f"⚠️ Vendor not found for context: {vendor_context}"
)
request.state.vendor = None
request.state.vendor_context = vendor_context
@@ -153,6 +243,7 @@ def get_current_vendor(request: Request) -> Optional[Vendor]:
def require_vendor_context():
"""Dependency to require vendor context in endpoints."""
def dependency(request: Request):
vendor = get_current_vendor(request)
if not vendor: