feat: Docker, health check, schema migrations y webhook opt-in
- 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>
This commit is contained in:
96
database.py
96
database.py
@@ -6,6 +6,8 @@ SQLite vía aiosqlite. En v1 esto se reemplaza por Postgres con el mismo interfa
|
||||
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
|
||||
|
||||
@@ -19,49 +21,9 @@ logger = structlog.get_logger(__name__)
|
||||
|
||||
_conn: aiosqlite.Connection | None = None
|
||||
|
||||
_CREATE_AUDIT_LOG = """
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp TEXT NOT NULL,
|
||||
timestamp_local TEXT,
|
||||
chat_id INTEGER,
|
||||
evento TEXT NOT NULL,
|
||||
detalle TEXT,
|
||||
resultado TEXT,
|
||||
duracion_ms INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_chat_id ON audit_log(chat_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_evento ON audit_log(evento);
|
||||
"""
|
||||
|
||||
_CREATE_INVITE_CODES = """
|
||||
CREATE TABLE IF NOT EXISTS invite_codes (
|
||||
codigo TEXT PRIMARY KEY,
|
||||
creado_at TEXT NOT NULL,
|
||||
usado INTEGER DEFAULT 0,
|
||||
usado_por INTEGER,
|
||||
usado_at TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
_CREATE_TENANTS = """
|
||||
CREATE TABLE IF NOT EXISTS tenants (
|
||||
chat_id INTEGER PRIMARY KEY,
|
||||
nombre_preferido TEXT,
|
||||
tono TEXT DEFAULT 'cotidiano',
|
||||
timezone TEXT DEFAULT 'America/Asuncion',
|
||||
credentials_enc TEXT,
|
||||
estado TEXT DEFAULT 'pendiente',
|
||||
consentimiento_aceptado INTEGER DEFAULT 0,
|
||||
created_at TEXT,
|
||||
last_active TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def get_conn() -> aiosqlite.Connection:
|
||||
"""Retorna la conexión activa. Llamar solo después de init_db()."""
|
||||
"""Retorna la conexión activa. Llamar solo después de run_migrations()."""
|
||||
return _assert_conn()
|
||||
|
||||
|
||||
@@ -69,23 +31,57 @@ def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
async def init_db(db_path: Path) -> None:
|
||||
"""Crear tablas si no existen. Llamar una sola vez al arrancar."""
|
||||
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_INVITE_CODES)
|
||||
await _conn.execute(_CREATE_TENANTS)
|
||||
# CREATE INDEX no soporta executescript — ejecutar sentencias separadas
|
||||
for stmt in _CREATE_AUDIT_LOG.strip().split(";"):
|
||||
stmt = stmt.strip()
|
||||
if stmt:
|
||||
await _conn.execute(stmt)
|
||||
|
||||
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.")
|
||||
|
||||
Reference in New Issue
Block a user