""" 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 )