# 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 from app.routes import admin_pages, platform_pages, shop_pages, vendor_pages 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.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 templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) # 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. VendorContextMiddleware (detect vendor, extract clean_path) # 2. ContextMiddleware (detect context using clean_path) # 3. LanguageMiddleware (detect language based on context) # 4. ThemeContextMiddleware (load theme) # 5. LoggingMiddleware (log all requests) # # Therefore we add them in REVERSE: # - Add ThemeContextMiddleware FIRST (runs LAST in request) # - Add LanguageMiddleware SECOND (runs after context) # - Add ContextMiddleware THIRD # - Add VendorContextMiddleware FOURTH # - 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 first in request chain) logger.info("Adding VendorContextMiddleware (detects vendor, extracts clean_path)") app.add_middleware(VendorContextMiddleware) logger.info("=" * 80) logger.info("MIDDLEWARE ORDER SUMMARY:") logger.info(" Execution order (request →):") logger.info(" 1. LoggingMiddleware (timing)") logger.info(" 2. VendorContextMiddleware (vendor detection)") logger.info(" 3. ContextMiddleware (context detection)") logger.info(" 4. LanguageMiddleware (language detection)") logger.info(" 5. ThemeContextMiddleware (theme loading)") logger.info(" 6. FastAPI Router") logger.info("=" * 80) # ======================================== # MOUNT STATIC FILES - Use absolute path # ======================================== 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 ) # Vendor management pages (dashboard, products, orders, etc.) logger.info("Registering vendor page routes: /vendor/{code}/*") app.include_router( vendor_pages.router, prefix="/vendor", tags=["vendor-pages"], include_in_schema=False, ) # Customer shop pages - Register at TWO prefixes: # 1. /shop/* (for subdomain/custom domain modes) # 2. /vendors/{code}/shop/* (for path-based development mode) logger.info("Registering shop page routes:") logger.info(" - /shop/* (subdomain/custom domain mode)") logger.info(" - /vendors/{code}/shop/* (path-based development mode)") app.include_router( shop_pages.router, prefix="/shop", tags=["shop-pages"], include_in_schema=False ) app.include_router( shop_pages.router, prefix="/vendors/{vendor_code}/shop", tags=["shop-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) if not vendor: raise HTTPException(status_code=404, detail=f"Vendor '{vendor_code}' not found") from app.routes.shop_pages import get_shop_context from app.services.content_page_service import content_page_service # Try to find landing page landing_page = content_page_service.get_page_for_vendor( db, slug="landing", vendor_id=vendor.id, include_unpublished=False ) if not landing_page: landing_page = content_page_service.get_page_for_vendor( db, 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_shop_context(request, db=db, page=landing_page) ) # No landing page - redirect to shop return RedirectResponse(url=f"/vendors/{vendor_code}/shop/", status_code=302) # ============================================================================ # PLATFORM PUBLIC PAGES (Platform Homepage, About, FAQ, etc.) # ============================================================================ logger.info("Registering platform public page routes:") logger.info(" - / (platform homepage)") logger.info(" - /{slug} (platform content pages: /about, /faq, /terms, /contact)") @app.get("/", response_class=HTMLResponse, include_in_schema=False) async def platform_homepage(request: Request, db: Session = Depends(get_db)): """ Platform homepage at localhost:8000 or platform.com Looks for CMS page with slug='platform_homepage' (vendor_id=NULL) Falls back to default static template if not found. """ from app.services.content_page_service import content_page_service logger.debug("[PLATFORM] Homepage requested") # Try to load platform homepage from CMS homepage = content_page_service.get_page_for_vendor( db, slug="platform_homepage", vendor_id=None, # Platform-level page include_unpublished=False, ) # Load header and footer navigation header_pages = content_page_service.list_pages_for_vendor( db, vendor_id=None, header_only=True, include_unpublished=False ) footer_pages = content_page_service.list_pages_for_vendor( db, vendor_id=None, footer_only=True, include_unpublished=False ) # Get language from request state and build i18n context language = getattr(request.state, "language", "fr") i18n_globals = get_jinja2_globals(language) if homepage: # Use template selection from CMS template_name = homepage.template or "default" template_path = f"platform/homepage-{template_name}.html" logger.info(f"[PLATFORM] Rendering CMS homepage with template: {template_path}") context = { "request": request, "page": homepage, "header_pages": header_pages, "footer_pages": footer_pages, } context.update(i18n_globals) return templates.TemplateResponse(template_path, context) # Fallback to default static template logger.info("[PLATFORM] No CMS homepage found, using default template") context = { "request": request, "header_pages": header_pages, "footer_pages": footer_pages, } context.update(i18n_globals) return templates.TemplateResponse("platform/homepage-default.html", context) @app.get("/{slug}", response_class=HTMLResponse, include_in_schema=False) async def platform_content_page( request: Request, slug: str, db: Session = Depends(get_db) ): """ Platform content pages: /about, /faq, /terms, /contact, etc. Loads content from CMS with slug (vendor_id=NULL for platform pages). Returns 404 if page not found. This route MUST be defined LAST to avoid conflicts with other routes. """ from app.services.content_page_service import content_page_service logger.debug(f"[PLATFORM] Content page requested: /{slug}") # Load page from CMS page = content_page_service.get_page_for_vendor( db, slug=slug, vendor_id=None, include_unpublished=False, # Platform pages only ) if not page: logger.warning(f"[PLATFORM] Content page not found: {slug}") raise HTTPException(status_code=404, detail=f"Page not found: {slug}") # Load header and footer navigation header_pages = content_page_service.list_pages_for_vendor( db, vendor_id=None, header_only=True, include_unpublished=False ) footer_pages = content_page_service.list_pages_for_vendor( db, vendor_id=None, footer_only=True, include_unpublished=False ) logger.info(f"[PLATFORM] Rendering content page: {page.title} (/{slug})") # Get language from request state and build i18n context language = getattr(request.state, "language", "fr") i18n_globals = get_jinja2_globals(language) context = { "request": request, "page": page, "header_pages": header_pages, "footer_pages": footer_pages, } context.update(i18n_globals) return templates.TemplateResponse("platform/content-page.html", context) 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) # ============================================================================ # API ROUTES (JSON Responses) # ============================================================================ # Public Routes (no authentication required) @app.get("/", response_class=HTMLResponse, include_in_schema=False) async def root(request: Request, db: Session = Depends(get_db)): """ Smart root handler: - If vendor detected (domain/subdomain): Show vendor landing page or redirect to shop - If no vendor (platform root): Redirect to documentation """ vendor = getattr(request.state, "vendor", None) if vendor: # Vendor context detected - serve landing page from app.services.content_page_service import content_page_service # Try to find landing page (slug='landing' or 'home') landing_page = content_page_service.get_page_for_vendor( db, slug="landing", vendor_id=vendor.id, include_unpublished=False ) if not landing_page: # Try 'home' slug as fallback landing_page = content_page_service.get_page_for_vendor( db, slug="home", vendor_id=vendor.id, include_unpublished=False ) if landing_page: # Render landing page with selected template from app.routes.shop_pages import get_shop_context template_name = landing_page.template or "default" template_path = f"vendor/landing-{template_name}.html" return templates.TemplateResponse( template_path, get_shop_context(request, db=db, page=landing_page) ) # No landing page - redirect to shop vendor_context = getattr(request.state, "vendor_context", None) access_method = ( vendor_context.get("detection_method", "unknown") if vendor_context else "unknown" ) if access_method == "path": full_prefix = ( vendor_context.get("full_prefix", "/vendor/") if vendor_context else "/vendor/" ) return RedirectResponse( url=f"{full_prefix}{vendor.subdomain}/shop/", status_code=302 ) # Domain/subdomain return RedirectResponse(url="/shop/", status_code=302) # No vendor - platform root return RedirectResponse(url="/documentation") @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)