loreal-utilisation-dept/backend/app/main.py
DJP 04edbfdd2c Initial commit: dockerised FastAPI backend + React/Vite frontend rewrite
Replaces a static SPA that shipped an Airtable PAT in the JS bundle.
The new architecture holds all secrets server-side, fronts the app
behind Apache on optical-dev with the shared-vhost split-build pattern,
and is designed for a later Azure AD/MSAL swap-in.

- backend/   FastAPI + uvicorn, local auth (Azure AD stub), Airtable
             proxy with TTL cache, Zoho .xlsx/.csv parser, merge
             service for utilisation summaries. 28 pytest tests.
- frontend/  React + Vite + TS + Tailwind + Recharts SPA. Login entry
             chunk 12.83 KB gzipped; Recharts lazy-loaded. No tokens
             or Airtable URLs in the built bundle.
- deploy/    Idempotent deploy.sh (port auto-pick 8200-8299,
             .env-persisted) + split-build Apache include template.
- docker-compose.yml pins name: utilisation-dept and binds 127.0.0.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:37:04 -04:00

109 lines
3.7 KiB
Python

"""FastAPI app entrypoint.
Decisions:
- All routers already mount under /api/... so we attach them at app root.
- A small Starlette middleware enforces the 20 MB Content-Length cap for
multipart bodies before they hit any route handler.
- CORS is only enabled when configured (or when DEV_AUTH_BYPASS=true,
which adds the Vite dev origin). In prod the SPA is same-origin via Apache.
- slowapi state and exception handler are wired here.
- Lifespan hook closes the shared httpx client cleanly.
"""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp
from app import __version__
from app.config import settings
from app.deps.airtable import airtable_client
from app.routers import airtable as airtable_router
from app.routers import auth as auth_router
from app.routers import health as health_router
from app.routers import timelog as timelog_router
from app.routers import utilisation as utilisation_router
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(asctime)sZ %(levelname)s %(name)s: %(message)s")
class ContentLengthLimitMiddleware(BaseHTTPMiddleware):
"""Reject requests whose Content-Length exceeds settings.MAX_UPLOAD_BYTES."""
def __init__(self, app: ASGIApp, max_bytes: int) -> None:
super().__init__(app)
self.max_bytes = max_bytes
async def dispatch(self, request: Request, call_next):
cl = request.headers.get("content-length")
if cl is not None:
try:
if int(cl) > self.max_bytes:
return JSONResponse({"detail": "Payload too large"}, status_code=413)
except ValueError:
pass
return await call_next(request)
@asynccontextmanager
async def lifespan(app: FastAPI):
if settings.DEV_AUTH_BYPASS:
logger.warning("DEV_AUTH_BYPASS=true — auth is disabled. DO NOT use in production.")
logger.info("utilisation-dept backend v%s starting (AUTH_MODE=%s)", __version__, settings.AUTH_MODE)
yield
await airtable_client.aclose()
def create_app() -> FastAPI:
app = FastAPI(
title="utilisation-dept",
version=__version__,
lifespan=lifespan,
# Docs at /api/docs so Apache routes /utilisation-dept/api/docs there.
docs_url="/api/docs",
redoc_url=None,
openapi_url="/api/openapi.json",
)
# CORS — only when we have origins. Same-origin in prod via Apache.
origins = settings.cors_origins
if origins:
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(ContentLengthLimitMiddleware, max_bytes=settings.MAX_UPLOAD_BYTES)
# slowapi wiring — limiter instance defined in auth router.
app.state.limiter = auth_router.limiter
app.add_middleware(SlowAPIMiddleware)
@app.exception_handler(RateLimitExceeded)
async def _rate_limit_handler(request: Request, exc: RateLimitExceeded):
return JSONResponse({"detail": "Too many requests"}, status_code=429)
# Routers
app.include_router(health_router.router)
app.include_router(auth_router.router)
app.include_router(airtable_router.router)
app.include_router(timelog_router.router)
app.include_router(utilisation_router.router)
return app
app = create_app()