"""Acces SQLite (WAL). Conexiune per-thread, schema idempotenta, heartbeat worker.""" from __future__ import annotations import sqlite3 from datetime import datetime, timezone from pathlib import Path from .config import get_settings _SCHEMA = Path(__file__).resolve().parent / "schema.sql" def _connect(db_path: Path) -> sqlite3.Connection: db_path.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(db_path, timeout=15.0, isolation_level=None) # autocommit; tranzactii explicite conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode = WAL") conn.execute("PRAGMA foreign_keys = ON") conn.execute("PRAGMA busy_timeout = 15000") return conn def get_connection() -> sqlite3.Connection: """Conexiune noua catre baza configurata. Apelantul o inchide.""" return _connect(get_settings().db_path) def init_db() -> None: """Creeaza schema daca lipseste + migrari aditive. Idempotent — sigur la fiecare boot.""" conn = get_connection() try: conn.executescript(_SCHEMA.read_text(encoding="utf-8")) _migrate(conn) # Seed fallback nomenclator (doar daca e gol) ca editorul de mapari + fuzzy # sa mearga inainte ca worker-ul sa fi luat lista live din RAR. from .mapping import seed_nomenclator_if_empty seed_nomenclator_if_empty(conn) finally: conn.close() def _migrate(conn: sqlite3.Connection) -> None: """Migrari aditive pentru DB create inainte de o coloana noua (CREATE IF NOT EXISTS nu altereaza).""" # Coloane submissions sub_cols = {r["name"] for r in conn.execute("PRAGMA table_info(submissions)").fetchall()} if "next_attempt_at" not in sub_cols: conn.execute("ALTER TABLE submissions ADD COLUMN next_attempt_at TEXT") if "rar_creds_enc" not in sub_cols: conn.execute("ALTER TABLE submissions ADD COLUMN rar_creds_enc TEXT") if "purge_after" not in sub_cols: conn.execute("ALTER TABLE submissions ADD COLUMN purge_after TEXT") if "batch_id" not in sub_cols: conn.execute("ALTER TABLE submissions ADD COLUMN batch_id INTEGER") if "row_index" not in sub_cols: conn.execute("ALTER TABLE submissions ADD COLUMN row_index INTEGER") # Coloane accounts acc_cols = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()} if "rar_creds_enc" not in acc_cols: conn.execute("ALTER TABLE accounts ADD COLUMN rar_creds_enc TEXT") if "active" not in acc_cols: # Conturi existente raman active (default 1). Lifecycle consumat de 3.3. conn.execute("ALTER TABLE accounts ADD COLUMN active INTEGER NOT NULL DEFAULT 1") # Unicitate CUI (un CUI = un cont); NULL distinct nativ -> conturi fara CUI multiplu. conn.execute( "CREATE UNIQUE INDEX IF NOT EXISTS ux_accounts_cui ON accounts(cui) WHERE cui IS NOT NULL" ) # Index batch_id pe submissions (poate lipsi pe DB veche) existing_idx = {r["name"] for r in conn.execute( "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='submissions'" ).fetchall()} if "idx_submissions_batch" not in existing_idx: conn.execute( "CREATE INDEX IF NOT EXISTS idx_submissions_batch ON submissions(batch_id) " "WHERE batch_id IS NOT NULL" ) if "idx_submissions_account_status" not in existing_idx: conn.execute( "CREATE INDEX IF NOT EXISTS idx_submissions_account_status " "ON submissions(account_id, status)" ) def _now_iso() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds") def write_heartbeat(conn: sqlite3.Connection, *, rar_login_ok: bool = False, detail: str = "") -> None: """Worker bate la fiecare iteratie. last_rar_login_ok se actualizeaza doar la login reusit.""" if rar_login_ok: conn.execute( "UPDATE worker_heartbeat SET last_beat=?, last_rar_login_ok=?, detail=? WHERE id=1", (_now_iso(), _now_iso(), detail), ) else: conn.execute( "UPDATE worker_heartbeat SET last_beat=?, detail=? WHERE id=1", (_now_iso(), detail), ) def read_heartbeat(conn: sqlite3.Connection) -> sqlite3.Row | None: return conn.execute("SELECT * FROM worker_heartbeat WHERE id=1").fetchone() def queue_depth(conn: sqlite3.Connection) -> int: row = conn.execute("SELECT COUNT(*) AS n FROM submissions WHERE status='queued'").fetchone() return int(row["n"]) if row else 0