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>
99 lines
3.3 KiB
Python
99 lines
3.3 KiB
Python
"""Application settings loaded from environment variables.
|
|
|
|
Decisions:
|
|
- Uses pydantic-settings v2 so values are validated at import-time. Missing
|
|
required fields raise at startup rather than producing a 500 later.
|
|
- All cache TTLs default to the values documented in the docker-compose file.
|
|
- AIRTABLE_PAT and SESSION_SECRET have empty defaults so that tests / local
|
|
dev can boot without them (auth bypass paths short-circuit the checks).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from functools import lru_cache
|
|
from typing import Literal
|
|
|
|
from pydantic import Field
|
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
|
|
|
|
class Settings(BaseSettings):
|
|
model_config = SettingsConfigDict(
|
|
env_file=None,
|
|
case_sensitive=True,
|
|
extra="ignore",
|
|
)
|
|
|
|
# Airtable
|
|
AIRTABLE_PAT: str = ""
|
|
AIRTABLE_BASE_ID: str = "appoByydxIQANKtSh"
|
|
# Table names — kept as constants here; could be promoted to env vars later.
|
|
AIRTABLE_TABLE_RESOURCES: str = "Resource"
|
|
AIRTABLE_TABLE_BOOKINGS: str = "Booking Resource"
|
|
|
|
# Session / auth
|
|
SESSION_SECRET: str = "dev-insecure-secret-change-me"
|
|
ADMIN_USERNAME: str = "admin"
|
|
ADMIN_PASSWORD_BCRYPT: str = ""
|
|
# Single role for the local-admin user. Tabs are gated on the
|
|
# frontend per this value. MSAL → Azure groups will replace this
|
|
# mapping when that lands; for v1 we keep it env-driven.
|
|
ADMIN_ROLE: Literal["global-lead", "dept-lead", "forecast"] = "global-lead"
|
|
AUTH_MODE: Literal["local", "azure"] = "local"
|
|
DEV_AUTH_BYPASS: bool = False
|
|
|
|
# Anthropic — empty by default so the /api/chat endpoint can return
|
|
# a helpful 503 telling the user to configure the key.
|
|
ANTHROPIC_API_KEY: str = ""
|
|
ANTHROPIC_MODEL: str = "claude-sonnet-4-6"
|
|
|
|
# Cache TTLs (seconds)
|
|
CACHE_TTL_RESOURCES: int = 600
|
|
CACHE_TTL_BOOKINGS: int = 60
|
|
CACHE_TTL_META: int = 600
|
|
|
|
# Azure (stubbed in v1)
|
|
AZURE_TENANT_ID: str = ""
|
|
AZURE_CLIENT_ID: str = ""
|
|
|
|
# App
|
|
APP_BASE_PATH: str = "/utilisation-dept"
|
|
# Override cookie path explicitly when needed (tests use "/" to bypass
|
|
# the Apache-stripped-prefix mismatch).
|
|
SESSION_COOKIE_PATH: str = ""
|
|
|
|
# CORS — comma separated origins. Empty in prod (same-origin via Apache).
|
|
CORS_ALLOWED_ORIGINS: str = ""
|
|
|
|
# Session cookie
|
|
SESSION_MAX_AGE: int = 60 * 60 * 8 # 8h
|
|
|
|
# Multipart upload limit (bytes). Default 100 MB — full-year Zoho time-log
|
|
# exports can run 30-60 MB, and we want headroom. Override via env var.
|
|
MAX_UPLOAD_BYTES: int = 100 * 1024 * 1024
|
|
|
|
@property
|
|
def cookie_path(self) -> str:
|
|
# Allow an explicit override for tests / unusual deploys.
|
|
if self.SESSION_COOKIE_PATH:
|
|
return self.SESSION_COOKIE_PATH
|
|
# itsdangerous cookie path; needs trailing slash to match the SPA mount.
|
|
p = self.APP_BASE_PATH.rstrip("/")
|
|
return f"{p}/" if p else "/"
|
|
|
|
@property
|
|
def cors_origins(self) -> list[str]:
|
|
if not self.CORS_ALLOWED_ORIGINS:
|
|
# Dev convenience: when bypass is on, allow Vite default.
|
|
if self.DEV_AUTH_BYPASS:
|
|
return ["http://localhost:5173"]
|
|
return []
|
|
return [o.strip() for o in self.CORS_ALLOWED_ORIGINS.split(",") if o.strip()]
|
|
|
|
|
|
@lru_cache
|
|
def get_settings() -> Settings:
|
|
return Settings()
|
|
|
|
|
|
settings = get_settings()
|