- Refactorización de endpoints de auditoría y helpers - Mejoras en esquemas de auditoría (audit.py) - Correcciones en endpoint SLA - Actualizaciones en múltiples rutas del frontend interno: layout, tickets, usuarios, tenants, categorías, sistemas, SLA (at-risk, violations), auditoría (main + security), login, perfil - Actualización de tailwind.config.js - Eliminación de docs de versiones anteriores (CAMBIOS_v1.10.0, v1.8.0, OPTIMIZACIONES) - Nuevos scripts de prueba: generate_security_test_data.py, generate_sla_test_data.py - Script de prueba de sincronización crítica (test_critical_sync.ps1) - README actualizado en scripts/
1072 lines
38 KiB
Python
1072 lines
38 KiB
Python
"""
|
|
Tickets endpoints - ServiceManagerWeb
|
|
"""
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
|
from fastapi.responses import FileResponse
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, func
|
|
from sqlalchemy.orm import selectinload
|
|
from typing import List, Optional
|
|
from datetime import datetime
|
|
from app.core.database import get_db
|
|
from app.api.deps import get_current_user, get_current_tenant
|
|
from app.models.ticket import Ticket, TicketStatus, TicketPriority
|
|
from app.models.user import User
|
|
from app.models.tenant import Tenant
|
|
from app.models.category import Category
|
|
from app.models.system import System
|
|
from app.models.comment import TicketComment
|
|
from app.models.attachment import TicketAttachment
|
|
from app.api.schemas.attachment import AttachmentResponse
|
|
from app.api.schemas.ticket import (
|
|
TicketCreate, TicketUpdate, TicketResponse,
|
|
TicketCloseRequest, CommentCreate, CommentResponse
|
|
)
|
|
from app.core.file_handler import file_handler
|
|
from app.services.audit_service import AuditService
|
|
import uuid
|
|
|
|
router = APIRouter()
|
|
|
|
# ===================================
|
|
# TICKET ENDPOINTS
|
|
# ===================================
|
|
|
|
@router.post("/", response_model=TicketResponse, status_code=status.HTTP_201_CREATED)
|
|
async def create_ticket(
|
|
ticket: TicketCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Crear un nuevo ticket
|
|
"""
|
|
# Retry logic para evitar race conditions en generación de ticket_number
|
|
max_retries = 3
|
|
last_error = None
|
|
|
|
for attempt in range(max_retries):
|
|
try:
|
|
# Generar número de ticket único basado en el máximo existente
|
|
result = await db.execute(
|
|
select(Ticket.ticket_number)
|
|
.where(Ticket.tenant_id == current_user.tenant_id)
|
|
.order_by(Ticket.ticket_number.desc())
|
|
.limit(1)
|
|
)
|
|
last_ticket_number = result.scalar_one_or_none()
|
|
|
|
if last_ticket_number:
|
|
# Extraer el número del formato TK-XXXXXX
|
|
last_number = int(last_ticket_number.split('-')[1])
|
|
next_number = last_number + 1
|
|
else:
|
|
next_number = 1
|
|
|
|
ticket_number = f"TK-{next_number:06d}"
|
|
|
|
# Convertir IDs de string a UUID si son proporcionados
|
|
category_uuid = uuid.UUID(ticket.category_id) if ticket.category_id else None
|
|
system_uuid = uuid.UUID(ticket.affected_system_id) if ticket.affected_system_id else None
|
|
|
|
# Validar categoría
|
|
category = None
|
|
if category_uuid:
|
|
category = await db.get(Category, category_uuid)
|
|
if not category:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"La categoría con ID {ticket.category_id} no existe."
|
|
)
|
|
|
|
# Validar sistema
|
|
if system_uuid:
|
|
system = await db.get(System, system_uuid)
|
|
if not system:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"El sistema con ID {ticket.affected_system_id} no existe."
|
|
)
|
|
|
|
# Calcular SLA deadlines basados en la categoría
|
|
from datetime import timedelta
|
|
sla_response_due = None
|
|
sla_resolution_due = None
|
|
assigned_to_user = None
|
|
|
|
if category:
|
|
now = datetime.utcnow()
|
|
sla_response_due = now + timedelta(hours=category.sla_response_hours)
|
|
sla_resolution_due = now + timedelta(hours=category.sla_resolution_hours)
|
|
|
|
# Auto-asignar si la categoría tiene configurado auto_assign_to
|
|
if category.auto_assign_to:
|
|
assigned_to_user = category.auto_assign_to
|
|
|
|
db_ticket = Ticket(
|
|
id=uuid.uuid4(),
|
|
tenant_id=current_user.tenant_id,
|
|
ticket_number=ticket_number,
|
|
subject=ticket.subject,
|
|
description=ticket.description,
|
|
category_id=category_uuid,
|
|
affected_system_id=system_uuid,
|
|
priority=TicketPriority[ticket.priority.upper()],
|
|
created_by=current_user.id,
|
|
assigned_to=assigned_to_user,
|
|
status=TicketStatus.NEW,
|
|
sla_response_due=sla_response_due,
|
|
sla_resolution_due=sla_resolution_due,
|
|
created_at=datetime.utcnow(),
|
|
updated_at=datetime.utcnow()
|
|
)
|
|
|
|
db.add(db_ticket)
|
|
await db.commit()
|
|
await db.refresh(db_ticket)
|
|
|
|
# Registrar creación en auditoría
|
|
try:
|
|
await AuditService.log(
|
|
db=db,
|
|
tenant_id=current_user.tenant_id,
|
|
user_id=current_user.id,
|
|
action="ticket.create",
|
|
resource_type="ticket",
|
|
resource_id=db_ticket.id,
|
|
new_values={
|
|
"ticket_number": db_ticket.ticket_number,
|
|
"subject": db_ticket.subject,
|
|
"priority": db_ticket.priority.value,
|
|
"status": db_ticket.status.value
|
|
}
|
|
)
|
|
await db.commit()
|
|
except Exception as e:
|
|
# No fallar si falla el audit log
|
|
pass
|
|
|
|
# ✅ Éxito - retornar ticket creado
|
|
return {
|
|
"id": str(db_ticket.id),
|
|
"ticket_number": db_ticket.ticket_number,
|
|
"subject": db_ticket.subject,
|
|
"title": db_ticket.subject,
|
|
"description": db_ticket.description,
|
|
"status": db_ticket.status.value,
|
|
"priority": db_ticket.priority.value,
|
|
"category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
|
|
"affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None,
|
|
"created_by": str(db_ticket.created_by),
|
|
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
|
|
"created_at": db_ticket.created_at,
|
|
"updated_at": db_ticket.updated_at
|
|
}
|
|
|
|
except ValueError as e:
|
|
await db.rollback()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid UUID format: {str(e)}"
|
|
)
|
|
except HTTPException:
|
|
# Re-lanzar HTTPExceptions directamente
|
|
await db.rollback()
|
|
raise
|
|
except Exception as e:
|
|
await db.rollback()
|
|
last_error = e
|
|
|
|
# Si es un error de llave duplicada, reintentar
|
|
if "duplicate key" in str(e).lower() and "ticket_number" in str(e).lower():
|
|
if attempt < max_retries - 1:
|
|
continue # Reintentar
|
|
|
|
# Para cualquier otro error, fallar inmediatamente
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Error creating ticket: {str(e)}"
|
|
)
|
|
|
|
# Si llegamos aquí después de todos los reintentos
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"No se pudo crear el ticket después de {max_retries} intentos: {str(last_error)}"
|
|
)
|
|
|
|
|
|
@router.get("/", response_model=List[TicketResponse])
|
|
async def get_tickets(
|
|
skip: int = 0,
|
|
limit: int = 100,
|
|
status: Optional[str] = None,
|
|
priority: Optional[str] = None,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Obtener tickets con filtros opcionales
|
|
Roles ADMIN/SUPPORT_MANAGER/AGENT: Ven todos los tickets del tenant
|
|
Roles CLIENT_USER/CLIENT_ADMIN: Solo ven sus propios tickets
|
|
|
|
Filtros disponibles:
|
|
- status: NEW, IN_PROGRESS, WAITING_CUSTOMER, RESOLVED, CLOSED, REOPENED
|
|
- priority: LOW, MEDIUM, HIGH, URGENT
|
|
"""
|
|
# Construir query base filtrado por tenant
|
|
query = select(Ticket).where(
|
|
Ticket.tenant_id == current_user.tenant_id
|
|
)
|
|
|
|
# Si es cliente, solo puede ver sus propios tickets
|
|
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
|
|
query = query.where(Ticket.created_by == current_user.id)
|
|
|
|
# Filtro por estado
|
|
if status:
|
|
try:
|
|
status_enum = TicketStatus[status.upper()]
|
|
query = query.where(Ticket.status == status_enum)
|
|
except KeyError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid status: {status}. Valid values: NEW, IN_PROGRESS, WAITING_CUSTOMER, RESOLVED, CLOSED, REOPENED"
|
|
)
|
|
|
|
# Filtro por prioridad
|
|
if priority:
|
|
try:
|
|
priority_enum = TicketPriority[priority.upper()]
|
|
query = query.where(Ticket.priority == priority_enum)
|
|
except KeyError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid priority: {priority}. Valid values: LOW, MEDIUM, HIGH, URGENT"
|
|
)
|
|
|
|
query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit)
|
|
|
|
result = await db.execute(query)
|
|
tickets = result.scalars().all()
|
|
|
|
# ✅ CORREGIDO: Usar affected_system_id y agregar campos SLA
|
|
return [
|
|
{
|
|
"id": str(t.id),
|
|
"ticket_number": t.ticket_number,
|
|
"subject": t.subject,
|
|
"title": t.subject,
|
|
"description": t.description,
|
|
"status": t.status.value,
|
|
"priority": t.priority.value,
|
|
"category_id": str(t.category_id) if t.category_id else None,
|
|
"affected_system_id": str(t.affected_system_id) if t.affected_system_id else None,
|
|
"created_by": str(t.created_by),
|
|
"assigned_to": str(t.assigned_to) if t.assigned_to else None,
|
|
"created_at": t.created_at,
|
|
"updated_at": t.updated_at,
|
|
"sla_response_due": t.sla_response_due,
|
|
"sla_resolution_due": t.sla_resolution_due,
|
|
"first_response_at": t.first_response_at,
|
|
"resolved_at": t.resolved_at
|
|
}
|
|
for t in tickets
|
|
]
|
|
|
|
|
|
@router.get("/admin/all", response_model=List[dict])
|
|
async def get_all_tickets_admin(
|
|
skip: int = 0,
|
|
limit: int = 100,
|
|
status_filter: Optional[str] = None,
|
|
priority_filter: Optional[str] = None,
|
|
tenant_id_filter: Optional[str] = None,
|
|
category_filter: Optional[str] = None,
|
|
assigned_to_filter: Optional[str] = None,
|
|
search: Optional[str] = None,
|
|
date_from: Optional[str] = None,
|
|
date_to: Optional[str] = None,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Obtener todos los tickets de todos los tenants (solo para administradores)
|
|
Incluye información del tenant y usuario que creó el ticket
|
|
Filtros: estado, prioridad, tenant, categoría, asignado a, búsqueda de texto y fechas
|
|
"""
|
|
# Verificar que el usuario sea administrador
|
|
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="No tienes permisos para acceder a esta función"
|
|
)
|
|
|
|
# Query base con joins para obtener información del tenant y usuario creador
|
|
query = select(Ticket, Tenant, User).join(
|
|
Tenant, Ticket.tenant_id == Tenant.id
|
|
).join(
|
|
User, Ticket.created_by == User.id
|
|
)
|
|
|
|
# Aplicar filtros
|
|
if status_filter:
|
|
try:
|
|
status_enum = TicketStatus[status_filter.upper()]
|
|
query = query.where(Ticket.status == status_enum)
|
|
except KeyError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid status: {status_filter}"
|
|
)
|
|
|
|
if priority_filter:
|
|
try:
|
|
priority_enum = TicketPriority[priority_filter.upper()]
|
|
query = query.where(Ticket.priority == priority_enum)
|
|
except KeyError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid priority: {priority_filter}"
|
|
)
|
|
|
|
if tenant_id_filter:
|
|
try:
|
|
tenant_uuid = uuid.UUID(tenant_id_filter)
|
|
query = query.where(Ticket.tenant_id == tenant_uuid)
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid tenant ID format"
|
|
)
|
|
|
|
if category_filter:
|
|
try:
|
|
category_uuid = uuid.UUID(category_filter)
|
|
query = query.where(Ticket.category_id == category_uuid)
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid category ID format"
|
|
)
|
|
|
|
if assigned_to_filter:
|
|
try:
|
|
assigned_uuid = uuid.UUID(assigned_to_filter)
|
|
query = query.where(Ticket.assigned_to == assigned_uuid)
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid assigned user ID format"
|
|
)
|
|
|
|
if search:
|
|
# Búsqueda de texto en subject y description
|
|
search_pattern = f"%{search}%"
|
|
query = query.where(
|
|
(Ticket.subject.ilike(search_pattern)) |
|
|
(Ticket.description.ilike(search_pattern))
|
|
)
|
|
|
|
if date_from:
|
|
try:
|
|
from datetime import datetime
|
|
date_from_parsed = datetime.fromisoformat(date_from)
|
|
query = query.where(Ticket.created_at >= date_from_parsed)
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid date_from format. Use YYYY-MM-DD"
|
|
)
|
|
|
|
if date_to:
|
|
try:
|
|
from datetime import datetime, timedelta
|
|
# Agregar 1 día para incluir todo el día final
|
|
date_to_parsed = datetime.fromisoformat(date_to) + timedelta(days=1)
|
|
query = query.where(Ticket.created_at < date_to_parsed)
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid date_to format. Use YYYY-MM-DD"
|
|
)
|
|
|
|
query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit)
|
|
|
|
result = await db.execute(query)
|
|
rows = result.all()
|
|
|
|
return [
|
|
{
|
|
"id": str(ticket.id),
|
|
"ticket_number": ticket.ticket_number,
|
|
"subject": ticket.subject,
|
|
"title": ticket.subject,
|
|
"description": ticket.description,
|
|
"status": ticket.status.value,
|
|
"priority": ticket.priority.value,
|
|
"category_id": str(ticket.category_id) if ticket.category_id else None,
|
|
"affected_system_id": str(ticket.affected_system_id) if ticket.affected_system_id else None,
|
|
"created_by": str(ticket.created_by),
|
|
"assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None,
|
|
"created_at": ticket.created_at,
|
|
"updated_at": ticket.updated_at,
|
|
# Información del tenant/cliente
|
|
"tenant": {
|
|
"id": str(tenant.id),
|
|
"name": tenant.name,
|
|
"slug": tenant.slug,
|
|
"contact_email": tenant.contact_email
|
|
},
|
|
# Información del usuario creador
|
|
"created_by_user": {
|
|
"id": str(user.id),
|
|
"email": user.email,
|
|
"first_name": user.first_name,
|
|
"last_name": user.last_name,
|
|
"role": user.role.value if hasattr(user.role, 'value') else str(user.role)
|
|
}
|
|
}
|
|
for ticket, tenant, user in rows
|
|
]
|
|
|
|
|
|
@router.get("/{ticket_id}", response_model=TicketResponse)
|
|
async def get_ticket(
|
|
ticket_id: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Obtener un ticket específico
|
|
Roles ADMIN/SUPPORT_MANAGER/AGENT: Pueden ver todos los tickets del tenant
|
|
Roles CLIENT_USER/CLIENT_ADMIN: Solo pueden ver sus propios tickets
|
|
"""
|
|
try:
|
|
ticket_uuid = uuid.UUID(ticket_id)
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid ticket ID format"
|
|
)
|
|
|
|
# Construir query basado en el rol del usuario
|
|
query = select(Ticket).where(
|
|
Ticket.id == ticket_uuid,
|
|
Ticket.tenant_id == current_user.tenant_id
|
|
)
|
|
|
|
# Si es cliente, solo puede ver sus propios tickets
|
|
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
|
|
query = query.where(Ticket.created_by == current_user.id)
|
|
|
|
result = await db.execute(query)
|
|
ticket = result.scalars().first()
|
|
|
|
if not ticket:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Ticket {ticket_id} not found"
|
|
)
|
|
|
|
# ✅ CORREGIDO: Usar affected_system_id + campos SLA
|
|
return {
|
|
"id": str(ticket.id),
|
|
"ticket_number": ticket.ticket_number,
|
|
"subject": ticket.subject,
|
|
"title": ticket.subject,
|
|
"description": ticket.description,
|
|
"status": ticket.status.value,
|
|
"priority": ticket.priority.value,
|
|
"category_id": str(ticket.category_id) if ticket.category_id else None,
|
|
"affected_system_id": str(ticket.affected_system_id) if ticket.affected_system_id else None,
|
|
"created_by": str(ticket.created_by),
|
|
"assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None,
|
|
"created_at": ticket.created_at,
|
|
"updated_at": ticket.updated_at,
|
|
"first_response_at": ticket.first_response_at,
|
|
"resolved_at": ticket.resolved_at,
|
|
"sla_response_due": ticket.sla_response_due,
|
|
"sla_resolution_due": ticket.sla_resolution_due,
|
|
}
|
|
|
|
|
|
@router.patch("/{ticket_id}", response_model=TicketResponse)
|
|
async def update_ticket(
|
|
ticket_id: str,
|
|
ticket_update: TicketUpdate,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Actualizar un ticket
|
|
Roles ADMIN/SUPPORT_MANAGER/AGENT: Pueden actualizar cualquier ticket del tenant
|
|
Roles CLIENT_USER/CLIENT_ADMIN: Solo pueden actualizar sus propios tickets
|
|
"""
|
|
try:
|
|
ticket_uuid = uuid.UUID(ticket_id)
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid ticket ID format"
|
|
)
|
|
|
|
# Construir query basado en el rol del usuario
|
|
query = select(Ticket).where(
|
|
Ticket.id == ticket_uuid,
|
|
Ticket.tenant_id == current_user.tenant_id
|
|
)
|
|
|
|
# Si es cliente, solo puede actualizar sus propios tickets
|
|
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
|
|
query = query.where(Ticket.created_by == current_user.id)
|
|
|
|
result = await db.execute(query)
|
|
db_ticket = result.scalars().first()
|
|
|
|
if not db_ticket:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Ticket {ticket_id} not found"
|
|
)
|
|
|
|
# Guardar valores anteriores para audit
|
|
old_values = {
|
|
"subject": db_ticket.subject,
|
|
"description": db_ticket.description,
|
|
"status": db_ticket.status.value,
|
|
"priority": db_ticket.priority.value,
|
|
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None
|
|
}
|
|
|
|
try:
|
|
update_data = ticket_update.dict(exclude_unset=True)
|
|
|
|
for field, value in update_data.items():
|
|
if field == "status" and value:
|
|
new_status = TicketStatus[value.upper()]
|
|
setattr(db_ticket, field, new_status)
|
|
# Registrar timestamp de resolución
|
|
if new_status in (TicketStatus.RESOLVED, TicketStatus.CLOSED):
|
|
if db_ticket.resolved_at is None:
|
|
db_ticket.resolved_at = datetime.utcnow()
|
|
# Si reabre el ticket, limpiar resolved_at
|
|
elif new_status in (TicketStatus.NEW, TicketStatus.IN_PROGRESS, TicketStatus.WAITING_CUSTOMER):
|
|
db_ticket.resolved_at = None
|
|
elif field == "priority" and value:
|
|
setattr(db_ticket, field, TicketPriority[value.upper()])
|
|
elif field == "assigned_to" and value:
|
|
setattr(db_ticket, field, uuid.UUID(value))
|
|
else:
|
|
setattr(db_ticket, field, value)
|
|
|
|
db_ticket.updated_at = datetime.utcnow()
|
|
|
|
await db.commit()
|
|
await db.refresh(db_ticket)
|
|
|
|
# Registrar actualización en auditoría
|
|
try:
|
|
new_values = {
|
|
"subject": db_ticket.subject,
|
|
"description": db_ticket.description,
|
|
"status": db_ticket.status.value,
|
|
"priority": db_ticket.priority.value,
|
|
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None
|
|
}
|
|
|
|
# Si cambió assigned_to, registrar como acción de asignación
|
|
action = "ticket.assign" if old_values["assigned_to"] != new_values["assigned_to"] else "ticket.update"
|
|
|
|
await AuditService.log(
|
|
db=db,
|
|
tenant_id=current_user.tenant_id,
|
|
user_id=current_user.id,
|
|
action=action,
|
|
resource_type="ticket",
|
|
resource_id=db_ticket.id,
|
|
old_values=old_values,
|
|
new_values=new_values
|
|
)
|
|
await db.commit()
|
|
except Exception as e:
|
|
# No fallar si falla el audit log
|
|
pass
|
|
|
|
# ✅ CORREGIDO: Usar affected_system_id
|
|
return {
|
|
"id": str(db_ticket.id),
|
|
"ticket_number": db_ticket.ticket_number,
|
|
"subject": db_ticket.subject,
|
|
"title": db_ticket.subject,
|
|
"description": db_ticket.description,
|
|
"status": db_ticket.status.value,
|
|
"priority": db_ticket.priority.value,
|
|
"category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
|
|
"affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None, # ✅ CORREGIDO
|
|
"created_by": str(db_ticket.created_by),
|
|
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
|
|
"created_at": db_ticket.created_at,
|
|
"updated_at": db_ticket.updated_at
|
|
}
|
|
|
|
except Exception as e:
|
|
await db.rollback()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Error updating ticket: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.patch("/{ticket_id}/close", response_model=TicketResponse)
|
|
async def close_ticket(
|
|
ticket_id: str,
|
|
close_request: TicketCloseRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Cerrar un ticket
|
|
Roles ADMIN/SUPPORT_MANAGER/AGENT: Pueden cerrar cualquier ticket del tenant
|
|
Roles CLIENT_USER/CLIENT_ADMIN: Solo pueden cerrar sus propios tickets
|
|
"""
|
|
try:
|
|
ticket_uuid = uuid.UUID(ticket_id)
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid ticket ID format"
|
|
)
|
|
|
|
# Construir query basado en el rol del usuario
|
|
query = select(Ticket).where(
|
|
Ticket.id == ticket_uuid,
|
|
Ticket.tenant_id == current_user.tenant_id
|
|
)
|
|
|
|
# Si es cliente, solo puede cerrar sus propios tickets
|
|
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
|
|
query = query.where(Ticket.created_by == current_user.id)
|
|
|
|
result = await db.execute(query)
|
|
db_ticket = result.scalars().first()
|
|
|
|
if not db_ticket:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Ticket {ticket_id} not found"
|
|
)
|
|
|
|
try:
|
|
db_ticket.status = TicketStatus.CLOSED
|
|
if db_ticket.resolved_at is None:
|
|
db_ticket.resolved_at = datetime.utcnow()
|
|
db_ticket.updated_at = datetime.utcnow()
|
|
|
|
await db.commit()
|
|
await db.refresh(db_ticket)
|
|
|
|
return {
|
|
"id": str(db_ticket.id),
|
|
"ticket_number": db_ticket.ticket_number,
|
|
"subject": db_ticket.subject,
|
|
"title": db_ticket.subject,
|
|
"description": db_ticket.description,
|
|
"status": db_ticket.status.value,
|
|
"priority": db_ticket.priority.value,
|
|
"category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
|
|
"affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None,
|
|
"created_by": str(db_ticket.created_by),
|
|
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
|
|
"created_at": db_ticket.created_at,
|
|
"updated_at": db_ticket.updated_at,
|
|
"resolved_at": db_ticket.resolved_at
|
|
}
|
|
|
|
except Exception as e:
|
|
await db.rollback()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Error closing ticket: {str(e)}"
|
|
)
|
|
|
|
|
|
## ===================================
|
|
# COMMENT ENDPOINTS
|
|
# ===================================
|
|
|
|
|
|
@router.get("/{ticket_id}/comments", response_model=List[CommentResponse])
|
|
async def get_ticket_comments(
|
|
ticket_id: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Obtener comentarios de un ticket
|
|
"""
|
|
try:
|
|
ticket_uuid = uuid.UUID(ticket_id)
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid ticket ID format"
|
|
)
|
|
|
|
# Verificar que el ticket existe y el usuario tiene acceso
|
|
ticket_query = select(Ticket).where(
|
|
Ticket.id == ticket_uuid,
|
|
Ticket.tenant_id == current_user.tenant_id
|
|
)
|
|
ticket_result = await db.execute(ticket_query)
|
|
ticket = ticket_result.scalars().first()
|
|
|
|
if not ticket:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Ticket {ticket_id} not found"
|
|
)
|
|
|
|
# Obtener comentarios
|
|
comments_query = select(TicketComment, User).join(
|
|
User, TicketComment.author_id == User.id
|
|
).where(
|
|
TicketComment.ticket_id == ticket_uuid
|
|
).order_by(TicketComment.created_at.asc())
|
|
|
|
result = await db.execute(comments_query)
|
|
comments_with_users = result.all()
|
|
|
|
# Formatear respuesta
|
|
return [
|
|
{
|
|
"id": str(comment.id),
|
|
"ticket_id": str(comment.ticket_id),
|
|
"author_id": str(comment.author_id),
|
|
"author_name": f"{user.first_name} {user.last_name}",
|
|
"content": comment.content,
|
|
"is_internal": comment.is_internal,
|
|
"created_at": comment.created_at,
|
|
"updated_at": comment.updated_at
|
|
}
|
|
for comment, user in comments_with_users
|
|
]
|
|
|
|
|
|
@router.post("/{ticket_id}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED)
|
|
async def create_comment(
|
|
ticket_id: str,
|
|
comment: CommentCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Agregar un comentario a un ticket
|
|
"""
|
|
try:
|
|
ticket_uuid = uuid.UUID(ticket_id)
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid ticket ID format"
|
|
)
|
|
|
|
# Verificar que el ticket existe y el usuario tiene acceso
|
|
ticket_query = select(Ticket).where(
|
|
Ticket.id == ticket_uuid,
|
|
Ticket.tenant_id == current_user.tenant_id
|
|
)
|
|
ticket_result = await db.execute(ticket_query)
|
|
ticket_obj = ticket_result.scalars().first()
|
|
|
|
if not ticket_obj:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Ticket {ticket_id} not found"
|
|
)
|
|
|
|
# Crear comentario
|
|
new_comment = TicketComment(
|
|
id=uuid.uuid4(),
|
|
ticket_id=ticket_uuid,
|
|
author_id=current_user.id,
|
|
content=comment.content,
|
|
is_internal=comment.is_internal,
|
|
created_at=datetime.utcnow(),
|
|
updated_at=datetime.utcnow()
|
|
)
|
|
|
|
db.add(new_comment)
|
|
|
|
# Registrar primera respuesta de staff si aún no se ha hecho
|
|
staff_roles = ["ADMIN", "SUPPORT_MANAGER", "AGENT"]
|
|
if (
|
|
current_user.role in staff_roles
|
|
and not comment.is_internal
|
|
and ticket_obj.first_response_at is None
|
|
):
|
|
ticket_obj.first_response_at = datetime.utcnow()
|
|
|
|
# Actualizar el ticket updated_at
|
|
ticket_obj.updated_at = datetime.utcnow()
|
|
|
|
await db.commit()
|
|
await db.refresh(new_comment)
|
|
|
|
# Retornar con el nombre del autor
|
|
return {
|
|
"id": str(new_comment.id),
|
|
"ticket_id": str(new_comment.ticket_id),
|
|
"author_id": str(new_comment.author_id),
|
|
"author_name": f"{current_user.first_name} {current_user.last_name}",
|
|
"content": new_comment.content,
|
|
"is_internal": new_comment.is_internal,
|
|
"created_at": new_comment.created_at,
|
|
"updated_at": new_comment.updated_at
|
|
}
|
|
@router.delete("/{ticket_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_ticket(
|
|
ticket_id: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Eliminar un ticket (solo admin/manager)
|
|
"""
|
|
try:
|
|
ticket_uuid = uuid.UUID(ticket_id)
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid ticket ID format"
|
|
)
|
|
|
|
query = select(Ticket).where(
|
|
Ticket.id == ticket_uuid,
|
|
Ticket.tenant_id == current_user.tenant_id
|
|
)
|
|
|
|
result = await db.execute(query)
|
|
db_ticket = result.scalars().first()
|
|
|
|
if not db_ticket:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Ticket {ticket_id} not found"
|
|
)
|
|
|
|
# Guardar datos del ticket antes de eliminar para audit
|
|
old_values = {
|
|
"ticket_number": db_ticket.ticket_number,
|
|
"subject": db_ticket.subject,
|
|
"status": db_ticket.status.value,
|
|
"priority": db_ticket.priority.value
|
|
}
|
|
|
|
await db.delete(db_ticket)
|
|
await db.commit()
|
|
|
|
# Registrar eliminación en auditoría
|
|
try:
|
|
await AuditService.log(
|
|
db=db,
|
|
tenant_id=current_user.tenant_id,
|
|
user_id=current_user.id,
|
|
action="ticket.delete",
|
|
resource_type="ticket",
|
|
resource_id=ticket_uuid,
|
|
old_values=old_values
|
|
)
|
|
await db.commit()
|
|
except Exception as e:
|
|
# No fallar si falla el audit log
|
|
pass
|
|
|
|
return {"message": "Ticket deleted successfully"}
|
|
|
|
# ===================================
|
|
# ===================================
|
|
# ATTACHMENT ENDPOINTS
|
|
# ===================================
|
|
|
|
@router.get("/{ticket_id}/attachments", response_model=List[AttachmentResponse])
|
|
async def get_ticket_attachments(
|
|
ticket_id: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
current_tenant: Tenant = Depends(get_current_tenant)
|
|
):
|
|
"""Obtener adjuntos de un ticket"""
|
|
try:
|
|
ticket_uuid = uuid.UUID(ticket_id)
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="ID de ticket inválido")
|
|
|
|
# Verificar que el ticket existe y pertenece al tenant
|
|
result = await db.execute(
|
|
select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_tenant.id)
|
|
)
|
|
ticket = result.scalar_one_or_none()
|
|
|
|
if not ticket:
|
|
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
|
|
|
# Obtener attachments
|
|
result = await db.execute(
|
|
select(TicketAttachment)
|
|
.where(TicketAttachment.ticket_id == ticket_uuid)
|
|
.options(selectinload(TicketAttachment.uploaded_by_user))
|
|
.order_by(TicketAttachment.created_at.desc())
|
|
)
|
|
attachments = result.scalars().all()
|
|
|
|
# Construir respuesta
|
|
response = []
|
|
for att in attachments:
|
|
response.append(AttachmentResponse(
|
|
id=att.id,
|
|
ticket_id=att.ticket_id,
|
|
comment_id=att.comment_id,
|
|
uploaded_by=att.uploaded_by,
|
|
filename=att.filename,
|
|
original_filename=att.original_filename,
|
|
mime_type=att.mime_type,
|
|
file_size=att.file_size,
|
|
file_path=att.file_path,
|
|
uploaded_by_name=f"{att.uploaded_by_user.first_name} {att.uploaded_by_user.last_name}" if att.uploaded_by_user else "Unknown",
|
|
created_at=att.created_at,
|
|
download_url=f"/api/v1/tickets/{ticket_id}/attachments/{att.id}/download"
|
|
))
|
|
|
|
return response
|
|
|
|
|
|
@router.post("/{ticket_id}/attachments", status_code=status.HTTP_201_CREATED)
|
|
async def upload_attachment(
|
|
ticket_id: str,
|
|
file: UploadFile = File(...),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
current_tenant: Tenant = Depends(get_current_tenant)
|
|
):
|
|
"""Subir un archivo adjunto a un ticket"""
|
|
try:
|
|
ticket_uuid = uuid.UUID(ticket_id)
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="ID de ticket inválido")
|
|
|
|
# Verificar ticket
|
|
result = await db.execute(
|
|
select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_tenant.id)
|
|
)
|
|
ticket = result.scalar_one_or_none()
|
|
|
|
if not ticket:
|
|
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
|
|
|
# Guardar archivo
|
|
file_metadata = await file_handler.save_upload(file, current_tenant.id, ticket_uuid)
|
|
|
|
# Crear registro en BD
|
|
attachment = TicketAttachment(
|
|
id=uuid.uuid4(),
|
|
ticket_id=ticket_uuid,
|
|
uploaded_by=current_user.id,
|
|
filename=file_metadata["filename"],
|
|
original_filename=file_metadata["original_filename"],
|
|
mime_type=file_metadata["mime_type"],
|
|
file_size=file_metadata["file_size"],
|
|
file_path=file_metadata["file_path"],
|
|
md5_hash=file_metadata["md5_hash"],
|
|
sha256_hash=file_metadata["sha256_hash"],
|
|
created_at=datetime.utcnow()
|
|
)
|
|
|
|
db.add(attachment)
|
|
await db.commit()
|
|
await db.refresh(attachment, ["uploaded_by_user"])
|
|
|
|
return {
|
|
"success": True,
|
|
"message": "Archivo subido exitosamente",
|
|
"data": AttachmentResponse(
|
|
id=attachment.id,
|
|
ticket_id=attachment.ticket_id,
|
|
comment_id=attachment.comment_id,
|
|
uploaded_by=attachment.uploaded_by,
|
|
filename=attachment.filename,
|
|
original_filename=attachment.original_filename,
|
|
mime_type=attachment.mime_type,
|
|
file_size=attachment.file_size,
|
|
file_path=attachment.file_path,
|
|
uploaded_by_name=f"{attachment.uploaded_by_user.first_name} {attachment.uploaded_by_user.last_name}",
|
|
created_at=attachment.created_at,
|
|
download_url=f"/api/v1/tickets/{ticket_id}/attachments/{attachment.id}/download"
|
|
)
|
|
}
|
|
|
|
|
|
@router.get("/{ticket_id}/attachments/{attachment_id}/download")
|
|
async def download_attachment(
|
|
ticket_id: str,
|
|
attachment_id: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
current_tenant: Tenant = Depends(get_current_tenant)
|
|
):
|
|
"""Descargar un archivo adjunto"""
|
|
import logging
|
|
logger = logging.getLogger(__name__)
|
|
|
|
logger.info(f"Download request - ticket_id: {ticket_id}, attachment_id: {attachment_id}")
|
|
|
|
try:
|
|
ticket_uuid = uuid.UUID(ticket_id)
|
|
attachment_uuid = uuid.UUID(attachment_id)
|
|
except ValueError:
|
|
logger.error(f"Invalid UUID format - ticket_id: {ticket_id}, attachment_id: {attachment_id}")
|
|
raise HTTPException(status_code=400, detail="ID inválido")
|
|
|
|
# Verificar ticket
|
|
result = await db.execute(
|
|
select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_tenant.id)
|
|
)
|
|
ticket = result.scalar_one_or_none()
|
|
|
|
if not ticket:
|
|
logger.error(f"Ticket not found - ticket_id: {ticket_id}")
|
|
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
|
|
|
# Obtener attachment
|
|
result = await db.execute(
|
|
select(TicketAttachment)
|
|
.where(TicketAttachment.id == attachment_uuid, TicketAttachment.ticket_id == ticket_uuid)
|
|
)
|
|
attachment = result.scalar_one_or_none()
|
|
|
|
if not attachment:
|
|
logger.error(f"Attachment not found - attachment_id: {attachment_id}")
|
|
raise HTTPException(status_code=404, detail="Adjunto no encontrado")
|
|
|
|
logger.info(f"Attachment found - file_path: {attachment.file_path}, original_filename: {attachment.original_filename}")
|
|
|
|
# Obtener path del archivo
|
|
try:
|
|
file_path = file_handler.get_file_path(attachment.file_path)
|
|
logger.info(f"Absolute file path: {file_path}")
|
|
|
|
if not file_path.exists():
|
|
logger.error(f"File does not exist at path: {file_path}")
|
|
raise HTTPException(status_code=404, detail="Archivo no encontrado en el sistema")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting file path: {str(e)}")
|
|
raise
|
|
|
|
# Retornar archivo
|
|
logger.info(f"Returning file: {attachment.original_filename}")
|
|
return FileResponse(
|
|
path=file_path,
|
|
filename=attachment.original_filename,
|
|
media_type=attachment.mime_type
|
|
) |