Version con fallas

This commit is contained in:
2026-02-12 14:00:04 -07:00
parent 96cd09476c
commit 2033a35a2b
44 changed files with 196 additions and 3723 deletions

View File

@@ -1,338 +0,0 @@
"""
Audit Endpoints - ServiceManagerWeb
Endpoints para consulta de logs de auditoría.
Solo accesible por roles: ADMIN, SUPPORT_MANAGER, AUDITOR
"""
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, or_, desc
from sqlalchemy.orm import selectinload
from typing import Optional, List
from datetime import datetime, timedelta
import uuid
import structlog
from app.core.database import get_db
from app.api.deps import get_current_user, get_current_tenant
from app.models.user import User, UserRole
from app.models.tenant import Tenant
from app.models.audit import AuditLog
from app.api.schemas.audit import (
AuditLogResponse,
AuditLogListResponse,
AuditLogFilters,
AuditLogStats
)
router = APIRouter()
logger = structlog.get_logger(__name__)
def require_auditor_role(current_user: User = Depends(get_current_user)) -> User:
"""
Dependency que verifica que el usuario tenga rol de auditor.
Solo ADMIN, SUPPORT_MANAGER y AUDITOR pueden ver logs de auditoría.
"""
allowed_roles = [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AUDITOR]
if current_user.role not in allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Solo usuarios con rol ADMIN, SUPPORT_MANAGER o AUDITOR pueden acceder a logs de auditoría"
)
return current_user
@router.get("/", response_model=AuditLogListResponse)
async def get_audit_logs(
# Paginación
page: int = Query(default=1, ge=1, description="Número de página"),
per_page: int = Query(default=50, ge=1, le=100, description="Registros por página"),
# Filtros
user_id: Optional[uuid.UUID] = Query(None, description="Filtrar por usuario"),
action: Optional[str] = Query(None, description="Filtrar por acción"),
resource_type: Optional[str] = Query(None, description="Filtrar por tipo de recurso"),
resource_id: Optional[uuid.UUID] = Query(None, description="Filtrar por ID de recurso"),
date_from: Optional[datetime] = Query(None, description="Fecha desde"),
date_to: Optional[datetime] = Query(None, description="Fecha hasta"),
search: Optional[str] = Query(None, description="Búsqueda en acción o email"),
# Dependencies
current_user: User = Depends(require_auditor_role),
current_tenant: Tenant = Depends(get_current_tenant),
db: AsyncSession = Depends(get_db)
):
"""
Obtener logs de auditoría con filtros y paginación.
**Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR
**Filtros disponibles**:
- `user_id`: Acciones de un usuario específico
- `action`: Tipo de acción (ej: "ticket.create")
- `resource_type`: Tipo de recurso (ej: "ticket")
- `resource_id`: ID de recurso específico
- `date_from`, `date_to`: Rango de fechas
- `search`: Búsqueda en acciones
**Retorna**: Lista paginada de audit logs
"""
logger.info(
"Fetching audit logs",
user_id=str(current_user.id),
tenant_id=str(current_tenant.id),
filters={
"user_id": str(user_id) if user_id else None,
"action": action,
"resource_type": resource_type,
"page": page
}
)
# Query base - solo logs del tenant actual
# Usar selectinload para cargar la relación user (eager loading para async)
query = (
select(AuditLog)
.where(AuditLog.tenant_id == current_tenant.id)
.options(selectinload(AuditLog.user))
)
# Aplicar filtros
if user_id:
query = query.where(AuditLog.user_id == user_id)
if action:
query = query.where(AuditLog.action == action)
if resource_type:
query = query.where(AuditLog.resource_type == resource_type)
if resource_id:
query = query.where(AuditLog.resource_id == resource_id)
if date_from:
query = query.where(AuditLog.created_at >= date_from)
if date_to:
# Agregar 1 día para incluir todo el día
date_to_end = date_to + timedelta(days=1)
query = query.where(AuditLog.created_at < date_to_end)
if search:
# Búsqueda en action
search_filter = AuditLog.action.ilike(f"%{search}%")
query = query.where(search_filter)
# Ordenar por fecha descendente (más recientes primero)
query = query.order_by(desc(AuditLog.created_at))
# Contar total antes de paginar
count_query = select(func.count()).select_from(query.subquery())
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Aplicar paginación
offset = (page - 1) * per_page
query = query.offset(offset).limit(per_page)
# Ejecutar query
result = await db.execute(query)
logs = result.scalars().all()
# Calcular total de páginas
total_pages = (total + per_page - 1) // per_page
# Convertir a response schema (agregar info del usuario)
logs_response = []
for log in logs:
log_dict = {
"id": log.id,
"tenant_id": log.tenant_id,
"user_id": log.user_id,
"action": log.action,
"resource_type": log.resource_type,
"resource_id": log.resource_id,
"ip_address": str(log.ip_address) if log.ip_address else None,
"user_agent": log.user_agent,
"correlation_id": log.correlation_id,
"old_values": log.old_values,
"new_values": log.new_values,
"metadata": log.extra_metadata,
"created_at": log.created_at,
"action_display": log.action_display,
"user_email": None,
"user_name": None
}
# Agregar info del usuario si existe
if log.user:
log_dict["user_email"] = log.user.email
log_dict["user_name"] = log.user.full_name
log_dict["user_role"] = log.user.role.value if hasattr(log.user.role, 'value') else str(log.user.role)
logs_response.append(AuditLogResponse(**log_dict))
return AuditLogListResponse(
logs=logs_response,
total=total,
page=page,
per_page=per_page,
total_pages=total_pages
)
@router.get("/stats", response_model=AuditLogStats)
async def get_audit_stats(
current_user: User = Depends(require_auditor_role),
current_tenant: Tenant = Depends(get_current_tenant),
db: AsyncSession = Depends(get_db)
):
"""
Obtener estadísticas de auditoría del tenant.
**Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR
**Retorna**: Estadísticas de actividad
"""
logger.info(
"Fetching audit stats",
user_id=str(current_user.id),
tenant_id=str(current_tenant.id)
)
now = datetime.utcnow()
# Total de acciones
total_query = select(func.count()).select_from(AuditLog).where(
AuditLog.tenant_id == current_tenant.id
)
total_result = await db.execute(total_query)
total_actions = total_result.scalar() or 0
# Acciones hoy (últimas 24 horas)
today_start = now - timedelta(days=1)
today_query = select(func.count()).select_from(AuditLog).where(
and_(
AuditLog.tenant_id == current_tenant.id,
AuditLog.created_at >= today_start
)
)
today_result = await db.execute(today_query)
actions_today = today_result.scalar() or 0
# Acciones esta semana (últimos 7 días)
week_start = now - timedelta(days=7)
week_query = select(func.count()).select_from(AuditLog).where(
and_(
AuditLog.tenant_id == current_tenant.id,
AuditLog.created_at >= week_start
)
)
week_result = await db.execute(week_query)
actions_this_week = week_result.scalar() or 0
# Top 5 acciones más frecuentes
top_actions_query = select(
AuditLog.action,
func.count(AuditLog.id).label('count')
).where(
AuditLog.tenant_id == current_tenant.id
).group_by(
AuditLog.action
).order_by(
desc('count')
).limit(5)
top_actions_result = await db.execute(top_actions_query)
top_actions = {row.action: row.count for row in top_actions_result}
# Acciones por tipo de recurso
by_resource_query = select(
AuditLog.resource_type,
func.count(AuditLog.id).label('count')
).where(
AuditLog.tenant_id == current_tenant.id
).group_by(
AuditLog.resource_type
).order_by(
desc('count')
)
by_resource_result = await db.execute(by_resource_query)
by_resource_type = {row.resource_type: row.count for row in by_resource_result}
# Top usuarios (necesitamos hacer join - simplificado por ahora)
# En producción podrías hacer un join con users para obtener nombres
top_users = {} # Placeholder - implementar con join si es necesario
return AuditLogStats(
total_actions=total_actions,
actions_today=actions_today,
actions_this_week=actions_this_week,
top_actions=top_actions,
top_users=top_users,
by_resource_type=by_resource_type
)
@router.get("/{log_id}", response_model=AuditLogResponse)
async def get_audit_log_detail(
log_id: uuid.UUID,
current_user: User = Depends(require_auditor_role),
current_tenant: Tenant = Depends(get_current_tenant),
db: AsyncSession = Depends(get_db)
):
"""
Obtener detalle de un audit log específico.
**Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR
**Retorna**: Detalle completo del audit log
"""
# Buscar el log
query = select(AuditLog).where(
and_(
AuditLog.id == log_id,
AuditLog.tenant_id == current_tenant.id
)
)
result = await db.execute(query)
log = result.scalar_one_or_none()
if not log:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Audit log {log_id} no encontrado"
)
# Convertir a response
log_dict = {
"id": log.id,
"tenant_id": log.tenant_id,
"user_id": log.user_id,
"action": log.action,
"resource_type": log.resource_type,
"resource_id": log.resource_id,
"ip_address": str(log.ip_address) if log.ip_address else None,
"user_agent": log.user_agent,
"correlation_id": log.correlation_id,
"old_values": log.old_values,
"new_values": log.new_values,
"metadata": log.extra_metadata,
"created_at": log.created_at,
"action_display": log.action_display,
"user_email": None,
"user_name": None
}
if log.user:
log_dict["user_email"] = log.user.email
log_dict["user_name"] = log.user.full_name
return AuditLogResponse(**log_dict)

