""" Tickets endpoints - ServiceManagerWeb """ from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File from fastapi.responses import FileResponse from pydantic import BaseModel 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.core.file_handler import file_handler import uuid router = APIRouter() # =================================== # SCHEMAS # =================================== class TicketCreate(BaseModel): subject: str description: str category_id: Optional[str] = None affected_system_id: Optional[str] = None # ✅ CORREGIDO: Era system_id priority: str = "MEDIUM" class TicketUpdate(BaseModel): subject: Optional[str] = None description: Optional[str] = None status: Optional[str] = None priority: Optional[str] = None assigned_to: Optional[str] = None class TicketResponse(BaseModel): id: str ticket_number: str subject: str title: str description: str status: str priority: str category_id: Optional[str] = None affected_system_id: Optional[str] = None # ✅ CORREGIDO: Era system_id created_by: str assigned_to: Optional[str] = None created_at: datetime updated_at: datetime class Config: from_attributes = True class TicketCloseRequest(BaseModel): resolution: Optional[str] = None # =================================== # 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 """ try: # Generar número de ticket único result = await db.execute( select(func.count(Ticket.id)).where(Ticket.tenant_id == current_user.tenant_id) ) count = result.scalar() or 0 ticket_number = f"TK-{count + 1:06d}" # Generar número de ticket único result = await db.execute( select(func.count(Ticket.id)).where(Ticket.tenant_id == current_user.tenant_id) ) count = result.scalar() or 0 ticket_number = f"TK-{count + 1: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 # ✅ CORREGIDO # ✅ CORREGIDO: Validar en la tabla correcta con el nombre correcto del modelo if category_uuid: category = await db.get(Category, category_uuid) # ✅ Category, no TicketCategory 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 si el system_id existe en la tabla affected_systems 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." ) 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, # ✅ CORREGIDO: Nombre correcto del campo priority=TicketPriority[ticket.priority.upper()], created_by=current_user.id, status=TicketStatus.NEW, created_at=datetime.utcnow(), updated_at=datetime.utcnow() ) db.add(db_ticket) await db.commit() await db.refresh(db_ticket) # ✅ CORREGIDO: Usar affected_system_id en respuesta 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 ValueError as e: await db.rollback() raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid UUID format: {str(e)}" ) 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 """ 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, 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 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, # ✅ CORREGIDO "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 } @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 """ 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, 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: update_data = ticket_update.dict(exclude_unset=True) for field, value in update_data.items(): if field == "status" and value: setattr(db_ticket, field, TicketStatus[value.upper()]) 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) # ✅ 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 """ 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, 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 db_ticket.updated_at = datetime.utcnow() await db.commit() await db.refresh(db_ticket) # ✅ 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 closing ticket: {str(e)}" ) ## =================================== # COMMENT ENDPOINTS # =================================== class CommentCreate(BaseModel): content: str is_internal: bool = False class CommentResponse(BaseModel): id: str ticket_id: str author_id: str author_name: str content: str is_internal: bool created_at: datetime updated_at: datetime class Config: from_attributes = True @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) # 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" ) await db.delete(db_ticket) await db.commit() 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 )