From c20e1a74762a72fdb3e1789a05d9732614710941 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:11:33 -0500 Subject: [PATCH] feat: log de actividad por usuario MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tabla activity_log en SQLite (migración automática) - Registra: login, login_fallido, rda_enviado, rda_masivo, rda_directo, automation_run, contrato_creado, contrato_editado, contrato_toggle - Endpoint GET /logs/actividad con filtros por usuario, acción y fecha - UI /logs/actividad con badges de color por tipo de acción - Enlace "Actividad" en navegación lateral Co-Authored-By: Claude Sonnet 4.6 --- app/database.py | 12 +++ app/routes/auth.py | 3 + app/routes/automation.py | 6 ++ app/routes/contratos.py | 10 +++ app/routes/logs.py | 46 +++++++++++ app/routes/transaccion.py | 11 +++ app/templates/actividad.html | 145 +++++++++++++++++++++++++++++++++++ app/templates/base.html | 3 + app/utils/__init__.py | 0 app/utils/activity.py | 21 +++++ 10 files changed, 257 insertions(+) create mode 100644 app/templates/actividad.html create mode 100644 app/utils/__init__.py create mode 100644 app/utils/activity.py diff --git a/app/database.py b/app/database.py index 5d61f0a..ae2d5a8 100644 --- a/app/database.py +++ b/app/database.py @@ -23,6 +23,18 @@ def _migrate(conn): conn.execute("ALTER TABLE envios ADD COLUMN mensaje_tns TEXT") tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} + if "activity_log" not in tables: + conn.execute(""" + CREATE TABLE activity_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + username TEXT NOT NULL DEFAULT '', + accion TEXT NOT NULL, + detalle TEXT NOT NULL DEFAULT '', + ip TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) if "contratos" not in tables: conn.execute(""" CREATE TABLE contratos ( diff --git a/app/routes/auth.py b/app/routes/auth.py index d2e945b..da6a154 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, Request, Form, Depends, HTTPException from fastapi.responses import RedirectResponse, JSONResponse from app.database import get_connection from app.auth import hash_password, verify_password, create_token, get_current_user +from app.utils.activity import log_activity, get_ip router = APIRouter(prefix="/auth", tags=["auth"]) @@ -62,10 +63,12 @@ async def login( "SELECT * FROM users WHERE username = ?", (username,) ).fetchone() if not user or not verify_password(password, user["password_hash"]): + log_activity(0, username, "login_fallido", f"Intento fallido desde {get_ip(request)}", get_ip(request)) return request.app.state.templates.TemplateResponse("login.html", { "request": request, "error": "Usuario o contraseña incorrectos" }) + log_activity(user["id"], user["username"], "login", f"Inicio de sesión", get_ip(request)) token = create_token(user["id"], user["username"]) resp = RedirectResponse("/dashboard", status_code=302) resp.set_cookie(key="token", value=token, httponly=True) diff --git a/app/routes/automation.py b/app/routes/automation.py index ab075f7..4ec114a 100644 --- a/app/routes/automation.py +++ b/app/routes/automation.py @@ -15,6 +15,7 @@ from app.services.json_generator import ( from app.services.api_client import get_tns_token, TNS_BASE from app.services.whatsapp_sync import sync_paciente, sync_todos, guardar_sync_log from app.routes.contratos import load_contrato_map, load_excluded_set +from app.utils.activity import log_activity, get_ip router = APIRouter(prefix="/automation", tags=["automation"]) @@ -323,6 +324,11 @@ async def run_automation( fecha_inicio=fecha, fecha_fin=fecha, servicios=len(grupo_rows)) + r1 = resultado["paso1_terceros"] + r2 = resultado["paso2_rda"] + log_activity(user["user_id"], user["username"], "automation_run", + f"Fecha {fecha} | Terceros: {r1['enviados']} OK/{r1['errores']} err | RDA: {r2['enviados']} OK/{r2['errores']} err/{r2.get('excluidos',0)} excluidos", + get_ip(request)) return JSONResponse({"success": True, "resultado": resultado}) diff --git a/app/routes/contratos.py b/app/routes/contratos.py index e41689b..c51bd23 100644 --- a/app/routes/contratos.py +++ b/app/routes/contratos.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, Request, Form, Depends from fastapi.responses import RedirectResponse, JSONResponse from app.database import get_connection from app.auth import get_current_user +from app.utils.activity import log_activity, get_ip router = APIRouter(prefix="/contratos", tags=["contratos"]) @@ -134,6 +135,8 @@ async def contrato_create( (numero_contrato.strip(), nit_empresa.strip(), tipo_usuario.strip(), descripcion.strip(), int(excluir)), ) conn.commit() + log_activity(user["user_id"], user["username"], "contrato_creado", + f"Contrato {numero_contrato.strip()} — {descripcion.strip()}", get_ip(request)) except Exception: pass conn.close() @@ -156,6 +159,8 @@ async def contrato_update( (numero_contrato.strip(), nit_empresa.strip(), tipo_usuario.strip(), descripcion.strip(), int(excluir), contrato_id), ) conn.commit() + log_activity(user["user_id"], user["username"], "contrato_editado", + f"Contrato {numero_contrato.strip()} — {descripcion.strip()}", get_ip(request)) conn.close() return RedirectResponse("/contratos", status_code=302) @@ -168,6 +173,11 @@ async def toggle_excluir(contrato_id: int, user: dict = Depends(get_current_user (contrato_id,) ) conn.commit() + row = conn.execute("SELECT numero_contrato, excluir FROM contratos WHERE id=?", (contrato_id,)).fetchone() + if row: + estado = "excluido" if row["excluir"] else "activado" + log_activity(user["user_id"], user["username"], "contrato_toggle", + f"Contrato {row['numero_contrato']} → {estado}", get_ip(request)) conn.close() return RedirectResponse("/contratos", status_code=302) diff --git a/app/routes/logs.py b/app/routes/logs.py index 6085293..d05ca24 100644 --- a/app/routes/logs.py +++ b/app/routes/logs.py @@ -87,6 +87,52 @@ async def logs_page( }) +@router.get("/actividad") +async def actividad_page( + request: Request, + user: dict = Depends(get_current_user), + username: str = "", + accion: str = "", + fecha_desde: str = "", + fecha_hasta: str = "", + offset: int = 0, +): + conn = get_connection() + where = ["1=1"] + params = [] + if username: + where.append("a.username = ?") + params.append(username) + if accion: + where.append("a.accion = ?") + params.append(accion) + if fecha_desde: + where.append("DATE(a.created_at) >= ?") + params.append(fecha_desde) + if fecha_hasta: + where.append("DATE(a.created_at) <= ?") + params.append(fecha_hasta) + w = " AND ".join(where) + total = conn.execute(f"SELECT COUNT(*) FROM activity_log a WHERE {w}", params).fetchone()[0] + rows = conn.execute(f""" + SELECT a.* FROM activity_log a + WHERE {w} ORDER BY a.created_at DESC LIMIT ? OFFSET ? + """, params + [PAGE_SIZE, offset]).fetchall() + usuarios = conn.execute("SELECT DISTINCT username FROM activity_log ORDER BY username").fetchall() + acciones = conn.execute("SELECT DISTINCT accion FROM activity_log ORDER BY accion").fetchall() + conn.close() + return request.app.state.templates.TemplateResponse("actividad.html", { + "request": request, "user": user, + "rows": rows, "total": total, + "usuarios": [u["username"] for u in usuarios], + "acciones": [a["accion"] for a in acciones], + "filtro_username": username, "filtro_accion": accion, + "filtro_fecha_desde": fecha_desde, "filtro_fecha_hasta": fecha_hasta, + "offset": offset, "page_size": PAGE_SIZE, + "has_more": (offset + PAGE_SIZE) < total, + }) + + @router.get("/detalle/{envio_id}") async def detalle_envio(envio_id: int, request: Request, user: dict = Depends(get_current_user)): conn = get_connection() diff --git a/app/routes/transaccion.py b/app/routes/transaccion.py index 7bef9d2..5d17696 100644 --- a/app/routes/transaccion.py +++ b/app/routes/transaccion.py @@ -9,6 +9,7 @@ from app.services.firebird_service import get_firebird_from_config from app.services.json_generator import generar_rda_paciente, agrupar_por_recepcion from app.services.api_client import get_tns_token, TNS_BASE from app.routes.contratos import load_contrato_map, load_excluded_set +from app.utils.activity import log_activity, get_ip router = APIRouter(prefix="/transaccion", tags=["transaccion"]) @@ -219,6 +220,10 @@ async def send_one( conn.commit() conn.close() + factura_log = str(grupo_rows[0].get("NUM_FACTURA", idrecepcion)) + log_activity(user["user_id"], user["username"], "rda_enviado", + f"Factura {factura_log} | Contrato {grupo_rows[0].get('CODCONTRATO','')} | {'OK' if ok_rda else 'ERROR: '+msg_tns[:80]}", + get_ip(request)) return JSONResponse({"success": ok_rda, "message": msg_tns, "raw_tns": raw_resp, "idrecepcion": idrecepcion}) @@ -318,6 +323,9 @@ async def send_transaccion( ok_count = sum(1 for r in resultados if r["success"]) err_count = len(resultados) - ok_count + log_activity(user["user_id"], user["username"], "rda_masivo", + f"Enviados: {ok_count} OK, {err_count} errores, {excluidos_count} excluidos | {fecha_inicio} → {fecha_fin}", + get_ip(request)) return JSONResponse({ "success": err_count == 0, "total_enviados": ok_count, @@ -429,6 +437,9 @@ async def send_direct( }) ok_count = sum(1 for r in resultados if r["success"]) + log_activity(user["user_id"], user["username"], "rda_directo", + f"Factura {factura_str} | {'OK' if ok_count > 0 else 'ERROR'}", + get_ip(request)) return JSONResponse({ "success": ok_count > 0, "total": len(resultados), diff --git a/app/templates/actividad.html b/app/templates/actividad.html new file mode 100644 index 0000000..4510ade --- /dev/null +++ b/app/templates/actividad.html @@ -0,0 +1,145 @@ +{% extends "base.html" %} +{% block title %}Actividad{% endblock %} +{% block header %}Registro de Actividad{% endblock %} +{% block content %} + + +
+ + Envíos TNS + + + Actividad usuarios + +
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + +
+
+
+
+ + +
+
+

Eventos

+ {{ total }} registro(s) +
+
+ + + + + + + + + + + + {% for r in rows %} + + + + + + + + {% else %} + + + + {% endfor %} + +
Fecha/HoraUsuarioAcciónDetalleIP
+ {{ r.created_at[:16].replace('T',' ') }} + + {{ r.username }} + + {% set a = r.accion %} + {% if a == 'login' %} + Login + {% elif a == 'login_fallido' %} + Login fallido + {% elif a == 'rda_enviado' %} + RDA enviado + {% elif a == 'rda_masivo' %} + RDA masivo + {% elif a == 'rda_directo' %} + RDA directo + {% elif a == 'automation_run' %} + Automatización + {% elif a == 'contrato_creado' %} + Contrato creado + {% elif a == 'contrato_editado' %} + Contrato editado + {% elif a == 'contrato_toggle' %} + Contrato toggle + {% else %} + {{ a }} + {% endif %} + + {{ r.detalle }} + {{ r.ip }}
+ No hay eventos registrados aún +
+
+ + {% if has_more or offset > 0 %} +
+ {% if offset > 0 %} + + Anterior + + {% else %}{% endif %} + {{ offset + 1 }}–{{ [offset + page_size, total]|min }} de {{ total }} + {% if has_more %} + + Siguiente + + {% else %}{% endif %} +
+ {% endif %} +
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index 0af6659..12e41ee 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -70,6 +70,9 @@ Historial + + Actividad + Salir diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/utils/activity.py b/app/utils/activity.py new file mode 100644 index 0000000..f335c2f --- /dev/null +++ b/app/utils/activity.py @@ -0,0 +1,21 @@ +from app.database import get_connection + + +def log_activity(user_id: int, username: str, accion: str, detalle: str = "", ip: str = ""): + try: + conn = get_connection() + conn.execute( + "INSERT INTO activity_log (user_id, username, accion, detalle, ip) VALUES (?,?,?,?,?)", + (user_id, username, accion, detalle, ip), + ) + conn.commit() + conn.close() + except Exception: + pass + + +def get_ip(request) -> str: + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + return forwarded.split(",")[0].strip() + return request.client.host if request.client else ""