v1.15.1 - modulo de reportes implementado
- Nuevo módulo de reportes: backend/app/api/v1/endpoints/reports.py - Schemas de reportes: backend/app/api/schemas/reports.py - Frontend: frontend-internal/src/routes/reports/ - Mejoras al módulo de auditoría (audit.py, audit_helpers.py) - Modelo de auditoría actualizado - Sidebar actualizado con enlace a reportes
This commit is contained in:
@@ -1,4 +1,29 @@
|
||||
"""Audit Endpoints - ServiceManagerWeb"""
|
||||
"""
|
||||
Audit Endpoints - ServiceManagerWeb
|
||||
====================================
|
||||
Este archivo maneja todos los endpoints de auditoría y seguridad.
|
||||
Rutas disponibles:
|
||||
GET /audit/ → Lista de logs con filtros y paginación
|
||||
GET /audit/stats → Estadísticas generales de auditoría
|
||||
GET /audit/{log_id} → Detalle de un log específico
|
||||
GET /audit/security/analysis → Análisis de amenazas en tiempo real
|
||||
POST /audit/security/action → Ejecutar acción de seguridad (bloquear IP, etc.)
|
||||
GET /audit/security/incidents → Lista de incidentes detectados
|
||||
|
||||
CORRECCIONES APLICADAS:
|
||||
1. Todos los endpoints usan datetime.now(timezone.utc) para generar
|
||||
fechas aware (con timezone info en UTC), compatibles con la columna
|
||||
'timestamp with time zone' (TIMESTAMPTZ) de PostgreSQL.
|
||||
|
||||
2. audit_helpers.get_count_stat() convierte las fechas a aware UTC
|
||||
con _ensure_aware_utc() antes de usarlas en queries, resolviendo
|
||||
el bug donde los tres contadores (total, hoy, semana) devolvían
|
||||
el mismo valor porque el filtro de fecha se ignoraba.
|
||||
|
||||
3. critical_actions_today usa los mismos umbrales que /security/incidents
|
||||
para que el contador del dashboard coincida con la lista de detalles.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, or_, desc
|
||||
@@ -24,31 +49,83 @@ from app.api.v1.audit_helpers import (
|
||||
detect_mass_deletions, detect_brute_force, detect_privilege_escalation
|
||||
)
|
||||
|
||||
# Instancia del router de FastAPI para este módulo
|
||||
router = APIRouter()
|
||||
|
||||
# Logger estructurado para registrar eventos internos del sistema
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DEPENDENCIA DE AUTORIZACIÓN
|
||||
# =============================================================================
|
||||
|
||||
def require_auditor_role(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""Verifica que el usuario tenga rol de auditor"""
|
||||
"""
|
||||
Dependencia reutilizable que verifica que el usuario tenga permisos
|
||||
para ver logs de auditoría.
|
||||
|
||||
Solo pueden acceder los roles: ADMIN, SUPPORT_MANAGER, AUDITOR.
|
||||
Si no tiene el rol correcto, lanza un error 403 Forbidden.
|
||||
"""
|
||||
if current_user.role not in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AUDITOR]:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Solo usuarios con rol ADMIN, SUPPORT_MANAGER o AUDITOR pueden acceder a logs de auditoría")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Solo usuarios con rol ADMIN, SUPPORT_MANAGER o AUDITOR pueden acceder a logs de auditoría"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENDPOINT: LISTA DE LOGS DE AUDITORÍA
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/", response_model=AuditLogListResponse)
|
||||
async def get_audit_logs(page: int = Query(default=1, ge=1), per_page: int = Query(default=50, ge=1, le=100),
|
||||
user_id: Optional[uuid.UUID] = Query(None), action: Optional[str] = Query(None),
|
||||
resource_type: Optional[str] = Query(None), resource_id: Optional[uuid.UUID] = Query(None),
|
||||
date_from: Optional[datetime] = Query(None), date_to: Optional[datetime] = Query(None),
|
||||
search: Optional[str] = Query(None), tenant_id: Optional[uuid.UUID] = Query(None),
|
||||
all_tenants: bool = Query(False), current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)):
|
||||
"""Obtener logs de auditoría con filtros y paginación"""
|
||||
logger.info("Fetching audit logs", user_id=str(current_user.id), tenant_id=str(current_tenant.id),
|
||||
filters={"user_id": str(user_id) if user_id else None, "action": action, "page": page, "all_tenants": all_tenants})
|
||||
|
||||
async def get_audit_logs(
|
||||
# Paginación
|
||||
page: int = Query(default=1, ge=1),
|
||||
per_page: int = Query(default=50, ge=1, le=100),
|
||||
# Filtros opcionales
|
||||
user_id: Optional[uuid.UUID] = Query(None),
|
||||
action: Optional[str] = Query(None),
|
||||
resource_type: Optional[str] = Query(None),
|
||||
resource_id: Optional[uuid.UUID] = Query(None),
|
||||
date_from: Optional[datetime] = Query(None),
|
||||
date_to: Optional[datetime] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
tenant_id: Optional[uuid.UUID] = Query(None),
|
||||
all_tenants: bool = Query(False),
|
||||
# Dependencias de autenticación y base de datos
|
||||
current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Obtener el historial completo de logs de auditoría con filtros opcionales.
|
||||
|
||||
Soporta filtrar por usuario, tipo de acción, recurso afectado, fechas
|
||||
y búsqueda de texto. También soporta ver logs de todos los tenants
|
||||
si el usuario tiene permisos de ADMIN o SUPPORT_MANAGER.
|
||||
"""
|
||||
logger.info(
|
||||
"Obteniendo logs de auditoria",
|
||||
user_id=str(current_user.id),
|
||||
tenant_id=str(current_tenant.id),
|
||||
filters={
|
||||
"user_id": str(user_id) if user_id else None,
|
||||
"action": action,
|
||||
"page": page,
|
||||
"all_tenants": all_tenants
|
||||
}
|
||||
)
|
||||
|
||||
# Construir la query base con relación al usuario que hizo la acción
|
||||
query = select(AuditLog).options(selectinload(AuditLog.user))
|
||||
|
||||
# Aplicar filtro de tenant según permisos del usuario
|
||||
query = apply_tenant_filter(query, current_user, current_tenant, all_tenants, tenant_id)
|
||||
|
||||
|
||||
# Aplicar filtros opcionales uno por uno
|
||||
if user_id:
|
||||
query = query.where(AuditLog.user_id == user_id)
|
||||
if action:
|
||||
@@ -62,230 +139,610 @@ async def get_audit_logs(page: int = Query(default=1, ge=1), per_page: int = Que
|
||||
if date_to:
|
||||
query = query.where(AuditLog.created_at < date_to)
|
||||
if search:
|
||||
# Búsqueda parcial en el campo "action" (ej: "ticket" encuentra "ticket.create")
|
||||
query = query.where(AuditLog.action.ilike(f"%{search}%"))
|
||||
|
||||
|
||||
# Ordenar por fecha descendente (más reciente primero)
|
||||
query = query.order_by(desc(AuditLog.created_at))
|
||||
|
||||
|
||||
# Contar total de registros para calcular páginas
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(count_query)).scalar() or 0
|
||||
|
||||
|
||||
# Aplicar paginación
|
||||
offset = (page - 1) * per_page
|
||||
query = query.offset(offset).limit(per_page)
|
||||
|
||||
|
||||
# Ejecutar query y obtener resultados
|
||||
result = await db.execute(query)
|
||||
logs = result.scalars().all()
|
||||
|
||||
|
||||
# Calcular número total de páginas
|
||||
total_pages = (total + per_page - 1) // per_page
|
||||
|
||||
# Convertir modelos a schemas de respuesta
|
||||
logs_response = [AuditLogResponse(**audit_log_to_dict(log)) for log in logs]
|
||||
|
||||
return AuditLogListResponse(logs=logs_response, total=total, page=page, per_page=per_page, total_pages=total_pages)
|
||||
|
||||
return AuditLogListResponse(
|
||||
logs=logs_response,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
total_pages=total_pages
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENDPOINT: ESTADÍSTICAS DE AUDITORÍA
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/stats", response_model=AuditLogStats)
|
||||
async def get_audit_stats(all_tenants: bool = Query(False), current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)):
|
||||
"""Obtener estadísticas de auditoría"""
|
||||
async def get_audit_stats(
|
||||
all_tenants: bool = Query(False),
|
||||
current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Obtener estadísticas resumidas de auditoría para el dashboard.
|
||||
|
||||
Incluye:
|
||||
- Total de acciones registradas
|
||||
- Acciones de las últimas 24 horas
|
||||
- Acciones de los últimos 7 días
|
||||
- Incidentes críticos detectados hoy (alineado con /security/incidents)
|
||||
- Acciones más frecuentes
|
||||
- Usuarios más activos
|
||||
- Distribución por tipo de recurso
|
||||
"""
|
||||
can_see_all_tenants = current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]
|
||||
logger.info("Fetching audit stats", user_id=str(current_user.id), tenant_id=str(current_tenant.id),
|
||||
all_tenants=all_tenants, can_see_all=can_see_all_tenants)
|
||||
|
||||
|
||||
logger.info(
|
||||
"Obteniendo estadisticas de auditoria",
|
||||
user_id=str(current_user.id),
|
||||
tenant_id=str(current_tenant.id),
|
||||
all_tenants=all_tenants,
|
||||
can_see_all=can_see_all_tenants
|
||||
)
|
||||
|
||||
# datetime.now(timezone.utc) genera un datetime aware en UTC,
|
||||
# compatible con la columna TIMESTAMPTZ de PostgreSQL
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Determinar si se debe filtrar por tenant o ver todos
|
||||
apply_tenant = not (all_tenants and can_see_all_tenants)
|
||||
tenant_filter = current_tenant.id if apply_tenant else None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CONTADORES GENERALES
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# Total histórico de acciones (sin filtro de fecha)
|
||||
total_actions = await get_count_stat(db, tenant_filter)
|
||||
|
||||
# Acciones en las últimas 24 horas
|
||||
# get_count_stat convierte internamente a aware UTC con _ensure_aware_utc()
|
||||
actions_today = await get_count_stat(db, tenant_filter, now - timedelta(days=1))
|
||||
|
||||
# Acciones en los últimos 7 días
|
||||
actions_this_week = await get_count_stat(db, tenant_filter, now - timedelta(days=7))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CONTADOR DE INCIDENTES CRÍTICOS
|
||||
# ------------------------------------------------------------------
|
||||
# Usa los mismos umbrales que los detectores de /security/incidents
|
||||
# para que el número del dashboard sea consistente con la lista.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
today_start = now - timedelta(days=1)
|
||||
critical_conditions = [
|
||||
AuditLog.created_at >= today_start,
|
||||
or_(AuditLog.action.like('%.delete'), AuditLog.action.like('user.update'),
|
||||
AuditLog.action.like('%.assign'), AuditLog.action.in_(['user.login_failed', 'user.logout']))
|
||||
]
|
||||
if apply_tenant:
|
||||
critical_conditions.append(AuditLog.tenant_id == tenant_filter)
|
||||
|
||||
critical_actions_today = (await db.execute(select(func.count()).select_from(AuditLog).where(and_(*critical_conditions)))).scalar() or 0
|
||||
|
||||
|
||||
# Contar intentos fallidos de login en las últimas 24 horas
|
||||
failed_login_count = (await db.execute(
|
||||
select(func.count()).select_from(AuditLog).where(
|
||||
AuditLog.action == 'user.login_failed',
|
||||
AuditLog.created_at >= today_start,
|
||||
*([AuditLog.tenant_id == tenant_filter] if apply_tenant else [])
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
# Contar eliminaciones en las últimas 24 horas
|
||||
deletion_count = (await db.execute(
|
||||
select(func.count()).select_from(AuditLog).where(
|
||||
AuditLog.action.like('%.delete'),
|
||||
AuditLog.created_at >= today_start,
|
||||
*([AuditLog.tenant_id == tenant_filter] if apply_tenant else [])
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
# Contar cambios de privilegios en las últimas 24 horas
|
||||
privilege_count = (await db.execute(
|
||||
select(func.count()).select_from(AuditLog).where(
|
||||
AuditLog.action == 'user.update',
|
||||
AuditLog.created_at >= today_start,
|
||||
*([AuditLog.tenant_id == tenant_filter] if apply_tenant else [])
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
# Calcular número real de incidentes usando los mismos umbrales
|
||||
# que los detectores en /security/incidents:
|
||||
# - Fuerza bruta: incidente si hay >= 20 intentos fallidos
|
||||
# - Eliminación masiva: incidente si hay >= 50 eliminaciones
|
||||
# - Escalada privilegios: incidente si hay >= 3 cambios de rol
|
||||
critical_actions_today = sum([
|
||||
1 if failed_login_count >= 20 else 0,
|
||||
1 if deletion_count >= 50 else 0,
|
||||
1 if privilege_count >= 3 else 0,
|
||||
])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DATOS PARA GRÁFICAS Y TABLAS DEL DASHBOARD
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# Top acciones más frecuentes (ej: "ticket.create", "user.login")
|
||||
top_actions = await get_top_items(db, AuditLog.action, tenant_filter)
|
||||
|
||||
# Distribución por tipo de recurso (ej: "ticket", "user", "tenant")
|
||||
by_resource_type = await get_top_items(db, AuditLog.resource_type, tenant_filter, limit=10)
|
||||
|
||||
# Usuarios más activos (hace join con tabla de usuarios)
|
||||
top_users = await get_top_items(db, None, tenant_filter, join_user=True)
|
||||
|
||||
return AuditLogStats(total_actions=total_actions, actions_today=actions_today,
|
||||
actions_this_week=actions_this_week, critical_actions_today=critical_actions_today,
|
||||
top_actions=top_actions, top_users=top_users, by_resource_type=by_resource_type)
|
||||
|
||||
return AuditLogStats(
|
||||
total_actions=total_actions,
|
||||
actions_today=actions_today,
|
||||
actions_this_week=actions_this_week,
|
||||
critical_actions_today=critical_actions_today,
|
||||
top_actions=top_actions,
|
||||
top_users=top_users,
|
||||
by_resource_type=by_resource_type
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENDPOINT: DETALLE DE UN LOG ESPECÍFICO
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/{log_id}", response_model=AuditLogResponse)
|
||||
async def get_audit_log_detail(log_id: uuid.UUID, current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)):
|
||||
"""Obtener detalle de un log de auditoría"""
|
||||
async def get_audit_log_detail(
|
||||
log_id: uuid.UUID,
|
||||
current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Obtener el detalle completo de un log de auditoría por su ID.
|
||||
|
||||
Incluye información del usuario que realizó la acción, valores
|
||||
anteriores y nuevos (para cambios), IP de origen, user agent, etc.
|
||||
|
||||
Retorna 404 si el log no existe o no pertenece al tenant del usuario.
|
||||
"""
|
||||
# Buscar el log por ID incluyendo los datos del usuario relacionado
|
||||
query = select(AuditLog).where(AuditLog.id == log_id).options(selectinload(AuditLog.user))
|
||||
|
||||
# Aplicar filtro de tenant para garantizar aislamiento multi-tenant
|
||||
query = apply_tenant_filter(query, current_user, current_tenant)
|
||||
|
||||
|
||||
result = await db.execute(query)
|
||||
log = result.scalar_one_or_none()
|
||||
|
||||
|
||||
if not log:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Audit log {log_id} not found")
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Registro de auditoria {log_id} no encontrado"
|
||||
)
|
||||
|
||||
return AuditLogResponse(**audit_log_to_dict(log))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENDPOINT: ANÁLISIS DE SEGURIDAD EN TIEMPO REAL
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/security/analysis", response_model=SecurityAnalysisResponse)
|
||||
async def get_security_analysis(all_tenants: bool = Query(False), current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)):
|
||||
"""Análisis de seguridad basado en logs de auditoría"""
|
||||
logger.info("Security analysis requested", user_id=str(current_user.id), tenant_id=str(current_tenant.id))
|
||||
|
||||
async def get_security_analysis(
|
||||
hours: int = Query(default=24, ge=1, le=720),
|
||||
all_tenants: bool = Query(False),
|
||||
current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Analizar los logs de auditoría para detectar patrones sospechosos.
|
||||
|
||||
Detecta tres tipos de amenazas:
|
||||
1. Fuerza bruta: Muchos intentos fallidos de login desde las mismas IPs
|
||||
2. Eliminación masiva: Gran cantidad de registros eliminados en poco tiempo
|
||||
3. Escalada privilegios: Cambios de roles sospechosos en usuarios
|
||||
|
||||
Calcula un nivel de riesgo general (low/medium/high/critical) y
|
||||
devuelve recomendaciones de acción.
|
||||
"""
|
||||
logger.info(
|
||||
"Analisis de seguridad solicitado",
|
||||
user_id=str(current_user.id),
|
||||
tenant_id=str(current_tenant.id),
|
||||
hours=hours
|
||||
)
|
||||
|
||||
# aware UTC para compatibilidad con TIMESTAMPTZ de PostgreSQL
|
||||
now = datetime.now(timezone.utc)
|
||||
analysis_start = now - timedelta(hours=24)
|
||||
|
||||
query = select(AuditLog).where(AuditLog.created_at >= analysis_start).options(selectinload(AuditLog.user))
|
||||
analysis_start = now - timedelta(hours=hours)
|
||||
|
||||
query = (
|
||||
select(AuditLog)
|
||||
.where(AuditLog.created_at >= analysis_start)
|
||||
.options(selectinload(AuditLog.user))
|
||||
)
|
||||
query = apply_tenant_filter(query, current_user, current_tenant, all_tenants)
|
||||
|
||||
|
||||
result = await db.execute(query)
|
||||
logs = result.scalars().all()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CONTADORES DE EVENTOS SOSPECHOSOS
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
failed_logins = sum(1 for log in logs if log.action == 'user.login_failed')
|
||||
mass_deletions = sum(1 for log in logs if '.delete' in log.action)
|
||||
privilege_changes = sum(1 for log in logs if log.action == 'user.update' and log.new_values and 'role' in log.new_values)
|
||||
|
||||
privilege_changes = sum(
|
||||
1 for log in logs
|
||||
if log.action == 'user.update'
|
||||
and log.new_values
|
||||
and 'role' in log.new_values
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GENERACIÓN DE PATRONES DE AMENAZA
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
threat_patterns = []
|
||||
|
||||
|
||||
# Amenaza 1: Fuerza bruta (umbral mínimo: 5 intentos fallidos)
|
||||
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]
|
||||
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(
|
||||
id="brute_force_attempt",
|
||||
type="brute_force",
|
||||
description=f"Se detectaron {failed_logins} intentos fallidos de login en las últimas 24h",
|
||||
description=(
|
||||
f"Se detectaron {failed_logins} intentos fallidos de "
|
||||
f"login en las ultimas {hours}h"
|
||||
),
|
||||
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),
|
||||
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_ips=list(set(affected_ips_list))[:5],
|
||||
affected_users=[],
|
||||
recommended_action="Considerar bloquear IPs con múltiples fallos"
|
||||
recommended_action="Considerar bloquear IPs con multiples fallos"
|
||||
))
|
||||
|
||||
|
||||
# Amenaza 2: Eliminación masiva (umbral mínimo: 10 eliminaciones)
|
||||
if mass_deletions >= 10:
|
||||
deleting_users = [log.user.email for log in logs if '.delete' in log.action and log.user]
|
||||
deleting_users = [
|
||||
log.user.email
|
||||
for log in logs
|
||||
if '.delete' in log.action and log.user
|
||||
]
|
||||
threat_patterns.append(SecurityThreatPattern(
|
||||
id="mass_deletion",
|
||||
type="mass_deletion",
|
||||
description=f"Se detectaron {mass_deletions} eliminaciones en las últimas 24h",
|
||||
description=(
|
||||
f"Se detectaron {mass_deletions} eliminaciones en "
|
||||
f"las ultimas {hours}h"
|
||||
),
|
||||
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),
|
||||
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_ips=[],
|
||||
affected_users=list(set(deleting_users))[:5],
|
||||
recommended_action="Revisar qué usuarios están eliminando recursos"
|
||||
recommended_action="Revisar que usuarios estan eliminando recursos masivamente"
|
||||
))
|
||||
|
||||
|
||||
# Amenaza 3: Escalada de privilegios (umbral mínimo: 3 cambios de rol)
|
||||
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]
|
||||
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(
|
||||
id="suspicious_privilege_changes",
|
||||
type="privilege_escalation",
|
||||
description=f"Se detectaron {privilege_changes} cambios de privilegios en las últimas 24h",
|
||||
description=(
|
||||
f"Se detectaron {privilege_changes} cambios de "
|
||||
f"privilegios en las ultimas {hours}h"
|
||||
),
|
||||
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),
|
||||
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_ips=[],
|
||||
affected_users=list(set(affected_users_list))[:5],
|
||||
recommended_action="Auditar cambios de roles recientes"
|
||||
))
|
||||
|
||||
risk_score = min(100, (failed_logins * 2) + (mass_deletions * 5) + (privilege_changes * 10))
|
||||
risk_level = "critical" if risk_score >= 80 else "high" if risk_score >= 50 else "medium" if risk_score >= 20 else "low"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CÁLCULO DE NIVEL DE RIESGO GENERAL
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
risk_score = min(
|
||||
100,
|
||||
(failed_logins * 2) + (mass_deletions * 5) + (privilege_changes * 10)
|
||||
)
|
||||
|
||||
if risk_score >= 80:
|
||||
risk_level = "critical"
|
||||
elif risk_score >= 50:
|
||||
risk_level = "high"
|
||||
elif risk_score >= 20:
|
||||
risk_level = "medium"
|
||||
else:
|
||||
risk_level = "low"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# RECOMENDACIONES AUTOMÁTICAS
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
recommended_actions = []
|
||||
|
||||
if failed_logins >= 20:
|
||||
recommended_actions.append("Implementar bloqueo automático de IPs después de múltiples intentos fallidos")
|
||||
recommended_actions.append(
|
||||
"Implementar bloqueo automatico de IPs despues de multiples intentos fallidos"
|
||||
)
|
||||
if mass_deletions >= 50:
|
||||
recommended_actions.append("Activar confirmación adicional para eliminaciones masivas")
|
||||
recommended_actions.append(
|
||||
"Activar confirmacion adicional para eliminaciones masivas"
|
||||
)
|
||||
if privilege_changes >= 3:
|
||||
recommended_actions.append(
|
||||
"Revisar y aprobar manualmente los cambios de roles recientes"
|
||||
)
|
||||
if not recommended_actions:
|
||||
recommended_actions.append("Continuar monitoreando actividad del sistema")
|
||||
|
||||
# Calcular IPs sospechosas (más de 5 intentos fallidos)
|
||||
suspicious_ips = len(set([log.ip_address for log in logs if log.ip_address and log.action == 'user.login_failed']))
|
||||
|
||||
# Contar acciones críticas (delete, privilege changes, etc)
|
||||
|
||||
suspicious_ips = len(set(
|
||||
log.ip_address
|
||||
for log in logs
|
||||
if log.ip_address and log.action == 'user.login_failed'
|
||||
))
|
||||
|
||||
critical_actions = mass_deletions + privilege_changes
|
||||
|
||||
|
||||
return SecurityAnalysisResponse(
|
||||
overall_risk_level=risk_level,
|
||||
total_threats_detected=len(threat_patterns),
|
||||
threats=threat_patterns,
|
||||
analysis_period_hours=24,
|
||||
generated_at=datetime.utcnow(),
|
||||
analysis_period_hours=hours,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
failed_login_attempts=failed_logins,
|
||||
suspicious_ips_count=suspicious_ips,
|
||||
critical_actions_count=critical_actions,
|
||||
recommended_actions=recommended_actions
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENDPOINT: EJECUTAR ACCIÓN DE SEGURIDAD
|
||||
# =============================================================================
|
||||
|
||||
@router.post("/security/action", response_model=SecurityActionResponse)
|
||||
async def execute_security_action(action: SecurityActionRequest, current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)):
|
||||
"""Ejecutar acción de seguridad"""
|
||||
async def execute_security_action(
|
||||
action: SecurityActionRequest,
|
||||
current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Ejecutar una acción de seguridad manual sobre una amenaza detectada.
|
||||
|
||||
Acciones disponibles:
|
||||
- block_ip: Bloquear una dirección IP por X minutos
|
||||
- notify_admin: Enviar notificación a los administradores
|
||||
- force_password_reset: Forzar cambio de contraseña a un usuario
|
||||
- disable_user: Desactivar temporalmente una cuenta de usuario
|
||||
|
||||
Solo ADMIN y SUPPORT_MANAGER pueden ejecutar estas acciones.
|
||||
Todas las acciones quedan registradas en el log de auditoría.
|
||||
"""
|
||||
if current_user.role not in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Solo administradores pueden ejecutar acciones de seguridad")
|
||||
|
||||
logger.info("Security action requested", user_id=str(current_user.id),
|
||||
action_type=action.action_type, target=action.target)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Solo administradores pueden ejecutar acciones de seguridad"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Accion de seguridad solicitada",
|
||||
user_id=str(current_user.id),
|
||||
action_type=action.action_type,
|
||||
target=action.target
|
||||
)
|
||||
|
||||
# Registrar en auditoría para trazabilidad completa
|
||||
try:
|
||||
await AuditService.log(db=db, tenant_id=current_tenant.id, user_id=current_user.id,
|
||||
action=f"security.{action.action_type}", resource_type="security", resource_id=None,
|
||||
metadata={"target": action.target, "reason": action.reason, "duration_minutes": action.duration_minutes})
|
||||
await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=current_tenant.id,
|
||||
user_id=current_user.id,
|
||||
action=f"security.{action.action_type}",
|
||||
resource_type="security",
|
||||
resource_id=None,
|
||||
metadata={
|
||||
"target": action.target,
|
||||
"reason": action.reason,
|
||||
"duration_minutes": action.duration_minutes
|
||||
}
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.error("Failed to log security action", error=str(e))
|
||||
|
||||
logger.error("Fallo al registrar accion de seguridad en auditoria", error=str(e))
|
||||
|
||||
action_messages = {
|
||||
"block_ip": f"IP {action.target} bloqueada por {action.duration_minutes or 60} minutos. Razón: {action.reason}",
|
||||
"notify_admin": f"Notificación enviada a administradores sobre: {action.reason}",
|
||||
"force_password_reset": f"Se forzará cambio de contraseña para {action.target}. Razón: {action.reason}",
|
||||
"disable_user": f"Usuario {action.target} desactivado temporalmente. Razón: {action.reason}"
|
||||
"block_ip": (
|
||||
f"IP {action.target} bloqueada por "
|
||||
f"{action.duration_minutes or 60} minutos. Razon: {action.reason}"
|
||||
),
|
||||
"notify_admin": (
|
||||
f"Notificacion enviada a administradores sobre: {action.reason}"
|
||||
),
|
||||
"force_password_reset": (
|
||||
f"Se forzara cambio de contrasena para {action.target}. "
|
||||
f"Razon: {action.reason}"
|
||||
),
|
||||
"disable_user": (
|
||||
f"Usuario {action.target} desactivado temporalmente. "
|
||||
f"Razon: {action.reason}"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
success = action.action_type in action_messages
|
||||
message = action_messages.get(action.action_type, f"Tipo de acción no reconocida: {action.action_type}")
|
||||
|
||||
message = action_messages.get(
|
||||
action.action_type,
|
||||
f"Tipo de accion no reconocida: {action.action_type}"
|
||||
)
|
||||
|
||||
return SecurityActionResponse(success=success, message=message, action_id=None)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENDPOINT: LISTA DE INCIDENTES DE SEGURIDAD
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/security/incidents", response_model=SecurityIncidentListResponse)
|
||||
async def get_security_incidents(page: int = Query(default=1, ge=1), per_page: int = Query(default=20, ge=1, le=100),
|
||||
severity: Optional[str] = Query(None), status: Optional[str] = Query(None),
|
||||
incident_type: Optional[str] = Query(None), search: Optional[str] = Query(None),
|
||||
all_tenants: bool = Query(False), current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)):
|
||||
"""Obtener incidentes de seguridad"""
|
||||
logger.info("Fetching security incidents", user_id=str(current_user.id), tenant_id=str(current_tenant.id),
|
||||
filters={"severity": severity, "status": status, "type": incident_type, "page": page})
|
||||
|
||||
async def get_security_incidents(
|
||||
# Paginación
|
||||
page: int = Query(default=1, ge=1),
|
||||
per_page: int = Query(default=20, ge=1, le=100),
|
||||
# Filtros opcionales
|
||||
severity: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
incident_type: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
all_tenants: bool = Query(False),
|
||||
# Dependencias
|
||||
current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Obtener la lista de incidentes de seguridad detectados.
|
||||
|
||||
Los incidentes se generan dinámicamente analizando los logs de
|
||||
auditoría de los últimos 7 días usando tres detectores:
|
||||
|
||||
1. detect_brute_force: Analiza intentos fallidos de login
|
||||
2. detect_mass_deletions: Analiza eliminaciones masivas
|
||||
3. detect_privilege_escalation: Analiza cambios de rol sospechosos
|
||||
|
||||
Los umbrales son los mismos que usa /stats para critical_actions_today,
|
||||
garantizando consistencia entre el contador y la lista.
|
||||
"""
|
||||
logger.info(
|
||||
"Obteniendo incidentes de seguridad",
|
||||
user_id=str(current_user.id),
|
||||
tenant_id=str(current_tenant.id),
|
||||
filters={
|
||||
"severity": severity,
|
||||
"status": status,
|
||||
"type": incident_type,
|
||||
"page": page
|
||||
}
|
||||
)
|
||||
|
||||
# aware UTC para compatibilidad con TIMESTAMPTZ de PostgreSQL
|
||||
now = datetime.now(timezone.utc)
|
||||
analysis_start = now - timedelta(days=7)
|
||||
|
||||
base_query = select(AuditLog).options(selectinload(AuditLog.user)).where(AuditLog.created_at >= analysis_start)
|
||||
|
||||
base_query = (
|
||||
select(AuditLog)
|
||||
.options(selectinload(AuditLog.user))
|
||||
.where(AuditLog.created_at >= analysis_start)
|
||||
)
|
||||
base_query = apply_tenant_filter(base_query, current_user, current_tenant, all_tenants)
|
||||
|
||||
deletion_result = await db.execute(base_query.where(AuditLog.action.like('%.delete')).order_by(desc(AuditLog.created_at)))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DETECTOR 1: ELIMINACIONES MASIVAS
|
||||
# ------------------------------------------------------------------
|
||||
deletion_result = await db.execute(
|
||||
base_query
|
||||
.where(AuditLog.action.like('%.delete'))
|
||||
.order_by(desc(AuditLog.created_at))
|
||||
)
|
||||
deletion_logs = deletion_result.scalars().all()
|
||||
deletion_incidents = detect_mass_deletions(deletion_logs, now)
|
||||
|
||||
failed_login_result = await db.execute(base_query.where(AuditLog.action == 'user.login_failed').order_by(desc(AuditLog.created_at)))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DETECTOR 2: FUERZA BRUTA
|
||||
# ------------------------------------------------------------------
|
||||
failed_login_result = await db.execute(
|
||||
base_query
|
||||
.where(AuditLog.action == 'user.login_failed')
|
||||
.order_by(desc(AuditLog.created_at))
|
||||
)
|
||||
failed_login_logs = failed_login_result.scalars().all()
|
||||
brute_force_incidents = detect_brute_force(failed_login_logs, now)
|
||||
|
||||
privilege_result = await db.execute(base_query.where(and_(AuditLog.action == 'user.update', AuditLog.new_values.op('?')('role'))).order_by(desc(AuditLog.created_at)))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DETECTOR 3: ESCALADA DE PRIVILEGIOS
|
||||
# El operador '?' verifica si el campo JSON contiene la clave 'role'
|
||||
# ------------------------------------------------------------------
|
||||
privilege_result = await db.execute(
|
||||
base_query
|
||||
.where(and_(
|
||||
AuditLog.action == 'user.update',
|
||||
AuditLog.new_values.op('?')('role')
|
||||
))
|
||||
.order_by(desc(AuditLog.created_at))
|
||||
)
|
||||
privilege_logs = privilege_result.scalars().all()
|
||||
privilege_incidents = detect_privilege_escalation(privilege_logs)
|
||||
|
||||
incidents = [SecurityIncidentResponse(**inc) for inc in (deletion_incidents + brute_force_incidents + privilege_incidents)]
|
||||
|
||||
|
||||
# Combinar todos los incidentes
|
||||
incidents = [
|
||||
SecurityIncidentResponse(**inc)
|
||||
for inc in (deletion_incidents + brute_force_incidents + privilege_incidents)
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# FILTROS EN MEMORIA (los incidentes son generados dinámicamente)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
if severity:
|
||||
incidents = [i for i in incidents if i.severity == severity]
|
||||
if status:
|
||||
@@ -294,14 +751,29 @@ async def get_security_incidents(page: int = Query(default=1, ge=1), per_page: i
|
||||
incidents = [i for i in incidents if i.incident_type == incident_type]
|
||||
if search:
|
||||
search_lower = search.lower()
|
||||
incidents = [i for i in incidents if search_lower in i.title.lower() or (i.description and search_lower in i.description.lower())]
|
||||
|
||||
incidents = [
|
||||
i for i in incidents
|
||||
if search_lower in i.title.lower()
|
||||
or (i.description and search_lower in i.description.lower())
|
||||
]
|
||||
|
||||
# Ordenar por fecha descendente
|
||||
incidents.sort(key=lambda x: x.created_at, reverse=True)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PAGINACIÓN MANUAL
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
total = len(incidents)
|
||||
total_pages = (total + per_page - 1) // per_page
|
||||
start_idx = (page - 1) * per_page
|
||||
end_idx = start_idx + per_page
|
||||
paginated_incidents = incidents[start_idx:end_idx]
|
||||
|
||||
return SecurityIncidentListResponse(incidents=paginated_incidents, total=total, page=page, per_page=per_page, total_pages=total_pages)
|
||||
|
||||
return SecurityIncidentListResponse(
|
||||
incidents=paginated_incidents,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
total_pages=total_pages
|
||||
)
|
||||
761
backend/app/api/v1/endpoints/reports.py
Normal file
761
backend/app/api/v1/endpoints/reports.py
Normal file
@@ -0,0 +1,761 @@
|
||||
"""
|
||||
Reports Endpoints - ServiceManagerWeb
|
||||
|
||||
Módulo de reportes y estadísticas del sistema.
|
||||
Accesible por ADMIN y SUPPORT_MANAGER.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, case, text
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import uuid
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.api.deps import get_current_user
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.ticket import Ticket, TicketStatus, TicketPriority
|
||||
from app.models.category import Category
|
||||
from app.models.system import System
|
||||
from app.models.tenant import Tenant, TenantStatus
|
||||
from app.api.schemas.reports import (
|
||||
ReportSummaryResponse,
|
||||
TicketsByStatus,
|
||||
TicketsByPriority,
|
||||
AgentReportResponse,
|
||||
AgentReportRow,
|
||||
CategoryReportResponse,
|
||||
CategoryReportRow,
|
||||
ClientReportResponse,
|
||||
ClientReportRow,
|
||||
TrendsReportResponse,
|
||||
TrendDataPoint,
|
||||
CSATReportResponse,
|
||||
CSATDistribution,
|
||||
SystemReportResponse,
|
||||
SystemReportRow,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
CLOSED_STATUSES = {TicketStatus.RESOLVED, TicketStatus.CLOSED}
|
||||
|
||||
# ===================================
|
||||
# HELPERS
|
||||
# ===================================
|
||||
|
||||
def require_reports_access(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""ADMIN, SUPPORT_MANAGER y AUDITOR pueden leer reportes."""
|
||||
allowed = [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AUDITOR]
|
||||
if current_user.role not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Solo ADMIN, SUPPORT_MANAGER y AUDITOR pueden acceder a los reportes.",
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
def require_admin(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""Solo ADMIN puede ver reportes entre tenants."""
|
||||
if current_user.role != UserRole.ADMIN:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Solo ADMIN puede ver reportes de todos los clientes.",
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
def _period_dates(days: int) -> tuple[datetime, datetime]:
|
||||
"""Devuelve (inicio, fin) del período solicitado en UTC."""
|
||||
end = datetime.now(timezone.utc)
|
||||
start = end - timedelta(days=days)
|
||||
return start, end
|
||||
|
||||
|
||||
# ===================================
|
||||
# 1. RESUMEN GENERAL
|
||||
# ===================================
|
||||
|
||||
@router.get("/summary", response_model=ReportSummaryResponse)
|
||||
async def get_report_summary(
|
||||
days: int = Query(default=30, ge=1, le=365, description="Días hacia atrás del período"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_reports_access),
|
||||
):
|
||||
"""
|
||||
Resumen ejecutivo del período seleccionado.
|
||||
|
||||
Incluye:
|
||||
- Total de tickets creados
|
||||
- Tickets abiertos vs resueltos
|
||||
- Tiempo promedio de resolución
|
||||
- Calificación promedio (CSAT)
|
||||
- Desglose por estado y prioridad
|
||||
- Comparación con el período anterior
|
||||
"""
|
||||
period_start, period_end = _period_dates(days)
|
||||
prev_start = period_start - timedelta(days=days)
|
||||
|
||||
tenant_filter = Ticket.tenant_id == current_user.tenant_id
|
||||
|
||||
# ── Conteos por estado ──
|
||||
status_rows = (await db.execute(
|
||||
select(Ticket.status, func.count(Ticket.id).label("cnt"))
|
||||
.where(and_(tenant_filter, Ticket.created_at >= period_start))
|
||||
.group_by(Ticket.status)
|
||||
)).all()
|
||||
|
||||
by_status = TicketsByStatus()
|
||||
for row in status_rows:
|
||||
s = row.status.value if hasattr(row.status, "value") else str(row.status)
|
||||
setattr(by_status, s.lower(), row.cnt)
|
||||
by_status.total = sum(
|
||||
[by_status.new, by_status.triage, by_status.in_progress,
|
||||
by_status.waiting_customer, by_status.resolved, by_status.closed, by_status.reopened]
|
||||
)
|
||||
|
||||
# ── Conteos por prioridad ──
|
||||
priority_rows = (await db.execute(
|
||||
select(Ticket.priority, func.count(Ticket.id).label("cnt"))
|
||||
.where(and_(tenant_filter, Ticket.created_at >= period_start))
|
||||
.group_by(Ticket.priority)
|
||||
)).all()
|
||||
|
||||
by_priority = TicketsByPriority()
|
||||
for row in priority_rows:
|
||||
p = row.priority.value if hasattr(row.priority, "value") else str(row.priority)
|
||||
setattr(by_priority, p.lower(), row.cnt)
|
||||
by_priority.total = sum([by_priority.low, by_priority.medium, by_priority.high, by_priority.urgent])
|
||||
|
||||
total_tickets = by_status.total
|
||||
resolved_tickets = by_status.resolved + by_status.closed
|
||||
open_tickets = total_tickets - resolved_tickets
|
||||
|
||||
# ── Promedio de tiempo de resolución (segundos → horas) ──
|
||||
res_time_row = (await db.execute(
|
||||
select(func.avg(
|
||||
func.extract("epoch", Ticket.resolved_at - Ticket.created_at)
|
||||
).label("avg_seconds"))
|
||||
.where(and_(
|
||||
tenant_filter,
|
||||
Ticket.created_at >= period_start,
|
||||
Ticket.resolved_at.isnot(None),
|
||||
))
|
||||
)).scalar_one_or_none()
|
||||
avg_resolution_hours = round(res_time_row / 3600, 2) if res_time_row else None
|
||||
|
||||
# ── Promedio de primera respuesta ──
|
||||
resp_time_row = (await db.execute(
|
||||
select(func.avg(
|
||||
func.extract("epoch", Ticket.first_response_at - Ticket.created_at)
|
||||
).label("avg_seconds"))
|
||||
.where(and_(
|
||||
tenant_filter,
|
||||
Ticket.created_at >= period_start,
|
||||
Ticket.first_response_at.isnot(None),
|
||||
))
|
||||
)).scalar_one_or_none()
|
||||
avg_first_response_hours = round(resp_time_row / 3600, 2) if resp_time_row else None
|
||||
|
||||
# ── CSAT ──
|
||||
csat_row = (await db.execute(
|
||||
select(func.avg(Ticket.rating).label("avg"), func.count(Ticket.rating).label("cnt"))
|
||||
.where(and_(tenant_filter, Ticket.created_at >= period_start, Ticket.rating.isnot(None)))
|
||||
)).one()
|
||||
avg_rating = round(float(csat_row.avg), 2) if csat_row.avg else None
|
||||
total_rated = csat_row.cnt or 0
|
||||
|
||||
# ── Comparación con período anterior ──
|
||||
prev_total = (await db.execute(
|
||||
select(func.count(Ticket.id))
|
||||
.where(and_(tenant_filter, Ticket.created_at >= prev_start, Ticket.created_at < period_start))
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
prev_resolved = (await db.execute(
|
||||
select(func.count(Ticket.id))
|
||||
.where(and_(
|
||||
tenant_filter,
|
||||
Ticket.created_at >= prev_start,
|
||||
Ticket.created_at < period_start,
|
||||
Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]),
|
||||
))
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
tickets_change_pct = None
|
||||
if prev_total > 0:
|
||||
tickets_change_pct = round(((total_tickets - prev_total) / prev_total) * 100, 1)
|
||||
|
||||
resolution_change_pct = None
|
||||
if prev_total > 0 and total_tickets > 0:
|
||||
cur_rate = resolved_tickets / total_tickets * 100
|
||||
prev_rate = prev_resolved / prev_total * 100 if prev_total > 0 else 0
|
||||
resolution_change_pct = round(cur_rate - prev_rate, 1)
|
||||
|
||||
return ReportSummaryResponse(
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
total_tickets=total_tickets,
|
||||
open_tickets=open_tickets,
|
||||
resolved_tickets=resolved_tickets,
|
||||
avg_resolution_hours=avg_resolution_hours,
|
||||
avg_first_response_hours=avg_first_response_hours,
|
||||
avg_rating=avg_rating,
|
||||
total_rated=total_rated,
|
||||
by_status=by_status,
|
||||
by_priority=by_priority,
|
||||
tickets_change_pct=tickets_change_pct,
|
||||
resolution_change_pct=resolution_change_pct,
|
||||
)
|
||||
|
||||
|
||||
# ===================================
|
||||
# 2. RENDIMIENTO POR AGENTE
|
||||
# ===================================
|
||||
|
||||
@router.get("/by-agent", response_model=AgentReportResponse)
|
||||
async def get_report_by_agent(
|
||||
days: int = Query(default=30, ge=1, le=365),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_reports_access),
|
||||
):
|
||||
"""
|
||||
Rendimiento de cada agente en el período:
|
||||
- Tickets asignados y resueltos
|
||||
- Tasa de resolución
|
||||
- Tiempo promedio de resolución
|
||||
- Calificación promedio (CSAT)
|
||||
"""
|
||||
period_start, period_end = _period_dates(days)
|
||||
tenant_filter = and_(
|
||||
Ticket.tenant_id == current_user.tenant_id,
|
||||
Ticket.created_at >= period_start,
|
||||
Ticket.assigned_to.isnot(None),
|
||||
)
|
||||
|
||||
# Obtener todos los agentes del tenant
|
||||
agents_result = await db.execute(
|
||||
select(User).where(
|
||||
and_(
|
||||
User.tenant_id == current_user.tenant_id,
|
||||
User.role.in_([UserRole.AGENT, UserRole.SUPPORT_MANAGER, UserRole.ADMIN]),
|
||||
User.is_active == True,
|
||||
)
|
||||
)
|
||||
)
|
||||
agents = agents_result.scalars().all()
|
||||
|
||||
rows: List[AgentReportRow] = []
|
||||
for agent in agents:
|
||||
agent_filter = and_(tenant_filter, Ticket.assigned_to == agent.id)
|
||||
|
||||
total_assigned = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(agent_filter)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
if total_assigned == 0:
|
||||
continue # omitir agentes sin tickets en el período
|
||||
|
||||
resolved = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(agent_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]))
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
avg_res_seconds = (await db.execute(
|
||||
select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at)))
|
||||
.where(and_(agent_filter, Ticket.resolved_at.isnot(None)))
|
||||
)).scalar_one_or_none()
|
||||
|
||||
csat = (await db.execute(
|
||||
select(func.avg(Ticket.rating), func.count(Ticket.rating))
|
||||
.where(and_(agent_filter, Ticket.rating.isnot(None)))
|
||||
)).one()
|
||||
|
||||
urgent_handled = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(agent_filter, Ticket.priority == TicketPriority.URGENT)
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
rows.append(AgentReportRow(
|
||||
agent_id=str(agent.id),
|
||||
agent_name=f"{agent.first_name} {agent.last_name}",
|
||||
agent_email=agent.email,
|
||||
total_assigned=total_assigned,
|
||||
resolved=resolved,
|
||||
open=total_assigned - resolved,
|
||||
resolution_rate=round((resolved / total_assigned * 100), 1) if total_assigned else 0,
|
||||
avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None,
|
||||
avg_rating=round(float(csat[0]), 2) if csat[0] else None,
|
||||
total_rated=csat[1] or 0,
|
||||
urgent_handled=urgent_handled,
|
||||
))
|
||||
|
||||
rows.sort(key=lambda r: r.resolved, reverse=True)
|
||||
|
||||
return AgentReportResponse(
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
agents=rows,
|
||||
total_agents=len(rows),
|
||||
)
|
||||
|
||||
|
||||
# ===================================
|
||||
# 3. TICKETS POR CATEGORÍA
|
||||
# ===================================
|
||||
|
||||
@router.get("/by-category", response_model=CategoryReportResponse)
|
||||
async def get_report_by_category(
|
||||
days: int = Query(default=30, ge=1, le=365),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_reports_access),
|
||||
):
|
||||
"""
|
||||
Tickets agrupados por categoría con tasa de cumplimiento SLA.
|
||||
"""
|
||||
period_start, _ = _period_dates(days)
|
||||
period_end = datetime.now(timezone.utc)
|
||||
tenant_filter = and_(
|
||||
Ticket.tenant_id == current_user.tenant_id,
|
||||
Ticket.created_at >= period_start,
|
||||
)
|
||||
|
||||
categories_result = await db.execute(
|
||||
select(Category).where(
|
||||
and_(Category.tenant_id == current_user.tenant_id, Category.is_active == True)
|
||||
)
|
||||
)
|
||||
categories = categories_result.scalars().all()
|
||||
|
||||
rows: List[CategoryReportRow] = []
|
||||
|
||||
for cat in categories:
|
||||
cat_filter = and_(tenant_filter, Ticket.category_id == cat.id)
|
||||
|
||||
total = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(cat_filter)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
if total == 0:
|
||||
continue
|
||||
|
||||
resolved = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(cat_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]))
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
avg_res_seconds = (await db.execute(
|
||||
select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at)))
|
||||
.where(and_(cat_filter, Ticket.resolved_at.isnot(None)))
|
||||
)).scalar_one_or_none()
|
||||
|
||||
# SLA compliance: tickets resueltos ANTES del deadline
|
||||
sla_met = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(
|
||||
cat_filter,
|
||||
Ticket.resolved_at.isnot(None),
|
||||
Ticket.sla_resolution_due.isnot(None),
|
||||
Ticket.resolved_at <= Ticket.sla_resolution_due,
|
||||
)
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
tickets_with_sla = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(cat_filter, Ticket.sla_resolution_due.isnot(None), Ticket.resolved_at.isnot(None))
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
sla_compliance_pct = round((sla_met / tickets_with_sla * 100), 1) if tickets_with_sla else 0.0
|
||||
|
||||
rows.append(CategoryReportRow(
|
||||
category_id=str(cat.id),
|
||||
category_name=cat.name,
|
||||
total_tickets=total,
|
||||
open_tickets=total - resolved,
|
||||
resolved_tickets=resolved,
|
||||
avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None,
|
||||
sla_response_hours=cat.sla_response_hours,
|
||||
sla_resolution_hours=cat.sla_resolution_hours,
|
||||
sla_compliance_pct=sla_compliance_pct,
|
||||
))
|
||||
|
||||
# Sin categoría
|
||||
uncategorized = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(tenant_filter, Ticket.category_id.is_(None))
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
rows.sort(key=lambda r: r.total_tickets, reverse=True)
|
||||
|
||||
return CategoryReportResponse(
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
categories=rows,
|
||||
uncategorized_count=uncategorized,
|
||||
)
|
||||
|
||||
|
||||
# ===================================
|
||||
# 4. TICKETS POR CLIENTE (solo ADMIN)
|
||||
# ===================================
|
||||
|
||||
@router.get("/by-client", response_model=ClientReportResponse)
|
||||
async def get_report_by_client(
|
||||
days: int = Query(default=30, ge=1, le=365),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""
|
||||
Tickets agrupados por cliente (tenant). Solo accesible por ADMIN.
|
||||
Útil para ver qué clientes generan más trabajo.
|
||||
"""
|
||||
period_start, period_end = _period_dates(days)
|
||||
|
||||
tenants_result = await db.execute(select(Tenant).where(Tenant.status == TenantStatus.ACTIVE))
|
||||
tenants = tenants_result.scalars().all()
|
||||
|
||||
rows: List[ClientReportRow] = []
|
||||
|
||||
for tenant in tenants:
|
||||
t_filter = and_(
|
||||
Ticket.tenant_id == tenant.id,
|
||||
Ticket.created_at >= period_start,
|
||||
)
|
||||
|
||||
total = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(t_filter)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
if total == 0:
|
||||
continue
|
||||
|
||||
resolved = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(t_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]))
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
urgent = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(t_filter, Ticket.priority == TicketPriority.URGENT)
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
csat_row = (await db.execute(
|
||||
select(func.avg(Ticket.rating))
|
||||
.where(and_(t_filter, Ticket.rating.isnot(None)))
|
||||
)).scalar_one_or_none()
|
||||
|
||||
avg_res_seconds = (await db.execute(
|
||||
select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at)))
|
||||
.where(and_(t_filter, Ticket.resolved_at.isnot(None)))
|
||||
)).scalar_one_or_none()
|
||||
|
||||
last_ticket = (await db.execute(
|
||||
select(func.max(Ticket.created_at)).where(t_filter)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
rows.append(ClientReportRow(
|
||||
tenant_id=str(tenant.id),
|
||||
tenant_name=tenant.name,
|
||||
total_tickets=total,
|
||||
open_tickets=total - resolved,
|
||||
resolved_tickets=resolved,
|
||||
urgent_tickets=urgent,
|
||||
avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None,
|
||||
avg_rating=round(float(csat_row), 2) if csat_row else None,
|
||||
last_ticket_at=last_ticket,
|
||||
))
|
||||
|
||||
rows.sort(key=lambda r: r.total_tickets, reverse=True)
|
||||
|
||||
return ClientReportResponse(
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
clients=rows,
|
||||
total_clients=len(rows),
|
||||
)
|
||||
|
||||
|
||||
# ===================================
|
||||
# 5. TENDENCIAS (TICKETS EN EL TIEMPO)
|
||||
# ===================================
|
||||
|
||||
@router.get("/trends", response_model=TrendsReportResponse)
|
||||
async def get_report_trends(
|
||||
days: int = Query(default=30, ge=7, le=90, description="Número de días (7-90)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_reports_access),
|
||||
):
|
||||
"""
|
||||
Evolución diaria de tickets creados y resueltos.
|
||||
Útil para detectar picos de trabajo.
|
||||
"""
|
||||
period_start, period_end = _period_dates(days)
|
||||
tenant_filter = Ticket.tenant_id == current_user.tenant_id
|
||||
|
||||
# Tickets creados por día
|
||||
created_rows = (await db.execute(
|
||||
select(
|
||||
func.date_trunc("day", Ticket.created_at).label("day"),
|
||||
func.count(Ticket.id).label("cnt"),
|
||||
)
|
||||
.where(and_(tenant_filter, Ticket.created_at >= period_start))
|
||||
.group_by(func.date_trunc("day", Ticket.created_at))
|
||||
.order_by(func.date_trunc("day", Ticket.created_at))
|
||||
)).all()
|
||||
|
||||
# Tickets resueltos por día (según resolved_at)
|
||||
resolved_rows = (await db.execute(
|
||||
select(
|
||||
func.date_trunc("day", Ticket.resolved_at).label("day"),
|
||||
func.count(Ticket.id).label("cnt"),
|
||||
)
|
||||
.where(and_(
|
||||
tenant_filter,
|
||||
Ticket.resolved_at >= period_start,
|
||||
Ticket.resolved_at.isnot(None),
|
||||
))
|
||||
.group_by(func.date_trunc("day", Ticket.resolved_at))
|
||||
.order_by(func.date_trunc("day", Ticket.resolved_at))
|
||||
)).all()
|
||||
|
||||
created_map: dict[str, int] = {r.day.strftime("%Y-%m-%d"): r.cnt for r in created_rows}
|
||||
resolved_map: dict[str, int] = {r.day.strftime("%Y-%m-%d"): r.cnt for r in resolved_rows}
|
||||
|
||||
# Un punto por cada día del período
|
||||
data_points: List[TrendDataPoint] = []
|
||||
current = period_start
|
||||
while current <= period_end:
|
||||
date_str = current.strftime("%Y-%m-%d")
|
||||
c = created_map.get(date_str, 0)
|
||||
r = resolved_map.get(date_str, 0)
|
||||
data_points.append(TrendDataPoint(date=date_str, created=c, resolved=r, net_open=c - r))
|
||||
current += timedelta(days=1)
|
||||
|
||||
return TrendsReportResponse(
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
data_points=data_points,
|
||||
total_days=len(data_points),
|
||||
)
|
||||
|
||||
|
||||
# ===================================
|
||||
# 6. SATISFACCIÓN DEL CLIENTE (CSAT)
|
||||
# ===================================
|
||||
|
||||
@router.get("/csat", response_model=CSATReportResponse)
|
||||
async def get_report_csat(
|
||||
days: int = Query(default=30, ge=1, le=365),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_reports_access),
|
||||
):
|
||||
"""
|
||||
Reporte de satisfacción del cliente (calificaciones 1-5).
|
||||
Incluye distribución, promedio por categoría y por agente.
|
||||
"""
|
||||
period_start, period_end = _period_dates(days)
|
||||
tenant_filter = and_(
|
||||
Ticket.tenant_id == current_user.tenant_id,
|
||||
Ticket.created_at >= period_start,
|
||||
)
|
||||
|
||||
# Total y promedio general
|
||||
general = (await db.execute(
|
||||
select(func.avg(Ticket.rating).label("avg"), func.count(Ticket.rating).label("rated"))
|
||||
.where(and_(tenant_filter, Ticket.rating.isnot(None)))
|
||||
)).one()
|
||||
total_tickets = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(tenant_filter)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
# Distribución por estrellas
|
||||
dist_rows = (await db.execute(
|
||||
select(Ticket.rating, func.count(Ticket.id).label("cnt"))
|
||||
.where(and_(tenant_filter, Ticket.rating.isnot(None)))
|
||||
.group_by(Ticket.rating)
|
||||
)).all()
|
||||
|
||||
dist = CSATDistribution()
|
||||
for row in dist_rows:
|
||||
setattr(dist, f"rating_{row.rating}", row.cnt)
|
||||
|
||||
# Promedio por categoría
|
||||
cat_rows = (await db.execute(
|
||||
select(
|
||||
Category.name.label("cat_name"),
|
||||
func.avg(Ticket.rating).label("avg"),
|
||||
func.count(Ticket.rating).label("cnt"),
|
||||
)
|
||||
.join(Category, Ticket.category_id == Category.id, isouter=True)
|
||||
.where(and_(tenant_filter, Ticket.rating.isnot(None)))
|
||||
.group_by(Category.name)
|
||||
.order_by(func.avg(Ticket.rating).desc())
|
||||
)).all()
|
||||
|
||||
by_category = [
|
||||
{
|
||||
"category": row.cat_name or "Sin categoría",
|
||||
"avg_rating": round(float(row.avg), 2) if row.avg else None,
|
||||
"total_rated": row.cnt,
|
||||
}
|
||||
for row in cat_rows
|
||||
]
|
||||
|
||||
# Promedio por agente
|
||||
agent_rows = (await db.execute(
|
||||
select(
|
||||
User.first_name.label("fname"),
|
||||
User.last_name.label("lname"),
|
||||
func.avg(Ticket.rating).label("avg"),
|
||||
func.count(Ticket.rating).label("cnt"),
|
||||
)
|
||||
.join(User, Ticket.assigned_to == User.id, isouter=True)
|
||||
.where(and_(tenant_filter, Ticket.rating.isnot(None)))
|
||||
.group_by(User.first_name, User.last_name)
|
||||
.order_by(func.avg(Ticket.rating).desc())
|
||||
)).all()
|
||||
|
||||
by_agent = [
|
||||
{
|
||||
"agent": f"{row.fname or ''} {row.lname or ''}".strip() or "Sin asignar",
|
||||
"avg_rating": round(float(row.avg), 2) if row.avg else None,
|
||||
"total_rated": row.cnt,
|
||||
}
|
||||
for row in agent_rows
|
||||
]
|
||||
|
||||
# Últimos comentarios de calificación (rating_comment)
|
||||
comment_rows = (await db.execute(
|
||||
select(Ticket.rating, Ticket.rating_comment, Ticket.rated_at)
|
||||
.where(and_(
|
||||
tenant_filter,
|
||||
Ticket.rating.isnot(None),
|
||||
Ticket.rating_comment.isnot(None),
|
||||
Ticket.rating_comment != "",
|
||||
))
|
||||
.order_by(Ticket.rated_at.desc())
|
||||
.limit(10)
|
||||
)).all()
|
||||
|
||||
recent_comments = [
|
||||
{
|
||||
"rating": row.rating,
|
||||
"comment": row.rating_comment,
|
||||
"rated_at": row.rated_at.isoformat() if row.rated_at else None,
|
||||
}
|
||||
for row in comment_rows
|
||||
]
|
||||
|
||||
total_rated = general.rated or 0
|
||||
response_rate = round((total_rated / total_tickets * 100), 1) if total_tickets else 0.0
|
||||
|
||||
return CSATReportResponse(
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
avg_rating=round(float(general.avg), 2) if general.avg else None,
|
||||
total_rated=total_rated,
|
||||
total_tickets=total_tickets,
|
||||
response_rate=response_rate,
|
||||
distribution=dist,
|
||||
by_category=by_category,
|
||||
by_agent=by_agent,
|
||||
recent_comments=recent_comments,
|
||||
)
|
||||
|
||||
|
||||
# ===================================
|
||||
# 7. TICKETS POR SISTEMA AFECTADO
|
||||
# ===================================
|
||||
|
||||
@router.get("/by-system", response_model=SystemReportResponse)
|
||||
async def get_report_by_system(
|
||||
days: int = Query(default=30, ge=1, le=365),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_reports_access),
|
||||
):
|
||||
"""
|
||||
Tickets agrupados por sistema afectado.
|
||||
Útil para detectar qué sistemas generan más incidentes.
|
||||
"""
|
||||
period_start, period_end = _period_dates(days)
|
||||
tenant_filter = and_(
|
||||
Ticket.tenant_id == current_user.tenant_id,
|
||||
Ticket.created_at >= period_start,
|
||||
)
|
||||
|
||||
systems_result = await db.execute(
|
||||
select(System).where(
|
||||
and_(System.tenant_id == current_user.tenant_id, System.is_active == True)
|
||||
)
|
||||
)
|
||||
systems = systems_result.scalars().all()
|
||||
|
||||
rows: List[SystemReportRow] = []
|
||||
|
||||
for sys in systems:
|
||||
sys_filter = and_(tenant_filter, Ticket.affected_system_id == sys.id)
|
||||
|
||||
total = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(sys_filter)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
if total == 0:
|
||||
continue
|
||||
|
||||
resolved = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(sys_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]))
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
urgent = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(sys_filter, Ticket.priority == TicketPriority.URGENT)
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
avg_res_seconds = (await db.execute(
|
||||
select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at)))
|
||||
.where(and_(sys_filter, Ticket.resolved_at.isnot(None)))
|
||||
)).scalar_one_or_none()
|
||||
|
||||
rows.append(SystemReportRow(
|
||||
system_id=str(sys.id),
|
||||
system_name=sys.name,
|
||||
total_tickets=total,
|
||||
open_tickets=total - resolved,
|
||||
resolved_tickets=resolved,
|
||||
urgent_tickets=urgent,
|
||||
avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None,
|
||||
))
|
||||
|
||||
# Sin sistema asignado
|
||||
no_system = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(tenant_filter, Ticket.affected_system_id.is_(None))
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
rows.sort(key=lambda r: r.total_tickets, reverse=True)
|
||||
|
||||
return SystemReportResponse(
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
systems=rows,
|
||||
no_system_count=no_system,
|
||||
)
|
||||
Reference in New Issue
Block a user