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

26
.gitignore vendored
View File

@@ -30,29 +30,3 @@ docker-compose.override.yml
# Uploads # Uploads
uploads/ uploads/
# Test Coverage
htmlcov/
.coverage
*.cover
.pytest_cache/
# Backups
backups/
*.backup
*.bak
# Temporary files
temp_*.txt
temp_*.py
*.tmp
*.swp
*~
# Debug/Test scripts (usar scripts/ en su lugar)
check_*.py
fix_*.py
list_*.py
add_*.py
set_*.py
test_*.ps1

View File

@@ -1,49 +0,0 @@
# CHANGELOG - ServiceManagerWeb
## [1.5.1] - 2026-02-12
### 🔒 Seguridad y Control de Acceso
- **Control de acceso basado en roles (RBAC)** completamente implementado
- ADMIN/AGENT/SUPPORT_MANAGER: Acceso a todos los tickets del tenant
- CLIENT_USER/CLIENT_ADMIN: Acceso solo a tickets propios
- Protección de endpoints de Categories y Systems
- Solo ADMIN/SUPPORT_MANAGER pueden crear/modificar/eliminar
- Otros roles tienen acceso de solo lectura
- Header `X-Tenant-ID` agregado en todas las peticiones del frontend-internal
- Validación de multi-tenancy reforzada en todos los endpoints
### 🐛 Correcciones de Bugs
- **Fix crítico**: Generación de números de ticket duplicados
- Implementado retry logic con 3 intentos
- Búsqueda del número máximo existente en lugar de simple contador
- Manejo específico de errores de llave duplicada
- Corrección de filtros en endpoint `GET /tickets`
- Staff interno ahora ve todos los tickets del tenant
- Clientes solo ven sus propios tickets
### ✨ Mejoras
- Documentación mejorada en docstrings de endpoints
- Mensajes de error más descriptivos
- Mejor manejo de excepciones en creación de tickets
### 📚 Documentación
- Actualizado README con roles y permisos
- Agregados comentarios explicativos en código crítico
- Scripts de prueba para validar RBAC
### 🔧 Tech Stack
- Backend: Python FastAPI + SQLAlchemy 2.0 (async)
- Frontend: SvelteKit + TypeScript
- Base de datos: PostgreSQL
- Cache/Queue: Redis + Celery
---
## [0.1.0] - 2026-01-01
### 🎉 Versión Inicial
- Sistema multi-tenant de Mesa de Ayuda
- Autenticación JWT con refresh tokens
- Gestión de tickets, categorías y sistemas
- Dos frontends: cliente e interno
- Docker Compose para desarrollo local

View File

@@ -94,40 +94,6 @@ docker-compose ps
- API Docs: http://localhost:8000/docs - API Docs: http://localhost:8000/docs
- Adminer (DB): http://localhost:8080 - Adminer (DB): http://localhost:8080
## Utilidades Administrativas
Para gestión y debugging de la base de datos, usa el script consolidado:
```bash
# Ver todos los comandos disponibles
python scripts/db_utils.py --help
# Listar todos los usuarios
python scripts/db_utils.py list-users
# Verificar información de un usuario
python scripts/db_utils.py check-user admin@example.com
# Resetear contraseña de un usuario
python scripts/db_utils.py reset-password admin@example.com --password admin123
# Listar últimos 10 tickets
python scripts/db_utils.py list-tickets --limit 10
# Verificar información de un ticket específico
python scripts/db_utils.py check-ticket <TICKET_ID>
# Filtrar por tenant
python scripts/db_utils.py list-users --tenant-id <TENANT_UUID>
python scripts/db_utils.py list-tickets --tenant-id <TENANT_UUID>
```
**💡 Alternativas para debugging:**
- **PostgreSQL directo**: Conectarte con pgAdmin, DBeaver o `psql`
- **Python Shell**: `python -m asyncio` desde el directorio backend
- **Tests**: Crear tests específicos en `backend/tests/`
- **API Docs**: Usar Swagger UI en http://localhost:8000/docs
## Scripts de Desarrollo ## Scripts de Desarrollo
```bash ```bash

View File

@@ -1,254 +0,0 @@
# Release Notes - ServiceManagerWeb v1.5.1
**Fecha**: 12 de Febrero, 2026
**Rama**: main
**Commit**: 771b6eb
---
## 📦 Información de la Versión
**Versión Anterior**: v1.4.1.4
**Versión Actual**: v1.5.1
**Tipo de Release**: Minor (Funcionalidades + Correcciones Críticas)
---
## 🔒 Seguridad y Control de Acceso
### Control de Acceso Basado en Roles (RBAC)
#### Implementación Completa
- **Staff Interno** (ADMIN, AGENT, SUPPORT_MANAGER)
- ✅ Acceso a todos los tickets del tenant
- ✅ Puede ver/modificar cualquier ticket
- ✅ Control total sobre recursos compartidos
- **Clientes** (CLIENT_USER, CLIENT_ADMIN)
- ✅ Acceso solo a sus propios tickets
- ✅ No pueden ver tickets de otros clientes del mismo tenant
- ✅ Restricciones adecuadas implementadas
#### Endpoints Protegidos
**Categories** (`/api/v1/categories`)
- GET: Todos los roles (lectura)
- POST/PUT/DELETE: Solo ADMIN y SUPPORT_MANAGER
**Systems** (`/api/v1/systems`)
- GET: Todos los roles (lectura)
- POST/PUT/DELETE: Solo ADMIN y SUPPORT_MANAGER
**Tickets** (`/api/v1/tickets`)
- GET (listado): Filtrado según rol
- GET (detalle): Validación de permisos por rol
- POST: Todos (según su alcance)
- PATCH/DELETE: Validación por rol y propiedad
### Multi-Tenancy Reforzado
- ✅ Header `X-Tenant-ID` agregado en frontend-internal
- ✅ Validación de tenant en todos los endpoints
- ✅ Aislamiento estricto de datos entre tenants
- ✅ Prevención de acceso cruzado entre organizaciones
---
## 🐛 Correcciones Críticas
### Fix: Números de Ticket Duplicados
**Problema Original**:
- Generación de números con simple contador
- Race conditions en creación simultánea
- Violación de constraint unique `uq_tickets_tenant_number`
**Solución Implementada**:
```python
# Retry logic con 3 intentos
# Búsqueda del MAX número existente
# Manejo específico de errores de llave duplicada
for attempt in range(max_retries):
last_number = get_max_ticket_number()
next_number = last_number + 1
try:
create_ticket(next_number)
break
except DuplicateKeyError:
if attempt < max_retries - 1:
continue # Reintentar
```
**Resultado**:
- ✅ 0% fallos por duplicados
- ✅ Manejo robusto de alta concurrencia
- ✅ Recuperación automática de errores
---
## ✨ Mejoras de Código
### Backend
1. **Validación Robusta**
- Type hints completos en todos los endpoints
- Validación de permisos antes de queries
- Mensajes de error descriptivos
2. **Manejo de Excepciones**
- Try/catch específicos por tipo de error
- Rollback automático en fallos
- Logging estructurado
3. **Documentación**
- Docstrings actualizados con información de permisos
- Comentarios explicativos en lógica compleja
- Ejemplos de uso en código
### Frontend
1. **API Client**
- Header `X-Tenant-ID` en todas las peticiones
- Manejo consistente de errores
- Type safety mejorado
---
## 📚 Documentación
### Archivos Nuevos
1. **CHANGELOG.md**
- Historial completo de versiones
- Formato estándar Keep a Changelog
- Categorización por tipo de cambio
2. **test_rbac.py**
- Script de validación de permisos
- Tests automatizados de RBAC
- Verificación de aislamiento multi-tenant
### Archivos Actualizados
- `backend/pyproject.toml` → v1.5.1
- `frontend-internal/package.json` → v1.5.1
- `frontend-client/package.json` → v1.5.1
- Endpoints: tickets.py, categories.py, systems.py
---
## 🧪 Testing
### Scripts de Validación
```bash
# Test de control de acceso
python backend/test_rbac.py
# Test de creación de tickets
python backend/test_ticket_numbers.py
# Verificar usuarios y roles
python backend/list_all_users.py
```
### Cobertura
- ✅ RBAC implementado y validado
- ✅ Multi-tenancy verificado
- ✅ Generación de números probada
- ✅ Endpoints protegidos confirmados
---
## 💾 Backup
**Ubicación**: `../backups/ServiceManagerWeb_v1.5.1_backup_20260212_085751`
**Contenido**:
- Código fuente completo
- Configuraciones
- Scripts y utilidades
- Documentación
**Exclusiones**:
- node_modules/
- .git/
- __pycache__/
- logs/
- uploads/
---
## 🚀 Despliegue
### Para Subir al Repositorio Remoto
```bash
# Subir commit
git push origin main
# Subir tag
git push origin v1.5.1
```
### Para Desplegar en Producción
1. Pull de la versión
```bash
git fetch --tags
git checkout v1.5.1
```
2. Actualizar dependencias
```bash
docker-compose pull
docker-compose build
```
3. Reiniciar servicios
```bash
docker-compose down
docker-compose up -d
```
4. Verificar estado
```bash
docker-compose ps
curl http://localhost:8000/health
```
---
## ⚠️ Breaking Changes
**Ninguno**: Esta versión es completamente compatible con v1.4.x
---
## 📊 Estadísticas
- **Archivos modificados**: 8
- **Líneas agregadas**: 336
- **Líneas eliminadas**: 101
- **Commits**: 1
- **Tags**: 1
---
## 👥 Contribuidores
- **Autor**: icamarillo <icamarillo@aduanasoft.com.mx>
- **Fecha**: Thu Feb 12 09:00:16 2026 -0700
---
## 🔗 Referencias
- **Commit**: 771b6eba30e183fafbff6d2f074a41c378170730
- **Tag**: v1.5.1
- **Rama**: main
- **Changelog**: CHANGELOG.md
---
_Generado automáticamente el 12 de Febrero, 2026_

View File

@@ -1,109 +0,0 @@
"""
Audit Schemas - ServiceManagerWeb
Schemas Pydantic para endpoints de auditoría
"""
from pydantic import BaseModel, Field, UUID4
from typing import Optional, Dict, Any
from datetime import datetime
class AuditLogBase(BaseModel):
"""Schema base para audit logs."""
action: str = Field(..., description="Acción realizada (ej: ticket.create)")
resource_type: str = Field(..., description="Tipo de recurso (ticket, user, etc.)")
resource_id: Optional[UUID4] = Field(None, description="ID del recurso afectado")
extra_metadata: Optional[Dict[str, Any]] = Field(None, description="Metadata adicional", alias="metadata")
class AuditLogResponse(AuditLogBase):
"""
Schema de respuesta para audit logs.
Incluye toda la información del log con datos del usuario.
"""
id: UUID4
tenant_id: UUID4
user_id: Optional[UUID4]
# Información del usuario (si existe)
user_email: Optional[str] = None
user_name: Optional[str] = None
user_role: Optional[str] = None
# Contexto de la acción
ip_address: Optional[str]
user_agent: Optional[str]
correlation_id: Optional[UUID4]
# Cambios realizados
old_values: Optional[Dict[str, Any]]
new_values: Optional[Dict[str, Any]]
# Timestamp
created_at: datetime
# Display friendly
action_display: str = Field(description="Acción en formato amigable")
class Config:
from_attributes = True
class AuditLogFilters(BaseModel):
"""
Filtros para consulta de audit logs.
Permite filtrar por múltiples criterios.
"""
# Paginación
page: int = Field(default=1, ge=1, description="Número de página")
per_page: int = Field(default=50, ge=1, le=100, description="Elementos por página")
# Filtros
user_id: Optional[UUID4] = Field(None, description="Filtrar por usuario")
action: Optional[str] = Field(None, description="Filtrar por acción específica")
resource_type: Optional[str] = Field(None, description="Filtrar por tipo de recurso")
resource_id: Optional[UUID4] = Field(None, description="Filtrar por ID de recurso")
# Rango de fechas
date_from: Optional[datetime] = Field(None, description="Fecha inicio (ISO 8601)")
date_to: Optional[datetime] = Field(None, description="Fecha fin (ISO 8601)")
# Búsqueda
search: Optional[str] = Field(None, description="Búsqueda en acciones o recursos")
class AuditLogStats(BaseModel):
"""
Estadísticas de auditoría.
Resumen de actividad del sistema.
"""
total_actions: int = Field(description="Total de acciones registradas")
actions_today: int = Field(description="Acciones en las últimas 24 horas")
actions_this_week: int = Field(description="Acciones en los últimos 7 días")
# Top acciones
top_actions: Dict[str, int] = Field(description="Acciones más frecuentes")
# Top usuarios
top_users: Dict[str, int] = Field(description="Usuarios más activos")
# Actividad por tipo de recurso
by_resource_type: Dict[str, int] = Field(description="Acciones por tipo de recurso")
class AuditLogListResponse(BaseModel):
"""
Respuesta paginada de audit logs.
"""
logs: list[AuditLogResponse]
total: int = Field(description="Total de registros")
page: int = Field(description="Página actual")
per_page: int = Field(description="Registros por página")
total_pages: int = Field(description="Total de páginas")
class Config:
from_attributes = True

View File

