"""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.models.issue import TicketIssue, ticket_issue_tagged_users, IssueStatus from app.api.schemas.issue import IssueCreate, IssueResponse, IssueStatusUpdate 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, UserRole 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 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, current_user: User = Depends(get_current_user), ticket_service: TicketService = Depends(get_ticket_service), ): """Crear un nuevo ticket""" 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 == UserRole.CLIENT_USER: 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.options( selectinload(Ticket.category), selectinload(Ticket.affected_system), selectinload(Ticket.assigned_to_user) ) query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit) result = await db.execute(query) tickets = result.scalars().all() return [ticket_to_dict(t) 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 del tenant del administrador (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) 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") 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() category_ids = list({ticket.category_id for ticket, _, _ in rows if ticket.category_id}) categories_map = {} if category_ids: from app.models.category import Category as CategoryModel cat_result = await db.execute(select(CategoryModel).where(CategoryModel.id.in_(category_ids))) categories_map = {c.id: c.name for c in cat_result.scalars().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, "category_id": str(ticket.category_id) if ticket.category_id else None, "category_name": categories_map.get(ticket.category_id) if ticket.category_id else None, "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) if current_user.role != UserRole.ADMIN: query = query.where(Ticket.tenant_id == current_user.tenant_id) if current_user.role == UserRole.CLIENT_USER: 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) if current_user.role != UserRole.ADMIN: query = query.where(Ticket.tenant_id == current_user.tenant_id) if current_user.role == UserRole.CLIENT_USER: 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) if current_user.role != UserRole.ADMIN: query = query.where(Ticket.tenant_id == current_user.tenant_id) if current_user.role == UserRole.CLIENT_USER: 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) if current_user.role != UserRole.ADMIN: query = query.where(Ticket.tenant_id == current_user.tenant_id) if current_user.role == UserRole.CLIENT_USER: 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") if comment.is_internal and current_user.role.is_client: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Clientes no pueden crear comentarios internos" ) 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)""" if current_user.role not in (UserRole.ADMIN, UserRole.SUPPORT_MANAGER): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="No tienes permisos para eliminar tickets" ) 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") query = select(Ticket).where(Ticket.id == ticket_uuid) if current_user.role != UserRole.ADMIN: query = query.where(Ticket.tenant_id == current_tenant.id) result = await db.execute(query) ticket = result.scalar_one_or_none() if not ticket: raise HTTPException(status_code=404, detail="Ticket no encontrado") if current_user.role == UserRole.CLIENT_USER and ticket.created_by != current_user.id: 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") query = select(Ticket).where(Ticket.id == ticket_uuid) if current_user.role != UserRole.ADMIN: query = query.where(Ticket.tenant_id == current_tenant.id) result = await db.execute(query) ticket = result.scalar_one_or_none() if not ticket: raise HTTPException(status_code=404, detail="Ticket no encontrado") if current_user.role == UserRole.CLIENT_USER and ticket.created_by != current_user.id: raise HTTPException(status_code=404, detail="Ticket no encontrado") upload_tenant_id = ticket.tenant_id file_metadata = await file_handler.save_upload(file, upload_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__) ticket_uuid = validate_uuid_param(ticket_id, "ticket ID") attachment_uuid = validate_uuid_param(attachment_id, "attachment ID") query = select(Ticket).where(Ticket.id == ticket_uuid) if current_user.role != UserRole.ADMIN: query = query.where(Ticket.tenant_id == current_tenant.id) result = await db.execute(query) ticket = result.scalar_one_or_none() if not ticket: raise HTTPException(status_code=404, detail="Ticket no encontrado") if current_user.role == UserRole.CLIENT_USER and ticket.created_by != current_user.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: raise HTTPException(status_code=404, detail="Adjunto no encontrado") try: file_path = file_handler.get_file_path(attachment.file_path) if not file_path.exists(): 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 return FileResponse( path=file_path, filename=attachment.original_filename, media_type=attachment.mime_type ) @router.get("/{ticket_id}/issues", response_model=List[IssueResponse]) async def get_ticket_issues( ticket_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Obtener asuntos de un ticket.""" ticket_uuid = validate_uuid_param(ticket_id, "ticket ID") query = select(Ticket).where(Ticket.id == ticket_uuid) if current_user.role != UserRole.ADMIN: query = query.where(Ticket.tenant_id == current_user.tenant_id) if current_user.role == UserRole.CLIENT_USER: 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="Ticket no encontrado") issues_query = ( select(TicketIssue) .where(TicketIssue.ticket_id == ticket_uuid) .options( selectinload(TicketIssue.created_by_user), selectinload(TicketIssue.tagged_users), ) .order_by(TicketIssue.created_at.desc()) ) result = await db.execute(issues_query) issues = result.scalars().all() return [ IssueResponse( id=issue.id, ticket_id=issue.ticket_id, tenant_id=issue.tenant_id, content=issue.content, priority=issue.priority.value, status=issue.status.value, created_by=issue.created_by, created_by_name=f"{issue.created_by_user.first_name} {issue.created_by_user.last_name}", tagged_users=[ {"id": u.id, "full_name": f"{u.first_name} {u.last_name}", "email": u.email} for u in issue.tagged_users ], attachment_filename=issue.attachment_filename, attachment_mime_type=issue.attachment_mime_type, created_at=issue.created_at, updated_at=issue.updated_at, ) for issue in issues ] @router.post("/{ticket_id}/issues", response_model=IssueResponse, status_code=status.HTTP_201_CREATED) async def create_ticket_issue( ticket_id: str, issue: IssueCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), current_tenant: Tenant = Depends(get_current_tenant), ): """Crear un asunto de escalación en un ticket.""" ticket_uuid = validate_uuid_param(ticket_id, "ticket ID") query = select(Ticket).where(Ticket.id == ticket_uuid) if current_user.role != UserRole.ADMIN: query = query.where(Ticket.tenant_id == current_user.tenant_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="Ticket no encontrado") is_ticket_owner = ticket_obj.created_by == current_user.id is_client_admin = current_user.role == UserRole.CLIENT_ADMIN is_staff = current_user.role.is_global if not is_ticket_owner and not is_client_admin and not is_staff: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Solo el creador del ticket o el administrador pueden crear asuntos", ) tagged_users = [] if issue.tagged_user_ids: result = await db.execute( select(User).where( User.id.in_(issue.tagged_user_ids), User.is_active == True, ) ) tagged_users = result.scalars().all() found_ids = {u.id for u in tagged_users} missing = [str(uid) for uid in issue.tagged_user_ids if uid not in found_ids] if missing: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Usuarios no encontrados: {missing}", ) new_issue = TicketIssue( id=uuid.uuid4(), ticket_id=ticket_uuid, tenant_id=ticket_obj.tenant_id, created_by=current_user.id, content=issue.content, priority=TicketPriority[issue.priority], attachment_path=None, attachment_filename=None, attachment_mime_type=None, created_at=datetime.utcnow(), updated_at=datetime.utcnow(), ) new_issue.tagged_users = tagged_users db.add(new_issue) await db.commit() await db.refresh(new_issue, ["created_by_user", "tagged_users"]) await safe_audit_log( db=db, tenant_id=current_user.tenant_id, user_id=current_user.id, action="ticket.issue.create", resource_type="ticket_issue", resource_id=new_issue.id, new_values={ "ticket_id": str(ticket_uuid), "priority": issue.priority, "tagged_users": [str(uid) for uid in issue.tagged_user_ids], }, ) return IssueResponse( id=new_issue.id, ticket_id=new_issue.ticket_id, tenant_id=new_issue.tenant_id, content=new_issue.content, priority=new_issue.priority.value, status=new_issue.status.value, created_by=new_issue.created_by, created_by_name=f"{new_issue.created_by_user.first_name} {new_issue.created_by_user.last_name}", tagged_users=[ {"id": u.id, "full_name": f"{u.first_name} {u.last_name}", "email": u.email} for u in new_issue.tagged_users ], attachment_filename=new_issue.attachment_filename, attachment_mime_type=new_issue.attachment_mime_type, created_at=new_issue.created_at, updated_at=new_issue.updated_at, ) @router.post("/{ticket_id}/issues/{issue_id}/attachment", status_code=status.HTTP_200_OK) async def upload_issue_attachment( ticket_id: str, issue_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 adjunto a un asunto existente.""" ticket_uuid = validate_uuid_param(ticket_id, "ticket ID") issue_uuid = validate_uuid_param(issue_id, "issue ID") result = await db.execute( select(TicketIssue).where( TicketIssue.id == issue_uuid, TicketIssue.ticket_id == ticket_uuid, ) ) db_issue = result.scalars().first() if not db_issue: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asunto no encontrado") if db_issue.created_by != current_user.id and not current_user.role.is_global: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Sin permisos") upload_tenant_id = db_issue.tenant_id file_metadata = await file_handler.save_upload(file, upload_tenant_id, ticket_uuid) db_issue.attachment_path = file_metadata["file_path"] db_issue.attachment_filename = file_metadata["original_filename"] db_issue.attachment_mime_type = file_metadata["mime_type"] db_issue.updated_at = datetime.utcnow() await db.commit() return { "message": "Adjunto subido correctamente", "attachment_filename": db_issue.attachment_filename, "attachment_mime_type": db_issue.attachment_mime_type, } @router.get("/{ticket_id}/issues/{issue_id}", response_model=IssueResponse) async def get_issue_detail( ticket_id: str, issue_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Obtener detalle de un asunto. Accesible para todos los roles.""" ticket_uuid = validate_uuid_param(ticket_id, "ticket ID") issue_uuid = validate_uuid_param(issue_id, "issue ID") query = select(Ticket).where(Ticket.id == ticket_uuid) if current_user.role != UserRole.ADMIN: query = query.where(Ticket.tenant_id == current_user.tenant_id) result = await db.execute(query) if not result.scalars().first(): raise HTTPException(status_code=404, detail="Ticket no encontrado") result = await db.execute( select(TicketIssue) .where(TicketIssue.id == issue_uuid, TicketIssue.ticket_id == ticket_uuid) .options( selectinload(TicketIssue.created_by_user), selectinload(TicketIssue.tagged_users), ) ) issue = result.scalars().first() if not issue: raise HTTPException(status_code=404, detail="Asunto no encontrado") return IssueResponse( id=issue.id, ticket_id=issue.ticket_id, tenant_id=issue.tenant_id, content=issue.content, priority=issue.priority.value, status=issue.status.value, created_by=issue.created_by, created_by_name=f"{issue.created_by_user.first_name} {issue.created_by_user.last_name}", tagged_users=[ {"id": u.id, "full_name": f"{u.first_name} {u.last_name}", "email": u.email} for u in issue.tagged_users ], attachment_filename=issue.attachment_filename, attachment_mime_type=issue.attachment_mime_type, created_at=issue.created_at, updated_at=issue.updated_at, ) @router.patch("/{ticket_id}/issues/{issue_id}/status", response_model=IssueResponse) async def update_issue_status( ticket_id: str, issue_id: str, data: IssueStatusUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Actualizar status de un asunto.""" ticket_uuid = validate_uuid_param(ticket_id, "ticket ID") issue_uuid = validate_uuid_param(issue_id, "issue ID") if current_user.role != UserRole.ADMIN and current_user.role != UserRole.CLIENT_ADMIN: raise HTTPException(status_code=403, detail="Solo administradores pueden cambiar el status") result = await db.execute( select(TicketIssue) .where(TicketIssue.id == issue_uuid, TicketIssue.ticket_id == ticket_uuid) .options( selectinload(TicketIssue.created_by_user), selectinload(TicketIssue.tagged_users), ) ) issue = result.scalars().first() if not issue: raise HTTPException(status_code=404, detail="Asunto no encontrado") issue.status = IssueStatus[data.status] issue.updated_at = datetime.utcnow() await db.commit() await safe_audit_log( db=db, tenant_id=current_user.tenant_id, user_id=current_user.id, action="ticket.issue.status_update", resource_type="ticket_issue", resource_id=issue.id, new_values={"status": data.status}, ) return IssueResponse( id=issue.id, ticket_id=issue.ticket_id, tenant_id=issue.tenant_id, content=issue.content, priority=issue.priority.value, status=issue.status.value, created_by=issue.created_by, created_by_name=f"{issue.created_by_user.first_name} {issue.created_by_user.last_name}", tagged_users=[ {"id": u.id, "full_name": f"{u.first_name} {u.last_name}", "email": u.email} for u in issue.tagged_users ], attachment_filename=issue.attachment_filename, attachment_mime_type=issue.attachment_mime_type, created_at=issue.created_at, updated_at=issue.updated_at, ) @router.delete("/{ticket_id}/issues/{issue_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_issue( ticket_id: str, issue_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Eliminar un asunto. Solo ADMIN global o CLIENT_ADMIN.""" ticket_uuid = validate_uuid_param(ticket_id, "ticket ID") issue_uuid = validate_uuid_param(issue_id, "issue ID") if current_user.role != UserRole.ADMIN and current_user.role != UserRole.CLIENT_ADMIN: raise HTTPException(status_code=403, detail="Solo administradores pueden eliminar asuntos") result = await db.execute( select(TicketIssue).where( TicketIssue.id == issue_uuid, TicketIssue.ticket_id == ticket_uuid, ) ) issue = result.scalars().first() if not issue: raise HTTPException(status_code=404, detail="Asunto no encontrado") old_content = issue.content old_status = issue.status.value await db.delete(issue) await db.commit() await safe_audit_log( db=db, tenant_id=current_user.tenant_id, user_id=current_user.id, action="ticket.issue.delete", resource_type="ticket_issue", resource_id=issue_uuid, old_values={"content": old_content, "status": old_status}, )