""" Runtime settings store — persists editable settings to /data/settings_overrides.json. Changes take effect immediately (in-memory setattr on the global Settings object) and survive restarts (JSON file is loaded in main.py lifespan). Settings that require a container restart (TrueNAS URL, poll interval, allowed IPs, etc.) are NOT included here and are display-only on the settings page. """ import json import logging from pathlib import Path from app.config import settings log = logging.getLogger(__name__) # Field name → coerce function. Only fields listed here are accepted by save(). _EDITABLE: dict[str, type] = { "smtp_host": str, "smtp_ssl_mode": str, "smtp_timeout": int, "smtp_user": str, "smtp_password": str, "smtp_from": str, "smtp_to": str, "smtp_daily_report_enabled": bool, "smtp_report_hour": int, "smtp_alert_on_fail": bool, "smtp_alert_on_pass": bool, "webhook_url": str, "stuck_job_hours": int, "max_parallel_burnins": int, } _VALID_SSL_MODES = {"starttls", "ssl", "plain"} def _overrides_path() -> Path: return Path(settings.db_path).parent / "settings_overrides.json" def _coerce(key: str, raw) -> object: coerce = _EDITABLE[key] if coerce is bool: if isinstance(raw, bool): return raw return str(raw).lower() in ("1", "true", "yes", "on") return coerce(raw) def _apply(data: dict) -> None: """Apply a dict of updates to the live settings object.""" for key, raw in data.items(): if key not in _EDITABLE: continue try: val = _coerce(key, raw) if key == "smtp_ssl_mode" and val not in _VALID_SSL_MODES: log.warning("settings_store: invalid smtp_ssl_mode %r — ignoring", val) continue if key == "smtp_report_hour" and not (0 <= int(val) <= 23): log.warning("settings_store: smtp_report_hour out of range — ignoring") continue setattr(settings, key, val) except (ValueError, TypeError) as exc: log.warning("settings_store: invalid value for %s: %s", key, exc) def init() -> None: """Load persisted overrides at startup. Call once from lifespan.""" path = _overrides_path() if not path.exists(): return try: data = json.loads(path.read_text()) _apply(data) log.info("settings_store: loaded %d override(s) from %s", len(data), path) except Exception as exc: log.warning("settings_store: could not load overrides from %s: %s", path, exc) def save(updates: dict) -> list[str]: """ Validate, apply, and persist a dict of settings updates. Returns list of keys that were actually saved. Raises ValueError for unknown or invalid fields. """ accepted: dict = {} for key, raw in updates.items(): if key not in _EDITABLE: raise ValueError(f"Unknown or non-editable setting: {key!r}") accepted[key] = raw _apply(accepted) # Persist ALL currently-applied editable values (not just the delta) snapshot = {k: getattr(settings, k) for k in _EDITABLE} path = _overrides_path() path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(snapshot, indent=2)) log.info("settings_store: saved %d key(s) — snapshot written to %s", len(accepted), path) return list(accepted.keys())