# 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 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 sqlalchemy import text from sqlalchemy.orm import Session from app.api.main import api_router from app.core.config import settings 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, shop_pages, vendor_pages 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}") # ======================================== # 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) # 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 ) 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}") return templates.TemplateResponse( template_path, { "request": request, "page": homepage, "header_pages": header_pages, "footer_pages": footer_pages, }, ) # Fallback to default static template logger.info("[PLATFORM] No CMS homepage found, using default template") return templates.TemplateResponse( "platform/homepage-default.html", { "request": request, "header_pages": header_pages, "footer_pages": footer_pages, }, ) @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})") return templates.TemplateResponse( "platform/content-page.html", { "request": request, "page": page, "header_pages": header_pages, "footer_pages": footer_pages, }, ) 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)