"""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 # --- Jurnal de aplicatie (app_events, PRD 5.6 US-003) --- def insert_app_event( conn: sqlite3.Connection, *, request_id: str | None, account_id: int | None, sursa: str, tip: str, nivel: str, cod: str | None, mesaj: str | None, context_json: str | None, purge_after: str | None, ) -> None: """Insert minimal intr-un rand app_events. Apelat DOAR prin observ.log_event (care a redactat deja toate valorile). Nu redacteaza aici — separarea de responsabilitati: db.py persista, observ.py/security.py curata.""" conn.execute( "INSERT INTO app_events (request_id, account_id, sursa, tip, nivel, cod, mesaj, " "context_json, purge_after) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", (request_id, account_id, sursa, tip, nivel, cod, mesaj, context_json, purge_after), ) def read_app_events( conn: sqlite3.Connection, *, account_id: int | None = None, tip: str | None = None, nivel: str | None = None, date_from: str | None = None, date_to: str | None = None, limit: int = 100, offset: int = 0, ) -> list[sqlite3.Row]: """Citire paginata din app_events, ordine descrescatoare dupa id (cele mai noi intai). account_id=None -> toate conturile (admin). account_id=int -> scoped pe cont (NULL apartine contului 1, ca restul UI-ului). Filtrele tip/nivel/data sunt optionale. """ where: list[str] = [] params: list = [] if account_id is not None: where.append("(account_id = ? OR (account_id IS NULL AND ? = 1))") params.extend([account_id, account_id]) if tip: where.append("tip = ?") params.append(tip) if nivel: where.append("nivel = ?") params.append(nivel) if date_from: where.append("date(ts) >= date(?)") params.append(date_from) if date_to: where.append("date(ts) <= date(?)") params.append(date_to) sql = "SELECT id, ts, request_id, account_id, sursa, tip, nivel, cod, mesaj, context_json FROM app_events" if where: sql += " WHERE " + " AND ".join(where) sql += " ORDER BY id DESC LIMIT ? OFFSET ?" params.extend([limit, offset]) return conn.execute(sql, params).fetchall()