"""Application configuration (env-overridable). Data egress: none, with ONE deliberate exception — the exchange-rate fetch (services/fx_service.py) calls the configured FX provider (Frankfurter by default) with currency codes and dates only. No financial figures, filenames, or transaction data ever leave the server.""" from __future__ import annotations import os from pathlib import Path from urllib.parse import quote_plus from dotenv import load_dotenv # Load ar-aging-app/.env (or cwd) before reading settings. _APP_ROOT = Path(__file__).resolve().parent.parent.parent # .../ar-aging-app load_dotenv(_APP_ROOT / ".env") load_dotenv() # also allow cwd overrides BASE_DIR = Path(__file__).resolve().parent.parent # .../backend DATA_DIR = Path(os.environ.get("AR_DATA_DIR", str(BASE_DIR / "data"))) UPLOAD_DIR = DATA_DIR / "uploads" EXPORT_DIR = DATA_DIR / "exports" # --------------------------------------------------------------------------- database # The store is switchable so the app is never blocked on infrastructure: # AR_DB_BACKEND=sqlite a single local file — zero setup, good for a laptop or a demo # AR_DB_BACKEND=mysql the shared server, for real multi-user month-end work # Unset, it picks MySQL when a real MYSQL_HOST is configured and SQLite otherwise, so a # machine with no database installed still runs instead of failing at import. _PLACEHOLDER_HOSTS = {"", "your-mysql-host.example.com", "changeme", "todo"} SQLITE_PATH = Path(os.environ.get("AR_SQLITE_PATH", str(DATA_DIR / "ar_aging.db"))) MYSQL_HOST = os.environ.get("MYSQL_HOST", "") MYSQL_PORT = int(os.environ.get("MYSQL_PORT", "3306")) MYSQL_USER = os.environ.get("MYSQL_USER", "") MYSQL_PASSWORD = os.environ.get("MYSQL_PASSWORD", "") MYSQL_DATABASE = os.environ.get("MYSQL_DATABASE", "") MYSQL_SLOW_QUERY_MS = int(os.environ.get("MYSQL_SLOW_QUERY_MS", "500")) MYSQL_POOL_SIZE = int(os.environ.get("MYSQL_POOL_SIZE", "10")) MYSQL_POOL_RECYCLE = int(os.environ.get("MYSQL_POOL_RECYCLE", "3600")) def _mysql_configured() -> bool: return (MYSQL_HOST.strip().lower() not in _PLACEHOLDER_HOSTS and bool(MYSQL_USER) and bool(MYSQL_DATABASE)) DB_BACKEND = (os.environ.get("AR_DB_BACKEND") or ("mysql" if _mysql_configured() else "sqlite")).strip().lower() if DB_BACKEND not in ("sqlite", "mysql"): raise RuntimeError(f"AR_DB_BACKEND must be 'sqlite' or 'mysql' (got {DB_BACKEND!r}).") def mysql_url() -> str: if not _mysql_configured(): raise RuntimeError( "MYSQL_HOST, MYSQL_USER, and MYSQL_DATABASE are required for the mysql " "backend. Copy .env.example to .env and fill in real credentials, or set " "AR_DB_BACKEND=sqlite to use a local file." ) user = quote_plus(MYSQL_USER) password = quote_plus(MYSQL_PASSWORD) return ( f"mysql+pymysql://{user}:{password}" f"@{MYSQL_HOST}:{MYSQL_PORT}/{MYSQL_DATABASE}" f"?charset=utf8mb4" ) def database_url() -> str: if DB_BACKEND == "mysql": return mysql_url() SQLITE_PATH.parent.mkdir(parents=True, exist_ok=True) return f"sqlite:///{SQLITE_PATH}" def database_label() -> str: """Human-readable target, for logs and the launcher — never includes the password.""" if DB_BACKEND == "mysql": return f"MySQL {MYSQL_USER}@{MYSQL_HOST}:{MYSQL_PORT}/{MYSQL_DATABASE}" return f"SQLite {SQLITE_PATH}" # Retention: temp uploads/exports older than this are purged (0 = keep forever). RETENTION_DAYS = int(os.environ.get("AR_RETENTION_DAYS", "30")) # Defaults DEFAULT_CLEARING_LAG_DAYS = int(os.environ.get("AR_CLEARING_LAG_DAYS", "2")) DEFAULT_TOLERANCE = float(os.environ.get("AR_TOLERANCE", "0.01")) MAX_UPLOAD_BYTES = int(os.environ.get("AR_MAX_UPLOAD_BYTES", str(2 * 1024 * 1024 * 1024))) # 2 GB ALLOWED_EXTENSIONS = {".xlsx", ".xls", ".csv"} # CORS (frontend dev server) CORS_ORIGINS = os.environ.get( "AR_CORS_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173" ).split(",") # --------------------------------------------------------------------------- auth # AR_AUTH: on | off | auto (default). # auto — login is required as soon as at least one user exists (create users with # `python manage.py add-user`), and the API is open while there are none. # A fresh dev checkout and the test suite therefore run without ceremony, # while creating the first real user turns authentication on by itself. # on — login is always required (production; set it in .env.production). # off — never required (explicit opt-out; never use on a reachable server). AUTH_MODE = os.environ.get("AR_AUTH", "auto").strip().lower() if AUTH_MODE not in ("on", "off", "auto"): raise RuntimeError(f"AR_AUTH must be 'on', 'off' or 'auto' (got {AUTH_MODE!r}).") # Signs login tokens. REQUIRED in production — without it a random ephemeral key is used # and every restart logs everyone out (fine for a laptop, wrong for a server). SECRET_KEY = os.environ.get("AR_SECRET_KEY", "") # Token lifetime (hours). AUTH_TOKEN_HOURS = int(os.environ.get("AR_AUTH_TOKEN_HOURS", "12")) # --------------------------------------------------------------------------- FX provider # frankfurter (default; free, keyless, central-bank rates) | exchangerate-api (paid, needs # FX_API_KEY). Rates fetched are suggestions: Control C5 still requires a human to confirm # them for the reporting month before the close can publish. FX_PROVIDER = os.environ.get("AR_FX_PROVIDER", "frankfurter").strip().lower() FX_API_KEY = os.environ.get("AR_FX_API_KEY", "") FX_TIMEOUT_S = float(os.environ.get("AR_FX_TIMEOUT_S", "15")) def ensure_dirs() -> None: for d in (DATA_DIR, UPLOAD_DIR, EXPORT_DIR): d.mkdir(parents=True, exist_ok=True)