131 lines
5.6 KiB
Python
131 lines
5.6 KiB
Python
"""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)
|