852 lines
34 KiB
Python
852 lines
34 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, 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},
|
|
) |