View File

@@ -4,14 +4,13 @@ Authentication Endpoints - ServiceManagerWeb
Endpoints para autenticación y autorización
"""
from fastapi import APIRouter, HTTPException, status, Depends, Request
from fastapi import APIRouter, HTTPException, status, Depends
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from pydantic import BaseModel, EmailStr
from typing import Optional
from datetime import datetime # Para actualizar last_login
import structlog
from app.core.database import get_db
@@ -19,8 +18,6 @@ from app.core.security import security
from app.core.config import get_settings
from app.models.user import User
from app.models.tenant import Tenant
from app.services.audit_service import AuditService # Servicio de auditoría
from app.services.token_service import TokenService # Servicio de tokens
router = APIRouter()
logger = structlog.get_logger(__name__)
@@ -70,17 +67,13 @@ class TokenResponse(BaseModel):
@router.post("/login", response_model=LoginResponse)
async def login(
login_data: LoginRequest,
request: Request, # Agregar Request para capturar IP y user agent
db: AsyncSession = Depends(get_db)
):
"""
Authenticate user and return access/refresh tokens.
**Auditoría**: Registra login exitoso y fallido en audit_logs
Args:
login_data: Login credentials
request: FastAPI Request (para auditoría)
db: Database session
Returns:
@@ -106,24 +99,6 @@ async def login(
"Login failed - invalid credentials",
email=login_data.email
)
# 🔍 AUDITORÍA: Registrar intento fallido
if user: # Solo si el usuario existe (password incorrecto)
await AuditService.log(
db=db,
tenant_id=user.tenant_id,
user_id=None, # NULL porque aún no autenticado
action="user.login_failed",
resource_type="user",
resource_id=user.id,
metadata={
"email": login_data.email,
"reason": "invalid_password"
},
request=request
)
await db.commit() # Commit del audit log
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Credenciales inválidas"
@@ -135,31 +110,11 @@ async def login(
"Login failed - user inactive",
email=login_data.email
)
# 🔍 AUDITORÍA: Registrar intento con usuario inactivo
await AuditService.log(
db=db,
tenant_id=user.tenant_id,
user_id=None,
action="user.login_failed",
resource_type="user",
resource_id=user.id,
metadata={
"email": login_data.email,
"reason": "user_inactive"
},
request=request
)
await db.commit()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Usuario inactivo"
)
# Actualizar último login
user.last_login = datetime.utcnow()
# Create tokens
token_data = {
"sub": str(user.id),
@@ -171,62 +126,6 @@ async def login(
access_token = security.create_access_token(token_data)
refresh_token = security.create_refresh_token(token_data)
# Extraer información del dispositivo para rastreo
user_agent = request.headers.get("user-agent")
client_ip = request.client.host if request.client else None
# device_id: El frontend puede enviarlo en el futuro como header
device_id = request.headers.get("x-device-id") # Opcional
# device_name: Simplificado del user-agent (ej: "Chrome en Windows")
device_name = None
if user_agent:
# Parseo básico para obtener un nombre legible
if "Chrome" in user_agent:
device_name = "Chrome"
elif "Firefox" in user_agent:
device_name = "Firefox"
elif "Safari" in user_agent:
device_name = "Safari"
elif "Edge" in user_agent:
device_name = "Edge"
else:
device_name = "Unknown Browser"
# Agregar OS
if "Windows" in user_agent:
device_name += " en Windows"
elif "Macintosh" in user_agent or "Mac OS" in user_agent:
device_name += " en macOS"
elif "Linux" in user_agent:
device_name += " en Linux"
elif "Android" in user_agent:
device_name += " en Android"
elif "iPhone" in user_agent or "iPad" in user_agent:
device_name += " en iOS"
# Persistir refresh token en BD con tracking completo
await TokenService.create_refresh_token(
db=db,
user=user,
refresh_token=refresh_token,
device_id=device_id,
device_name=device_name,
user_agent=user_agent,
ip_address=client_ip
)
# 🔍 AUDITORÍA: Registrar login exitoso
await AuditService.log_login(
db=db,
user=user,
request=request,
success=True
)
# Commit de todos los cambios (last_login + refresh token + audit log)
await db.commit()
logger.info(
"Login successful",
email=login_data.email,
@@ -260,9 +159,6 @@ async def refresh_token(
"""
Refresh access token using refresh token.
Verifica el JWT, valida en BD que no esté revocado/expirado,
actualiza estadísticas de uso y genera nuevo access token.
Args:
refresh_data: Refresh token data
db: Database session
@@ -275,26 +171,18 @@ async def refresh_token(
"""
logger.info("Token refresh attempt")
# 1. Verify refresh token JWT signature
# Verify refresh token
payload = security.verify_token(refresh_data.refresh_token)
if not payload or payload.get("type") != "refresh":
logger.warning("Token refresh failed - invalid JWT")
logger.warning("Token refresh failed - invalid token")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token"
)
# 2. Verificar que el token exista en BD y no esté revocado/expirado
# También actualiza last_used_at y usage_count automáticamente
db_token = await TokenService.verify_refresh_token(db, refresh_data.refresh_token)
if not db_token:
logger.warning("Token refresh failed - token not found, revoked or expired")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or revoked refresh token"
)
# TODO: Check if refresh token exists in database and is not revoked
# 3. Create new access token
# Create new access token
token_data = {
"sub": payload["sub"],
"email": payload["email"],
@@ -304,15 +192,7 @@ async def refresh_token(
access_token = security.create_access_token(token_data)
# 4. Commit actualización de estadísticas de uso
await db.commit()
logger.info(
"Token refresh successful",
user_id=payload["sub"],
token_id=str(db_token.id),
usage_count=db_token.usage_count
)
logger.info("Token refresh successful", user_id=payload["sub"])
return TokenResponse(
access_token=access_token,
@@ -326,17 +206,14 @@ async def logout(
db: AsyncSession = Depends(get_db)
):
"""
Logout user and revoke all refresh tokens.
Revoca todos los tokens del usuario (logout en todos los dispositivos)
y registra quién ejecutó la revocación para auditoría.
Logout user and revoke refresh token.
Args:
token: Access token
db: Database session
Returns:
Success message with count of revoked tokens
Success message
"""
logger.info("Logout attempt")
@@ -348,31 +225,11 @@ async def logout(
detail="Invalid token"
)
# Extraer user_id del token
user_id_str = payload.get("sub")
if not user_id_str:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload"
)
# TODO: Revoke refresh token in database
user_id = uuid.UUID(user_id_str)
logger.info("Logout successful", user_id=payload["sub"])
# Revocar todos los tokens del usuario con auditoría de quién lo revocó
count = await TokenService.revoke_all_user_tokens(
db=db,
user_id=user_id,
revoked_by_user_id=user_id # El usuario se revoca a sí mismo
)
await db.commit()
logger.info("Logout successful", user_id=str(user_id), tokens_revoked=count)
return {
"message": "Successfully logged out from all devices",
"tokens_revoked": count
}
return {"message": "Successfully logged out"}
@router.get("/me")

View File

@@ -82,25 +82,17 @@ async def read_categories(
async def create_category(
category: CategoryCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user)
current_user: User = Depends(deps.get_current_user) # ✅ CORREGIDO: Type hint
):
"""
Crear nueva categoría en el tenant del usuario actual.
**Permisos**: Solo ADMIN y SUPPORT_MANAGER pueden crear categorías.
✅ Implementa multi-tenancy: asigna automáticamente tenant_id del usuario.
"""
# Verificar permisos
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No tienes permisos para crear categorías"
)
# Asignar tenant_id del usuario actual
# ✅ CORREGIDO: Asignar tenant_id del usuario actual
db_category = Category(
**category.model_dump(),
tenant_id=current_user.tenant_id
tenant_id=current_user.tenant_id # ✅ Multi-tenancy automático
)
db.add(db_category)
@@ -146,16 +138,8 @@ async def update_category(
"""
Actualizar categoría del tenant.
**Permisos**: Solo ADMIN y SUPPORT_MANAGER pueden actualizar categorías.
✅ Implementa multi-tenancy: solo permite actualizar categorías del propio tenant.
"""
# Verificar permisos
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No tienes permisos para actualizar categorías"
)
query = select(Category).where(
Category.id == category_id,
Category.tenant_id == current_user.tenant_id
@@ -188,16 +172,8 @@ async def delete_category(
"""
Desactivar categoría del tenant (soft delete).
**Permisos**: Solo ADMIN y SUPPORT_MANAGER pueden desactivar categorías.
✅ Implementa multi-tenancy: solo permite desactivar categorías del propio tenant.
"""
# Verificar permisos
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No tienes permisos para desactivar categorías"
)
query = select(Category).where(
Category.id == category_id,
Category.tenant_id == current_user.tenant_id

View File

@@ -50,49 +50,11 @@ async def get_current_client_profile(
profile = result.scalar_one_or_none()
if not profile:
# Si no existe, devolver un perfil vacío con solo tenant_id
# No crear en base de datos hasta que el usuario guarde
return ClientProfileResponse(
id=None,
tenant_id=current_tenant.id,
business_name=None,
commercial_name=None,
client_code=None,
client_type=None,
rfc=None,
tax_id=None,
country=None,
state=None,
city=None,
address=None,
external_number=None,
internal_number=None,
postal_code=None,
neighborhood=None,
main_phone=None,
secondary_phone=None,
direct_phone=None,
phone_extension=None,
fax=None,
business_hours=None,
website=None,
main_email=None,
billing_email=None,
advertising_medium=None,
nationality=None,
logo_url=None,
company_representative=None,
legal_representative=None,
credit_limit=None,
payment_terms=None,
preferred_currency="MXN",
send_to_billing=False,
is_active_client=True,
is_prospect=False,
notes=None,
created_at=None,
updated_at=None
)
# Si no existe, crear uno vacío
profile = ClientProfile(tenant_id=current_tenant.id)
db.add(profile)
await db.commit()
await db.refresh(profile)
return profile

View File

@@ -70,25 +70,17 @@ async def read_systems(
async def create_system(
system: SystemCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user)
current_user: User = Depends(deps.get_current_user) # ✅ CORREGIDO: Type hint
):
"""
Crear nuevo sistema en el tenant del usuario actual.
**Permisos**: Solo ADMIN y SUPPORT_MANAGER pueden crear sistemas.
✅ Implementa multi-tenancy: asigna automáticamente tenant_id del usuario.
"""
# Verificar permisos
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No tienes permisos para crear sistemas"
)
# Asignar tenant_id del usuario actual
# ✅ CORREGIDO: Asignar tenant_id del usuario actual
db_system = System(
**system.model_dump(),
tenant_id=current_user.tenant_id
tenant_id=current_user.tenant_id # ✅ Multi-tenancy automático
)
db.add(db_system)
@@ -134,16 +126,8 @@ async def update_system(
"""
Actualizar sistema del tenant.
**Permisos**: Solo ADMIN y SUPPORT_MANAGER pueden actualizar sistemas.
✅ Implementa multi-tenancy: solo permite actualizar sistemas del propio tenant.
"""
# Verificar permisos
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No tienes permisos para actualizar sistemas"
)
query = select(System).where(
System.id == system_id,
System.tenant_id == current_user.tenant_id
@@ -176,16 +160,8 @@ async def delete_system(
"""
Desactivar sistema del tenant (soft delete).
**Permisos**: Solo ADMIN y SUPPORT_MANAGER pueden desactivar sistemas.
✅ Implementa multi-tenancy: solo permite desactivar sistemas del propio tenant.
"""
# Verificar permisos
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No tienes permisos para desactivar sistemas"
)
query = select(System).where(
System.id == system_id,
System.tenant_id == current_user.tenant_id

View File

@@ -78,188 +78,85 @@ async def create_ticket(
"""
Crear un nuevo ticket
"""
# Retry logic para evitar race conditions en generación de ticket_number
max_retries = 3
last_error = None
for attempt in range(max_retries):
try:
# Generar número de ticket único basado en el máximo existente
result = await db.execute(
select(Ticket.ticket_number)
.where(Ticket.tenant_id == current_user.tenant_id)
.order_by(Ticket.ticket_number.desc())
.limit(1)
)
last_ticket_number = result.scalar_one_or_none()
if last_ticket_number:
# Extraer el número del formato TK-XXXXXX
last_number = int(last_ticket_number.split('-')[1])
next_number = last_number + 1
else:
next_number = 1
ticket_number = f"TK-{next_number: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
# Validar categoría
if category_uuid:
category = await db.get(Category, category_uuid)
if not category:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"La categoría con ID {ticket.category_id} no existe."
)
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}"
# 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 sistema
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,
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)
# ✅ Éxito - retornar ticket creado
return {
"id": str(db_ticket.id),
"ticket_number": db_ticket.ticket_number,
"subject": db_ticket.subject,
"title": 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,
"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 HTTPException:
# Re-lanzar HTTPExceptions directamente
await db.rollback()
raise
except Exception as e:
await db.rollback()
last_error = e
# Si es un error de llave duplicada, reintentar
if "duplicate key" in str(e).lower() and "ticket_number" in str(e).lower():
if attempt < max_retries - 1:
continue # Reintentar
# Para cualquier otro error, fallar inmediatamente
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Error creating ticket: {str(e)}"
)
# Si llegamos aquí después de todos los reintentos
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"No se pudo crear el ticket después de {max_retries} intentos: {str(last_error)}"
)
@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
Roles ADMIN/SUPPORT_MANAGER/AGENT: Ven todos los tickets del tenant
Roles CLIENT_USER/CLIENT_ADMIN: Solo ven sus propios tickets
"""
# Construir query base filtrado por tenant
query = select(Ticket).where(
Ticket.tenant_id == current_user.tenant_id
)
# Si es cliente, solo puede ver sus propios tickets
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
query = query.where(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,
"title": 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
# 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,
"title": 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
}
for t in tickets
]
@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,
priority_filter: Optional[str] = None,
tenant_id_filter: Optional[str] = None,
category_filter: Optional[str] = None,
assigned_to_filter: Optional[str] = None,
search: Optional[str] = None,
except ValueError as e:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid UUID format: {str(e)}"
)
date_from: Optional[str] = None,
date_to: Optional[str] = None,
db: AsyncSession = Depends(get_db),
@@ -414,8 +311,6 @@ async def get_ticket(
):
"""
Obtener un ticket específico
Roles ADMIN/SUPPORT_MANAGER/AGENT: Pueden ver todos los tickets del tenant
Roles CLIENT_USER/CLIENT_ADMIN: Solo pueden ver sus propios tickets
"""
try:
ticket_uuid = uuid.UUID(ticket_id)
@@ -425,16 +320,12 @@ async def get_ticket(
detail="Invalid ticket ID format"
)
# Construir query basado en el rol del usuario
query = select(Ticket).where(
Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_id
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_by == current_user.id
)
# Si es cliente, solo puede ver sus propios tickets
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
query = query.where(Ticket.created_by == current_user.id)
result = await db.execute(query)
ticket = result.scalars().first()
@@ -471,8 +362,6 @@ async def update_ticket(
):
"""
Actualizar un ticket
Roles ADMIN/SUPPORT_MANAGER/AGENT: Pueden actualizar cualquier ticket del tenant
Roles CLIENT_USER/CLIENT_ADMIN: Solo pueden actualizar sus propios tickets
"""
try:
ticket_uuid = uuid.UUID(ticket_id)
@@ -482,16 +371,12 @@ async def update_ticket(
detail="Invalid ticket ID format"
)
# Construir query basado en el rol del usuario
query = select(Ticket).where(
Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_id
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_by == current_user.id
)
# Si es cliente, solo puede actualizar sus propios tickets
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
query = query.where(Ticket.created_by == current_user.id)
result = await db.execute(query)
db_ticket = result.scalars().first()
@@ -553,8 +438,6 @@ async def close_ticket(
):
"""
Cerrar un ticket
Roles ADMIN/SUPPORT_MANAGER/AGENT: Pueden cerrar cualquier ticket del tenant
Roles CLIENT_USER/CLIENT_ADMIN: Solo pueden cerrar sus propios tickets
"""
try:
ticket_uuid = uuid.UUID(ticket_id)
@@ -564,16 +447,12 @@ async def close_ticket(
detail="Invalid ticket ID format"
)
# Construir query basado en el rol del usuario
query = select(Ticket).where(
Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_id
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_by == current_user.id
)
# Si es cliente, solo puede cerrar sus propios tickets
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
query = query.where(Ticket.created_by == current_user.id)
result = await db.execute(query)
db_ticket = result.scalars().first()

View File

@@ -6,7 +6,7 @@ Router principal para la API v1
from fastapi import APIRouter
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile
api_router = APIRouter()
@@ -23,13 +23,6 @@ api_router.include_router(
tags=["authentication"]
)
# Audit routes (antes de otros para logging)
api_router.include_router(
audit.router,
prefix="/audit",
tags=["audit"]
)
api_router.include_router(
tenants.router,
prefix="/tenants",