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:
co-authored by
Claude Sonnet 4.6
parent
8e29e2d04f
commit
c20e1a7476
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Actividad{% endblock %}
|
||||
{% block header %}Registro de Actividad{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-2 mb-5">
|
||||
<a href="/logs" class="px-4 py-2 rounded-lg text-sm font-medium bg-white border border-gray-200 text-gray-600 hover:bg-gray-50">
|
||||
<i class="fas fa-paper-plane mr-1"></i> Envíos TNS
|
||||
</a>
|
||||
<span class="px-4 py-2 rounded-lg text-sm font-medium bg-blue-600 text-white">
|
||||
<i class="fas fa-user-clock mr-1"></i> Actividad usuarios
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Filtros -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 mb-5">
|
||||
<form method="get" action="/logs/actividad" class="p-4">
|
||||
<input type="hidden" name="offset" value="0">
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1">Usuario</label>
|
||||
<select name="username" class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm min-w-[130px]">
|
||||
<option value="">Todos</option>
|
||||
{% for u in usuarios %}
|
||||
<option value="{{ u }}" {{ 'selected' if filtro_username == u }}>{{ u }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1">Acción</label>
|
||||
<select name="accion" class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm min-w-[150px]">
|
||||
<option value="">Todas</option>
|
||||
{% for a in acciones %}
|
||||
<option value="{{ a }}" {{ 'selected' if filtro_accion == a }}>{{ a }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1">Desde</label>
|
||||
<input type="date" name="fecha_desde" value="{{ filtro_fecha_desde }}"
|
||||
class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1">Hasta</label>
|
||||
<input type="date" name="fecha_hasta" value="{{ filtro_fecha_hasta }}"
|
||||
class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm">
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button type="submit" class="px-4 py-1.5 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700">
|
||||
<i class="fas fa-search mr-1"></i> Filtrar
|
||||
</button>
|
||||
<a href="/logs/actividad" class="px-3 py-1.5 bg-gray-100 text-gray-600 rounded-lg text-sm hover:bg-gray-200" title="Limpiar">
|
||||
<i class="fas fa-times"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Tabla -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 class="font-semibold text-gray-800"><i class="fas fa-list mr-2 text-gray-400"></i>Eventos</h3>
|
||||
<span class="text-xs text-gray-400">{{ total }} registro(s)</span>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Fecha/Hora</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Usuario</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Acción</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Detalle</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{% for r in rows %}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-2.5 text-xs text-gray-500 font-mono whitespace-nowrap">
|
||||
{{ r.created_at[:16].replace('T',' ') }}
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<span class="font-medium text-gray-700">{{ r.username }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
{% set a = r.accion %}
|
||||
{% if a == 'login' %}
|
||||
<span class="px-2 py-0.5 bg-green-100 text-green-700 rounded-full text-xs font-medium">Login</span>
|
||||
{% elif a == 'login_fallido' %}
|
||||
<span class="px-2 py-0.5 bg-red-100 text-red-700 rounded-full text-xs font-medium">Login fallido</span>
|
||||
{% elif a == 'rda_enviado' %}
|
||||
<span class="px-2 py-0.5 bg-purple-100 text-purple-700 rounded-full text-xs font-medium">RDA enviado</span>
|
||||
{% elif a == 'rda_masivo' %}
|
||||
<span class="px-2 py-0.5 bg-indigo-100 text-indigo-700 rounded-full text-xs font-medium">RDA masivo</span>
|
||||
{% elif a == 'rda_directo' %}
|
||||
<span class="px-2 py-0.5 bg-blue-100 text-blue-700 rounded-full text-xs font-medium">RDA directo</span>
|
||||
{% elif a == 'automation_run' %}
|
||||
<span class="px-2 py-0.5 bg-orange-100 text-orange-700 rounded-full text-xs font-medium">Automatización</span>
|
||||
{% elif a == 'contrato_creado' %}
|
||||
<span class="px-2 py-0.5 bg-teal-100 text-teal-700 rounded-full text-xs font-medium">Contrato creado</span>
|
||||
{% elif a == 'contrato_editado' %}
|
||||
<span class="px-2 py-0.5 bg-yellow-100 text-yellow-700 rounded-full text-xs font-medium">Contrato editado</span>
|
||||
{% elif a == 'contrato_toggle' %}
|
||||
<span class="px-2 py-0.5 bg-gray-100 text-gray-700 rounded-full text-xs font-medium">Contrato toggle</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-0.5 bg-gray-100 text-gray-600 rounded-full text-xs font-medium">{{ a }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-xs text-gray-600 max-w-xs truncate" title="{{ r.detalle }}">
|
||||
{{ r.detalle }}
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-xs text-gray-400 font-mono">{{ r.ip }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="px-4 py-10 text-center text-gray-400">
|
||||
No hay eventos registrados aún
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if has_more or offset > 0 %}
|
||||
<div class="px-6 py-3 border-t border-gray-100 flex justify-between items-center">
|
||||
{% if offset > 0 %}
|
||||
<a href="?username={{ filtro_username }}&accion={{ filtro_accion }}&fecha_desde={{ filtro_fecha_desde }}&fecha_hasta={{ filtro_fecha_hasta }}&offset={{ [offset - page_size, 0]|max }}"
|
||||
class="px-3 py-1.5 bg-gray-100 text-gray-700 rounded-lg text-xs hover:bg-gray-200">
|
||||
<i class="fas fa-chevron-left mr-1"></i> Anterior
|
||||
</a>
|
||||
{% else %}<span></span>{% endif %}
|
||||
<span class="text-xs text-gray-400">{{ offset + 1 }}–{{ [offset + page_size, total]|min }} de {{ total }}</span>
|
||||
{% if has_more %}
|
||||
<a href="?username={{ filtro_username }}&accion={{ filtro_accion }}&fecha_desde={{ filtro_fecha_desde }}&fecha_hasta={{ filtro_fecha_hasta }}&offset={{ offset + page_size }}"
|
||||
class="px-3 py-1.5 bg-gray-100 text-gray-700 rounded-lg text-xs hover:bg-gray-200">
|
||||
Siguiente <i class="fas fa-chevron-right ml-1"></i>
|
||||
</a>
|
||||
{% else %}<span></span>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -70,6 +70,9 @@
|
||||
<a href="/logs" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if request.url.path == '/logs' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">␍
|
||||
<i class="fas fa-history w-5 mr-2"></i> Historial␍
|
||||
</a>␍
|
||||
<a href="/logs/actividad" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium {% if request.url.path == '/logs/actividad' %}bg-blue-600 text-white{% else %}text-gray-300 hover:bg-gray-700{% endif %}">␍
|
||||
<i class="fas fa-user-clock w-5 mr-2"></i> Actividad␍
|
||||
</a>␍
|
||||
<a href="/auth/logout" class="flex items-center px-3 py-2.5 rounded-lg text-sm font-medium text-gray-300 hover:bg-gray-700">␍
|
||||
<i class="fas fa-sign-out-alt w-5 mr-2"></i> Salir␍
|
||||
</a>␍
|
||||
|
||||
@@ -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 ""
|
||||
Reference in New Issue
Block a user