feat: add multi-platform CMS architecture (Phase 1)
Implement the foundation for multi-platform support allowing independent business offerings (OMS, Loyalty, etc.) with their own CMS pages. Database Models: - Add Platform model for business offerings (domain, branding, config) - Add VendorPlatform junction table for many-to-many relationship - Update SubscriptionTier with platform_id and CMS limits - Update ContentPage with platform_id, is_platform_page for three-tier hierarchy - Add CMS feature codes (cms_basic, cms_custom_pages, cms_templates, etc.) Three-Tier Content Resolution: 1. Vendor override (platform_id + vendor_id + slug) 2. Vendor default (platform_id + vendor_id=NULL + is_platform_page=False) 3. Platform marketing pages (is_platform_page=True) New Components: - PlatformContextMiddleware for detecting platform from domain/path - ContentPageService updated with full three-tier resolution - Platform folder structure (app/platforms/oms/, app/platforms/loyalty/) - Alembic migration with backfill for existing data Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
375
middleware/platform_context.py
Normal file
375
middleware/platform_context.py
Normal file
@@ -0,0 +1,375 @@
|
||||
# middleware/platform_context.py
|
||||
"""
|
||||
Platform Context Middleware
|
||||
|
||||
Detects platform from host/domain/path and injects into request.state.
|
||||
This middleware runs BEFORE VendorContextMiddleware to establish platform context.
|
||||
|
||||
Handles two routing modes:
|
||||
1. Production: Domain-based (oms.lu, loyalty.lu → Platform detection)
|
||||
2. Development: Path-based (localhost:9999/oms/*, localhost:9999/loyalty/* → Platform detection)
|
||||
|
||||
Also provides platform_clean_path for downstream middleware to use.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from models.database.platform import Platform
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default platform code for backward compatibility
|
||||
DEFAULT_PLATFORM_CODE = "oms"
|
||||
|
||||
|
||||
class PlatformContextManager:
|
||||
"""Manages platform context detection for multi-platform routing."""
|
||||
|
||||
@staticmethod
|
||||
def detect_platform_context(request: Request) -> dict | None:
|
||||
"""
|
||||
Detect platform context from request.
|
||||
|
||||
Priority order:
|
||||
1. Domain-based (production): oms.lu → platform code "oms"
|
||||
2. Path-based (development): localhost:9999/oms/* → platform code "oms"
|
||||
3. Default: localhost without path prefix → default platform
|
||||
|
||||
Returns dict with platform info or None if not detected.
|
||||
"""
|
||||
host = request.headers.get("host", "")
|
||||
path = request.url.path
|
||||
|
||||
# Remove port from host if present (e.g., localhost:9999 -> localhost)
|
||||
host_without_port = host.split(":")[0] if ":" in host else host
|
||||
|
||||
# Skip platform detection for admin routes - admin is global
|
||||
if PlatformContextManager.is_admin_request(request):
|
||||
return None
|
||||
|
||||
# Method 1: Domain-based detection (production)
|
||||
# Check if the host matches a known platform domain
|
||||
# This will be resolved in get_platform_from_context by DB lookup
|
||||
if host_without_port and host_without_port not in ["localhost", "127.0.0.1"]:
|
||||
# Could be a platform domain or a vendor subdomain/custom domain
|
||||
# Check if it's a known platform domain pattern
|
||||
# For now, assume non-localhost hosts that aren't subdomains are platform domains
|
||||
if "." in host_without_port:
|
||||
# This could be:
|
||||
# - Platform domain: oms.lu, loyalty.lu
|
||||
# - Vendor subdomain: vendor.oms.lu
|
||||
# - Custom domain: shop.mycompany.com
|
||||
# We detect platform domain vs subdomain by checking if it's a root domain
|
||||
parts = host_without_port.split(".")
|
||||
if len(parts) == 2: # e.g., oms.lu (root domain)
|
||||
return {
|
||||
"domain": host_without_port,
|
||||
"detection_method": "domain",
|
||||
"host": host,
|
||||
"original_path": path,
|
||||
}
|
||||
|
||||
# Method 2: Path-based detection (development)
|
||||
# Check for path prefix like /oms/, /loyalty/
|
||||
if path.startswith("/"):
|
||||
path_parts = path[1:].split("/") # Remove leading / and split
|
||||
if path_parts and path_parts[0]:
|
||||
potential_platform_code = path_parts[0].lower()
|
||||
# Check if this could be a platform code (not vendor paths)
|
||||
if potential_platform_code not in [
|
||||
"vendor",
|
||||
"vendors",
|
||||
"admin",
|
||||
"api",
|
||||
"static",
|
||||
"media",
|
||||
"assets",
|
||||
"health",
|
||||
"docs",
|
||||
"redoc",
|
||||
"openapi.json",
|
||||
]:
|
||||
return {
|
||||
"path_prefix": potential_platform_code,
|
||||
"detection_method": "path",
|
||||
"host": host,
|
||||
"original_path": path,
|
||||
"clean_path": "/" + "/".join(path_parts[1:]) if len(path_parts) > 1 else "/",
|
||||
}
|
||||
|
||||
# Method 3: Default platform for localhost without prefix
|
||||
if host_without_port in ["localhost", "127.0.0.1"]:
|
||||
return {
|
||||
"path_prefix": DEFAULT_PLATFORM_CODE,
|
||||
"detection_method": "default",
|
||||
"host": host,
|
||||
"original_path": path,
|
||||
"clean_path": path,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_platform_from_context(db: Session, context: dict) -> Platform | None:
|
||||
"""
|
||||
Get platform from database using context information.
|
||||
|
||||
Supports:
|
||||
1. Domain-based lookup (Platform.domain)
|
||||
2. Path-prefix lookup (Platform.path_prefix)
|
||||
3. Default lookup (Platform.code)
|
||||
"""
|
||||
if not context:
|
||||
return None
|
||||
|
||||
platform = None
|
||||
|
||||
# Method 1: Domain-based lookup
|
||||
if context.get("detection_method") == "domain":
|
||||
domain = context.get("domain")
|
||||
if domain:
|
||||
platform = (
|
||||
db.query(Platform)
|
||||
.filter(Platform.domain == domain)
|
||||
.filter(Platform.is_active == True)
|
||||
.first()
|
||||
)
|
||||
|
||||
if platform:
|
||||
logger.debug(
|
||||
f"[PLATFORM] Platform found via domain: {domain} → {platform.name}"
|
||||
)
|
||||
return platform
|
||||
else:
|
||||
logger.debug(f"[PLATFORM] No platform found for domain: {domain}")
|
||||
|
||||
# Method 2: Path-prefix lookup
|
||||
if context.get("detection_method") == "path":
|
||||
path_prefix = context.get("path_prefix")
|
||||
if path_prefix:
|
||||
# First try path_prefix, then try code
|
||||
platform = (
|
||||
db.query(Platform)
|
||||
.filter(
|
||||
(Platform.path_prefix == path_prefix) | (Platform.code == path_prefix)
|
||||
)
|
||||
.filter(Platform.is_active == True)
|
||||
.first()
|
||||
)
|
||||
|
||||
if platform:
|
||||
logger.debug(
|
||||
f"[PLATFORM] Platform found via path prefix: {path_prefix} → {platform.name}"
|
||||
)
|
||||
return platform
|
||||
else:
|
||||
logger.debug(f"[PLATFORM] No platform found for path prefix: {path_prefix}")
|
||||
|
||||
# Method 3: Default lookup
|
||||
if context.get("detection_method") == "default":
|
||||
platform = (
|
||||
db.query(Platform)
|
||||
.filter(Platform.code == DEFAULT_PLATFORM_CODE)
|
||||
.filter(Platform.is_active == True)
|
||||
.first()
|
||||
)
|
||||
|
||||
if platform:
|
||||
logger.debug(
|
||||
f"[PLATFORM] Default platform found: {platform.name}"
|
||||
)
|
||||
return platform
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def extract_clean_path(request: Request, platform_context: dict | None) -> str:
|
||||
"""
|
||||
Extract clean path without platform prefix for routing.
|
||||
|
||||
Downstream middleware (like VendorContextMiddleware) should use this
|
||||
clean path for their detection logic.
|
||||
"""
|
||||
if not platform_context:
|
||||
return request.url.path
|
||||
|
||||
# For path-based detection, use the pre-computed clean path
|
||||
if platform_context.get("detection_method") == "path":
|
||||
return platform_context.get("clean_path", request.url.path)
|
||||
|
||||
# For domain-based or default, path remains unchanged
|
||||
return request.url.path
|
||||
|
||||
@staticmethod
|
||||
def is_admin_request(request: Request) -> bool:
|
||||
"""Check if request is for admin interface."""
|
||||
host = request.headers.get("host", "")
|
||||
path = request.url.path
|
||||
|
||||
if ":" in host:
|
||||
host = host.split(":")[0]
|
||||
|
||||
if host.startswith("admin."):
|
||||
return True
|
||||
|
||||
if path.startswith("/admin"):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def is_static_file_request(request: Request) -> bool:
|
||||
"""Check if request is for static files."""
|
||||
path = request.url.path.lower()
|
||||
|
||||
static_extensions = (
|
||||
".ico", ".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".svg",
|
||||
".woff", ".woff2", ".ttf", ".eot", ".webp", ".map", ".json",
|
||||
".xml", ".txt", ".pdf", ".webmanifest",
|
||||
)
|
||||
|
||||
static_paths = ("/static/", "/media/", "/assets/", "/.well-known/")
|
||||
|
||||
if path.endswith(static_extensions):
|
||||
return True
|
||||
|
||||
if any(path.startswith(static_path) for static_path in static_paths):
|
||||
return True
|
||||
|
||||
if "favicon.ico" in path:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class PlatformContextMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware to inject platform context into request state.
|
||||
|
||||
Runs BEFORE VendorContextMiddleware to establish platform context.
|
||||
|
||||
Sets:
|
||||
request.state.platform: Platform object
|
||||
request.state.platform_context: Detection metadata
|
||||
request.state.platform_clean_path: Path without platform prefix
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
"""
|
||||
Detect and inject platform context.
|
||||
"""
|
||||
# Skip platform detection for static files
|
||||
if PlatformContextManager.is_static_file_request(request):
|
||||
logger.debug(
|
||||
f"[PLATFORM] Skipping platform detection for static file: {request.url.path}"
|
||||
)
|
||||
request.state.platform = None
|
||||
request.state.platform_context = None
|
||||
request.state.platform_clean_path = request.url.path
|
||||
return await call_next(request)
|
||||
|
||||
# Skip platform detection for system endpoints
|
||||
if request.url.path in ["/health", "/docs", "/redoc", "/openapi.json"]:
|
||||
logger.debug(
|
||||
f"[PLATFORM] Skipping platform detection for system path: {request.url.path}"
|
||||
)
|
||||
request.state.platform = None
|
||||
request.state.platform_context = None
|
||||
request.state.platform_clean_path = request.url.path
|
||||
return await call_next(request)
|
||||
|
||||
# Admin requests are global (no platform context)
|
||||
if PlatformContextManager.is_admin_request(request):
|
||||
logger.debug(
|
||||
f"[PLATFORM] Admin request - no platform context: {request.url.path}"
|
||||
)
|
||||
request.state.platform = None
|
||||
request.state.platform_context = None
|
||||
request.state.platform_clean_path = request.url.path
|
||||
return await call_next(request)
|
||||
|
||||
# Detect platform context
|
||||
platform_context = PlatformContextManager.detect_platform_context(request)
|
||||
|
||||
if platform_context:
|
||||
db_gen = get_db()
|
||||
db = next(db_gen)
|
||||
try:
|
||||
platform = PlatformContextManager.get_platform_from_context(
|
||||
db, platform_context
|
||||
)
|
||||
|
||||
if platform:
|
||||
request.state.platform = platform
|
||||
request.state.platform_context = platform_context
|
||||
request.state.platform_clean_path = PlatformContextManager.extract_clean_path(
|
||||
request, platform_context
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"[PLATFORM_CONTEXT] Platform detected",
|
||||
extra={
|
||||
"platform_id": platform.id,
|
||||
"platform_code": platform.code,
|
||||
"platform_name": platform.name,
|
||||
"detection_method": platform_context.get("detection_method"),
|
||||
"original_path": request.url.path,
|
||||
"clean_path": request.state.platform_clean_path,
|
||||
},
|
||||
)
|
||||
else:
|
||||
# Platform code detected but not found in database
|
||||
# This could be a vendor path like /vendors/...
|
||||
logger.debug(
|
||||
"[PLATFORM] Platform code not found, may be vendor path",
|
||||
extra={
|
||||
"context": platform_context,
|
||||
"detection_method": platform_context.get("detection_method"),
|
||||
},
|
||||
)
|
||||
request.state.platform = None
|
||||
request.state.platform_context = None
|
||||
request.state.platform_clean_path = request.url.path
|
||||
finally:
|
||||
db.close()
|
||||
else:
|
||||
logger.debug(
|
||||
"[PLATFORM] No platform context detected",
|
||||
extra={
|
||||
"path": request.url.path,
|
||||
"host": request.headers.get("host", ""),
|
||||
},
|
||||
)
|
||||
request.state.platform = None
|
||||
request.state.platform_context = None
|
||||
request.state.platform_clean_path = request.url.path
|
||||
|
||||
# Continue to next middleware
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
def get_current_platform(request: Request) -> Platform | None:
|
||||
"""Helper function to get current platform from request state."""
|
||||
return getattr(request.state, "platform", None)
|
||||
|
||||
|
||||
def require_platform_context():
|
||||
"""Dependency to require platform context in endpoints."""
|
||||
|
||||
def dependency(request: Request):
|
||||
platform = get_current_platform(request)
|
||||
if not platform:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Platform not found"
|
||||
)
|
||||
return platform
|
||||
|
||||
return dependency
|
||||
Reference in New Issue
Block a user