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>
109 lines
3.7 KiB
Python
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()
|