diff --git a/backend/app/api/v1/endpoints/client_profile.py b/backend/app/api/v1/endpoints/client_profile.py
index 2fbcc80..d31a0e6 100644
--- a/backend/app/api/v1/endpoints/client_profile.py
+++ b/backend/app/api/v1/endpoints/client_profile.py
@@ -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)
diff --git a/backend/app/api/v1/endpoints/sla.py b/backend/app/api/v1/endpoints/sla.py
index 2144db6..2176dc2 100644
--- a/backend/app/api/v1/endpoints/sla.py
+++ b/backend/app/api/v1/endpoints/sla.py
@@ -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
diff --git a/backend/app/api/v1/endpoints/tickets.py b/backend/app/api/v1/endpoints/tickets.py
index fa3af0c..7955ba2 100644
--- a/backend/app/api/v1/endpoints/tickets.py
+++ b/backend/app/api/v1/endpoints/tickets.py
@@ -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)
diff --git a/backend/app/api/v1/endpoints/tickets.py.backup b/backend/app/api/v1/endpoints/tickets.py.backup
deleted file mode 100644
index e4a0fc3..0000000
--- a/backend/app/api/v1/endpoints/tickets.py.backup
+++ /dev/null
@@ -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"
- )
\ No newline at end of file
diff --git a/backend/migrations/versions/fix_client_profiles_timestamps.py b/backend/migrations/versions/fix_client_profiles_timestamps.py
new file mode 100644
index 0000000..5639b6e
--- /dev/null
+++ b/backend/migrations/versions/fix_client_profiles_timestamps.py
@@ -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
+ )
diff --git a/frontend-client/src/routes/profile/+page.svelte.backup b/frontend-client/src/routes/profile/+page.svelte.backup
deleted file mode 100644
index 3887d39..0000000
--- a/frontend-client/src/routes/profile/+page.svelte.backup
+++ /dev/null
@@ -1,1062 +0,0 @@
-
-
-
Gestiona tu información personal y configuración empresarial
-Actualiza tu información básica
-Datos generales de tu empresa
-Datos de contacto y comunicación
-Gestiona tu contraseña y configuración de seguridad
-- {$auth.user?.is_two_factor_enabled - ? 'La autenticación de dos factores está habilitada' - : 'Mejora la seguridad habilitando 2FA'} -
-Detalles sobre tu cuenta y organización
-Gestión de tickets del sistema de mesa de ayuda.
+Gestión de tickets del sistema de mesa de ayuda.