This commit is contained in:
2026-03-03 14:02:18 -07:00
parent f10b15d91b
commit 3b46f48655
13 changed files with 1041 additions and 99 deletions

View File

@@ -11,7 +11,7 @@ import uuid
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.user import User, UserRole
from app.models.tenant import Tenant
from app.models.category import Category
from app.models.system import System
@@ -28,98 +28,27 @@ from app.api.v1.helpers import (
safe_audit_log, generate_next_ticket_number, calculate_sla_deadlines, ticket_to_dict
)
from app.services.audit_service import AuditService
from app.services.ticket_service import TicketService, get_ticket_service
router = APIRouter()
@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)):
async def create_ticket(
ticket: TicketCreate,
current_user: User = Depends(get_current_user),
ticket_service: TicketService = Depends(get_ticket_service),
):
"""Crear un nuevo ticket"""
max_retries = 3
last_error = None
for attempt in range(max_retries):
try:
ticket_number = await generate_next_ticket_number(db, current_user.tenant_id)
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
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.")
# ✅ SECURITY: Validate category belongs to current tenant (prevents cross-tenant category injection)
if category.tenant_id != current_user.tenant_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"La categoría con ID {ticket.category_id} no existe.")
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.")
# ✅ SECURITY: Validate system belongs to current tenant (prevents cross-tenant system injection)
if system.tenant_id != current_user.tenant_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"El sistema con ID {ticket.affected_system_id} no existe.")
sla_response_due, sla_resolution_due = calculate_sla_deadlines(category)
assigned_to_user = category.auto_assign_to if category and category.auto_assign_to else None
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)
await safe_audit_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})
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,
"system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None,
"contact_email": ticket.contact_email,
"contact_phone": ticket.contact_phone,
"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,
"sla_response_due": db_ticket.sla_response_due,
"sla_resolution_due": db_ticket.sla_resolution_due,
"first_response_at": db_ticket.first_response_at,
"resolved_at": db_ticket.resolved_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:
await db.rollback()
raise
except Exception as e:
await db.rollback()
last_error = e
if "duplicate key" in str(e).lower() and "ticket_number" in str(e).lower():
if attempt < max_retries - 1:
continue
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Error creating ticket: {str(e)}")
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)}")
return await ticket_service.create_ticket(ticket, current_user.tenant_id, current_user.id)
@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"""
query = select(Ticket).where(Ticket.tenant_id == current_user.tenant_id)
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
# Solo CLIENT_USER ve únicamente sus propios tickets.
# CLIENT_ADMIN ve todos los del tenant.
if current_user.role == UserRole.CLIENT_USER:
query = query.where(Ticket.created_by == current_user.id)
query = apply_enum_filter(query, Ticket.status, status, TicketStatus, "status")
@@ -142,14 +71,14 @@ async def get_all_tickets_admin(skip: int = 0, limit: int = 100, status_filter:
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 del tenant del administrador (ADMIN/SUPPORT_MANAGER)."""
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
if current_user.role not in (UserRole.ADMIN, UserRole.SUPPORT_MANAGER):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No tienes permisos para acceder a esta función")
query = select(Ticket, Tenant, User).join(Tenant, Ticket.tenant_id == Tenant.id).join(User, Ticket.created_by == User.id)
# SUPPORT_MANAGER solo ve su propio tenant.
# ADMIN ve todos los tenants (es el administrador de la plataforma).
if current_user.role == "SUPPORT_MANAGER":
if current_user.role == UserRole.SUPPORT_MANAGER:
query = query.where(Ticket.tenant_id == current_user.tenant_id)
query = apply_enum_filter(query, Ticket.status, status_filter, TicketStatus, "status")
@@ -207,7 +136,7 @@ async def get_ticket(ticket_id: str, db: AsyncSession = Depends(get_db), current
"""Obtener un ticket por ID"""
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id)
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
if current_user.role.is_client:
query = query.where(Ticket.created_by == current_user.id)
query = query.options(selectinload(Ticket.category), selectinload(Ticket.affected_system), selectinload(Ticket.assigned_to_user))
@@ -224,7 +153,7 @@ async def update_ticket(ticket_id: str, ticket: TicketUpdate, db: AsyncSession =
"""Actualizar un ticket"""
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id)
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
if current_user.role.is_client:
query = query.where(Ticket.created_by == current_user.id)
result = await db.execute(query)

View File

@@ -45,8 +45,12 @@ async def read_users(
- role: filtrar por rol
- is_active: filtrar por estado activo
"""
# ✅ CORREGIDO: Filtrar por tenant_id
query = select(User).where(User.tenant_id == current_user.tenant_id)
# ADMIN global ve todos los tenants; el resto solo ve su propio tenant
from app.models.user import UserRole as _UserRole
if current_user.role != _UserRole.ADMIN:
query = select(User).where(User.tenant_id == current_user.tenant_id)
else:
query = select(User)
# Aplicar filtros opcionales
if role:

View File

@@ -68,11 +68,11 @@ async def generate_next_ticket_number(db: AsyncSession, tenant_id: uuid.UUID) ->
last_ticket_number = result.scalar_one_or_none()
if last_ticket_number:
last_number = int(last_ticket_number.split('-')[1])
last_number = int(last_ticket_number.split('-')[-1])
next_number = last_number + 1
else:
next_number = 1
return f"TK-{next_number:06d}"