Finance-Accounts/ar-aging-app/backend/app/config.py

154 lines
6.8 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"))
# --------------------------------------------------------------------------- email
# Used ONLY for password codes ("email me a code" on the login/Settings screens).
# Unset -> the email-code flow is hidden and passwords change via the current-password
# form (or manage.py set-password by the admin).
#
# Preferred transport: the company's internal Mail API (the same service the TikTok
# dashboard uses for its verification codes) — a bearer-token multipart POST.
MAIL_API_URL = os.environ.get("AR_MAIL_API_URL", "")
MAIL_API_TOKEN = os.environ.get("AR_MAIL_API_TOKEN", "")
# Fallback transport: any standard SMTP account
# (Office365: smtp.office365.com:587, Gmail: smtp.gmail.com:587 with an app password).
SMTP_HOST = os.environ.get("AR_SMTP_HOST", "")
SMTP_PORT = int(os.environ.get("AR_SMTP_PORT", "587"))
SMTP_USER = os.environ.get("AR_SMTP_USER", "")
SMTP_PASSWORD = os.environ.get("AR_SMTP_PASSWORD", "")
SMTP_FROM = os.environ.get("AR_SMTP_FROM", SMTP_USER)
SMTP_STARTTLS = os.environ.get("AR_SMTP_STARTTLS", "true").strip().lower() != "false"
def email_enabled() -> bool:
return bool(MAIL_API_URL) or bool(SMTP_HOST and SMTP_FROM)
# --------------------------------------------------------------------------- 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)