feat: log de actividad por usuario

- 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 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-10 17:11:33 -05:00
co-authored by Claude Sonnet 4.6
parent 8e29e2d04f
commit c20e1a7476
10 changed files with 257 additions and 0 deletions
+3
View File
@@ -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)
+6
View File
@@ -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})
+10
View File
@@ -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)
+46
View File
@@ -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()
+11
View File
@@ -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),