Multitenant implementation with custom Domain, theme per vendor
This commit is contained in:
117
middleware/theme_context.py
Normal file
117
middleware/theme_context.py
Normal 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())
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user