feat: Version 1.11.0 - Mejoras en auditoría, SLA, frontend y correcciones de sincronización

- Refactorización de endpoints de auditoría y helpers
- Mejoras en esquemas de auditoría (audit.py)
- Correcciones en endpoint SLA
- Actualizaciones en múltiples rutas del frontend interno:
  layout, tickets, usuarios, tenants, categorías, sistemas,
  SLA (at-risk, violations), auditoría (main + security), login, perfil
- Actualización de tailwind.config.js
- Eliminación de docs de versiones anteriores (CAMBIOS_v1.10.0, v1.8.0, OPTIMIZACIONES)
- Nuevos scripts de prueba: generate_security_test_data.py, generate_sla_test_data.py
- Script de prueba de sincronización crítica (test_critical_sync.ps1)
- README actualizado en scripts/
This commit is contained in:
2026-02-20 10:53:53 -07:00
parent 517297e89a
commit ceea67eb2b
32 changed files with 5715 additions and 3324 deletions

View File

@@ -57,6 +57,7 @@ class AuditLogResponse(AuditLogBase):
class SecurityThreatPattern(BaseModel):
"""Patrón de amenaza detectado."""
id: str = Field(description="ID único de la amenaza (pattern_id)")
type: str = Field(description="Tipo de amenaza (brute_force, privilege_escalation, etc.)")
severity: str = Field(description="Severidad: low, medium, high, critical")
description: str = Field(description="Descripción de la amenaza")
@@ -65,7 +66,7 @@ class SecurityThreatPattern(BaseModel):
affected_users: list[str] = Field(default=[], description="Usuarios afectados")
first_seen: datetime = Field(description="Primera ocurrencia")
last_seen: datetime = Field(description="Última ocurrencia")
recommendations: list[str] = Field(default=[], description="Recomendaciones de acción")
recommended_action: str = Field(default="", description="Acción recomendada")
class SecurityAnalysisResponse(BaseModel):

View File

