Files
hechakuaa_bot/admin/app.py

160 lines
6.1 KiB
Python

"""
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@<IP_VPS>
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("<h1>Tenant no encontrado</h1>", 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