loreal-utilisation-dept/backend/app/main.py
DJP 993e370cea feat: Forecast, Project Type Summary, Time Log Detail, AI Chat, filters v2, stats bar, RBAC
Brings the new app to full parity with the original L'Oréal SPA and
beyond. Backend 59/59 tests (was 40, +19). Frontend typecheck/lint/build
clean. Main entry chunk 15.76 KB gz (budget 30 KB).

Backend — new endpoints + services:
- POST /api/deliverable/parse        — parse Deliverable Summary CSV/XLSX
- POST /api/projectsummary/parse     — parse Project Summary CSV/XLSX
- GET  /api/timelog/rows             — paginated, searchable, sortable view
                                        over the parsed Zoho upload
- GET  /api/forecast                 — 4-week pipeline + capacity decision
- GET  /api/project-types            — hours/asset, duration, concentration
                                        per project type + auto-insights
- POST /api/chat                     — Claude API proxy. 503s gracefully
                                        when ANTHROPIC_API_KEY is unset.
                                        Prompt-cached system prompt;
                                        rate-limited 20/min/IP.
- GET  /api/auth/me now returns role.

Backend — services:
- zoho_parse.py: extracts ~20 fields (brand, division, hub, userRole,
  projectType, assetCount, projectStatus, project start/end dates,
  userAgency, employingCompany, sageJobProfile, …) with back-compat
  aliases so existing callers keep working.
- parse_store.py: in-process TTL-cached registry of parsed uploads keyed
  by content hash. Lets endpoints reference an upload without re-sending it.
- forecast.py: working-day overlap math, exit-rate, weekly throughput
  baseline, capacity decision string mirroring the original wording.
- project_types.py: per-type aggregation + concentration-risk insights.
- timelog_filters.py: server-side filter by brands/divisions/hubs/roles.
- ai_context.py: builds the dashboard context block fed to Claude.

Frontend — new pages + components:
- pages/Forecast.tsx                 — ComposedChart (stacked bars + line)
                                        + capacity-decision banner + table
- pages/ProjectTypeSummary.tsx       — sortable table + small trend chart
- pages/TimeLogDetail.tsx            — virtualised, searchable, sortable
                                        view over all parsed timelog rows
- components/ChatView.tsx            — floating side panel with Claude.
                                        6 preset prompts mirroring the
                                        original. Visible only for roles
                                        with chat access.
- components/ChatToggle.tsx          — bottom-right FAB.
- components/StatsBar.tsx            — always-visible: Time Entries /
                                        People / Projects / Total Hours /
                                        Date Range.
- hooks/useDataContext.tsx           — single source of truth for filter
                                        state + parsed upload + filter
                                        dimensions (brands/divs/hubs/
                                        roles derived from uploads).

Frontend — modified:
- App.tsx, Navbar.tsx                — 7 tabs + role gating per the
                                        original TAB_ACCESS matrix.
- hooks/useAuth.tsx                  — role + canAccess(tab).
- lib/filters.ts, FilterBar.tsx      — Brand / Division / Hub / Role
                                        multiselects added (additive — keep
                                        Department / Name / Billing).
- pages/Department, Resourcing,
  Bookings, Tutorial.tsx             — wired into DataContext; tutorial
                                        is now a single 9-step global tour
                                        mirroring the original's narrative.

Config:
- backend/.env.example: ADMIN_ROLE, ANTHROPIC_API_KEY, ANTHROPIC_MODEL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:40:03 -04:00

119 lines
4.2 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 chat as chat_router
from app.routers import deliverable as deliverable_router
from app.routers import forecast as forecast_router
from app.routers import health as health_router
from app.routers import project_summary as project_summary_router
from app.routers import project_types as project_types_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)
app.include_router(deliverable_router.router)
app.include_router(project_summary_router.router)
app.include_router(forecast_router.router)
app.include_router(project_types_router.router)
app.include_router(chat_router.router)
return app
app = create_app()