v1.7.0 - Fix: Corregido error 500 en SLA Dashboard

- Fix error de sintaxis SQL en cálculo de tickets 'at risk'
- Fix error de timezone (offset-naive vs offset-aware datetimes)
- Implementado sistema completo de SLA Management
- Agregados endpoints: /sla/dashboard, /sla/violations, /sla/at-risk
- Creadas vistas frontend para dashboard, violaciones y tickets en riesgo
- Actualizado sistema de Celery para monitoreo automático de SLAs
- Mejorada configuración de categorías con tiempos SLA personalizables
- Corregidos problemas de proxy en configuración de Vite
- Agregado troubleshooting guide en README

Archivos principales modificados:
- backend/app/api/v1/endpoints/sla.py (nuevo)
- backend/app/api/schemas/sla.py (nuevo)
- frontend-internal/src/routes/sla/ (nuevo módulo completo)
- workers/app/tasks/sla_tasks.py (queries async mejoradas)

Documentación: docs/changelog-2026-02-17.md
This commit is contained in:
2026-02-17 08:22:32 -07:00
parent 42a5bb54cc
commit 75726d915f
17 changed files with 2504 additions and 143 deletions

View File

@@ -8,12 +8,24 @@ from celery import current_task
from datetime import datetime, timedelta
from typing import List, Dict, Any, Optional
import structlog
import asyncio
from sqlalchemy import select, and_, or_, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.celery import celery_app
from app.core.config import get_settings
from app.core.logging import get_logger
from app.core.database import get_async_session_context
from app.tasks.email_tasks import send_templated_email_task
# Import models
import sys
sys.path.insert(0, '../../backend')
from app.models.ticket import Ticket, TicketStatus
from app.models.user import User
from app.models.category import Category
from app.models.tenant import Tenant
settings = get_settings()
logger = get_logger(__name__)
@@ -46,92 +58,161 @@ def check_sla_violations(self) -> Dict[str, Any]:
task_logger.info("SLA check disabled, skipping")
return {"status": "disabled"}
try:
current_time = datetime.utcnow()
results = {
"checked_at": current_time.isoformat(),
"response_violations": [],
"resolution_violations": [],
"warnings": [],
"notifications_sent": 0
}
# TODO: Implement actual database queries
# For now, simulate some checks
# Mock violations for development
mock_violations = [
{
"ticket_id": "mock-ticket-1",
"ticket_number": "TKT-2024-000001",
"subject": "Problema urgente de conexión",
"priority": "HIGH",
"sla_type": "response",
"due_at": (current_time - timedelta(minutes=30)).isoformat(),
"assigned_to_email": "agent@example.com",
"created_by_email": "cliente@example.com",
"tenant_id": "mock-tenant-1"
async def check_violations_async():
"""Inner async function for database operations"""
async with get_async_session_context() as db:
current_time = datetime.utcnow()
results = {
"checked_at": current_time.isoformat(),
"response_violations": [],
"resolution_violations": [],
"warnings": [],
"notifications_sent": 0
}
]
# Process violations
for violation in mock_violations:
task_logger.info(
"Processing SLA violation",
ticket_id=violation["ticket_id"],
sla_type=violation["sla_type"]
)
if violation["sla_type"] == "response":
results["response_violations"].append(violation)
try:
# Query para Response SLA violations
# Tickets sin primera respuesta y con SLA vencido
response_violations_query = select(Ticket).where(
and_(
Ticket.first_response_at == None,
Ticket.sla_response_due != None,
Ticket.sla_response_due < current_time,
Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED])
)
)
# Send notification to assigned agent
if violation["assigned_to_email"]:
response_result = await db.execute(response_violations_query)
response_tickets = response_result.scalars().all()
task_logger.info(f"Found {len(response_tickets)} response SLA violations")
# Procesar violaciones de respuesta
for ticket in response_tickets:
# Cargar relaciones
await db.refresh(ticket, ['created_by', 'assigned_to', 'category'])
violation = {
"ticket_id": str(ticket.id),
"ticket_number": ticket.ticket_number,
"subject": ticket.subject,
"priority": ticket.priority.value,
"sla_type": "response",
"due_at": ticket.sla_response_due.isoformat(),
"assigned_to_email": ticket.assigned_to.email if ticket.assigned_to else None,
"created_by_email": ticket.created_by.email,
"tenant_id": str(ticket.tenant_id)
}
results["response_violations"].append(violation)
task_logger.info(
"Processing response SLA violation",
ticket_id=violation["ticket_id"],
ticket_number=violation["ticket_number"]
)
# Enviar notificación al agente asignado
if violation["assigned_to_email"]:
send_templated_email_task.apply_async(kwargs={
"to_email": violation["assigned_to_email"],
"template_name": "sla_response_violation",
"context": {
"ticket_number": violation["ticket_number"],
"subject": violation["subject"],
"priority": violation["priority"],
"due_at": violation["due_at"],
"ticket_url": f"https://admin.servicemanager.local/tickets/{violation['ticket_id']}"
},
"tenant_id": violation["tenant_id"],
"correlation_id": self.request.id
})
results["notifications_sent"] += 1
# Query para Resolution SLA violations
# Tickets no resueltos con SLA de resolución vencido
resolution_violations_query = select(Ticket).where(
and_(
Ticket.resolved_at == None,
Ticket.sla_resolution_due != None,
Ticket.sla_resolution_due < current_time,
Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED])
)
)
resolution_result = await db.execute(resolution_violations_query)
resolution_tickets = resolution_result.scalars().all()
task_logger.info(f"Found {len(resolution_tickets)} resolution SLA violations")
# Procesar violaciones de resolución
for ticket in resolution_tickets:
await db.refresh(ticket, ['created_by', 'assigned_to', 'category'])
violation = {
"ticket_id": str(ticket.id),
"ticket_number": ticket.ticket_number,
"subject": ticket.subject,
"priority": ticket.priority.value,
"sla_type": "resolution",
"due_at": ticket.sla_resolution_due.isoformat(),
"assigned_to_email": ticket.assigned_to.email if ticket.assigned_to else None,
"created_by_email": ticket.created_by.email,
"tenant_id": str(ticket.tenant_id)
}
results["resolution_violations"].append(violation)
task_logger.info(
"Processing resolution SLA violation",
ticket_id=violation["ticket_id"],
ticket_number=violation["ticket_number"]
)
# Enviar escalación al manager
# TODO: Obtener email del manager desde configuración del tenant
send_templated_email_task.apply_async(kwargs={
"to_email": violation["assigned_to_email"],
"template_name": "sla_response_violation",
"to_email": violation["assigned_to_email"] or "manager@example.com",
"template_name": "sla_resolution_violation",
"context": {
"ticket_number": violation["ticket_number"],
"subject": violation["subject"],
"priority": violation["priority"],
"due_at": violation["due_at"],
"assigned_to": violation["assigned_to_email"] or "Sin asignar",
"ticket_url": f"https://admin.servicemanager.local/tickets/{violation['ticket_id']}"
},
"tenant_id": violation["tenant_id"],
"correlation_id": self.request.id
})
results["notifications_sent"] += 1
elif violation["sla_type"] == "resolution":
results["resolution_violations"].append(violation)
# Send escalation notification
send_templated_email_task.apply_async(kwargs={
"to_email": "manager@example.com", # TODO: Get from tenant config
"template_name": "sla_resolution_violation",
"context": {
"ticket_number": violation["ticket_number"],
"subject": violation["subject"],
"priority": violation["priority"],
"assigned_to": violation["assigned_to_email"],
"ticket_url": f"https://admin.servicemanager.local/tickets/{violation['ticket_id']}"
},
"tenant_id": violation["tenant_id"],
"correlation_id": self.request.id
})
results["notifications_sent"] += 1
task_logger.info(
"SLA violations check completed",
response_violations=len(results["response_violations"]),
resolution_violations=len(results["resolution_violations"]),
warnings=len(results["warnings"]),
notifications_sent=results["notifications_sent"]
)
return results
except Exception as exc:
task_logger.error(
"Error during SLA violations check",
error=str(exc),
exc_info=True
)
raise
try:
# Ejecutar la función async
loop = asyncio.get_event_loop()
if loop.is_running():
# Si ya hay un loop corriendo, crear uno nuevo
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# TODO: Check for SLA warnings (approaching deadline)
task_logger.info(
"SLA violations check completed",
response_violations=len(results["response_violations"]),
resolution_violations=len(results["resolution_violations"]),
warnings=len(results["warnings"]),
notifications_sent=results["notifications_sent"]
)
return results
return loop.run_until_complete(check_violations_async())
except Exception as exc:
task_logger.error(
@@ -171,60 +252,132 @@ def calculate_sla_metrics(self, tenant_id: str, date_from: str, date_to: str) ->
date_to=date_to
)
try:
# TODO: Implement actual database queries
# For now, return mock metrics
metrics = {
"tenant_id": tenant_id,
"date_from": date_from,
"date_to": date_to,
"calculated_at": datetime.utcnow().isoformat(),
"response_sla": {
"target_hours": 2,
"met_count": 45,
"total_count": 50,
"percentage": 90.0,
"avg_response_time_hours": 1.8
},
"resolution_sla": {
"target_hours": 24,
"met_count": 42,
"total_count": 48,
"percentage": 87.5,
"avg_resolution_time_hours": 22.5
},
"by_priority": {
"LOW": {
"response_sla_percentage": 95.0,
"resolution_sla_percentage": 90.0
},
"MEDIUM": {
"response_sla_percentage": 88.0,
"resolution_sla_percentage": 85.0
},
"HIGH": {
"response_sla_percentage": 92.0,
"resolution_sla_percentage": 88.0
},
"URGENT": {
"response_sla_percentage": 85.0,
"resolution_sla_percentage": 80.0
async def calculate_metrics_async():
"""Inner async function for database operations"""
async with get_async_session_context() as db:
try:
from uuid import UUID
tenant_uuid = UUID(tenant_id)
date_from_dt = datetime.fromisoformat(date_from)
date_to_dt = datetime.fromisoformat(date_to)
# Query base para tickets del período
base_query = select(Ticket).where(
and_(
Ticket.tenant_id == tenant_uuid,
Ticket.created_at >= date_from_dt,
Ticket.created_at <= date_to_dt
)
)
result = await db.execute(base_query)
tickets = result.scalars().all()
total_count = len(tickets)
# Calcular métricas de Response SLA
response_met = 0
response_total = 0
response_times = []
for ticket in tickets:
if ticket.sla_response_due:
response_total += 1
if ticket.first_response_at:
if ticket.first_response_at <= ticket.sla_response_due:
response_met += 1
response_time = (ticket.first_response_at - ticket.created_at).total_seconds() / 3600
response_times.append(response_time)
avg_response_time = sum(response_times) / len(response_times) if response_times else 0
response_percentage = (response_met / response_total * 100) if response_total > 0 else 0
# Calcular métricas de Resolution SLA
resolution_met = 0
resolution_total = 0
resolution_times = []
for ticket in tickets:
if ticket.sla_resolution_due:
resolution_total += 1
if ticket.resolved_at:
if ticket.resolved_at <= ticket.sla_resolution_due:
resolution_met += 1
resolution_time = (ticket.resolved_at - ticket.created_at).total_seconds() / 3600
resolution_times.append(resolution_time)
avg_resolution_time = sum(resolution_times) / len(resolution_times) if resolution_times else 0
resolution_percentage = (resolution_met / resolution_total * 100) if resolution_total > 0 else 0
# Métricas por prioridad
by_priority = {}
for priority in ["LOW", "MEDIUM", "HIGH", "URGENT"]:
priority_tickets = [t for t in tickets if t.priority.value == priority]
p_response_met = sum(1 for t in priority_tickets if t.first_response_at and t.sla_response_due and t.first_response_at <= t.sla_response_due)
p_response_total = sum(1 for t in priority_tickets if t.sla_response_due)
p_response_pct = (p_response_met / p_response_total * 100) if p_response_total > 0 else 0
p_resolution_met = sum(1 for t in priority_tickets if t.resolved_at and t.sla_resolution_due and t.resolved_at <= t.sla_resolution_due)
p_resolution_total = sum(1 for t in priority_tickets if t.sla_resolution_due)
p_resolution_pct = (p_resolution_met / p_resolution_total * 100) if p_resolution_total > 0 else 0
by_priority[priority] = {
"response_sla_percentage": round(p_response_pct, 1),
"resolution_sla_percentage": round(p_resolution_pct, 1)
}
metrics = {
"tenant_id": tenant_id,
"date_from": date_from,
"date_to": date_to,
"calculated_at": datetime.utcnow().isoformat(),
"response_sla": {
"target_hours": 2, # Promedio estimado
"met_count": response_met,
"total_count": response_total,
"percentage": round(response_percentage, 1),
"avg_response_time_hours": round(avg_response_time, 1)
},
"resolution_sla": {
"target_hours": 24, # Promedio estimado
"met_count": resolution_met,
"total_count": resolution_total,
"percentage": round(resolution_percentage, 1),
"avg_resolution_time_hours": round(avg_resolution_time, 1)
},
"by_priority": by_priority,
"trends": {
"response_sla_trend": "Calculating...",
"resolution_sla_trend": "Calculating..."
}
}
},
"trends": {
"response_sla_trend": "+2.5%",
"resolution_sla_trend": "-1.2%"
}
}
task_logger.info(
"SLA metrics calculation completed",
response_sla_percentage=metrics["response_sla"]["percentage"],
resolution_sla_percentage=metrics["resolution_sla"]["percentage"],
total_tickets=total_count
)
return metrics
except Exception as exc:
task_logger.error(
"Error during SLA metrics calculation",
error=str(exc),
exc_info=True
)
raise
try:
# Ejecutar la función async
loop = asyncio.get_event_loop()
if loop.is_running():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
task_logger.info(
"SLA metrics calculation completed",
response_sla_percentage=metrics["response_sla"]["percentage"],
resolution_sla_percentage=metrics["resolution_sla"]["percentage"]
)
return metrics
return loop.run_until_complete(calculate_metrics_async())
except Exception as exc:
task_logger.error(