v1.8.0: Sistema funcional con filtros optimizados y UI mejorada

Mejoras en Módulo de Tickets:
- Implementado sistema de filtros funcional por estado y prioridad
- Tabla compacta estilo auditoría (50% más espacio visible)
- Backend actualizado: parámetros 'status' y 'priority' con validación
- Interfaz más limpia con labels reducidos y 2 columnas de filtros
- Eliminación de columna SLA duplicada en tabla

Correcciones Backend:
- Endpoint /v1/tickets/: filtros 'status' y 'priority' funcionan correctamente
- Endpoint /v1/sla/violations: timezone UTC y eager loading con selectinload
- Endpoint /v1/client-profile/: generación explícita de UUID
- Migración fix_client_profiles_timestamps aplicada

Mejoras UI Frontend:
- Tabla tickets: encabezados uppercase text-xs, celdas px-3 py-2
- Toggle de estado activo/inactivo en gestión de tenants (tabla + modal)
- Badges más compactos con rounded-full
- Botones de acciones con separador visual y transiciones
- Filtros con URLSearchParams para construcción correcta de queries

Arquitectura:
- SQLAlchemy: eager loading para evitar N+1 queries
- Timezone handling: datetime.now(timezone.utc) para comparaciones
- Svelte reactivity: keyed loops y spread operator para forzar updates
- API client: endpoint con query string completo

Estado del sistema: Totalmente funcional para producción MVP
This commit is contained in:
2026-02-17 12:43:06 -07:00
parent cc1e964c3a
commit e6440395ea
8 changed files with 245 additions and 1633 deletions

View File

