479 lines
26 KiB
Python
479 lines
26 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.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,
|
|
"system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None,
|
|
"contact_email": ticket.contact_email,
|
|
"contact_phone": ticket.contact_phone,
|
|
"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,
|
|
"sla_response_due": db_ticket.sla_response_due,
|
|
"sla_resolution_due": db_ticket.sla_resolution_due,
|
|
"first_response_at": db_ticket.first_response_at,
|
|
"resolved_at": db_ticket.resolved_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.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 ["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)
|
|
|
|
# SUPPORT_MANAGER solo ve su propio tenant.
|
|
# ADMIN ve todos los tenants (es el administrador de la plataforma).
|
|
if current_user.role == "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()
|
|
|
|
# Cargar categorías en un solo query para evitar N+1
|
|
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, 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")
|
|
|
|
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"] 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")
|
|
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")
|
|
|
|
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"] and ticket.created_by != current_user.id:
|
|
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")
|
|
|
|
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"] 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:
|
|
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)
|