loreal-utilisation-dept/backend/app/config.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

89 lines
2.8 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 = ""
AUTH_MODE: Literal["local", "azure"] = "local"
DEV_AUTH_BYPASS: bool = False
# 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) — Zoho exports rarely exceed 20 MB.
MAX_UPLOAD_BYTES: int = 20 * 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()