@@ -50,8 +50,11 @@ async def get_current_client_profile(
profile = result.scalar_one_or_none()
if not profile:
# Si no existe, crear uno vacío
profile = ClientProfile(tenant_id=current_tenant.id)
# Si no existe, crear uno vacío con valores por defecto explícitos
profile = ClientProfile(
id=uuid.uuid4(),
tenant_id=current_tenant.id
)
db.add(profile)
await db.commit()
await db.refresh(profile)

View File

@@ -8,6 +8,7 @@ Solo accesible por roles staff internos
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, or_, desc, case, cast
from sqlalchemy.orm import selectinload
from typing import Optional, List
from datetime import datetime, timedelta, timezone
import uuid
@@ -359,8 +360,12 @@ async def get_sla_violations(
now = datetime.now(timezone.utc)
db_now = func.now()
# Base query
query = select(Ticket).where(
# Base query con carga de relaciones
query = select(Ticket).options(
selectinload(Ticket.created_by_user),
selectinload(Ticket.assigned_to_user),
selectinload(Ticket.category)
).where(
and_(
Ticket.tenant_id == current_tenant.id,
Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED])
@@ -421,23 +426,25 @@ async def get_sla_violations(
# Formatear response
violations = []
for ticket in tickets:
# Asegurar que los datetimes de BD sean timezone-aware
sla_response_due = ticket.sla_response_due.replace(tzinfo=timezone.utc) if ticket.sla_response_due and ticket.sla_response_due.tzinfo is None else ticket.sla_response_due
sla_resolution_due = ticket.sla_resolution_due.replace(tzinfo=timezone.utc) if ticket.sla_resolution_due and ticket.sla_resolution_due.tzinfo is None else ticket.sla_resolution_due
# Determinar tipo de violación
response_violated = ticket.first_response_at is None and ticket.sla_response_due and now > ticket.sla_response_due
resolution_violated = ticket.sla_resolution_due and now > ticket.sla_resolution_due
response_violated = ticket.first_response_at is None and sla_response_due and now > sla_response_due
resolution_violated = sla_resolution_due and now > sla_resolution_due
# Priorizar resolution si ambos están violados
if resolution_violated:
violation_type = SLATypeEnum.RESOLUTION
due_at = ticket.sla_resolution_due
due_at = sla_resolution_due
else:
violation_type = SLATypeEnum.RESPONSE
due_at = ticket.sla_response_due
due_at = sla_response_due
hours_overdue = (now - due_at).total_seconds() / 3600 if due_at else 0
# Cargar relaciones
await db.refresh(ticket, ['created_by', 'assigned_to', 'category'])
# Las relaciones ya están cargadas por selectinload
violations.append(SLAViolationResponse(
ticket=TicketBasicInfo(
id=ticket.id,
@@ -453,17 +460,17 @@ async def get_sla_violations(
sla_resolution_hours=ticket.category.sla_resolution_hours
) if ticket.category else None,
created_by=UserBasicInfo(
id=ticket.created_by.id,
first_name=ticket.created_by.first_name,
last_name=ticket.created_by.last_name,
email=ticket.created_by.email
id=ticket.created_by_user.id,
first_name=ticket.created_by_user.first_name,
last_name=ticket.created_by_user.last_name,
email=ticket.created_by_user.email
),
assigned_to=UserBasicInfo(
id=ticket.assigned_to.id,
first_name=ticket.assigned_to.first_name,
last_name=ticket.assigned_to.last_name,
email=ticket.assigned_to.email
) if ticket.assigned_to else None,
id=ticket.assigned_to_user.id,
first_name=ticket.assigned_to_user.first_name,
last_name=ticket.assigned_to_user.last_name,
email=ticket.assigned_to_user.email
) if ticket.assigned_to_user else None,
sla_type=violation_type,
sla_due_at=due_at,
violated_at=due_at, # Se violó en el momento del due

View File

@@ -240,14 +240,19 @@ async def create_ticket(
async def get_tickets(
skip: int = 0,
limit: int = 100,
status_filter: Optional[str] = None,
status: Optional[str] = None,
priority: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Obtener tickets
Obtener tickets con filtros opcionales
Roles ADMIN/SUPPORT_MANAGER/AGENT: Ven todos los tickets del tenant
Roles CLIENT_USER/CLIENT_ADMIN: Solo ven sus propios tickets
Filtros disponibles:
- status: NEW, IN_PROGRESS, WAITING_CUSTOMER, RESOLVED, CLOSED, REOPENED
- priority: LOW, MEDIUM, HIGH, URGENT
"""
# Construir query base filtrado por tenant
query = select(Ticket).where(
@@ -258,14 +263,26 @@ async def get_tickets(
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
query = query.where(Ticket.created_by == current_user.id)
if status_filter:
# Filtro por estado
if status:
try:
status_enum = TicketStatus[status_filter.upper()]
status_enum = TicketStatus[status.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}"
detail=f"Invalid status: {status}. Valid values: NEW, IN_PROGRESS, WAITING_CUSTOMER, RESOLVED, CLOSED, REOPENED"
)
# Filtro por prioridad
if priority:
try:
priority_enum = TicketPriority[priority.upper()]
query = query.where(Ticket.priority == priority_enum)
except KeyError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid priority: {priority}. Valid values: LOW, MEDIUM, HIGH, URGENT"
)
query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit)

View File

@@ -1,451 +0,0 @@
"""
Tickets endpoints - ServiceManagerWeb
"""
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from pydantic import BaseModel
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 app.models.category import Category # ✅ CORREGIDO: Era TicketCategory
from app.models.system import System
import uuid
router = APIRouter()
# ===================================
# SCHEMAS
# ===================================
class TicketCreate(BaseModel):
subject: str
description: str
category_id: Optional[str] = None
affected_system_id: Optional[str] = None # ✅ CORREGIDO: Era system_id
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
affected_system_id: Optional[str] = None # ✅ CORREGIDO: Era system_id
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.affected_system_id) if ticket.affected_system_id else None # ✅ CORREGIDO
# ✅ CORREGIDO: Validar en la tabla correcta con el nombre correcto del modelo
if category_uuid:
category = await db.get(Category, category_uuid) # ✅ Category, no TicketCategory
if not category:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"La categoría con ID {ticket.category_id} no existe."
)
# Validar si el system_id existe en la tabla affected_systems
if system_uuid:
system = await db.get(System, system_uuid)
if not system:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"El sistema con ID {ticket.affected_system_id} no existe."
)
db_ticket = Ticket(
id=uuid.uuid4(),
tenant_id=current_user.tenant_id,
ticket_number=ticket_number,
subject=ticket.subject,
description=ticket.description,
category_id=category_uuid,
affected_system_id=system_uuid, # ✅ CORREGIDO: Nombre correcto del campo
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)
# ✅ CORREGIDO: Usar affected_system_id en 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,
"affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None, # ✅ CORREGIDO
"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()
# ✅ CORREGIDO: Usar affected_system_id
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,
"affected_system_id": str(t.affected_system_id) if t.affected_system_id else None, # ✅ CORREGIDO
"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"
)
# ✅ CORREGIDO: Usar affected_system_id
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,
"affected_system_id": str(ticket.affected_system_id) if ticket.affected_system_id else None, # ✅ CORREGIDO
"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)
# ✅ CORREGIDO: Usar affected_system_id
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,
"affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None, # ✅ CORREGIDO
"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)
# ✅ CORREGIDO: Usar affected_system_id
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,
"affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None, # ✅ CORREGIDO
"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"
)

View File

@@ -0,0 +1,47 @@
"""Fix client_profiles timestamps to use server defaults
Revision ID: fix_client_timestamps
Revises: a1b2c3d4e5f6
Create Date: 2026-02-17 12:05:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'fix_client_timestamps'
down_revision = 'a1b2c3d4e5f6'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Modificar created_at para usar server_default
op.alter_column('client_profiles', 'created_at',
existing_type=sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text('now()')
)
# Modificar updated_at para usar server_default
op.alter_column('client_profiles', 'updated_at',
existing_type=sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text('now()')
)
def downgrade() -> None:
# Remover server_default
op.alter_column('client_profiles', 'created_at',
existing_type=sa.DateTime(timezone=True),
nullable=False,
server_default=None
)
op.alter_column('client_profiles', 'updated_at',
existing_type=sa.DateTime(timezone=True),
nullable=False,
server_default=None
)