✨ Características Nuevas: - Cálculo automático de SLA en tickets basado en categoría - Auto-asignación de tickets según configuración de categoría - Auditoría completa en operaciones de categorías (create/update/delete) - Visualización de estado SLA en listado y detalle de tickets 🐛 Correcciones: - Fix actualización de status en tenants (manejo correcto de enum TenantStatus) - Corrección de campos contact_phone y contact_email en tenants - Corrección de modelo TicketResponse (agregar campos SLA y usar ConfigDict) - Eliminación de archivo changelog duplicado 🔧 Mejoras de Infraestructura: - Agregar montaje de backend en workers y beat para imports correctos - Mejorar path handling en sla_tasks.py para Docker - Scripts de testing integrados (test_frontend_integration, test_manual, test_tenant_update) - Agregar database.py en workers/app/core para sesiones async 📝 Frontend: - Actualizar UI de tenants con nuevos campos (email, teléfono, status enum) - Agregar columna de SLA en listado de tickets - Mostrar información detallada de SLA en vista de ticket individual - Indicadores visuales de estado de SLA (vencido, cumplido, en plazo)
485 lines
19 KiB
Python
485 lines
19 KiB
Python
"""
|
|
SLA Tasks - ServiceManagerWeb Workers
|
|
|
|
Tareas para monitoreo y gestión de SLAs
|
|
"""
|
|
|
|
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
|
|
import os
|
|
# En Docker, backend está montado en /backend
|
|
backend_path = '/backend' if os.path.exists('/backend') else '../../backend'
|
|
if backend_path not in sys.path:
|
|
sys.path.insert(0, backend_path)
|
|
|
|
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__)
|
|
|
|
|
|
@celery_app.task(
|
|
bind=True,
|
|
time_limit=300,
|
|
soft_time_limit=240
|
|
)
|
|
def check_sla_violations(self) -> Dict[str, Any]:
|
|
"""
|
|
Check for SLA violations and send alerts.
|
|
|
|
This task runs every 5 minutes to check for:
|
|
- Response SLA violations
|
|
- Resolution SLA violations
|
|
- SLA warnings (approaching deadline)
|
|
|
|
Returns:
|
|
Dict with check results
|
|
"""
|
|
task_logger = logger.bind(
|
|
task_id=self.request.id,
|
|
task_name=self.name
|
|
)
|
|
|
|
task_logger.info("Starting SLA violations check")
|
|
|
|
if not settings.SLA_CHECK_ENABLED:
|
|
task_logger.info("SLA check disabled, skipping")
|
|
return {"status": "disabled"}
|
|
|
|
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
|
|
}
|
|
|
|
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])
|
|
)
|
|
)
|
|
|
|
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"] or "manager@example.com",
|
|
"template_name": "sla_resolution_violation",
|
|
"context": {
|
|
"ticket_number": violation["ticket_number"],
|
|
"subject": violation["subject"],
|
|
"priority": violation["priority"],
|
|
"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
|
|
|
|
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)
|
|
|
|
return loop.run_until_complete(check_violations_async())
|
|
|
|
except Exception as exc:
|
|
task_logger.error(
|
|
"SLA violations check failed",
|
|
error=str(exc),
|
|
exc_info=True
|
|
)
|
|
raise
|
|
|
|
|
|
@celery_app.task(
|
|
bind=True,
|
|
time_limit=600,
|
|
soft_time_limit=540
|
|
)
|
|
def calculate_sla_metrics(self, tenant_id: str, date_from: str, date_to: str) -> Dict[str, Any]:
|
|
"""
|
|
Calculate SLA metrics for a tenant and date range.
|
|
|
|
Args:
|
|
tenant_id: Tenant ID
|
|
date_from: Start date (ISO format)
|
|
date_to: End date (ISO format)
|
|
|
|
Returns:
|
|
Dict with SLA metrics
|
|
"""
|
|
task_logger = logger.bind(
|
|
task_id=self.request.id,
|
|
task_name=self.name,
|
|
tenant_id=tenant_id
|
|
)
|
|
|
|
task_logger.info(
|
|
"Starting SLA metrics calculation",
|
|
date_from=date_from,
|
|
date_to=date_to
|
|
)
|
|
|
|
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..."
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
return loop.run_until_complete(calculate_metrics_async())
|
|
|
|
except Exception as exc:
|
|
task_logger.error(
|
|
"SLA metrics calculation failed",
|
|
error=str(exc),
|
|
exc_info=True
|
|
)
|
|
raise
|
|
|
|
|
|
@celery_app.task(
|
|
bind=True,
|
|
time_limit=300,
|
|
soft_time_limit=240
|
|
)
|
|
def send_sla_warnings(self, tenant_id: Optional[str] = None) -> Dict[str, Any]:
|
|
"""
|
|
Send SLA warning notifications for tickets approaching deadline.
|
|
|
|
Args:
|
|
tenant_id: Optional tenant ID to filter by
|
|
|
|
Returns:
|
|
Dict with warning results
|
|
"""
|
|
task_logger = logger.bind(
|
|
task_id=self.request.id,
|
|
task_name=self.name,
|
|
tenant_id=tenant_id
|
|
)
|
|
|
|
task_logger.info("Starting SLA warnings check")
|
|
|
|
try:
|
|
current_time = datetime.utcnow()
|
|
warning_threshold = settings.SLA_WARNING_THRESHOLD # 80% of SLA time
|
|
|
|
# TODO: Query database for tickets approaching SLA deadlines
|
|
|
|
# Mock warnings
|
|
warnings = [
|
|
{
|
|
"ticket_id": "mock-ticket-2",
|
|
"ticket_number": "TKT-2024-000002",
|
|
"subject": "Consulta técnica",
|
|
"priority": "MEDIUM",
|
|
"sla_type": "response",
|
|
"due_at": (current_time + timedelta(minutes=30)).isoformat(),
|
|
"time_remaining_percent": 15.0,
|
|
"assigned_to_email": "agent@example.com",
|
|
"tenant_id": "mock-tenant-1"
|
|
}
|
|
]
|
|
|
|
notifications_sent = 0
|
|
|
|
for warning in warnings:
|
|
if warning["time_remaining_percent"] <= (1 - warning_threshold) * 100:
|
|
task_logger.info(
|
|
"Sending SLA warning",
|
|
ticket_id=warning["ticket_id"],
|
|
time_remaining_percent=warning["time_remaining_percent"]
|
|
)
|
|
|
|
send_templated_email_task.apply_async(kwargs={
|
|
"to_email": warning["assigned_to_email"],
|
|
"template_name": "sla_warning",
|
|
"context": {
|
|
"ticket_number": warning["ticket_number"],
|
|
"subject": warning["subject"],
|
|
"priority": warning["priority"],
|
|
"sla_type": warning["sla_type"],
|
|
"due_at": warning["due_at"],
|
|
"time_remaining_percent": warning["time_remaining_percent"],
|
|
"ticket_url": f"https://admin.servicemanager.local/tickets/{warning['ticket_id']}"
|
|
},
|
|
"tenant_id": warning["tenant_id"],
|
|
"correlation_id": self.request.id
|
|
})
|
|
notifications_sent += 1
|
|
|
|
task_logger.info(
|
|
"SLA warnings check completed",
|
|
warnings_found=len(warnings),
|
|
notifications_sent=notifications_sent
|
|
)
|
|
|
|
return {
|
|
"warnings_found": len(warnings),
|
|
"notifications_sent": notifications_sent,
|
|
"warnings": warnings
|
|
}
|
|
|
|
except Exception as exc:
|
|
task_logger.error(
|
|
"SLA warnings check failed",
|
|
error=str(exc),
|
|
exc_info=True
|
|
)
|
|
raise |