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
+145
View File
@@ -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 %}