Files
orion/main.py
Samir Boulahtit ff5b395cdd feat: add Sentry, Cloudflare R2, and CloudFlare CDN integrations
Production quick wins for improved observability and scalability:

Sentry Error Tracking:
- Add sentry-sdk[fastapi] dependency
- Initialize Sentry in main.py with FastAPI/SQLAlchemy integrations
- Add Celery integration for background task error tracking
- Feature-flagged via SENTRY_DSN (disabled when empty)

Cloudflare R2 Storage:
- Add boto3 dependency for S3-compatible API
- Create storage_service.py with StorageBackend abstraction
- LocalStorageBackend for development (default)
- R2StorageBackend for production cloud storage
- Feature-flagged via STORAGE_BACKEND setting

CloudFlare CDN/Proxy:
- Create middleware/cloudflare.py for CF header handling
- Extract real client IP from CF-Connecting-IP
- Support CF-IPCountry for geo features
- Feature-flagged via CLOUDFLARE_ENABLED setting

Documentation:
- Add docs/deployment/cloudflare.md setup guide
- Update infrastructure.md with dev vs prod requirements
- Add enterprise upgrade checklist for scaling beyond 1000 users
- Update installation.md with new environment variables

All features are optional and disabled by default for development.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 19:44:59 +01:00

575 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
# 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)