"""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 # Presentation time zone (DB is UTC, #42) display_timezone: str = "Europe/Rome" # 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 # API key session lifetime (hours); renewed on use (env API_KEY_TTL_HOURS). api_key_ttl_hours: int = 24 # 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()