Bump version to 1.2.0
This commit is contained in:
427
backend/app/api/v1/endpoints/tickets.py
Normal file
427
backend/app/api/v1/endpoints/tickets.py
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
"""
|
||||||
|
Tickets endpoints - ServiceManagerWeb
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
from app.models.ticket import Ticket, TicketStatus, TicketPriority
|
||||||
|
from app.models.user import User
|
||||||
|
from pydantic import BaseModel
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# SCHEMAS
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
class TicketCreate(BaseModel):
|
||||||
|
subject: str
|
||||||
|
description: str
|
||||||
|
category_id: Optional[str] = None
|
||||||
|
system_id: Optional[str] = None
|
||||||
|
priority: str = "MEDIUM"
|
||||||
|
|
||||||
|
class TicketUpdate(BaseModel):
|
||||||
|
subject: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
priority: Optional[str] = None
|
||||||
|
assigned_to: Optional[str] = None
|
||||||
|
|
||||||
|
class TicketResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
ticket_number: str
|
||||||
|
subject: str
|
||||||
|
description: str
|
||||||
|
status: str
|
||||||
|
priority: str
|
||||||
|
category_id: Optional[str] = None
|
||||||
|
system_id: Optional[str] = None
|
||||||
|
created_by: str
|
||||||
|
assigned_to: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class TicketCloseRequest(BaseModel):
|
||||||
|
resolution: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# TICKET ENDPOINTS
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
@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
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Generar número de ticket único
|
||||||
|
result = await db.execute(
|
||||||
|
select(func.count(Ticket.id)).where(Ticket.tenant_id == current_user.tenant_id)
|
||||||
|
)
|
||||||
|
count = result.scalar() or 0
|
||||||
|
ticket_number = f"TK-{count + 1:06d}"
|
||||||
|
|
||||||
|
# Convertir IDs de string a UUID si son proporcionados
|
||||||
|
category_uuid = uuid.UUID(ticket.category_id) if ticket.category_id else None
|
||||||
|
system_uuid = uuid.UUID(ticket.system_id) if ticket.system_id 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,
|
||||||
|
system_id=system_uuid,
|
||||||
|
priority=TicketPriority[ticket.priority.upper()],
|
||||||
|
created_by=current_user.id,
|
||||||
|
status=TicketStatus.NEW,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
updated_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(db_ticket)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(db_ticket)
|
||||||
|
|
||||||
|
# Convertir a respuesta
|
||||||
|
return {
|
||||||
|
"id": str(db_ticket.id),
|
||||||
|
"ticket_number": db_ticket.ticket_number,
|
||||||
|
"subject": 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,
|
||||||
|
"system_id": str(db_ticket.system_id) if db_ticket.system_id else None,
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid UUID format: {str(e)}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Error creating ticket: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[TicketResponse])
|
||||||
|
async def get_tickets(
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
status_filter: Optional[str] = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Obtener tickets del usuario actual
|
||||||
|
"""
|
||||||
|
query = select(Ticket).where(
|
||||||
|
Ticket.tenant_id == current_user.tenant_id,
|
||||||
|
Ticket.created_by == current_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if status_filter:
|
||||||
|
try:
|
||||||
|
status_enum = TicketStatus[status_filter.upper()]
|
||||||
|
query = query.where(Ticket.status == status_enum)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid status: {status_filter}"
|
||||||
|
)
|
||||||
|
|
||||||
|
query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
tickets = result.scalars().all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(t.id),
|
||||||
|
"ticket_number": t.ticket_number,
|
||||||
|
"subject": t.subject,
|
||||||
|
"description": t.description,
|
||||||
|
"status": t.status.value,
|
||||||
|
"priority": t.priority.value,
|
||||||
|
"category_id": str(t.category_id) if t.category_id else None,
|
||||||
|
"system_id": str(t.system_id) if t.system_id else None,
|
||||||
|
"created_by": str(t.created_by),
|
||||||
|
"assigned_to": str(t.assigned_to) if t.assigned_to else None,
|
||||||
|
"created_at": t.created_at,
|
||||||
|
"updated_at": t.updated_at
|
||||||
|
}
|
||||||
|
for t in tickets
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@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 específico
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
ticket_uuid = uuid.UUID(ticket_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid ticket ID format"
|
||||||
|
)
|
||||||
|
|
||||||
|
query = select(Ticket).where(
|
||||||
|
Ticket.id == ticket_uuid,
|
||||||
|
Ticket.tenant_id == current_user.tenant_id,
|
||||||
|
Ticket.created_by == current_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
ticket = result.scalars().first()
|
||||||
|
|
||||||
|
if not ticket:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Ticket {ticket_id} not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(ticket.id),
|
||||||
|
"ticket_number": ticket.ticket_number,
|
||||||
|
"subject": ticket.subject,
|
||||||
|
"description": ticket.description,
|
||||||
|
"status": ticket.status.value,
|
||||||
|
"priority": ticket.priority.value,
|
||||||
|
"category_id": str(ticket.category_id) if ticket.category_id else None,
|
||||||
|
"system_id": str(ticket.system_id) if ticket.system_id else None,
|
||||||
|
"created_by": str(ticket.created_by),
|
||||||
|
"assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None,
|
||||||
|
"created_at": ticket.created_at,
|
||||||
|
"updated_at": ticket.updated_at
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{ticket_id}", response_model=TicketResponse)
|
||||||
|
async def update_ticket(
|
||||||
|
ticket_id: str,
|
||||||
|
ticket_update: TicketUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Actualizar un ticket
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
ticket_uuid = uuid.UUID(ticket_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid ticket ID format"
|
||||||
|
)
|
||||||
|
|
||||||
|
query = select(Ticket).where(
|
||||||
|
Ticket.id == ticket_uuid,
|
||||||
|
Ticket.tenant_id == current_user.tenant_id,
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
update_data = ticket_update.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 == "assigned_to" and value:
|
||||||
|
setattr(db_ticket, field, uuid.UUID(value))
|
||||||
|
else:
|
||||||
|
setattr(db_ticket, field, value)
|
||||||
|
|
||||||
|
db_ticket.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(db_ticket)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(db_ticket.id),
|
||||||
|
"ticket_number": db_ticket.ticket_number,
|
||||||
|
"subject": 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,
|
||||||
|
"system_id": str(db_ticket.system_id) if db_ticket.system_id else None,
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Error updating ticket: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
ticket_uuid = uuid.UUID(ticket_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid ticket ID format"
|
||||||
|
)
|
||||||
|
|
||||||
|
query = select(Ticket).where(
|
||||||
|
Ticket.id == ticket_uuid,
|
||||||
|
Ticket.tenant_id == current_user.tenant_id,
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db_ticket.status = TicketStatus.CLOSED
|
||||||
|
db_ticket.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(db_ticket)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(db_ticket.id),
|
||||||
|
"ticket_number": db_ticket.ticket_number,
|
||||||
|
"subject": 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,
|
||||||
|
"system_id": str(db_ticket.system_id) if db_ticket.system_id else None,
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Error closing ticket: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# COMMENT ENDPOINTS (placeholder)
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
@router.get("/{ticket_id}/comments")
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{ticket_id}/comments", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_comment(
|
||||||
|
ticket_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Agregar un comentario a un ticket
|
||||||
|
"""
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||||
|
detail="Comments not yet implemented"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# ATTACHMENT ENDPOINTS (placeholder)
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
@router.get("/{ticket_id}/attachments")
|
||||||
|
async def get_ticket_attachments(
|
||||||
|
ticket_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Obtener adjuntos de un ticket
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Subir un archivo adjunto a un ticket
|
||||||
|
"""
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||||
|
detail="File uploads not yet implemented"
|
||||||
|
)
|
||||||
@@ -5,7 +5,7 @@ Router principal para la API v1
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories
|
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
@@ -45,3 +45,10 @@ api_router.include_router(
|
|||||||
prefix="/categories",
|
prefix="/categories",
|
||||||
tags=["categories"]
|
tags=["categories"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Tickets routes
|
||||||
|
api_router.include_router(
|
||||||
|
tickets.router,
|
||||||
|
prefix="/tickets",
|
||||||
|
tags=["tickets"]
|
||||||
|
)
|
||||||
@@ -84,8 +84,29 @@ async function apiCall(endpoint: string, options: RequestInit = {}) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json();
|
let errorMessage = 'Request failed';
|
||||||
throw new Error(error.detail || 'Request failed');
|
try {
|
||||||
|
const error = await response.json();
|
||||||
|
console.error('❌ API Error Response:', error);
|
||||||
|
|
||||||
|
// Manejar diferentes formatos de error de FastAPI
|
||||||
|
if (error.detail) {
|
||||||
|
if (Array.isArray(error.detail)) {
|
||||||
|
// Errores de validación de FastAPI
|
||||||
|
errorMessage = error.detail.map(e => `${e.loc.join('.')}: ${e.msg}`).join(', ');
|
||||||
|
} else if (typeof error.detail === 'string') {
|
||||||
|
errorMessage = error.detail;
|
||||||
|
} else {
|
||||||
|
errorMessage = JSON.stringify(error.detail);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errorMessage = JSON.stringify(error);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
@@ -146,9 +167,21 @@ function createTicketsStore() {
|
|||||||
update(state => ({ ...state, isLoading: true, error: null }));
|
update(state => ({ ...state, isLoading: true, error: null }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Mapear campos del frontend al formato del backend
|
||||||
|
const ticketData = {
|
||||||
|
subject: ticket.title, // ← Backend espera "subject" no "title"
|
||||||
|
description: ticket.description,
|
||||||
|
category_id: ticket.category_id,
|
||||||
|
priority: ticket.priority,
|
||||||
|
system_id: null // ← Opcional
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
console.log('Sending ticket data:', ticketData);
|
||||||
|
|
||||||
const newTicket = await apiCall('/tickets/', {
|
const newTicket = await apiCall('/tickets/', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(ticket)
|
body: JSON.stringify(ticketData)
|
||||||
});
|
});
|
||||||
|
|
||||||
update(state => ({
|
update(state => ({
|
||||||
@@ -159,6 +192,7 @@ function createTicketsStore() {
|
|||||||
|
|
||||||
return newTicket;
|
return newTicket;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Create ticket error:', error);
|
||||||
update(state => ({
|
update(state => ({
|
||||||
...state,
|
...state,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user