import json import sqlite3 import threading from datetime import datetime, timezone from typing import Any, Iterable from settings import DB_PATH _lock = threading.Lock() def _connect() -> sqlite3.Connection: conn = sqlite3.connect(DB_PATH, isolation_level=None, check_same_thread=False) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA foreign_keys=ON") return conn _conn: sqlite3.Connection = _connect() SCHEMA = """ CREATE TABLE IF NOT EXISTS flats ( id TEXT PRIMARY KEY, link TEXT NOT NULL, address TEXT, rooms REAL, size REAL, total_rent REAL, sqm_price REAL, year_built TEXT, wbs TEXT, connectivity_morning_time REAL, connectivity_night_time REAL, address_link_gmaps TEXT, payload_json TEXT NOT NULL, matched_criteria INTEGER NOT NULL DEFAULT 0, discovered_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_flats_discovered ON flats(discovered_at DESC); CREATE INDEX IF NOT EXISTS idx_flats_matched ON flats(matched_criteria); CREATE TABLE IF NOT EXISTS applications ( id INTEGER PRIMARY KEY AUTOINCREMENT, flat_id TEXT NOT NULL, url TEXT NOT NULL, triggered_by TEXT NOT NULL, -- 'user' | 'auto' started_at TEXT NOT NULL, finished_at TEXT, success INTEGER, message TEXT, FOREIGN KEY (flat_id) REFERENCES flats(id) ); CREATE INDEX IF NOT EXISTS idx_applications_flat ON applications(flat_id); CREATE INDEX IF NOT EXISTS idx_applications_started ON applications(started_at DESC); CREATE TABLE IF NOT EXISTS state ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp TEXT NOT NULL, actor TEXT NOT NULL, action TEXT NOT NULL, details TEXT, ip TEXT ); CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp DESC); """ DEFAULTS = { "mode": "manual", # 'manual' | 'auto' "kill_switch": "0", # '1' = apply disabled "apply_circuit_open": "0", # '1' = opened by circuit breaker "apply_recent_failures": "0", "last_alert_heartbeat": "", "last_apply_heartbeat": "", } def init_db() -> None: with _lock: _conn.executescript(SCHEMA) for k, v in DEFAULTS.items(): _conn.execute("INSERT OR IGNORE INTO state(key, value) VALUES (?, ?)", (k, v)) def now_iso() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds") def get_state(key: str) -> str | None: row = _conn.execute("SELECT value FROM state WHERE key = ?", (key,)).fetchone() return row["value"] if row else None def set_state(key: str, value: str) -> None: with _lock: _conn.execute( "INSERT INTO state(key, value) VALUES (?, ?) " "ON CONFLICT(key) DO UPDATE SET value = excluded.value", (key, value), ) def upsert_flat(payload: dict, matched: bool) -> bool: """Returns True if this flat is new.""" flat_id = str(payload["id"]) conn_info = payload.get("connectivity") or {} with _lock: existing = _conn.execute("SELECT id FROM flats WHERE id = ?", (flat_id,)).fetchone() if existing: return False _conn.execute( """ INSERT INTO flats( id, link, address, rooms, size, total_rent, sqm_price, year_built, wbs, connectivity_morning_time, connectivity_night_time, address_link_gmaps, payload_json, matched_criteria, discovered_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( flat_id, payload.get("link", ""), payload.get("address", ""), payload.get("rooms"), payload.get("size"), payload.get("total_rent"), payload.get("sqm_price"), str(payload.get("year_built", "")), str(payload.get("wbs", "")), conn_info.get("morning_time"), conn_info.get("night_time"), payload.get("address_link_gmaps"), json.dumps(payload, default=str), 1 if matched else 0, now_iso(), ), ) return True def recent_flats(limit: int = 50) -> list[sqlite3.Row]: return list( _conn.execute( """ SELECT f.*, (SELECT success FROM applications a WHERE a.flat_id = f.id ORDER BY a.started_at DESC LIMIT 1) AS last_application_success, (SELECT message FROM applications a WHERE a.flat_id = f.id ORDER BY a.started_at DESC LIMIT 1) AS last_application_message, (SELECT started_at FROM applications a WHERE a.flat_id = f.id ORDER BY a.started_at DESC LIMIT 1) AS last_application_at FROM flats f ORDER BY f.discovered_at DESC LIMIT ? """, (limit,), ).fetchall() ) def get_flat(flat_id: str) -> sqlite3.Row | None: return _conn.execute("SELECT * FROM flats WHERE id = ?", (flat_id,)).fetchone() def start_application(flat_id: str, url: str, triggered_by: str) -> int: with _lock: cur = _conn.execute( "INSERT INTO applications(flat_id, url, triggered_by, started_at) VALUES (?, ?, ?, ?)", (flat_id, url, triggered_by, now_iso()), ) return cur.lastrowid def finish_application(app_id: int, success: bool, message: str) -> None: with _lock: _conn.execute( "UPDATE applications SET finished_at = ?, success = ?, message = ? WHERE id = ?", (now_iso(), 1 if success else 0, message, app_id), ) def recent_applications(limit: int = 20) -> list[sqlite3.Row]: return list( _conn.execute( """ SELECT a.*, f.address, f.link FROM applications a JOIN flats f ON f.id = a.flat_id ORDER BY a.started_at DESC LIMIT ? """, (limit,), ).fetchall() ) def log_audit(actor: str, action: str, details: str = "", ip: str = "") -> None: with _lock: _conn.execute( "INSERT INTO audit_log(timestamp, actor, action, details, ip) VALUES (?, ?, ?, ?, ?)", (now_iso(), actor, action, details, ip), ) def recent_audit(limit: int = 30) -> list[sqlite3.Row]: return list( _conn.execute( "SELECT * FROM audit_log ORDER BY timestamp DESC LIMIT ?", (limit,), ).fetchall() )