From 6215fc40a707a728294339f77f812ce07153e484 Mon Sep 17 00:00:00 2001 From: arielit3 Date: Mon, 19 Jan 2026 14:04:55 -0700 Subject: [PATCH] Bump version to 1.2.0 --- backend/app/api/v1/endpoints/tickets.py | 427 ++++++++++++++++++++++ backend/app/api/v1/router.py | 9 +- frontend-client/src/lib/stores/tickets.ts | 40 +- 3 files changed, 472 insertions(+), 4 deletions(-) create mode 100644 backend/app/api/v1/endpoints/tickets.py diff --git a/backend/app/api/v1/endpoints/tickets.py b/backend/app/api/v1/endpoints/tickets.py new file mode 100644 index 0000000..020e3fc --- /dev/null +++ b/backend/app/api/v1/endpoints/tickets.py @@ -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" + ) \ No newline at end of file diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 924133f..f53b71f 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -5,7 +5,7 @@ Router principal para la API v1 """ 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() @@ -45,3 +45,10 @@ api_router.include_router( prefix="/categories", tags=["categories"] ) + +# Tickets routes +api_router.include_router( + tickets.router, + prefix="/tickets", + tags=["tickets"] +) \ No newline at end of file diff --git a/frontend-client/src/lib/stores/tickets.ts b/frontend-client/src/lib/stores/tickets.ts index 9442c95..72f8bea 100644 --- a/frontend-client/src/lib/stores/tickets.ts +++ b/frontend-client/src/lib/stores/tickets.ts @@ -84,8 +84,29 @@ async function apiCall(endpoint: string, options: RequestInit = {}) { }); if (!response.ok) { - const error = await response.json(); - throw new Error(error.detail || 'Request failed'); + let errorMessage = '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(); @@ -146,9 +167,21 @@ function createTicketsStore() { update(state => ({ ...state, isLoading: true, error: null })); 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/', { method: 'POST', - body: JSON.stringify(ticket) + body: JSON.stringify(ticketData) }); update(state => ({ @@ -159,6 +192,7 @@ function createTicketsStore() { return newTicket; } catch (error) { + console.error('Create ticket error:', error); update(state => ({ ...state, isLoading: false,