# 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 # Import page routers (legacy routes - will be migrated to modules) from app.routes import admin_pages, platform_pages, storefront_pages, vendor_pages # Module route auto-discovery from app.modules.routes import discover_module_routes, get_vendor_page_routes from app.utils.i18n import get_jinja2_globals from middleware.context import ContextMiddleware 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. ContextMiddleware (detect context using clean_path) # 4. LanguageMiddleware (detect language based on context) # 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 ContextMiddleware 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 context detection middleware (runs after vendor context extraction) logger.info("Adding ContextMiddleware (detects context type using clean_path)") app.add_middleware(ContextMiddleware) # 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. ContextMiddleware (context 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) 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_static = module_dir / "static" if module_static.exists(): module_name = module_dir.name 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) # ============================================================================ # Include HTML page routes (these return rendered templates, not JSON) logger.info("=" * 80) logger.info("ROUTE REGISTRATION") logger.info("=" * 80) # Platform marketing pages (homepage, pricing, signup) logger.info("Registering platform page routes: /*, /pricing, /find-shop, /signup") app.include_router( platform_pages.router, prefix="", tags=["platform-pages"], include_in_schema=False ) # Admin pages logger.info("Registering admin page routes: /admin/*") app.include_router( admin_pages.router, prefix="/admin", tags=["admin-pages"], include_in_schema=False ) # CMS module admin pages (self-contained module) # NOTE: These routes are specific (/content-pages/*) so they won't conflict logger.info("Registering CMS admin page routes: /admin/content-pages/*") app.include_router( cms_admin_pages, prefix="/admin", tags=["cms-admin-pages"], include_in_schema=False ) # Vendor management pages (dashboard, products, orders, etc.) # NOTE: Legacy routes - modules with their own routes will override these logger.info("Registering vendor page routes: /vendor/{code}/*") app.include_router( vendor_pages.router, prefix="/vendor", tags=["vendor-pages"], include_in_schema=False, ) # ============================================================================= # AUTO-DISCOVERED MODULE ROUTES # ============================================================================= # Self-contained modules register their routes automatically. # Routes are discovered from app/modules/*/routes/pages/ and routes/api/ # NOTE: CMS has catch-all route, so it's registered last via priority sorting logger.info("Auto-discovering module page routes...") vendor_page_routes = get_vendor_page_routes() # Sort routes: CMS last (has catch-all), others alphabetically def route_priority(route): if route.module_code == "cms": return (1, route.module_code) # CMS last return (0, route.module_code) vendor_page_routes.sort(key=route_priority) for route_info in vendor_page_routes: logger.info(f" Registering {route_info.module_code} vendor pages: {route_info.prefix}") app.include_router( route_info.router, prefix=route_info.prefix, tags=route_info.tags, include_in_schema=route_info.include_in_schema, ) # 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("Registering storefront page routes:") logger.info(" - /storefront/* (subdomain/custom domain mode)") logger.info(" - /vendors/{code}/storefront/* (path-based development mode)") app.include_router( storefront_pages.router, prefix="/storefront", tags=["storefront-pages"], include_in_schema=False ) app.include_router( storefront_pages.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.routes.storefront_pages 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)