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>
89 lines
2.8 KiB
Python
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()
|