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.models.attachment import TicketAttachment
|
||||||
from app.api.schemas.attachment import AttachmentResponse
|
from app.api.schemas.attachment import AttachmentResponse
|
||||||
from app.api.schemas.ticket import (
|
from app.api.schemas.ticket import (
|
||||||
TicketCreate, TicketUpdate, TicketResponse,
|
TicketCreate, TicketUpdate, TicketResponse,
|
||||||
TicketCloseRequest, CommentCreate, CommentResponse
|
TicketCloseRequest, CommentCreate, CommentResponse
|
||||||
)
|
)
|
||||||
from app.core.file_handler import file_handler
|
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 = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=TicketResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("/", response_model=TicketResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_ticket(
|
async def create_ticket(
|
||||||
ticket: TicketCreate,
|
ticket: TicketCreate,
|
||||||
@@ -43,16 +44,19 @@ async def create_ticket(
|
|||||||
"""Crear un nuevo ticket"""
|
"""Crear un nuevo ticket"""
|
||||||
return await ticket_service.create_ticket(ticket, current_user.tenant_id, current_user.id)
|
return await ticket_service.create_ticket(ticket, current_user.tenant_id, current_user.id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=List[TicketResponse])
|
@router.get("/", response_model=List[TicketResponse])
|
||||||
async def get_tickets(skip: int = 0, limit: int = 100, status: Optional[str] = None, priority: Optional[str] = None,
|
async def get_tickets(
|
||||||
db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
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"""
|
"""Obtener tickets con filtros opcionales"""
|
||||||
query = select(Ticket).where(Ticket.tenant_id == current_user.tenant_id)
|
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:
|
if current_user.role == UserRole.CLIENT_USER:
|
||||||
query = query.where(Ticket.created_by == current_user.id)
|
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.status, status, TicketStatus, "status")
|
||||||
query = apply_enum_filter(query, Ticket.priority, priority, TicketPriority, "priority")
|
query = apply_enum_filter(query, Ticket.priority, priority, TicketPriority, "priority")
|
||||||
query = query.options(
|
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)
|
selectinload(Ticket.assigned_to_user)
|
||||||
)
|
)
|
||||||
query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit)
|
query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit)
|
||||||
|
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
tickets = result.scalars().all()
|
tickets = result.scalars().all()
|
||||||
|
|
||||||
return [ticket_to_dict(t) for t in tickets]
|
return [ticket_to_dict(t) for t in tickets]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/admin/all", response_model=List[dict])
|
@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,
|
async def get_all_tickets_admin(
|
||||||
priority_filter: Optional[str] = None, tenant_id_filter: Optional[str] = None, category_filter: Optional[str] = None,
|
skip: int = 0, limit: int = 100,
|
||||||
assigned_to_filter: Optional[str] = None, search: Optional[str] = None, date_from: Optional[str] = None,
|
status_filter: Optional[str] = None, priority_filter: Optional[str] = None,
|
||||||
date_to: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
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)."""
|
"""Obtener todos los tickets del tenant del administrador (ADMIN/SUPPORT_MANAGER)."""
|
||||||
if current_user.role not in (UserRole.ADMIN, UserRole.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")
|
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)
|
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:
|
if current_user.role == UserRole.SUPPORT_MANAGER:
|
||||||
query = query.where(Ticket.tenant_id == current_user.tenant_id)
|
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)
|
query = query.where(Ticket.created_at < date_to_parsed)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid date_to format. Use YYYY-MM-DD")
|
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)
|
query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit)
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
rows = result.all()
|
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})
|
category_ids = list({ticket.category_id for ticket, _, _ in rows if ticket.category_id})
|
||||||
categories_map = {}
|
categories_map = {}
|
||||||
if category_ids:
|
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,
|
"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}",
|
"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,
|
"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,
|
"created_at": ticket.created_at, "updated_at": ticket.updated_at,
|
||||||
"sla_resolution_due": ticket.sla_resolution_due, "first_response_at": ticket.first_response_at,
|
"sla_response_due": ticket.sla_response_due, "sla_resolution_due": ticket.sla_resolution_due,
|
||||||
"resolved_at": ticket.resolved_at}
|
"first_response_at": ticket.first_response_at, "resolved_at": ticket.resolved_at}
|
||||||
for ticket, tenant, creator in rows
|
for ticket, tenant, creator in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{ticket_id}", response_model=TicketResponse)
|
@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"""
|
"""Obtener un ticket por ID"""
|
||||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket 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:
|
if current_user.role.is_client:
|
||||||
query = query.where(Ticket.created_by == current_user.id)
|
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)
|
result = await db.execute(query)
|
||||||
db_ticket = result.scalars().first()
|
db_ticket = result.scalars().first()
|
||||||
|
|
||||||
if not db_ticket:
|
if not db_ticket:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
|
||||||
|
|
||||||
return ticket_to_dict(db_ticket)
|
return ticket_to_dict(db_ticket)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{ticket_id}", response_model=TicketResponse)
|
@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"""
|
"""Actualizar un ticket"""
|
||||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket 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:
|
if current_user.role.is_client:
|
||||||
query = query.where(Ticket.created_by == current_user.id)
|
query = query.where(Ticket.created_by == current_user.id)
|
||||||
|
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
db_ticket = result.scalars().first()
|
db_ticket = result.scalars().first()
|
||||||
if not db_ticket:
|
if not db_ticket:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
|
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)
|
update_data = ticket.dict(exclude_unset=True)
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
if field == "status" and value:
|
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))
|
setattr(db_ticket, field, uuid.UUID(value))
|
||||||
elif value is not None:
|
elif value is not None:
|
||||||
setattr(db_ticket, field, value)
|
setattr(db_ticket, field, value)
|
||||||
|
|
||||||
db_ticket.updated_at = datetime.utcnow()
|
db_ticket.updated_at = datetime.utcnow()
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(db_ticket, ["category", "affected_system", "assigned_to_user"])
|
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}
|
new_values = {
|
||||||
await safe_audit_log(db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
|
"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,
|
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)
|
return ticket_to_dict(db_ticket)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{ticket_id}/close", response_model=TicketResponse)
|
@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"""
|
"""Cerrar un ticket"""
|
||||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket 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, Ticket.tenant_id == current_user.tenant_id)
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
db_ticket = result.scalars().first()
|
db_ticket = result.scalars().first()
|
||||||
|
|
||||||
if not db_ticket:
|
if not db_ticket:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
|
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]:
|
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")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ticket ya está cerrado o resuelto")
|
||||||
|
|
||||||
old_status = db_ticket.status.value
|
old_status = db_ticket.status.value
|
||||||
db_ticket.status = TicketStatus.CLOSED
|
db_ticket.status = TicketStatus.CLOSED
|
||||||
db_ticket.resolved_at = datetime.utcnow()
|
db_ticket.resolved_at = datetime.utcnow()
|
||||||
db_ticket.updated_at = datetime.utcnow()
|
db_ticket.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
if close_request.resolution_notes:
|
if close_request.resolution_notes:
|
||||||
comment = TicketComment(
|
comment = TicketComment(
|
||||||
id=uuid.uuid4(), ticket_id=ticket_uuid, author_id=current_user.id,
|
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()
|
is_internal=False, created_at=datetime.utcnow(), updated_at=datetime.utcnow()
|
||||||
)
|
)
|
||||||
db.add(comment)
|
db.add(comment)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(db_ticket, ["category", "affected_system", "assigned_to_user"])
|
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,
|
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)
|
return ticket_to_dict(db_ticket)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{ticket_id}/comments", response_model=List[CommentResponse])
|
@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"""
|
"""Obtener comentarios de un ticket"""
|
||||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket 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 in ["CLIENT_USER", "CLIENT_ADMIN"]:
|
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.where(Ticket.created_by == current_user.id)
|
||||||
|
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
ticket_obj = result.scalars().first()
|
ticket_obj = result.scalars().first()
|
||||||
if not ticket_obj:
|
if not ticket_obj:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
|
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)
|
result = await db.execute(comments_query)
|
||||||
comments = result.scalars().all()
|
comments = result.scalars().all()
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{"id": str(c.id), "ticket_id": str(c.ticket_id), "author_id": str(c.author_id),
|
{"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",
|
"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
|
for c in comments
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{ticket_id}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED)
|
@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"""
|
"""Crear un comentario en un ticket"""
|
||||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket 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, 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)
|
query = query.where(Ticket.created_by == current_user.id)
|
||||||
|
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
ticket_obj = result.scalars().first()
|
ticket_obj = result.scalars().first()
|
||||||
if not ticket_obj:
|
if not ticket_obj:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
|
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(
|
new_comment = TicketComment(
|
||||||
id=uuid.uuid4(), ticket_id=ticket_uuid, author_id=current_user.id,
|
id=uuid.uuid4(), ticket_id=ticket_uuid, author_id=current_user.id,
|
||||||
content=comment.content, is_internal=comment.is_internal,
|
content=comment.content, is_internal=comment.is_internal,
|
||||||
created_at=datetime.utcnow(), updated_at=datetime.utcnow()
|
created_at=datetime.utcnow(), updated_at=datetime.utcnow()
|
||||||
)
|
)
|
||||||
db.add(new_comment)
|
db.add(new_comment)
|
||||||
|
|
||||||
staff_roles = ["ADMIN", "SUPPORT_MANAGER", "AGENT"]
|
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:
|
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.first_response_at = datetime.utcnow()
|
||||||
|
|
||||||
ticket_obj.updated_at = datetime.utcnow()
|
ticket_obj.updated_at = datetime.utcnow()
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(new_comment)
|
await db.refresh(new_comment)
|
||||||
|
|
||||||
return {
|
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}",
|
"author_name": f"{current_user.first_name} {current_user.last_name}",
|
||||||
"content": new_comment.content, "is_internal": new_comment.is_internal,
|
"content": new_comment.content, "is_internal": new_comment.is_internal,
|
||||||
"created_at": new_comment.created_at, "updated_at": new_comment.updated_at
|
"created_at": new_comment.created_at, "updated_at": new_comment.updated_at
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{ticket_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@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)"""
|
"""Eliminar un ticket (solo admin/manager)"""
|
||||||
if current_user.role not in (UserRole.ADMIN, UserRole.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 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")
|
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, Ticket.tenant_id == current_user.tenant_id)
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
db_ticket = result.scalars().first()
|
db_ticket = result.scalars().first()
|
||||||
|
|
||||||
if not db_ticket:
|
if not db_ticket:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
|
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,
|
old_values = {
|
||||||
"status": db_ticket.status.value, "priority": db_ticket.priority.value}
|
"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.delete(db_ticket)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
await safe_audit_log(db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
|
await safe_audit_log(
|
||||||
action="ticket.delete", resource_type="ticket", resource_id=ticket_uuid, old_values=old_values)
|
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"}
|
return {"message": "Ticket deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{ticket_id}/attachments", response_model=List[AttachmentResponse])
|
@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"""
|
"""Obtener adjuntos de un ticket"""
|
||||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
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()
|
ticket = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not ticket:
|
if not ticket:
|
||||||
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
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")
|
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()
|
attachments = result.scalars().all()
|
||||||
|
|
||||||
return [
|
return [
|
||||||
AttachmentResponse(
|
AttachmentResponse(
|
||||||
id=att.id, ticket_id=att.ticket_id, comment_id=att.comment_id, uploaded_by=att.uploaded_by,
|
id=att.id, ticket_id=att.ticket_id, comment_id=att.comment_id,
|
||||||
filename=att.filename, original_filename=att.original_filename, mime_type=att.mime_type,
|
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,
|
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",
|
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
|
) for att in attachments
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{ticket_id}/attachments", status_code=status.HTTP_201_CREATED)
|
@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),
|
async def upload_attachment(
|
||||||
current_user: User = Depends(get_current_user), current_tenant: Tenant = Depends(get_current_tenant)):
|
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"""
|
"""Subir un archivo adjunto a un ticket"""
|
||||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
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()
|
ticket = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not ticket:
|
if not ticket:
|
||||||
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
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")
|
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(
|
attachment = TicketAttachment(
|
||||||
id=uuid.uuid4(), ticket_id=ticket_uuid, uploaded_by=current_user.id, filename=file_metadata["filename"],
|
id=uuid.uuid4(), ticket_id=ticket_uuid, uploaded_by=current_user.id,
|
||||||
original_filename=file_metadata["original_filename"], mime_type=file_metadata["mime_type"],
|
filename=file_metadata["filename"], original_filename=file_metadata["original_filename"],
|
||||||
file_size=file_metadata["file_size"], file_path=file_metadata["file_path"],
|
mime_type=file_metadata["mime_type"], file_size=file_metadata["file_size"],
|
||||||
md5_hash=file_metadata["md5_hash"], sha256_hash=file_metadata["sha256_hash"], created_at=datetime.utcnow()
|
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)
|
db.add(attachment)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(attachment, ["uploaded_by_user"])
|
await db.refresh(attachment, ["uploaded_by_user"])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True, "message": "Archivo subido exitosamente",
|
"success": True, "message": "Archivo subido exitosamente",
|
||||||
"data": AttachmentResponse(
|
"data": AttachmentResponse(
|
||||||
id=attachment.id, ticket_id=attachment.ticket_id, comment_id=attachment.comment_id,
|
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,
|
uploaded_by=attachment.uploaded_by, filename=attachment.filename,
|
||||||
mime_type=attachment.mime_type, file_size=attachment.file_size, file_path=attachment.file_path,
|
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}",
|
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")
|
@router.get("/{ticket_id}/attachments/{attachment_id}/download")
|
||||||
async def download_attachment(ticket_id: str, attachment_id: str, db: AsyncSession = Depends(get_db),
|
async def download_attachment(
|
||||||
current_user: User = Depends(get_current_user), current_tenant: Tenant = Depends(get_current_tenant)):
|
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"""
|
"""Descargar un archivo adjunto"""
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger(__name__)
|
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")
|
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||||
attachment_uuid = validate_uuid_param(attachment_id, "attachment 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()
|
ticket = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not ticket:
|
if not ticket:
|
||||||
logger.error(f"Ticket not found - ticket_id: {ticket_id}")
|
|
||||||
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
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")
|
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()
|
attachment = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not attachment:
|
if not attachment:
|
||||||
logger.error(f"Attachment not found - attachment_id: {attachment_id}")
|
|
||||||
raise HTTPException(status_code=404, detail="Adjunto no encontrado")
|
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:
|
try:
|
||||||
file_path = file_handler.get_file_path(attachment.file_path)
|
file_path = file_handler.get_file_path(attachment.file_path)
|
||||||
logger.info(f"Absolute file path: {file_path}")
|
|
||||||
|
|
||||||
if not file_path.exists():
|
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")
|
raise HTTPException(status_code=404, detail="Archivo no encontrado en el sistema")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting file path: {str(e)}")
|
logger.error(f"Error getting file path: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
logger.info(f"Returning file: {attachment.original_filename}")
|
return FileResponse(
|
||||||
return FileResponse(path=file_path, filename=attachment.original_filename, media_type=attachment.mime_type)
|
path=file_path,
|
||||||
|
filename=attachment.original_filename,
|
||||||
|
media_type=attachment.mime_type
|
||||||
|
)
|
||||||
|
|
||||||
@router.get("/{ticket_id}/issues", response_model=List[IssueResponse])
|
@router.get("/{ticket_id}/issues", response_model=List[IssueResponse])
|
||||||
async def get_ticket_issues(
|
async def get_ticket_issues(
|
||||||
@@ -425,11 +543,9 @@ async def get_ticket_issues(
|
|||||||
"""Obtener asuntos de un ticket."""
|
"""Obtener asuntos de un ticket."""
|
||||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
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)
|
||||||
query = select(Ticket).where(
|
if current_user.role != UserRole.ADMIN:
|
||||||
Ticket.id == ticket_uuid,
|
query = query.where(Ticket.tenant_id == current_user.tenant_id)
|
||||||
Ticket.tenant_id == current_user.tenant_id,
|
|
||||||
)
|
|
||||||
if current_user.role.is_client:
|
if current_user.role.is_client:
|
||||||
query = query.where(Ticket.created_by == current_user.id)
|
query = query.where(Ticket.created_by == current_user.id)
|
||||||
|
|
||||||
@@ -452,21 +568,17 @@ async def get_ticket_issues(
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
IssueResponse(
|
IssueResponse(
|
||||||
id=issue.id,
|
id=issue.id, ticket_id=issue.ticket_id, tenant_id=issue.tenant_id,
|
||||||
ticket_id=issue.ticket_id,
|
content=issue.content, priority=issue.priority.value,
|
||||||
tenant_id=issue.tenant_id,
|
|
||||||
content=issue.content,
|
|
||||||
priority=issue.priority.value,
|
|
||||||
created_by=issue.created_by,
|
created_by=issue.created_by,
|
||||||
created_by_name=f"{issue.created_by_user.first_name} {issue.created_by_user.last_name}",
|
created_by_name=f"{issue.created_by_user.first_name} {issue.created_by_user.last_name}",
|
||||||
tagged_users=[
|
tagged_users=[
|
||||||
{"id": u.id, "full_name": f"{u.first_name} {u.last_name}", "email": u.email}
|
{"id": u.id, "full_name": f"{u.first_name} {u.last_name}", "email": u.email}
|
||||||
for u in issue.tagged_users
|
for u in issue.tagged_users
|
||||||
],
|
],
|
||||||
attachment_filename=issue.attachment_filename,
|
attachment_filename=issue.attachment_filename,
|
||||||
attachment_mime_type=issue.attachment_mime_type,
|
attachment_mime_type=issue.attachment_mime_type,
|
||||||
created_at=issue.created_at,
|
created_at=issue.created_at, updated_at=issue.updated_at,
|
||||||
updated_at=issue.updated_at,
|
|
||||||
)
|
)
|
||||||
for issue in issues
|
for issue in issues
|
||||||
]
|
]
|
||||||
@@ -481,14 +593,9 @@ async def create_ticket_issue(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
current_tenant: Tenant = Depends(get_current_tenant),
|
current_tenant: Tenant = Depends(get_current_tenant),
|
||||||
):
|
):
|
||||||
"""
|
"""Crear un asunto de escalación en un ticket."""
|
||||||
Crear un asunto de escalación en un ticket.
|
|
||||||
|
|
||||||
Solo puede crearlo el dueño del ticket o el CLIENT_ADMIN del tenant.
|
|
||||||
"""
|
|
||||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||||
|
|
||||||
# 1. Verificar que el ticket existe y pertenece al tenant
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Ticket).where(
|
select(Ticket).where(
|
||||||
Ticket.id == ticket_uuid,
|
Ticket.id == ticket_uuid,
|
||||||
@@ -499,23 +606,21 @@ async def create_ticket_issue(
|
|||||||
if not ticket_obj:
|
if not ticket_obj:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ticket no encontrado")
|
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_ticket_owner = ticket_obj.created_by == current_user.id
|
||||||
is_client_admin = current_user.role == UserRole.CLIENT_ADMIN
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
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 = []
|
tagged_users = []
|
||||||
if issue.tagged_user_ids:
|
if issue.tagged_user_ids:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(User).where(
|
select(User).where(
|
||||||
User.id.in_(issue.tagged_user_ids),
|
User.id.in_(issue.tagged_user_ids),
|
||||||
User.tenant_id == current_user.tenant_id,
|
|
||||||
User.is_active == True,
|
User.is_active == True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -526,10 +631,9 @@ async def create_ticket_issue(
|
|||||||
if missing:
|
if missing:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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_path = None
|
||||||
attachment_filename = None
|
attachment_filename = None
|
||||||
attachment_mime_type = None
|
attachment_mime_type = None
|
||||||
@@ -539,19 +643,13 @@ async def create_ticket_issue(
|
|||||||
attachment_filename = file_metadata["original_filename"]
|
attachment_filename = file_metadata["original_filename"]
|
||||||
attachment_mime_type = file_metadata["mime_type"]
|
attachment_mime_type = file_metadata["mime_type"]
|
||||||
|
|
||||||
# 5. Crear el asunto
|
|
||||||
new_issue = TicketIssue(
|
new_issue = TicketIssue(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(), ticket_id=ticket_uuid,
|
||||||
ticket_id=ticket_uuid,
|
tenant_id=current_user.tenant_id, created_by=current_user.id,
|
||||||
tenant_id=current_user.tenant_id,
|
content=issue.content, priority=TicketPriority[issue.priority],
|
||||||
created_by=current_user.id,
|
attachment_path=attachment_path, attachment_filename=attachment_filename,
|
||||||
content=issue.content,
|
|
||||||
priority=TicketPriority[issue.priority],
|
|
||||||
attachment_path=attachment_path,
|
|
||||||
attachment_filename=attachment_filename,
|
|
||||||
attachment_mime_type=attachment_mime_type,
|
attachment_mime_type=attachment_mime_type,
|
||||||
created_at=datetime.utcnow(),
|
created_at=datetime.utcnow(), updated_at=datetime.utcnow(),
|
||||||
updated_at=datetime.utcnow(),
|
|
||||||
)
|
)
|
||||||
new_issue.tagged_users = tagged_users
|
new_issue.tagged_users = tagged_users
|
||||||
db.add(new_issue)
|
db.add(new_issue)
|
||||||
@@ -559,27 +657,18 @@ async def create_ticket_issue(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(new_issue, ["created_by_user", "tagged_users"])
|
await db.refresh(new_issue, ["created_by_user", "tagged_users"])
|
||||||
|
|
||||||
# 6. Audit log
|
|
||||||
await safe_audit_log(
|
await safe_audit_log(
|
||||||
db=db,
|
db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
|
||||||
tenant_id=current_user.tenant_id,
|
action="ticket.issue.create", resource_type="ticket_issue", resource_id=new_issue.id,
|
||||||
user_id=current_user.id,
|
|
||||||
action="ticket.issue.create",
|
|
||||||
resource_type="ticket_issue",
|
|
||||||
resource_id=new_issue.id,
|
|
||||||
new_values={
|
new_values={
|
||||||
"ticket_id": str(ticket_uuid),
|
"ticket_id": str(ticket_uuid), "priority": issue.priority,
|
||||||
"priority": issue.priority,
|
|
||||||
"tagged_users": [str(uid) for uid in issue.tagged_user_ids],
|
"tagged_users": [str(uid) for uid in issue.tagged_user_ids],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return IssueResponse(
|
return IssueResponse(
|
||||||
id=new_issue.id,
|
id=new_issue.id, ticket_id=new_issue.ticket_id, tenant_id=new_issue.tenant_id,
|
||||||
ticket_id=new_issue.ticket_id,
|
content=new_issue.content, priority=new_issue.priority.value,
|
||||||
tenant_id=new_issue.tenant_id,
|
|
||||||
content=new_issue.content,
|
|
||||||
priority=new_issue.priority.value,
|
|
||||||
created_by=new_issue.created_by,
|
created_by=new_issue.created_by,
|
||||||
created_by_name=f"{new_issue.created_by_user.first_name} {new_issue.created_by_user.last_name}",
|
created_by_name=f"{new_issue.created_by_user.first_name} {new_issue.created_by_user.last_name}",
|
||||||
tagged_users=[
|
tagged_users=[
|
||||||
@@ -588,6 +677,5 @@ async def create_ticket_issue(
|
|||||||
],
|
],
|
||||||
attachment_filename=new_issue.attachment_filename,
|
attachment_filename=new_issue.attachment_filename,
|
||||||
attachment_mime_type=new_issue.attachment_mime_type,
|
attachment_mime_type=new_issue.attachment_mime_type,
|
||||||
created_at=new_issue.created_at,
|
created_at=new_issue.created_at, updated_at=new_issue.updated_at,
|
||||||
updated_at=new_issue.updated_at,
|
|
||||||
)
|
)
|
||||||
@@ -54,13 +54,13 @@ function createAuthStore() {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/auth/me', {
|
const response = await fetch('/api/v1/auth/me', {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: { 'X-App': 'client' }
|
headers: { 'X-App': 'internal' }
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const user = await response.json();
|
const user = await response.json();
|
||||||
set({ user, token: null, isAuthenticated: true, isLoading: false });
|
set({ user, token: null, isAuthenticated: true, isLoading: false });
|
||||||
}
|
}
|
||||||
} catch (error) {}
|
} catch (error) { }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
login: async (credentials: LoginRequest): Promise<void> => {
|
login: async (credentials: LoginRequest): Promise<void> => {
|
||||||
@@ -94,12 +94,12 @@ function createAuthStore() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'X-App': 'client',
|
'X-App': 'internal',
|
||||||
'X-Tenant-Slug': slug,
|
'X-Tenant-Slug': slug,
|
||||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch {}
|
} catch { }
|
||||||
set(initialState);
|
set(initialState);
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
|
|||||||
@@ -1,35 +1,36 @@
|
|||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [sveltekit()],
|
plugins: [sveltekit()],
|
||||||
server: {
|
server: {
|
||||||
port: parseInt(process.env.PORT || '3001'),
|
port: parseInt(process.env.PORT || '3001'),
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
watch: {
|
watch: {
|
||||||
usePolling: true,
|
usePolling: true,
|
||||||
interval: 500
|
interval: 500
|
||||||
},
|
|
||||||
hmr: {
|
|
||||||
host: 'localhost',
|
|
||||||
clientPort: parseInt(process.env.HMR_CLIENT_PORT || '3001')
|
|
||||||
},
|
|
||||||
fs: {
|
|
||||||
allow: ['/app', '.'],
|
|
||||||
strict: false
|
|
||||||
},
|
|
||||||
proxy: {
|
|
||||||
'/api': {
|
|
||||||
target: process.env.PUBLIC_API_URL || 'http://backend:8000',
|
|
||||||
changeOrigin: true,
|
|
||||||
rewrite: (path) => path.replace(/^\/api/, '')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
preview: {
|
hmr: {
|
||||||
port: parseInt(process.env.PORT || '3001'),
|
host: 'localhost',
|
||||||
host: '0.0.0.0'
|
clientPort: parseInt(process.env.HMR_CLIENT_PORT || '3001')
|
||||||
},
|
},
|
||||||
build: {
|
fs: {
|
||||||
target: 'esnext'
|
allow: ['/app', '.'],
|
||||||
|
strict: false
|
||||||
|
},
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: process.env.PUBLIC_API_URL || 'http://backend:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, '')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
preview: {
|
||||||
|
port: parseInt(process.env.PORT || '3001'),
|
||||||
|
host: '0.0.0.0'
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
target: 'esnext'
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user