| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159 |
- """Per-user preferences.
- Admin config sets the global default for each pref; a user may override it, and the
- override persists across logins. Overrides live in the existing jobs.db (a separate
- connection — WAL + busy_timeout are set by JobQueue), NOT in the shared config.toml.
- Key-value schema so adding a new pref needs no migration. The effective value is the
- admin default overridden by the user's stored value ONLY IF it still validates against
- the live config — so if an admin deletes a folder a user had pinned, that user quietly
- falls back to the default instead of hitting an unknown-folder error.
- """
- from __future__ import annotations
- import json
- import logging
- import sqlite3
- import threading
- from dataclasses import dataclass
- from datetime import datetime, timezone
- from typing import Any, Callable, Mapping
- log = logging.getLogger("pps.prefs")
- _SORT_FIELDS = {"subject", "date", "from", "rcpt"}
- _SORT_DIRS = {"asc", "desc"}
- @dataclass(frozen=True)
- class PrefSpec:
- default_path: tuple[str, ...] # where the admin default lives
- coerce: Callable[[Any], Any]
- validate: Callable[[Any, Mapping], bool] # (value, live quarantine cfg) -> ok
- def _in_folders(v, q) -> bool:
- return v in q.get("folders", [])
- PREF_SPECS: dict[str, PrefSpec] = {
- "default_folder": PrefSpec(("default_folder",), str, _in_folders),
- "default_limit": PrefSpec(("default_limit",), int, lambda v, q: 1 <= v <= 1000),
- "sort_field": PrefSpec(("default_sort_field",), str, lambda v, q: v in _SORT_FIELDS),
- "sort_dir": PrefSpec(("default_sort_dir",), str, lambda v, q: v in _SORT_DIRS),
- }
- def _now() -> str:
- return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
- class PrefStore:
- def __init__(self, db_path: str):
- self._lock = threading.Lock()
- self._conn = sqlite3.connect(db_path, check_same_thread=False)
- self._conn.row_factory = sqlite3.Row
- with self._lock:
- self._conn.execute("PRAGMA busy_timeout=5000")
- self._conn.execute(
- """CREATE TABLE IF NOT EXISTS user_prefs (
- email TEXT NOT NULL,
- key TEXT NOT NULL,
- value TEXT NOT NULL,
- updated_at TEXT NOT NULL,
- PRIMARY KEY (email, key)
- )"""
- )
- self._conn.commit()
- # ------------------------------------------------------------------ reads
- def raw(self, email: str) -> dict:
- """The user's stored overrides (decoded), unvalidated."""
- with self._lock:
- rows = self._conn.execute(
- "SELECT key, value FROM user_prefs WHERE email=?", (email,)
- ).fetchall()
- out = {}
- for r in rows:
- try:
- out[r["key"]] = json.loads(r["value"])
- except json.JSONDecodeError:
- continue
- return out
- def effective(self, email: str, snap) -> dict:
- """Admin defaults overridden by valid user values."""
- q = snap.quarantine
- overrides = self.raw(email)
- out = {}
- for key, spec in PREF_SPECS.items():
- default = _dig(q, spec.default_path)
- value = default
- if key in overrides:
- try:
- cand = spec.coerce(overrides[key])
- if spec.validate(cand, q):
- value = cand
- except (TypeError, ValueError):
- pass
- out[key] = value
- return out
- def describe(self, email: str, snap) -> dict:
- q = snap.quarantine
- defaults = {k: _dig(q, s.default_path) for k, s in PREF_SPECS.items()}
- overrides = self.raw(email)
- return {
- "prefs": self.effective(email, snap),
- "defaults": defaults,
- "overridden": sorted(k for k in overrides if k in PREF_SPECS),
- }
- # ------------------------------------------------------------------ writes
- def set_many(self, email: str, patch: dict, snap) -> dict:
- """Validate + persist a user's overrides. Returns the new effective prefs."""
- q = snap.quarantine
- to_write = []
- for key, value in patch.items():
- spec = PREF_SPECS.get(key)
- if spec is None:
- raise ValueError(f"unknown preference: {key}")
- try:
- coerced = spec.coerce(value)
- except (TypeError, ValueError):
- raise ValueError(f"invalid value for {key}: {value!r}")
- if not spec.validate(coerced, q):
- raise ValueError(f"invalid value for {key}: {value!r}")
- to_write.append((email, key, json.dumps(coerced), _now()))
- with self._lock:
- self._conn.executemany(
- "INSERT INTO user_prefs (email, key, value, updated_at) VALUES (?,?,?,?)"
- " ON CONFLICT(email, key) DO UPDATE SET value=excluded.value,"
- " updated_at=excluded.updated_at",
- to_write,
- )
- self._conn.commit()
- return self.effective(email, snap)
- def clear(self, email: str, keys: list[str] | None = None) -> None:
- with self._lock:
- if keys:
- self._conn.executemany(
- "DELETE FROM user_prefs WHERE email=? AND key=?",
- [(email, k) for k in keys],
- )
- else:
- self._conn.execute("DELETE FROM user_prefs WHERE email=?", (email,))
- self._conn.commit()
- def _dig(m: Mapping, path: tuple[str, ...]):
- cur: Any = m
- for p in path:
- if not isinstance(cur, Mapping) or p not in cur:
- return None
- cur = cur[p]
- return cur
|