@@ -133,10 +133,10 @@ class ClientProfileUpdate(ClientProfileBase):
class ClientProfileResponse(ClientProfileBase): class ClientProfileResponse(ClientProfileBase):
"""Schema de respuesta para ClientProfile.""" """Schema de respuesta para ClientProfile."""
id: Optional[uuid.UUID] = None id: uuid.UUID
tenant_id: uuid.UUID tenant_id: uuid.UUID
created_at: Optional[datetime] = None created_at: datetime
updated_at: Optional[datetime] = None updated_at: datetime
class Config: class Config:
from_attributes = True from_attributes = True

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 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 fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr
from typing import Optional from typing import Optional
from datetime import datetime # Para actualizar last_login
import structlog import structlog
from app.core.database import get_db 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.core.config import get_settings
from app.models.user import User from app.models.user import User
from app.models.tenant import Tenant 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() router = APIRouter()
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
@@ -70,17 +67,13 @@ class TokenResponse(BaseModel):
@router.post("/login", response_model=LoginResponse) @router.post("/login", response_model=LoginResponse)
async def login( async def login(
login_data: LoginRequest, login_data: LoginRequest,
request: Request, # Agregar Request para capturar IP y user agent
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
""" """
Authenticate user and return access/refresh tokens. Authenticate user and return access/refresh tokens.
**Auditoría**: Registra login exitoso y fallido en audit_logs
Args: Args:
login_data: Login credentials login_data: Login credentials
request: FastAPI Request (para auditoría)
db: Database session db: Database session
Returns: Returns:
@@ -106,24 +99,6 @@ async def login(
"Login failed - invalid credentials", "Login failed - invalid credentials",
email=login_data.email 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( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Credenciales inválidas" detail="Credenciales inválidas"
@@ -135,31 +110,11 @@ async def login(
"Login failed - user inactive", "Login failed - user inactive",
email=login_data.email 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( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Usuario inactivo" detail="Usuario inactivo"
) )
# Actualizar último login
user.last_login = datetime.utcnow()
# Create tokens # Create tokens
token_data = { token_data = {
"sub": str(user.id), "sub": str(user.id),
@@ -171,62 +126,6 @@ async def login(
access_token = security.create_access_token(token_data) access_token = security.create_access_token(token_data)
refresh_token = security.create_refresh_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( logger.info(
"Login successful", "Login successful",
email=login_data.email, email=login_data.email,
@@ -260,9 +159,6 @@ async def refresh_token(
""" """
Refresh access token using 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: Args:
refresh_data: Refresh token data refresh_data: Refresh token data
db: Database session db: Database session
@@ -275,26 +171,18 @@ async def refresh_token(
""" """
logger.info("Token refresh attempt") logger.info("Token refresh attempt")
# 1. Verify refresh token JWT signature # Verify refresh token
payload = security.verify_token(refresh_data.refresh_token) payload = security.verify_token(refresh_data.refresh_token)
if not payload or payload.get("type") != "refresh": if not payload or payload.get("type") != "refresh":
logger.warning("Token refresh failed - invalid JWT") logger.warning("Token refresh failed - invalid token")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token" detail="Invalid refresh token"
) )
# 2. Verificar que el token exista en BD y no esté revocado/expirado # TODO: Check if refresh token exists in database and is not revoked
# 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"
)
# 3. Create new access token # Create new access token
token_data = { token_data = {
"sub": payload["sub"], "sub": payload["sub"],
"email": payload["email"], "email": payload["email"],
@@ -304,15 +192,7 @@ async def refresh_token(
access_token = security.create_access_token(token_data) access_token = security.create_access_token(token_data)
# 4. Commit actualización de estadísticas de uso logger.info("Token refresh successful", user_id=payload["sub"])
await db.commit()
logger.info(
"Token refresh successful",
user_id=payload["sub"],
token_id=str(db_token.id),
usage_count=db_token.usage_count
)
return TokenResponse( return TokenResponse(
access_token=access_token, access_token=access_token,
@@ -326,17 +206,14 @@ async def logout(
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
""" """
Logout user and revoke all refresh tokens. Logout user and revoke refresh token.
Revoca todos los tokens del usuario (logout en todos los dispositivos)
y registra quién ejecutó la revocación para auditoría.
Args: Args:
token: Access token token: Access token
db: Database session db: Database session
Returns: Returns:
Success message with count of revoked tokens Success message
""" """
logger.info("Logout attempt") logger.info("Logout attempt")
@@ -348,31 +225,11 @@ async def logout(
detail="Invalid token" detail="Invalid token"
) )
# Extraer user_id del token # TODO: Revoke refresh token in database
user_id_str = payload.get("sub")
if not user_id_str:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload"
)
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ó return {"message": "Successfully logged out"}
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
}
@router.get("/me") @router.get("/me")

View File

@@ -82,25 +82,17 @@ async def read_categories(
async def create_category( async def create_category(
category: CategoryCreate, category: CategoryCreate,
db: AsyncSession = Depends(get_db), 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. 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. ✅ Implementa multi-tenancy: asigna automáticamente tenant_id del usuario.
""" """
# Verificar permisos # ✅ CORREGIDO: Asignar tenant_id del usuario actual
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
db_category = Category( db_category = Category(
**category.model_dump(), **category.model_dump(),
tenant_id=current_user.tenant_id tenant_id=current_user.tenant_id # ✅ Multi-tenancy automático
) )
db.add(db_category) db.add(db_category)
@@ -146,16 +138,8 @@ async def update_category(
""" """
Actualizar categoría del tenant. 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. ✅ 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( query = select(Category).where(
Category.id == category_id, Category.id == category_id,
Category.tenant_id == current_user.tenant_id Category.tenant_id == current_user.tenant_id
@@ -188,16 +172,8 @@ async def delete_category(
""" """
Desactivar categoría del tenant (soft delete). 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. ✅ 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( query = select(Category).where(
Category.id == category_id, Category.id == category_id,
Category.tenant_id == current_user.tenant_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() profile = result.scalar_one_or_none()
if not profile: if not profile:
# Si no existe, devolver un perfil vacío con solo tenant_id # Si no existe, crear uno vacío
# No crear en base de datos hasta que el usuario guarde profile = ClientProfile(tenant_id=current_tenant.id)
return ClientProfileResponse( db.add(profile)
id=None, await db.commit()
tenant_id=current_tenant.id, await db.refresh(profile)
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
)
return profile return profile

View File

@@ -70,25 +70,17 @@ async def read_systems(
async def create_system( async def create_system(
system: SystemCreate, system: SystemCreate,
db: AsyncSession = Depends(get_db), 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. 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. ✅ Implementa multi-tenancy: asigna automáticamente tenant_id del usuario.
""" """
# Verificar permisos # ✅ CORREGIDO: Asignar tenant_id del usuario actual
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
db_system = System( db_system = System(
**system.model_dump(), **system.model_dump(),
tenant_id=current_user.tenant_id tenant_id=current_user.tenant_id # ✅ Multi-tenancy automático
) )
db.add(db_system) db.add(db_system)
@@ -134,16 +126,8 @@ async def update_system(
""" """
Actualizar sistema del tenant. Actualizar sistema del tenant.
**Permisos**: Solo ADMIN y SUPPORT_MANAGER pueden actualizar sistemas.
✅ Implementa multi-tenancy: solo permite actualizar sistemas del propio tenant. ✅ 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( query = select(System).where(
System.id == system_id, System.id == system_id,
System.tenant_id == current_user.tenant_id System.tenant_id == current_user.tenant_id
@@ -176,16 +160,8 @@ async def delete_system(
""" """
Desactivar sistema del tenant (soft delete). 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. ✅ 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( query = select(System).where(
System.id == system_id, System.id == system_id,
System.tenant_id == current_user.tenant_id System.tenant_id == current_user.tenant_id

View File

@@ -78,188 +78,85 @@ async def create_ticket(
""" """
Crear un nuevo ticket Crear un nuevo ticket
""" """
# Retry logic para evitar race conditions en generación de ticket_number try:
max_retries = 3 # Generar número de ticket único
last_error = None result = await db.execute(
select(func.count(Ticket.id)).where(Ticket.tenant_id == current_user.tenant_id)
for attempt in range(max_retries): )
try: count = result.scalar() or 0
# Generar número de ticket único basado en el máximo existente ticket_number = f"TK-{count + 1:06d}"
result = await db.execute(
select(Ticket.ticket_number) # Generar número de ticket único
.where(Ticket.tenant_id == current_user.tenant_id) result = await db.execute(
.order_by(Ticket.ticket_number.desc()) select(func.count(Ticket.id)).where(Ticket.tenant_id == current_user.tenant_id)
.limit(1) )
) count = result.scalar() or 0
last_ticket_number = result.scalar_one_or_none() ticket_number = f"TK-{count + 1:06d}"
if last_ticket_number: # Convertir IDs de string a UUID si son proporcionados
# Extraer el número del formato TK-XXXXXX category_uuid = uuid.UUID(ticket.category_id) if ticket.category_id else None
last_number = int(last_ticket_number.split('-')[1]) system_uuid = uuid.UUID(ticket.affected_system_id) if ticket.affected_system_id else None # ✅ CORREGIDO
next_number = last_number + 1
else: # ✅ CORREGIDO: Validar en la tabla correcta con el nombre correcto del modelo
next_number = 1 if category_uuid:
category = await db.get(Category, category_uuid) # ✅ Category, no TicketCategory
ticket_number = f"TK-{next_number:06d}" if not category:
raise HTTPException(
# Convertir IDs de string a UUID si son proporcionados status_code=status.HTTP_400_BAD_REQUEST,
category_uuid = uuid.UUID(ticket.category_id) if ticket.category_id else None detail=f"La categoría con ID {ticket.category_id} no existe."
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."
)
# Validar sistema # Validar si el system_id existe en la tabla affected_systems
if system_uuid: if system_uuid:
system = await db.get(System, system_uuid) system = await db.get(System, system_uuid)
if not system: if not system:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"El sistema con ID {ticket.affected_system_id} no existe." detail=f"El sistema con ID {ticket.affected_system_id} no existe."
) )
db_ticket = Ticket( db_ticket = Ticket(
id=uuid.uuid4(), id=uuid.uuid4(),
tenant_id=current_user.tenant_id, tenant_id=current_user.tenant_id,
ticket_number=ticket_number, ticket_number=ticket_number,
subject=ticket.subject, subject=ticket.subject,
description=ticket.description, description=ticket.description,
category_id=category_uuid, category_id=category_uuid,
affected_system_id=system_uuid, affected_system_id=system_uuid, # ✅ CORREGIDO: Nombre correcto del campo
priority=TicketPriority[ticket.priority.upper()], priority=TicketPriority[ticket.priority.upper()],
created_by=current_user.id, created_by=current_user.id,
status=TicketStatus.NEW, status=TicketStatus.NEW,
created_at=datetime.utcnow(), created_at=datetime.utcnow(),
updated_at=datetime.utcnow() updated_at=datetime.utcnow()
) )
db.add(db_ticket) db.add(db_ticket)
await db.commit() await db.commit()
await db.refresh(db_ticket) await db.refresh(db_ticket)
# ✅ Éxito - retornar ticket creado # ✅ CORREGIDO: Usar affected_system_id en respuesta
return { return {
"id": str(db_ticket.id), "id": str(db_ticket.id),
"ticket_number": db_ticket.ticket_number, "ticket_number": db_ticket.ticket_number,
"subject": db_ticket.subject, "subject": db_ticket.subject,
"title": db_ticket.subject, "title": db_ticket.subject,
"description": db_ticket.description, "description": db_ticket.description,
"status": db_ticket.status.value, "status": db_ticket.status.value,
"priority": db_ticket.priority.value, "priority": db_ticket.priority.value,
"category_id": str(db_ticket.category_id) if db_ticket.category_id else None, "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, "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), "created_by": str(db_ticket.created_by),
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None, "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
"created_at": db_ticket.created_at, "created_at": db_ticket.created_at,
"updated_at": db_ticket.updated_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
} }
for t in tickets
] except ValueError as e:
await db.rollback()
raise HTTPException(
@router.get("/admin/all", response_model=List[dict]) status_code=status.HTTP_400_BAD_REQUEST,
async def get_all_tickets_admin( detail=f"Invalid UUID format: {str(e)}"
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,
date_from: Optional[str] = None, date_from: Optional[str] = None,
date_to: Optional[str] = None, date_to: Optional[str] = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -414,8 +311,6 @@ async def get_ticket(
): ):
""" """
Obtener un ticket específico 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: try:
ticket_uuid = uuid.UUID(ticket_id) ticket_uuid = uuid.UUID(ticket_id)
@@ -425,16 +320,12 @@ async def get_ticket(
detail="Invalid ticket ID format" detail="Invalid ticket ID format"
) )
# Construir query basado en el rol del usuario
query = select(Ticket).where( query = select(Ticket).where(
Ticket.id == ticket_uuid, 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) result = await db.execute(query)
ticket = result.scalars().first() ticket = result.scalars().first()
@@ -471,8 +362,6 @@ async def update_ticket(
): ):
""" """
Actualizar un 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: try:
ticket_uuid = uuid.UUID(ticket_id) ticket_uuid = uuid.UUID(ticket_id)
@@ -482,16 +371,12 @@ async def update_ticket(
detail="Invalid ticket ID format" detail="Invalid ticket ID format"
) )
# Construir query basado en el rol del usuario
query = select(Ticket).where( query = select(Ticket).where(
Ticket.id == ticket_uuid, 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) result = await db.execute(query)
db_ticket = result.scalars().first() db_ticket = result.scalars().first()
@@ -553,8 +438,6 @@ async def close_ticket(
): ):
""" """
Cerrar un 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: try:
ticket_uuid = uuid.UUID(ticket_id) ticket_uuid = uuid.UUID(ticket_id)
@@ -564,16 +447,12 @@ async def close_ticket(
detail="Invalid ticket ID format" detail="Invalid ticket ID format"
) )
# Construir query basado en el rol del usuario
query = select(Ticket).where( query = select(Ticket).where(
Ticket.id == ticket_uuid, 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) result = await db.execute(query)
db_ticket = result.scalars().first() db_ticket = result.scalars().first()

View File

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

View File

@@ -23,8 +23,6 @@ from app.models.user import User
from app.models.ticket import Ticket from app.models.ticket import Ticket
from app.models.comment import TicketComment from app.models.comment import TicketComment
from app.models.attachment import TicketAttachment from app.models.attachment import TicketAttachment
from app.models.audit import AuditLog # Nuevo modelo para auditoría
from app.models.refresh_token import RefreshToken # Modelo para refresh tokens
from app.core.logging import setup_logging from app.core.logging import setup_logging
from app.api.v1.router import api_router from app.api.v1.router import api_router

View File

@@ -8,8 +8,6 @@ from .system import System
from .category import Category from .category import Category
from .client_profile import ClientProfile from .client_profile import ClientProfile
from .attachment import TicketAttachment from .attachment import TicketAttachment
from .audit import AuditLog
from .refresh_token import RefreshToken
__all__ = [ __all__ = [
"User", "User",
@@ -19,7 +17,5 @@ __all__ = [
"System", "System",
"Category", "Category",
"ClientProfile", "ClientProfile",
"TicketAttachment", "TicketAttachment"
"AuditLog",
"RefreshToken"
] ]

View File

@@ -1,148 +0,0 @@
"""
Audit Log Model - ServiceManagerWeb
Modelo para bitácora de auditoría y compliance.
Registra todas las acciones importantes del sistema.
"""
from sqlalchemy import String, Text, DateTime, ForeignKey, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, INET, JSONB
from typing import Optional, Dict, Any, TYPE_CHECKING
import uuid
from datetime import datetime
from app.core.database import Base
if TYPE_CHECKING:
from app.models.tenant import Tenant
from app.models.user import User
class AuditLog(Base):
"""
Bitácora de auditoría para tracking completo de acciones.
Registra:
- Quién hizo la acción (user_id)
- Qué hizo (action)
- Sobre qué recurso (resource_type + resource_id)
- Cuándo lo hizo (created_at)
- Desde dónde (ip_address, user_agent)
- Qué cambió (old_values, new_values)
"""
__tablename__ = "audit_logs"
# Multi-tenancy
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
index=True
)
# Usuario que ejecutó la acción (NULL = acción del sistema)
user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
index=True
)
# Acción realizada (ej: "user.login", "ticket.create", "ticket.assign")
action: Mapped[str] = mapped_column(
String(100),
nullable=False,
index=True
)
# Tipo de recurso afectado (user, ticket, comment, category, etc.)
resource_type: Mapped[str] = mapped_column(
String(50),
nullable=False,
index=True
)
# ID del recurso afectado
resource_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
nullable=True
)
# Contexto de la request
ip_address: Mapped[Optional[str]] = mapped_column(INET, nullable=True)
user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# Correlation ID para rastrear requests relacionadas
correlation_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
nullable=True,
index=True
)
# Valores antes del cambio (JSON)
old_values: Mapped[Optional[Dict[str, Any]]] = mapped_column(
JSONB,
nullable=True
)
# Valores después del cambio (JSON)
new_values: Mapped[Optional[Dict[str, Any]]] = mapped_column(
JSONB,
nullable=True
)
# Metadata adicional (cualquier info relevante)
# Nota: 'metadata' está reservado en SQLAlchemy, usamos 'extra_metadata'
extra_metadata: Mapped[Optional[Dict[str, Any]]] = mapped_column(
'metadata', # Nombre real de la columna en BD
JSONB,
nullable=True
)
# Timestamp
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=datetime.utcnow,
nullable=False,
index=True
)
# Relaciones
tenant: Mapped["Tenant"] = relationship("Tenant", foreign_keys=[tenant_id])
user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[user_id])
# Índices compuestos para queries comunes
__table_args__ = (
Index('idx_audit_logs_tenant_action', 'tenant_id', 'action'),
Index('idx_audit_logs_resource', 'resource_type', 'resource_id'),
Index('idx_audit_logs_user_created', 'user_id', 'created_at'),
)
# Configuración del mapper: excluir updated_at porque audit logs son inmutables
__mapper_args__ = {
"exclude_properties": ["updated_at"]
}
def __repr__(self) -> str:
return f"<AuditLog(action='{self.action}', resource='{self.resource_type}:{self.resource_id}')>"
@property
def action_display(self) -> str:
"""Formato amigable de la acción."""
parts = self.action.split('.')
if len(parts) == 2:
resource, verb = parts
verb_map = {
'create': 'creó',
'update': 'actualizó',
'delete': 'eliminó',
'login': 'inició sesión',
'logout': 'cerró sesión',
'assign': 'asignó',
'close': 'cerró',
'reopen': 'reabrió'
}
return f"{verb_map.get(verb, verb)} {resource}"
return self.action

View File

@@ -1,171 +0,0 @@
"""
Refresh Token Model - ServiceManagerWeb
Modelo para persistencia de refresh tokens con revocación y tracking.
"""
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Index, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from typing import Optional, TYPE_CHECKING
import uuid
from datetime import datetime
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
class RefreshToken(Base):
"""
Refresh Token persistente para gestión de sesiones.
Almacena refresh tokens con información de dispositivo y permite
revocación para mejorar la seguridad.
Características:
- Token hasheado (no se guarda en texto plano)
- Device fingerprinting
- Revocación individual con tracking
- Auto-expiración
- Tracking de IP y uso
"""
__tablename__ = "refresh_tokens"
# User relationship
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True
)
# Token JWT (almacenado directamente - firmado y verificable)
# VARCHAR(500) para acomodar JWTs con payload extenso
token: Mapped[str] = mapped_column(
String(500),
nullable=False,
unique=True
)
# Device information
device_id: Mapped[Optional[str]] = mapped_column(
String(100),
nullable=True
)
device_name: Mapped[Optional[str]] = mapped_column(
String(200),
nullable=True
)
user_agent: Mapped[Optional[str]] = mapped_column(
String(500),
nullable=True
)
# IP address del cliente (varchar(45) para IPv6)
ip_address: Mapped[Optional[str]] = mapped_column(
String(45),
nullable=True
)
# Expiración del token
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
index=True
)
# Estado de revocación
revoked: Mapped[bool] = mapped_column(
Boolean,
default=False,
nullable=False
)
revoked_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True
)
revoked_by: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True
)
# Tracking de uso
last_used_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True
)
usage_count: Mapped[int] = mapped_column(
Integer,
default=0,
nullable=False
)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=datetime.utcnow,
nullable=False,
server_default="NOW()"
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=datetime.utcnow,
onupdate=datetime.utcnow,
nullable=False,
server_default="NOW()"
)
# Relación con usuario
user: Mapped["User"] = relationship("User", foreign_keys=[user_id], back_populates="refresh_tokens")
revoker: Mapped[Optional["User"]] = relationship("User", foreign_keys=[revoked_by])
# Índices compuestos
__table_args__ = (
Index('idx_refresh_tokens_user_expires', 'user_id', 'expires_at'),
)
def __repr__(self) -> str:
return f"<RefreshToken(user_id='{self.user_id}', revoked={self.revoked}, expires={self.expires_at})>"
@property
def is_valid(self) -> bool:
"""
Verificar si el token es válido.
Un token es válido si:
- No está revocado
- No ha expirado
"""
return not self.revoked and self.expires_at > datetime.utcnow()
@property
def is_expired(self) -> bool:
"""Verificar si el token ha expirado."""
return datetime.utcnow() >= self.expires_at
def revoke(self, revoked_by: Optional[uuid.UUID] = None) -> None:
"""
Marcar el token como revocado.
Args:
revoked_by: ID del usuario que revocó el token
"""
self.revoked = True
self.revoked_at = datetime.utcnow()
if revoked_by:
self.revoked_by = revoked_by
def track_usage(self) -> None:
"""Registrar uso del token."""
self.last_used_at = datetime.utcnow()
self.usage_count += 1

View File

@@ -78,13 +78,6 @@ class User(Base):
back_populates="assigned_to_user", back_populates="assigned_to_user",
foreign_keys="Ticket.assigned_to" foreign_keys="Ticket.assigned_to"
) )
# Refresh tokens: especificar user_id como FK (hay 2 FKs: user_id y revoked_by)
refresh_tokens: Mapped[List["RefreshToken"]] = relationship(
"RefreshToken",
back_populates="user",
cascade="all, delete-orphan",
foreign_keys="[RefreshToken.user_id]"
)
# Unique constraint por tenant # Unique constraint por tenant
__table_args__ = ( __table_args__ = (

View File

@@ -1,311 +0,0 @@
"""
Audit Service - ServiceManagerWeb
Funciones helper para facilitar el registro de auditoría.
Simplifica el proceso de logging en toda la aplicación.
"""
from typing import Optional, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import Request
import uuid
import structlog
from app.models.audit import AuditLog
from app.models.user import User
logger = structlog.get_logger(__name__)
class AuditService:
"""
Servicio centralizado para registro de auditoría.
Uso básico:
await AuditService.log(
db=db,
tenant_id=tenant.id,
user_id=current_user.id,
action="ticket.create",
resource_type="ticket",
resource_id=new_ticket.id,
new_values={"subject": "...", "status": "NEW"}
)
"""
@staticmethod
async def log(
db: AsyncSession,
tenant_id: uuid.UUID,
action: str,
resource_type: str,
resource_id: Optional[uuid.UUID] = None,
user_id: Optional[uuid.UUID] = None,
old_values: Optional[Dict[str, Any]] = None,
new_values: Optional[Dict[str, Any]] = None,
metadata: Optional[Dict[str, Any]] = None,
request: Optional[Request] = None
) -> AuditLog:
"""
Registra una acción en la bitácora de auditoría.
Args:
db: Sesión de base de datos
tenant_id: ID del tenant
action: Acción realizada (formato: "recurso.verbo")
Ejemplos: "user.login", "ticket.create", "ticket.assign"
resource_type: Tipo de recurso ("user", "ticket", "comment", etc.)
resource_id: ID del recurso afectado (opcional)
user_id: ID del usuario que ejecutó la acción (opcional = sistema)
old_values: Valores antes del cambio (opcional)
new_values: Valores después del cambio (opcional)
metadata: Información adicional (opcional)
request: Request de FastAPI para extraer IP y user agent (opcional)
Returns:
AuditLog creado
"""
# Extraer información del request si está disponible
ip_address = None
user_agent = None
correlation_id = None
if request:
# IP del cliente
if request.client:
ip_address = request.client.host
# User agent
user_agent = request.headers.get("user-agent")
# Correlation ID (si existe en el request state)
correlation_id = getattr(request.state, "correlation_id", None)
# Crear registro de auditoría
audit_log = AuditLog(
tenant_id=tenant_id,
user_id=user_id,
action=action,
resource_type=resource_type,
resource_id=resource_id,
ip_address=ip_address,
user_agent=user_agent,
correlation_id=correlation_id,
old_values=old_values,
new_values=new_values,
extra_metadata=metadata # Mapeo metadata -> extra_metadata
)
db.add(audit_log)
await db.flush() # No commit, se hará con la transacción principal
# Log estructurado para debugging
logger.info(
"Audit log created",
action=action,
resource_type=resource_type,
resource_id=str(resource_id) if resource_id else None,
user_id=str(user_id) if user_id else "system",
tenant_id=str(tenant_id)
)
return audit_log
@staticmethod
async def log_login(
db: AsyncSession,
user: User,
request: Request,
success: bool = True
) -> AuditLog:
"""
Registra un intento de login.
Args:
db: Sesión de base de datos
user: Usuario que intentó loguearse
request: Request de FastAPI
success: Si el login fue exitoso
Returns:
AuditLog creado
"""
return await AuditService.log(
db=db,
tenant_id=user.tenant_id,
user_id=user.id if success else None,
action="user.login" if success else "user.login_failed",
resource_type="user",
resource_id=user.id,
metadata={
"success": success,
"email": user.email
},
request=request
)
@staticmethod
async def log_logout(
db: AsyncSession,
user: User,
request: Request
) -> AuditLog:
"""
Registra un logout.
Args:
db: Sesión de base de datos
user: Usuario que cerró sesión
request: Request de FastAPI
Returns:
AuditLog creado
"""
return await AuditService.log(
db=db,
tenant_id=user.tenant_id,
user_id=user.id,
action="user.logout",
resource_type="user",
resource_id=user.id,
request=request
)
@staticmethod
async def log_create(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
resource_type: str,
resource_id: uuid.UUID,
new_values: Dict[str, Any],
request: Optional[Request] = None
) -> AuditLog:
"""
Registra la creación de un recurso.
Args:
db: Sesión de base de datos
tenant_id: ID del tenant
user_id: ID del usuario que creó el recurso
resource_type: Tipo de recurso ("ticket", "user", etc.)
resource_id: ID del recurso creado
new_values: Valores del nuevo recurso
request: Request de FastAPI (opcional)
Returns:
AuditLog creado
"""
return await AuditService.log(
db=db,
tenant_id=tenant_id,
user_id=user_id,
action=f"{resource_type}.create",
resource_type=resource_type,
resource_id=resource_id,
new_values=new_values,
request=request
)
@staticmethod
async def log_update(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
resource_type: str,
resource_id: uuid.UUID,
old_values: Dict[str, Any],
new_values: Dict[str, Any],
request: Optional[Request] = None
) -> AuditLog:
"""
Registra la actualización de un recurso.
Args:
db: Sesión de base de datos
tenant_id: ID del tenant
user_id: ID del usuario que actualizó
resource_type: Tipo de recurso
resource_id: ID del recurso
old_values: Valores anteriores
new_values: Valores nuevos
request: Request de FastAPI (opcional)
Returns:
AuditLog creado
"""
return await AuditService.log(
db=db,
tenant_id=tenant_id,
user_id=user_id,
action=f"{resource_type}.update",
resource_type=resource_type,
resource_id=resource_id,
old_values=old_values,
new_values=new_values,
request=request
)
@staticmethod
async def log_delete(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
resource_type: str,
resource_id: uuid.UUID,
old_values: Dict[str, Any],
request: Optional[Request] = None
) -> AuditLog:
"""
Registra la eliminación de un recurso.
Args:
db: Sesión de base de datos
tenant_id: ID del tenant
user_id: ID del usuario que eliminó
resource_type: Tipo de recurso
resource_id: ID del recurso eliminado
old_values: Valores del recurso antes de eliminar
request: Request de FastAPI (opcional)
Returns:
AuditLog creado
"""
return await AuditService.log(
db=db,
tenant_id=tenant_id,
user_id=user_id,
action=f"{resource_type}.delete",
resource_type=resource_type,
resource_id=resource_id,
old_values=old_values,
request=request
)
@staticmethod
def sanitize_values(values: Dict[str, Any]) -> Dict[str, Any]:
"""
Sanitiza valores sensibles antes de guardarlos en audit log.
Remueve campos como passwords, tokens, etc.
Args:
values: Diccionario de valores
Returns:
Diccionario sanitizado
"""
sensitive_fields = {
'password',
'password_hash',
'totp_secret',
'backup_codes',
'token',
'access_token',
'refresh_token'
}
return {
key: '***REDACTED***' if key in sensitive_fields else value
for key, value in values.items()
}

View File

@@ -1,270 +0,0 @@
"""
Token Service - ServiceManagerWeb
Servicio para gestión de refresh tokens persistentes.
"""
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from datetime import datetime, timedelta
from typing import Optional
import uuid
import structlog
from app.models.refresh_token import RefreshToken
from app.models.user import User
from app.core.config import get_settings
logger = structlog.get_logger(__name__)
settings = get_settings()
class TokenService:
"""
Servicio para gestión de refresh tokens.
Proporciona métodos para crear, validar, revocar y limpiar
refresh tokens persistentes.
NOTA: Los tokens se almacenan directamente en BD (no hash)
ya que los JWTs son firmados y verificables.
"""
@staticmethod
async def create_refresh_token(
db: AsyncSession,
user: User,
refresh_token: str,
device_id: Optional[str] = None,
device_name: Optional[str] = None,
user_agent: Optional[str] = None,
ip_address: Optional[str] = None
) -> RefreshToken:
"""
Crear y persistir un refresh token.
Args:
db: Sesión de base de datos
user: Usuario propietario del token
refresh_token: Token JWT generado (se almacena directamente)
device_id: ID único del dispositivo (UUID generado por cliente)
device_name: Nombre del dispositivo (ej: "Chrome en Windows")
user_agent: User agent completo del navegador
ip_address: IP del cliente
Returns:
RefreshToken creado
"""
# Calcular expiración
expires_at = datetime.utcnow() + timedelta(
days=settings.REFRESH_TOKEN_EXPIRE_DAYS
)
# Crear registro - almacena JWT directamente (columna UNIQUE)
db_token = RefreshToken(
user_id=user.id,
token=refresh_token, # JWT almacenado directamente
device_id=device_id,
device_name=device_name,
user_agent=user_agent,
ip_address=ip_address,
expires_at=expires_at,
revoked=False,
usage_count=0
)
db.add(db_token)
await db.flush()
logger.info(
"Refresh token created",
user_id=str(user.id),
token_id=str(db_token.id),
device_name=device_name,
expires_at=expires_at.isoformat()
)
return db_token
@staticmethod
async def verify_refresh_token(
db: AsyncSession,
refresh_token: str
) -> Optional[RefreshToken]:
"""
Verificar que el refresh token exista y sea válido.
Busca el JWT directamente en la BD y verifica su estado.
Args:
db: Sesión de base de datos
refresh_token: Token JWT a verificar
Returns:
RefreshToken si es válido, None si no existe o está revocado/expirado
"""
# Buscar token directamente en BD (sin hash)
query = select(RefreshToken).where(
RefreshToken.token == refresh_token
)
result = await db.execute(query)
db_token = result.scalar_one_or_none()
if not db_token:
logger.warning("Refresh token not found in database")
return None
# Verificar si es válido (usa property is_valid del modelo)
if not db_token.is_valid:
logger.warning(
"Invalid refresh token",
token_id=str(db_token.id),
revoked=db_token.revoked,
expired=db_token.is_expired
)
return None
# Actualizar estadísticas de uso
db_token.track_usage()
await db.flush()
logger.info(
"Refresh token verified and usage tracked",
token_id=str(db_token.id),
usage_count=db_token.usage_count
)
return db_token
@staticmethod
async def revoke_token(
db: AsyncSession,
refresh_token: str,
revoked_by_user_id: Optional[uuid.UUID] = None
) -> bool:
"""
Revocar un refresh token específico.
Args:
db: Sesión de base de datos
refresh_token: Token JWT a revocar
revoked_by_user_id: ID del usuario que revoca (para auditoría)
Returns:
True si se revocó, False si no se encontró
"""
# Buscar token directamente (sin hash)
query = select(RefreshToken).where(
RefreshToken.token == refresh_token
)
result = await db.execute(query)
db_token = result.scalar_one_or_none()
if not db_token:
logger.warning("Refresh token not found for revocation")
return False
# Revocar usando método del modelo
db_token.revoke(revoked_by=revoked_by_user_id)
await db.flush()
logger.info(
"Refresh token revoked",
token_id=str(db_token.id),
revoked_by=str(revoked_by_user_id) if revoked_by_user_id else None
)
return True
@staticmethod
async def revoke_all_user_tokens(
db: AsyncSession,
user_id: uuid.UUID,
revoked_by_user_id: Optional[uuid.UUID] = None
) -> int:
"""
Revocar todos los tokens activos de un usuario.
Útil para logout en todos los dispositivos.
Args:
db: Sesión de base de datos
user_id: ID del usuario
revoked_by_user_id: ID del usuario que ejecuta la revocación (para auditoría)
Returns:
Número de tokens revocados
"""
# Buscar todos los tokens activos del usuario
query = select(RefreshToken).where(
RefreshToken.user_id == user_id,
RefreshToken.revoked == False
)
result = await db.execute(query)
tokens = result.scalars().all()
count = 0
for token in tokens:
token.revoke(revoked_by=revoked_by_user_id)
count += 1
await db.flush()
logger.info(
"All user tokens revoked",
user_id=str(user_id),
count=count,
revoked_by=str(revoked_by_user_id) if revoked_by_user_id else None
)
return count
@staticmethod
async def cleanup_expired_tokens(
db: AsyncSession
) -> int:
"""
Eliminar tokens expirados de la base de datos.
Tarea de mantenimiento para limpiar tokens antiguos.
Args:
db: Sesión de base de datos
Returns:
Número de tokens eliminados
"""
# Eliminar tokens expirados hace más de 7 días
cutoff_date = datetime.utcnow() - timedelta(days=7)
query = delete(RefreshToken).where(
RefreshToken.expires_at < cutoff_date
)
result = await db.execute(query)
await db.flush()
deleted_count = result.rowcount
logger.info("Expired tokens cleaned up", count=deleted_count)
return deleted_count
@staticmethod
async def get_user_tokens(
db: AsyncSession,
user_id: uuid.UUID
) -> list[RefreshToken]:
"""
Obtener todos los tokens activos de un usuario.
Args:
db: Sesión de base de datos
user_id: ID del usuario
Returns:
Lista de RefreshTokens activos
"""
query = select(RefreshToken).where(
RefreshToken.user_id == user_id,
RefreshToken.revoked == False,
RefreshToken.expires_at > datetime.utcnow()
).order_by(RefreshToken.created_at.desc())
result = await db.execute(query)
return list(result.scalars().all())

View File

@@ -1,81 +0,0 @@
"""add_audit_logs_table
Revision ID: a1b2c3d4e5f6
Revises: 13362e8c493a
Create Date: 2026-02-12 10:00:00.000000
Registra el modelo AuditLog en Alembic.
La tabla audit_logs ya existe en schema.sql, esta migración solo
la registra en el control de versiones de Alembic.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'a1b2c3d4e5f6'
down_revision = '13362e8c493a'
branch_labels = None
depends_on = None
def upgrade():
"""
Verificar que audit_logs existe y registrarla en Alembic.
La tabla fue creada por schema.sql, esta migración solo verifica
que exista y está disponible para usar.
"""
from sqlalchemy import inspect
bind = op.get_bind()
inspector = inspect(bind)
tables = inspector.get_table_names()
if 'audit_logs' in tables:
print("✅ Tabla audit_logs encontrada (creada por schema.sql)")
print("✅ Modelo AuditLog registrado en Alembic")
# Verificar que tenga los índices necesarios
existing_indexes = [idx['name'] for idx in inspector.get_indexes('audit_logs')]
missing_indexes = []
required_indexes = [
'idx_audit_logs_tenant_id',
'idx_audit_logs_user_id',
'idx_audit_logs_action',
'idx_audit_logs_correlation_id',
'idx_audit_logs_created_at'
]
for idx in required_indexes:
if idx not in existing_indexes:
missing_indexes.append(idx)
if missing_indexes:
print(f"⚠️ Índices faltantes: {', '.join(missing_indexes)}")
print(" (Esto es normal si usaste schema.sql completo)")
else:
print("✅ Todos los índices necesarios están presentes")
else:
print("⚠️ La tabla audit_logs NO existe")
print(" Ejecuta: docker-compose exec -T postgres psql -U postgres -d servicemanager < db/schema.sql")
print(" O crea la tabla manualmente desde schema.sql")
# No crear la tabla aquí - debe venir de schema.sql para mantener consistencia
raise Exception(
"La tabla audit_logs no existe. "
"Por favor ejecuta el schema.sql completo primero."
)
def downgrade():
"""
No eliminar la tabla - fue creada por schema.sql.
Solo des-registrar de Alembic.
"""
print(" Tabla audit_logs NO será eliminada (creada por schema.sql)")
print("✅ Modelo AuditLog des-registrado de Alembic")

View File

@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "servicemanager-backend" name = "servicemanager-backend"
version = "1.5.1" version = "0.1.0"
description = "ServiceManagerWeb Backend - Mesa de Ayuda B2B" description = "ServiceManagerWeb Backend - Mesa de Ayuda B2B"
authors = [ authors = [
{name = "Aduanasoft", email = "dev@aduanasoft.com"} {name = "Aduanasoft", email = "dev@aduanasoft.com"}

View File

View File

@@ -1,109 +0,0 @@
"""
Script de verificación de control de acceso basado en roles
"""
import asyncio
import httpx
BASE_URL = "http://localhost:8000/api/v1"
# Credenciales de prueba
USERS = {
"admin": {"email": "admin@aduanasoft.com", "password": "Admin123!", "tenant_slug": "aduanasoft"},
"agent": {"email": "agente@aduanasoft.com", "password": "Agente123!", "tenant_slug": "aduanasoft"},
"client": {"email": "test_user@example.com", "password": "TestPassword123!", "tenant_slug": "aduanasoft"}
}
async def login(user_type: str):
"""Login y obtener token"""
async with httpx.AsyncClient() as client:
response = await client.post(
f"{BASE_URL}/auth/login",
json=USERS[user_type]
)
if response.status_code == 200:
data = response.json()
return data["access_token"], data["user"]
return None, None
async def test_endpoint(method: str, endpoint: str, token: str, tenant_id: str, data: dict = None):
"""Probar un endpoint"""
async with httpx.AsyncClient() as client:
headers = {
"Authorization": f"Bearer {token}",
"X-Tenant-ID": tenant_id
}
if method == "GET":
response = await client.get(f"{BASE_URL}{endpoint}", headers=headers)
elif method == "POST":
response = await client.post(f"{BASE_URL}{endpoint}", headers=headers, json=data)
elif method == "PUT":
response = await client.put(f"{BASE_URL}{endpoint}", headers=headers, json=data)
elif method == "DELETE":
response = await client.delete(f"{BASE_URL}{endpoint}", headers=headers)
return response.status_code
async def main():
print("=" * 80)
print("VERIFICACIÓN DE CONTROL DE ACCESO BASADO EN ROLES")
print("=" * 80)
# Login todos los usuarios
print("\n1. Autenticando usuarios...")
admin_token, admin_user = await login("admin")
agent_token, agent_user = await login("agent")
client_token, client_user = await login("client")
if not all([admin_token, agent_token, client_token]):
print("❌ Error en autenticación")
return
tenant_id = admin_user["tenant_id"]
print(f"✅ Todos autenticados - Tenant ID: {tenant_id}")
# Test 1: Listar tickets
print("\n2. Test GET /tickets (listar tickets)")
print(" - Admin:", "" if await test_endpoint("GET", "/tickets/", admin_token, tenant_id) == 200 else "")
print(" - Agent:", "" if await test_endpoint("GET", "/tickets/", agent_token, tenant_id) == 200 else "")
print(" - Client:", "" if await test_endpoint("GET", "/tickets/", client_token, tenant_id) == 200 else "")
# Test 2: Crear categoría (solo ADMIN/SUPPORT_MANAGER)
print("\n3. Test POST /categories/ (crear categoría)")
category_data = {"name": "Test Category", "description": "Test"}
admin_status = await test_endpoint("POST", "/categories/", admin_token, tenant_id, category_data)
agent_status = await test_endpoint("POST", "/categories/", agent_token, tenant_id, category_data)
client_status = await test_endpoint("POST", "/categories/", client_token, tenant_id, category_data)
print(f" - Admin: {'' if admin_status in [200, 201] else ''} (esperado: 201)")
print(f" - Agent: {'' if agent_status == 403 else ''} (esperado: 403)")
print(f" - Client: {'' if client_status == 403 else ''} (esperado: 403)")
# Test 3: Crear sistema (solo ADMIN/SUPPORT_MANAGER)
print("\n4. Test POST /systems/ (crear sistema)")
system_data = {"name": "Test System", "description": "Test"}
admin_status = await test_endpoint("POST", "/systems/", admin_token, tenant_id, system_data)
agent_status = await test_endpoint("POST", "/systems/", agent_token, tenant_id, system_data)
client_status = await test_endpoint("POST", "/systems/", client_token, tenant_id, system_data)
print(f" - Admin: {'' if admin_status in [200, 201] else ''} (esperado: 201)")
print(f" - Agent: {'' if agent_status == 403 else ''} (esperado: 403)")
print(f" - Client: {'' if client_status == 403 else ''} (esperado: 403)")
# Test 4: Ver tickets de otros usuarios
print("\n5. Test de visibilidad de tickets:")
print(" - Admin puede ver tickets de clientes: ✅ (implementado)")
print(" - Agent puede ver tickets de clientes: ✅ (implementado)")
print(" - Client solo ve sus propios tickets: ✅ (implementado)")
print("\n" + "=" * 80)
print("RESUMEN")
print("=" * 80)
print("✅ Control de acceso basado en roles implementado correctamente")
print("✅ Staff interno (ADMIN/AGENT) puede ver todos los tickets del tenant")
print("✅ Clientes solo ven sus propios tickets")
print("✅ Solo ADMIN/SUPPORT_MANAGER pueden crear/modificar categories/systems")
print("=" * 80)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -1,84 +0,0 @@
"""Script para verificar el acceso a tickets con diferentes usuarios"""
import asyncio
import httpx
import os
BASE_URL = "http://localhost:8000/api/v1"
TICKET_ID = "2bd79718-440d-4144-b660-c0c6051fcf73"
async def login(email: str, password: str, tenant_slug: str = "aduanasoft"):
"""Login y obtener token"""
async with httpx.AsyncClient() as client:
response = await client.post(
f"{BASE_URL}/auth/login",
json={
"email": email,
"password": password,
"tenant_slug": tenant_slug
}
)
if response.status_code == 200:
data = response.json()
return data["access_token"], data["user"]
else:
print(f"❌ Login failed for {email}: {response.text}")
return None, None
async def get_ticket(ticket_id: str, token: str, tenant_id: str):
"""Intentar obtener un ticket"""
async with httpx.AsyncClient() as client:
response = await client.get(
f"{BASE_URL}/tickets/{ticket_id}",
headers={
"Authorization": f"Bearer {token}",
"X-Tenant-ID": tenant_id
}
)
return response.status_code, response.text
async def test_access():
print("=" * 60)
print("PRUEBA DE ACCESO A TICKETS")
print("=" * 60)
# Test con test_user (CLIENT_USER)
print("\n1. Probando con test_user (CLIENT_USER)...")
token, user = await login("test_user@example.com", "TestPassword123!")
if token and user:
print(f" ✅ Login exitoso - Role: {user['role']}, Tenant: {user['tenant_id']}")
status, response = await get_ticket(TICKET_ID, token, user['tenant_id'])
if status == 200:
print(f" ✅ Ticket obtenido correctamente")
else:
print(f" ❌ Error {status}: {response}")
# Test con admin
print("\n2. Probando con admin (ADMIN)...")
token, user = await login("admin@aduanasoft.com", "Admin123!")
if token and user:
print(f" ✅ Login exitoso - Role: {user['role']}, Tenant: {user['tenant_id']}")
status, response = await get_ticket(TICKET_ID, token, user['tenant_id'])
if status == 200:
print(f" ✅ Ticket obtenido correctamente")
else:
print(f" ❌ Error {status}: {response}")
# Test con agente
print("\n3. Probando con agente (AGENT)...")
token, user = await login("agente@aduanasoft.com", "Agente123!")
if token and user:
print(f" ✅ Login exitoso - Role: {user['role']}, Tenant: {user['tenant_id']}")
status, response = await get_ticket(TICKET_ID, token, user['tenant_id'])
if status == 200:
print(f" ✅ Ticket obtenido correctamente")
else:
print(f" ❌ Error {status}: {response}")
print("\n" + "=" * 60)
print("Nota: Este ticket fue creado por test_user@example.com")
print("Ahora todos los usuarios del mismo tenant deberían poder verlo")
print("según su rol (admins y agentes: todos, clientes: solo propios)")
print("=" * 60)
if __name__ == "__main__":
asyncio.run(test_access())

View File

@@ -1,6 +1,6 @@
{ {
"name": "@servicemanager/client-frontend", "name": "@servicemanager/client-frontend",
"version": "1.5.1", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -3,9 +3,6 @@ import { auth } from './auth.js';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import type { Writable } from 'svelte/store'; import type { Writable } from 'svelte/store';
// API Configuration
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
// Types // Types
export interface Category { export interface Category {
id: string; id: string;
@@ -33,7 +30,7 @@ const initialState: AppState = {
async function apiCall(endpoint: string, options: RequestInit = {}) { async function apiCall(endpoint: string, options: RequestInit = {}) {
const authState = get(auth); const authState = get(auth);
const response = await fetch(`${API_URL}/v1${endpoint}`, { const response = await fetch(`/api/v1${endpoint}`, {
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@@ -1,9 +1,6 @@
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import type { Writable } from 'svelte/store'; import type { Writable } from 'svelte/store';
// API Configuration
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
// Types // Types
export interface User { export interface User {
id: string; id: string;
@@ -82,7 +79,7 @@ function createAuthStore() {
update(state => ({ ...state, isLoading: true })); update(state => ({ ...state, isLoading: true }));
try { try {
const response = await fetch(`${API_URL}/v1/auth/login`, { const response = await fetch('/api/v1/auth/login', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@@ -2,9 +2,6 @@ import type { Writable } from 'svelte/store';
import { get, writable } from 'svelte/store'; import { get, writable } from 'svelte/store';
import { auth } from './auth'; import { auth } from './auth';
// API Configuration
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
// Types // Types
interface FastAPIValidationError { interface FastAPIValidationError {
loc: (string | number)[]; loc: (string | number)[];
@@ -87,7 +84,7 @@ async function apiCall(endpoint: string, options: RequestInit = {}) {
throw new Error('Not authenticated'); throw new Error('Not authenticated');
} }
const response = await fetch(`${API_URL}/v1${endpoint}`, { const response = await fetch(`/api/v1${endpoint}`, {
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -262,7 +259,7 @@ function createTicketsStore() {
throw new Error('Not authenticated'); throw new Error('Not authenticated');
} }
const response = await fetch(`${API_URL}/v1/tickets/${ticketId}/attachments`, { const response = await fetch(`/api/v1/tickets/${ticketId}/attachments`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${authState.token}`, 'Authorization': `Bearer ${authState.token}`,
@@ -341,7 +338,7 @@ function createTicketsStore() {
throw new Error('Not authenticated'); throw new Error('Not authenticated');
} }
const response = await fetch(`${API_URL}/v1/tickets/${ticketId}/attachments/${attachmentId}/download`, { const response = await fetch(`/api/v1/tickets/${ticketId}/attachments/${attachmentId}/download`, {
method: 'GET', method: 'GET',
headers: { headers: {
'Authorization': `Bearer ${authState.token}`, 'Authorization': `Bearer ${authState.token}`,

View File

@@ -4,9 +4,6 @@
import { toast } from '$lib/stores/toast.js'; import { toast } from '$lib/stores/toast.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
// API Configuration
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
let currentPassword = ''; let currentPassword = '';
let newPassword = ''; let newPassword = '';
let confirmPassword = ''; let confirmPassword = '';
@@ -85,7 +82,7 @@
return; return;
} }
const response = await fetch(`${API_URL}/v1/client-profile/`, { const response = await fetch('/api/v1/client-profile/', {
headers: { headers: {
Authorization: `Bearer ${$auth.token}`, Authorization: `Bearer ${$auth.token}`,
'X-Tenant-ID': $auth.user.tenant_id 'X-Tenant-ID': $auth.user.tenant_id
@@ -188,7 +185,7 @@
isUpdatingProfile = true; isUpdatingProfile = true;
try { try {
const response = await fetch(`${API_URL}/v1/auth/profile`, { const response = await fetch('/api/v1/auth/profile', {
method: 'PATCH', method: 'PATCH',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -221,7 +218,7 @@
isChangingPassword = true; isChangingPassword = true;
try { try {
const response = await fetch(`${API_URL}/v1/auth/change-password`, { const response = await fetch('/api/v1/auth/change-password', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -272,7 +269,7 @@
profileData.credit_limit = parseFloat(profileData.credit_limit); profileData.credit_limit = parseFloat(profileData.credit_limit);
} }
const response = await fetch(`${API_URL}/v1/client-profile/`, { const response = await fetch('/api/v1/client-profile/', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@@ -8,7 +8,7 @@ export default defineConfig({
host: '0.0.0.0', host: '0.0.0.0',
proxy: { proxy: {
'/api': { '/api': {
target: 'http://backend:8000', target: 'http://servicemanager-backend:8000',
changeOrigin: true, changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '') rewrite: (path) => path.replace(/^\/api/, '')
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@servicemanager/internal-frontend", "name": "@servicemanager/internal-frontend",
"version": "1.5.1", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { auth } from '$lib/stores/auth.js'; import { auth } from '$lib/stores/auth.js';
;
export let toggleSidebar: () => void; export let toggleSidebar: () => void;

View File

@@ -1,6 +1,7 @@
<script> <script>
import { createEventDispatcher, onMount, onDestroy } from 'svelte'; import { createEventDispatcher, onMount, onDestroy } from 'svelte';
export let open = false;
export let title = ''; export let title = '';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@@ -10,7 +11,7 @@
} }
function handleKeydown(e) { function handleKeydown(e) {
if (e.key === 'Escape') { if (e.key === 'Escape' && open) {
close(); close();
} }
} }
@@ -18,7 +19,8 @@
<svelte:window on:keydown={handleKeydown}/> <svelte:window on:keydown={handleKeydown}/>
<div class="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true"> {#if open}
<div class="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0"> <div class="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" aria-hidden="true" on:click={close}></div> <div class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" aria-hidden="true" on:click={close}></div>
@@ -36,11 +38,7 @@
</div> </div>
</div> </div>
</div> </div>
{#if $$slots.footer}
<div class="mt-5 sm:mt-6 sm:flex sm:flex-row-reverse">
<slot name="footer" />
</div>
{/if}
</div> </div>
</div> </div>
</div> </div>
{/if}

View File

@@ -62,16 +62,7 @@
name: 'Auditoría', name: 'Auditoría',
href: '/audit', href: '/audit',
icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z' icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z'
} }
);
}
return baseNavigation;
}
function isCurrentPage(href: string) {
return $page.url.pathname === href || ($page.url.pathname.startsWith(href) && href !== '/');
}
</script> </script>
<!-- Mobile sidebar backdrop --> <!-- Mobile sidebar backdrop -->

View File

@@ -1,9 +1,6 @@
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import type { Writable } from 'svelte/store'; import type { Writable } from 'svelte/store';
// API Configuration
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
// Types // Types
export interface InternalUser { export interface InternalUser {
id: string; id: string;
@@ -98,7 +95,7 @@ function createAuthStore() {
update(state => ({ ...state, isLoading: true })); update(state => ({ ...state, isLoading: true }));
try { try {
const response = await fetch(`${API_URL}/v1/auth/login`, { const response = await fetch('/api/v1/auth/login', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -150,7 +147,7 @@ function createAuthStore() {
update (state => ({ ...state, isLoading: true })); update (state => ({ ...state, isLoading: true }));
try { try {
const response = await fetch(`${API_URL}/v1/auth/refresh`, { const response = await fetch('/api/v1/auth/refresh', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@@ -25,15 +25,11 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
const authState = get(auth); const authState = get(auth);
const token = authState.token || (typeof window !== 'undefined' ? localStorage.getItem('internal_auth_token') : null); const token = authState.token || (typeof window !== 'undefined' ? localStorage.getItem('internal_auth_token') : null);
const user = authState.user || (typeof window !== 'undefined' ? JSON.parse(localStorage.getItem('internal_auth_user') || 'null') : null);
const headers = new Headers(init.headers); const headers = new Headers(init.headers);
if (token) { if (token) {
headers.set('Authorization', `Bearer ${token}`); headers.set('Authorization', `Bearer ${token}`);
} }
if (user && user.tenant_id) {
headers.set('X-Tenant-ID', user.tenant_id);
}
if (!headers.has('Content-Type')) { if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json'); headers.set('Content-Type', 'application/json');
} }
@@ -69,15 +65,11 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
async function downloadFile(endpoint: string, filename: string): Promise<void> { async function downloadFile(endpoint: string, filename: string): Promise<void> {
const authState = get(auth); const authState = get(auth);
const token = authState.token || (typeof window !== 'undefined' ? localStorage.getItem('internal_auth_token') : null); const token = authState.token || (typeof window !== 'undefined' ? localStorage.getItem('internal_auth_token') : null);
const user = authState.user || (typeof window !== 'undefined' ? JSON.parse(localStorage.getItem('internal_auth_user') || 'null') : null);
const headers = new Headers(); const headers = new Headers();
if (token) { if (token) {
headers.set('Authorization', `Bearer ${token}`); headers.set('Authorization', `Bearer ${token}`);
} }
if (user && user.tenant_id) {
headers.set('X-Tenant-ID', user.tenant_id);
}
const response = await fetch(`${API_BASE}${endpoint}`, { const response = await fetch(`${API_BASE}${endpoint}`, {
method: 'GET', method: 'GET',

View File

@@ -1,704 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/utils/api';
import { toast } from '$lib/stores/toast';
import Modal from '$lib/components/Modal.svelte';
// Estado de carga y datos
let logs = [];
let stats = null;
let users = [];
let isLoading = false;
let selectedLog = null;
let showDetailModal = false;
// Paginación
let currentPage = 1;
let totalPages = 1;
let totalLogs = 0;
const perPage = 20;
// Filtros
let filterUserId = '';
let filterAction = '';
let filterResourceType = '';
let filterDateFrom = '';
let filterDateTo = '';
let searchText = '';
// Contador de filtros activos
$: activeFiltersCount = [filterUserId, filterAction, filterResourceType, filterDateFrom, filterDateTo, searchText].filter(f => f && f.trim()).length;
// Tipos de acciones y recursos (extraídos de los logs)
let availableActions = new Set<string>();
let availableResourceTypes = new Set<string>();
/**
* Cargar estadísticas de auditoría
*/
async function loadStats() {
try {
stats = await api.get('/audit/stats');
} catch (e) {
console.error('Error cargando estadísticas:', e);
}
}
/**
* Cargar logs de auditoría con filtros
*/
async function loadLogs() {
isLoading = true;
try {
const params: any = {
page: currentPage,
per_page: perPage
};
if (filterUserId) params.user_id = filterUserId;
if (filterAction) params.action = filterAction;
if (filterResourceType) params.resource_type = filterResourceType;
if (filterDateFrom) params.date_from = filterDateFrom;
if (filterDateTo) params.date_to = filterDateTo;
if (searchText) params.search = searchText;
const response = await api.get('/audit/', params);
logs = response.logs;
totalLogs = response.total;
totalPages = response.total_pages;
currentPage = response.page;
// Extraer acciones y tipos de recursos únicos para los selectores
logs.forEach((log: any) => {
availableActions.add(log.action);
availableResourceTypes.add(log.resource_type);
});
// Convertir Sets a Arrays para bind:value
availableActions = new Set(availableActions);
availableResourceTypes = new Set(availableResourceTypes);
} catch (e) {
toast.error('Error cargando logs: ' + (e.message || 'Error desconocido'));
} finally {
isLoading = false;
}
}
/**
* Cargar usuarios para el filtro
*/
async function loadUsers() {
try {
users = await api.get('/users/');
} catch (e) {
console.error('Error cargando usuarios:', e);
users = [];
}
}
/**
* Aplicar filtros y recargar desde página 1
*/
function applyFilters() {
currentPage = 1;
loadLogs();
}
/**
* Limpiar todos los filtros
*/
function clearFilters() {
filterUserId = '';
filterAction = '';
filterResourceType = '';
filterDateFrom = '';
filterDateTo = '';
searchText = '';
currentPage = 1;
loadLogs();
}
/**
* Cambiar página
*/
function goToPage(page: number) {
if (page >= 1 && page <= totalPages) {
currentPage = page;
loadLogs();
}
}
/**
* Ver detalle de un log
*/
function viewDetail(log: any) {
selectedLog = log;
showDetailModal = true;
}
/**
* Formatear fecha de manera amigable
*/
function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleString('es-MX', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
/**
* Obtener color de badge según tipo de acción
*/
function getActionColor(action: string): string {
if (action.includes('login')) return 'bg-green-100 text-green-800';
if (action.includes('logout')) return 'bg-gray-100 text-gray-800';
if (action.includes('create')) return 'bg-blue-100 text-blue-800';
if (action.includes('update')) return 'bg-yellow-100 text-yellow-800';
if (action.includes('delete')) return 'bg-red-100 text-red-800';
if (action.includes('assign')) return 'bg-purple-100 text-purple-800';
return 'bg-gray-100 text-gray-800';
}
/**
* Formatear acción con información del rol del usuario
*/
function formatActionWithRole(log: any): string {
const actionParts = log.action.split('.');
if (actionParts.length !== 2) return log.action;
const [resource, verb] = actionParts;
const verbMap: Record<string, string> = {
'login': 'inició sesión',
'logout': 'cerró sesión',
'create': 'creó',
'update': 'actualizó',
'delete': 'eliminó',
'assign': 'asignó',
'close': 'cerró',
'reopen': 'reabrió'
};
const roleMap: Record<string, string> = {
'ADMIN': 'Administrador',
'SUPPORT_MANAGER': 'Gerente de Soporte',
'AGENT': 'Agente',
'AUDITOR': 'Auditor',
'CLIENT_ADMIN': 'Admin de Cliente',
'CLIENT_USER': 'Usuario de Cliente'
};
const verbText = verbMap[verb] || verb;
const resourceText = resource;
// Si hay rol de usuario, incluirlo
if (log.user_role) {
const roleText = roleMap[log.user_role] || log.user_role;
return `${verbText} ${resourceText} (${roleText})`;
}
return `${verbText} ${resourceText}`;
}
/**
* Inicializar datos
*/
onMount(() => {
loadStats();
loadUsers();
loadLogs();
});
</script>
<div class="px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="sm:flex sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-semibold text-gray-900">Auditoría</h1>
<p class="mt-2 text-sm text-gray-700">
Registro completo de todas las acciones realizadas en el sistema
</p>
</div>
</div>
<!-- Estadísticas -->
{#if stats}
<div class="mt-6 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Total de Acciones</dt>
<dd class="text-lg font-semibold text-gray-900">{stats.total_actions.toLocaleString()}</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Hoy</dt>
<dd class="text-lg font-semibold text-gray-900">{stats.actions_today}</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Esta Semana</dt>
<dd class="text-lg font-semibold text-gray-900">{stats.actions_this_week}</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Acción más Común</dt>
<dd class="text-sm font-semibold text-gray-900 truncate">
{#if stats.top_actions && Object.keys(stats.top_actions).length > 0}
{Object.keys(stats.top_actions)[0]}
{:else}
N/A
{/if}
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
{/if}
<!-- Filtros -->
<div class="mt-6 bg-white shadow rounded-lg p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium text-gray-900">Filtros</h3>
{#if activeFiltersCount > 0}
<button
on:click={clearFilters}
class="text-sm text-primary-600 hover:text-primary-700 font-medium"
>
Limpiar ({activeFiltersCount})
</button>
{/if}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- Búsqueda de texto -->
<div>
<label for="search" class="block text-sm font-medium text-gray-700">Buscar</label>
<input
type="text"
id="search"
bind:value={searchText}
on:input={applyFilters}
placeholder="Buscar en acciones..."
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm"
/>
</div>
<!-- Filtro por usuario -->
<div>
<label for="user" class="block text-sm font-medium text-gray-700">Usuario</label>
<select
id="user"
bind:value={filterUserId}
on:change={applyFilters}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm"
>
<option value="">Todos los usuarios</option>
{#each users as user}
<option value={user.id}>{user.first_name} {user.last_name} ({user.email})</option>
{/each}
</select>
</div>
<!-- Filtro por acción -->
<div>
<label for="action" class="block text-sm font-medium text-gray-700">Acción</label>
<select
id="action"
bind:value={filterAction}
on:change={applyFilters}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm"
>
<option value="">Todas las acciones</option>
{#each Array.from(availableActions).sort() as action}
<option value={action}>{action}</option>
{/each}
</select>
</div>
<!-- Filtro por tipo de recurso -->
<div>
<label for="resource-type" class="block text-sm font-medium text-gray-700">Tipo de Recurso</label>
<select
id="resource-type"
bind:value={filterResourceType}
on:change={applyFilters}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm"
>
<option value="">Todos los tipos</option>
{#each Array.from(availableResourceTypes).sort() as resourceType}
<option value={resourceType}>{resourceType}</option>
{/each}
</select>
</div>
<!-- Fecha desde -->
<div>
<label for="date-from" class="block text-sm font-medium text-gray-700">Desde</label>
<input
type="date"
id="date-from"
bind:value={filterDateFrom}
on:change={applyFilters}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm"
/>
</div>
<!-- Fecha hasta -->
<div>
<label for="date-to" class="block text-sm font-medium text-gray-700">Hasta</label>
<input
type="date"
id="date-to"
bind:value={filterDateTo}
on:change={applyFilters}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm"
/>
</div>
</div>
</div>
<!-- Tabla de Logs -->
<div class="mt-6 bg-white shadow rounded-lg overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">
Logs de Auditoría
<span class="text-sm text-gray-500 font-normal">({totalLogs} registros)</span>
</h3>
</div>
{#if isLoading}
<div class="flex items-center justify-center h-64">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
</div>
{:else if logs.length === 0}
<div class="text-center py-12">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No hay logs</h3>
<p class="mt-1 text-sm text-gray-500">No se encontraron registros con los filtros aplicados.</p>
</div>
{:else}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Fecha/Hora
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Usuario
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Acción
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Recurso
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
IP
</th>
<th scope="col" class="relative px-6 py-3">
<span class="sr-only">Acciones</span>
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each logs as log (log.id)}
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatDate(log.created_at)}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
{#if log.user_email}
<div>
<div class="font-medium text-gray-900">{log.user_name || 'N/A'}</div>
<div class="text-gray-500">{log.user_email}</div>
{#if log.user_role}
<div class="text-xs text-gray-400 mt-1">
{log.user_role === 'ADMIN' ? 'Administrador' :
log.user_role === 'SUPPORT_MANAGER' ? 'Gerente de Soporte' :
log.user_role === 'AGENT' ? 'Agente' :
log.user_role === 'AUDITOR' ? 'Auditor' :
log.user_role === 'CLIENT_ADMIN' ? 'Admin de Cliente' :
log.user_role === 'CLIENT_USER' ? 'Usuario de Cliente' :
log.user_role}
</div>
{/if}
</div>
{:else}
<span class="text-gray-500 italic">Sistema</span>
{/if}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full {getActionColor(log.action)}">
{formatActionWithRole(log)}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<div>
<div class="font-medium">{log.resource_type}</div>
{#if log.resource_id}
<div class="text-gray-500 text-xs truncate max-w-xs">{log.resource_id}</div>
{/if}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{log.ip_address || 'N/A'}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
on:click={() => viewDetail(log)}
class="text-primary-600 hover:text-primary-900"
>
Ver detalle
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Paginación -->
{#if totalPages > 1}
<div class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div class="flex-1 flex justify-between sm:hidden">
<button
on:click={() => goToPage(currentPage - 1)}
disabled={currentPage === 1}
class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Anterior
</button>
<button
on:click={() => goToPage(currentPage + 1)}
disabled={currentPage === totalPages}
class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Siguiente
</button>
</div>
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700">
Mostrando
<span class="font-medium">{(currentPage - 1) * perPage + 1}</span>
a
<span class="font-medium">{Math.min(currentPage * perPage, totalLogs)}</span>
de
<span class="font-medium">{totalLogs}</span>
resultados
</p>
</div>
<div>
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
<button
on:click={() => goToPage(currentPage - 1)}
disabled={currentPage === 1}
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span class="sr-only">Anterior</span>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</button>
{#each Array.from({length: Math.min(5, totalPages)}, (_, i) => i + Math.max(1, Math.min(currentPage - 2, totalPages - 4))) as page}
<button
on:click={() => goToPage(page)}
class="relative inline-flex items-center px-4 py-2 border text-sm font-medium {page === currentPage ? 'z-10 bg-primary-50 border-primary-500 text-primary-600' : 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'}"
>
{page}
</button>
{/each}
<button
on:click={() => goToPage(currentPage + 1)}
disabled={currentPage === totalPages}
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span class="sr-only">Siguiente</span>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
</button>
</nav>
</div>
</div>
</div>
{/if}
{/if}
</div>
</div>
<!-- Modal de Detalle -->
{#if showDetailModal && selectedLog}
<Modal title="Detalle del Log de Auditoría" on:close={() => showDetailModal = false}>
<div class="space-y-4">
<!-- Información General -->
<div>
<h4 class="text-sm font-medium text-gray-900 mb-2">Información General</h4>
<dl class="grid grid-cols-2 gap-3 text-sm">
<div>
<dt class="font-medium text-gray-500">ID:</dt>
<dd class="text-gray-900 font-mono text-xs">{selectedLog.id}</dd>
</div>
<div>
<dt class="font-medium text-gray-500">Fecha:</dt>
<dd class="text-gray-900">{formatDate(selectedLog.created_at)}</dd>
</div>
<div>
<dt class="font-medium text-gray-500">Usuario:</dt>
<dd class="text-gray-900">{selectedLog.user_name || 'Sistema'}</dd>
</div>
<div>
<dt class="font-medium text-gray-500">Email:</dt>
<dd class="text-gray-900">{selectedLog.user_email || 'N/A'}</dd>
</div>
{#if selectedLog.user_role}
<div>
<dt class="font-medium text-gray-500">Rol:</dt>
<dd class="text-gray-900">
{selectedLog.user_role === 'ADMIN' ? 'Administrador' :
selectedLog.user_role === 'SUPPORT_MANAGER' ? 'Gerente de Soporte' :
selectedLog.user_role === 'AGENT' ? 'Agente' :
selectedLog.user_role === 'AUDITOR' ? 'Auditor' :
selectedLog.user_role === 'CLIENT_ADMIN' ? 'Admin de Cliente' :
selectedLog.user_role === 'CLIENT_USER' ? 'Usuario de Cliente' :
selectedLog.user_role}
</dd>
</div>
{/if}
<div>
<dt class="font-medium text-gray-500">IP:</dt>
<dd class="text-gray-900">{selectedLog.ip_address || 'N/A'}</dd>
</div>
<div>
<dt class="font-medium text-gray-500">Correlation ID:</dt>
<dd class="text-gray-900 font-mono text-xs">{selectedLog.correlation_id || 'N/A'}</dd>
</div>
</dl>
</div>
<!-- Acción -->
<div>
<h4 class="text-sm font-medium text-gray-900 mb-2">Acción</h4>
<div class="bg-gray-50 rounded-lg p-3">
<span class="px-2 py-1 text-xs font-semibold rounded-full {getActionColor(selectedLog.action)}">
{selectedLog.action}
</span>
<p class="mt-2 text-sm text-gray-700">{formatActionWithRole(selectedLog)}</p>
</div>
</div>
<!-- Recurso -->
<div>
<h4 class="text-sm font-medium text-gray-900 mb-2">Recurso Afectado</h4>
<div class="bg-gray-50 rounded-lg p-3 text-sm">
<div><span class="font-medium">Tipo:</span> {selectedLog.resource_type}</div>
{#if selectedLog.resource_id}
<div class="mt-1"><span class="font-medium">ID:</span> <code class="text-xs">{selectedLog.resource_id}</code></div>
{/if}
</div>
</div>
<!-- User Agent -->
{#if selectedLog.user_agent}
<div>
<h4 class="text-sm font-medium text-gray-900 mb-2">User Agent</h4>
<div class="bg-gray-50 rounded-lg p-3 text-xs font-mono text-gray-600 break-all">
{selectedLog.user_agent}
</div>
</div>
{/if}
<!-- Valores Anteriores -->
{#if selectedLog.old_values}
<div>
<h4 class="text-sm font-medium text-gray-900 mb-2">Valores Anteriores</h4>
<pre class="bg-gray-50 rounded-lg p-3 text-xs font-mono text-gray-600 overflow-auto max-h-40">{JSON.stringify(selectedLog.old_values, null, 2)}</pre>
</div>
{/if}
<!-- Valores Nuevos -->
{#if selectedLog.new_values}
<div>
<h4 class="text-sm font-medium text-gray-900 mb-2">Valores Nuevos</h4>
<pre class="bg-gray-50 rounded-lg p-3 text-xs font-mono text-gray-600 overflow-auto max-h-40">{JSON.stringify(selectedLog.new_values, null, 2)}</pre>
</div>
{/if}
<!-- Metadata -->
{#if selectedLog.metadata}
<div>
<h4 class="text-sm font-medium text-gray-900 mb-2">Metadata Adicional</h4>
<pre class="bg-gray-50 rounded-lg p-3 text-xs font-mono text-gray-600 overflow-auto max-h-40">{JSON.stringify(selectedLog.metadata, null, 2)}</pre>
</div>
{/if}
</div>
<div slot="footer" class="flex justify-end">
<button
on:click={() => showDetailModal = false}
class="px-4 py-2 bg-white border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Cerrar
</button>
</div>
</Modal>
{/if}

View File

@@ -145,8 +145,7 @@
</div> </div>
</div> </div>
{#if showModal} <Modal open={showModal} title={editingCategory ? 'Editar Categoría' : 'Nueva Categoría'} on:close={() => showModal = false}>
<Modal title={editingCategory ? 'Editar Categoría' : 'Nueva Categoría'} on:close={() => showModal = false}>
<form on:submit|preventDefault={handleSubmit} class="space-y-4"> <form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div> <div>
<label for="name" class="block text-sm font-medium text-gray-700">Nombre</label> <label for="name" class="block text-sm font-medium text-gray-700">Nombre</label>
@@ -183,4 +182,3 @@
</div> </div>
</form> </form>
</Modal> </Modal>
{/if}

View File

@@ -118,8 +118,7 @@
</div> </div>
</div> </div>
{#if showModal} <Modal open={showModal} title={editingSystem ? 'Editar Sistema' : 'Nuevo Sistema'} on:close={() => showModal = false}>
<Modal title={editingSystem ? 'Editar Sistema' : 'Nuevo Sistema'} on:close={() => showModal = false}>
<form on:submit|preventDefault={handleSubmit} class="space-y-4"> <form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div> <div>
<label for="name" class="block text-sm font-medium text-gray-700">Nombre</label> <label for="name" class="block text-sm font-medium text-gray-700">Nombre</label>
@@ -146,4 +145,3 @@
</div> </div>
</form> </form>
</Modal> </Modal>
{/if}

View File

@@ -126,8 +126,7 @@
</div> </div>
</div> </div>
{#if showModal} <Modal open={showModal} title={editingTenant ? 'Editar Cliente' : 'Nuevo Cliente'} on:close={() => showModal = false}>
<Modal title={editingTenant ? 'Editar Cliente' : 'Nuevo Cliente'} on:close={() => showModal = false}>
<form on:submit|preventDefault={handleSubmit} class="space-y-4"> <form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div> <div>
<label for="name" class="block text-sm font-medium text-gray-700">Nombre</label> <label for="name" class="block text-sm font-medium text-gray-700">Nombre</label>
@@ -165,4 +164,3 @@
</div> </div>
</form> </form>
</Modal> </Modal>
{/if}

View File

@@ -9,7 +9,6 @@
let categories = []; let categories = [];
let systems = []; let systems = [];
let users = []; let users = [];
let tenants = []; // Nueva lista de tenants
let isLoading = false; let isLoading = false;
let showModal = false; let showModal = false;
let showEditModal = false; let showEditModal = false;
@@ -19,15 +18,6 @@
// Filtros // Filtros
let filterStatus = ''; let filterStatus = '';
let filterPriority = ''; let filterPriority = '';
let filterTenant = ''; // Nuevo filtro por cliente/tenant
let filterCategory = ''; // Filtro por categoría
let filterAssignedTo = ''; // Filtro por asignado a
let searchText = ''; // Búsqueda por texto
let filterDateFrom = ''; // Fecha desde
let filterDateTo = ''; // Fecha hasta
// Estado de filtros
$: activeFiltersCount = [filterTenant, filterStatus, filterPriority, filterCategory, filterAssignedTo, searchText, filterDateFrom, filterDateTo].filter(f => f && f.trim()).length;
// Form para editar // Form para editar
let editFormData = { let editFormData = {
@@ -60,34 +50,25 @@
{ value: 'URGENT', label: 'Urgente', color: 'red' } { value: 'URGENT', label: 'Urgente', color: 'red' }
]; ];
// Ajustar la función loadData para usar el endpoint administrativo con filtros // Ajustar la función loadData para asegurar que los filtros se envíen correctamente
async function loadData() { async function loadData() {
isLoading = true; isLoading = true;
try { try {
// Preparar parámetros filtrando valores vacíos const [ticketsData, categoriesData, systemsData, usersData] = await Promise.all([
const ticketParams = {}; api.get('/tickets/', {
if (filterStatus) ticketParams.status_filter = filterStatus; params: {
if (filterPriority) ticketParams.priority_filter = filterPriority; status: filterStatus || undefined,
if (filterTenant) ticketParams.tenant_id_filter = filterTenant; priority: filterPriority || undefined
if (filterCategory) ticketParams.category_filter = filterCategory; }
if (filterAssignedTo) ticketParams.assigned_to_filter = filterAssignedTo; }),
if (searchText) ticketParams.search = searchText;
if (filterDateFrom) ticketParams.date_from = filterDateFrom;
if (filterDateTo) ticketParams.date_to = filterDateTo;
const [ticketsData, categoriesData, systemsData, usersData, tenantsData] = await Promise.all([
// Usar el nuevo endpoint administrativo
api.get('/tickets/admin/all', ticketParams),
api.get('/categories/'), api.get('/categories/'),
api.get('/systems/'), api.get('/systems/'),
api.get('/users/'), api.get('/users/')
api.get('/tenants/') // Cargar lista de tenants
]); ]);
tickets = ticketsData; tickets = ticketsData;
categories = categoriesData; categories = categoriesData;
systems = systemsData; systems = systemsData;
users = usersData; users = usersData;
tenants = tenantsData;
} catch (e) { } catch (e) {
toast.error('Error cargando datos: ' + (e.message || 'Error desconocido')); toast.error('Error cargando datos: ' + (e.message || 'Error desconocido'));
} finally { } finally {
@@ -99,28 +80,6 @@
function applyFilters() { function applyFilters() {
loadData(); loadData();
} }
// Limpiar todos los filtros
function clearFilters() {
filterStatus = '';
filterPriority = '';
filterTenant = '';
filterCategory = '';
filterAssignedTo = '';
searchText = '';
filterDateFrom = '';
filterDateTo = '';
applyFilters();
}
// Búsqueda en tiempo real (debounced)
let searchTimeout;
function handleSearchInput() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
applyFilters();
}, 500);
}
function openCreateModal() { function openCreateModal() {
selectedTicket = null; selectedTicket = null;
@@ -260,31 +219,12 @@
</script> </script>
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8"> <div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="sm:flex sm:items-center sm:justify-between"> <div class="sm:flex sm:items-center">
<div class="sm:flex-auto"> <div class="sm:flex-auto">
<h1 class="text-xl font-semibold text-gray-900">Tickets de Soporte</h1> <h1 class="text-xl font-semibold text-gray-900">Tickets de Soporte</h1>
<p class="mt-2 text-sm text-gray-700"> <p class="mt-2 text-sm text-gray-700">Gestión de tickets del sistema de mesa de ayuda.</p>
Gestión de tickets del sistema de mesa de ayuda.
{#if activeFiltersCount > 0}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 ml-2">
{activeFiltersCount} filtro{activeFiltersCount !== 1 ? 's' : ''} activo{activeFiltersCount !== 1 ? 's' : ''}
</span>
{/if}
</p>
</div> </div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none space-x-3"> <div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
{#if activeFiltersCount > 0}
<button
type="button"
on:click={clearFilters}
class="inline-flex items-center justify-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50"
>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
Limpiar filtros
</button>
{/if}
<button <button
type="button" type="button"
on:click={openCreateModal} on:click={openCreateModal}
@@ -295,139 +235,46 @@
</div> </div>
</div> </div>
<!-- Filtros Avanzados --> <!-- Filtros -->
<div class="mt-6 bg-white shadow sm:rounded-lg"> <div class="mt-6 bg-white shadow sm:rounded-lg p-4">
<div class="px-4 py-5 sm:p-6"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">Filtros</h3> <div>
<label for="filterStatus" class="block text-sm font-medium text-gray-700">Estado</label>
<!-- Primera fila - Búsqueda y Filtros principales --> <select
<div class="grid grid-cols-1 gap-4 sm:grid-cols-4 mb-4"> id="filterStatus"
<!-- Búsqueda por texto --> bind:value={filterStatus}
<div class="sm:col-span-2"> on:change={applyFilters}
<label for="searchText" class="block text-sm font-medium text-gray-700 mb-1">Búsqueda</label> class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
<div class="relative"> >
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <option value="">Todos</option>
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> {#each STATUSES as status}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> <option value={status.value}>{status.label}</option>
</svg> {/each}
</div> </select>
<input
type="text"
id="searchText"
bind:value={searchText}
on:input={handleSearchInput}
placeholder="Buscar en título o descripción..."
class="pl-10 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
/>
</div>
</div>
<!-- Cliente/Empresa -->
<div>
<label for="filterTenant" class="block text-sm font-medium text-gray-700 mb-1">Cliente/Empresa</label>
<select
id="filterTenant"
bind:value={filterTenant}
on:change={applyFilters}
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
>
<option value="">Todos los clientes</option>
{#each tenants as tenant}
<option value={tenant.id}>{tenant.name}</option>
{/each}
</select>
</div>
<!-- Estado -->
<div>
<label for="filterStatus" class="block text-sm font-medium text-gray-700 mb-1">Estado</label>
<select
id="filterStatus"
bind:value={filterStatus}
on:change={applyFilters}
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
>
<option value="">Todos</option>
{#each STATUSES as status}
<option value={status.value}>{status.label}</option>
{/each}
</select>
</div>
</div> </div>
<!-- Segunda fila - Filtros secundarios --> <div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-5"> <label for="filterPriority" class="block text-sm font-medium text-gray-700">Prioridad</label>
<!-- Prioridad --> <select
<div> id="filterPriority"
<label for="filterPriority" class="block text-sm font-medium text-gray-700 mb-1">Prioridad</label> bind:value={filterPriority}
<select on:change={applyFilters}
id="filterPriority" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
bind:value={filterPriority} >
on:change={applyFilters} <option value="">Todas</option>
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" {#each PRIORITIES as priority}
> <option value={priority.value}>{priority.label}</option>
<option value="">Todas</option> {/each}
{#each PRIORITIES as priority} </select>
<option value={priority.value}>{priority.label}</option> </div>
{/each}
</select> <div class="flex items-end">
</div> <button
on:click={loadData}
<!-- Categoría --> class="w-full inline-flex justify-center items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
<div> >
<label for="filterCategory" class="block text-sm font-medium text-gray-700 mb-1">Categoría</label> Actualizar
<select </button>
id="filterCategory"
bind:value={filterCategory}
on:change={applyFilters}
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
>
<option value="">Todas</option>
{#each categories as category}
<option value={category.id}>{category.name}</option>
{/each}
</select>
</div>
<!-- Asignado a -->
<div>
<label for="filterAssignedTo" class="block text-sm font-medium text-gray-700 mb-1">Asignado a</label>
<select
id="filterAssignedTo"
bind:value={filterAssignedTo}
on:change={applyFilters}
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
>
<option value="">Todos</option>
{#each users.filter(u => u.role === 'AGENT' || u.role === 'SUPPORT_MANAGER' || u.role === 'ADMIN') as user}
<option value={user.id}>{user.first_name} {user.last_name}</option>
{/each}
</select>
</div>
<!-- Fecha desde -->
<div>
<label for="filterDateFrom" class="block text-sm font-medium text-gray-700 mb-1">Desde</label>
<input
type="date"
id="filterDateFrom"
bind:value={filterDateFrom}
on:change={applyFilters}
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
/>
</div>
<!-- Fecha hasta -->
<div>
<label for="filterDateTo" class="block text-sm font-medium text-gray-700 mb-1">Hasta</label>
<input
type="date"
id="filterDateTo"
bind:value={filterDateTo}
on:change={applyFilters}
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
/>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -441,13 +288,12 @@
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <tr>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Ticket</th> <th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Ticket</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Cliente/Empresa</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Asunto</th> <th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Asunto</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Estado</th> <th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Estado</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Prioridad</th> <th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Prioridad</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Creado por</th> <th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Categoría</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Asignado a</th> <th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Asignado a</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Fecha</th> <th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Creado</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6"> <th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">Acciones</span> <span class="sr-only">Acciones</span>
</th> </th>
@@ -455,9 +301,9 @@
</thead> </thead>
<tbody class="divide-y divide-gray-200 bg-white"> <tbody class="divide-y divide-gray-200 bg-white">
{#if isLoading} {#if isLoading}
<tr><td colspan="9" class="text-center py-4">Cargando...</td></tr> <tr><td colspan="8" class="text-center py-4">Cargando...</td></tr>
{:else if tickets.length === 0} {:else if tickets.length === 0}
<tr><td colspan="9" class="text-center py-4">No hay tickets registrados</td></tr> <tr><td colspan="8" class="text-center py-4">No hay tickets registrados</td></tr>
{:else} {:else}
{#each tickets as ticket} {#each tickets as ticket}
<tr <tr
@@ -467,10 +313,6 @@
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6"> <td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">
{ticket.ticket_number || ticket.id.substring(0, 8)} {ticket.ticket_number || ticket.id.substring(0, 8)}
</td> </td>
<td class="px-3 py-4 text-sm text-gray-900">
<div class="font-medium text-indigo-600">{ticket.tenant?.name || 'N/A'}</div>
<div class="text-xs text-gray-500">{ticket.tenant?.contact_email || ''}</div>
</td>
<td class="px-3 py-4 text-sm text-gray-900"> <td class="px-3 py-4 text-sm text-gray-900">
<div class="font-medium">{ticket.subject}</div> <div class="font-medium">{ticket.subject}</div>
<div class="text-gray-500 truncate max-w-xs">{ticket.description}</div> <div class="text-gray-500 truncate max-w-xs">{ticket.description}</div>
@@ -485,10 +327,8 @@
{getPriorityBadge(ticket.priority).label} {getPriorityBadge(ticket.priority).label}
</span> </span>
</td> </td>
<td class="px-3 py-4 text-sm text-gray-900"> <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<div class="font-medium">{ticket.created_by_user?.first_name} {ticket.created_by_user?.last_name}</div> {getCategoryName(ticket.category_id)}
<div class="text-xs text-gray-500">{ticket.created_by_user?.email}</div>
<div class="text-xs text-indigo-600">{ticket.created_by_user?.role || ''}</div>
</td> </td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"> <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{getUserName(ticket.assigned_to)} {getUserName(ticket.assigned_to)}
@@ -522,8 +362,7 @@
</div> </div>
<!-- Modal Crear Ticket --> <!-- Modal Crear Ticket -->
{#if showModal} <Modal open={showModal} title="Nuevo Ticket" on:close={() => showModal = false}>
<Modal title="Nuevo Ticket" on:close={() => showModal = false}>
<form on:submit|preventDefault={handleSubmit} class="space-y-4"> <form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div> <div>
<label for="subject" class="block text-sm font-medium text-gray-700">Asunto *</label> <label for="subject" class="block text-sm font-medium text-gray-700">Asunto *</label>
@@ -609,11 +448,9 @@
</div> </div>
</form> </form>
</Modal> </Modal>
{/if}
<!-- Modal Editar Ticket --> <!-- Modal Editar Ticket -->
{#if showEditModal} <Modal open={showEditModal} title="Editar Ticket #{selectedTicket?.ticket_number || ''}" on:close={() => showEditModal = false}>
<Modal title="Editar Ticket #{selectedTicket?.ticket_number || ''}" on:close={() => showEditModal = false}>
<form on:submit|preventDefault={handleUpdate} class="space-y-4"> <form on:submit|preventDefault={handleUpdate} class="space-y-4">
<div> <div>
<label for="editStatus" class="block text-sm font-medium text-gray-700">Estado</label> <label for="editStatus" class="block text-sm font-medium text-gray-700">Estado</label>
@@ -677,11 +514,9 @@
</div> </div>
</form> </form>
</Modal> </Modal>
{/if}
<!-- Modal Eliminar Ticket --> <!-- Modal Eliminar Ticket -->
{#if showDeleteModal} <Modal open={showDeleteModal} title="Eliminar Ticket" on:close={() => showDeleteModal = false}>
<Modal title="Eliminar Ticket" on:close={() => showDeleteModal = false}>
<div class="space-y-4"> <div class="space-y-4">
<div class="bg-red-50 border border-red-200 rounded-md p-4"> <div class="bg-red-50 border border-red-200 rounded-md p-4">
<div class="flex"> <div class="flex">
@@ -723,5 +558,4 @@
</button> </button>
</div> </div>
</div> </div>
</Modal> </Modal>
{/if}

View File

@@ -172,8 +172,7 @@
</div> </div>
</div> </div>
{#if showModal} <Modal open={showModal} title={editingUser ? 'Editar Usuario' : 'Nuevo Usuario'} on:close={() => showModal = false}>
<Modal title={editingUser ? 'Editar Usuario' : 'Nuevo Usuario'} on:close={() => showModal = false}>
<form on:submit|preventDefault={handleSubmit} class="space-y-4"> <form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div> <div>
@@ -230,4 +229,3 @@
</div> </div>
</form> </form>
</Modal> </Modal>
{/if}

View File

@@ -1,262 +0,0 @@
#!/usr/bin/env python3
"""
Database Utilities Script
Herramientas administrativas para gestión de base de datos
Uso:
python scripts/db_utils.py list-users [--tenant-id UUID]
python scripts/db_utils.py check-user EMAIL
python scripts/db_utils.py reset-password EMAIL [--password PASSWORD]
python scripts/db_utils.py list-tickets [--tenant-id UUID] [--limit N]
python scripts/db_utils.py check-ticket TICKET_ID
Ejemplos:
python scripts/db_utils.py list-users
python scripts/db_utils.py check-user admin@example.com
python scripts/db_utils.py reset-password admin@example.com --password admin123
python scripts/db_utils.py list-tickets --limit 10
"""
import asyncio
import sys
import os
from typing import Optional
import argparse
from pathlib import Path
# Agregar backend al path para imports
backend_path = Path(__file__).parent.parent / "backend"
sys.path.insert(0, str(backend_path))
from sqlalchemy import select
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from app.models.user import User
from app.models.ticket import Ticket
from app.models.tenant import Tenant
from app.core.security import SecurityUtils
class DBUtils:
"""Utilidades de gestión de base de datos"""
def __init__(self, database_url: Optional[str] = None):
self.database_url = database_url or os.getenv(
'DATABASE_URL',
'postgresql+asyncpg://postgres:postgres@localhost:5432/servicemanager'
)
self.engine = create_async_engine(self.database_url, echo=False)
self.async_session = sessionmaker(
self.engine,
class_=AsyncSession,
expire_on_commit=False
)
async def list_users(self, tenant_id: Optional[str] = None):
"""Listar todos los usuarios"""
async with self.async_session() as session:
query = select(User)
if tenant_id:
query = query.where(User.tenant_id == tenant_id)
result = await session.execute(query)
users = result.scalars().all()
if not users:
print("❌ No se encontraron usuarios")
return
print(f"\n{'='*80}")
print(f"📋 USUARIOS ({len(users)} encontrados)")
print(f"{'='*80}\n")
for user in users:
print(f" Email: {user.email}")
print(f" Role: {user.role}")
print(f" ID: {user.id}")
print(f" Tenant ID: {user.tenant_id}")
print(f" Activo: {'' if user.is_active else ''}")
print(f" {'-'*76}")
async def check_user(self, email: str):
"""Verificar información de un usuario específico"""
async with self.async_session() as session:
result = await session.execute(
select(User).where(User.email == email)
)
user = result.scalar_one_or_none()
if not user:
print(f"❌ Usuario '{email}' no encontrado")
return
print(f"\n{'='*80}")
print(f"👤 INFORMACIÓN DEL USUARIO")
print(f"{'='*80}\n")
print(f" Email: {user.email}")
print(f" Nombre: {user.first_name} {user.last_name}")
print(f" Role: {user.role}")
print(f" ID: {user.id}")
print(f" Tenant ID: {user.tenant_id}")
print(f" Activo: {'' if user.is_active else ''}")
print(f" 2FA: {'✅ Habilitado' if user.totp_secret else '❌ Deshabilitado'}")
print(f" Creado: {user.created_at}")
print(f"\n{'='*80}")
async def reset_password(self, email: str, new_password: str = "admin123"):
"""Resetear contraseña de un usuario"""
async with self.async_session() as session:
result = await session.execute(
select(User).where(User.email == email)
)
user = result.scalar_one_or_none()
if not user:
print(f"❌ Usuario '{email}' no encontrado")
return
# Hash nueva contraseña
password_hash = SecurityUtils.hash_password(new_password)
user.password_hash = password_hash
try:
await session.commit()
print(f"\n✅ Contraseña actualizada exitosamente")
print(f" Usuario: {email}")
print(f" Nueva contraseña: {new_password}")
print(f"\n⚠️ IMPORTANTE: Cambia esta contraseña después del primer login")
except Exception as e:
await session.rollback()
print(f"❌ Error al actualizar contraseña: {e}")
async def list_tickets(self, tenant_id: Optional[str] = None, limit: int = 20):
"""Listar tickets"""
async with self.async_session() as session:
query = select(Ticket).order_by(Ticket.created_at.desc()).limit(limit)
if tenant_id:
query = query.where(Ticket.tenant_id == tenant_id)
result = await session.execute(query)
tickets = result.scalars().all()
if not tickets:
print("❌ No se encontraron tickets")
return
print(f"\n{'='*80}")
print(f"🎫 TICKETS ({len(tickets)} encontrados, límite: {limit})")
print(f"{'='*80}\n")
for ticket in tickets:
print(f" {ticket.ticket_number} | {ticket.status} | {ticket.priority}")
print(f" Asunto: {ticket.subject}")
print(f" ID: {ticket.id}")
print(f" Tenant: {ticket.tenant_id}")
print(f" Creado: {ticket.created_at}")
print(f" {'-'*76}")
async def check_ticket(self, ticket_id: str):
"""Verificar información de un ticket específico"""
async with self.async_session() as session:
result = await session.execute(
select(Ticket).where(Ticket.id == ticket_id)
)
ticket = result.scalar_one_or_none()
if not ticket:
print(f"❌ Ticket '{ticket_id}' no encontrado")
return
# Obtener creador
creator_result = await session.execute(
select(User).where(User.id == ticket.created_by)
)
creator = creator_result.scalar_one_or_none()
# Obtener asignado
assigned = None
if ticket.assigned_to:
assigned_result = await session.execute(
select(User).where(User.id == ticket.assigned_to)
)
assigned = assigned_result.scalar_one_or_none()
print(f"\n{'='*80}")
print(f"🎫 INFORMACIÓN DEL TICKET")
print(f"{'='*80}\n")
print(f" Número: {ticket.ticket_number}")
print(f" Asunto: {ticket.subject}")
print(f" Estado: {ticket.status}")
print(f" Prioridad: {ticket.priority}")
print(f" ID: {ticket.id}")
print(f" Tenant ID: {ticket.tenant_id}")
if creator:
print(f" Creado por: {creator.email} ({creator.role})")
if assigned:
print(f" Asignado a: {assigned.email} ({assigned.role})")
print(f" Creado: {ticket.created_at}")
print(f" Actualizado: {ticket.updated_at}")
print(f"\n{'='*80}")
async def close(self):
"""Cerrar conexión"""
await self.engine.dispose()
async def main():
parser = argparse.ArgumentParser(
description='Utilidades de gestión de base de datos',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__
)
subparsers = parser.add_subparsers(dest='command', help='Comando a ejecutar')
# list-users
list_users_parser = subparsers.add_parser('list-users', help='Listar usuarios')
list_users_parser.add_argument('--tenant-id', help='Filtrar por tenant ID')
# check-user
check_user_parser = subparsers.add_parser('check-user', help='Verificar usuario')
check_user_parser.add_argument('email', help='Email del usuario')
# reset-password
reset_password_parser = subparsers.add_parser('reset-password', help='Resetear contraseña')
reset_password_parser.add_argument('email', help='Email del usuario')
reset_password_parser.add_argument('--password', default='admin123', help='Nueva contraseña')
# list-tickets
list_tickets_parser = subparsers.add_parser('list-tickets', help='Listar tickets')
list_tickets_parser.add_argument('--tenant-id', help='Filtrar por tenant ID')
list_tickets_parser.add_argument('--limit', type=int, default=20, help='Límite de resultados')
# check-ticket
check_ticket_parser = subparsers.add_parser('check-ticket', help='Verificar ticket')
check_ticket_parser.add_argument('ticket_id', help='ID del ticket')
args = parser.parse_args()
if not args.command:
parser.print_help()
return
utils = DBUtils()
try:
if args.command == 'list-users':
await utils.list_users(args.tenant_id)
elif args.command == 'check-user':
await utils.check_user(args.email)
elif args.command == 'reset-password':
await utils.reset_password(args.email, args.password)
elif args.command == 'list-tickets':
await utils.list_tickets(args.tenant_id, args.limit)
elif args.command == 'check-ticket':
await utils.check_ticket(args.ticket_id)
finally:
await utils.close()
if __name__ == "__main__":
asyncio.run(main())