- migrations/001_initial_schema.sql: DDL idempotente con CREATE TABLE/INDEX IF NOT EXISTS - database.py: run_migrations() aplica .sql pendientes en orden; init_db() queda como alias - health.py: servidor aiohttp en puerto 8080 — GET /health, GET /health/history, POST /webhook Watcher loop alerta al admin por Telegram si el poller no actualiza en N horas - config.py: webhook_url, webhook_secret, health_port, health_alert_hours - poller.py: on_poll_done callback (conecta con health.record_poll) - main.py: arranca HealthServer, llama run_migrations, modo webhook opt-in via WEBHOOK_URL - Dockerfile + docker-compose.yml + .dockerignore - pyproject.toml: aiohttp>=3.9 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
185 lines
6.1 KiB
Python
185 lines
6.1 KiB
Python
"""
|
|
database.py — Persistencia de tenants y preferencias.
|
|
|
|
SQLite vía aiosqlite. En v1 esto se reemplaza por Postgres con el mismo interface público.
|
|
|
|
Schema:
|
|
- tenants: un registro por abogado registrado.
|
|
Las credenciales van cifradas con vault.py (Fernet derivada por chat_id).
|
|
- Migraciones: directorio migrations/*.sql, ordenadas por nombre.
|
|
schema_migrations lleva registro de las ya aplicadas.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import aiosqlite
|
|
import structlog
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
_conn: aiosqlite.Connection | None = None
|
|
|
|
|
|
def get_conn() -> aiosqlite.Connection:
|
|
"""Retorna la conexión activa. Llamar solo después de run_migrations()."""
|
|
return _assert_conn()
|
|
|
|
|
|
def _now_iso() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
async def run_migrations(db_path: Path) -> None:
|
|
"""
|
|
Aplica migraciones pendientes desde migrations/*.sql en orden alfabético.
|
|
Idempotente: versiones ya aplicadas se saltean.
|
|
Reemplaza init_db() — llamar una sola vez al arrancar.
|
|
"""
|
|
global _conn
|
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
_conn = await aiosqlite.connect(str(db_path))
|
|
_conn.row_factory = aiosqlite.Row
|
|
|
|
await _conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
version TEXT PRIMARY KEY,
|
|
applied_at TEXT NOT NULL
|
|
)
|
|
""")
|
|
await _conn.commit()
|
|
|
|
async with _conn.execute("SELECT version FROM schema_migrations") as cur:
|
|
applied = {row["version"] for row in await cur.fetchall()}
|
|
|
|
migrations_dir = Path(__file__).parent / "migrations"
|
|
for migration_file in sorted(migrations_dir.glob("*.sql")):
|
|
version = migration_file.stem
|
|
if version in applied:
|
|
logger.debug("migration_ya_aplicada", version=version)
|
|
continue
|
|
|
|
sql = migration_file.read_text(encoding="utf-8")
|
|
for stmt in sql.split(";"):
|
|
stmt = stmt.strip()
|
|
# Saltear líneas de comentario puras y sentencias vacías
|
|
if stmt and not all(line.startswith("--") for line in stmt.splitlines() if line.strip()):
|
|
await _conn.execute(stmt)
|
|
|
|
await _conn.execute(
|
|
"INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)",
|
|
(version, _now_iso()),
|
|
)
|
|
await _conn.commit()
|
|
logger.info("migration_aplicada", version=version)
|
|
|
|
logger.info("database_iniciada", path=str(db_path))
|
|
|
|
|
|
async def init_db(db_path: Path) -> None:
|
|
"""Alias de run_migrations() para compatibilidad con scripts existentes."""
|
|
await run_migrations(db_path)
|
|
|
|
|
|
def _assert_conn() -> aiosqlite.Connection:
|
|
if _conn is None:
|
|
raise RuntimeError("DB no inicializada — llamar a init_db() antes de usar database.")
|
|
return _conn
|
|
|
|
|
|
async def get_tenant(chat_id: int) -> dict | None:
|
|
"""Retorna el tenant o None si no existe."""
|
|
conn = _assert_conn()
|
|
async with conn.execute(
|
|
"SELECT * FROM tenants WHERE chat_id = ?", (chat_id,)
|
|
) as cur:
|
|
row = await cur.fetchone()
|
|
return dict(row) if row else None
|
|
|
|
|
|
async def save_tenant(chat_id: int, **fields: object) -> None:
|
|
"""
|
|
Crear o actualizar un tenant. Los kwargs son los campos de la tabla.
|
|
Hace upsert: inserta si no existe, actualiza si ya existe.
|
|
"""
|
|
conn = _assert_conn()
|
|
fields["last_active"] = _now_iso()
|
|
existing = await get_tenant(chat_id)
|
|
|
|
if existing is None:
|
|
fields.setdefault("created_at", _now_iso())
|
|
columns = ["chat_id", *fields.keys()]
|
|
placeholders = ", ".join("?" * len(columns))
|
|
values = [chat_id, *fields.values()]
|
|
await conn.execute(
|
|
f"INSERT INTO tenants ({', '.join(columns)}) VALUES ({placeholders})",
|
|
values,
|
|
)
|
|
logger.info("tenant_creado", chat_id=chat_id)
|
|
else:
|
|
set_clause = ", ".join(f"{k} = ?" for k in fields)
|
|
values_upd = [*fields.values(), chat_id]
|
|
await conn.execute(
|
|
f"UPDATE tenants SET {set_clause} WHERE chat_id = ?",
|
|
values_upd,
|
|
)
|
|
logger.info("tenant_actualizado", chat_id=chat_id, campos=list(fields.keys()))
|
|
|
|
await conn.commit()
|
|
|
|
|
|
async def list_active_tenants() -> list[dict]:
|
|
"""Retorna todos los tenants con estado='activo'."""
|
|
conn = _assert_conn()
|
|
async with conn.execute(
|
|
"SELECT * FROM tenants WHERE estado = 'activo'"
|
|
) as cur:
|
|
rows = await cur.fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
async def delete_tenant(chat_id: int) -> None:
|
|
"""Elimina el tenant y sus datos de la DB."""
|
|
conn = _assert_conn()
|
|
await conn.execute("DELETE FROM tenants WHERE chat_id = ?", (chat_id,))
|
|
await conn.commit()
|
|
logger.info("tenant_eliminado", chat_id=chat_id)
|
|
|
|
|
|
# ── Invite codes ──────────────────────────────────────────────────────────────
|
|
|
|
async def crear_invite_code(codigo: str) -> None:
|
|
"""Inserta un código de invitación de un solo uso."""
|
|
conn = _assert_conn()
|
|
await conn.execute(
|
|
"INSERT INTO invite_codes (codigo, creado_at) VALUES (?, ?)",
|
|
(codigo, _now_iso()),
|
|
)
|
|
await conn.commit()
|
|
logger.info("invite_code_creado", codigo=codigo[:4] + "****")
|
|
|
|
|
|
async def verificar_y_consumir_invite_code(codigo: str, chat_id: int) -> bool:
|
|
"""
|
|
Verifica que el código existe y no fue usado, lo marca como consumido y
|
|
retorna True. Retorna False si no existe o ya fue usado.
|
|
Operación atómica en una sola transacción SQLite.
|
|
"""
|
|
conn = _assert_conn()
|
|
async with conn.execute(
|
|
"SELECT usado FROM invite_codes WHERE codigo = ?", (codigo,)
|
|
) as cur:
|
|
row = await cur.fetchone()
|
|
|
|
if row is None or row["usado"]:
|
|
return False
|
|
|
|
await conn.execute(
|
|
"UPDATE invite_codes SET usado = 1, usado_por = ?, usado_at = ? WHERE codigo = ?",
|
|
(chat_id, _now_iso(), codigo),
|
|
)
|
|
await conn.commit()
|
|
logger.info("invite_code_consumido", chat_id=chat_id)
|
|
return True
|