Major architecture change to unify frontend detection: ## Problem Solved - Eliminated code duplication across 3 middleware files - Fixed incomplete path detection (now detects /api/v1/admin/*) - Unified on FrontendType enum (deprecates RequestContext) - Added request.state.frontend_type for all requests ## New Components - app/core/frontend_detector.py: Centralized FrontendDetector class - middleware/frontend_type.py: FrontendTypeMiddleware (replaces ContextMiddleware) - docs/architecture/frontend-detection.md: Complete architecture documentation ## Changes - main.py: Use FrontendTypeMiddleware instead of ContextMiddleware - middleware/context.py: Deprecated (kept for backwards compatibility) - middleware/platform_context.py: Use FrontendDetector.is_admin() - middleware/vendor_context.py: Use FrontendDetector.is_admin() - middleware/language.py: Use FrontendType instead of context_value - app/exceptions/handler.py: Use FrontendType.STOREFRONT - app/exceptions/error_renderer.py: Use FrontendType - Customer routes: Cookie path changed from /shop to /storefront ## Documentation - docs/architecture/frontend-detection.md: New comprehensive docs - docs/architecture/middleware.md: Updated for new system - docs/architecture/request-flow.md: Updated for FrontendType - docs/backend/middleware-reference.md: Updated API reference ## Tests - tests/unit/core/test_frontend_detector.py: 37 new tests - tests/unit/middleware/test_frontend_type.py: 11 new tests - tests/unit/middleware/test_context.py: Updated for compatibility Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
501 lines
20 KiB
Python
501 lines
20 KiB
Python
# main.py
|
|
"""
|
|
Wizamart FastAPI Application
|
|
|
|
Multi-tenant e-commerce marketplace platform with:
|
|
- Three deployment modes (subdomain, custom domain, path-based)
|
|
- Three interfaces (platform administration (admin), vendor dashboard (vendor), customer shop (shop))
|
|
- Comprehensive exception handling
|
|
- Middleware stack for context injection
|
|
"""
|
|
|
|
import io
|
|
import sys
|
|
|
|
# Fix Windows console encoding issues (must be at the very top)
|
|
if sys.platform == "win32":
|
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
|
|
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8")
|
|
|
|
import logging
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
|
|
import sentry_sdk
|
|
from fastapi import Depends, FastAPI, HTTPException, Request, Response
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.templating import Jinja2Templates
|
|
from sentry_sdk.integrations.fastapi import FastApiIntegration
|
|
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
|
from sqlalchemy import text
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.api.main import api_router
|
|
from app.core.config import settings
|
|
|
|
# =============================================================================
|
|
# SENTRY INITIALIZATION
|
|
# =============================================================================
|
|
# Initialize Sentry for error tracking (only if DSN is configured)
|
|
if settings.sentry_dsn:
|
|
sentry_sdk.init(
|
|
dsn=settings.sentry_dsn,
|
|
environment=settings.sentry_environment,
|
|
traces_sample_rate=settings.sentry_traces_sample_rate,
|
|
integrations=[
|
|
FastApiIntegration(transaction_style="endpoint"),
|
|
SqlalchemyIntegration(),
|
|
],
|
|
# Send PII data (emails, usernames) - set to False if privacy is critical
|
|
send_default_pii=True,
|
|
# Release version for tracking deployments
|
|
release=f"wizamart@{settings.version}",
|
|
)
|
|
logging.getLogger(__name__).info(
|
|
f"Sentry initialized for environment: {settings.sentry_environment}"
|
|
)
|
|
from app.core.database import get_db
|
|
from app.core.lifespan import lifespan
|
|
from app.exceptions import ServiceUnavailableException
|
|
from app.exceptions.handler import setup_exception_handlers
|
|
|
|
# Module route auto-discovery - all page routes now come from modules
|
|
from app.modules.routes import (
|
|
get_admin_page_routes,
|
|
get_platform_page_routes,
|
|
get_storefront_page_routes,
|
|
get_vendor_page_routes,
|
|
)
|
|
from app.utils.i18n import get_jinja2_globals
|
|
from middleware.frontend_type import FrontendTypeMiddleware
|
|
from middleware.language import LanguageMiddleware
|
|
from middleware.logging import LoggingMiddleware
|
|
from middleware.theme_context import ThemeContextMiddleware
|
|
|
|
# Import REFACTORED class-based middleware
|
|
from middleware.platform_context import PlatformContextMiddleware
|
|
from middleware.vendor_context import VendorContextMiddleware
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Get the project root directory (where main.py is located)
|
|
BASE_DIR = Path(__file__).resolve().parent
|
|
STATIC_DIR = BASE_DIR / "static"
|
|
TEMPLATES_DIR = BASE_DIR / "app" / "templates"
|
|
|
|
# FastAPI app with lifespan
|
|
app = FastAPI(
|
|
title=settings.project_name,
|
|
description=settings.description,
|
|
version=settings.version,
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
# Configure Jinja2 Templates (shared instance with i18n globals)
|
|
from app.templates_config import templates # noqa: F401 - re-exported for compatibility
|
|
|
|
# Setup custom exception handlers (unified approach)
|
|
setup_exception_handlers(app)
|
|
|
|
# Add CORS middleware
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=settings.allowed_hosts,
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# ============================================================================
|
|
# MIDDLEWARE REGISTRATION (CORRECTED ORDER!)
|
|
# ============================================================================
|
|
#
|
|
# IMPORTANT: Middleware execution order with BaseHTTPMiddleware:
|
|
#
|
|
# When using app.add_middleware or wrapping with BaseHTTPMiddleware,
|
|
# the LAST added middleware runs FIRST (LIFO - Last In, First Out).
|
|
#
|
|
# So we add them in REVERSE order of desired execution:
|
|
#
|
|
# Desired execution order:
|
|
# 1. PlatformContextMiddleware (detect platform from domain/path)
|
|
# 2. VendorContextMiddleware (detect vendor, uses platform_clean_path)
|
|
# 3. FrontendTypeMiddleware (detect frontend type using FrontendDetector)
|
|
# 4. LanguageMiddleware (detect language based on frontend type)
|
|
# 5. ThemeContextMiddleware (load theme)
|
|
# 6. LoggingMiddleware (log all requests)
|
|
#
|
|
# Therefore we add them in REVERSE:
|
|
# - Add ThemeContextMiddleware FIRST (runs LAST in request)
|
|
# - Add LanguageMiddleware SECOND
|
|
# - Add FrontendTypeMiddleware THIRD
|
|
# - Add VendorContextMiddleware FOURTH
|
|
# - Add PlatformContextMiddleware FIFTH
|
|
# - Add LoggingMiddleware LAST (runs FIRST for timing)
|
|
# ============================================================================
|
|
|
|
logger.info("=" * 80)
|
|
logger.info("MIDDLEWARE REGISTRATION")
|
|
logger.info("=" * 80)
|
|
|
|
# Add logging middleware (runs first for timing, logs all requests/responses)
|
|
logger.info("Adding LoggingMiddleware (runs first for request timing)")
|
|
app.add_middleware(LoggingMiddleware)
|
|
|
|
# Add theme context middleware (runs last in request chain)
|
|
logger.info("Adding ThemeContextMiddleware (detects and loads theme)")
|
|
app.add_middleware(ThemeContextMiddleware)
|
|
|
|
# Add language middleware (detects language after context is determined)
|
|
logger.info("Adding LanguageMiddleware (detects language based on context)")
|
|
app.add_middleware(LanguageMiddleware)
|
|
|
|
# Add frontend type detection middleware (runs after vendor context extraction)
|
|
logger.info("Adding FrontendTypeMiddleware (detects frontend type using FrontendDetector)")
|
|
app.add_middleware(FrontendTypeMiddleware)
|
|
|
|
# Add vendor context middleware (runs after platform context)
|
|
logger.info("Adding VendorContextMiddleware (detects vendor, uses platform_clean_path)")
|
|
app.add_middleware(VendorContextMiddleware)
|
|
|
|
# Add platform context middleware (runs first in request chain, before vendor)
|
|
logger.info("Adding PlatformContextMiddleware (detects platform from domain/path)")
|
|
app.add_middleware(PlatformContextMiddleware)
|
|
|
|
logger.info("=" * 80)
|
|
logger.info("MIDDLEWARE ORDER SUMMARY:")
|
|
logger.info(" Execution order (request →):")
|
|
logger.info(" 1. LoggingMiddleware (timing)")
|
|
logger.info(" 2. PlatformContextMiddleware (platform detection)")
|
|
logger.info(" 3. VendorContextMiddleware (vendor detection)")
|
|
logger.info(" 4. FrontendTypeMiddleware (frontend type detection)")
|
|
logger.info(" 5. LanguageMiddleware (language detection)")
|
|
logger.info(" 6. ThemeContextMiddleware (theme loading)")
|
|
logger.info(" 7. FastAPI Router")
|
|
logger.info("=" * 80)
|
|
|
|
# ========================================
|
|
# MOUNT STATIC FILES - Use absolute path
|
|
# ========================================
|
|
# NOTE: Mount order matters in FastAPI - more specific paths must come FIRST
|
|
# Module static files must be mounted before the main /static mount
|
|
|
|
# Mount module static files FIRST (self-contained modules)
|
|
# NOTE: More specific paths must be mounted BEFORE less specific paths
|
|
MODULES_DIR = BASE_DIR / "app" / "modules"
|
|
if MODULES_DIR.exists():
|
|
for module_dir in sorted(MODULES_DIR.iterdir()):
|
|
if not module_dir.is_dir():
|
|
continue
|
|
|
|
module_name = module_dir.name
|
|
|
|
# Mount module locale files FIRST (more specific path)
|
|
# Must come before static mount to avoid being intercepted
|
|
module_locales = module_dir / "locales"
|
|
if module_locales.exists():
|
|
mount_path = f"/static/modules/{module_name}/locales"
|
|
app.mount(mount_path, StaticFiles(directory=str(module_locales)), name=f"{module_name}_locales")
|
|
logger.info(f"Mounted module locales: {mount_path} -> {module_locales}")
|
|
|
|
# Mount module static files (less specific path)
|
|
module_static = module_dir / "static"
|
|
if module_static.exists():
|
|
mount_path = f"/static/modules/{module_name}"
|
|
app.mount(mount_path, StaticFiles(directory=str(module_static)), name=f"{module_name}_static")
|
|
logger.info(f"Mounted module static files: {mount_path} -> {module_static}")
|
|
|
|
# Mount main static directory AFTER module statics
|
|
if STATIC_DIR.exists():
|
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
|
logger.info(f"Mounted static files from: {STATIC_DIR}")
|
|
else:
|
|
logger.warning(f"Static directory not found at {STATIC_DIR}")
|
|
|
|
# Mount uploads directory for user-uploaded media files
|
|
UPLOADS_DIR = BASE_DIR / "uploads"
|
|
if UPLOADS_DIR.exists():
|
|
app.mount("/uploads", StaticFiles(directory=str(UPLOADS_DIR)), name="uploads")
|
|
logger.info(f"Mounted uploads from: {UPLOADS_DIR}")
|
|
else:
|
|
# Create uploads directory if it doesn't exist
|
|
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
|
app.mount("/uploads", StaticFiles(directory=str(UPLOADS_DIR)), name="uploads")
|
|
logger.info(f"Created and mounted uploads directory: {UPLOADS_DIR}")
|
|
# ========================================
|
|
|
|
# Include API router (JSON endpoints at /api/*)
|
|
app.include_router(api_router, prefix="/api")
|
|
|
|
# ============================================================================
|
|
# FAVICON ROUTES (Must be registered BEFORE page routers)
|
|
# ============================================================================
|
|
|
|
|
|
def serve_favicon() -> Response:
|
|
"""
|
|
Serve favicon with caching headers.
|
|
Checks multiple possible locations for the favicon.
|
|
"""
|
|
possible_paths = [
|
|
STATIC_DIR / "favicon.ico",
|
|
STATIC_DIR / "images" / "favicon.ico",
|
|
STATIC_DIR / "assets" / "favicon.ico",
|
|
]
|
|
|
|
for favicon_path in possible_paths:
|
|
if favicon_path.exists():
|
|
return FileResponse(
|
|
favicon_path,
|
|
media_type="image/x-icon",
|
|
headers={
|
|
"Cache-Control": "public, max-age=86400", # Cache for 1 day
|
|
},
|
|
)
|
|
|
|
return Response(status_code=204)
|
|
|
|
|
|
@app.get("/favicon.ico", include_in_schema=False)
|
|
async def favicon():
|
|
"""Serve favicon from root path."""
|
|
return serve_favicon()
|
|
|
|
|
|
@app.get("/vendor/favicon.ico", include_in_schema=False)
|
|
async def vendor_favicon():
|
|
"""Handle vendor-prefixed favicon requests."""
|
|
return serve_favicon()
|
|
|
|
|
|
# ============================================================================
|
|
# HEALTH CHECK (Must be before catch-all routes)
|
|
# ============================================================================
|
|
|
|
|
|
@app.get("/health")
|
|
def health_check(db: Session = Depends(get_db)):
|
|
"""Health check endpoint"""
|
|
try:
|
|
# Test database connection
|
|
db.execute(text("SELECT 1"))
|
|
return {
|
|
"status": "healthy",
|
|
"timestamp": datetime.now(UTC),
|
|
"message": f"{settings.project_name} v{settings.version}",
|
|
"docs": {
|
|
"swagger": "/docs",
|
|
"redoc": "/redoc",
|
|
"openapi": "/openapi.json",
|
|
"complete": "/documentation",
|
|
},
|
|
"features": [
|
|
"Multi-tenant architecture with vendor isolation",
|
|
"JWT Authentication with role-based access control",
|
|
"Marketplace product import and curation",
|
|
"Vendor catalog management",
|
|
"Product-based inventory tracking",
|
|
"Stripe Connect payment processing",
|
|
],
|
|
"supported_marketplaces": [
|
|
"Letzshop",
|
|
],
|
|
"deployment_modes": [
|
|
"Subdomain-based (production): vendor.platform.com",
|
|
"Custom domain (production): customvendordomain.com",
|
|
"Path-based (development): /vendors/vendorname/ or /vendor/vendorname/",
|
|
],
|
|
"auth_required": "Most endpoints require Bearer token authentication",
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Health check failed: {e}")
|
|
raise ServiceUnavailableException("Service unhealthy")
|
|
|
|
|
|
# ============================================================================
|
|
# HTML PAGE ROUTES (Jinja2 Templates) - AUTO-DISCOVERED FROM MODULES
|
|
# ============================================================================
|
|
# All page routes are now auto-discovered from self-contained modules.
|
|
# Routes are discovered from app/modules/*/routes/pages/{admin,vendor,public,storefront}.py
|
|
|
|
logger.info("=" * 80)
|
|
logger.info("ROUTE REGISTRATION (AUTO-DISCOVERY)")
|
|
logger.info("=" * 80)
|
|
|
|
# =============================================================================
|
|
# PLATFORM PAGES (Marketing pages - homepage, pricing, signup, etc.)
|
|
# =============================================================================
|
|
# Platform pages are served at root level (/) for platform marketing
|
|
logger.info("Auto-discovering platform (marketing) page routes...")
|
|
platform_page_routes = get_platform_page_routes()
|
|
logger.info(f" Found {len(platform_page_routes)} platform page route modules")
|
|
|
|
for route_info in platform_page_routes:
|
|
logger.info(f" Registering {route_info.module_code} platform pages (priority={route_info.priority})")
|
|
app.include_router(
|
|
route_info.router,
|
|
prefix="", # Platform pages at root
|
|
tags=route_info.tags,
|
|
include_in_schema=route_info.include_in_schema,
|
|
)
|
|
|
|
# =============================================================================
|
|
# ADMIN PAGES
|
|
# =============================================================================
|
|
logger.info("Auto-discovering admin page routes...")
|
|
admin_page_routes = get_admin_page_routes()
|
|
logger.info(f" Found {len(admin_page_routes)} admin page route modules")
|
|
|
|
for route_info in admin_page_routes:
|
|
logger.info(f" Registering {route_info.module_code} admin pages")
|
|
app.include_router(
|
|
route_info.router,
|
|
prefix="/admin",
|
|
tags=route_info.tags,
|
|
include_in_schema=route_info.include_in_schema,
|
|
)
|
|
|
|
# =============================================================================
|
|
# VENDOR PAGES
|
|
# =============================================================================
|
|
logger.info("Auto-discovering vendor page routes...")
|
|
vendor_page_routes = get_vendor_page_routes()
|
|
logger.info(f" Found {len(vendor_page_routes)} vendor page route modules")
|
|
|
|
for route_info in vendor_page_routes:
|
|
logger.info(f" Registering {route_info.module_code} vendor pages (priority={route_info.priority})")
|
|
app.include_router(
|
|
route_info.router,
|
|
prefix="/vendor",
|
|
tags=route_info.tags,
|
|
include_in_schema=route_info.include_in_schema,
|
|
)
|
|
|
|
# =============================================================================
|
|
# STOREFRONT PAGES (Customer Shop)
|
|
# =============================================================================
|
|
# Customer shop pages - Register at TWO prefixes:
|
|
# 1. /storefront/* (for subdomain/custom domain modes)
|
|
# 2. /vendors/{code}/storefront/* (for path-based development mode)
|
|
logger.info("Auto-discovering storefront page routes...")
|
|
storefront_page_routes = get_storefront_page_routes()
|
|
logger.info(f" Found {len(storefront_page_routes)} storefront page route modules")
|
|
|
|
# Register at /storefront/* (direct access)
|
|
logger.info(" Registering storefront routes at /storefront/*")
|
|
for route_info in storefront_page_routes:
|
|
logger.info(f" - {route_info.module_code} (priority={route_info.priority})")
|
|
app.include_router(
|
|
route_info.router,
|
|
prefix="/storefront",
|
|
tags=["storefront-pages"],
|
|
include_in_schema=False,
|
|
)
|
|
|
|
# Register at /vendors/{code}/storefront/* (path-based development mode)
|
|
logger.info(" Registering storefront routes at /vendors/{code}/storefront/*")
|
|
for route_info in storefront_page_routes:
|
|
app.include_router(
|
|
route_info.router,
|
|
prefix="/vendors/{vendor_code}/storefront",
|
|
tags=["storefront-pages"],
|
|
include_in_schema=False,
|
|
)
|
|
|
|
|
|
# Add handler for /vendors/{vendor_code}/ root path
|
|
@app.get(
|
|
"/vendors/{vendor_code}/", response_class=HTMLResponse, include_in_schema=False
|
|
)
|
|
async def vendor_root_path(
|
|
vendor_code: str, request: Request, db: Session = Depends(get_db)
|
|
):
|
|
"""Handle vendor root path (e.g., /vendors/wizamart/)"""
|
|
# Vendor should already be in request.state from middleware
|
|
vendor = getattr(request.state, "vendor", None)
|
|
platform = getattr(request.state, "platform", None)
|
|
|
|
if not vendor:
|
|
raise HTTPException(status_code=404, detail=f"Vendor '{vendor_code}' not found")
|
|
|
|
from app.modules.core.utils.page_context import get_storefront_context
|
|
from app.modules.cms.services import content_page_service
|
|
|
|
# Get platform_id (use platform from context or default to 1 for OMS)
|
|
platform_id = platform.id if platform else 1
|
|
|
|
# Try to find landing page (with three-tier resolution)
|
|
landing_page = content_page_service.get_page_for_vendor(
|
|
db, platform_id=platform_id, slug="landing", vendor_id=vendor.id, include_unpublished=False
|
|
)
|
|
|
|
if not landing_page:
|
|
landing_page = content_page_service.get_page_for_vendor(
|
|
db, platform_id=platform_id, slug="home", vendor_id=vendor.id, include_unpublished=False
|
|
)
|
|
|
|
if landing_page:
|
|
# Render landing page with selected template
|
|
template_name = landing_page.template or "default"
|
|
template_path = f"vendor/landing-{template_name}.html"
|
|
|
|
return templates.TemplateResponse(
|
|
template_path, get_storefront_context(request, db=db, page=landing_page)
|
|
)
|
|
# No landing page - redirect to shop
|
|
return RedirectResponse(url=f"/vendors/{vendor_code}/storefront/", status_code=302)
|
|
|
|
|
|
# ============================================================================
|
|
# PLATFORM ROUTING (via PlatformContextMiddleware)
|
|
#
|
|
# The PlatformContextMiddleware handles all platform routing by rewriting paths:
|
|
# - /platforms/oms/ → rewrites to / (served by platform_pages router)
|
|
# - /platforms/oms/pricing → rewrites to /pricing (served by platform_pages router)
|
|
# - /platforms/loyalty/ → rewrites to / (served by platform_pages router)
|
|
#
|
|
# The middleware also sets request.state.platform so handlers know which
|
|
# platform's content to serve. All platform page routes are defined in
|
|
# app/routes/platform_pages.py which is included above.
|
|
#
|
|
# URL Structure (Development - localhost:9999):
|
|
# - / → Main marketing site ('main' platform)
|
|
# - /about → Main marketing site about page
|
|
# - /platforms/oms/ → OMS platform homepage
|
|
# - /platforms/oms/pricing → OMS platform pricing page
|
|
# - /platforms/loyalty/ → Loyalty platform homepage
|
|
#
|
|
# URL Structure (Production - domain-based):
|
|
# - wizamart.lu/ → Main marketing site
|
|
# - oms.lu/ → OMS platform homepage
|
|
# - loyalty.lu/ → Loyalty platform homepage
|
|
# ============================================================================
|
|
|
|
|
|
logger.info("=" * 80)
|
|
|
|
# Log all registered routes
|
|
logger.info("=" * 80)
|
|
logger.info("REGISTERED ROUTES SUMMARY")
|
|
logger.info("=" * 80)
|
|
for route in app.routes:
|
|
if hasattr(route, "methods") and hasattr(route, "path"):
|
|
methods = ", ".join(route.methods) if route.methods else "N/A"
|
|
logger.info(f" {methods:<10} {route.path:<60}")
|
|
logger.info("=" * 80)
|
|
|
|
@app.get("/documentation", response_class=HTMLResponse, include_in_schema=False)
|
|
async def documentation():
|
|
"""Redirect to documentation"""
|
|
if settings.debug:
|
|
return RedirectResponse(url="http://localhost:8001")
|
|
return RedirectResponse(url=settings.documentation_url)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
|
|
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|