Complete Non-Conformance Report system replacing the PowerApps/SharePoint prototype: FastAPI + SQLAlchemy 2 (async) + Alembic + MySQL 8 backend, React 18 + Vite + TypeScript + MUI frontend, Entra ID auth (MSAL / JWKS, group-gated), Microsoft Graph delegated Mail.Send notifications (OBO), six-stage workflow state machine with server-side enforcement, atomic NCR-YYYY-NNNN numbering, attachments with camera capture, immutable field-level audit trail, admin reopen, reports + CSV export, WeasyPrint PDF traveler, Power BI reporting views + read-only DB user, documented VISUAL ERP job-lookup stub, pytest suite (26 tests), docker-compose deployment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
89 lines
3.5 KiB
Python
89 lines
3.5 KiB
Python
"""Application configuration, sourced from environment variables (see .env.example)."""
|
|
from functools import lru_cache
|
|
from typing import Literal
|
|
from urllib.parse import quote_plus
|
|
|
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
|
|
|
|
class Settings(BaseSettings):
|
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
|
|
|
app_name: str = "PESCO NCR"
|
|
app_base_url: str = "http://localhost:8080"
|
|
log_level: str = "INFO"
|
|
|
|
# ── Auth ────────────────────────────────────────────────────────────────
|
|
# "entra" validates Entra ID JWTs; "dev" trusts an X-Dev-User header and
|
|
# must never be used outside local development.
|
|
auth_mode: Literal["entra", "dev"] = "entra"
|
|
entra_tenant_id: str = ""
|
|
entra_client_id: str = ""
|
|
entra_client_secret: str = ""
|
|
entra_allowed_group_id: str = ""
|
|
entra_api_audience: str = ""
|
|
initial_admin_emails: str = ""
|
|
|
|
# ── Database ────────────────────────────────────────────────────────────
|
|
# Full SQLAlchemy URL override (used by tests); otherwise assembled from
|
|
# the MYSQL_* parts below.
|
|
database_url: str = ""
|
|
mysql_host: str = "mysql"
|
|
mysql_port: int = 3306
|
|
mysql_database: str = "pesco_ncr"
|
|
mysql_user: str = "ncr_app"
|
|
mysql_password: str = ""
|
|
|
|
# ── Attachments ─────────────────────────────────────────────────────────
|
|
attachments_dir: str = "/data/attachments"
|
|
max_upload_mb: int = 25
|
|
|
|
# ── Notifications ───────────────────────────────────────────────────────
|
|
notifications_enabled_default: bool = True
|
|
|
|
# ── Job lookup (future VISUAL integration) ──────────────────────────────
|
|
job_lookup_provider: Literal["null", "visual"] = "null"
|
|
visual_db_host: str = ""
|
|
visual_db_port: int = 1433
|
|
visual_db_name: str = ""
|
|
visual_db_user: str = ""
|
|
visual_db_password: str = ""
|
|
visual_site_id: str = ""
|
|
|
|
seed_demo_data: bool = False
|
|
|
|
@property
|
|
def effective_database_url(self) -> str:
|
|
if self.database_url:
|
|
return self.database_url
|
|
return (
|
|
f"mysql+aiomysql://{quote_plus(self.mysql_user)}:{quote_plus(self.mysql_password)}"
|
|
f"@{self.mysql_host}:{self.mysql_port}/{self.mysql_database}?charset=utf8mb4"
|
|
)
|
|
|
|
@property
|
|
def sync_database_url(self) -> str:
|
|
"""Synchronous-driver URL for Alembic."""
|
|
return self.effective_database_url.replace("+aiomysql", "+pymysql").replace(
|
|
"+aiosqlite", ""
|
|
)
|
|
|
|
@property
|
|
def api_audiences(self) -> list[str]:
|
|
if self.entra_api_audience:
|
|
return [self.entra_api_audience]
|
|
return [f"api://{self.entra_client_id}", self.entra_client_id]
|
|
|
|
@property
|
|
def initial_admin_email_set(self) -> set[str]:
|
|
return {e.strip().lower() for e in self.initial_admin_emails.split(",") if e.strip()}
|
|
|
|
@property
|
|
def max_upload_bytes(self) -> int:
|
|
return self.max_upload_mb * 1024 * 1024
|
|
|
|
|
|
@lru_cache
|
|
def get_settings() -> Settings:
|
|
return Settings()
|