"""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, timedelta 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.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.api.v1.helpers import ( validate_uuid_param, apply_client_permissions, apply_enum_filter, safe_audit_log, generate_next_ticket_number, calculate_sla_deadlines, ticket_to_dict ) from app.services.audit_service import AuditService 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)): """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.") 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.") 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, "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: 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)}") @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"]: query = query.where(Ticket.created_by == current_user.id) query = apply_enum_filter(query, Ticket.status, status, TicketStatus, "status") query = apply_enum_filter(query, Ticket.priority, priority, TicketPriority, "priority") query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit) result = await db.execute(query) tickets = result.scalars().all() 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)""" 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 = select(Ticket, Tenant, User).join(Tenant, Ticket.tenant_id == Tenant.id).join(User, Ticket.created_by == User.id) query = apply_enum_filter(query, Ticket.status, status_filter, TicketStatus, "status") query = apply_enum_filter(query, Ticket.priority, priority_filter, TicketPriority, "priority") if tenant_id_filter: query = query.where(Ticket.tenant_id == validate_uuid_param(tenant_id_filter, "tenant ID")) if category_filter: query = query.where(Ticket.category_id == validate_uuid_param(category_filter, "category ID")) if assigned_to_filter: query = query.where(Ticket.assigned_to == validate_uuid_param(assigned_to_filter, "assigned user ID")) if search: search_pattern = f"%{search}%" query = query.where((Ticket.subject.ilike(search_pattern)) | (Ticket.description.ilike(search_pattern))) if date_from: try: 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: 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, "description": ticket.description, "status": ticket.status.value, "priority": ticket.priority.value, "tenant_id": str(ticket.tenant_id), "tenant_name": tenant.name, "tenant_slug": tenant.slug, "created_by": str(ticket.created_by), "creator_name": f"{creator.first_name} {creator.last_name}", "creator_email": creator.email, "assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None, "created_at": ticket.created_at, "updated_at": ticket.updated_at, "sla_response_due": ticket.sla_response_due, "sla_resolution_due": ticket.sla_resolution_due, "first_response_at": ticket.first_response_at, "resolved_at": ticket.resolved_at} for ticket, tenant, creator 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 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"]: query = query.where(Ticket.created_by == current_user.id) query = query.options(selectinload(Ticket.category), selectinload(Ticket.affected_system), selectinload(Ticket.assigned_to_user)) 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") return ticket_to_dict(db_ticket) @router.patch("/{ticket_id}", response_model=TicketResponse) async def update_ticket(ticket_id: str, ticket: TicketUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): """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"]: 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") old_values = {"status": db_ticket.status.value, "priority": db_ticket.priority.value, "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None} update_data = ticket.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 in ["category_id", "affected_system_id", "assigned_to"] and value: setattr(db_ticket, field, uuid.UUID(value)) elif value is not None: setattr(db_ticket, field, value) db_ticket.updated_at = datetime.utcnow() await db.commit() await db.refresh(db_ticket, ["category", "affected_system", "assigned_to_user"]) new_values = {"status": db_ticket.status.value, "priority": db_ticket.priority.value, "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None} await safe_audit_log(db=db, tenant_id=current_user.tenant_id, user_id=current_user.id, action="ticket.update", resource_type="ticket", resource_id=db_ticket.id, old_values=old_values, new_values=new_values) return ticket_to_dict(db_ticket) @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""" ticket_uuid = validate_uuid_param(ticket_id, "ticket ID") 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") if db_ticket.status in [TicketStatus.CLOSED, TicketStatus.RESOLVED]: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ticket ya está cerrado o resuelto") old_status = db_ticket.status.value db_ticket.status = TicketStatus.CLOSED db_ticket.resolved_at = datetime.utcnow() db_ticket.updated_at = datetime.utcnow() if close_request.resolution_notes: comment = TicketComment( id=uuid.uuid4(), ticket_id=ticket_uuid, author_id=current_user.id, content=f"Ticket cerrado: {close_request.resolution_notes}", is_internal=False, created_at=datetime.utcnow(), updated_at=datetime.utcnow() ) db.add(comment) await db.commit() await db.refresh(db_ticket, ["category", "affected_system", "assigned_to_user"]) await safe_audit_log(db=db, tenant_id=current_user.tenant_id, user_id=current_user.id, action="ticket.close", resource_type="ticket", resource_id=db_ticket.id, old_values={"status": old_status}, new_values={"status": db_ticket.status.value, "resolution_notes": close_request.resolution_notes}) return ticket_to_dict(db_ticket) @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""" 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"]: query = query.where(Ticket.created_by == current_user.id) result = await db.execute(query) ticket_obj = result.scalars().first() if not ticket_obj: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found") comments_query = select(TicketComment).where(TicketComment.ticket_id == ticket_uuid).options(selectinload(TicketComment.author)).order_by(TicketComment.created_at.desc()) result = await db.execute(comments_query) comments = result.scalars().all() return [ {"id": str(c.id), "ticket_id": str(c.ticket_id), "author_id": str(c.author_id), "author_name": f"{c.author.first_name} {c.author.last_name}" if c.author else "Unknown", "content": c.content, "is_internal": c.is_internal, "created_at": c.created_at, "updated_at": c.updated_at} for c in comments ] @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)): """Crear un comentario en 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"]: query = query.where(Ticket.created_by == current_user.id) result = await db.execute(query) ticket_obj = result.scalars().first() if not ticket_obj: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found") 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) 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() ticket_obj.updated_at = datetime.utcnow() await db.commit() await db.refresh(new_comment) 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)""" ticket_uuid = validate_uuid_param(ticket_id, "ticket ID") 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") 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() await safe_audit_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) return {"message": "Ticket deleted successfully"} @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""" ticket_uuid = validate_uuid_param(ticket_id, "ticket ID") 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") 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() return [ 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" ) for att in attachments ] @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""" ticket_uuid = validate_uuid_param(ticket_id, "ticket ID") 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") file_metadata = await file_handler.save_upload(file, current_tenant.id, ticket_uuid) 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}") ticket_uuid = validate_uuid_param(ticket_id, "ticket ID") attachment_uuid = validate_uuid_param(attachment_id, "attachment ID") 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") 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}") 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 logger.info(f"Returning file: {attachment.original_filename}") return FileResponse(path=file_path, filename=attachment.original_filename, media_type=attachment.mime_type)