Files
service_manager/backend/app/api/v1/endpoints/tickets.py

680 lines
28 KiB
Python

"""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
from app.api.schemas.issue import IssueCreate, IssueResponse
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,
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,
file: Optional[UploadFile] = File(None),
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}",
)
attachment_path = None
attachment_filename = None
attachment_mime_type = None
if file and file.filename:
file_metadata = await file_handler.save_upload(file, current_tenant.id, ticket_uuid)
attachment_path = file_metadata["file_path"]
attachment_filename = file_metadata["original_filename"]
attachment_mime_type = file_metadata["mime_type"]
new_issue = TicketIssue(
id=uuid.uuid4(), ticket_id=ticket_uuid,
tenant_id=current_user.tenant_id, created_by=current_user.id,
content=issue.content, priority=TicketPriority[issue.priority],
attachment_path=attachment_path, attachment_filename=attachment_filename,
attachment_mime_type=attachment_mime_type,
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,
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,
)