""" admin/app.py — Panel de administración interno (FastAPI). Solo escucha en 127.0.0.1. Acceso via SSH tunnel: ssh -L 9090:127.0.0.1:9090 pedrito@ Luego: http://localhost:9090 Autenticación: header X-Admin-Token en cada request. """ from __future__ import annotations import json from contextlib import asynccontextmanager from pathlib import Path from typing import Any import aiosqlite from fastapi import Depends, FastAPI, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from admin.auth import require_token _db_path: str = "" _admin_token: str = "" _data_dir: Path = Path("./data") templates = Jinja2Templates(directory="admin/templates") @asynccontextmanager async def _lifespan(app: FastAPI): app.state.db = await aiosqlite.connect(_db_path) app.state.db.row_factory = aiosqlite.Row yield await app.state.db.close() def create_app(db_path: str, admin_token: str, data_dir: Path) -> FastAPI: global _db_path, _admin_token, _data_dir _db_path = db_path _admin_token = admin_token _data_dir = data_dir app = FastAPI(lifespan=_lifespan, docs_url=None, redoc_url=None) auth = Depends(require_token(admin_token)) # ── helpers ─────────────────────────────────────────────────────────────── def _mask_cedula(credentials_enc: str | None) -> str: """Muestra '***' — la cédula está cifrada y no se expone en el panel.""" if not credentials_enc: return "—" return "***" def _parse_detalle(detalle_json: str | None) -> str: if not detalle_json: return "" try: return json.dumps(json.loads(detalle_json), ensure_ascii=False, indent=None) except Exception: return detalle_json or "" # ── rutas ───────────────────────────────────────────────────────────────── @app.get("/", include_in_schema=False) async def root(): return RedirectResponse(url="/tenants") @app.get("/tenants", response_class=HTMLResponse, dependencies=[auth]) async def lista_tenants(request: Request): db: aiosqlite.Connection = request.app.state.db async with db.execute("SELECT * FROM tenants ORDER BY created_at DESC") as cur: rows = [dict(r) for r in await cur.fetchall()] for r in rows: r["cedula_masked"] = _mask_cedula(r.get("credentials_enc")) r.pop("credentials_enc", None) return templates.TemplateResponse("tenants.html", {"request": request, "tenants": rows}) @app.get("/tenants/{chat_id}", response_class=HTMLResponse, dependencies=[auth]) async def detalle_tenant(request: Request, chat_id: int): db: aiosqlite.Connection = request.app.state.db async with db.execute("SELECT * FROM tenants WHERE chat_id = ?", (chat_id,)) as cur: row = await cur.fetchone() if not row: return HTMLResponse("

Tenant no encontrado

", status_code=404) tenant: dict[str, Any] = dict(row) tenant["cedula_masked"] = _mask_cedula(tenant.get("credentials_enc")) tenant.pop("credentials_enc", None) async with db.execute( "SELECT * FROM audit_log WHERE chat_id = ? ORDER BY timestamp DESC LIMIT 20", (chat_id,), ) as cur: logs = [dict(r) for r in await cur.fetchall()] for entry in logs: entry["detalle_str"] = _parse_detalle(entry.get("detalle")) return templates.TemplateResponse( "tenant.html", {"request": request, "tenant": tenant, "logs": logs} ) @app.post("/tenants/{chat_id}/resetear", dependencies=[auth]) async def resetear_tenant(request: Request, chat_id: int): db: aiosqlite.Connection = request.app.state.db await db.execute("DELETE FROM tenants WHERE chat_id = ?", (chat_id,)) await db.execute("DELETE FROM audit_log WHERE chat_id = ?", (chat_id,)) await db.commit() snapshot = _data_dir / f"snapshot_{chat_id}.json" if snapshot.exists(): snapshot.unlink() return RedirectResponse(url="/tenants", status_code=303) @app.get("/audit", response_class=HTMLResponse, dependencies=[auth]) async def audit_log(request: Request, chat_id: str = "", page: int = 1): db: aiosqlite.Connection = request.app.state.db limit = 200 offset = (page - 1) * limit if chat_id.strip(): try: cid = int(chat_id.strip()) async with db.execute( "SELECT * FROM audit_log WHERE chat_id = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?", (cid, limit, offset), ) as cur: logs = [dict(r) for r in await cur.fetchall()] async with db.execute( "SELECT COUNT(*) FROM audit_log WHERE chat_id = ?", (cid,) ) as cur: total = (await cur.fetchone())[0] except ValueError: logs, total = [], 0 else: async with db.execute( "SELECT * FROM audit_log ORDER BY timestamp DESC LIMIT ? OFFSET ?", (limit, offset), ) as cur: logs = [dict(r) for r in await cur.fetchall()] async with db.execute("SELECT COUNT(*) FROM audit_log") as cur: total = (await cur.fetchone())[0] for entry in logs: entry["detalle_str"] = _parse_detalle(entry.get("detalle")) has_more = (offset + limit) < total return templates.TemplateResponse( "audit.html", { "request": request, "logs": logs, "chat_id_filter": chat_id, "page": page, "has_more": has_more, "total": total, }, ) return app