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>
119 lines
4.2 KiB
Python
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()
|