a2f357b3d3
Nuova impostazione app_env (env APP_ENV, default "dev"). All'istanza di Settings, se app_env non è "dev"/"test" e SERVER_SECRET_KEY o DB_PASSWORD sono ancora il valore committato, RuntimeError che nomina la variabile. Dev e test continuano a partire senza .env: il default "dev" non fa mai scattare la guardia. Le parti frontend (flask_app/config.py) e deploy (docker-compose.yml) di questa issue sono degli altri lotti (B2, C2). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
128 lines
4.8 KiB
Python
128 lines
4.8 KiB
Python
"""TieMeasureFlow Server Configuration."""
|
|
from pathlib import Path
|
|
|
|
from pydantic import model_validator
|
|
from pydantic_settings import BaseSettings
|
|
|
|
# Committed defaults. Fine for dev/test (SQLite in-memory, nothing exposed); a
|
|
# real deployment that still has these is running on secrets anyone can read
|
|
# in this file's git history.
|
|
_DEFAULT_SECRET_KEY = "change-this-to-a-random-secret-key"
|
|
_DEFAULT_DB_PASSWORD = "change_me_in_production"
|
|
|
|
|
|
class Settings(BaseSettings):
|
|
"""Application settings loaded from environment variables."""
|
|
|
|
# Deployment environment. "dev" and "test" skip the production-only guard
|
|
# below; anything else (e.g. "production") is treated as a real
|
|
# deployment. Local dev and the test suite must keep starting with no
|
|
# .env at all, which is why this defaults to "dev" rather than requiring
|
|
# the variable.
|
|
app_env: str = "dev"
|
|
|
|
# Database
|
|
db_host: str = "localhost"
|
|
db_port: int = 3306
|
|
db_name: str = "tiemeasureflow"
|
|
db_user: str = "tmflow"
|
|
db_password: str = _DEFAULT_DB_PASSWORD
|
|
|
|
# Server
|
|
server_host: str = "0.0.0.0"
|
|
server_port: int = 8000
|
|
server_secret_key: str = _DEFAULT_SECRET_KEY
|
|
server_cors_origins: str = "http://localhost:5000"
|
|
# Worker processes (e.g. `uvicorn --workers N`). Each rate-limit bucket lives
|
|
# in process memory, so this is how RateLimitMiddleware divides the
|
|
# configured limits to keep the cluster-wide effective limit close to what
|
|
# was configured instead of multiplying it by N.
|
|
web_concurrency: int = 1
|
|
# How many reverse proxies between the client and this API are trusted to
|
|
# have appended their own hop to X-Forwarded-For (Nginx, in the default
|
|
# deploy). Used by RateLimitMiddleware._client_ip. 0 disables proxy header
|
|
# parsing entirely (only request.client.host is trusted).
|
|
trusted_proxy_count: int = 1
|
|
|
|
# File Storage
|
|
upload_dir: str = "uploads"
|
|
max_upload_size_mb: int = 50
|
|
|
|
# Rate Limiting (requests per minute, per real client IP)
|
|
rate_limit_login: int = 5
|
|
rate_limit_general: int = 300
|
|
|
|
# SSL (Production)
|
|
ssl_certfile: str | None = None
|
|
ssl_keyfile: str | None = None
|
|
|
|
# Setup page (empty = disabled)
|
|
setup_password: str | None = None
|
|
# By default, once an admin user exists, setup's privileged endpoints
|
|
# (reset-db, seed, manage-user, change-user-password, init-db) refuse to
|
|
# run again. Set true for scripted re-provisioning of a demo/test deploy.
|
|
setup_allow_after_init: bool = False
|
|
|
|
# AI / OpenRouter (for technical sheet parsing)
|
|
openrouter_api_key: str | None = None
|
|
openrouter_model: str = "anthropic/claude-sonnet-4"
|
|
|
|
# Vision worker (internal network only, reachable as `vision` in Compose)
|
|
vision_worker_url: str = "http://vision:8100"
|
|
|
|
@property
|
|
def database_url(self) -> str:
|
|
"""Async MySQL connection string."""
|
|
return (
|
|
f"mysql+asyncmy://{self.db_user}:{self.db_password}"
|
|
f"@{self.db_host}:{self.db_port}/{self.db_name}"
|
|
)
|
|
|
|
@property
|
|
def cors_origins(self) -> list[str]:
|
|
"""Parse CORS origins from comma-separated string."""
|
|
return [origin.strip() for origin in self.server_cors_origins.split(",")]
|
|
|
|
@property
|
|
def upload_path(self) -> Path:
|
|
"""Absolute path to upload directory.
|
|
|
|
After the V2.0.0 restructure, uploads live at the project root
|
|
(mounted as a Docker volume), not inside the backend tree.
|
|
"""
|
|
# Path(__file__) = src/backend/config.py → parents[2] = project root
|
|
return Path(__file__).resolve().parents[2] / self.upload_dir
|
|
|
|
# Always resolve .env against the project root regardless of cwd
|
|
# (pydantic-settings would otherwise treat the path as cwd-relative).
|
|
model_config = {
|
|
"env_file": str(Path(__file__).resolve().parents[2] / ".env"),
|
|
"env_file_encoding": "utf-8",
|
|
"extra": "ignore",
|
|
}
|
|
|
|
@model_validator(mode="after")
|
|
def _refuse_default_secrets_outside_dev(self) -> "Settings":
|
|
"""Refuse to boot with a committed default secret in a real deployment.
|
|
|
|
Only fires when APP_ENV names an actual deployment (not "dev"/"test"),
|
|
so local development and the test suite keep starting with no .env at
|
|
all.
|
|
"""
|
|
if self.app_env in ("dev", "test"):
|
|
return self
|
|
if self.server_secret_key == _DEFAULT_SECRET_KEY:
|
|
raise RuntimeError(
|
|
"SERVER_SECRET_KEY is still the committed default value; "
|
|
"set it before running with APP_ENV=" + self.app_env
|
|
)
|
|
if self.db_password == _DEFAULT_DB_PASSWORD:
|
|
raise RuntimeError(
|
|
"DB_PASSWORD is still the committed default value; "
|
|
"set it before running with APP_ENV=" + self.app_env
|
|
)
|
|
return self
|
|
|
|
|
|
settings = Settings()
|