"""System settings. System-wide parameters (closing day, global start date, auto-reminder toggle and lead days) are persisted in ``system_settings`` with an append-only ``system_setting_changes`` trail. The auto-reminder engine itself lives in ``reminders.py`` (HEL-195); this module only owns the settings store. """ from __future__ import annotations import re import sqlite3 from datetime import datetime from .db import utc_now # Defaults are applied when a key is absent; the value type is always string. DEFAULT_SETTINGS: dict[str, str] = { "closing_day": "5", "start_date": "2026-01-01", "auto_remind": "1", "remind_days": "3", } _SETTING_KEYS = frozenset({"closing_day", "start_date", "auto_remind", "remind_days"}) _DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") def _valid_date(value: str) -> bool: if not _DATE_RE.match(value): return False try: datetime.strptime(value, "%Y-%m-%d") except ValueError: return False return True def get_settings(connection: sqlite3.Connection) -> dict[str, str]: settings = dict(DEFAULT_SETTINGS) rows = connection.execute( "SELECT key, value FROM system_settings" ).fetchall() for row in rows: settings[row["key"]] = row["value"] return settings def validate_settings(values: dict[str, object]) -> tuple[dict[str, str], str | None]: """Return ``(cleaned, error)``; ``cleaned`` holds only recognized keys.""" cleaned: dict[str, str] = {} if "closing_day" in values: raw = str(values["closing_day"]).strip() try: day = int(raw) except ValueError: return cleaned, "每月结账日须为 1-28 之间的整数。" if day < 1 or day > 28: return cleaned, "每月结账日须为 1-28 之间的整数。" cleaned["closing_day"] = str(day) if "start_date" in values: raw = str(values["start_date"]).strip() if not _valid_date(raw): return cleaned, "全局起算日须为有效日期(YYYY-MM-DD)。" cleaned["start_date"] = raw if "auto_remind" in values: raw = str(values["auto_remind"]).strip() if raw not in {"0", "1"}: return cleaned, "自动提醒开关须为 0 或 1。" cleaned["auto_remind"] = raw if "remind_days" in values: raw = str(values["remind_days"]).strip() try: days = int(raw) except ValueError: return cleaned, "提前提醒天数须为不小于 1 的整数。" if days < 1 or days > 30: return cleaned, "提前提醒天数须为 1-30 之间的整数。" cleaned["remind_days"] = str(days) return cleaned, None def update_settings( connection: sqlite3.Connection, values: dict[str, object], actor: sqlite3.Row, ) -> dict[str, str]: cleaned, error = validate_settings(values) if error is not None: raise ValueError(error) if not cleaned: raise ValueError("没有需要保存的设置项。") current = get_settings(connection) with connection: for key, new_value in cleaned.items(): old_value = current.get(key) if old_value == new_value: continue connection.execute( """ INSERT INTO system_settings (key, value, updated_by, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_by = excluded.updated_by, updated_at = excluded.updated_at """, (key, new_value, actor["id"], utc_now()), ) connection.execute( """ INSERT INTO system_setting_changes ( key, before_value, after_value, actor_user_id, actor_username, created_at ) VALUES (?, ?, ?, ?, ?, ?) """, ( key, old_value, new_value, actor["id"], actor["username"], utc_now(), ), ) return get_settings(connection) # ---------------------------------------------------------------------- # Reminder pending items # ----------------------------------------------------------------------