@@ -123,7 +123,7 @@ def detect_mass_deletions(logs: List[AuditLog], now: datetime) -> List[dict]:
"status": status,
"incident_type": "mass_deletion",
"affected_user": group['user'],
"source_ip": group['logs'][0].ip_address,
"source_ip": str(group['logs'][0].ip_address) if group['logs'][0].ip_address else None,
"evidence": [f"{log.action} - {log.resource_type} - {log.created_at.strftime('%H:%M:%S')}" for log in group['logs'][:5]],
"metadata": {
"total_deletions": group['count'],
@@ -207,7 +207,7 @@ def detect_privilege_escalation(logs: List[AuditLog]) -> List[dict]:
"status": "investigating",
"incident_type": "privilege_escalation",
"affected_user": log.user.email,
"source_ip": log.ip_address,
"source_ip": str(log.ip_address) if log.ip_address else None,
"evidence": [f"Cambio de rol: {old_role}{new_role} - {log.created_at.strftime('%Y-%m-%d %H:%M')}"],
"metadata": {
"old_role": old_role,

View File

@@ -152,38 +152,47 @@ async def get_security_analysis(all_tenants: bool = Query(False), current_user:
threat_patterns = []
if failed_logins >= 5:
affected_ips_list = [str(log.ip_address) for log in logs if log.action == 'user.login_failed' and log.ip_address]
threat_patterns.append(SecurityThreatPattern(
pattern_id="brute_force_attempt",
id="brute_force_attempt",
type="brute_force",
description=f"Se detectaron {failed_logins} intentos fallidos de login en las últimas 24h",
severity="high" if failed_logins >= 20 else "medium",
occurrences=failed_logins,
first_seen=min((log.created_at for log in logs if log.action == 'user.login_failed'), default=now),
last_seen=max((log.created_at for log in logs if log.action == 'user.login_failed'), default=now),
affected_resources=[str(log.ip_address) for log in logs if log.action == 'user.login_failed' and log.ip_address][:5],
affected_ips=list(set(affected_ips_list))[:5],
affected_users=[],
recommended_action="Considerar bloquear IPs con múltiples fallos"
))
if mass_deletions >= 10:
deleting_users = [log.user.email for log in logs if '.delete' in log.action and log.user]
threat_patterns.append(SecurityThreatPattern(
pattern_id="mass_deletion",
id="mass_deletion",
type="mass_deletion",
description=f"Se detectaron {mass_deletions} eliminaciones en las últimas 24h",
severity="critical" if mass_deletions >= 50 else "high",
occurrences=mass_deletions,
first_seen=min((log.created_at for log in logs if '.delete' in log.action), default=now),
last_seen=max((log.created_at for log in logs if '.delete' in log.action), default=now),
affected_resources=[log.resource_type for log in logs if '.delete' in log.action][:5],
affected_ips=[],
affected_users=list(set(deleting_users))[:5],
recommended_action="Revisar qué usuarios están eliminando recursos"
))
if privilege_changes >= 3:
affected_users_list = [log.user.email for log in logs if log.action == 'user.update' and log.user and log.new_values and 'role' in log.new_values]
threat_patterns.append(SecurityThreatPattern(
pattern_id="suspicious_privilege_changes",
id="suspicious_privilege_changes",
type="privilege_escalation",
description=f"Se detectaron {privilege_changes} cambios de privilegios en las últimas 24h",
severity="high",
occurrences=privilege_changes,
first_seen=min((log.created_at for log in logs if log.action == 'user.update' and log.new_values and 'role' in log.new_values), default=now),
last_seen=max((log.created_at for log in logs if log.action == 'user.update' and log.new_values and 'role' in log.new_values), default=now),
affected_resources=[log.user.email for log in logs if log.action == 'user.update' and log.user and log.new_values and 'role' in log.new_values][:5],
affected_ips=[],
affected_users=list(set(affected_users_list))[:5],
recommended_action="Auditar cambios de roles recientes"
))

File diff suppressed because it is too large Load Diff

View File

@@ -91,7 +91,7 @@ async def get_sla_dashboard(
days=days
)
now = datetime.now(timezone.utc)
now = datetime.now(timezone.utc).replace(tzinfo=None)
period_start = now - timedelta(days=days)
# Usar func.now() para comparaciones en SQL (evita timezone issues)
@@ -357,7 +357,7 @@ async def get_sla_violations(
sla_type=sla_type
)
now = datetime.now(timezone.utc)
now = datetime.now(timezone.utc).replace(tzinfo=None)
db_now = func.now()
# Base query con carga de relaciones
@@ -417,18 +417,16 @@ async def get_sla_violations(
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Aplicar paginación
query = query.order_by(desc(Ticket.created_at)).offset(skip).limit(limit)
# Obtener todos los tickets sin paginación primero (los ordenaremos por tiempo vencido después)
result = await db.execute(query)
tickets = result.scalars().all()
# Formatear response
violations = []
for ticket in tickets:
# Asegurar que los datetimes de BD sean timezone-aware
sla_response_due = ticket.sla_response_due.replace(tzinfo=timezone.utc) if ticket.sla_response_due and ticket.sla_response_due.tzinfo is None else ticket.sla_response_due
sla_resolution_due = ticket.sla_resolution_due.replace(tzinfo=timezone.utc) if ticket.sla_resolution_due and ticket.sla_resolution_due.tzinfo is None else ticket.sla_resolution_due
# Todos los campos son timezone-naive (TIMESTAMP WITHOUT TIME ZONE)
sla_response_due = ticket.sla_response_due
sla_resolution_due = ticket.sla_resolution_due
# Determinar tipo de violación
response_violated = ticket.first_response_at is None and sla_response_due and now > sla_response_due
@@ -479,10 +477,16 @@ async def get_sla_violations(
resolved_at=ticket.resolved_at
))
# Ordenar por tiempo vencido (de mayor a menor)
violations.sort(key=lambda v: v.hours_overdue, reverse=True)
# Aplicar paginación en Python
paginated_violations = violations[skip:skip + limit]
total_pages = (total + limit - 1) // limit
return SLAViolationsListResponse(
violations=violations,
violations=paginated_violations,
total=total,
page=(skip // limit) + 1,
per_page=limit,
@@ -515,13 +519,16 @@ async def get_tickets_at_risk(
threshold=threshold
)
now = datetime.now(timezone.utc)
now = datetime.now(timezone.utc).replace(tzinfo=None)
db_now = func.now()
threshold_decimal = threshold / 100.0
# Query para tickets en riesgo
# Query para tickets en riesgo con relaciones precargadas
# Un ticket está en riesgo si: (now - created_at) / (due_at - created_at) >= threshold
query = select(Ticket).where(
query = select(Ticket).options(
selectinload(Ticket.assigned_to_user),
selectinload(Ticket.category)
).where(
and_(
Ticket.tenant_id == current_tenant.id,
Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED]),
@@ -576,14 +583,15 @@ async def get_tickets_at_risk(
else:
continue
# Normalizar created_at a timezone-naive para evitar errores de comparación
created_at = ticket.created_at.replace(tzinfo=None) if ticket.created_at.tzinfo else ticket.created_at
time_remaining = (due_at - now).total_seconds() / 3600
total_time = (due_at - ticket.created_at).total_seconds() / 3600
total_time = (due_at - created_at).total_seconds() / 3600
elapsed_time = total_time - time_remaining
risk_percentage = (elapsed_time / total_time * 100) if total_time > 0 else 0
# Cargar relaciones
await db.refresh(ticket, ['assigned_to', 'category'])
# Las relaciones ya están cargadas por selectinload
at_risk_tickets.append(SLATicketAtRisk(
ticket=TicketBasicInfo(
id=ticket.id,
@@ -599,11 +607,11 @@ async def get_tickets_at_risk(
sla_resolution_hours=ticket.category.sla_resolution_hours
) if ticket.category else None,
assigned_to=UserBasicInfo(
id=ticket.assigned_to.id,
first_name=ticket.assigned_to.first_name,
last_name=ticket.assigned_to.last_name,
email=ticket.assigned_to.email
) if ticket.assigned_to else None,
id=ticket.assigned_to_user.id,
first_name=ticket.assigned_to_user.first_name,
last_name=ticket.assigned_to_user.last_name,
email=ticket.assigned_to_user.email
) if ticket.assigned_to_user else None,
sla_type=sla_type,
sla_due_at=due_at,
time_remaining_hours=time_remaining,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,193 @@
# Scripts de Prueba de Seguridad
Scripts para generar datos de prueba para el análisis de seguridad.
## 📋 Scripts Disponibles
### 1. `generate_security_test_data.py`
Genera un conjunto completo de logs de auditoría para probar todas las funcionalidades del análisis de seguridad.
#### Uso
```bash
# Asegúrate de estar en el entorno virtual
cd backend
python scripts/generate_security_test_data.py
```
#### Qué Genera
- **25 intentos fallidos de login** → Amenaza HIGH de fuerza bruta
- **55 eliminaciones masivas** → Amenaza CRITICAL
- **5 cambios de privilegios** → Amenaza HIGH de escalación de privilegios
- **20 logs normales** → Actividad regular para contexto
#### Limpiar Datos de Prueba
```bash
python scripts/generate_security_test_data.py cleanup
```
Esto eliminará **TODOS** los logs de auditoría de las últimas 24 horas.
---
## 🎯 Escenarios de Prueba
### Escenario 1: Sistema Limpio (Sin Amenazas)
```bash
# Limpiar todos los logs
python scripts/generate_security_test_data.py cleanup
```
**Resultado esperado:**
- Dashboard con todos los contadores en 0
- Tab "Crítico" vacío
- Mensaje: "Sistema Seguro"
---
### Escenario 2: Solo Amenazas Leves
Modifica el script para generar solo 6 intentos fallidos (MEDIUM severity):
```python
# En generate_security_test_data.py, línea ~70
for i in range(6): # Cambiar de 25 a 6
```
**Resultado esperado:**
- 1 amenaza MEDIUM en tab correspondiente
- Tab "Crítico" vacío
- Nivel de riesgo: LOW o MEDIUM
---
### Escenario 3: Amenazas Críticas
Ejecuta el script completo:
```bash
python scripts/generate_security_test_data.py
```
**Resultado esperado:**
- 1 amenaza CRÍTICA (eliminaciones masivas)
- 2 amenazas HIGH (login fallidos + privilegios)
- Tab "Crítico" con 1 amenaza
- Nivel de riesgo: CRITICAL
---
## 🔒 Umbrales de Detección
| Tipo de Amenaza | Umbral Detección | Severidades |
|-----------------|------------------|-------------|
| **Fuerza Bruta** | ≥5 intentos fallidos | MEDIUM (5-19), HIGH (≥20) |
| **Eliminaciones Masivas** | ≥10 eliminaciones | HIGH (10-49), **CRITICAL (≥50)** |
| **Cambios de Privilegios** | ≥3 cambios de rol | HIGH (siempre) |
---
## 🧪 Verificar Resultados
1. **Accede al panel de auditoría**: http://localhost:3001/audit/security
2. **Verifica los contadores del dashboard:**
- Nivel de Riesgo
- Amenazas Detectadas
- Intentos Fallidos
- IPs Sospechosas
- Acciones Críticas
3. **Prueba los tabs:**
- Todas: Debe mostrar amenazas activas
- Crítico: Solo amenazas critical (si hay)
- High: Amenazas de alta severidad
- Medium: Amenazas de severidad media
- Low: Amenazas de baja severidad
- Resueltas: Amenazas marcadas como resueltas
4. **Prueba la búsqueda:**
- Busca por IP: `192.168.1.100`
- Busca por descripción: `intentos fallidos`
- Busca por tipo: `brute_force`
5. **Prueba los filtros:**
- Filtra por tipo de amenaza
- Combina búsqueda + filtro
6. **Prueba las acciones:**
- Selecciona múltiples amenazas
- Resuelve en batch
- Marca como resuelta individualmente
- Reabre amenazas resueltas
---
## ⚠️ Advertencias
- **NO ejecutar en producción**: Estos scripts son SOLO para desarrollo/testing
- **Los datos son ficticios**: IPs, usuarios y acciones son simulados
- **Cleanup elimina TODO**: El comando cleanup elimina TODOS los logs de las últimas 24h, no solo los de prueba
---
## 🐛 Troubleshooting
### Error: "No se encontró ningún tenant"
```bash
# Ejecuta las migraciones
cd backend
alembic upgrade head
```
### Error: "No se encontró ningún usuario"
```bash
# Crea un usuario de prueba
python scripts/create_test_user.py
```
### La página no muestra amenazas
- Verifica que el backend esté corriendo: `uvicorn app.main:app --reload`
- Revisa la consola del navegador para errores
- Verifica que los logs se crearon: `SELECT COUNT(*) FROM audit_logs WHERE created_at >= NOW() - INTERVAL '24 hours';`
### Las fechas no son de hoy
- Los logs se crean con timestamps aleatorios en las últimas 24h
- Si todos tienen la misma fecha, es porque se generaron en el mismo segundo (normal)
---
## 📝 Personalizar Generación
Para crear escenarios personalizados, edita `generate_security_test_data.py`:
```python
# Cambiar cantidad de intentos fallidos
for i in range(50): # Más intentos = mayor severidad
# Cambiar IPs sospechosas
suspicious_ips = ["1.2.3.4", "5.6.7.8"]
# Cambiar período temporal
time_offset = timedelta(hours=12) # Todos en las últimas 12h
# Agregar más tipos de amenazas
# Agrega nuevos bloques de generación siguiendo el patrón
```
---
## 🚀 Flujo Recomendado de Prueba
1. **Limpia el sistema**: `python scripts/generate_security_test_data.py cleanup`
2. **Verifica sistema limpio**: Accede a la página, debe estar vacía
3. **Genera datos completos**: `python scripts/generate_security_test_data.py`
4. **Prueba todas las funcionalidades**: tabs, filtros, búsqueda, acciones
5. **Marca algunas como resueltas**: Prueba el flujo de resolución
6. **Verifica tab "Resueltas"**: Confirma que aparecen ahí
7. **Reabre algunas**: Prueba el flujo de reapertura
8. **Limpia al finalizar**: `python scripts/generate_security_test_data.py cleanup`

View File

@@ -0,0 +1,240 @@
"""
Script para generar datos de prueba de seguridad en logs de auditoría.
Esto permite probar la funcionalidad de análisis de seguridad con diferentes tipos de amenazas.
"""
import asyncio
import sys
from pathlib import Path
from datetime import datetime, timedelta, timezone
import uuid
import random
# Agregar el directorio raíz al path
sys.path.insert(0, str(Path(__file__).parent.parent))
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import AsyncSessionLocal
from app.models.audit import AuditLog
from app.models.user import User
from app.models.tenant import Tenant
async def generate_test_data():
"""Genera logs de auditoría de prueba para análisis de seguridad."""
async with AsyncSessionLocal() as db:
# Obtener tenant y usuarios de prueba
tenant_result = await db.execute(select(Tenant).limit(1))
tenant = tenant_result.scalar_one_or_none()
if not tenant:
print("❌ No se encontró ningún tenant. Ejecuta las migraciones primero.")
return
user_result = await db.execute(select(User).where(User.tenant_id == tenant.id).limit(1))
user = user_result.scalar_one_or_none()
if not user:
print("❌ No se encontró ningún usuario. Crea un usuario primero.")
return
print(f"✅ Usando tenant: {tenant.name}")
print(f"✅ Usando usuario: {user.email}")
print()
now = datetime.now(timezone.utc)
# IPs de prueba
suspicious_ips = [
"192.168.1.100",
"10.0.0.50",
"172.16.0.10",
"203.0.113.42",
"198.51.100.88"
]
logs_created = 0
# ============================================
# 1. GENERAR INTENTOS FALLIDOS DE LOGIN (Fuerza Bruta)
# ============================================
print("🔐 Generando intentos fallidos de login...")
# Generar 25 intentos fallidos (esto hará que sea HIGH severity)
for i in range(25):
time_offset = timedelta(hours=random.randint(0, 23), minutes=random.randint(0, 59))
log = AuditLog(
id=uuid.uuid4(),
tenant_id=tenant.id,
user_id=user.id,
action="user.login_failed",
resource_type="auth",
resource_id=None,
ip_address=random.choice(suspicious_ips),
user_agent="Mozilla/5.0 (Test Browser)",
metadata={"reason": "invalid_credentials", "username": f"test_user_{i}"},
created_at=now - time_offset
)
db.add(log)
logs_created += 1
print(f" ✓ Creados {25} intentos fallidos de login (HIGH severity)")
# ============================================
# 2. GENERAR ELIMINACIONES MASIVAS (CRITICAL)
# ============================================
print("🗑️ Generando eliminaciones masivas...")
resources = ["ticket", "comment", "attachment", "category", "user"]
# Generar 55 eliminaciones (esto hará que sea CRITICAL severity)
for i in range(55):
time_offset = timedelta(hours=random.randint(0, 23), minutes=random.randint(0, 59))
resource = random.choice(resources)
log = AuditLog(
id=uuid.uuid4(),
tenant_id=tenant.id,
user_id=user.id,
action=f"{resource}.delete",
resource_type=resource,
resource_id=uuid.uuid4(),
ip_address=random.choice(suspicious_ips),
user_agent="Mozilla/5.0 (Test Browser)",
metadata={"deleted_by": user.email},
created_at=now - time_offset
)
db.add(log)
logs_created += 1
print(f" ✓ Creadas {55} eliminaciones masivas (CRITICAL severity)")
# ============================================
# 3. GENERAR CAMBIOS DE PRIVILEGIOS (HIGH)
# ============================================
print("👤 Generando cambios de privilegios...")
roles = ["AGENT", "CLIENT_USER", "AUDITOR", "SUPPORT_MANAGER", "ADMIN"]
# Generar 5 cambios de rol (esto hará que sea HIGH severity)
for i in range(5):
time_offset = timedelta(hours=random.randint(0, 23), minutes=random.randint(0, 59))
old_role = random.choice(roles)
new_role = random.choice([r for r in roles if r != old_role])
log = AuditLog(
id=uuid.uuid4(),
tenant_id=tenant.id,
user_id=user.id,
action="user.update",
resource_type="user",
resource_id=uuid.uuid4(),
ip_address=random.choice(suspicious_ips),
user_agent="Mozilla/5.0 (Test Browser)",
old_values={"role": old_role},
new_values={"role": new_role},
metadata={"changed_by": user.email},
created_at=now - time_offset
)
db.add(log)
logs_created += 1
print(f" ✓ Creados {5} cambios de privilegios (HIGH severity)")
# ============================================
# 4. GENERAR LOGS NORMALES (para dar contexto)
# ============================================
print("📋 Generando logs de actividad normal...")
normal_actions = [
"ticket.create",
"ticket.update",
"comment.create",
"user.login",
"ticket.view",
]
for i in range(20):
time_offset = timedelta(hours=random.randint(0, 23), minutes=random.randint(0, 59))
action = random.choice(normal_actions)
log = AuditLog(
id=uuid.uuid4(),
tenant_id=tenant.id,
user_id=user.id,
action=action,
resource_type=action.split('.')[0],
resource_id=uuid.uuid4(),
ip_address=random.choice(suspicious_ips),
user_agent="Mozilla/5.0 (Test Browser)",
metadata={"action": "normal_activity"},
created_at=now - time_offset
)
db.add(log)
logs_created += 1
print(f" ✓ Creados {20} logs de actividad normal")
# Guardar todo
await db.commit()
print()
print("=" * 60)
print(f"✅ GENERACIÓN COMPLETADA")
print(f" Total de logs creados: {logs_created}")
print()
print("📊 Amenazas esperadas en el análisis:")
print(" 🔴 1 amenaza CRÍTICA: 55 eliminaciones masivas")
print(" 🟠 1 amenaza HIGH: 25 intentos fallidos de login")
print(" 🟠 1 amenaza HIGH: 5 cambios de privilegios")
print()
print("🌐 Accede a la página de seguridad para ver el análisis")
print("=" * 60)
async def cleanup_test_data():
"""Elimina los logs de auditoría de prueba."""
async with AsyncSessionLocal() as db:
tenant_result = await db.execute(select(Tenant).limit(1))
tenant = tenant_result.scalar_one_or_none()
if not tenant:
print("❌ No se encontró ningún tenant.")
return
# Eliminar logs de las últimas 24 horas
now = datetime.now(timezone.utc)
cutoff = now - timedelta(hours=24)
result = await db.execute(
select(AuditLog).where(
AuditLog.tenant_id == tenant.id,
AuditLog.created_at >= cutoff
)
)
logs = result.scalars().all()
if not logs:
print(" No hay logs de prueba para eliminar.")
return
for log in logs:
await db.delete(log)
await db.commit()
print(f"✅ Eliminados {len(logs)} logs de prueba de las últimas 24 horas")
if __name__ == "__main__":
import sys
if len(sys.argv) > 1 and sys.argv[1] == "cleanup":
print("🧹 Limpiando datos de prueba...")
asyncio.run(cleanup_test_data())
else:
print("🚀 Generando datos de prueba para análisis de seguridad...")
print()
asyncio.run(generate_test_data())
print()
print("💡 Para limpiar estos datos de prueba, ejecuta:")
print(" python scripts/generate_security_test_data.py cleanup")

View File

@@ -0,0 +1,399 @@
"""
Script para generar datos de prueba de SLA Management.
Crea tickets con diferentes estados de SLA para probar el dashboard.
"""
import asyncio
import sys
from pathlib import Path
from datetime import datetime, timedelta, timezone
import uuid
import random
# Agregar el directorio raíz al path
sys.path.insert(0, str(Path(__file__).parent.parent))
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import AsyncSessionLocal
from app.models.ticket import Ticket, TicketStatus, TicketPriority
from app.models.category import Category
from app.models.user import User
from app.models.tenant import Tenant
from app.models.system import System
async def generate_sla_test_data():
"""Genera tickets de prueba con diferentes estados de SLA."""
async with AsyncSessionLocal() as db:
# Obtener tenant y usuarios
tenant_result = await db.execute(select(Tenant).limit(1))
tenant = tenant_result.scalar_one_or_none()
if not tenant:
print("❌ No se encontró ningún tenant. Ejecuta las migraciones primero.")
return
# Obtener usuarios
users_result = await db.execute(
select(User).where(User.tenant_id == tenant.id).limit(5)
)
users = list(users_result.scalars().all())
if not users:
print("❌ No se encontraron usuarios. Crea usuarios primero.")
return
creator = users[0]
agents = users if len(users) > 1 else [creator]
# Obtener o crear categorías
categories_result = await db.execute(
select(Category).where(Category.tenant_id == tenant.id)
)
categories = list(categories_result.scalars().all())
if not categories:
print("📁 Creando categorías de prueba...")
category_data = [
{"name": "Soporte Técnico", "sla_response_hours": 2, "sla_resolution_hours": 24, "color": "#3B82F6"},
{"name": "Facturación", "sla_response_hours": 4, "sla_resolution_hours": 48, "color": "#10B981"},
{"name": "Incidente Crítico", "sla_response_hours": 1, "sla_resolution_hours": 8, "color": "#EF4444"},
{"name": "Consulta General", "sla_response_hours": 8, "sla_resolution_hours": 72, "color": "#6B7280"},
]
for cat_data in category_data:
category = Category(
id=uuid.uuid4(),
tenant_id=tenant.id,
name=cat_data["name"],
description=f"Categoría de {cat_data['name']}",
color=cat_data["color"],
sla_response_hours=cat_data["sla_response_hours"],
sla_resolution_hours=cat_data["sla_resolution_hours"],
is_active=True
)
db.add(category)
categories.append(category)
await db.commit()
print(f" ✓ Creadas {len(categories)} categorías")
# Obtener o crear sistemas afectados
systems_result = await db.execute(
select(System).where(System.tenant_id == tenant.id)
)
systems = list(systems_result.scalars().all())
if not systems:
print("🖥️ Creando sistemas de prueba...")
system_names = ["Portal Web", "API REST", "Base de Datos", "Sistema de Pagos"]
for sys_name in system_names:
system = System(
id=uuid.uuid4(),
tenant_id=tenant.id,
name=sys_name,
description=f"Sistema {sys_name}",
is_active=True
)
db.add(system)
systems.append(system)
await db.commit()
print(f" ✓ Creados {len(systems)} sistemas")
print(f"✅ Usando tenant: {tenant.name}")
print(f"✅ Usuarios disponibles: {len(users)}")
print(f"✅ Categorías disponibles: {len(categories)}")
print()
now = datetime.now(timezone.utc)
tickets_created = 0
# Función auxiliar para crear ticket
def create_ticket(
subject: str,
description: str,
priority: TicketPriority,
status: TicketStatus,
category: Category,
created_hours_ago: int,
first_response_hours_after: int = None,
resolved_hours_after: int = None,
assigned: bool = True
):
nonlocal tickets_created
ticket_id = uuid.uuid4()
created_at = now - timedelta(hours=created_hours_ago)
# Calcular SLA deadlines basados en la categoría (sin timezone para la BD)
sla_response_due = (created_at + timedelta(hours=category.sla_response_hours)).replace(tzinfo=None)
sla_resolution_due = (created_at + timedelta(hours=category.sla_resolution_hours)).replace(tzinfo=None)
# Primera respuesta (si aplica)
first_response_at = None
if first_response_hours_after is not None:
first_response_at = (created_at + timedelta(hours=first_response_hours_after)).replace(tzinfo=None)
# Resolución (si aplica)
resolved_at = None
if resolved_hours_after is not None:
resolved_at = (created_at + timedelta(hours=resolved_hours_after)).replace(tzinfo=None)
ticket = Ticket(
id=ticket_id,
tenant_id=tenant.id,
ticket_number=f"TKT-{1000 + tickets_created}",
subject=subject,
description=description,
status=status,
priority=priority,
created_by=creator.id,
assigned_to=random.choice(agents).id if assigned else None,
category_id=category.id,
affected_system_id=random.choice(systems).id if systems else None,
sla_response_due=sla_response_due,
sla_resolution_due=sla_resolution_due,
first_response_at=first_response_at,
resolved_at=resolved_at,
created_at=created_at,
updated_at=resolved_at or first_response_at or created_at
)
db.add(ticket)
tickets_created += 1
return ticket
# ============================================
# 1. TICKETS CUMPLIENDO SLA RESPONSE (Verde)
# ============================================
print("✅ Generando tickets CUMPLIENDO Response SLA...")
for i in range(15):
category = random.choice(categories)
priority = random.choice([TicketPriority.LOW, TicketPriority.MEDIUM, TicketPriority.HIGH])
# Creado hace X horas, respondido ANTES del deadline
created_hours_ago = random.randint(24, 120)
response_time = random.uniform(0.5, category.sla_response_hours * 0.7) # 70% del SLA
status = random.choice([TicketStatus.IN_PROGRESS, TicketStatus.WAITING_CUSTOMER])
create_ticket(
subject=f"Ticket con respuesta a tiempo #{i+1}",
description=f"Este ticket fue respondido dentro del SLA de {category.name}",
priority=priority,
status=status,
category=category,
created_hours_ago=created_hours_ago,
first_response_hours_after=response_time,
assigned=True
)
print(f" ✓ Creados 15 tickets cumpliendo Response SLA")
# ============================================
# 2. TICKETS VIOLANDO SLA RESPONSE (Rojo)
# ============================================
print("🔴 Generando tickets VIOLANDO Response SLA...")
for i in range(8):
category = random.choice(categories)
priority = random.choice([TicketPriority.HIGH, TicketPriority.URGENT])
# Creado hace más tiempo que el SLA, SIN respuesta
created_hours_ago = category.sla_response_hours + random.randint(1, 10)
create_ticket(
subject=f"Ticket SIN respuesta - VIOLACIÓN #{i+1}",
description=f"Este ticket lleva {created_hours_ago}h sin respuesta (SLA: {category.sla_response_hours}h)",
priority=priority,
status=random.choice([TicketStatus.NEW, TicketStatus.TRIAGE]),
category=category,
created_hours_ago=created_hours_ago,
first_response_hours_after=None, # Sin respuesta!
assigned=random.choice([True, False])
)
print(f" ✓ Creados 8 tickets VIOLANDO Response SLA")
# ============================================
# 3. TICKETS EN RIESGO Response (Amarillo)
# ============================================
print("⚠️ Generando tickets EN RIESGO Response SLA...")
for i in range(10):
category = random.choice(categories)
priority = random.choice([TicketPriority.MEDIUM, TicketPriority.HIGH, TicketPriority.URGENT])
# Creado hace tiempo, cerca del deadline (80-95% consumido)
sla_hours = category.sla_response_hours
time_consumed = random.uniform(0.8, 0.95) * sla_hours
created_hours_ago = time_consumed
create_ticket(
subject=f"Ticket cerca de vencer respuesta #{i+1}",
description=f"Este ticket está al {int(time_consumed/sla_hours*100)}% del SLA de respuesta",
priority=priority,
status=random.choice([TicketStatus.TRIAGE, TicketStatus.NEW]),
category=category,
created_hours_ago=created_hours_ago,
first_response_hours_after=None, # Aún sin respuesta
assigned=True
)
print(f" ✓ Creados 10 tickets EN RIESGO Response SLA")
# ============================================
# 4. TICKETS CUMPLIENDO SLA RESOLUTION
# ============================================
print("✅ Generando tickets CUMPLIENDO Resolution SLA...")
for i in range(20):
category = random.choice(categories)
priority = random.choice([TicketPriority.LOW, TicketPriority.MEDIUM, TicketPriority.HIGH])
# Creado, respondido y resuelto dentro del SLA
created_hours_ago = random.randint(72, 240)
response_time = random.uniform(1, category.sla_response_hours * 0.5)
resolution_time = random.uniform(
response_time + 1,
category.sla_resolution_hours * 0.8
)
create_ticket(
subject=f"Ticket resuelto a tiempo #{i+1}",
description=f"Este ticket fue resuelto dentro del SLA de {category.name}",
priority=priority,
status=random.choice([TicketStatus.RESOLVED, TicketStatus.CLOSED]),
category=category,
created_hours_ago=created_hours_ago,
first_response_hours_after=response_time,
resolved_hours_after=resolution_time,
assigned=True
)
print(f" ✓ Creados 20 tickets cumpliendo Resolution SLA")
# ============================================
# 5. TICKETS VIOLANDO SLA RESOLUTION
# ============================================
print("🔴 Generando tickets VIOLANDO Resolution SLA...")
for i in range(6):
category = random.choice(categories)
priority = random.choice([TicketPriority.HIGH, TicketPriority.URGENT])
# Creado hace más del SLA de resolución, con respuesta pero sin resolver
created_hours_ago = category.sla_resolution_hours + random.randint(5, 48)
response_time = random.uniform(1, category.sla_response_hours * 0.5)
create_ticket(
subject=f"Ticket sin resolver - VIOLACIÓN #{i+1}",
description=f"Ticket lleva {created_hours_ago}h sin resolver (SLA: {category.sla_resolution_hours}h)",
priority=priority,
status=random.choice([TicketStatus.IN_PROGRESS, TicketStatus.WAITING_CUSTOMER]),
category=category,
created_hours_ago=created_hours_ago,
first_response_hours_after=response_time,
resolved_hours_after=None, # Sin resolver!
assigned=True
)
print(f" ✓ Creados 6 tickets VIOLANDO Resolution SLA")
# ============================================
# 6. TICKETS EN RIESGO Resolution
# ============================================
print("⚠️ Generando tickets EN RIESGO Resolution SLA...")
for i in range(12):
category = random.choice(categories)
priority = random.choice([TicketPriority.MEDIUM, TicketPriority.HIGH])
# Con respuesta, cerca del deadline de resolución
sla_hours = category.sla_resolution_hours
time_consumed = random.uniform(0.75, 0.95) * sla_hours
created_hours_ago = time_consumed
response_time = random.uniform(0.5, category.sla_response_hours * 0.5)
create_ticket(
subject=f"Ticket cerca de vencer resolución #{i+1}",
description=f"Este ticket está al {int(time_consumed/sla_hours*100)}% del SLA de resolución",
priority=priority,
status=TicketStatus.IN_PROGRESS,
category=category,
created_hours_ago=created_hours_ago,
first_response_hours_after=response_time,
resolved_hours_after=None,
assigned=True
)
print(f" ✓ Creados 12 tickets EN RIESGO Resolution SLA")
# Guardar todos los tickets
await db.commit()
print()
print("=" * 70)
print("✅ GENERACIÓN DE DATOS SLA COMPLETADA")
print(f" Total de tickets creados: {tickets_created}")
print()
print("📊 Distribución esperada:")
print(" ✅ Response cumplidos: 15 tickets")
print(" 🔴 Response violados: 8 tickets")
print(" ⚠️ Response en riesgo: 10 tickets")
print(" ✅ Resolution cumplidos: 20 tickets")
print(" 🔴 Resolution violados: 6 tickets")
print(" ⚠️ Resolution en riesgo: 12 tickets")
print()
print("🌐 Ve los resultados en:")
print(" Dashboard SLA: http://localhost:3001/sla")
print("=" * 70)
async def cleanup_sla_test_data():
"""Elimina tickets de prueba."""
async with AsyncSessionLocal() as db:
tenant_result = await db.execute(select(Tenant).limit(1))
tenant = tenant_result.scalar_one_or_none()
if not tenant:
print("❌ No se encontró ningún tenant.")
return
# Eliminar tickets que empiezan con TKT-
result = await db.execute(
select(Ticket).where(
Ticket.tenant_id == tenant.id,
Ticket.ticket_number.like('TKT-%')
)
)
tickets = result.scalars().all()
if not tickets:
print(" No hay tickets de prueba para eliminar.")
return
for ticket in tickets:
await db.delete(ticket)
await db.commit()
print(f"✅ Eliminados {len(tickets)} tickets de prueba")
if __name__ == "__main__":
import sys
if len(sys.argv) > 1 and sys.argv[1] == "cleanup":
print("🧹 Limpiando datos de prueba de SLA...")
print()
asyncio.run(cleanup_sla_test_data())
else:
print("🚀 Generando datos de prueba para SLA Management...")
print()
asyncio.run(generate_sla_test_data())
print()
print("💡 Para limpiar estos datos de prueba, ejecuta:")
print(" python scripts/generate_sla_test_data.py cleanup")