fix: ADMIN puede ver tickets de cualquier tenant, fixes seguridad tickets.py, logout interno
This commit is contained in:
@@ -21,7 +21,7 @@ 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,
|
||||
TicketCreate, TicketUpdate, TicketResponse,
|
||||
TicketCloseRequest, CommentCreate, CommentResponse
|
||||
)
|
||||
from app.core.file_handler import file_handler
|
||||
@@ -34,6 +34,7 @@ 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,
|
||||
@@ -43,16 +44,19 @@ async def create_ticket(
|
||||
"""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)):
|
||||
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)
|
||||
# Solo CLIENT_USER ve únicamente sus propios tickets.
|
||||
# CLIENT_ADMIN ve todos los del tenant.
|
||||
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(
|
||||
@@ -61,25 +65,28 @@ async def get_tickets(skip: int = 0, limit: int = 100, status: Optional[str] = N
|
||||
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)):
|
||||
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)
|
||||
|
||||
# SUPPORT_MANAGER solo ve su propio tenant.
|
||||
# ADMIN ve todos los tenants (es el administrador de la plataforma).
|
||||
if current_user.role == UserRole.SUPPORT_MANAGER:
|
||||
query = query.where(Ticket.tenant_id == current_user.tenant_id)
|
||||
|
||||
@@ -106,12 +113,11 @@ async def get_all_tickets_admin(skip: int = 0, limit: int = 100, status_filter:
|
||||
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:
|
||||
@@ -127,44 +133,66 @@ async def get_all_tickets_admin(skip: int = 0, limit: int = 100, status_filter:
|
||||
"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}
|
||||
"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)):
|
||||
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)
|
||||
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.is_client:
|
||||
query = query.where(Ticket.created_by == current_user.id)
|
||||
|
||||
query = query.options(selectinload(Ticket.category), selectinload(Ticket.affected_system), selectinload(Ticket.assigned_to_user))
|
||||
|
||||
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)):
|
||||
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)
|
||||
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.is_client:
|
||||
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}
|
||||
|
||||
|
||||
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:
|
||||
@@ -175,37 +203,48 @@ async def update_ticket(ticket_id: str, ticket: TicketUpdate, db: AsyncSession =
|
||||
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,
|
||||
|
||||
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)
|
||||
|
||||
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)):
|
||||
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,
|
||||
@@ -213,208 +252,287 @@ async def close_ticket(ticket_id: str, close_request: TicketCloseRequest, db: As
|
||||
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,
|
||||
|
||||
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})
|
||||
|
||||
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)):
|
||||
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 = 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.is_client:
|
||||
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())
|
||||
|
||||
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}
|
||||
"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)):
|
||||
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"]:
|
||||
if current_user.role.is_client:
|
||||
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")
|
||||
|
||||
|
||||
# Fix is_internal — clientes no pueden crear comentarios internos
|
||||
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),
|
||||
"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)):
|
||||
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")
|
||||
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}
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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)):
|
||||
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))
|
||||
|
||||
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 in ["CLIENT_USER", "CLIENT_ADMIN"] and ticket.created_by != current_user.id:
|
||||
if current_user.role.is_client 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()))
|
||||
|
||||
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,
|
||||
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"
|
||||
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)):
|
||||
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))
|
||||
|
||||
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 in ["CLIENT_USER", "CLIENT_ADMIN"] and ticket.created_by != current_user.id:
|
||||
if current_user.role.is_client 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)
|
||||
|
||||
|
||||
# Para el file_handler usamos el tenant real del ticket
|
||||
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()
|
||||
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=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"
|
||||
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)):
|
||||
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))
|
||||
|
||||
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:
|
||||
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:
|
||||
if current_user.role.is_client 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))
|
||||
|
||||
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)
|
||||
|
||||
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(
|
||||
@@ -425,11 +543,9 @@ async def get_ticket_issues(
|
||||
"""Obtener asuntos de un ticket."""
|
||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||
|
||||
# Verificar que el ticket existe y pertenece al tenant
|
||||
query = select(Ticket).where(
|
||||
Ticket.id == ticket_uuid,
|
||||
Ticket.tenant_id == current_user.tenant_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.is_client:
|
||||
query = query.where(Ticket.created_by == current_user.id)
|
||||
|
||||
@@ -452,21 +568,17 @@ async def get_ticket_issues(
|
||||
|
||||
return [
|
||||
IssueResponse(
|
||||
id=issue.id,
|
||||
ticket_id=issue.ticket_id,
|
||||
tenant_id=issue.tenant_id,
|
||||
content=issue.content,
|
||||
priority=issue.priority.value,
|
||||
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,
|
||||
created_at=issue.created_at, updated_at=issue.updated_at,
|
||||
)
|
||||
for issue in issues
|
||||
]
|
||||
@@ -481,14 +593,9 @@ async def create_ticket_issue(
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
):
|
||||
"""
|
||||
Crear un asunto de escalación en un ticket.
|
||||
|
||||
Solo puede crearlo el dueño del ticket o el CLIENT_ADMIN del tenant.
|
||||
"""
|
||||
"""Crear un asunto de escalación en un ticket."""
|
||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||
|
||||
# 1. Verificar que el ticket existe y pertenece al tenant
|
||||
result = await db.execute(
|
||||
select(Ticket).where(
|
||||
Ticket.id == ticket_uuid,
|
||||
@@ -499,23 +606,21 @@ async def create_ticket_issue(
|
||||
if not ticket_obj:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ticket no encontrado")
|
||||
|
||||
# 2. Verificar permisos: solo el creador del ticket o CLIENT_ADMIN
|
||||
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:
|
||||
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 del tenant pueden crear asuntos",
|
||||
detail="Solo el creador del ticket o el administrador pueden crear asuntos",
|
||||
)
|
||||
|
||||
# 3. Validar usuarios etiquetados — deben pertenecer al mismo tenant
|
||||
tagged_users = []
|
||||
if issue.tagged_user_ids:
|
||||
result = await db.execute(
|
||||
select(User).where(
|
||||
User.id.in_(issue.tagged_user_ids),
|
||||
User.tenant_id == current_user.tenant_id,
|
||||
User.is_active == True,
|
||||
)
|
||||
)
|
||||
@@ -526,10 +631,9 @@ async def create_ticket_issue(
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Usuarios no encontrados en el tenant: {missing}",
|
||||
detail=f"Usuarios no encontrados: {missing}",
|
||||
)
|
||||
|
||||
# 4. Manejar adjunto opcional
|
||||
attachment_path = None
|
||||
attachment_filename = None
|
||||
attachment_mime_type = None
|
||||
@@ -539,19 +643,13 @@ async def create_ticket_issue(
|
||||
attachment_filename = file_metadata["original_filename"]
|
||||
attachment_mime_type = file_metadata["mime_type"]
|
||||
|
||||
# 5. Crear el asunto
|
||||
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,
|
||||
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(),
|
||||
created_at=datetime.utcnow(), updated_at=datetime.utcnow(),
|
||||
)
|
||||
new_issue.tagged_users = tagged_users
|
||||
db.add(new_issue)
|
||||
@@ -559,27 +657,18 @@ async def create_ticket_issue(
|
||||
await db.commit()
|
||||
await db.refresh(new_issue, ["created_by_user", "tagged_users"])
|
||||
|
||||
# 6. Audit log
|
||||
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,
|
||||
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,
|
||||
"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,
|
||||
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=[
|
||||
@@ -588,6 +677,5 @@ async def create_ticket_issue(
|
||||
],
|
||||
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,
|
||||
created_at=new_issue.created_at, updated_at=new_issue.updated_at,
|
||||
)
|
||||
Reference in New Issue
Block a user