"""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") acc_cols.add("active") if "status" not in acc_cols: # Stare de ciclu de viata (5.5). Defensiv idempotent (ca is_admin in 3.3b). # Default 'active' (trece CHECK pe randurile existente), apoi derivam din `active`: # active=0 -> 'pending'. Invariant: active=1 <=> status='active'. conn.execute( "ALTER TABLE accounts ADD COLUMN status TEXT NOT NULL DEFAULT 'active' " "CHECK (status IN ('pending','active','blocked','archived','deleted'))" ) conn.execute( "UPDATE accounts SET status='pending' WHERE active=0 AND status='active'" ) # 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" ) # Coloane users (DB cu users creata inaintea acestor coloane) user_tbl = conn.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name='users'" ).fetchone() if user_tbl: user_cols = {r["name"] for r in conn.execute("PRAGMA table_info(users)").fetchall()} if "is_admin" not in user_cols: conn.execute("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0") if "email_verified" not in user_cols: conn.execute("ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0") # Coloana import_rows.override_json (3.6, Approach B): patch canonic editat in # preview, criptat Fernet. Defensiv idempotent (ca is_admin in 3.3b) — DB create # inainte de 3.6 nu au coloana. irows_tbl = conn.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name='import_rows'" ).fetchone() if irows_tbl: irows_cols = {r["name"] for r in conn.execute("PRAGMA table_info(import_rows)").fetchall()} if "override_json" not in irows_cols: conn.execute("ALTER TABLE import_rows ADD COLUMN override_json TEXT") # 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