"""Persistencia en SQLite: plan del dia, overrides manuales y auditoria.""" from __future__ import annotations import json import logging import os import sqlite3 from datetime import date, datetime logger = logging.getLogger(__name__) DEFAULT_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "autoficher.db") SCHEMA = """ CREATE TABLE IF NOT EXISTS day_plan ( day TEXT NOT NULL, -- YYYY-MM-DD idx INTEGER NOT NULL, -- orden del fichaje dentro del dia typ INTEGER NOT NULL, -- 0 entrada, 1 salida window_idx INTEGER NOT NULL, -- tramo al que pertenece target TEXT NOT NULL, -- hora teorica ISO local planned_at TEXT NOT NULL, -- instante planificado ISO local (con jitter) state TEXT NOT NULL DEFAULT 'pending', -- pending|done|cancelled|skipped|failed fired_at TEXT, check_id INTEGER, note TEXT, announced_at TEXT, message_id INTEGER, PRIMARY KEY (day, idx) ); CREATE TABLE IF NOT EXISTS overrides ( day TEXT PRIMARY KEY, action TEXT NOT NULL, -- off|on reason TEXT, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT NOT NULL, kind TEXT NOT NULL, msg TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS events_ts ON events(ts); CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT NOT NULL); """ def _d(x) -> str: return x.isoformat() if isinstance(x, (date, datetime)) else str(x) class Store: def __init__(self, path: str = DEFAULT_PATH): self.path = os.path.abspath(path) os.makedirs(os.path.dirname(self.path), exist_ok=True) self.db = sqlite3.connect(self.path, detect_types=0) self.db.row_factory = sqlite3.Row self.db.execute("PRAGMA journal_mode=WAL") self.db.executescript(SCHEMA) self._migrate() self.db.commit() def _migrate(self) -> None: cols = {r["name"] for r in self.db.execute("PRAGMA table_info(day_plan)")} for col, decl in (("announced_at", "TEXT"), ("message_id", "INTEGER")): if col not in cols: self.db.execute(f"ALTER TABLE day_plan ADD COLUMN {col} {decl}") logger.info("migracion: day_plan.%s aƱadida", col) def close(self) -> None: self.db.close() # ------------------------------------------------------------- plan diario def save_plan(self, day: date, rows: list[dict]) -> None: """Reemplaza el plan del dia. No toca fichajes ya realizados.""" with self.db: done = {r["idx"] for r in self.db.execute( "SELECT idx FROM day_plan WHERE day=? AND state IN ('done','cancelled')", (_d(day),))} for r in rows: if r["idx"] in done: continue self.db.execute( "INSERT OR REPLACE INTO day_plan(day,idx,typ,window_idx,target,planned_at,state)" " VALUES(?,?,?,?,?,?,COALESCE((SELECT state FROM day_plan WHERE day=? AND idx=?),'pending'))", (_d(day), r["idx"], r["typ"], r["window_idx"], _d(r["target"]), _d(r["planned_at"]), _d(day), r["idx"])) def get_plan(self, day: date) -> list[sqlite3.Row]: return list(self.db.execute( "SELECT * FROM day_plan WHERE day=? ORDER BY idx", (_d(day),))) def pending(self, day: date) -> list[sqlite3.Row]: return list(self.db.execute( "SELECT * FROM day_plan WHERE day=? AND state='pending' ORDER BY idx", (_d(day),))) def set_state(self, day: date, idx: int, state: str, fired_at: datetime | None = None, check_id: int | None = None, note: str | None = None) -> None: with self.db: self.db.execute( "UPDATE day_plan SET state=?, fired_at=COALESCE(?,fired_at)," " check_id=COALESCE(?,check_id), note=COALESCE(?,note) WHERE day=? AND idx=?", (state, _d(fired_at) if fired_at else None, check_id, note, _d(day), idx)) def mark_announced(self, day: date, idx: int, message_id: int | None) -> None: with self.db: self.db.execute( "UPDATE day_plan SET announced_at=?, message_id=? WHERE day=? AND idx=?", (datetime.now().isoformat(timespec="seconds"), message_id, _d(day), idx)) def reschedule(self, day: date, idx: int, planned_at: datetime) -> None: with self.db: self.db.execute( "UPDATE day_plan SET planned_at=?, announced_at=NULL, message_id=NULL" " WHERE day=? AND idx=? AND state='pending'", (_d(planned_at), _d(day), idx)) def get_check(self, day: date, idx: int): return self.db.execute( "SELECT * FROM day_plan WHERE day=? AND idx=?", (_d(day), idx)).fetchone() # --------------------------------------------------------------- overrides def set_override(self, day: date, action: str, reason: str = "") -> None: with self.db: self.db.execute( "INSERT OR REPLACE INTO overrides(day,action,reason,created_at) VALUES(?,?,?,?)", (_d(day), action, reason, datetime.now().isoformat(timespec="seconds"))) def get_override(self, day: date) -> sqlite3.Row | None: return self.db.execute("SELECT * FROM overrides WHERE day=?", (_d(day),)).fetchone() def clear_override(self, day: date) -> None: with self.db: self.db.execute("DELETE FROM overrides WHERE day=?", (_d(day),)) def list_overrides(self, since: date | None = None) -> list[sqlite3.Row]: if since: return list(self.db.execute( "SELECT * FROM overrides WHERE day>=? ORDER BY day", (_d(since),))) return list(self.db.execute("SELECT * FROM overrides ORDER BY day")) # ------------------------------------------------------------------ varios def log(self, kind: str, msg: str) -> None: with self.db: self.db.execute("INSERT INTO events(ts,kind,msg) VALUES(?,?,?)", (datetime.now().isoformat(timespec="seconds"), kind, msg)) def recent_events(self, limit: int = 30) -> list[sqlite3.Row]: return list(self.db.execute( "SELECT * FROM events ORDER BY id DESC LIMIT ?", (limit,))) def get(self, key: str, default=None): row = self.db.execute("SELECT v FROM kv WHERE k=?", (key,)).fetchone() return json.loads(row["v"]) if row else default def put(self, key: str, value) -> None: with self.db: self.db.execute("INSERT OR REPLACE INTO kv(k,v) VALUES(?,?)", (key, json.dumps(value)))