Compare commits
27 Commits
version-1.
...
6687802f19
| Author | SHA1 | Date | |
|---|---|---|---|
| 6687802f19 | |||
| b76fcb1390 | |||
| 80d41c9487 | |||
| b67a384923 | |||
| d691b71c44 | |||
| 5e58d53416 | |||
| 1a301409de | |||
| 12cbbd1b80 | |||
| 3f7b166767 | |||
| 6bc5145b9c | |||
| 597286fff0 | |||
| e141701567 | |||
| 7003e5cd80 | |||
| d8232fda7c | |||
| 16b3dc5d47 | |||
| 3b46f48655 | |||
| f10b15d91b | |||
| 49dfb3ef24 | |||
| b187aa1b46 | |||
| cd3d7e816f | |||
| 63925fe305 | |||
| c146a6c3c3 | |||
| ba779bde55 | |||
| ba94152074 | |||
| bd21207aae | |||
| 1ccc39732b | |||
| ceea67eb2b |
@@ -1,726 +0,0 @@
|
||||
# ServiceManagerWeb — Versión 1.10.0
|
||||
## Reporte Técnico de Cambios y Mejoras
|
||||
|
||||
---
|
||||
|
||||
**Proyecto:** ServiceManagerWeb – Mesa de Ayuda B2B Multi-tenant
|
||||
**Versión:** 1.10.0
|
||||
**Versión base:** 1.8.0 (commit `e644039`) / 1.9.0 (commit `16d795e`)
|
||||
**Fecha:** 19 de Febrero de 2026
|
||||
**Estado:** Sistema Funcional — Producción MVP
|
||||
**Empresa:** Aduanasoft
|
||||
**Autor:** Equipo de Desarrollo
|
||||
|
||||
---
|
||||
|
||||
## Resumen Ejecutivo
|
||||
|
||||
La versión 1.10.0 representa una fase de refactorización técnica profunda, optimización de rendimiento, y mejoras significativas en la interfaz de usuario. Los cambios abarcan el ciclo completo del sistema: backend (Python/FastAPI), frontend interno (SvelteKit), frontend cliente (SvelteKit) y la capa de infraestructura (Docker).
|
||||
|
||||
### Métricas Globales de esta Versión
|
||||
|
||||
| Indicador | Valor |
|
||||
|---|---|
|
||||
| Archivos modificados | 31 archivos |
|
||||
| Líneas añadidas (total) | ~3,250 líneas |
|
||||
| Líneas eliminadas (total) | ~3,440 líneas |
|
||||
| Reducción neta de código | ~190 líneas (refactorización limpia) |
|
||||
| Archivos nuevos creados | 14 archivos |
|
||||
| Archivos eliminados | 3 scripts de prueba temporales |
|
||||
| Scripts reorganizados | 3 (movidos a `backend/scripts/`) |
|
||||
|
||||
---
|
||||
|
||||
## 1. Backend — Python / FastAPI
|
||||
|
||||
### 1.1 `backend/app/api/v1/endpoints/audit.py`
|
||||
**Cambios:** +166 líneas añadidas / −1,119 líneas eliminadas
|
||||
**Balance neto:** −953 líneas (reducción del 74% del archivo)
|
||||
|
||||
#### Problema detectado
|
||||
El archivo `audit.py` tenía 1,285 líneas en la versión 1.8.0. Toda la lógica de detección de amenazas, análisis de seguridad y transformación de datos estaba inline dentro de las funciones de cada endpoint, generando duplicación masiva y dificultando el mantenimiento.
|
||||
|
||||
#### Cambios realizados
|
||||
|
||||
**a) Extracción de lógica a módulo auxiliar**
|
||||
Se creó el archivo `backend/app/api/v1/audit_helpers.py` (nuevo, ver sección 1.8) con las siguientes funciones extraídas del archivo original:
|
||||
|
||||
```python
|
||||
# ANTES — en audit.py líneas 420-580 (inline)
|
||||
# Toda la lógica de detección de amenazas vivía dentro de la función
|
||||
# get_security_analysis() sin separación alguna
|
||||
|
||||
# DESPUÉS — importado desde audit_helpers.py
|
||||
from app.api.v1.audit_helpers import (
|
||||
audit_log_to_dict,
|
||||
apply_tenant_filter,
|
||||
get_count_stat,
|
||||
get_top_items,
|
||||
detect_mass_deletions,
|
||||
detect_brute_force,
|
||||
detect_privilege_escalation
|
||||
)
|
||||
```
|
||||
|
||||
**b) Docstrings compactados**
|
||||
Los docstrings multilínea extensos se compactaron a una sola línea donde el nombre era autoexplicativo:
|
||||
|
||||
```python
|
||||
# ANTES (líneas 1-7 del archivo original)
|
||||
"""
|
||||
Audit Endpoints - ServiceManagerWeb
|
||||
|
||||
Endpoints para consulta de logs de auditoría.
|
||||
Solo accesible por roles: ADMIN, SUPPORT_MANAGER, AUDITOR
|
||||
"""
|
||||
|
||||
# DESPUÉS (línea 1)
|
||||
"""Audit Endpoints - ServiceManagerWeb"""
|
||||
```
|
||||
|
||||
**c) Firma de función require_auditor_role refactorizada**
|
||||
```python
|
||||
# ANTES (líneas 32-47) — 16 líneas
|
||||
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..."
|
||||
)
|
||||
return current_user
|
||||
|
||||
# DESPUÉS (líneas 20-24) — 5 líneas
|
||||
def require_auditor_role(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""Verifica que el usuario tenga rol de auditor"""
|
||||
if current_user.role not in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AUDITOR]:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Solo usuarios con rol ADMIN, SUPPORT_MANAGER o AUDITOR pueden acceder")
|
||||
return current_user
|
||||
```
|
||||
|
||||
**d) Firma del endpoint `get_audit_logs` compactada**
|
||||
```python
|
||||
# ANTES (líneas 49-72) — 24 líneas de parámetros separados
|
||||
|
||||
# DESPUÉS — parámetros agrupados en 6 líneas
|
||||
async def get_audit_logs(
|
||||
page: int = Query(default=1, ge=1),
|
||||
per_page: int = Query(default=50, ge=1, le=100),
|
||||
user_id: Optional[uuid.UUID] = Query(None),
|
||||
action: Optional[str] = Query(None),
|
||||
...
|
||||
```
|
||||
|
||||
**e) Corrección del schema `SecurityAnalysisResponse`**
|
||||
Se añadieron todos los campos requeridos que causaban error 500 al serializar la respuesta. Los campos faltantes eran:
|
||||
- `analysis_period_hours`
|
||||
- `total_threats_detected`
|
||||
- `suspicious_ips_count`
|
||||
- `critical_actions_count`
|
||||
|
||||
---
|
||||
|
||||
### 1.2 `backend/app/api/v1/endpoints/auth.py`
|
||||
**Cambios:** +369 líneas añadidas / −40 líneas eliminadas
|
||||
**Balance neto:** +329 líneas
|
||||
|
||||
#### Cambios realizados
|
||||
Se amplió la cobertura de autenticación con:
|
||||
- Manejo robusto de tokens de refresco
|
||||
- Validación mejorada de credenciales con mensajes de error específicos
|
||||
- Soporte para recuperación de contraseña por email
|
||||
- Integración con el servicio de email (`app/core/email.py`)
|
||||
- Logging estructurado con `structlog` en todos los endpoints críticos
|
||||
|
||||
---
|
||||
|
||||
### 1.3 `backend/app/api/v1/endpoints/tickets.py`
|
||||
**Cambios:** +217 líneas añadidas / −872 líneas eliminadas
|
||||
**Balance neto:** −655 líneas (reducción del 60%)
|
||||
|
||||
#### Problema detectado
|
||||
Igual que `audit.py`, el archivo de tickets tenía lógica de negocio repetida y funciones helper inline.
|
||||
|
||||
#### Cambios realizados
|
||||
|
||||
**a) Extracción a `backend/app/api/v1/helpers.py`**
|
||||
Se creó un módulo auxiliar general (nuevo, ver sección 1.9) con funciones reutilizables como:
|
||||
- `build_ticket_query()` — construye queries SQLAlchemy con filtros dinámicos
|
||||
- `paginate_query()` — paginación genérica reutilizable
|
||||
- `format_ticket_response()` — serialización consistente
|
||||
|
||||
**b) Corrección del error de `sla_breached`**
|
||||
```python
|
||||
# ANTES — causaba AttributeError / 500 en producción
|
||||
response.sla_breached = ticket.sla_breached # ← propiedad no existía
|
||||
|
||||
# DESPUÉS — removido, calculado dinámicamente
|
||||
# sla_breached se calcula desde sla_deadline vs datetime.utcnow()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.4 `backend/app/api/schemas/__init__.py`
|
||||
**Cambios:** +54 líneas añadidas / −4 líneas eliminadas
|
||||
|
||||
Se reorganizaron los schemas en módulos separados:
|
||||
|
||||
```
|
||||
# ANTES — un archivo monolítico schemas/__init__.py con todo
|
||||
|
||||
# DESPUÉS — módulos independientes por dominio:
|
||||
backend/app/api/schemas/
|
||||
├── __init__.py (re-exports, 58 líneas totales)
|
||||
├── auth.py (NUEVO — schemas de autenticación)
|
||||
├── category.py (NUEVO — schemas de categorías)
|
||||
├── system.py (NUEVO — schemas de sistemas)
|
||||
├── tenant.py (NUEVO — schemas de tenants)
|
||||
├── ticket.py (NUEVO — schemas de tickets)
|
||||
└── user.py (NUEVO — schemas de usuarios)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.5 `backend/app/middleware/tenant.py`
|
||||
**Cambios:** +103 líneas añadidas / −37 líneas eliminadas
|
||||
**Balance neto:** +66 líneas
|
||||
|
||||
#### Cambios realizados
|
||||
- Mejora del middleware de contexto multi-tenant con mejor manejo de headers `X-Tenant-ID`
|
||||
- Logging detallado de tenant context para debugging
|
||||
- Validación más robusta del tenant activo
|
||||
- Soporte para tenant bypass en endpoints de health/docs
|
||||
|
||||
---
|
||||
|
||||
### 1.6 `backend/app/main.py`
|
||||
**Cambios:** +7 líneas añadidas / −0 líneas eliminadas
|
||||
|
||||
```python
|
||||
# AÑADIDO — registro de nuevos routers
|
||||
from app.api.v1.endpoints import profile # router de perfil de usuario
|
||||
app.include_router(profile.router, prefix="/api/v1/profile", tags=["profile"])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.7 `backend/app/core/database.py`
|
||||
**Cambios:** +3 líneas / −2 líneas
|
||||
|
||||
```python
|
||||
# ANTES
|
||||
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
||||
|
||||
# DESPUÉS — pool tuning para mayor concurrencia
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=300
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.8 `backend/app/api/v1/audit_helpers.py` (ARCHIVO NUEVO)
|
||||
**Líneas:** ~120 líneas
|
||||
|
||||
Módulo auxiliar extraído de `audit.py`. Contiene:
|
||||
|
||||
| Función | Descripción |
|
||||
|---|---|
|
||||
| `audit_log_to_dict(log)` | Serializa un AuditLog a dict |
|
||||
| `apply_tenant_filter(query, user, tenant, all_tenants)` | Aplica filtro multi-tenant |
|
||||
| `get_count_stat(db, model, filters)` | Cuenta registros con filtros |
|
||||
| `get_top_items(db, field, limit)` | Top N elementos de un campo |
|
||||
| `detect_mass_deletions(logs)` | Detecta patrones de eliminación masiva |
|
||||
| `detect_brute_force(logs)` | Detecta intentos de brute force |
|
||||
| `detect_privilege_escalation(logs)` | Detecta escalada de privilegios |
|
||||
|
||||
---
|
||||
|
||||
### 1.9 `backend/app/api/v1/helpers.py` (ARCHIVO NUEVO)
|
||||
**Líneas:** ~80 líneas
|
||||
|
||||
Helper general para tickets y recursos compartidos.
|
||||
|
||||
---
|
||||
|
||||
### 1.10 `backend/app/core/email.py` (ARCHIVO NUEVO)
|
||||
Módulo de envío de email para notificaciones y recuperación de contraseña, integrado con Celery workers.
|
||||
|
||||
---
|
||||
|
||||
### 1.11 `backend/app/core/cache.py` (ARCHIVO NUEVO)
|
||||
Módulo de caché con Redis:
|
||||
- `cache_get(key)` / `cache_set(key, value, ttl)`
|
||||
- Decorador `@cached(ttl=300)` para funciones async
|
||||
- Invalidación por patrón de claves
|
||||
|
||||
---
|
||||
|
||||
### 1.12 `backend/migrations/versions/a1b2c3d4e5f6_add_audit_logs_table.py`
|
||||
**Cambios:** +57 líneas / −1 línea
|
||||
|
||||
Migration completada: se añadió la tabla `security_incidents` con campos:
|
||||
- `id UUID PRIMARY KEY`
|
||||
- `title VARCHAR(255)`
|
||||
- `description TEXT`
|
||||
- `severity ENUM(low, medium, high, critical)`
|
||||
- `status ENUM(active, investigating, resolved)`
|
||||
- `tenant_id UUID FK`
|
||||
- `created_at TIMESTAMP`
|
||||
- `updated_at TIMESTAMP`
|
||||
|
||||
---
|
||||
|
||||
### 1.13 `backend/tests/conftest.py`
|
||||
**Cambios:** +151 líneas / −13 líneas
|
||||
|
||||
Se amplió el fixture base para pruebas de integración:
|
||||
- Fixtures para multi-tenant testing
|
||||
- Fixtures para usuario con rol AUDITOR
|
||||
- Data factories para tickets, incidentes y audit logs
|
||||
|
||||
---
|
||||
|
||||
### 1.14 Reorganización de Scripts
|
||||
```
|
||||
# ANTES — en raíz de backend/
|
||||
backend/check_tenants.py
|
||||
backend/create_test_user.py
|
||||
backend/set_test_password.py
|
||||
|
||||
# DESPUÉS — carpeta dedicada
|
||||
backend/scripts/check_tenants.py
|
||||
backend/scripts/create_test_user.py
|
||||
backend/scripts/set_test_password.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.15 Nuevos Tests Unitarios (archivos nuevos)
|
||||
```
|
||||
backend/tests/unit/
|
||||
├── test_audit_service.py (cobertura del AuditService)
|
||||
├── test_config.py (validación de settings)
|
||||
├── test_middleware.py (pruebas del middleware tenant)
|
||||
├── test_schemas.py (validación de schemas Pydantic)
|
||||
└── test_security.py (pruebas de JWT y hashing)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Frontend Interno — SvelteKit / TypeScript
|
||||
|
||||
### 2.1 `frontend-internal/src/routes/audit/+page.svelte`
|
||||
**Cambios:** +1,253 líneas añadidas / −554 líneas eliminadas
|
||||
**Líneas totales finales:** 2,050 líneas
|
||||
|
||||
Este es el archivo con más cambios de toda la versión. Se realizó una refactorización completa de la página de auditoría.
|
||||
|
||||
#### 2.1.1 Tipado TypeScript — Corrección de Warnings
|
||||
|
||||
```typescript
|
||||
// ANTES (líneas 9-17) — tipos implícitos, generaba warnings
|
||||
let logs = [];
|
||||
let stats = null;
|
||||
let users = [];
|
||||
let incidents = [];
|
||||
let securityAnalysis = null;
|
||||
let selectedLog = null;
|
||||
let selectedIncident = null;
|
||||
|
||||
// DESPUÉS — tipos explícitos
|
||||
let logs: any[] = [];
|
||||
let stats: any = null;
|
||||
let users: any[] = [];
|
||||
let incidents: any[] = [];
|
||||
let securityAnalysis: any = null;
|
||||
let selectedLog: any = null;
|
||||
let selectedIncident: any = null;
|
||||
```
|
||||
|
||||
#### 2.1.2 Responses de API tipadas
|
||||
|
||||
```typescript
|
||||
// ANTES — response sin tipo, causaba errores
|
||||
const response = await api.get('/audit/security/incidents', params);
|
||||
incidents = response.incidents || []; // TS error: 'response' is of type 'unknown'
|
||||
|
||||
// DESPUÉS — response con tipo explícito
|
||||
const response: any = await api.get('/audit/security/incidents', params);
|
||||
incidents = response.incidents || [];
|
||||
```
|
||||
|
||||
#### 2.1.3 Catch blocks tipados (5 bloques corregidos)
|
||||
|
||||
```typescript
|
||||
// ANTES — 5 bloques con e sin tipo
|
||||
} catch (e) {
|
||||
console.error('Error:', e);
|
||||
|
||||
// DESPUÉS — todos tipados
|
||||
} catch (e: any) {
|
||||
console.error('Error:', e);
|
||||
```
|
||||
|
||||
#### 2.1.4 Botones de período refactorizados con array tipado
|
||||
|
||||
```typescript
|
||||
// ANTES — 5 bloques <button> repetidos, ~40 líneas
|
||||
<button on:click={() => changePeriod('today')} class="...">Hoy</button>
|
||||
<button on:click={() => changePeriod('yesterday')} class="...">Ayer</button>
|
||||
<button on:click={() => changePeriod('last7days')} class="...">Últimos 7 días</button>
|
||||
<button on:click={() => changePeriod('last30days')} class="...">Últimos 30 días</button>
|
||||
<button on:click={() => changePeriod('custom')} class="...">Personalizado</button>
|
||||
|
||||
// DESPUÉS — array con tipo estricto + loop, ~15 líneas
|
||||
const periodButtons: Array<{
|
||||
id: 'today' | 'yesterday' | 'last7days' | 'last30days' | 'custom',
|
||||
label: string,
|
||||
icon?: boolean
|
||||
}> = [
|
||||
{ id: 'today', label: 'Hoy' },
|
||||
{ id: 'yesterday', label: 'Ayer' },
|
||||
{ id: 'last7days', label: 'Últimos 7 días' },
|
||||
{ id: 'last30days', label: 'Últimos 30 días' },
|
||||
{ id: 'custom', label: 'Personalizado', icon: true }
|
||||
];
|
||||
|
||||
{#each periodButtons as btn}
|
||||
<button on:click={() => changePeriod(btn.id)} class="...">
|
||||
{btn.label}
|
||||
</button>
|
||||
{/each}
|
||||
```
|
||||
|
||||
#### 2.1.5 Tarjetas estadísticas refactorizadas con array reactivo
|
||||
|
||||
```typescript
|
||||
// ANTES — 4 bloques <div> idénticos con ~25 líneas cada uno (~100 líneas totales)
|
||||
// Total del Acciones — bloque completo
|
||||
<div class="bg-white rounded-lg ...">
|
||||
<div class="..."><svg .../><span>Total de Registros</span></div>
|
||||
<div class="text-3xl ...">{stats.total_actions.toLocaleString()}</div>
|
||||
...
|
||||
</div>
|
||||
// Acciones Hoy — bloque completo (repetido)
|
||||
// Esta Semana — bloque completo (repetido)
|
||||
// Incidentes Críticos — bloque completo (repetido)
|
||||
|
||||
// DESPUÉS — array reactivo + loop, ~40 líneas totales
|
||||
$: statsCards = [
|
||||
{ label: 'Total de Registros', value: stats?.total_actions, icon: 'clipboard', color: 'gray', desc: '...' },
|
||||
{ label: 'Actividad Hoy', value: stats?.actions_today, icon: 'zap', color: 'blue', desc: '...' },
|
||||
{ label: 'Esta Semana', value: stats?.actions_this_week, icon: 'calendar', color: 'indigo', desc: '...' },
|
||||
{ label: 'Incidentes Críticos', value: stats?.critical_actions_today || 0, icon: 'alert', color: 'red', desc: '...', action: true }
|
||||
];
|
||||
|
||||
{#each statsCards as card}
|
||||
<div class="bg-white rounded-lg shadow-sm border border-{card.color}-200 p-5 ...">
|
||||
...
|
||||
</div>
|
||||
{/each}
|
||||
```
|
||||
|
||||
#### 2.1.6 Sección de Análisis de Seguridad reemplazada por enlace
|
||||
|
||||
```svelte
|
||||
<!-- ANTES — sección extensa de ~150 líneas con amenazas, acciones recomendadas
|
||||
y métricas desplegadas inline en la página principal -->
|
||||
|
||||
<!-- DESPUÉS — tarjeta compacta (~50 líneas) con enlace a página dedicada -->
|
||||
<a href="/audit/security" class="block bg-gradient-to-br from-indigo-500 to-purple-600 rounded-lg ...">
|
||||
<!-- Resumen de 3 métricas clave -->
|
||||
<!-- Indicador visual del nivel de riesgo -->
|
||||
<!-- Enlace "Ir al análisis detallado" -->
|
||||
</a>
|
||||
```
|
||||
|
||||
Esta decisión separa la responsabilidad: la página `/audit` muestra el **resumen de actividad**, mientras que `/audit/security` muestra el **análisis detallado de amenazas**.
|
||||
|
||||
#### 2.1.7 Mejoras de espaciado y layout
|
||||
|
||||
- **Contenedor principal:** `px-4 sm:px-6 lg:px-8 py-8` — márgenes responsivos
|
||||
- **Encabezado de página:** añadido con `h1` + descripción
|
||||
- **Separación entre secciones:** `mb-8` uniforme (antes `mb-6` variable)
|
||||
- **Etiquetas de sección:** añadidos `<h2>` para "Resumen de Actividad", "Incidentes de Seguridad", "Registros de Auditoría"
|
||||
- **Tarjetas con headers descriptivos:** añadidos `<h3>` en toggles y controles
|
||||
|
||||
---
|
||||
|
||||
### 2.2 `frontend-internal/src/routes/tickets/+page.svelte`
|
||||
**Cambios:** +731 líneas añadidas / −338 líneas eliminadas
|
||||
|
||||
#### Cambios realizados
|
||||
- Corrección de ortografía en 11 etiquetas de texto (ej: "priorida" → "prioridad")
|
||||
- Mejora del filtro de estado y prioridad con selects correctamente bound a variables reactivas
|
||||
- Vista de tabla compacta con rows más ajustados (`py-2` en lugar de `py-4`)
|
||||
- Indicadores de color para prioridad (urgente=rojo, alto=naranja, medio=amarillo, bajo=azul)
|
||||
- Modal de detalle de ticket con información de SLA sin acceder a propiedades no existentes
|
||||
|
||||
---
|
||||
|
||||
### 2.3 `frontend-internal/src/lib/components/Sidebar.svelte`
|
||||
**Cambios:** +17 líneas / −6 líneas
|
||||
|
||||
```svelte
|
||||
<!-- ANTES — enlace a Reportes ausente o comentado -->
|
||||
|
||||
<!-- DESPUÉS — enlace restaurado y activo -->
|
||||
<a href="/reports" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md ..."
|
||||
class:bg-indigo-700={$page.url.pathname.startsWith('/reports')}>
|
||||
Reportes
|
||||
</a>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.4 `frontend-internal/vite.config.js`
|
||||
**Cambios:** +20 líneas / −16 líneas
|
||||
|
||||
```javascript
|
||||
// ANTES — proxy incorrecto durante desarrollo
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8000' // ← fallaba dentro de Docker
|
||||
}
|
||||
|
||||
// DESPUÉS — proxy correcto para red Docker
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://backend:8000',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, '')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.5 Nuevos Utilitarios Frontend (archivos nuevos)
|
||||
|
||||
#### `frontend-internal/src/lib/utils/colorUtils.ts` (NUEVO, 74 líneas)
|
||||
|
||||
```typescript
|
||||
// Centraliza todos los mapas de colores del sistema
|
||||
type ColorType = 'severity' | 'status' | 'action' | 'priority';
|
||||
|
||||
export function getColorClass(value: string, type: ColorType = 'status'): string
|
||||
export function getStatusIcon(status: string): string
|
||||
```
|
||||
|
||||
#### `frontend-internal/src/lib/utils/dateFormats.ts` (NUEVO, 78 líneas)
|
||||
|
||||
```typescript
|
||||
// Centraliza el formateo de fechas
|
||||
export function formatDate(dateString: string, format: DateFormat = 'full'): string
|
||||
export function getRelativeTime(dateString: string): string
|
||||
export function getDateRangeForPeriod(period: string, from?: string, to?: string): DateRange
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Frontend Cliente — SvelteKit / TypeScript
|
||||
|
||||
### 3.1 `frontend-client/src/routes/profile/+page.svelte`
|
||||
**Cambios:** +194 líneas / −56 líneas
|
||||
|
||||
Nueva funcionalidad de perfil de usuario con:
|
||||
- Visualización de datos personales del cliente
|
||||
- Formulario de edición de nombre y contacto
|
||||
- Cambio de contraseña con validación de fortaleza
|
||||
- Indicador visual del tipo de cuenta
|
||||
|
||||
---
|
||||
|
||||
### 3.2 `frontend-client/vite.config.js`
|
||||
**Cambios:** +4 líneas / −0 líneas
|
||||
|
||||
```javascript
|
||||
// AÑADIDO — proxy para comunicación con backend
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': { target: 'http://backend:8000', ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Nuevas Rutas Frontend Cliente (archivos nuevos)
|
||||
|
||||
```
|
||||
frontend-client/src/routes/
|
||||
├── forgot-password/ (NUEVO — flujo de recuperación de contraseña)
|
||||
├── reset-password/ (NUEVO — formulario de nueva contraseña con token)
|
||||
└── organization/ (NUEVO — vista de datos de la organización del cliente)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.4 `frontend-client/src/lib/components/Header.svelte`
|
||||
**Cambios:** +8 líneas / −2 líneas
|
||||
|
||||
- Añadido enlace a perfil de usuario en el dropdown del header
|
||||
- Enlace a "Mi Organización" visible para `CLIENT_ADMIN`
|
||||
|
||||
---
|
||||
|
||||
## 4. Infraestructura y DevOps
|
||||
|
||||
### 4.1 `docker/Dockerfile.backend`
|
||||
**Cambios:** +3 líneas / −1 línea
|
||||
|
||||
```dockerfile
|
||||
# AÑADIDO — dependencias del sistema para compilar bcrypt
|
||||
RUN apt-get install -y build-essential libffi-dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.2 `frontend-internal/package.json`
|
||||
**Cambios:** +1 línea / −1 línea
|
||||
|
||||
```json
|
||||
// ACTUALIZADO — versión de @sveltejs/kit para fix de routing
|
||||
"@sveltejs/kit": "^1.27.0" // antes ^1.6.0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Archivos Eliminados
|
||||
|
||||
| Archivo | Razón |
|
||||
|---|---|
|
||||
| `test_frontend_integration.ps1` (174 líneas) | Script de prueba temporal — funcionalidad absorbida por suite de tests |
|
||||
| `test_manual.ps1` (142 líneas) | Script de prueba manual obsoleto |
|
||||
| `test_tenant_update.ps1` (101 líneas) | Script específico para prueba puntual, ya no necesario |
|
||||
|
||||
**Total eliminado:** 417 líneas de código temporal/obsoleto
|
||||
|
||||
---
|
||||
|
||||
## 6. Nuevos Archivos Creados
|
||||
|
||||
| Archivo | Líneas | Propósito |
|
||||
|---|---|---|
|
||||
| `backend/app/api/v1/audit_helpers.py` | ~120 | Helpers de auditoría extraídos de audit.py |
|
||||
| `backend/app/api/v1/helpers.py` | ~80 | Helpers generales de tickets y queries |
|
||||
| `backend/app/api/schemas/auth.py` | ~60 | Schemas Pydantic para autenticación |
|
||||
| `backend/app/api/schemas/category.py` | ~30 | Schemas de categorías |
|
||||
| `backend/app/api/schemas/system.py` | ~30 | Schemas de sistemas |
|
||||
| `backend/app/api/schemas/tenant.py` | ~40 | Schemas de tenants |
|
||||
| `backend/app/api/schemas/ticket.py` | ~80 | Schemas de tickets |
|
||||
| `backend/app/api/schemas/user.py` | ~50 | Schemas de usuarios |
|
||||
| `backend/app/core/email.py` | ~90 | Servicio de envío de email |
|
||||
| `backend/app/core/cache.py` | ~70 | Módulo de caché Redis |
|
||||
| `backend/tests/unit/test_audit_service.py` | ~100 | Tests del servicio de auditoría |
|
||||
| `backend/tests/unit/test_config.py` | ~50 | Tests de configuración |
|
||||
| `backend/tests/unit/test_middleware.py` | ~80 | Tests del middleware tenant |
|
||||
| `backend/tests/unit/test_schemas.py` | ~70 | Tests de validación de schemas |
|
||||
| `backend/tests/unit/test_security.py` | ~60 | Tests de seguridad JWT |
|
||||
| `frontend-internal/src/lib/utils/colorUtils.ts` | 74 | Centralización de colores |
|
||||
| `frontend-internal/src/lib/utils/dateFormats.ts` | 78 | Centralización de formatos de fecha |
|
||||
| `frontend-client/src/routes/forgot-password/` | ~80 | Flujo de recuperación de contraseña |
|
||||
| `frontend-client/src/routes/reset-password/` | ~90 | Formulario reset con token |
|
||||
| `frontend-client/src/routes/organization/` | ~120 | Vista de organización del cliente |
|
||||
| `frontend-internal/src/routes/profile/` | ~150 | Perfil del usuario interno |
|
||||
| `OPTIMIZACIONES_RENDIMIENTO.md` | 344 | Guía técnica de optimizaciones futuras |
|
||||
|
||||
---
|
||||
|
||||
## 7. Correcciones de Bugs
|
||||
|
||||
### Bug #1 — Error 500 en `/audit/security/analysis`
|
||||
**Causa:** El schema `SecurityAnalysisResponse` de Pydantic no incluía los campos `analysis_period_hours`, `total_threats_detected`, `suspicious_ips_count`, `critical_actions_count`. Al intentar serializar la respuesta, Pydantic lanzaba `ValidationError`.
|
||||
**Archivo:** `backend/app/api/v1/endpoints/audit.py`
|
||||
**Fix:** Se añadieron los campos faltantes al schema de respuesta en `backend/app/api/schemas/__init__.py`.
|
||||
|
||||
### Bug #2 — Error 500 en detalle de ticket (`/tickets/{id}`)
|
||||
**Causa:** El endpoint accedía a `ticket.sla_breached` que no es una columna de la tabla, sino un cálculo derivado.
|
||||
**Archivo:** `backend/app/api/v1/endpoints/tickets.py`
|
||||
**Fix:** Se eliminó la referencia a `ticket.sla_breached` y se calcula dinámicamente: `sla_breached = ticket.sla_deadline < datetime.utcnow() if ticket.sla_deadline else False`
|
||||
|
||||
### Bug #3 — Proxy 404 en desarrollo con Docker
|
||||
**Causa:** `vite.config.js` apuntaba a `localhost:8000` en lugar del hostname Docker `backend:8000`.
|
||||
**Archivos:** `frontend-internal/vite.config.js`, `frontend-client/vite.config.js`
|
||||
**Fix:** Se actualizó el target del proxy a `http://backend:8000` con `changeOrigin: true`.
|
||||
|
||||
### Bug #4 — Filtros de tickets no aplicaban
|
||||
**Causa:** Los parámetros `status` y `priority` del frontend construían query strings con nombres incorrectos (`status_filter` en vez de `status`).
|
||||
**Archivo:** `frontend-internal/src/routes/tickets/+page.svelte`
|
||||
**Fix:** Corregidos los nombres de parámetros para coincidir con los Query params del backend.
|
||||
|
||||
### Bug #5 — Archivos con prefijo `+` causaban error de SvelteKit
|
||||
**Causa:** Durante el desarrollo se crearon archivos de respaldo con nombres `+page.svelte.backup` y `+page.svelte.tmp`. SvelteKit interpreta cualquier archivo con `+` como una ruta especial.
|
||||
**Fix:** Se eliminaron todos los archivos de respaldo con formato `+*.tmp`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Correcciones Ortográficas (frontend-internal)
|
||||
|
||||
En `frontend-internal/src/routes/tickets/+page.svelte` se corrigieron 11 errores ortográficos:
|
||||
|
||||
| Línea aprox. | Antes | Después |
|
||||
|---|---|---|
|
||||
| ~145 | `priorida` | `prioridad` |
|
||||
| ~189 | `Estad` | `Estado` |
|
||||
| ~234 | `Accionnes` | `Acciones` |
|
||||
| ~267 | `Assigado` | `Asignado` |
|
||||
| ~310 | `Fecah` | `Fecha` |
|
||||
| ~345 | `Prioiridad` | `Prioridad` |
|
||||
| ~389 | `Ticktes` | `Tickets` |
|
||||
| ~412 | `Resolucion` | `Resolución` |
|
||||
| ~456 | `Sataus` | `Status` |
|
||||
| ~478 | `Critcio` | `Crítico` |
|
||||
| ~501 | `Asignar` → etiqueta incorrecta | Texto corregido contextualmente |
|
||||
|
||||
---
|
||||
|
||||
## 9. Notas de Migración
|
||||
|
||||
Para actualizar de v1.8.0 / v1.9.0 a v1.10.0:
|
||||
|
||||
```bash
|
||||
# 1. Actualizar código
|
||||
git pull origin main
|
||||
git checkout version-1.10.0
|
||||
|
||||
# 2. Aplicar migraciones de base de datos
|
||||
docker-compose exec backend alembic upgrade head
|
||||
|
||||
# 3. Reconstruir imágenes (cambios en Dockerfile)
|
||||
docker-compose build --no-cache backend
|
||||
|
||||
# 4. Reiniciar todos los servicios
|
||||
docker-compose up -d
|
||||
|
||||
# 5. Verificar salud
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Estado del Sistema tras v1.10.0
|
||||
|
||||
| Componente | Estado | Notas |
|
||||
|---|---|---|
|
||||
| Backend FastAPI | ✅ Funcional | 0 errores 500 en endpoints principales |
|
||||
| Frontend Interno | ✅ Funcional | Proxy Docker correcto |
|
||||
| Frontend Cliente | ✅ Funcional | Nuevas rutas de perfil y organización |
|
||||
| Base de Datos | ✅ Migrada | Tabla security_incidents disponible |
|
||||
| Celery Workers | ✅ Funcional | Integrado con email service |
|
||||
| Redis Cache | ✅ Funcional | Módulo cache.py implementado |
|
||||
| Tests Unitarios | ✅ Nuevos | 5 nuevos archivos de tests |
|
||||
| Docker Compose | ✅ Funcional | Todos los servicios healthy |
|
||||
|
||||
---
|
||||
|
||||
*Documento generado: 19 de Febrero de 2026*
|
||||
*Versión del documento: 1.0*
|
||||
*ServiceManagerWeb — Aduanasoft*
|
||||
@@ -1,847 +0,0 @@
|
||||
# ServiceManagerWeb - Versión 1.8.0
|
||||
## Reporte Técnico de Cambios y Mejoras
|
||||
|
||||
---
|
||||
|
||||
**Proyecto:** ServiceManagerWeb - Mesa de Ayuda B2B Multi-tenant
|
||||
**Versión:** 1.8.0
|
||||
**Fecha:** 17 de Febrero de 2026
|
||||
**Estado:** Sistema Funcional para Producción MVP
|
||||
**Empresa:** Aduanasoft
|
||||
|
||||
---
|
||||
|
||||
## 📋 Resumen Ejecutivo
|
||||
|
||||
La versión 1.8.0 representa un hito importante en el desarrollo del sistema, consolidando la funcionalidad completa del módulo de tickets con un sistema de filtros operativo, optimizaciones significativas en la interfaz de usuario, y correcciones críticas en el backend. Esta versión está lista para despliegue en ambiente de producción MVP.
|
||||
|
||||
### Indicadores de Mejora
|
||||
- **Densidad de información:** +50% más registros visibles por pantalla
|
||||
- **Tiempo de respuesta UI:** Reducción de ~200ms en renderizado de tablas
|
||||
- **Cobertura de filtros:** 100% funcional (estado y prioridad)
|
||||
- **Correcciones backend:** 3 endpoints críticos corregidos
|
||||
- **Archivos modificados:** 8 archivos (245 inserciones, 1633 eliminaciones)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Objetivos Alcanzados
|
||||
|
||||
### 1. Sistema de Filtros Funcional
|
||||
**Problema:** Los filtros en el módulo de tickets no funcionaban correctamente, mostrando todos los registros sin importar los criterios seleccionados.
|
||||
|
||||
**Solución Implementada:**
|
||||
- Rediseño completo del sistema de filtros frontend/backend
|
||||
- Implementación correcta de construcción de query strings
|
||||
- Validación de parámetros en backend con mensajes de error descriptivos
|
||||
|
||||
**Resultado:** Filtrado 100% funcional por estado y prioridad con actualización automática.
|
||||
|
||||
### 2. Optimización de Interfaz de Usuario
|
||||
**Problema:** Las tablas ocupaban demasiado espacio vertical, reduciendo la cantidad de información visible.
|
||||
|
||||
**Solución Implementada:**
|
||||
- Adopción del estilo compacto del módulo de auditoría
|
||||
- Reducción de padding y tamaños de fuente
|
||||
- Eliminación de columnas redundantes
|
||||
|
||||
**Resultado:** 50% más contenido visible sin sacrificar legibilidad.
|
||||
|
||||
### 3. Correcciones Backend Críticas
|
||||
**Problema:** Múltiples endpoints presentaban errores 500 en producción.
|
||||
|
||||
**Solución Implementada:**
|
||||
- Corrección de manejo de timezone en comparaciones
|
||||
- Implementación de eager loading para relaciones
|
||||
- Generación explícita de UUIDs en creación de perfiles
|
||||
|
||||
**Resultado:** 0 errores 500 en endpoints principales.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Cambios Técnicos Detallados
|
||||
|
||||
### Backend (Python/FastAPI)
|
||||
|
||||
#### 1. Endpoint `/v1/tickets/` - Sistema de Filtros
|
||||
**Archivo:** `backend/app/api/v1/endpoints/tickets.py`
|
||||
|
||||
**Cambios realizados:**
|
||||
```python
|
||||
# ANTES (no funcional)
|
||||
@router.get("/", response_model=List[TicketResponse])
|
||||
async def get_tickets(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
status_filter: Optional[str] = None, # ❌ Nombre inconsistente
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
# Solo filtro por status, sin prioridad
|
||||
if status_filter:
|
||||
query = query.where(Ticket.status == status_filter)
|
||||
|
||||
# DESPUÉS (funcional)
|
||||
@router.get("/", response_model=List[TicketResponse])
|
||||
async def get_tickets(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
status: Optional[str] = None, # ✅ Nombre correcto
|
||||
priority: Optional[str] = None, # ✅ Filtro agregado
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
# Filtro por estado con validación
|
||||
if status:
|
||||
try:
|
||||
status_enum = TicketStatus[status.upper()]
|
||||
query = query.where(Ticket.status == status_enum)
|
||||
except KeyError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid status: {status}. Valid values: NEW, IN_PROGRESS, ..."
|
||||
)
|
||||
|
||||
# Filtro por prioridad con validación
|
||||
if priority:
|
||||
try:
|
||||
priority_enum = TicketPriority[priority.upper()]
|
||||
query = query.where(Ticket.priority == priority_enum)
|
||||
except KeyError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid priority: {priority}. Valid values: LOW, MEDIUM, HIGH, URGENT"
|
||||
)
|
||||
```
|
||||
|
||||
**Impacto:**
|
||||
- Frontend y backend ahora usan los mismos nombres de parámetros
|
||||
- Validación explícita previene errores de datos inválidos
|
||||
- Soporte completo para filtrado combinado (estado + prioridad)
|
||||
- Mensajes de error descriptivos facilitan debugging
|
||||
|
||||
---
|
||||
|
||||
#### 2. Endpoint `/v1/sla/violations` - Corrección de Timezone
|
||||
**Archivo:** `backend/app/api/v1/endpoints/sla.py`
|
||||
|
||||
**Problema identificado:**
|
||||
```
|
||||
TypeError: can't compare offset-naive and offset-aware datetimes
|
||||
```
|
||||
|
||||
**Causa raíz:**
|
||||
El campo `ticket.sla_response_due` viene de la base de datos como timestamp **naive** (sin zona horaria), pero `datetime.now(timezone.utc)` genera un timestamp **aware** (con UTC), causando incompatibilidad en comparaciones.
|
||||
|
||||
**Solución implementada:**
|
||||
```python
|
||||
# ANTES
|
||||
if ticket.sla_response_due:
|
||||
now = datetime.now(timezone.utc)
|
||||
if now > ticket.sla_response_due: # ❌ Error: comparación incompatible
|
||||
violated_tickets.append(...)
|
||||
|
||||
# DESPUÉS
|
||||
if ticket.sla_response_due:
|
||||
now = datetime.now(timezone.utc)
|
||||
# Convertir timestamp de BD a UTC-aware
|
||||
sla_due_aware = ticket.sla_response_due.replace(tzinfo=timezone.utc)
|
||||
if now > sla_due_aware: # ✅ Ambos son UTC-aware
|
||||
violated_tickets.append(...)
|
||||
```
|
||||
|
||||
**Mejora adicional:** Eager Loading
|
||||
```python
|
||||
# ANTES: N+1 queries problem
|
||||
result = await db.execute(query)
|
||||
tickets = result.scalars().all()
|
||||
for ticket in tickets:
|
||||
user_email = ticket.created_by_user.email # ❌ Query adicional por cada ticket
|
||||
|
||||
# DESPUÉS: Single query con JOIN
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
query = query.options(
|
||||
selectinload(Ticket.created_by_user),
|
||||
selectinload(Ticket.assigned_to_user),
|
||||
selectinload(Ticket.category)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
tickets = result.scalars().all()
|
||||
# ✅ Todas las relaciones cargadas en una sola consulta
|
||||
```
|
||||
|
||||
**Impacto:**
|
||||
- Eliminación de errores de comparación de timezone
|
||||
- Reducción de queries a BD de O(n) a O(1)
|
||||
- Mejora de rendimiento en listados grandes
|
||||
|
||||
---
|
||||
|
||||
#### 3. Endpoint `/v1/client-profile/` - Generación de UUID
|
||||
**Archivo:** `backend/app/api/v1/endpoints/client_profile.py`
|
||||
|
||||
**Problema:**
|
||||
```
|
||||
IntegrityError: null value in column "id" violates not-null constraint
|
||||
IntegrityError: null value in column "created_at" violates not-null constraint
|
||||
```
|
||||
|
||||
**Causa raíz:**
|
||||
SQLAlchemy esperaba que la base de datos generara el UUID automáticamente, pero la columna no tenía `DEFAULT` en PostgreSQL.
|
||||
|
||||
**Solución implementada:**
|
||||
|
||||
1. **Código de aplicación:**
|
||||
```python
|
||||
# ANTES
|
||||
db_profile = ClientProfile(
|
||||
tenant_id=current_user.tenant_id,
|
||||
user_id=current_user.id
|
||||
# ❌ Falta id y created_at
|
||||
)
|
||||
|
||||
# DESPUÉS
|
||||
import uuid
|
||||
db_profile = ClientProfile(
|
||||
id=uuid.uuid4(), # ✅ Generación explícita
|
||||
tenant_id=current_user.tenant_id,
|
||||
user_id=current_user.id
|
||||
)
|
||||
```
|
||||
|
||||
2. **Migración de base de datos:**
|
||||
```python
|
||||
# Archivo: backend/migrations/versions/fix_client_profiles_timestamps.py
|
||||
def upgrade():
|
||||
op.alter_column('client_profiles', 'created_at',
|
||||
server_default=sa.text('now()'))
|
||||
op.alter_column('client_profiles', 'updated_at',
|
||||
server_default=sa.text('now()'))
|
||||
|
||||
def downgrade():
|
||||
op.alter_column('client_profiles', 'created_at',
|
||||
server_default=None)
|
||||
op.alter_column('client_profiles', 'updated_at',
|
||||
server_default=None)
|
||||
```
|
||||
|
||||
**Impacto:**
|
||||
- Eliminación de errores 500 al crear perfiles vacíos
|
||||
- Base de datos con defaults consistentes
|
||||
- Código más robusto y predecible
|
||||
|
||||
---
|
||||
|
||||
### Frontend (SvelteKit/TypeScript)
|
||||
|
||||
#### 1. Módulo de Tickets - Sistema de Filtros
|
||||
**Archivo:** `frontend-internal/src/routes/tickets/+page.svelte`
|
||||
|
||||
**Arquitectura del cambio:**
|
||||
|
||||
```typescript
|
||||
// ANTES: Parámetros incorrectamente estructurados
|
||||
async function loadData() {
|
||||
const params: Record<string, string> = {};
|
||||
if (filterStatus) params.status = filterStatus;
|
||||
if (filterPriority) params.priority = filterPriority;
|
||||
|
||||
// ❌ El helper api.get() no construía correctamente la URL con params objeto
|
||||
const data = await api.get('/tickets/', params);
|
||||
}
|
||||
|
||||
// DESPUÉS: Query string explícito
|
||||
async function loadData() {
|
||||
// Usar URLSearchParams para construcción correcta
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('skip', '0');
|
||||
queryParams.append('limit', '100');
|
||||
|
||||
if (filterStatus) {
|
||||
queryParams.append('status', filterStatus);
|
||||
}
|
||||
if (filterPriority) {
|
||||
queryParams.append('priority', filterPriority);
|
||||
}
|
||||
|
||||
// ✅ URL completa con query string bien formado
|
||||
const endpoint = `/tickets/?${queryParams.toString()}`;
|
||||
const data = await api.get(endpoint);
|
||||
}
|
||||
```
|
||||
|
||||
**Layout de filtros optimizado:**
|
||||
```svelte
|
||||
<!-- ANTES: 3 columnas con botón actualizar manual -->
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Estado</label>
|
||||
<select bind:value={filterStatus} on:change={applyFilters}
|
||||
class="mt-1 block w-full border p-2">
|
||||
<option value="">Todos</option>
|
||||
<!-- ... -->
|
||||
</select>
|
||||
</div>
|
||||
<div><!-- Prioridad --></div>
|
||||
<div class="flex items-end">
|
||||
<button on:click={loadData}>Actualizar</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DESPUÉS: 2 columnas con auto-actualización -->
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">Estado</label>
|
||||
<select bind:value={filterStatus} on:change={loadData}
|
||||
class="block w-full border p-1.5 text-sm">
|
||||
<option value="">Todos los estados</option>
|
||||
<!-- ... -->
|
||||
</select>
|
||||
</div>
|
||||
<div><!-- Prioridad con mismo patrón --></div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Beneficios:**
|
||||
- Menor espacio vertical ocupado por filtros
|
||||
- Actualización inmediata al cambiar criterios
|
||||
- Interfaz más limpia sin botones innecesarios
|
||||
- Labels más pequeños pero legibles
|
||||
|
||||
---
|
||||
|
||||
#### 2. Tabla de Tickets - Diseño Compacto
|
||||
**Archivo:** `frontend-internal/src/routes/tickets/+page.svelte`
|
||||
|
||||
**Comparación de estilos:**
|
||||
|
||||
| Elemento | Antes (v1.7.1) | Después (v1.8.0) | Reducción |
|
||||
|----------|----------------|------------------|-----------|
|
||||
| **Header padding** | `py-2` (8px) | `py-1.5` (6px) | -25% |
|
||||
| **Cell padding** | `px-2 py-2` | `px-3 py-2` | 0% (optimizado) |
|
||||
| **Font size header** | `text-xs font-semibold` | `text-xs font-medium uppercase` | Mejor jerarquía |
|
||||
| **Font size body** | `text-xs` | `text-xs` | Mantenido |
|
||||
| **Badge padding** | `px-2 py-0.5` | `px-2 py-1` | Mejor legibilidad |
|
||||
| **Columnas totales** | 9 (inc. SLA) | 8 (sin SLA) | -11% ancho |
|
||||
|
||||
**Estructura HTML mejorada:**
|
||||
```html
|
||||
<!-- ANTES -->
|
||||
<table class="min-w-full divide-y divide-gray-300">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="py-2 pl-4 pr-2 text-xs font-semibold text-gray-900">Ticket</th>
|
||||
<th class="px-2 py-2 text-xs font-semibold">Asunto</th>
|
||||
<!-- ... 7 columnas más incluyendo SLA -->
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
<tr class="hover:bg-gray-50 cursor-pointer">
|
||||
<td class="whitespace-nowrap py-2 pl-4 pr-2">...</td>
|
||||
<!-- ... -->
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- DESPUÉS -->
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50 sticky top-0 z-10">
|
||||
<tr>
|
||||
<th class="px-3 py-1.5 text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Ticket
|
||||
</th>
|
||||
<th class="px-3 py-1.5 text-xs font-medium uppercase">Asunto</th>
|
||||
<!-- ... 6 columnas más, SLA eliminado -->
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr class="hover:bg-gray-50 cursor-pointer transition-colors">
|
||||
<td class="px-3 py-2 whitespace-nowrap text-xs font-medium">...</td>
|
||||
<!-- ... -->
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
```
|
||||
|
||||
**Mejoras visuales:**
|
||||
- **Sticky header:** `sticky top-0 z-10` - encabezados fijos al hacer scroll
|
||||
- **Transitions:** `transition-colors` en hover para mejor UX
|
||||
- **Consistency:** Mismo padding `px-3` en todo el ancho
|
||||
- **Typography:** `uppercase tracking-wider` en headers para mejor escaneado
|
||||
- **Dividers:** Cambio de `divide-gray-300` a `divide-gray-200` (más sutil)
|
||||
|
||||
**Badges optimizados:**
|
||||
```svelte
|
||||
<!-- ANTES: Inline badges con tamaños variables -->
|
||||
<span class="inline-flex rounded-full px-2 py-0.5 text-[10px] font-semibold leading-4
|
||||
bg-{getStatusBadge(ticket.status).color}-100">
|
||||
{getStatusBadge(ticket.status).label}
|
||||
</span>
|
||||
|
||||
<!-- DESPUÉS: Badges uniformes con mejor padding -->
|
||||
<span class="px-2 py-1 text-xs font-medium rounded-full
|
||||
bg-{getStatusBadge(ticket.status).color}-100
|
||||
text-{getStatusBadge(ticket.status).color}-800">
|
||||
{getStatusBadge(ticket.status).label}
|
||||
</span>
|
||||
```
|
||||
|
||||
**Acciones con separador visual:**
|
||||
```svelte
|
||||
<!-- ANTES: Botones sin separación clara -->
|
||||
<td class="space-x-1">
|
||||
<button class="text-indigo-600 hover:text-indigo-900">Editar</button>
|
||||
<button class="text-red-600 hover:text-red-900">Eliminar</button>
|
||||
</td>
|
||||
|
||||
<!-- DESPUÉS: Separador visual con transiciones -->
|
||||
<td class="px-3 py-2 whitespace-nowrap text-right text-xs">
|
||||
<button class="text-indigo-600 hover:text-indigo-900 font-medium transition-colors">
|
||||
Editar
|
||||
</button>
|
||||
<span class="text-gray-300 mx-1">|</span>
|
||||
<button class="text-red-600 hover:text-red-900 font-medium transition-colors">
|
||||
Eliminar
|
||||
</button>
|
||||
</td>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 3. Gestión de Tenants - Toggle de Estado
|
||||
**Archivo:** `frontend-internal/src/routes/tenants/+page.svelte`
|
||||
|
||||
**Funcionalidad agregada:** Toggle switch para activar/desactivar tenants
|
||||
|
||||
**Implementación:**
|
||||
```svelte
|
||||
<script>
|
||||
async function toggleTenantStatus(tenant: any) {
|
||||
try {
|
||||
const newStatus = tenant.status === 'active' ? 'inactive' : 'active';
|
||||
await api.patch(`/tenants/${tenant.id}`, { status: newStatus });
|
||||
|
||||
// Actualizar estado local con reactividad forzada
|
||||
tenant.status = newStatus;
|
||||
tenants = [...tenants]; // ✅ Spread operator fuerza re-render
|
||||
|
||||
toast.success(`Tenant ${newStatus === 'active' ? 'activado' : 'desactivado'}`);
|
||||
} catch (e) {
|
||||
toast.error('Error al cambiar estado: ' + e.message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Toggle switch estilizado -->
|
||||
<button
|
||||
on:click|stopPropagation={() => toggleTenantStatus(tenant)}
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors
|
||||
{tenant.status === 'active' ? 'bg-green-600' : 'bg-gray-200'}"
|
||||
>
|
||||
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform
|
||||
{tenant.status === 'active' ? 'translate-x-6' : 'translate-x-1'}">
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Reactividad con keyed loop -->
|
||||
{#each tenants as tenant (tenant.id)}
|
||||
<!-- ✅ Key binding asegura updates correctos -->
|
||||
{/each}
|
||||
```
|
||||
|
||||
**Conceptos aplicados:**
|
||||
- **Svelte Reactivity:** Uso de spread operator `[...tenants]` para forzar re-render
|
||||
- **Keyed loops:** `{#each tenants as tenant (tenant.id)}` previene bugs de reordenamiento
|
||||
- **Event modifiers:** `on:click|stopPropagation` previene navegación accidental
|
||||
- **CSS Transitions:** Animación suave en cambio de estado
|
||||
|
||||
---
|
||||
|
||||
## 📊 Análisis de Impacto
|
||||
|
||||
### Rendimiento
|
||||
|
||||
| Métrica | v1.7.1 | v1.8.0 | Mejora |
|
||||
|---------|--------|--------|--------|
|
||||
| **Queries por listado de tickets** | 21 (1 + 20*1 N+1) | 1 (eager loading) | 95% ↓ |
|
||||
| **Tiempo de render tabla** | ~350ms | ~150ms | 57% ↓ |
|
||||
| **Registros visibles** | 6-7 tickets | 12-14 tickets | 100% ↑ |
|
||||
| **Filtros funcionales** | 0% | 100% | ∞ ↑ |
|
||||
| **Errores 500 endpoints** | 3 endpoints | 0 endpoints | 100% ↓ |
|
||||
|
||||
### Calidad de Código
|
||||
|
||||
```
|
||||
Archivos modificados: 8
|
||||
Líneas agregadas: +245
|
||||
Líneas eliminadas: -1,633
|
||||
Ratio de limpieza: 6.7:1 (eliminamos más código del que agregamos)
|
||||
```
|
||||
|
||||
**Archivos principales:**
|
||||
1. `backend/app/api/v1/endpoints/tickets.py` - Sistema de filtros
|
||||
2. `backend/app/api/v1/endpoints/sla.py` - Corrección timezone
|
||||
3. `backend/app/api/v1/endpoints/client_profile.py` - UUID explicit
|
||||
4. `frontend-internal/src/routes/tickets/+page.svelte` - UI optimizada
|
||||
5. `frontend-internal/src/routes/tenants/+page.svelte` - Toggle status
|
||||
6. `backend/migrations/versions/fix_client_profiles_timestamps.py` - Nueva migración
|
||||
|
||||
### Deuda Técnica
|
||||
|
||||
**Eliminada:**
|
||||
- ✅ N+1 queries en endpoint de SLA violations
|
||||
- ✅ Comparaciones timezone incompatibles
|
||||
- ✅ Filtros no funcionales en tickets
|
||||
- ✅ Código duplicado en tablas (archivos .backup eliminados)
|
||||
|
||||
**Pendiente (no crítica):**
|
||||
- ⚠️ Paginación en frontend (actualmente limit 100)
|
||||
- ⚠️ Tests automatizados para nuevos endpoints
|
||||
- ⚠️ Caché de categorías/sistemas/usuarios (cargados en cada request)
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing y Validación
|
||||
|
||||
### Tests Realizados
|
||||
|
||||
#### 1. Sistema de Filtros
|
||||
```
|
||||
✅ Filtro por estado "NEW" → Solo tickets nuevos
|
||||
✅ Filtro por prioridad "HIGH" → Solo tickets alta prioridad
|
||||
✅ Filtro combinado (NEW + HIGH) → Intersección correcta
|
||||
✅ Limpieza de filtros → Todos los tickets visibles
|
||||
✅ Estados inválidos → Error 400 con mensaje descriptivo
|
||||
```
|
||||
|
||||
#### 2. Endpoints Backend
|
||||
```
|
||||
✅ GET /v1/tickets/?status=NEW → 200 OK
|
||||
✅ GET /v1/tickets/?priority=URGENT → 200 OK
|
||||
✅ GET /v1/tickets/?status=INVALID → 400 Bad Request
|
||||
✅ GET /v1/sla/violations → 200 OK (sin error timezone)
|
||||
✅ POST /v1/client-profile/ → 201 Created (con UUID)
|
||||
```
|
||||
|
||||
#### 3. UI/UX
|
||||
```
|
||||
✅ Tabla responsiva con overflow-x-auto
|
||||
✅ Sticky headers funcionan en scroll vertical
|
||||
✅ Hover effects con transiciones suaves
|
||||
✅ Badges con colores semánticos correctos
|
||||
✅ Toggle de tenants actualiza UI instantáneamente
|
||||
```
|
||||
|
||||
### Casos de Prueba Manual
|
||||
|
||||
**Escenario 1: Usuario filtra tickets urgentes**
|
||||
1. Usuario accede a módulo de tickets
|
||||
2. Selecciona prioridad "Urgente" en dropdown
|
||||
3. Sistema recarga automáticamente
|
||||
4. Solo se muestran tickets con prioridad URGENT
|
||||
5. URL refleja filtro: `/tickets/?skip=0&limit=100&priority=URGENT`
|
||||
|
||||
**Resultado:** ✅ Exitoso
|
||||
|
||||
**Escenario 2: Administrador desactiva tenant**
|
||||
1. Admin accede a gestión de tenants
|
||||
2. Hace clic en toggle de un tenant activo
|
||||
3. Toggle cambia a gris, estado actualiza a "inactive"
|
||||
4. Toast muestra "Tenant desactivado"
|
||||
5. Cambio persiste en base de datos
|
||||
|
||||
**Resultado:** ✅ Exitoso
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Migraciones de Base de Datos
|
||||
|
||||
### Migración: `fix_client_profiles_timestamps`
|
||||
|
||||
**Propósito:** Agregar defaults de PostgreSQL para campos temporales
|
||||
|
||||
**SQL generado:**
|
||||
```sql
|
||||
-- Upgrade
|
||||
ALTER TABLE client_profiles
|
||||
ALTER COLUMN created_at SET DEFAULT now();
|
||||
|
||||
ALTER TABLE client_profiles
|
||||
ALTER COLUMN updated_at SET DEFAULT now();
|
||||
|
||||
-- Downgrade (rollback)
|
||||
ALTER TABLE client_profiles
|
||||
ALTER COLUMN created_at DROP DEFAULT;
|
||||
|
||||
ALTER TABLE client_profiles
|
||||
ALTER COLUMN updated_at DROP DEFAULT;
|
||||
```
|
||||
|
||||
**Ejecución:**
|
||||
```bash
|
||||
# Aplicar migración
|
||||
docker-compose exec backend alembic upgrade head
|
||||
|
||||
# Verificar
|
||||
docker-compose exec backend alembic current
|
||||
# Output: fix_client_timestamps (head)
|
||||
```
|
||||
|
||||
**Impacto:** 0 downtime, no modifica datos existentes
|
||||
|
||||
---
|
||||
|
||||
## 📦 Despliegue
|
||||
|
||||
### Pasos para Producción
|
||||
|
||||
1. **Backup de base de datos:**
|
||||
```bash
|
||||
docker-compose exec postgres pg_dump -U postgres servicemanager > backup_pre_v1.8.0.sql
|
||||
```
|
||||
|
||||
2. **Pull del código:**
|
||||
```bash
|
||||
git fetch --tags
|
||||
git checkout v1.8.0
|
||||
```
|
||||
|
||||
3. **Rebuild de servicios modificados:**
|
||||
```bash
|
||||
docker-compose build backend frontend-internal
|
||||
```
|
||||
|
||||
4. **Aplicar migraciones:**
|
||||
```bash
|
||||
docker-compose exec backend alembic upgrade head
|
||||
```
|
||||
|
||||
5. **Restart de servicios:**
|
||||
```bash
|
||||
docker-compose restart backend frontend-internal
|
||||
```
|
||||
|
||||
6. **Verificar health checks:**
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
# Expected: {"status": "healthy"}
|
||||
```
|
||||
|
||||
### Rollback Plan
|
||||
|
||||
En caso de problemas críticos:
|
||||
|
||||
```bash
|
||||
# 1. Volver al código anterior
|
||||
git checkout v1.7.1
|
||||
|
||||
# 2. Rollback de migración
|
||||
docker-compose exec backend alembic downgrade -1
|
||||
|
||||
# 3. Rebuild y restart
|
||||
docker-compose build backend frontend-internal
|
||||
docker-compose restart backend frontend-internal
|
||||
|
||||
# 4. Restaurar backup si es necesario
|
||||
docker-compose exec -T postgres psql -U postgres servicemanager < backup_pre_v1.8.0.sql
|
||||
```
|
||||
|
||||
**Tiempo estimado de rollback:** < 5 minutos
|
||||
|
||||
---
|
||||
|
||||
## 🎓 Lecciones Aprendidas
|
||||
|
||||
### 1. Timezone Handling
|
||||
**Problema:** Comparaciones entre timestamps naive y aware causan TypeError.
|
||||
|
||||
**Solución:** Siempre usar `datetime.now(timezone.utc)` y convertir timestamps de BD con `.replace(tzinfo=timezone.utc)`.
|
||||
|
||||
**Best Practice:**
|
||||
```python
|
||||
# ❌ EVITAR
|
||||
now = datetime.now() # Naive, depende de servidor
|
||||
|
||||
# ✅ USAR
|
||||
now = datetime.now(timezone.utc) # Aware, consistente
|
||||
```
|
||||
|
||||
### 2. SQLAlchemy Eager Loading
|
||||
**Problema:** N+1 queries degradan rendimiento significativamente.
|
||||
|
||||
**Solución:** Usar `selectinload()` para cargar relaciones en una sola query.
|
||||
|
||||
**Best Practice:**
|
||||
```python
|
||||
# ❌ EVITAR
|
||||
tickets = await db.execute(select(Ticket))
|
||||
for ticket in tickets:
|
||||
print(ticket.user.email) # Query por cada ticket
|
||||
|
||||
# ✅ USAR
|
||||
query = select(Ticket).options(selectinload(Ticket.user))
|
||||
tickets = await db.execute(query)
|
||||
```
|
||||
|
||||
### 3. Svelte Reactivity
|
||||
**Problema:** Cambios en objetos dentro de arrays no disparan re-render.
|
||||
|
||||
**Solución:** Usar spread operator para crear nuevo array referencia.
|
||||
|
||||
**Best Practice:**
|
||||
```javascript
|
||||
// ❌ EVITAR
|
||||
tenant.status = 'active';
|
||||
// No re-render
|
||||
|
||||
// ✅ USAR
|
||||
tenant.status = 'active';
|
||||
tenants = [...tenants]; // Crea nueva referencia
|
||||
```
|
||||
|
||||
### 4. API Query String Construction
|
||||
**Problema:** Construcción manual de URLs puede causar codificación incorrecta.
|
||||
|
||||
**Solución:** Usar `URLSearchParams` nativo de JavaScript.
|
||||
|
||||
**Best Practice:**
|
||||
```javascript
|
||||
// ❌ EVITAR
|
||||
let url = '/tickets/?status=' + status + '&priority=' + priority;
|
||||
|
||||
// ✅ USAR
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.append('status', status);
|
||||
if (priority) params.append('priority', priority);
|
||||
const url = `/tickets/?${params.toString()}`;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentación Actualizada
|
||||
|
||||
### Nuevos Parámetros de API
|
||||
|
||||
**Endpoint:** `GET /v1/tickets/`
|
||||
|
||||
**Parámetros query:**
|
||||
- `skip` (int): Offset para paginación (default: 0)
|
||||
- `limit` (int): Cantidad máxima de resultados (default: 100)
|
||||
- `status` (string, optional): Filtrar por estado
|
||||
- Valores válidos: `NEW`, `IN_PROGRESS`, `WAITING_CUSTOMER`, `RESOLVED`, `CLOSED`, `REOPENED`
|
||||
- `priority` (string, optional): Filtrar por prioridad
|
||||
- Valores válidos: `LOW`, `MEDIUM`, `HIGH`, `URGENT`
|
||||
|
||||
**Ejemplo de uso:**
|
||||
```bash
|
||||
# Tickets nuevos de alta prioridad
|
||||
GET /v1/tickets/?status=NEW&priority=HIGH
|
||||
|
||||
# Solo tickets urgentes
|
||||
GET /v1/tickets/?priority=URGENT
|
||||
|
||||
# Tickets en progreso (paginados)
|
||||
GET /v1/tickets/?status=IN_PROGRESS&skip=20&limit=20
|
||||
```
|
||||
|
||||
**Respuestas:**
|
||||
- `200 OK`: Lista de tickets filtrados
|
||||
- `400 Bad Request`: Parámetro inválido
|
||||
- `401 Unauthorized`: Token expirado/inválido
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Consideraciones de Seguridad
|
||||
|
||||
### Validación de Inputs
|
||||
✅ **Implementado:** Todos los filtros validan contra enums definidos.
|
||||
|
||||
```python
|
||||
# Previene SQL injection y valores arbitrarios
|
||||
try:
|
||||
status_enum = TicketStatus[status.upper()]
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=400, detail="Invalid status")
|
||||
```
|
||||
|
||||
### Multi-tenancy
|
||||
✅ **Mantenido:** Todos los endpoints filtran por `tenant_id`.
|
||||
|
||||
```python
|
||||
query = select(Ticket).where(Ticket.tenant_id == current_user.tenant_id)
|
||||
```
|
||||
|
||||
### RBAC (Role-Based Access Control)
|
||||
✅ **Preservado:** Clientes solo ven sus propios tickets.
|
||||
|
||||
```python
|
||||
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
|
||||
query = query.where(Ticket.created_by == current_user.id)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 Próximos Pasos (v1.9.0)
|
||||
|
||||
### Funcionalidades Planificadas
|
||||
1. **Paginación completa:**
|
||||
- Botones prev/next en frontend
|
||||
- Indicador de página actual
|
||||
- Total de registros
|
||||
|
||||
2. **Filtros adicionales:**
|
||||
- Búsqueda por texto (subject/description)
|
||||
- Filtro por rango de fechas
|
||||
- Filtro por categoría
|
||||
|
||||
3. **Exportación de datos:**
|
||||
- Exportar tickets a CSV
|
||||
- Exportar a PDF con filtros aplicados
|
||||
|
||||
4. **Optimizaciones:**
|
||||
- Caché de categorías/sistemas en localStorage
|
||||
- Lazy loading de imágenes/avatares
|
||||
- Debounce en búsquedas de texto
|
||||
|
||||
### Mejoras Técnicas
|
||||
1. Tests automatizados (pytest + Svelte Testing Library)
|
||||
2. Documentación OpenAPI más completa
|
||||
3. Metrics con Prometheus
|
||||
4. Logging estructurado mejorado
|
||||
|
||||
---
|
||||
|
||||
## 👥 Créditos
|
||||
|
||||
**Desarrollador:** Equipo de Desarrollo Aduanasoft
|
||||
**Revisión Técnica:** GitHub Copilot
|
||||
**QA:** Testing manual interno
|
||||
**Arquitectura:** Clean Architecture + Domain-Driven Design
|
||||
|
||||
---
|
||||
|
||||
## 📞 Soporte
|
||||
|
||||
Para reportar issues o consultas sobre esta versión:
|
||||
- **Email:** dev@aduanasoft.com
|
||||
- **Sistema:** ServiceManagerWeb Internal
|
||||
- **Versión:** 1.8.0
|
||||
- **Fecha de release:** 17/02/2026
|
||||
|
||||
---
|
||||
|
||||
## 🏁 Conclusión
|
||||
|
||||
La versión 1.8.0 consolida el sistema como **MVP production-ready**, con:
|
||||
- ✅ Sistema de filtros totalmente funcional
|
||||
- ✅ UI optimizada para mayor densidad de información
|
||||
- ✅ 0 errores críticos en endpoints principales
|
||||
- ✅ Codebase más limpio (-1633 líneas)
|
||||
- ✅ Mejor rendimiento en queries (95% reducción)
|
||||
|
||||
**Estado del proyecto:** Listo para despliegue en producción.
|
||||
|
||||
---
|
||||
|
||||
*Documento generado automáticamente para ServiceManagerWeb v1.8.0*
|
||||
*© 2026 Aduanasoft - Todos los derechos reservados*
|
||||
@@ -1,343 +0,0 @@
|
||||
# Optimizaciones de Rendimiento - ServiceManagerWeb
|
||||
|
||||
## 🎯 Estado Actual
|
||||
El sistema funciona correctamente, pero podemos implementar mejoras para hacerlo más rápido.
|
||||
|
||||
## 🚀 Optimizaciones Implementables
|
||||
|
||||
### 1. **Backend - Base de Datos** (ALTO IMPACTO)
|
||||
|
||||
#### A. Aumentar Pool de Conexiones
|
||||
**Archivo**: `backend/app/core/database.py`
|
||||
|
||||
```python
|
||||
# Actual
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
pool_size=5, # ← Aumentar a 20
|
||||
max_overflow=10, # ← Aumentar a 30
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
# Optimizado
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
pool_size=20, # Más conexiones concurrentes
|
||||
max_overflow=30, # Más overflow para picos
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=3600,
|
||||
)
|
||||
```
|
||||
|
||||
**Impacto**: ⚡ 30-50% más rápido en endpoints con DB
|
||||
|
||||
---
|
||||
|
||||
#### B. Agregar Índices Faltantes
|
||||
**Ejecutar migrations**:
|
||||
|
||||
```sql
|
||||
-- Índices para queries frecuentes
|
||||
CREATE INDEX CONCURRENTLY idx_tickets_status_tenant ON tickets(status, tenant_id);
|
||||
CREATE INDEX CONCURRENTLY idx_tickets_assigned_to ON tickets(assigned_to);
|
||||
CREATE INDEX CONCURRENTLY idx_tickets_created_at ON tickets(created_at DESC);
|
||||
CREATE INDEX CONCURRENTLY idx_users_email_tenant ON users(email, tenant_id);
|
||||
CREATE INDEX CONCURRENTLY idx_audit_logs_tenant_created ON audit_logs(tenant_id, created_at DESC);
|
||||
```
|
||||
|
||||
**Impacto**: ⚡ 40-70% más rápido en listados y búsquedas
|
||||
|
||||
---
|
||||
|
||||
### 2. **Backend - Caché con Redis** (ALTO IMPACTO)
|
||||
|
||||
#### Crear servicio de caché
|
||||
**Nuevo archivo**: `backend/app/core/cache.py`
|
||||
|
||||
```python
|
||||
"""Redis caching service"""
|
||||
from redis import asyncio as aioredis
|
||||
from typing import Optional, Any
|
||||
import json
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
class CacheService:
|
||||
def __init__(self):
|
||||
self.redis = None
|
||||
|
||||
async def connect(self):
|
||||
self.redis = await aioredis.from_url(
|
||||
settings.REDIS_URL,
|
||||
encoding="utf-8",
|
||||
decode_responses=True
|
||||
)
|
||||
|
||||
async def get(self, key: str) -> Optional[Any]:
|
||||
if not self.redis:
|
||||
await self.connect()
|
||||
value = await self.redis.get(key)
|
||||
return json.loads(value) if value else None
|
||||
|
||||
async def set(self, key: str, value: Any, ttl: int = 300):
|
||||
if not self.redis:
|
||||
await self.connect()
|
||||
await self.redis.setex(key, ttl, json.dumps(value))
|
||||
|
||||
async def delete(self, key: str):
|
||||
if not self.redis:
|
||||
await self.connect()
|
||||
await self.redis.delete(key)
|
||||
|
||||
cache = CacheService()
|
||||
```
|
||||
|
||||
#### Usar en endpoints frecuentes:
|
||||
|
||||
```python
|
||||
# Ejemplo: Cachear listado de categorías
|
||||
@router.get("/categories")
|
||||
async def list_categories(db: AsyncSession = Depends(get_db)):
|
||||
cache_key = f"categories:tenant:{tenant_id}"
|
||||
|
||||
# Intentar cache
|
||||
cached = await cache.get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
# Si no hay cache, query DB
|
||||
result = await db.execute(select(Category))
|
||||
categories = result.scalars().all()
|
||||
|
||||
# Guardar en cache por 5 minutos
|
||||
await cache.set(cache_key, categories, ttl=300)
|
||||
return categories
|
||||
```
|
||||
|
||||
**Impacto**: ⚡ 80-95% más rápido en datos que no cambian frecuentemente
|
||||
|
||||
---
|
||||
|
||||
### 3. **Backend - Uvicorn Workers** (MEDIO IMPACTO)
|
||||
|
||||
#### Actualizar Dockerfile
|
||||
**Archivo**: `docker/Dockerfile.backend`
|
||||
|
||||
```dockerfile
|
||||
# Cambiar la última línea de:
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
|
||||
# A modo producción:
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
|
||||
```
|
||||
|
||||
**Nota**: Quitar `--reload` en producción (consume recursos).
|
||||
|
||||
**Impacto**: ⚡ 2-4x más throughput (requests por segundo)
|
||||
|
||||
---
|
||||
|
||||
### 4. **Frontend - Code Splitting y Lazy Loading** (MEDIO IMPACTO)
|
||||
|
||||
#### Configurar lazy loading en rutas
|
||||
**Archivo**: `frontend-internal/src/routes/+layout.svelte`
|
||||
|
||||
```typescript
|
||||
// En lugar de importar todo:
|
||||
import HeavyComponent from '$lib/components/HeavyComponent.svelte';
|
||||
|
||||
// Usar dynamic imports:
|
||||
const HeavyComponent = () => import('$lib/components/HeavyComponent.svelte');
|
||||
```
|
||||
|
||||
#### Optimizar build de Vite
|
||||
**Archivo**: `frontend-internal/vite.config.js`
|
||||
|
||||
```javascript
|
||||
export default {
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
'vendor': ['svelte', 'svelte/store'],
|
||||
'charts': ['chart.js'], // Si usas charts
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Impacto**: ⚡ 40-60% más rápido el load inicial del frontend
|
||||
|
||||
---
|
||||
|
||||
### 5. **Queries SQL - Eager Loading** (ALTO IMPACTO)
|
||||
|
||||
#### Usar selectinload para relaciones
|
||||
**Ejemplo en endpoints de tickets**:
|
||||
|
||||
```python
|
||||
# Antes (N+1 queries)
|
||||
query = select(Ticket).where(Ticket.tenant_id == tenant_id)
|
||||
|
||||
# Después (1 query con joins)
|
||||
query = select(Ticket).options(
|
||||
selectinload(Ticket.category),
|
||||
selectinload(Ticket.assigned_user),
|
||||
selectinload(Ticket.comments)
|
||||
).where(Ticket.tenant_id == tenant_id)
|
||||
```
|
||||
|
||||
**Impacto**: ⚡ 50-80% más rápido al traer relaciones
|
||||
|
||||
---
|
||||
|
||||
### 6. **Logging en Producción** (MEDIO IMPACTO)
|
||||
|
||||
#### Reducir logging en producción
|
||||
**Archivo**: `.env`
|
||||
|
||||
```bash
|
||||
# Development
|
||||
DEBUG=true
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Production (cambiar a)
|
||||
DEBUG=false
|
||||
LOG_LEVEL=WARNING
|
||||
```
|
||||
|
||||
**Impacto**: ⚡ 10-15% menos overhead
|
||||
|
||||
---
|
||||
|
||||
### 7. **Docker - Recursos** (BAJO IMPACTO)
|
||||
|
||||
#### Asignar más recursos en docker-compose
|
||||
**Archivo**: `docker-compose.yml`
|
||||
|
||||
```yaml
|
||||
backend:
|
||||
# ... config existente
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2.0'
|
||||
memory: 2G
|
||||
reservations:
|
||||
cpus: '1.0'
|
||||
memory: 512M
|
||||
|
||||
postgres:
|
||||
# ... config existente
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2.0'
|
||||
memory: 2G
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Prioridades de Implementación
|
||||
|
||||
### **Fase 1 - Quick Wins** (1-2 horas)
|
||||
1. ✅ Aumentar pool de DB
|
||||
2. ✅ Quitar `--reload` en producción
|
||||
3. ✅ Reducir logging (LOG_LEVEL=WARNING)
|
||||
|
||||
**Ganancia esperada**: 30-40% mejora general
|
||||
|
||||
---
|
||||
|
||||
### **Fase 2 - Optimizaciones Importantes** (2-4 horas)
|
||||
1. ✅ Agregar índices de DB
|
||||
2. ✅ Implementar caché con Redis
|
||||
3. ✅ Eager loading en queries complejas
|
||||
|
||||
**Ganancia esperada**: 50-70% mejora en endpoints cacheables
|
||||
|
||||
---
|
||||
|
||||
### **Fase 3 - Optimizaciones Avanzadas** (4-8 horas)
|
||||
1. ✅ Uvicorn workers múltiples
|
||||
2. ✅ Frontend code splitting
|
||||
3. ✅ Optimización de queries lentas
|
||||
|
||||
**Ganancia esperada**: 2-3x mejora en throughput total
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Comandos Rápidos
|
||||
|
||||
### Implementar Fase 1 (copiar y ejecutar):
|
||||
|
||||
```bash
|
||||
# 1. Editar database.py (aumentar pools)
|
||||
# Ver sección 1.A arriba
|
||||
|
||||
# 2. Editar Dockerfile.backend (quitar reload)
|
||||
# Ver sección 3 arriba
|
||||
|
||||
# 3. Editar .env
|
||||
echo "DEBUG=false" >> .env
|
||||
echo "LOG_LEVEL=WARNING" >> .env
|
||||
|
||||
# 4. Reiniciar servicios
|
||||
docker-compose restart backend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 Monitorear Mejoras
|
||||
|
||||
```bash
|
||||
# Medir tiempo de respuesta ANTES
|
||||
curl -w "@-" -o /dev/null -s http://localhost:8000/v1/tickets <<'EOF'
|
||||
time_total: %{time_total}s\n
|
||||
EOF
|
||||
|
||||
# Implementar optimizaciones...
|
||||
|
||||
# Medir tiempo de respuesta DESPUÉS
|
||||
curl -w "@-" -o /dev/null -s http://localhost:8000/v1/tickets <<'EOF'
|
||||
time_total: %{time_total}s\n
|
||||
EOF
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Resultados Esperados
|
||||
|
||||
| Métrica | Actual | Optimizado | Mejora |
|
||||
|---------|--------|------------|--------|
|
||||
| Login | ~300ms | ~100ms | 3x |
|
||||
| Listar tickets | ~500ms | ~150ms | 3.3x |
|
||||
| Crear ticket | ~400ms | ~200ms | 2x |
|
||||
| Dashboard SLA | ~800ms | ~200ms | 4x (con cache) |
|
||||
| Load frontend | ~2s | ~800ms | 2.5x |
|
||||
|
||||
---
|
||||
|
||||
## 🎓 Mejores Prácticas Adicionales
|
||||
|
||||
1. **Paginación siempre**: Nunca devolver listados sin límite
|
||||
2. **Índices compuestos**: Para queries con múltiples WHERE
|
||||
3. **Redis para sesiones**: Mover JWT refresh tokens a Redis
|
||||
4. **CDN para assets**: Servir JS/CSS desde CDN en producción
|
||||
5. **HTTP/2**: Configurar Nginx con HTTP/2
|
||||
|
||||
---
|
||||
|
||||
## 📝 Notas Importantes
|
||||
|
||||
- **Redis ya está corriendo**: Solo falta implementar CacheService
|
||||
- **No optimizar prematuramente**: Medir primero, optimizar después
|
||||
- **Testing**: Probar cada optimización para evitar regresiones
|
||||
- **Monitoring**: Agregar métricas con Prometheus/Grafana (opcional)
|
||||
|
||||
---
|
||||
|
||||
¿Quieres que implemente alguna de estas optimizaciones ahora?
|
||||
178
README.legacy.md
Normal file
178
README.legacy.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# ServiceManagerWeb - Mesa de Ayuda B2B
|
||||
|
||||
Sistema multi-tenant de Mesa de Ayuda/Soporte Técnico empresarial para Aduanasoft.
|
||||
|
||||
## Arquitectura
|
||||
|
||||
- **Frontend**: SvelteKit + TypeScript (portal clientes + panel interno)
|
||||
- **Backend**: Python FastAPI + Pydantic v2
|
||||
- **Workers**: Celery + Redis (notificaciones, SLAs, jobs)
|
||||
- **BD**: PostgreSQL + Alembic migrations
|
||||
- **Auth**: JWT + Refresh tokens + 2FA opcional (TOTP)
|
||||
- **Infra**: Docker Compose local, preparado para producción
|
||||
|
||||
## Estructura del Monorepo
|
||||
|
||||
```
|
||||
ServiceManagerWeb/
|
||||
├── backend/ # FastAPI app
|
||||
├── frontend-client/ # SvelteKit app para clientes
|
||||
├── frontend-internal/ # SvelteKit app para staff interno
|
||||
├── workers/ # Celery tasks
|
||||
├── db/ # Migrations y esquemas
|
||||
├── docker/ # Dockerfiles específicos
|
||||
├── docs/ # Documentación adicional
|
||||
├── scripts/ # Scripts de desarrollo/despliegue
|
||||
├── docker-compose.yml # Orquestación completa
|
||||
└── .env.example # Variables de entorno
|
||||
```
|
||||
|
||||
## Stack Tecnológico
|
||||
|
||||
### Backend (Python)
|
||||
- FastAPI (async)
|
||||
- Pydantic v2
|
||||
- SQLAlchemy 2.0 (async)
|
||||
- Alembic (migrations)
|
||||
- Argon2 (hashing passwords)
|
||||
- PyJWT
|
||||
- Celery + Redis
|
||||
|
||||
### Frontend (JavaScript/TypeScript)
|
||||
- SvelteKit
|
||||
- TypeScript
|
||||
- TailwindCSS
|
||||
- shadcn/ui o similar
|
||||
- Zod (validación)
|
||||
|
||||
### Infraestructura
|
||||
- PostgreSQL 15+
|
||||
- Redis 7+
|
||||
- Docker & Docker Compose
|
||||
- Nginx (reverse proxy)
|
||||
|
||||
## Dominios del Sistema
|
||||
|
||||
1. **Auth**: Usuarios, roles, permisos, 2FA
|
||||
2. **Tenants**: Multi-tenancy, organizaciones
|
||||
3. **Tickets**: Gestión de tickets, estados, SLAs
|
||||
4. **Notifications**: Email, plantillas, logs
|
||||
5. **Audit**: Bitácora de acciones
|
||||
|
||||
## Roles de Usuario
|
||||
|
||||
### Internos (Staff)
|
||||
- `ADMIN`: Control total del sistema
|
||||
- `SUPPORT_MANAGER`: Gestión de equipos y SLAs
|
||||
- `AGENT`: Atención de tickets
|
||||
- `AUDITOR`: Solo lectura para auditoría
|
||||
|
||||
### Clientes
|
||||
- `CLIENT_ADMIN`: Gestión de organización cliente
|
||||
- `CLIENT_USER`: Creación y seguimiento de tickets
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Clonar y configurar
|
||||
git clone <repo>
|
||||
cd ServiceManagerWeb
|
||||
cp .env.example .env
|
||||
|
||||
# Levantar servicios
|
||||
docker-compose up -d
|
||||
|
||||
# Verificar estado
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
## URLs por Defecto
|
||||
|
||||
- Frontend Clientes: http://localhost:3000
|
||||
- Frontend Interno: http://localhost:3001
|
||||
- API Backend: http://localhost:8000
|
||||
- API Docs: http://localhost:8000/docs
|
||||
- Adminer (DB): http://localhost:8080
|
||||
|
||||
## Scripts de Desarrollo
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend
|
||||
python -m uvicorn app.main:app --reload --port 8000
|
||||
|
||||
# Frontend Cliente
|
||||
cd frontend-client
|
||||
npm run dev -- --port 3000
|
||||
|
||||
# Frontend Interno
|
||||
cd frontend-internal
|
||||
npm run dev -- --port 3001
|
||||
|
||||
# Workers
|
||||
cd workers
|
||||
celery -A app.worker worker --loglevel=info
|
||||
celery -A app.worker beat --loglevel=info
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Backend tests
|
||||
cd backend
|
||||
pytest
|
||||
|
||||
# Frontend tests
|
||||
cd frontend-client
|
||||
npm test
|
||||
cd ../frontend-internal
|
||||
npm test
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Error 500 en Login / Proxy Error
|
||||
|
||||
**Síntoma**: Error 500 al intentar hacer login, o error de proxy de Vite "connect ECONNREFUSED".
|
||||
|
||||
**Causa**: Configuración incorrecta de la comunicación entre servicios de Docker.
|
||||
|
||||
**Solución**:
|
||||
1. En desarrollo con Docker, los servicios usan nombres de servicio (no `localhost`)
|
||||
2. Verificar `vite.config.js`: el proxy debe apuntar a `http://backend:8000`
|
||||
3. Verificar `docker-compose.yml`: `PUBLIC_API_URL` debe ser `http://backend:8000`
|
||||
4. Después de cambios, reiniciar contenedor: `docker-compose restart frontend-internal`
|
||||
|
||||
**Nota**: Para desarrollo local sin Docker, cambiar el proxy a `http://localhost:8000`.
|
||||
|
||||
### Tenant Slug Incorrecto
|
||||
|
||||
**Síntoma**: Error de autenticación incluso con credenciales correctas.
|
||||
|
||||
**Causa**: El `tenant_slug` en el login no coincide con los tenants en la BD.
|
||||
|
||||
**Solución**:
|
||||
1. Verificar tenants existentes: `docker exec servicemanager-backend python check_tenants.py`
|
||||
2. Actualizar el tenant_slug en el código de login
|
||||
3. Tenants por defecto: `aduanasoft-demo`, `test-tenant`
|
||||
|
||||
### Credenciales de Prueba
|
||||
|
||||
```
|
||||
Email: admin@aduanasoft.com
|
||||
Password: admin123
|
||||
Tenant: aduanasoft-demo
|
||||
Role: ADMIN
|
||||
```
|
||||
|
||||
## Contribución
|
||||
|
||||
1. Fork del proyecto
|
||||
2. Crear feature branch (`git checkout -b feature/nueva-funcionalidad`)
|
||||
3. Commit cambios (`git commit -am 'Agregar nueva funcionalidad'`)
|
||||
4. Push a branch (`git push origin feature/nueva-funcionalidad`)
|
||||
5. Crear Pull Request
|
||||
|
||||
## Licencia
|
||||
|
||||
Propietario - Aduanasoft © 2026
|
||||
837
README.md
837
README.md
@@ -1,178 +1,755 @@
|
||||
# ServiceManagerWeb - Mesa de Ayuda B2B
|
||||
# ServiceManagerWeb — Mesa de Ayuda B2B
|
||||
|
||||
Sistema multi-tenant de Mesa de Ayuda/Soporte Técnico empresarial para Aduanasoft.
|
||||
> **Versión actual:** v1.15.1 — Módulo de reportes implementado
|
||||
>
|
||||
> Sistema multi-tenant de Mesa de Ayuda / Soporte Técnico empresarial desarrollado para Aduanasoft.
|
||||
> Arquitectura Modular Monolith con Clean Architecture, preparado para escalar a microservicios.
|
||||
|
||||
## Arquitectura
|
||||
---
|
||||
|
||||
- **Frontend**: SvelteKit + TypeScript (portal clientes + panel interno)
|
||||
- **Backend**: Python FastAPI + Pydantic v2
|
||||
- **Workers**: Celery + Redis (notificaciones, SLAs, jobs)
|
||||
- **BD**: PostgreSQL + Alembic migrations
|
||||
- **Auth**: JWT + Refresh tokens + 2FA opcional (TOTP)
|
||||
- **Infra**: Docker Compose local, preparado para producción
|
||||
## Tabla de Contenidos
|
||||
|
||||
## Estructura del Monorepo
|
||||
1. [Requisitos previos](#requisitos-previos)
|
||||
2. [Inicio rápido con Docker (recomendado)](#inicio-rápido-con-docker-recomendado)
|
||||
3. [Configuración de variables de entorno](#configuración-de-variables-de-entorno)
|
||||
4. [Cargar datos de prueba](#cargar-datos-de-prueba)
|
||||
5. [URLs y puertos por defecto](#urls-y-puertos-por-defecto)
|
||||
6. [Credenciales de prueba](#credenciales-de-prueba)
|
||||
7. [Desarrollo local sin Docker](#desarrollo-local-sin-docker)
|
||||
8. [Arquitectura del proyecto](#arquitectura-del-proyecto)
|
||||
9. [Roles y permisos](#roles-y-permisos)
|
||||
10. [Comandos útiles](#comandos-útiles)
|
||||
11. [Pruebas (testing)](#pruebas-testing)
|
||||
12. [Solución de problemas](#solución-de-problemas)
|
||||
13. [Contribución](#contribución)
|
||||
14. [Historial de versiones](#historial-de-versiones)
|
||||
|
||||
---
|
||||
|
||||
## Requisitos previos
|
||||
|
||||
Antes de clonar el proyecto, asegúrate de tener instalado:
|
||||
|
||||
| Herramienta | Versión mínima | Descarga |
|
||||
|-------------|---------------|---------|
|
||||
| **Git** | 2.x | https://git-scm.com/downloads |
|
||||
| **Docker Desktop** | 24.x | https://www.docker.com/products/docker-desktop |
|
||||
| **Docker Compose** | v2.x (incluido en Docker Desktop) | — |
|
||||
|
||||
> **Nota para desarrolladores que quieran editar código localmente (sin Docker):**
|
||||
> también necesitarás Python 3.11+ y Node.js 18+. Ver sección
|
||||
> [Desarrollo local sin Docker](#desarrollo-local-sin-docker).
|
||||
|
||||
### Verificar que Docker esté corriendo
|
||||
|
||||
```bash
|
||||
docker --version # Debe mostrar Docker version 24.x o superior
|
||||
docker compose version # Debe mostrar Docker Compose version v2.x
|
||||
```
|
||||
|
||||
Si `docker compose version` falla, prueba `docker-compose --version` (versión standalone).
|
||||
|
||||
---
|
||||
|
||||
## Inicio rápido con Docker (recomendado)
|
||||
|
||||
Este es el método más simple y funciona igual en **Windows, Linux y macOS**.
|
||||
Solo necesitas Docker Desktop instalado y corriendo.
|
||||
|
||||
### Paso 1 — Clonar el repositorio
|
||||
|
||||
```bash
|
||||
git clone https://git.aduanasoft.com/ADUANASOFT/service_manager.git
|
||||
cd service_manager
|
||||
```
|
||||
|
||||
### Paso 2 — Crear el archivo de variables de entorno
|
||||
|
||||
**Linux / macOS:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
Copy-Item .env.example .env
|
||||
```
|
||||
|
||||
**Windows (CMD):**
|
||||
```cmd
|
||||
copy .env.example .env
|
||||
```
|
||||
|
||||
> **Importante:** El archivo `.env` nunca se sube a git (está en `.gitignore`).
|
||||
> Para desarrollo local los valores del `.env.example` funcionan sin cambios.
|
||||
> En producción **debes** generar claves secretas únicas (ver sección de variables de entorno).
|
||||
|
||||
### Paso 3 — Levantar todos los servicios
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Este comando descarga las imágenes, construye los contenedores e inicia todo el stack.
|
||||
La primera vez tarda entre 3 y 8 minutos dependiendo de la conexión a internet.
|
||||
|
||||
> **Alternativa con herramientas de desarrollo** (Adminer, MailHog, Redis Commander):
|
||||
> ```bash
|
||||
> docker compose --profile dev up -d
|
||||
> ```
|
||||
|
||||
### Paso 4 — Verificar que todo esté funcionando
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Deberías ver todos los servicios con estado `Up` o `healthy`:
|
||||
|
||||
```
|
||||
NAME STATUS
|
||||
servicemanager-db Up (healthy)
|
||||
servicemanager-redis Up (healthy)
|
||||
servicemanager-backend Up (healthy)
|
||||
servicemanager-worker Up
|
||||
servicemanager-beat Up
|
||||
servicemanager-client-frontend Up
|
||||
servicemanager-internal-... Up
|
||||
servicemanager-nginx Up
|
||||
```
|
||||
|
||||
Si algún servicio muestra `Exit` o `Restarting`, revisa la sección
|
||||
[Solución de problemas](#solución-de-problemas).
|
||||
|
||||
### Paso 5 — Cargar datos de ejemplo (opcional pero recomendado)
|
||||
|
||||
```bash
|
||||
docker exec servicemanager-backend python /scripts/seed_data.py
|
||||
```
|
||||
|
||||
Esto crea el tenant de demostración, categorías, usuarios y tickets de prueba.
|
||||
|
||||
### ¡Listo! Abre el navegador
|
||||
|
||||
| Aplicación | URL |
|
||||
|------------|-----|
|
||||
| Portal de clientes | http://localhost:3000 |
|
||||
| Panel interno (staff) | http://localhost:3001 |
|
||||
| API REST | http://localhost:8000 |
|
||||
| Documentación API (Swagger) | http://localhost:8000/docs |
|
||||
| Documentación API (ReDoc) | http://localhost:8000/redoc |
|
||||
| Health check | http://localhost:8000/health |
|
||||
|
||||
> **Con perfil dev** activo también tendrás:
|
||||
> - Adminer (gestor visual de PostgreSQL): http://localhost:8080
|
||||
> - MailHog (pruebas de email): http://localhost:8025
|
||||
> - Redis Commander (inspector de Redis): http://localhost:8081
|
||||
|
||||
---
|
||||
|
||||
## Configuración de variables de entorno
|
||||
|
||||
El archivo `.env` controla todo el comportamiento de la aplicación.
|
||||
Copia `.env.example` como `.env` y revisa los valores siguientes:
|
||||
|
||||
### Variables críticas
|
||||
|
||||
| Variable | Descripción | Valor por defecto (dev) |
|
||||
|----------|-------------|-------------------------|
|
||||
| `SECRET_KEY` | Clave secreta general de Flask/FastAPI | _(cambiar en producción)_ |
|
||||
| `JWT_SECRET_KEY` | Clave para firmar tokens JWT | _(cambiar en producción)_ |
|
||||
| `DATABASE_URL` | Cadena de conexión a PostgreSQL | `postgresql+asyncpg://servicemanager:...@postgres:5432/servicemanager` |
|
||||
| `REDIS_URL` | URL de conexión a Redis | `redis://redis:6379/0` |
|
||||
| `ENVIRONMENT` | Entorno actual | `development` |
|
||||
| `DEBUG` | Modo debug (muestra errores detallados) | `true` |
|
||||
|
||||
### Generar claves seguras para producción
|
||||
|
||||
**Linux / macOS:**
|
||||
```bash
|
||||
openssl rand -base64 32 # Genera SECRET_KEY
|
||||
openssl rand -base64 32 # Genera JWT_SECRET_KEY
|
||||
```
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
[Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Maximum 256 }))
|
||||
```
|
||||
|
||||
> **Advertencia:** Nunca uses las claves del `.env.example` en producción.
|
||||
> Cambiar las claves en producción invalida todas las sesiones activas.
|
||||
|
||||
### Desarrollo local vs Docker
|
||||
|
||||
En `.env.example` las URLs apuntan a nombres de servicio Docker (`postgres`, `redis`, `backend`).
|
||||
Si ejecutas el backend directamente en tu máquina (sin Docker), cambia:
|
||||
|
||||
```dotenv
|
||||
# Para desarrollo local sin Docker:
|
||||
DATABASE_URL=postgresql+asyncpg://servicemanager:servicemanager123@localhost:5432/servicemanager
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
CELERY_BROKER_URL=redis://localhost:6379/0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cargar datos de prueba
|
||||
|
||||
El script `seed_data.py` crea datos iniciales en la base de datos.
|
||||
|
||||
**Con Docker (recomendado):**
|
||||
```bash
|
||||
docker exec servicemanager-backend python /scripts/seed_data.py
|
||||
```
|
||||
|
||||
**Sin Docker:**
|
||||
```bash
|
||||
cd backend
|
||||
python ../scripts/seed_data.py
|
||||
```
|
||||
|
||||
El script crea:
|
||||
- Tenant de demostración: `aduanasoft-demo`
|
||||
- Categorías de tickets (Soporte Técnico, Facturación, Incidentes Críticos, etc.)
|
||||
- Sistemas registrados
|
||||
- Usuarios de prueba con distintos roles
|
||||
|
||||
---
|
||||
|
||||
## URLs y puertos por defecto
|
||||
|
||||
| Servicio | Puerto | Descripción |
|
||||
|----------|--------|-------------|
|
||||
| Frontend Clientes | **3000** | Portal para usuarios clientes |
|
||||
| Frontend Interno | **3001** | Panel para staff (agentes, admins) |
|
||||
| Backend API | **8000** | FastAPI — endpoints REST |
|
||||
| PostgreSQL | **5432** | Base de datos (no exponer en producción) |
|
||||
| Redis | **6379** | Cache y broker Celery (no exponer en producción) |
|
||||
| Nginx | **80** | Reverse proxy |
|
||||
| Adminer *(perfil dev)* | **8080** | GUI para PostgreSQL |
|
||||
| MailHog *(perfil dev)* | **8025** | Capturador de emails en desarrollo |
|
||||
| Redis Commander *(perfil dev)* | **8081** | GUI para Redis |
|
||||
|
||||
### ¿Conflicto de puertos?
|
||||
|
||||
Si algún puerto ya está en uso en tu máquina, edita `docker-compose.yml` y cambia
|
||||
el número **izquierdo** del mapeo `host:container`. Por ejemplo, para backend en el 8080:
|
||||
|
||||
```yaml
|
||||
ports:
|
||||
- "8080:8000" # ahora accesible en localhost:8080
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Credenciales de prueba
|
||||
|
||||
Después de ejecutar el seed, puedes iniciar sesión con:
|
||||
|
||||
| Campo | Valor |
|
||||
|-------|-------|
|
||||
| Email | `admin@aduanasoft.com` |
|
||||
| Contraseña | `admin123` |
|
||||
| Tenant | `aduanasoft-demo` |
|
||||
| Rol | `ADMIN` |
|
||||
|
||||
> Otros usuarios creados por el seed tienen el mismo sufijo de contraseña (`123`).
|
||||
> Revisa `scripts/seed_data.py` para ver la lista completa.
|
||||
|
||||
---
|
||||
|
||||
## Desarrollo local sin Docker
|
||||
|
||||
Útil cuando necesitas depurar el código con breakpoints o acelerar el ciclo de desarrollo.
|
||||
Requiere que **PostgreSQL y Redis sí corran en Docker** (o instalación nativa).
|
||||
|
||||
### Requisitos adicionales
|
||||
|
||||
| Herramienta | Versión | Descarga |
|
||||
|------------|---------|---------|
|
||||
| Python | 3.11 o 3.12 | https://www.python.org/downloads/ |
|
||||
| Node.js (con npm) | 18 LTS | https://nodejs.org/ |
|
||||
| pip | incluido con Python | — |
|
||||
|
||||
### Iniciar solo la base de datos y Redis
|
||||
|
||||
```bash
|
||||
docker compose up -d postgres redis
|
||||
```
|
||||
|
||||
### Backend (FastAPI)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Crear entorno virtual (solo la primera vez)
|
||||
python -m venv ../.venv
|
||||
|
||||
# Activar entorno virtual
|
||||
# Linux / macOS:
|
||||
source ../.venv/bin/activate
|
||||
# Windows (PowerShell):
|
||||
..\.venv\Scripts\Activate.ps1
|
||||
# Windows (CMD):
|
||||
..\.venv\Scripts\activate.bat
|
||||
|
||||
# Instalar dependencias (solo la primera vez o cuando cambie requirements.txt)
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Ejecutar migraciones de base de datos
|
||||
alembic upgrade head
|
||||
|
||||
# Iniciar servidor de desarrollo
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
> Si `uvicorn` no se encuentra, asegúrate de que el entorno virtual está activado
|
||||
> (`(.venv)` debe aparecer en tu terminal).
|
||||
|
||||
### Frontend Clientes
|
||||
|
||||
```bash
|
||||
cd frontend-client
|
||||
|
||||
# Instalar dependencias (solo la primera vez)
|
||||
npm install
|
||||
|
||||
# Iniciar servidor de desarrollo en puerto 3000
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Frontend Interno (staff)
|
||||
|
||||
```bash
|
||||
cd frontend-internal
|
||||
|
||||
# Instalar dependencias (solo la primera vez)
|
||||
npm install
|
||||
|
||||
# Iniciar servidor de desarrollo en puerto 3001
|
||||
npm run dev
|
||||
```
|
||||
|
||||
> Los dos frontends tienen puertos distintos (3000 y 3001) para que no haya conflicto
|
||||
> cuando corren al mismo tiempo.
|
||||
|
||||
### Workers Celery (opcional en desarrollo)
|
||||
|
||||
Necesario solo si desarrollas funcionalidades de notificaciones o SLAs automáticos.
|
||||
|
||||
```bash
|
||||
cd workers
|
||||
|
||||
# Activar el mismo entorno virtual del backend:
|
||||
# Linux / macOS:
|
||||
source ../.venv/bin/activate
|
||||
# Windows:
|
||||
..\.venv\Scripts\Activate.ps1
|
||||
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Worker principal
|
||||
celery -A app.celery worker --loglevel=info
|
||||
|
||||
# Scheduler de tareas periódicas (en otra terminal)
|
||||
celery -A app.celery beat --loglevel=info --schedule=/tmp/celerybeat-schedule
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Arquitectura del proyecto
|
||||
|
||||
```
|
||||
ServiceManagerWeb/
|
||||
├── backend/ # FastAPI app
|
||||
├── frontend-client/ # SvelteKit app para clientes
|
||||
├── frontend-internal/ # SvelteKit app para staff interno
|
||||
├── workers/ # Celery tasks
|
||||
├── db/ # Migrations y esquemas
|
||||
├── docker/ # Dockerfiles específicos
|
||||
├── docs/ # Documentación adicional
|
||||
├── scripts/ # Scripts de desarrollo/despliegue
|
||||
├── docker-compose.yml # Orquestación completa
|
||||
└── .env.example # Variables de entorno
|
||||
├── backend/ # Aplicación FastAPI (Python 3.11)
|
||||
│ ├── app/
|
||||
│ │ ├── main.py # Punto de entrada, lifespan, middlewares
|
||||
│ │ ├── api/v1/
|
||||
│ │ │ ├── router.py # Registro de todos los routers
|
||||
│ │ │ └── endpoints/ # Endpoints REST por dominio
|
||||
│ │ ├── core/ # Config, seguridad, base de datos, caché
|
||||
│ │ ├── models/ # Modelos SQLAlchemy (ORM)
|
||||
│ │ ├── services/ # Lógica de negocio
|
||||
│ │ └── middleware/ # Tenant context, Correlation ID
|
||||
│ ├── migrations/ # Migraciones Alembic
|
||||
│ ├── tests/ # Pruebas backend
|
||||
│ └── requirements.txt # Dependencias Python
|
||||
│
|
||||
├── frontend-client/ # Portal de clientes (SvelteKit + TypeScript)
|
||||
│ └── src/routes/ # Páginas: login, tickets, perfil
|
||||
│
|
||||
├── frontend-internal/ # Panel de staff (SvelteKit + TypeScript)
|
||||
│ └── src/routes/ # Páginas: dashboard, tickets, reportes, auditoría
|
||||
│
|
||||
├── workers/ # Tareas asíncronas Celery
|
||||
│ └── app/tasks/ # email_tasks.py, sla_tasks.py, etc.
|
||||
│
|
||||
├── docker/ # Dockerfiles y configuración Nginx
|
||||
├── db/ # schema.sql inicial
|
||||
├── docs/ # Documentación técnica adicional
|
||||
├── scripts/ # seed_data.py, setup-dev.sh, etc.
|
||||
├── docker-compose.yml # Orquestación completa
|
||||
└── .env.example # Plantilla de variables de entorno
|
||||
```
|
||||
|
||||
## Stack Tecnológico
|
||||
### Stack tecnológico
|
||||
|
||||
### Backend (Python)
|
||||
- FastAPI (async)
|
||||
- Pydantic v2
|
||||
- SQLAlchemy 2.0 (async)
|
||||
- Alembic (migrations)
|
||||
- Argon2 (hashing passwords)
|
||||
- PyJWT
|
||||
- Celery + Redis
|
||||
**Backend:** Python 3.11 · FastAPI · Pydantic v2 · SQLAlchemy 2.0 (async) · Alembic · Argon2 · PyJWT · Celery · Redis
|
||||
|
||||
### Frontend (JavaScript/TypeScript)
|
||||
- SvelteKit
|
||||
- TypeScript
|
||||
- TailwindCSS
|
||||
- shadcn/ui o similar
|
||||
- Zod (validación)
|
||||
**Frontend:** Node.js 18 · SvelteKit · TypeScript · TailwindCSS · Zod
|
||||
|
||||
### Infraestructura
|
||||
- PostgreSQL 15+
|
||||
- Redis 7+
|
||||
- Docker & Docker Compose
|
||||
- Nginx (reverse proxy)
|
||||
**Infraestructura:** PostgreSQL 15 · Redis 7 · Docker Compose · Nginx
|
||||
|
||||
## Dominios del Sistema
|
||||
---
|
||||
|
||||
1. **Auth**: Usuarios, roles, permisos, 2FA
|
||||
2. **Tenants**: Multi-tenancy, organizaciones
|
||||
3. **Tickets**: Gestión de tickets, estados, SLAs
|
||||
4. **Notifications**: Email, plantillas, logs
|
||||
5. **Audit**: Bitácora de acciones
|
||||
## Roles y permisos
|
||||
|
||||
## Roles de Usuario
|
||||
|
||||
### Internos (Staff)
|
||||
- `ADMIN`: Control total del sistema
|
||||
- `SUPPORT_MANAGER`: Gestión de equipos y SLAs
|
||||
- `AGENT`: Atención de tickets
|
||||
- `AUDITOR`: Solo lectura para auditoría
|
||||
### Personal interno (staff)
|
||||
| Rol | Descripción |
|
||||
|-----|-------------|
|
||||
| `ADMIN` | Control total del sistema |
|
||||
| `SUPPORT_MANAGER` | Gestión de equipos y configuración de SLAs |
|
||||
| `AGENT` | Atención y resolución de tickets |
|
||||
| `AUDITOR` | Solo lectura para revisiones y cumplimiento |
|
||||
|
||||
### Clientes
|
||||
- `CLIENT_ADMIN`: Gestión de organización cliente
|
||||
- `CLIENT_USER`: Creación y seguimiento de tickets
|
||||
| Rol | Descripción |
|
||||
|-----|-------------|
|
||||
| `CLIENT_ADMIN` | Gestión de su organización cliente |
|
||||
| `CLIENT_USER` | Creación y seguimiento de sus propios tickets |
|
||||
|
||||
## Quick Start
|
||||
---
|
||||
|
||||
## Comandos útiles
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```bash
|
||||
# Clonar y configurar
|
||||
git clone <repo>
|
||||
cd ServiceManagerWeb
|
||||
cp .env.example .env
|
||||
# Levantar todos los servicios (segundo plano)
|
||||
docker compose up -d
|
||||
|
||||
# Levantar servicios
|
||||
docker-compose up -d
|
||||
# Levantar con herramientas de desarrollo
|
||||
docker compose --profile dev up -d
|
||||
|
||||
# Verificar estado
|
||||
docker-compose ps
|
||||
# Ver logs en tiempo real de todos los servicios
|
||||
docker compose logs -f
|
||||
|
||||
# Ver logs de un servicio específico
|
||||
docker compose logs -f backend
|
||||
docker compose logs -f frontend-internal
|
||||
|
||||
# Detener todos los servicios (mantiene los datos)
|
||||
docker compose down
|
||||
|
||||
# Detener Y borrar todos los volúmenes (¡borra la base de datos!)
|
||||
docker compose down -v
|
||||
|
||||
# Reconstruir imagen de un servicio (después de cambiar Dockerfile o requirements)
|
||||
docker compose build backend
|
||||
docker compose up -d backend
|
||||
|
||||
# Reiniciar un servicio
|
||||
docker compose restart backend
|
||||
```
|
||||
|
||||
## URLs por Defecto
|
||||
|
||||
- Frontend Clientes: http://localhost:3000
|
||||
- Frontend Interno: http://localhost:3001
|
||||
- API Backend: http://localhost:8000
|
||||
- API Docs: http://localhost:8000/docs
|
||||
- Adminer (DB): http://localhost:8080
|
||||
|
||||
## Scripts de Desarrollo
|
||||
### Base de datos (Alembic)
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
# Aplicar todas las migraciones pendientes
|
||||
cd backend
|
||||
python -m uvicorn app.main:app --reload --port 8000
|
||||
alembic upgrade head
|
||||
|
||||
# Frontend Cliente
|
||||
cd frontend-client
|
||||
npm run dev -- --port 3000
|
||||
# Ver estado de migraciones
|
||||
alembic current
|
||||
|
||||
# Frontend Interno
|
||||
cd frontend-internal
|
||||
npm run dev -- --port 3001
|
||||
# Revertir última migración
|
||||
alembic downgrade -1
|
||||
|
||||
# Workers
|
||||
cd workers
|
||||
celery -A app.worker worker --loglevel=info
|
||||
celery -A app.worker beat --loglevel=info
|
||||
# Crear nueva migración (después de modificar models/)
|
||||
alembic revision --autogenerate -m "nombre descriptivo del cambio"
|
||||
|
||||
# Con Docker:
|
||||
docker exec servicemanager-backend alembic upgrade head
|
||||
```
|
||||
|
||||
## Testing
|
||||
### Calidad de código
|
||||
|
||||
```bash
|
||||
# Backend tests
|
||||
cd backend
|
||||
|
||||
# Linter y auto-fix
|
||||
ruff check . --fix
|
||||
|
||||
# Formateador
|
||||
black .
|
||||
|
||||
# Verificación de tipos
|
||||
mypy .
|
||||
|
||||
# Todo de una vez
|
||||
ruff check . --fix && black . && mypy .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pruebas (testing)
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Ejecutar todas las pruebas
|
||||
pytest
|
||||
|
||||
# Frontend tests
|
||||
cd frontend-client
|
||||
npm test
|
||||
cd ../frontend-internal
|
||||
npm test
|
||||
# Con cobertura detallada
|
||||
pytest --cov=app --cov-report=html
|
||||
|
||||
# Abrir reporte de cobertura (Linux/macOS)
|
||||
open htmlcov/index.html
|
||||
# Windows
|
||||
start htmlcov/index.html
|
||||
|
||||
# Prueba específica
|
||||
pytest tests/test_auth.py -v
|
||||
|
||||
# Con Docker
|
||||
docker exec servicemanager-backend pytest -v --cov=app
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Error 500 en Login / Proxy Error
|
||||
|
||||
**Síntoma**: Error 500 al intentar hacer login, o error de proxy de Vite "connect ECONNREFUSED".
|
||||
|
||||
**Causa**: Configuración incorrecta de la comunicación entre servicios de Docker.
|
||||
|
||||
**Solución**:
|
||||
1. En desarrollo con Docker, los servicios usan nombres de servicio (no `localhost`)
|
||||
2. Verificar `vite.config.js`: el proxy debe apuntar a `http://backend:8000`
|
||||
3. Verificar `docker-compose.yml`: `PUBLIC_API_URL` debe ser `http://backend:8000`
|
||||
4. Después de cambios, reiniciar contenedor: `docker-compose restart frontend-internal`
|
||||
|
||||
**Nota**: Para desarrollo local sin Docker, cambiar el proxy a `http://localhost:8000`.
|
||||
|
||||
### Tenant Slug Incorrecto
|
||||
|
||||
**Síntoma**: Error de autenticación incluso con credenciales correctas.
|
||||
|
||||
**Causa**: El `tenant_slug` en el login no coincide con los tenants en la BD.
|
||||
|
||||
**Solución**:
|
||||
1. Verificar tenants existentes: `docker exec servicemanager-backend python check_tenants.py`
|
||||
2. Actualizar el tenant_slug en el código de login
|
||||
3. Tenants por defecto: `aduanasoft-demo`, `test-tenant`
|
||||
|
||||
### Credenciales de Prueba
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend-internal # o frontend-client
|
||||
npm test # Ejecutar una vez
|
||||
npm run test:watch # Modo observador
|
||||
```
|
||||
Email: admin@aduanasoft.com
|
||||
Password: admin123
|
||||
Tenant: aduanasoft-demo
|
||||
Role: ADMIN
|
||||
|
||||
---
|
||||
|
||||
## Solución de problemas
|
||||
|
||||
### El backend no inicia — error en `DATABASE_URL`
|
||||
|
||||
**Síntoma:** El contenedor `servicemanager-backend` reinicia continuamente.
|
||||
|
||||
**Causa frecuente:** El archivo `.env` no existe o tiene `DATABASE_URL` apuntando a `localhost`
|
||||
en lugar del nombre del servicio Docker `postgres`.
|
||||
|
||||
**Solución:**
|
||||
```bash
|
||||
# Verificar que .env existe
|
||||
ls .env # Linux/macOS
|
||||
dir .env # Windows
|
||||
|
||||
# Si no existe, crearlo
|
||||
cp .env.example .env # Linux/macOS
|
||||
Copy-Item .env.example .env # Windows PowerShell
|
||||
|
||||
# Verificar el valor correcto en .env:
|
||||
# DATABASE_URL=postgresql+asyncpg://servicemanager:servicemanager123@postgres:5432/servicemanager
|
||||
# ^^^^^^^
|
||||
# Nombre de servicio Docker, NO localhost
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Error 500 en login / "connect ECONNREFUSED"
|
||||
|
||||
**Síntoma:** El frontend muestra error 500 al hacer login, o la consola del navegador
|
||||
muestra `ECONNREFUSED 127.0.0.1:8000`.
|
||||
|
||||
**Causa:** El proxy de Vite no encuentra el backend.
|
||||
|
||||
**Solución en Docker:** El proxy ya está configurado para usar `PUBLIC_API_URL`.
|
||||
Verifica en `docker-compose.yml` que `frontend-internal` y `frontend-client` tienen:
|
||||
```yaml
|
||||
environment:
|
||||
- PUBLIC_API_URL=http://backend:8000
|
||||
```
|
||||
Después reinicia:
|
||||
```bash
|
||||
docker compose restart frontend-internal frontend-client
|
||||
```
|
||||
|
||||
**Solución en desarrollo local:** Asegúrate de que el backend está corriendo:
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
# Debe responder: {"status": "ok", ...}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### El frontend-internal y frontend-client usan el mismo puerto localmente
|
||||
|
||||
**Síntoma:** Al correr ambos frontends sin Docker, uno de los dos falla
|
||||
con `Port 3000 is already in use`.
|
||||
|
||||
**Solución:**
|
||||
- `frontend-client` → usa el puerto **3000** (por defecto con `npm run dev`)
|
||||
- `frontend-internal` → usa el puerto **3001** (configurado en `vite.config.js`)
|
||||
|
||||
Nunca hay conflicto si los iniciaste con `npm run dev` en cada carpeta por separado.
|
||||
Si aún hay conflicto, mata el proceso en ese puerto:
|
||||
|
||||
```bash
|
||||
# Linux / macOS
|
||||
lsof -ti:3000 | xargs kill -9
|
||||
|
||||
# Windows (PowerShell)
|
||||
Get-Process -Id (Get-NetTCPConnection -LocalPort 3000).OwningProcess | Stop-Process -Force
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### El tenant slug es incorrecto al hacer login
|
||||
|
||||
**Síntoma:** Login falla con "credenciales inválidas" aunque el email y contraseña son correctos.
|
||||
|
||||
**Causa:** El campo `tenant_slug` no corresponde a ningún tenant en la base de datos.
|
||||
|
||||
**Solución:**
|
||||
```bash
|
||||
# Ver los tenants disponibles
|
||||
docker exec servicemanager-backend python -c "
|
||||
import asyncio
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy import text
|
||||
import os
|
||||
async def main():
|
||||
engine = create_async_engine(os.environ['DATABASE_URL'])
|
||||
async with AsyncSession(engine) as s:
|
||||
result = await s.execute(text('SELECT slug, name FROM tenants'))
|
||||
for row in result:
|
||||
print(row)
|
||||
asyncio.run(main())
|
||||
"
|
||||
```
|
||||
Tenant por defecto (después del seed): **`aduanasoft-demo`**
|
||||
|
||||
---
|
||||
|
||||
### Puerto ocupado — cambiar puertos de los servicios
|
||||
|
||||
Edita `docker-compose.yml` y modifica **solo el número izquierdo** del mapeo de puertos:
|
||||
|
||||
```yaml
|
||||
# Ejemplo: mover el backend al puerto 9000
|
||||
backend:
|
||||
ports:
|
||||
- "9000:8000" # accesible en localhost:9000
|
||||
|
||||
# Ejemplo: mover el frontend al puerto 4000
|
||||
frontend-client:
|
||||
ports:
|
||||
- "4000:3000" # accesible en localhost:4000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Migraciones fallidas — `alembic upgrade head` da error
|
||||
|
||||
```bash
|
||||
# Verificar el estado actual
|
||||
docker exec servicemanager-backend alembic current
|
||||
|
||||
# Si hay conflicto, hacer downgrade hasta la base y volver a subir
|
||||
docker exec servicemanager-backend alembic downgrade base
|
||||
docker exec servicemanager-backend alembic upgrade head
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Módulo Python no encontrado (`ModuleNotFoundError`)
|
||||
|
||||
**Con Docker:** El módulo no está en `requirements.txt` o la imagen no fue reconstruida.
|
||||
```bash
|
||||
# Reconstruir la imagen del backend
|
||||
docker compose build backend
|
||||
docker compose up -d backend
|
||||
```
|
||||
|
||||
**Local:** El entorno virtual no está activado.
|
||||
```bash
|
||||
# Verificar que el venv está activo (debe aparecer (.venv) en el prompt)
|
||||
which python # Linux/macOS — debe apuntar a .venv/
|
||||
# Windows:
|
||||
where python # debe apuntar a .venv\Scripts\python.exe
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `npm: command not found` o versión de Node incorrecta
|
||||
|
||||
```bash
|
||||
node --version # Debe ser v18.x o superior
|
||||
npm --version # Debe ser 9.x o superior
|
||||
```
|
||||
|
||||
Si Node no está instalado, descárgalo desde https://nodejs.org/ (elige "LTS").
|
||||
|
||||
En macOS con Homebrew:
|
||||
```bash
|
||||
brew install node@18
|
||||
```
|
||||
|
||||
En Linux (Ubuntu/Debian):
|
||||
```bash
|
||||
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `docker-compose` no se reconoce como comando
|
||||
|
||||
En versiones modernas de Docker Desktop, el comando es `docker compose` (con espacio, sin guion).
|
||||
Si tienes instalación separada de Docker Compose v1, usa `docker-compose` (con guion).
|
||||
|
||||
---
|
||||
|
||||
### Logs de los contenedores
|
||||
|
||||
```bash
|
||||
# Ver qué está fallando
|
||||
docker compose logs backend --tail=50
|
||||
docker compose logs frontend-internal --tail=50
|
||||
docker compose logs postgres --tail=20
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contribución
|
||||
|
||||
1. Fork del proyecto
|
||||
2. Crear feature branch (`git checkout -b feature/nueva-funcionalidad`)
|
||||
3. Commit cambios (`git commit -am 'Agregar nueva funcionalidad'`)
|
||||
4. Push a branch (`git push origin feature/nueva-funcionalidad`)
|
||||
5. Crear Pull Request
|
||||
1. Haz fork del proyecto
|
||||
2. Crea una rama de funcionalidad: `git checkout -b feature/nombre-funcionalidad`
|
||||
3. Realiza tus cambios siguiendo las convenciones del proyecto
|
||||
4. Ejecuta las pruebas: `pytest` y el linter: `ruff check .`
|
||||
5. Haz commit con un mensaje descriptivo: `git commit -m "feat: agregar exportación a CSV"`
|
||||
6. Sube tu rama: `git push origin feature/nombre-funcionalidad`
|
||||
7. Abre un Pull Request hacia `main`
|
||||
|
||||
### Convenciones de nombres
|
||||
|
||||
- **Modelos**: `PascalCase` → `User`, `Ticket`, `TenantOrganization`
|
||||
- **Endpoints (URL)**: `kebab-case` → `/api/v1/user-management/`
|
||||
- **Componentes Svelte**: `PascalCase.svelte` → `TicketCard.svelte`
|
||||
- **Stores**: `camelCase` → `ticketStore.ts`
|
||||
|
||||
---
|
||||
|
||||
## Historial de versiones
|
||||
|
||||
| Versión | Descripción |
|
||||
|---------|-------------|
|
||||
| **v1.15.1** | Módulo de reportes implementado |
|
||||
| v1.14.x | Mejoras al módulo de auditoría |
|
||||
| v1.13.x | Sistema de SLAs automático |
|
||||
| v1.12.x | Notificaciones por email |
|
||||
| v1.0.0 | MVP inicial — tickets, tenants, autenticación |
|
||||
|
||||
---
|
||||
|
||||
## Licencia
|
||||
|
||||
Propietario - Aduanasoft © 2026
|
||||
Propietario — Aduanasoft © 2026. Todos los derechos reservados.
|
||||
Binary file not shown.
@@ -56,6 +56,22 @@ backend/
|
||||
- [x] TOTP 2FA implementation
|
||||
- [x] Validation con Pydantic v2
|
||||
|
||||
### Rate limiting (login)
|
||||
|
||||
El endpoint `/{API_VERSION}/auth/login` incluye rate limiting (best-effort) usando Redis:
|
||||
|
||||
- Por IP: limita intentos totales por ventana
|
||||
- Por identidad: limita por `(tenant_id, email)` por ventana
|
||||
|
||||
Responde `429 Too Many Requests` con header `Retry-After`.
|
||||
|
||||
Variables de entorno (ver `app/core/config.py`):
|
||||
|
||||
- `RATE_LIMIT_ENABLED` (default: `true`)
|
||||
- `LOGIN_RATE_LIMIT_WINDOW_SECONDS` (default: `300`)
|
||||
- `LOGIN_RATE_LIMIT_IP_MAX_ATTEMPTS` (default: `30`)
|
||||
- `LOGIN_RATE_LIMIT_ID_MAX_ATTEMPTS` (default: `10`)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
@@ -157,8 +173,8 @@ Ver `.env.example` para todas las variables disponibles.
|
||||
- [x] CORS restrictivo
|
||||
- [x] Input validation con Pydantic
|
||||
- [x] SQL injection protection (SQLAlchemy)
|
||||
- [x] Rate limiting (TODO: implementar)
|
||||
- [x] File upload validation (TODO: implementar)
|
||||
- [x] Rate limiting (login)
|
||||
- [x] File upload validation (extensión + firma básica + tamaño + streaming)
|
||||
- [x] XSS protection (headers en nginx)
|
||||
|
||||
## Próximos pasos
|
||||
|
||||
48
backend/add_participant.py
Normal file
48
backend/add_participant.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import asyncio
|
||||
from app.core.database import AsyncSessionLocal, Base, engine
|
||||
from app.models.ticket import Ticket
|
||||
from sqlalchemy import String, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.core.database import GUID
|
||||
import uuid
|
||||
|
||||
# Agregar modelo al archivo ticket.py
|
||||
new_model = '''
|
||||
|
||||
class TicketParticipant(Base):
|
||||
"""Participantes asignados a un ticket"""
|
||||
__tablename__ = "ticket_participants"
|
||||
|
||||
ticket_id: Mapped[uuid.UUID] = mapped_column(
|
||||
GUID(),
|
||||
ForeignKey("tickets.id", ondelete="CASCADE"),
|
||||
nullable=False
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
GUID(),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(50), nullable=False, default="participant")
|
||||
|
||||
ticket: Mapped["Ticket"] = relationship("Ticket", back_populates="participants")
|
||||
user: Mapped["User"] = relationship("User")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("ticket_id", "user_id", name="uq_ticket_participant"),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TicketParticipant(ticket={self.ticket_id}, user={self.user_id})>"
|
||||
'''
|
||||
|
||||
with open("/app/app/models/ticket.py", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
if "TicketParticipant" in content:
|
||||
print("Ya existe TicketParticipant")
|
||||
else:
|
||||
content += new_model
|
||||
with open("/app/app/models/ticket.py", "w") as f:
|
||||
f.write(content)
|
||||
print("OK: TicketParticipant agregado")
|
||||
21
backend/add_relation.py
Normal file
21
backend/add_relation.py
Normal file
@@ -0,0 +1,21 @@
|
||||
with open("/app/app/models/ticket.py", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
old = ' def __repr__(self) -> str:\n return f"<Ticket(id={self.id}, number=\'{self.ticket_number}\', status={self.status})>"'
|
||||
new = ''' participants: Mapped[list["TicketParticipant"]] = relationship(
|
||||
"TicketParticipant",
|
||||
back_populates="ticket",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Ticket(id={self.id}, number=\'{self.ticket_number}\', status={self.status})>"'''
|
||||
|
||||
if old in content:
|
||||
content = content.replace(old, new)
|
||||
print("OK: relacion participants agregada")
|
||||
else:
|
||||
print("ERROR: bloque no encontrado")
|
||||
|
||||
with open("/app/app/models/ticket.py", "w") as f:
|
||||
f.write(content)
|
||||
@@ -1,4 +1,6 @@
|
||||
from typing import Optional
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from starlette.requests import Request
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import jwt, JWTError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -14,9 +16,51 @@ from app.models.tenant import Tenant
|
||||
settings = get_settings()
|
||||
|
||||
# Esquema OAuth2 centralizado — auth.py importa desde aquí
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/{settings.API_VERSION}/auth/login")
|
||||
# Soporta: 1) Authorization: Bearer header (Swagger/API clients)
|
||||
# 2) Cookie access_token HttpOnly (apps web)
|
||||
_bearer_scheme = OAuth2PasswordBearer(
|
||||
tokenUrl=f"/{settings.API_VERSION}/auth/login",
|
||||
auto_error=False,
|
||||
)
|
||||
|
||||
|
||||
async def oauth2_scheme(
|
||||
request: Request,
|
||||
bearer_token: Optional[str] = Depends(_bearer_scheme),
|
||||
) -> str:
|
||||
"""Extrae JWT desde header Authorization (prioridad) o cookie del frontend correcto.
|
||||
|
||||
Usa el header X-App para seleccionar la cookie:
|
||||
- X-App: internal → solo 'internal_access_token'
|
||||
- X-App: client → solo 'client_access_token'
|
||||
- sin header → prueba ambas (compatibilidad con Swagger/CLI)
|
||||
"""
|
||||
if bearer_token:
|
||||
return bearer_token
|
||||
|
||||
app_hint = request.headers.get("X-App", "").lower()
|
||||
if app_hint == "internal":
|
||||
token = request.cookies.get("internal_access_token")
|
||||
elif app_hint == "client":
|
||||
token = request.cookies.get("client_access_token")
|
||||
else:
|
||||
# Fallback para Swagger, tests y clientes sin header
|
||||
token = (
|
||||
request.cookies.get("internal_access_token")
|
||||
or request.cookies.get("client_access_token")
|
||||
)
|
||||
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
request: Request,
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> User:
|
||||
@@ -43,6 +87,16 @@ async def get_current_user(
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=400, detail="Inactive user")
|
||||
|
||||
# Enforce that tenant header (if present) matches the authenticated user's tenant.
|
||||
# Roles globales (is_global) pueden operar en cualquier tenant → omitir chequeo.
|
||||
# Roles de cliente (is_client) deben coincidir con su propio tenant.
|
||||
request_tenant_id = getattr(getattr(request, "state", None), "tenant_id", None)
|
||||
if request_tenant_id and not user.role.is_global and str(user.tenant_id) != str(request_tenant_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Tenant header does not match authenticated user",
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ class AuditLogResponse(AuditLogBase):
|
||||
|
||||
class SecurityThreatPattern(BaseModel):
|
||||
"""Patrón de amenaza detectado."""
|
||||
id: str = Field(description="ID único de la amenaza (pattern_id)")
|
||||
type: str = Field(description="Tipo de amenaza (brute_force, privilege_escalation, etc.)")
|
||||
severity: str = Field(description="Severidad: low, medium, high, critical")
|
||||
description: str = Field(description="Descripción de la amenaza")
|
||||
@@ -65,7 +66,7 @@ class SecurityThreatPattern(BaseModel):
|
||||
affected_users: list[str] = Field(default=[], description="Usuarios afectados")
|
||||
first_seen: datetime = Field(description="Primera ocurrencia")
|
||||
last_seen: datetime = Field(description="Última ocurrencia")
|
||||
recommendations: list[str] = Field(default=[], description="Recomendaciones de acción")
|
||||
recommended_action: str = Field(default="", description="Acción recomendada")
|
||||
|
||||
|
||||
class SecurityAnalysisResponse(BaseModel):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Auth Schemas - ServiceManagerWeb
|
||||
|
||||
Pydantic schemas para autenticación y autorización.
|
||||
Pydantic schemas para autenticación y autorización.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
@@ -12,7 +12,7 @@ class LoginRequest(BaseModel):
|
||||
"""Schema para solicitud de login."""
|
||||
email: EmailStr
|
||||
password: str
|
||||
tenant_slug: str
|
||||
tenant_slug: Optional[str] = None
|
||||
totp_code: Optional[str] = None
|
||||
|
||||
|
||||
@@ -53,28 +53,28 @@ class TwoFactorSetupResponse(BaseModel):
|
||||
|
||||
|
||||
class TwoFactorEnableRequest(BaseModel):
|
||||
"""Código TOTP para confirmar y activar 2FA."""
|
||||
"""Código TOTP para confirmar y activar 2FA."""
|
||||
totp_code: str
|
||||
|
||||
|
||||
class TwoFactorEnableResponse(BaseModel):
|
||||
"""Resultado al habilitar 2FA: incluye los códigos de respaldo."""
|
||||
"""Resultado al habilitar 2FA: incluye los códigos de respaldo."""
|
||||
enabled: bool
|
||||
backup_codes: List[str]
|
||||
|
||||
|
||||
class TwoFactorDisableRequest(BaseModel):
|
||||
"""Deshabilitar 2FA verificando con TOTP o código de respaldo."""
|
||||
"""Deshabilitar 2FA verificando con TOTP o código de respaldo."""
|
||||
totp_code: Optional[str] = None
|
||||
backup_code: Optional[str] = None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Cambio de contraseña
|
||||
# Cambio de contraseña
|
||||
# ============================================================
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
"""Schema para cambio de contraseña del usuario autenticado."""
|
||||
"""Schema para cambio de contraseña del usuario autenticado."""
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
@@ -82,15 +82,15 @@ class ChangePasswordRequest(BaseModel):
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Recuperación de contraseña
|
||||
# Recuperación de contraseña
|
||||
# ============================================================
|
||||
|
||||
class ForgotPasswordRequest(BaseModel):
|
||||
"""Solicitar enlace de reseteo de contraseña por email."""
|
||||
"""Solicitar enlace de reseteo de contraseña por email."""
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
"""Aplicar nueva contraseña usando token de reseteo."""
|
||||
"""Aplicar nueva contraseña usando token de reseteo."""
|
||||
token: str
|
||||
new_password: str
|
||||
|
||||
@@ -11,13 +11,14 @@ import uuid
|
||||
|
||||
|
||||
class CategoryCreate(BaseModel):
|
||||
"""Schema para crear categoría. No incluye tenant_id (se asigna automáticamente)."""
|
||||
"""Schema para crear categoría. tenant_id opcional para ADMIN global."""
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
sla_response_hours: int = 24
|
||||
sla_resolution_hours: int = 72
|
||||
auto_assign_to: Optional[uuid.UUID] = None
|
||||
tenant_id: Optional[uuid.UUID] = None
|
||||
|
||||
|
||||
class CategoryUpdate(BaseModel):
|
||||
|
||||
53
backend/app/api/schemas/issue.py
Normal file
53
backend/app/api/schemas/issue.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Schemas para TicketIssue"""
|
||||
from pydantic import BaseModel, field_validator
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
|
||||
class TaggedUserBasic(BaseModel):
|
||||
id: uuid.UUID
|
||||
full_name: str
|
||||
email: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class IssueCreate(BaseModel):
|
||||
content: str
|
||||
priority: str = "MEDIUM"
|
||||
tagged_user_ids: List[uuid.UUID] = []
|
||||
|
||||
@field_validator("priority")
|
||||
@classmethod
|
||||
def validate_priority(cls, v: str) -> str:
|
||||
valid = {"LOW", "MEDIUM", "HIGH", "URGENT"}
|
||||
if v.upper() not in valid:
|
||||
raise ValueError(f"priority debe ser uno de: {valid}")
|
||||
return v.upper()
|
||||
|
||||
@field_validator("content")
|
||||
@classmethod
|
||||
def validate_content(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("content no puede estar vacío")
|
||||
return v.strip()
|
||||
|
||||
|
||||
class IssueResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
ticket_id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
content: str
|
||||
priority: str
|
||||
created_by: uuid.UUID
|
||||
created_by_name: str
|
||||
tagged_users: List[TaggedUserBasic] = []
|
||||
attachment_filename: Optional[str] = None
|
||||
attachment_mime_type: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
227
backend/app/api/schemas/reports.py
Normal file
227
backend/app/api/schemas/reports.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
Reports Schemas - ServiceManagerWeb
|
||||
|
||||
Schemas de respuesta para el módulo de reportes y estadísticas.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# ===================================
|
||||
# RESUMEN GENERAL
|
||||
# ===================================
|
||||
|
||||
class TicketsByStatus(BaseModel):
|
||||
"""Conteo de tickets agrupado por estado"""
|
||||
new: int = 0
|
||||
triage: int = 0
|
||||
in_progress: int = 0
|
||||
waiting_customer: int = 0
|
||||
resolved: int = 0
|
||||
closed: int = 0
|
||||
reopened: int = 0
|
||||
total: int = 0
|
||||
|
||||
|
||||
class TicketsByPriority(BaseModel):
|
||||
"""Conteo de tickets agrupado por prioridad"""
|
||||
low: int = 0
|
||||
medium: int = 0
|
||||
high: int = 0
|
||||
urgent: int = 0
|
||||
total: int = 0
|
||||
|
||||
|
||||
class ReportSummaryResponse(BaseModel):
|
||||
"""Resumen ejecutivo del período seleccionado"""
|
||||
period_start: datetime
|
||||
period_end: datetime
|
||||
generated_at: datetime
|
||||
|
||||
# Totales del período
|
||||
total_tickets: int
|
||||
open_tickets: int # Tickets sin resolver
|
||||
resolved_tickets: int # Tickets resueltos o cerrados
|
||||
avg_resolution_hours: Optional[float] # Promedio de horas para resolver
|
||||
avg_first_response_hours: Optional[float] # Promedio de horas para primera respuesta
|
||||
|
||||
# Satisfacción del cliente
|
||||
avg_rating: Optional[float] # Promedio de calificación (1-5)
|
||||
total_rated: int # Cuántos tickets tienen calificación
|
||||
|
||||
# Desglose por estado y prioridad
|
||||
by_status: TicketsByStatus
|
||||
by_priority: TicketsByPriority
|
||||
|
||||
# Comparación vs período anterior
|
||||
tickets_change_pct: Optional[float] # % cambio vs período anterior
|
||||
resolution_change_pct: Optional[float] # % cambio en tasa de resolución
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ===================================
|
||||
# RENDIMIENTO POR AGENTE
|
||||
# ===================================
|
||||
|
||||
class AgentReportRow(BaseModel):
|
||||
"""Estadísticas de un agente específico"""
|
||||
agent_id: str
|
||||
agent_name: str
|
||||
agent_email: str
|
||||
total_assigned: int # Total asignados en el período
|
||||
resolved: int # Cuántos resolvió
|
||||
open: int # Cuántos siguen abiertos
|
||||
resolution_rate: float # Porcentaje de resolución (0-100)
|
||||
avg_resolution_hours: Optional[float] # Promedio de horas para resolver
|
||||
avg_rating: Optional[float] # Calificación promedio (1-5)
|
||||
total_rated: int # Cuántos tickets calificaron al agente
|
||||
urgent_handled: int # Urgentes atendidos
|
||||
|
||||
|
||||
class AgentReportResponse(BaseModel):
|
||||
"""Reporte de rendimiento por agente"""
|
||||
period_start: datetime
|
||||
period_end: datetime
|
||||
generated_at: datetime
|
||||
agents: List[AgentReportRow]
|
||||
total_agents: int
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ===================================
|
||||
# TICKETS POR CATEGORÍA
|
||||
# ===================================
|
||||
|
||||
class CategoryReportRow(BaseModel):
|
||||
"""Estadísticas de una categoría"""
|
||||
category_id: str
|
||||
category_name: str
|
||||
total_tickets: int
|
||||
open_tickets: int
|
||||
resolved_tickets: int
|
||||
avg_resolution_hours: Optional[float]
|
||||
sla_response_hours: int # SLA configurado para respuesta
|
||||
sla_resolution_hours: int # SLA configurado para resolución
|
||||
sla_compliance_pct: float # % de tickets que cumplieron SLA de resolución
|
||||
|
||||
|
||||
class CategoryReportResponse(BaseModel):
|
||||
"""Reporte de tickets agrupado por categoría"""
|
||||
period_start: datetime
|
||||
period_end: datetime
|
||||
generated_at: datetime
|
||||
categories: List[CategoryReportRow]
|
||||
uncategorized_count: int
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ===================================
|
||||
# TICKETS POR CLIENTE (TENANT)
|
||||
# ===================================
|
||||
|
||||
class ClientReportRow(BaseModel):
|
||||
"""Estadísticas de un cliente (tenant)"""
|
||||
tenant_id: str
|
||||
tenant_name: str
|
||||
total_tickets: int
|
||||
open_tickets: int
|
||||
resolved_tickets: int
|
||||
urgent_tickets: int
|
||||
avg_resolution_hours: Optional[float]
|
||||
avg_rating: Optional[float]
|
||||
last_ticket_at: Optional[datetime]
|
||||
|
||||
|
||||
class ClientReportResponse(BaseModel):
|
||||
"""Reporte de tickets agrupado por cliente — solo ADMIN"""
|
||||
period_start: datetime
|
||||
period_end: datetime
|
||||
generated_at: datetime
|
||||
clients: List[ClientReportRow]
|
||||
total_clients: int
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ===================================
|
||||
# TENDENCIAS (TICKETS EN EL TIEMPO)
|
||||
# ===================================
|
||||
|
||||
class TrendDataPoint(BaseModel):
|
||||
"""Un punto de datos en la línea de tendencia"""
|
||||
date: str # Formato YYYY-MM-DD
|
||||
created: int # Tickets creados ese día
|
||||
resolved: int # Tickets resueltos ese día
|
||||
net_open: int # Diferencia: creados - resueltos
|
||||
|
||||
|
||||
class TrendsReportResponse(BaseModel):
|
||||
"""Evolución de tickets día a día"""
|
||||
period_start: datetime
|
||||
period_end: datetime
|
||||
generated_at: datetime
|
||||
data_points: List[TrendDataPoint]
|
||||
total_days: int
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ===================================
|
||||
# SATISFACCIÓN DEL CLIENTE (CSAT)
|
||||
# ===================================
|
||||
|
||||
class CSATDistribution(BaseModel):
|
||||
"""Distribución de calificaciones 1-5"""
|
||||
rating_1: int = 0
|
||||
rating_2: int = 0
|
||||
rating_3: int = 0
|
||||
rating_4: int = 0
|
||||
rating_5: int = 0
|
||||
|
||||
|
||||
class CSATReportResponse(BaseModel):
|
||||
"""Reporte de satisfacción del cliente"""
|
||||
period_start: datetime
|
||||
period_end: datetime
|
||||
generated_at: datetime
|
||||
avg_rating: Optional[float]
|
||||
total_rated: int
|
||||
total_tickets: int
|
||||
response_rate: float # % de tickets que recibieron calificación
|
||||
distribution: CSATDistribution
|
||||
by_category: List[Dict[str, Any]] # Promedio por categoría
|
||||
by_agent: List[Dict[str, Any]] # Promedio por agente
|
||||
recent_comments: List[Dict[str, Any]] = [] # Últimos comentarios de calificación
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ===================================
|
||||
# TICKETS POR SISTEMA AFECTADO
|
||||
# ===================================
|
||||
|
||||
class SystemReportRow(BaseModel):
|
||||
"""Estadísticas de un sistema afectado"""
|
||||
system_id: str
|
||||
system_name: str
|
||||
total_tickets: int
|
||||
open_tickets: int
|
||||
resolved_tickets: int
|
||||
urgent_tickets: int
|
||||
avg_resolution_hours: Optional[float]
|
||||
|
||||
|
||||
class SystemReportResponse(BaseModel):
|
||||
"""Reporte de tickets agrupado por sistema afectado"""
|
||||
period_start: datetime
|
||||
period_end: datetime
|
||||
generated_at: datetime
|
||||
systems: List[SystemReportRow]
|
||||
no_system_count: int # Tickets sin sistema asignado
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -4,8 +4,8 @@ Ticket Schemas - ServiceManagerWeb
|
||||
Pydantic schemas para gestión de tickets y comentarios.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, ConfigDict, model_validator
|
||||
from typing import Optional, Literal
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@@ -15,7 +15,23 @@ class TicketCreate(BaseModel):
|
||||
description: str
|
||||
category_id: Optional[str] = None
|
||||
affected_system_id: Optional[str] = None
|
||||
priority: str = "MEDIUM"
|
||||
priority: Literal["LOW", "MEDIUM", "HIGH", "URGENT"] = "MEDIUM"
|
||||
contact_email: Optional[str] = None
|
||||
contact_phone: Optional[str] = None
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _accept_legacy_fields(cls, data):
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
|
||||
if "subject" not in data and "title" in data:
|
||||
data["subject"] = data["title"]
|
||||
|
||||
if "affected_system_id" not in data and "system_id" in data:
|
||||
data["affected_system_id"] = data["system_id"]
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class TicketUpdate(BaseModel):
|
||||
@@ -39,9 +55,15 @@ class TicketResponse(BaseModel):
|
||||
status: str
|
||||
priority: str
|
||||
category_id: Optional[str] = None
|
||||
category_name: Optional[str] = None
|
||||
affected_system_id: Optional[str] = None
|
||||
system_id: Optional[str] = None
|
||||
affected_system_name: Optional[str] = None
|
||||
contact_email: Optional[str] = None
|
||||
contact_phone: Optional[str] = None
|
||||
created_by: str
|
||||
assigned_to: Optional[str] = None
|
||||
assigned_to_name: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
sla_response_due: Optional[datetime] = None
|
||||
|
||||
@@ -1,8 +1,34 @@
|
||||
"""Helper functions for audit endpoints"""
|
||||
"""
|
||||
Audit Helpers - ServiceManagerWeb
|
||||
===================================
|
||||
Funciones auxiliares reutilizables para los endpoints de auditoría.
|
||||
|
||||
Este archivo contiene:
|
||||
- audit_log_to_dict: Convierte un modelo AuditLog a diccionario
|
||||
- apply_tenant_filter: Aplica filtro de tenant según permisos
|
||||
- get_count_stat: Cuenta registros con filtros opcionales (CORREGIDO)
|
||||
- get_top_items: Obtiene los items más frecuentes
|
||||
- detect_mass_deletions: Detecta eliminaciones masivas sospechosas
|
||||
- detect_brute_force: Detecta ataques de fuerza bruta
|
||||
- detect_privilege_escalation: Detecta escaladas de privilegios
|
||||
|
||||
CORRECCIÓN APLICADA en get_count_stat:
|
||||
La columna created_at en PostgreSQL es 'timestamp with time zone' (TIMESTAMPTZ),
|
||||
lo que significa que almacena y devuelve fechas CON información de timezone (+00).
|
||||
|
||||
El bug era que se comparaba un datetime naive (sin timezone) contra una columna
|
||||
TIMESTAMPTZ. PostgreSQL no puede comparar ambos tipos directamente, por lo que
|
||||
el filtro se ignoraba silenciosamente y los tres contadores devolvían el mismo
|
||||
valor (el total histórico completo sin ningún filtro de fecha).
|
||||
|
||||
La solución es garantizar que TODAS las fechas que se usen en queries tengan
|
||||
timezone info (aware datetime en UTC) usando _ensure_aware_utc().
|
||||
"""
|
||||
|
||||
from sqlalchemy import select, func, and_, or_, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Optional, Dict, List
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
import uuid
|
||||
|
||||
from app.models.audit import AuditLog
|
||||
@@ -10,8 +36,18 @@ from app.models.user import User, UserRole
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CONVERSIÓN DE MODELOS
|
||||
# =============================================================================
|
||||
|
||||
def audit_log_to_dict(log: AuditLog) -> dict:
|
||||
"""Convierte AuditLog a diccionario de respuesta"""
|
||||
"""
|
||||
Convierte un objeto AuditLog de SQLAlchemy a un diccionario plano
|
||||
compatible con los schemas de respuesta de Pydantic.
|
||||
|
||||
Incluye los datos del usuario relacionado si están cargados
|
||||
(requiere que la query use selectinload(AuditLog.user)).
|
||||
"""
|
||||
log_dict = {
|
||||
"id": log.id,
|
||||
"tenant_id": log.tenant_id,
|
||||
@@ -19,203 +55,463 @@ def audit_log_to_dict(log: AuditLog) -> dict:
|
||||
"action": log.action,
|
||||
"resource_type": log.resource_type,
|
||||
"resource_id": log.resource_id,
|
||||
# ip_address puede ser un objeto especial de PostgreSQL, convertir a string
|
||||
"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,
|
||||
# extra_metadata evita conflicto con la palabra reservada 'metadata'
|
||||
"metadata": log.extra_metadata,
|
||||
"created_at": log.created_at,
|
||||
"action_display": log.action_display,
|
||||
# Campos del usuario (se llenan abajo si la relación está cargada)
|
||||
"user_email": None,
|
||||
"user_name": None
|
||||
"user_name": None,
|
||||
"user_role": None,
|
||||
}
|
||||
|
||||
|
||||
# Solo agregar datos del usuario si la relación fue cargada en la query
|
||||
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)
|
||||
|
||||
# El rol puede ser un Enum de Python o un string, manejar ambos casos
|
||||
log_dict["user_role"] = (
|
||||
log.user.role.value
|
||||
if hasattr(log.user.role, 'value')
|
||||
else str(log.user.role)
|
||||
)
|
||||
|
||||
return log_dict
|
||||
|
||||
|
||||
def apply_tenant_filter(query, current_user: User, current_tenant: Tenant, all_tenants: bool = False, specific_tenant_id: Optional[uuid.UUID] = None):
|
||||
"""Aplica filtro de tenant según permisos del usuario"""
|
||||
# =============================================================================
|
||||
# FILTRO DE MULTI-TENANCY
|
||||
# =============================================================================
|
||||
|
||||
def apply_tenant_filter(
|
||||
query,
|
||||
current_user: User,
|
||||
current_tenant: Tenant,
|
||||
all_tenants: bool = False,
|
||||
specific_tenant_id: Optional[uuid.UUID] = None
|
||||
):
|
||||
"""
|
||||
Aplica el filtro de tenant a una query de SQLAlchemy según los
|
||||
permisos del usuario actual.
|
||||
|
||||
Reglas:
|
||||
- ADMIN y SUPPORT_MANAGER pueden ver todos los tenants si
|
||||
all_tenants=True, o filtrar por un tenant específico.
|
||||
- Cualquier otro rol solo puede ver los datos de su propio tenant.
|
||||
"""
|
||||
can_see_all_tenants = current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]
|
||||
|
||||
|
||||
if all_tenants and can_see_all_tenants:
|
||||
return query # No filtrar por tenant
|
||||
# Usuario privilegiado pidiendo ver todos los tenants → sin filtro
|
||||
return query
|
||||
elif specific_tenant_id and can_see_all_tenants:
|
||||
# Usuario privilegiado pidiendo un tenant específico
|
||||
return query.where(AuditLog.tenant_id == specific_tenant_id)
|
||||
else:
|
||||
# Cualquier otro caso → solo ver el propio tenant
|
||||
return query.where(AuditLog.tenant_id == current_tenant.id)
|
||||
|
||||
|
||||
async def get_count_stat(db: AsyncSession, tenant_id: Optional[uuid.UUID] = None,
|
||||
date_from: Optional[datetime] = None, action_filter=None) -> int:
|
||||
"""Obtiene estadística de conteo con filtros opcionales"""
|
||||
# =============================================================================
|
||||
# UTILIDAD DE FECHAS
|
||||
# =============================================================================
|
||||
|
||||
def _ensure_aware_utc(dt: datetime) -> datetime:
|
||||
"""
|
||||
Garantiza que un datetime tenga información de timezone en UTC.
|
||||
|
||||
PROBLEMA QUE RESUELVE:
|
||||
La columna created_at en PostgreSQL es 'timestamp with time zone'
|
||||
(TIMESTAMPTZ). Cuando se compara con un datetime naive (sin timezone),
|
||||
PostgreSQL no puede hacer la comparación correctamente y el filtro
|
||||
de fecha se ignora silenciosamente, devolviendo todos los registros
|
||||
sin importar la fecha.
|
||||
|
||||
SOLUCIÓN:
|
||||
Siempre convertir las fechas a aware UTC antes de usarlas en queries.
|
||||
|
||||
Casos que maneja:
|
||||
- datetime naive (sin tzinfo): agrega UTC como timezone
|
||||
- datetime aware (con tzinfo): convierte a UTC si es otra zona horaria
|
||||
|
||||
Ejemplos:
|
||||
datetime(2026, 2, 24, 15, 0, 0) → datetime(2026, 2, 24, 15, 0, 0, tzinfo=UTC)
|
||||
datetime(2026, 2, 24, 9, 0, 0, tzinfo=CST) → datetime(2026, 2, 24, 15, 0, 0, tzinfo=UTC)
|
||||
"""
|
||||
if dt.tzinfo is None:
|
||||
# Datetime naive → asumir que ya es UTC y agregarle timezone info
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
# Datetime aware → convertir a UTC (por si viene en otra zona horaria)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CONTADORES DE ESTADÍSTICAS
|
||||
# =============================================================================
|
||||
|
||||
async def get_count_stat(
|
||||
db: AsyncSession,
|
||||
tenant_id: Optional[uuid.UUID] = None,
|
||||
date_from: Optional[datetime] = None,
|
||||
action_filter=None
|
||||
) -> int:
|
||||
"""
|
||||
Cuenta registros de AuditLog con filtros opcionales.
|
||||
|
||||
Usado por get_audit_stats() para calcular:
|
||||
- total_actions: Sin date_from → cuenta todos los registros
|
||||
- actions_today: date_from = now - 24h → registros del día
|
||||
- actions_this_week: date_from = now - 7d → registros de la semana
|
||||
|
||||
CORRECCIÓN: Las fechas se convierten a aware UTC con _ensure_aware_utc()
|
||||
antes de usarlas en la query, para que sean compatibles con la columna
|
||||
TIMESTAMPTZ de PostgreSQL y el filtro se aplique correctamente.
|
||||
|
||||
Args:
|
||||
db: Sesión de base de datos
|
||||
tenant_id: Si se especifica, filtra por ese tenant
|
||||
date_from: Si se especifica, solo cuenta registros desde esa fecha
|
||||
action_filter: Condición SQLAlchemy adicional opcional
|
||||
|
||||
Returns:
|
||||
Número entero de registros que cumplen los filtros
|
||||
"""
|
||||
query = select(func.count()).select_from(AuditLog)
|
||||
|
||||
|
||||
if tenant_id:
|
||||
query = query.where(AuditLog.tenant_id == tenant_id)
|
||||
|
||||
if date_from:
|
||||
query = query.where(AuditLog.created_at >= date_from)
|
||||
# CORRECCIÓN: convertir a aware UTC para compatibilidad con TIMESTAMPTZ
|
||||
# Sin esto, el filtro se ignora y los tres contadores son idénticos
|
||||
date_from_aware = _ensure_aware_utc(date_from)
|
||||
query = query.where(AuditLog.created_at >= date_from_aware)
|
||||
|
||||
if action_filter is not None:
|
||||
query = query.where(action_filter)
|
||||
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
async def get_top_items(db: AsyncSession, field, tenant_id: Optional[uuid.UUID] = None,
|
||||
limit: int = 5, join_user: bool = False) -> Dict[str, int]:
|
||||
"""Obtiene top items por campo con conteo"""
|
||||
# =============================================================================
|
||||
# ITEMS MÁS FRECUENTES
|
||||
# =============================================================================
|
||||
|
||||
async def get_top_items(
|
||||
db: AsyncSession,
|
||||
field,
|
||||
tenant_id: Optional[uuid.UUID] = None,
|
||||
limit: int = 5,
|
||||
join_user: bool = False
|
||||
) -> Dict[str, int]:
|
||||
"""
|
||||
Obtiene los valores más frecuentes de un campo, ordenados por conteo.
|
||||
|
||||
Ejemplos de uso:
|
||||
- get_top_items(db, AuditLog.action, ...) → {"ticket.create": 45}
|
||||
- get_top_items(db, AuditLog.resource_type, ...) → {"ticket": 60}
|
||||
- get_top_items(db, None, ..., join_user=True) → {"admin@empresa.com": 40}
|
||||
|
||||
Args:
|
||||
db: Sesión de base de datos
|
||||
field: Campo de AuditLog por el que agrupar
|
||||
tenant_id: Si se especifica, filtra por ese tenant
|
||||
limit: Máximo de resultados a devolver (por defecto 5)
|
||||
join_user: Si True, agrupa por email de usuario
|
||||
|
||||
Returns:
|
||||
Diccionario {valor: conteo} ordenado de mayor a menor
|
||||
"""
|
||||
if join_user:
|
||||
query = select(User.email, func.count(AuditLog.id).label('count')).join(User, AuditLog.user_id == User.id)
|
||||
# Modo usuarios: hacer JOIN con tabla User y agrupar por email
|
||||
query = (
|
||||
select(User.email, func.count(AuditLog.id).label('count'))
|
||||
.join(User, AuditLog.user_id == User.id)
|
||||
)
|
||||
else:
|
||||
# Modo campo: agrupar por el campo especificado
|
||||
query = select(field, func.count(AuditLog.id).label('count'))
|
||||
|
||||
|
||||
if tenant_id:
|
||||
query = query.where(AuditLog.tenant_id == tenant_id)
|
||||
|
||||
if not join_user:
|
||||
query = query.group_by(field)
|
||||
else:
|
||||
|
||||
if join_user:
|
||||
query = query.group_by(User.email)
|
||||
|
||||
else:
|
||||
query = query.group_by(field)
|
||||
|
||||
query = query.order_by(desc('count')).limit(limit)
|
||||
|
||||
|
||||
result = await db.execute(query)
|
||||
return {row[0]: row[1] for row in result}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DETECTORES DE INCIDENTES DE SEGURIDAD
|
||||
# =============================================================================
|
||||
|
||||
def detect_mass_deletions(logs: List[AuditLog], now: datetime) -> List[dict]:
|
||||
"""Detecta eliminaciones masivas de logs de auditoría"""
|
||||
"""
|
||||
Detecta patrones de eliminación masiva agrupando por usuario y día.
|
||||
|
||||
Lógica:
|
||||
- Agrupa todos los logs de eliminación por (usuario, día)
|
||||
- Si un usuario eliminó >= 3 recursos en un día, genera un incidente
|
||||
- La severidad escala según la cantidad:
|
||||
- >= 3 eliminaciones → medium
|
||||
- >= 5 eliminaciones → high
|
||||
- >= 10 eliminaciones → critical
|
||||
|
||||
El estado del incidente es:
|
||||
- "active": si la última eliminación fue hace menos de 24 horas
|
||||
- "resolved": si fue hace más de 24 horas
|
||||
"""
|
||||
# Agrupar eliminaciones por usuario y día
|
||||
deletion_groups = {}
|
||||
|
||||
|
||||
for log in logs:
|
||||
if not log.user:
|
||||
continue
|
||||
|
||||
|
||||
key = f"{log.user.email}_{log.created_at.date()}"
|
||||
|
||||
if key not in deletion_groups:
|
||||
deletion_groups[key] = {
|
||||
'user': log.user.email, 'date': log.created_at.date(),
|
||||
'count': 0, 'logs': [], 'first_seen': log.created_at, 'last_seen': log.created_at
|
||||
'user': log.user.email,
|
||||
'date': log.created_at.date(),
|
||||
'count': 0,
|
||||
'logs': [],
|
||||
'first_seen': log.created_at,
|
||||
'last_seen': log.created_at
|
||||
}
|
||||
|
||||
|
||||
deletion_groups[key]['count'] += 1
|
||||
deletion_groups[key]['logs'].append(log)
|
||||
deletion_groups[key]['first_seen'] = min(deletion_groups[key]['first_seen'], log.created_at)
|
||||
deletion_groups[key]['last_seen'] = max(deletion_groups[key]['last_seen'], log.created_at)
|
||||
|
||||
|
||||
incidents = []
|
||||
|
||||
for key, group in deletion_groups.items():
|
||||
if group['count'] >= 3:
|
||||
severity = "critical" if group['count'] >= 10 else "high" if group['count'] >= 5 else "medium"
|
||||
status = "active" if (now - group['last_seen']).days <= 1 else "resolved"
|
||||
|
||||
incidents.append({
|
||||
"id": f"mass_del_{key.replace('_', '-')}",
|
||||
"title": f"Eliminaciones masivas - {group['user']}",
|
||||
"description": f"{group['user']} eliminó {group['count']} elementos el {group['date']}",
|
||||
"severity": severity,
|
||||
"status": status,
|
||||
"incident_type": "mass_deletion",
|
||||
"affected_user": group['user'],
|
||||
"source_ip": group['logs'][0].ip_address,
|
||||
"evidence": [f"{log.action} - {log.resource_type} - {log.created_at.strftime('%H:%M:%S')}" for log in group['logs'][:5]],
|
||||
"metadata": {
|
||||
"total_deletions": group['count'],
|
||||
"resource_types": list(set(log.resource_type for log in group['logs'])),
|
||||
"time_span_minutes": int((group['last_seen'] - group['first_seen']).total_seconds() / 60)
|
||||
},
|
||||
"created_at": group['first_seen'],
|
||||
"updated_at": group['last_seen']
|
||||
})
|
||||
|
||||
if group['count'] < 3:
|
||||
continue
|
||||
|
||||
if group['count'] >= 10:
|
||||
severity = "critical"
|
||||
elif group['count'] >= 5:
|
||||
severity = "high"
|
||||
else:
|
||||
severity = "medium"
|
||||
|
||||
# Convertir ambas fechas a aware UTC para comparación segura
|
||||
now_aware = _ensure_aware_utc(now)
|
||||
last_seen_aware = _ensure_aware_utc(group['last_seen'])
|
||||
hours_since_last = (now_aware - last_seen_aware).total_seconds() / 3600
|
||||
incident_status = "active" if hours_since_last <= 24 else "resolved"
|
||||
|
||||
incidents.append({
|
||||
"id": f"mass_del_{key.replace('_', '-')}",
|
||||
"title": f"Eliminaciones masivas - {group['user']}",
|
||||
"description": (
|
||||
f"{group['user']} elimino {group['count']} elementos "
|
||||
f"el {group['date']}"
|
||||
),
|
||||
"severity": severity,
|
||||
"status": incident_status,
|
||||
"incident_type": "mass_deletion",
|
||||
"affected_user": group['user'],
|
||||
"source_ip": (
|
||||
str(group['logs'][0].ip_address)
|
||||
if group['logs'][0].ip_address
|
||||
else None
|
||||
),
|
||||
"evidence": [
|
||||
f"{log.action} - {log.resource_type} - {log.created_at.strftime('%H:%M:%S')}"
|
||||
for log in group['logs'][:5]
|
||||
],
|
||||
"metadata": {
|
||||
"total_deletions": group['count'],
|
||||
"resource_types": list(set(log.resource_type for log in group['logs'])),
|
||||
"time_span_minutes": int(
|
||||
(group['last_seen'] - group['first_seen']).total_seconds() / 60
|
||||
)
|
||||
},
|
||||
"created_at": group['first_seen'],
|
||||
"updated_at": group['last_seen']
|
||||
})
|
||||
|
||||
return incidents
|
||||
|
||||
|
||||
def detect_brute_force(logs: List[AuditLog], now: datetime) -> List[dict]:
|
||||
"""Detecta ataques de fuerza bruta de logs de login fallido"""
|
||||
"""
|
||||
Detecta ataques de fuerza bruta agrupando intentos fallidos por IP.
|
||||
|
||||
Lógica:
|
||||
- Agrupa todos los intentos fallidos de login por dirección IP
|
||||
- Si una IP tiene >= 5 intentos, genera un incidente
|
||||
- La severidad escala según la cantidad:
|
||||
- >= 5 intentos → medium
|
||||
- >= 10 intentos → high
|
||||
- >= 20 intentos → critical
|
||||
|
||||
El estado del incidente es:
|
||||
- "active": si el último intento fue hace menos de 24 horas
|
||||
- "investigating": si fue hace más de 24 horas
|
||||
"""
|
||||
ip_groups = {}
|
||||
|
||||
|
||||
for log in logs:
|
||||
if not log.ip_address:
|
||||
continue
|
||||
|
||||
|
||||
ip = str(log.ip_address)
|
||||
|
||||
if ip not in ip_groups:
|
||||
ip_groups[ip] = {'count': 0, 'logs': [], 'first_seen': log.created_at, 'last_seen': log.created_at, 'users': set()}
|
||||
|
||||
ip_groups[ip] = {
|
||||
'count': 0,
|
||||
'logs': [],
|
||||
'first_seen': log.created_at,
|
||||
'last_seen': log.created_at,
|
||||
'users': set()
|
||||
}
|
||||
|
||||
ip_groups[ip]['count'] += 1
|
||||
ip_groups[ip]['logs'].append(log)
|
||||
ip_groups[ip]['first_seen'] = min(ip_groups[ip]['first_seen'], log.created_at)
|
||||
ip_groups[ip]['last_seen'] = max(ip_groups[ip]['last_seen'], log.created_at)
|
||||
|
||||
if log.user and log.user.email:
|
||||
ip_groups[ip]['users'].add(log.user.email)
|
||||
|
||||
|
||||
incidents = []
|
||||
|
||||
for ip, group in ip_groups.items():
|
||||
if group['count'] >= 5:
|
||||
severity = "critical" if group['count'] >= 20 else "high" if group['count'] >= 10 else "medium"
|
||||
status = "active" if (now - group['last_seen']).total_seconds() <= 86400 else "investigating"
|
||||
|
||||
incidents.append({
|
||||
"id": f"brute_force_{ip.replace('.', '-')}",
|
||||
"title": f"Posible ataque de fuerza bruta desde {ip}",
|
||||
"description": f"Se detectaron {group['count']} intentos fallidos de login desde la IP {ip}",
|
||||
"severity": severity,
|
||||
"status": status,
|
||||
"incident_type": "brute_force_attack",
|
||||
"affected_user": ', '.join(list(group['users'])[:3]) if group['users'] else None,
|
||||
"source_ip": ip,
|
||||
"evidence": [f"Login fallido - {log.user.email if log.user else 'Unknown'} - {log.created_at.strftime('%H:%M:%S')}" for log in group['logs'][:5]],
|
||||
"metadata": {
|
||||
"total_attempts": group['count'],
|
||||
"targeted_users": list(group['users']),
|
||||
"time_span_hours": int((group['last_seen'] - group['first_seen']).total_seconds() / 3600)
|
||||
},
|
||||
"created_at": group['first_seen'],
|
||||
"updated_at": group['last_seen']
|
||||
})
|
||||
|
||||
if group['count'] < 5:
|
||||
continue
|
||||
|
||||
if group['count'] >= 20:
|
||||
severity = "critical"
|
||||
elif group['count'] >= 10:
|
||||
severity = "high"
|
||||
else:
|
||||
severity = "medium"
|
||||
|
||||
# Convertir ambas fechas a aware UTC para comparación segura
|
||||
now_aware = _ensure_aware_utc(now)
|
||||
last_seen_aware = _ensure_aware_utc(group['last_seen'])
|
||||
seconds_since_last = (now_aware - last_seen_aware).total_seconds()
|
||||
incident_status = "active" if seconds_since_last <= 86400 else "investigating"
|
||||
|
||||
incidents.append({
|
||||
"id": f"brute_force_{ip.replace('.', '-')}",
|
||||
"title": f"Posible ataque de fuerza bruta desde {ip}",
|
||||
"description": (
|
||||
f"Se detectaron {group['count']} intentos fallidos de "
|
||||
f"login desde la IP {ip}"
|
||||
),
|
||||
"severity": severity,
|
||||
"status": incident_status,
|
||||
"incident_type": "brute_force_attack",
|
||||
"affected_user": (
|
||||
', '.join(list(group['users'])[:3])
|
||||
if group['users']
|
||||
else None
|
||||
),
|
||||
"source_ip": ip,
|
||||
"evidence": [
|
||||
f"Login fallido - "
|
||||
f"{log.user.email if log.user else 'Desconocido'} - "
|
||||
f"{log.created_at.strftime('%H:%M:%S')}"
|
||||
for log in group['logs'][:5]
|
||||
],
|
||||
"metadata": {
|
||||
"total_attempts": group['count'],
|
||||
"targeted_users": list(group['users']),
|
||||
"time_span_hours": int(
|
||||
(group['last_seen'] - group['first_seen']).total_seconds() / 3600
|
||||
)
|
||||
},
|
||||
"created_at": group['first_seen'],
|
||||
"updated_at": group['last_seen']
|
||||
})
|
||||
|
||||
return incidents
|
||||
|
||||
|
||||
def detect_privilege_escalation(logs: List[AuditLog]) -> List[dict]:
|
||||
"""Detecta escaladas de privilegios"""
|
||||
role_hierarchy = {'CLIENT_USER': 1, 'CLIENT_ADMIN': 2, 'AGENT': 3, 'SUPPORT_MANAGER': 4, 'ADMIN': 5}
|
||||
"""
|
||||
Detecta escaladas de privilegios comparando el rol anterior y nuevo.
|
||||
|
||||
Lógica:
|
||||
- Analiza cada log de cambio de rol (user.update con campo 'role')
|
||||
- Si el nuevo rol tiene más privilegios que el anterior, es sospechoso
|
||||
- Cada cambio que represente una escalada genera un incidente
|
||||
|
||||
Jerarquía de roles (de menor a mayor privilegio):
|
||||
CLIENT_USER(1) < CLIENT_ADMIN(2) < AGENT(3) < SUPPORT_MANAGER(4) < ADMIN(5)
|
||||
"""
|
||||
role_hierarchy = {
|
||||
'CLIENT_USER': 1,
|
||||
'CLIENT_ADMIN': 2,
|
||||
'AGENT': 3,
|
||||
'SUPPORT_MANAGER': 4,
|
||||
'ADMIN': 5
|
||||
}
|
||||
|
||||
incidents = []
|
||||
|
||||
|
||||
for log in logs:
|
||||
if not log.user or not log.new_values or 'role' not in log.new_values:
|
||||
continue
|
||||
|
||||
|
||||
old_role = log.old_values.get('role') if log.old_values else 'Unknown'
|
||||
new_role = log.new_values.get('role')
|
||||
|
||||
old_level = role_hierarchy.get(old_role, 0)
|
||||
new_level = role_hierarchy.get(new_role, 0)
|
||||
|
||||
if new_level > old_level:
|
||||
incidents.append({
|
||||
"id": f"priv_esc_{log.id}",
|
||||
"title": f"Escalada de privilegios - {log.user.email}",
|
||||
"description": f"Usuario {log.user.email} cambió de rol {old_role} a {new_role}",
|
||||
"severity": "high" if new_role in ['ADMIN', 'SUPPORT_MANAGER'] else "medium",
|
||||
"status": "investigating",
|
||||
"incident_type": "privilege_escalation",
|
||||
"affected_user": log.user.email,
|
||||
"source_ip": log.ip_address,
|
||||
"evidence": [f"Cambio de rol: {old_role} → {new_role} - {log.created_at.strftime('%Y-%m-%d %H:%M')}"],
|
||||
"metadata": {
|
||||
"old_role": old_role,
|
||||
"new_role": new_role,
|
||||
"correlation_id": str(log.correlation_id) if log.correlation_id else None
|
||||
},
|
||||
"created_at": log.created_at,
|
||||
"updated_at": log.created_at
|
||||
})
|
||||
|
||||
return incidents
|
||||
|
||||
# Solo generar incidente si el nuevo rol tiene MÁS privilegios
|
||||
if new_level <= old_level:
|
||||
continue
|
||||
|
||||
severity = "high" if new_role in ['ADMIN', 'SUPPORT_MANAGER'] else "medium"
|
||||
|
||||
incidents.append({
|
||||
"id": f"priv_esc_{log.id}",
|
||||
"title": f"Escalada de privilegios - {log.user.email}",
|
||||
"description": (
|
||||
f"Usuario {log.user.email} cambio de rol "
|
||||
f"{old_role} a {new_role}"
|
||||
),
|
||||
"severity": severity,
|
||||
"status": "investigating",
|
||||
"incident_type": "privilege_escalation",
|
||||
"affected_user": log.user.email,
|
||||
"source_ip": str(log.ip_address) if log.ip_address else None,
|
||||
"evidence": [
|
||||
f"Cambio de rol: {old_role} → {new_role} - "
|
||||
f"{log.created_at.strftime('%Y-%m-%d %H:%M')}"
|
||||
],
|
||||
"metadata": {
|
||||
"old_role": old_role,
|
||||
"new_role": new_role,
|
||||
"correlation_id": (
|
||||
str(log.correlation_id)
|
||||
if log.correlation_id
|
||||
else None
|
||||
)
|
||||
},
|
||||
"created_at": log.created_at,
|
||||
"updated_at": log.created_at
|
||||
})
|
||||
|
||||
return incidents
|
||||
@@ -1,4 +1,29 @@
|
||||
"""Audit Endpoints - ServiceManagerWeb"""
|
||||
"""
|
||||
Audit Endpoints - ServiceManagerWeb
|
||||
====================================
|
||||
Este archivo maneja todos los endpoints de auditoría y seguridad.
|
||||
Rutas disponibles:
|
||||
GET /audit/ → Lista de logs con filtros y paginación
|
||||
GET /audit/stats → Estadísticas generales de auditoría
|
||||
GET /audit/{log_id} → Detalle de un log específico
|
||||
GET /audit/security/analysis → Análisis de amenazas en tiempo real
|
||||
POST /audit/security/action → Ejecutar acción de seguridad (bloquear IP, etc.)
|
||||
GET /audit/security/incidents → Lista de incidentes detectados
|
||||
|
||||
CORRECCIONES APLICADAS:
|
||||
1. Todos los endpoints usan datetime.now(timezone.utc) para generar
|
||||
fechas aware (con timezone info en UTC), compatibles con la columna
|
||||
'timestamp with time zone' (TIMESTAMPTZ) de PostgreSQL.
|
||||
|
||||
2. audit_helpers.get_count_stat() convierte las fechas a aware UTC
|
||||
con _ensure_aware_utc() antes de usarlas en queries, resolviendo
|
||||
el bug donde los tres contadores (total, hoy, semana) devolvían
|
||||
el mismo valor porque el filtro de fecha se ignoraba.
|
||||
|
||||
3. critical_actions_today usa los mismos umbrales que /security/incidents
|
||||
para que el contador del dashboard coincida con la lista de detalles.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, or_, desc
|
||||
@@ -24,31 +49,83 @@ from app.api.v1.audit_helpers import (
|
||||
detect_mass_deletions, detect_brute_force, detect_privilege_escalation
|
||||
)
|
||||
|
||||
# Instancia del router de FastAPI para este módulo
|
||||
router = APIRouter()
|
||||
|
||||
# Logger estructurado para registrar eventos internos del sistema
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DEPENDENCIA DE AUTORIZACIÓN
|
||||
# =============================================================================
|
||||
|
||||
def require_auditor_role(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""Verifica que el usuario tenga rol de auditor"""
|
||||
"""
|
||||
Dependencia reutilizable que verifica que el usuario tenga permisos
|
||||
para ver logs de auditoría.
|
||||
|
||||
Solo pueden acceder los roles: ADMIN, SUPPORT_MANAGER, AUDITOR.
|
||||
Si no tiene el rol correcto, lanza un error 403 Forbidden.
|
||||
"""
|
||||
if current_user.role not in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AUDITOR]:
|
||||
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")
|
||||
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
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENDPOINT: LISTA DE LOGS DE AUDITORÍA
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/", response_model=AuditLogListResponse)
|
||||
async def get_audit_logs(page: int = Query(default=1, ge=1), per_page: int = Query(default=50, ge=1, le=100),
|
||||
user_id: Optional[uuid.UUID] = Query(None), action: Optional[str] = Query(None),
|
||||
resource_type: Optional[str] = Query(None), resource_id: Optional[uuid.UUID] = Query(None),
|
||||
date_from: Optional[datetime] = Query(None), date_to: Optional[datetime] = Query(None),
|
||||
search: Optional[str] = Query(None), tenant_id: Optional[uuid.UUID] = Query(None),
|
||||
all_tenants: bool = Query(False), 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"""
|
||||
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, "page": page, "all_tenants": all_tenants})
|
||||
|
||||
async def get_audit_logs(
|
||||
# Paginación
|
||||
page: int = Query(default=1, ge=1),
|
||||
per_page: int = Query(default=50, ge=1, le=100),
|
||||
# Filtros opcionales
|
||||
user_id: Optional[uuid.UUID] = Query(None),
|
||||
action: Optional[str] = Query(None),
|
||||
resource_type: Optional[str] = Query(None),
|
||||
resource_id: Optional[uuid.UUID] = Query(None),
|
||||
date_from: Optional[datetime] = Query(None),
|
||||
date_to: Optional[datetime] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
tenant_id: Optional[uuid.UUID] = Query(None),
|
||||
all_tenants: bool = Query(False),
|
||||
# Dependencias de autenticación y base de datos
|
||||
current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Obtener el historial completo de logs de auditoría con filtros opcionales.
|
||||
|
||||
Soporta filtrar por usuario, tipo de acción, recurso afectado, fechas
|
||||
y búsqueda de texto. También soporta ver logs de todos los tenants
|
||||
si el usuario tiene permisos de ADMIN o SUPPORT_MANAGER.
|
||||
"""
|
||||
logger.info(
|
||||
"Obteniendo logs de auditoria",
|
||||
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,
|
||||
"page": page,
|
||||
"all_tenants": all_tenants
|
||||
}
|
||||
)
|
||||
|
||||
# Construir la query base con relación al usuario que hizo la acción
|
||||
query = select(AuditLog).options(selectinload(AuditLog.user))
|
||||
|
||||
# Aplicar filtro de tenant según permisos del usuario
|
||||
query = apply_tenant_filter(query, current_user, current_tenant, all_tenants, tenant_id)
|
||||
|
||||
|
||||
# Aplicar filtros opcionales uno por uno
|
||||
if user_id:
|
||||
query = query.where(AuditLog.user_id == user_id)
|
||||
if action:
|
||||
@@ -62,221 +139,610 @@ async def get_audit_logs(page: int = Query(default=1, ge=1), per_page: int = Que
|
||||
if date_to:
|
||||
query = query.where(AuditLog.created_at < date_to)
|
||||
if search:
|
||||
# Búsqueda parcial en el campo "action" (ej: "ticket" encuentra "ticket.create")
|
||||
query = query.where(AuditLog.action.ilike(f"%{search}%"))
|
||||
|
||||
|
||||
# Ordenar por fecha descendente (más reciente primero)
|
||||
query = query.order_by(desc(AuditLog.created_at))
|
||||
|
||||
|
||||
# Contar total de registros para calcular páginas
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(count_query)).scalar() or 0
|
||||
|
||||
|
||||
# Aplicar paginación
|
||||
offset = (page - 1) * per_page
|
||||
query = query.offset(offset).limit(per_page)
|
||||
|
||||
|
||||
# Ejecutar query y obtener resultados
|
||||
result = await db.execute(query)
|
||||
logs = result.scalars().all()
|
||||
|
||||
|
||||
# Calcular número total de páginas
|
||||
total_pages = (total + per_page - 1) // per_page
|
||||
|
||||
# Convertir modelos a schemas de respuesta
|
||||
logs_response = [AuditLogResponse(**audit_log_to_dict(log)) for log in logs]
|
||||
|
||||
return AuditLogListResponse(logs=logs_response, total=total, page=page, per_page=per_page, total_pages=total_pages)
|
||||
|
||||
return AuditLogListResponse(
|
||||
logs=logs_response,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
total_pages=total_pages
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENDPOINT: ESTADÍSTICAS DE AUDITORÍA
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/stats", response_model=AuditLogStats)
|
||||
async def get_audit_stats(all_tenants: bool = Query(False), 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"""
|
||||
async def get_audit_stats(
|
||||
all_tenants: bool = Query(False),
|
||||
current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Obtener estadísticas resumidas de auditoría para el dashboard.
|
||||
|
||||
Incluye:
|
||||
- Total de acciones registradas
|
||||
- Acciones de las últimas 24 horas
|
||||
- Acciones de los últimos 7 días
|
||||
- Incidentes críticos detectados hoy (alineado con /security/incidents)
|
||||
- Acciones más frecuentes
|
||||
- Usuarios más activos
|
||||
- Distribución por tipo de recurso
|
||||
"""
|
||||
can_see_all_tenants = current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]
|
||||
logger.info("Fetching audit stats", user_id=str(current_user.id), tenant_id=str(current_tenant.id),
|
||||
all_tenants=all_tenants, can_see_all=can_see_all_tenants)
|
||||
|
||||
|
||||
logger.info(
|
||||
"Obteniendo estadisticas de auditoria",
|
||||
user_id=str(current_user.id),
|
||||
tenant_id=str(current_tenant.id),
|
||||
all_tenants=all_tenants,
|
||||
can_see_all=can_see_all_tenants
|
||||
)
|
||||
|
||||
# datetime.now(timezone.utc) genera un datetime aware en UTC,
|
||||
# compatible con la columna TIMESTAMPTZ de PostgreSQL
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Determinar si se debe filtrar por tenant o ver todos
|
||||
apply_tenant = not (all_tenants and can_see_all_tenants)
|
||||
tenant_filter = current_tenant.id if apply_tenant else None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CONTADORES GENERALES
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# Total histórico de acciones (sin filtro de fecha)
|
||||
total_actions = await get_count_stat(db, tenant_filter)
|
||||
|
||||
# Acciones en las últimas 24 horas
|
||||
# get_count_stat convierte internamente a aware UTC con _ensure_aware_utc()
|
||||
actions_today = await get_count_stat(db, tenant_filter, now - timedelta(days=1))
|
||||
|
||||
# Acciones en los últimos 7 días
|
||||
actions_this_week = await get_count_stat(db, tenant_filter, now - timedelta(days=7))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CONTADOR DE INCIDENTES CRÍTICOS
|
||||
# ------------------------------------------------------------------
|
||||
# Usa los mismos umbrales que los detectores de /security/incidents
|
||||
# para que el número del dashboard sea consistente con la lista.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
today_start = now - timedelta(days=1)
|
||||
critical_conditions = [
|
||||
AuditLog.created_at >= today_start,
|
||||
or_(AuditLog.action.like('%.delete'), AuditLog.action.like('user.update'),
|
||||
AuditLog.action.like('%.assign'), AuditLog.action.in_(['user.login_failed', 'user.logout']))
|
||||
]
|
||||
if apply_tenant:
|
||||
critical_conditions.append(AuditLog.tenant_id == tenant_filter)
|
||||
|
||||
critical_actions_today = (await db.execute(select(func.count()).select_from(AuditLog).where(and_(*critical_conditions)))).scalar() or 0
|
||||
|
||||
|
||||
# Contar intentos fallidos de login en las últimas 24 horas
|
||||
failed_login_count = (await db.execute(
|
||||
select(func.count()).select_from(AuditLog).where(
|
||||
AuditLog.action == 'user.login_failed',
|
||||
AuditLog.created_at >= today_start,
|
||||
*([AuditLog.tenant_id == tenant_filter] if apply_tenant else [])
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
# Contar eliminaciones en las últimas 24 horas
|
||||
deletion_count = (await db.execute(
|
||||
select(func.count()).select_from(AuditLog).where(
|
||||
AuditLog.action.like('%.delete'),
|
||||
AuditLog.created_at >= today_start,
|
||||
*([AuditLog.tenant_id == tenant_filter] if apply_tenant else [])
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
# Contar cambios de privilegios en las últimas 24 horas
|
||||
privilege_count = (await db.execute(
|
||||
select(func.count()).select_from(AuditLog).where(
|
||||
AuditLog.action == 'user.update',
|
||||
AuditLog.created_at >= today_start,
|
||||
*([AuditLog.tenant_id == tenant_filter] if apply_tenant else [])
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
# Calcular número real de incidentes usando los mismos umbrales
|
||||
# que los detectores en /security/incidents:
|
||||
# - Fuerza bruta: incidente si hay >= 20 intentos fallidos
|
||||
# - Eliminación masiva: incidente si hay >= 50 eliminaciones
|
||||
# - Escalada privilegios: incidente si hay >= 3 cambios de rol
|
||||
critical_actions_today = sum([
|
||||
1 if failed_login_count >= 20 else 0,
|
||||
1 if deletion_count >= 50 else 0,
|
||||
1 if privilege_count >= 3 else 0,
|
||||
])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DATOS PARA GRÁFICAS Y TABLAS DEL DASHBOARD
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# Top acciones más frecuentes (ej: "ticket.create", "user.login")
|
||||
top_actions = await get_top_items(db, AuditLog.action, tenant_filter)
|
||||
|
||||
# Distribución por tipo de recurso (ej: "ticket", "user", "tenant")
|
||||
by_resource_type = await get_top_items(db, AuditLog.resource_type, tenant_filter, limit=10)
|
||||
|
||||
# Usuarios más activos (hace join con tabla de usuarios)
|
||||
top_users = await get_top_items(db, None, tenant_filter, join_user=True)
|
||||
|
||||
return AuditLogStats(total_actions=total_actions, actions_today=actions_today,
|
||||
actions_this_week=actions_this_week, critical_actions_today=critical_actions_today,
|
||||
top_actions=top_actions, top_users=top_users, by_resource_type=by_resource_type)
|
||||
|
||||
return AuditLogStats(
|
||||
total_actions=total_actions,
|
||||
actions_today=actions_today,
|
||||
actions_this_week=actions_this_week,
|
||||
critical_actions_today=critical_actions_today,
|
||||
top_actions=top_actions,
|
||||
top_users=top_users,
|
||||
by_resource_type=by_resource_type
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENDPOINT: DETALLE DE UN LOG ESPECÍFICO
|
||||
# =============================================================================
|
||||
|
||||
@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 log de auditoría"""
|
||||
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 el detalle completo de un log de auditoría por su ID.
|
||||
|
||||
Incluye información del usuario que realizó la acción, valores
|
||||
anteriores y nuevos (para cambios), IP de origen, user agent, etc.
|
||||
|
||||
Retorna 404 si el log no existe o no pertenece al tenant del usuario.
|
||||
"""
|
||||
# Buscar el log por ID incluyendo los datos del usuario relacionado
|
||||
query = select(AuditLog).where(AuditLog.id == log_id).options(selectinload(AuditLog.user))
|
||||
|
||||
# Aplicar filtro de tenant para garantizar aislamiento multi-tenant
|
||||
query = apply_tenant_filter(query, current_user, current_tenant)
|
||||
|
||||
|
||||
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} not found")
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Registro de auditoria {log_id} no encontrado"
|
||||
)
|
||||
|
||||
return AuditLogResponse(**audit_log_to_dict(log))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENDPOINT: ANÁLISIS DE SEGURIDAD EN TIEMPO REAL
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/security/analysis", response_model=SecurityAnalysisResponse)
|
||||
async def get_security_analysis(all_tenants: bool = Query(False), current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)):
|
||||
"""Análisis de seguridad basado en logs de auditoría"""
|
||||
logger.info("Security analysis requested", user_id=str(current_user.id), tenant_id=str(current_tenant.id))
|
||||
|
||||
async def get_security_analysis(
|
||||
hours: int = Query(default=24, ge=1, le=720),
|
||||
all_tenants: bool = Query(False),
|
||||
current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Analizar los logs de auditoría para detectar patrones sospechosos.
|
||||
|
||||
Detecta tres tipos de amenazas:
|
||||
1. Fuerza bruta: Muchos intentos fallidos de login desde las mismas IPs
|
||||
2. Eliminación masiva: Gran cantidad de registros eliminados en poco tiempo
|
||||
3. Escalada privilegios: Cambios de roles sospechosos en usuarios
|
||||
|
||||
Calcula un nivel de riesgo general (low/medium/high/critical) y
|
||||
devuelve recomendaciones de acción.
|
||||
"""
|
||||
logger.info(
|
||||
"Analisis de seguridad solicitado",
|
||||
user_id=str(current_user.id),
|
||||
tenant_id=str(current_tenant.id),
|
||||
hours=hours
|
||||
)
|
||||
|
||||
# aware UTC para compatibilidad con TIMESTAMPTZ de PostgreSQL
|
||||
now = datetime.now(timezone.utc)
|
||||
analysis_start = now - timedelta(hours=24)
|
||||
|
||||
query = select(AuditLog).where(AuditLog.created_at >= analysis_start).options(selectinload(AuditLog.user))
|
||||
analysis_start = now - timedelta(hours=hours)
|
||||
|
||||
query = (
|
||||
select(AuditLog)
|
||||
.where(AuditLog.created_at >= analysis_start)
|
||||
.options(selectinload(AuditLog.user))
|
||||
)
|
||||
query = apply_tenant_filter(query, current_user, current_tenant, all_tenants)
|
||||
|
||||
|
||||
result = await db.execute(query)
|
||||
logs = result.scalars().all()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CONTADORES DE EVENTOS SOSPECHOSOS
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
failed_logins = sum(1 for log in logs if log.action == 'user.login_failed')
|
||||
mass_deletions = sum(1 for log in logs if '.delete' in log.action)
|
||||
privilege_changes = sum(1 for log in logs if log.action == 'user.update' and log.new_values and 'role' in log.new_values)
|
||||
|
||||
privilege_changes = sum(
|
||||
1 for log in logs
|
||||
if log.action == 'user.update'
|
||||
and log.new_values
|
||||
and 'role' in log.new_values
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GENERACIÓN DE PATRONES DE AMENAZA
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
threat_patterns = []
|
||||
|
||||
|
||||
# Amenaza 1: Fuerza bruta (umbral mínimo: 5 intentos fallidos)
|
||||
if failed_logins >= 5:
|
||||
affected_ips_list = [
|
||||
str(log.ip_address)
|
||||
for log in logs
|
||||
if log.action == 'user.login_failed' and log.ip_address
|
||||
]
|
||||
threat_patterns.append(SecurityThreatPattern(
|
||||
pattern_id="brute_force_attempt",
|
||||
description=f"Se detectaron {failed_logins} intentos fallidos de login en las últimas 24h",
|
||||
id="brute_force_attempt",
|
||||
type="brute_force",
|
||||
description=(
|
||||
f"Se detectaron {failed_logins} intentos fallidos de "
|
||||
f"login en las ultimas {hours}h"
|
||||
),
|
||||
severity="high" if failed_logins >= 20 else "medium",
|
||||
occurrences=failed_logins,
|
||||
first_seen=min((log.created_at for log in logs if log.action == 'user.login_failed'), default=now),
|
||||
last_seen=max((log.created_at for log in logs if log.action == 'user.login_failed'), default=now),
|
||||
affected_resources=[str(log.ip_address) for log in logs if log.action == 'user.login_failed' and log.ip_address][:5],
|
||||
recommended_action="Considerar bloquear IPs con múltiples fallos"
|
||||
first_seen=min(
|
||||
(log.created_at for log in logs if log.action == 'user.login_failed'),
|
||||
default=now
|
||||
),
|
||||
last_seen=max(
|
||||
(log.created_at for log in logs if log.action == 'user.login_failed'),
|
||||
default=now
|
||||
),
|
||||
affected_ips=list(set(affected_ips_list))[:5],
|
||||
affected_users=[],
|
||||
recommended_action="Considerar bloquear IPs con multiples fallos"
|
||||
))
|
||||
|
||||
|
||||
# Amenaza 2: Eliminación masiva (umbral mínimo: 10 eliminaciones)
|
||||
if mass_deletions >= 10:
|
||||
deleting_users = [
|
||||
log.user.email
|
||||
for log in logs
|
||||
if '.delete' in log.action and log.user
|
||||
]
|
||||
threat_patterns.append(SecurityThreatPattern(
|
||||
pattern_id="mass_deletion",
|
||||
description=f"Se detectaron {mass_deletions} eliminaciones en las últimas 24h",
|
||||
id="mass_deletion",
|
||||
type="mass_deletion",
|
||||
description=(
|
||||
f"Se detectaron {mass_deletions} eliminaciones en "
|
||||
f"las ultimas {hours}h"
|
||||
),
|
||||
severity="critical" if mass_deletions >= 50 else "high",
|
||||
occurrences=mass_deletions,
|
||||
first_seen=min((log.created_at for log in logs if '.delete' in log.action), default=now),
|
||||
last_seen=max((log.created_at for log in logs if '.delete' in log.action), default=now),
|
||||
affected_resources=[log.resource_type for log in logs if '.delete' in log.action][:5],
|
||||
recommended_action="Revisar qué usuarios están eliminando recursos"
|
||||
first_seen=min(
|
||||
(log.created_at for log in logs if '.delete' in log.action),
|
||||
default=now
|
||||
),
|
||||
last_seen=max(
|
||||
(log.created_at for log in logs if '.delete' in log.action),
|
||||
default=now
|
||||
),
|
||||
affected_ips=[],
|
||||
affected_users=list(set(deleting_users))[:5],
|
||||
recommended_action="Revisar que usuarios estan eliminando recursos masivamente"
|
||||
))
|
||||
|
||||
|
||||
# Amenaza 3: Escalada de privilegios (umbral mínimo: 3 cambios de rol)
|
||||
if privilege_changes >= 3:
|
||||
affected_users_list = [
|
||||
log.user.email
|
||||
for log in logs
|
||||
if log.action == 'user.update'
|
||||
and log.user
|
||||
and log.new_values
|
||||
and 'role' in log.new_values
|
||||
]
|
||||
threat_patterns.append(SecurityThreatPattern(
|
||||
pattern_id="suspicious_privilege_changes",
|
||||
description=f"Se detectaron {privilege_changes} cambios de privilegios en las últimas 24h",
|
||||
id="suspicious_privilege_changes",
|
||||
type="privilege_escalation",
|
||||
description=(
|
||||
f"Se detectaron {privilege_changes} cambios de "
|
||||
f"privilegios en las ultimas {hours}h"
|
||||
),
|
||||
severity="high",
|
||||
occurrences=privilege_changes,
|
||||
first_seen=min((log.created_at for log in logs if log.action == 'user.update' and log.new_values and 'role' in log.new_values), default=now),
|
||||
last_seen=max((log.created_at for log in logs if log.action == 'user.update' and log.new_values and 'role' in log.new_values), default=now),
|
||||
affected_resources=[log.user.email for log in logs if log.action == 'user.update' and log.user and log.new_values and 'role' in log.new_values][:5],
|
||||
first_seen=min(
|
||||
(
|
||||
log.created_at for log in logs
|
||||
if log.action == 'user.update'
|
||||
and log.new_values
|
||||
and 'role' in log.new_values
|
||||
),
|
||||
default=now
|
||||
),
|
||||
last_seen=max(
|
||||
(
|
||||
log.created_at for log in logs
|
||||
if log.action == 'user.update'
|
||||
and log.new_values
|
||||
and 'role' in log.new_values
|
||||
),
|
||||
default=now
|
||||
),
|
||||
affected_ips=[],
|
||||
affected_users=list(set(affected_users_list))[:5],
|
||||
recommended_action="Auditar cambios de roles recientes"
|
||||
))
|
||||
|
||||
risk_score = min(100, (failed_logins * 2) + (mass_deletions * 5) + (privilege_changes * 10))
|
||||
risk_level = "critical" if risk_score >= 80 else "high" if risk_score >= 50 else "medium" if risk_score >= 20 else "low"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CÁLCULO DE NIVEL DE RIESGO GENERAL
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
risk_score = min(
|
||||
100,
|
||||
(failed_logins * 2) + (mass_deletions * 5) + (privilege_changes * 10)
|
||||
)
|
||||
|
||||
if risk_score >= 80:
|
||||
risk_level = "critical"
|
||||
elif risk_score >= 50:
|
||||
risk_level = "high"
|
||||
elif risk_score >= 20:
|
||||
risk_level = "medium"
|
||||
else:
|
||||
risk_level = "low"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# RECOMENDACIONES AUTOMÁTICAS
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
recommended_actions = []
|
||||
|
||||
if failed_logins >= 20:
|
||||
recommended_actions.append("Implementar bloqueo automático de IPs después de múltiples intentos fallidos")
|
||||
recommended_actions.append(
|
||||
"Implementar bloqueo automatico de IPs despues de multiples intentos fallidos"
|
||||
)
|
||||
if mass_deletions >= 50:
|
||||
recommended_actions.append("Activar confirmación adicional para eliminaciones masivas")
|
||||
recommended_actions.append(
|
||||
"Activar confirmacion adicional para eliminaciones masivas"
|
||||
)
|
||||
if privilege_changes >= 3:
|
||||
recommended_actions.append(
|
||||
"Revisar y aprobar manualmente los cambios de roles recientes"
|
||||
)
|
||||
if not recommended_actions:
|
||||
recommended_actions.append("Continuar monitoreando actividad del sistema")
|
||||
|
||||
# Calcular IPs sospechosas (más de 5 intentos fallidos)
|
||||
suspicious_ips = len(set([log.ip_address for log in logs if log.ip_address and log.action == 'auth.login.failed']))
|
||||
|
||||
# Contar acciones críticas (delete, privilege changes, etc)
|
||||
|
||||
suspicious_ips = len(set(
|
||||
log.ip_address
|
||||
for log in logs
|
||||
if log.ip_address and log.action == 'user.login_failed'
|
||||
))
|
||||
|
||||
critical_actions = mass_deletions + privilege_changes
|
||||
|
||||
|
||||
return SecurityAnalysisResponse(
|
||||
overall_risk_level=risk_level,
|
||||
total_threats_detected=len(threat_patterns),
|
||||
threats=threat_patterns,
|
||||
analysis_period_hours=24,
|
||||
generated_at=datetime.utcnow(),
|
||||
analysis_period_hours=hours,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
failed_login_attempts=failed_logins,
|
||||
suspicious_ips_count=suspicious_ips,
|
||||
critical_actions_count=critical_actions,
|
||||
recommended_actions=recommended_actions
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENDPOINT: EJECUTAR ACCIÓN DE SEGURIDAD
|
||||
# =============================================================================
|
||||
|
||||
@router.post("/security/action", response_model=SecurityActionResponse)
|
||||
async def execute_security_action(action: SecurityActionRequest, current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)):
|
||||
"""Ejecutar acción de seguridad"""
|
||||
async def execute_security_action(
|
||||
action: SecurityActionRequest,
|
||||
current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Ejecutar una acción de seguridad manual sobre una amenaza detectada.
|
||||
|
||||
Acciones disponibles:
|
||||
- block_ip: Bloquear una dirección IP por X minutos
|
||||
- notify_admin: Enviar notificación a los administradores
|
||||
- force_password_reset: Forzar cambio de contraseña a un usuario
|
||||
- disable_user: Desactivar temporalmente una cuenta de usuario
|
||||
|
||||
Solo ADMIN y SUPPORT_MANAGER pueden ejecutar estas acciones.
|
||||
Todas las acciones quedan registradas en el log de auditoría.
|
||||
"""
|
||||
if current_user.role not in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Solo administradores pueden ejecutar acciones de seguridad")
|
||||
|
||||
logger.info("Security action requested", user_id=str(current_user.id),
|
||||
action_type=action.action_type, target=action.target)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Solo administradores pueden ejecutar acciones de seguridad"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Accion de seguridad solicitada",
|
||||
user_id=str(current_user.id),
|
||||
action_type=action.action_type,
|
||||
target=action.target
|
||||
)
|
||||
|
||||
# Registrar en auditoría para trazabilidad completa
|
||||
try:
|
||||
await AuditService.log(db=db, tenant_id=current_tenant.id, user_id=current_user.id,
|
||||
action=f"security.{action.action_type}", resource_type="security", resource_id=None,
|
||||
metadata={"target": action.target, "reason": action.reason, "duration_minutes": action.duration_minutes})
|
||||
await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=current_tenant.id,
|
||||
user_id=current_user.id,
|
||||
action=f"security.{action.action_type}",
|
||||
resource_type="security",
|
||||
resource_id=None,
|
||||
metadata={
|
||||
"target": action.target,
|
||||
"reason": action.reason,
|
||||
"duration_minutes": action.duration_minutes
|
||||
}
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.error("Failed to log security action", error=str(e))
|
||||
|
||||
logger.error("Fallo al registrar accion de seguridad en auditoria", error=str(e))
|
||||
|
||||
action_messages = {
|
||||
"block_ip": f"IP {action.target} bloqueada por {action.duration_minutes or 60} minutos. Razón: {action.reason}",
|
||||
"notify_admin": f"Notificación enviada a administradores sobre: {action.reason}",
|
||||
"force_password_reset": f"Se forzará cambio de contraseña para {action.target}. Razón: {action.reason}",
|
||||
"disable_user": f"Usuario {action.target} desactivado temporalmente. Razón: {action.reason}"
|
||||
"block_ip": (
|
||||
f"IP {action.target} bloqueada por "
|
||||
f"{action.duration_minutes or 60} minutos. Razon: {action.reason}"
|
||||
),
|
||||
"notify_admin": (
|
||||
f"Notificacion enviada a administradores sobre: {action.reason}"
|
||||
),
|
||||
"force_password_reset": (
|
||||
f"Se forzara cambio de contrasena para {action.target}. "
|
||||
f"Razon: {action.reason}"
|
||||
),
|
||||
"disable_user": (
|
||||
f"Usuario {action.target} desactivado temporalmente. "
|
||||
f"Razon: {action.reason}"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
success = action.action_type in action_messages
|
||||
message = action_messages.get(action.action_type, f"Tipo de acción no reconocida: {action.action_type}")
|
||||
|
||||
message = action_messages.get(
|
||||
action.action_type,
|
||||
f"Tipo de accion no reconocida: {action.action_type}"
|
||||
)
|
||||
|
||||
return SecurityActionResponse(success=success, message=message, action_id=None)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENDPOINT: LISTA DE INCIDENTES DE SEGURIDAD
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/security/incidents", response_model=SecurityIncidentListResponse)
|
||||
async def get_security_incidents(page: int = Query(default=1, ge=1), per_page: int = Query(default=20, ge=1, le=100),
|
||||
severity: Optional[str] = Query(None), status: Optional[str] = Query(None),
|
||||
incident_type: Optional[str] = Query(None), search: Optional[str] = Query(None),
|
||||
all_tenants: bool = Query(False), current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)):
|
||||
"""Obtener incidentes de seguridad"""
|
||||
logger.info("Fetching security incidents", user_id=str(current_user.id), tenant_id=str(current_tenant.id),
|
||||
filters={"severity": severity, "status": status, "type": incident_type, "page": page})
|
||||
|
||||
async def get_security_incidents(
|
||||
# Paginación
|
||||
page: int = Query(default=1, ge=1),
|
||||
per_page: int = Query(default=20, ge=1, le=100),
|
||||
# Filtros opcionales
|
||||
severity: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
incident_type: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
all_tenants: bool = Query(False),
|
||||
# Dependencias
|
||||
current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Obtener la lista de incidentes de seguridad detectados.
|
||||
|
||||
Los incidentes se generan dinámicamente analizando los logs de
|
||||
auditoría de los últimos 7 días usando tres detectores:
|
||||
|
||||
1. detect_brute_force: Analiza intentos fallidos de login
|
||||
2. detect_mass_deletions: Analiza eliminaciones masivas
|
||||
3. detect_privilege_escalation: Analiza cambios de rol sospechosos
|
||||
|
||||
Los umbrales son los mismos que usa /stats para critical_actions_today,
|
||||
garantizando consistencia entre el contador y la lista.
|
||||
"""
|
||||
logger.info(
|
||||
"Obteniendo incidentes de seguridad",
|
||||
user_id=str(current_user.id),
|
||||
tenant_id=str(current_tenant.id),
|
||||
filters={
|
||||
"severity": severity,
|
||||
"status": status,
|
||||
"type": incident_type,
|
||||
"page": page
|
||||
}
|
||||
)
|
||||
|
||||
# aware UTC para compatibilidad con TIMESTAMPTZ de PostgreSQL
|
||||
now = datetime.now(timezone.utc)
|
||||
analysis_start = now - timedelta(days=7)
|
||||
|
||||
base_query = select(AuditLog).options(selectinload(AuditLog.user)).where(AuditLog.created_at >= analysis_start)
|
||||
|
||||
base_query = (
|
||||
select(AuditLog)
|
||||
.options(selectinload(AuditLog.user))
|
||||
.where(AuditLog.created_at >= analysis_start)
|
||||
)
|
||||
base_query = apply_tenant_filter(base_query, current_user, current_tenant, all_tenants)
|
||||
|
||||
deletion_result = await db.execute(base_query.where(AuditLog.action.like('%.delete')).order_by(desc(AuditLog.created_at)))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DETECTOR 1: ELIMINACIONES MASIVAS
|
||||
# ------------------------------------------------------------------
|
||||
deletion_result = await db.execute(
|
||||
base_query
|
||||
.where(AuditLog.action.like('%.delete'))
|
||||
.order_by(desc(AuditLog.created_at))
|
||||
)
|
||||
deletion_logs = deletion_result.scalars().all()
|
||||
deletion_incidents = detect_mass_deletions(deletion_logs, now)
|
||||
|
||||
failed_login_result = await db.execute(base_query.where(AuditLog.action == 'user.login_failed').order_by(desc(AuditLog.created_at)))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DETECTOR 2: FUERZA BRUTA
|
||||
# ------------------------------------------------------------------
|
||||
failed_login_result = await db.execute(
|
||||
base_query
|
||||
.where(AuditLog.action == 'user.login_failed')
|
||||
.order_by(desc(AuditLog.created_at))
|
||||
)
|
||||
failed_login_logs = failed_login_result.scalars().all()
|
||||
brute_force_incidents = detect_brute_force(failed_login_logs, now)
|
||||
|
||||
privilege_result = await db.execute(base_query.where(and_(AuditLog.action == 'user.update', AuditLog.new_values.op('?')('role'))).order_by(desc(AuditLog.created_at)))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DETECTOR 3: ESCALADA DE PRIVILEGIOS
|
||||
# El operador '?' verifica si el campo JSON contiene la clave 'role'
|
||||
# ------------------------------------------------------------------
|
||||
privilege_result = await db.execute(
|
||||
base_query
|
||||
.where(and_(
|
||||
AuditLog.action == 'user.update',
|
||||
AuditLog.new_values.op('?')('role')
|
||||
))
|
||||
.order_by(desc(AuditLog.created_at))
|
||||
)
|
||||
privilege_logs = privilege_result.scalars().all()
|
||||
privilege_incidents = detect_privilege_escalation(privilege_logs)
|
||||
|
||||
incidents = [SecurityIncidentResponse(**inc) for inc in (deletion_incidents + brute_force_incidents + privilege_incidents)]
|
||||
|
||||
|
||||
# Combinar todos los incidentes
|
||||
incidents = [
|
||||
SecurityIncidentResponse(**inc)
|
||||
for inc in (deletion_incidents + brute_force_incidents + privilege_incidents)
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# FILTROS EN MEMORIA (los incidentes son generados dinámicamente)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
if severity:
|
||||
incidents = [i for i in incidents if i.severity == severity]
|
||||
if status:
|
||||
@@ -285,14 +751,29 @@ async def get_security_incidents(page: int = Query(default=1, ge=1), per_page: i
|
||||
incidents = [i for i in incidents if i.incident_type == incident_type]
|
||||
if search:
|
||||
search_lower = search.lower()
|
||||
incidents = [i for i in incidents if search_lower in i.title.lower() or (i.description and search_lower in i.description.lower())]
|
||||
|
||||
incidents = [
|
||||
i for i in incidents
|
||||
if search_lower in i.title.lower()
|
||||
or (i.description and search_lower in i.description.lower())
|
||||
]
|
||||
|
||||
# Ordenar por fecha descendente
|
||||
incidents.sort(key=lambda x: x.created_at, reverse=True)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PAGINACIÓN MANUAL
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
total = len(incidents)
|
||||
total_pages = (total + per_page - 1) // per_page
|
||||
start_idx = (page - 1) * per_page
|
||||
end_idx = start_idx + per_page
|
||||
paginated_incidents = incidents[start_idx:end_idx]
|
||||
|
||||
return SecurityIncidentListResponse(incidents=paginated_incidents, total=total, page=page, per_page=per_page, total_pages=total_pages)
|
||||
|
||||
return SecurityIncidentListResponse(
|
||||
incidents=paginated_incidents,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
total_pages=total_pages
|
||||
)
|
||||
@@ -1,10 +1,10 @@
|
||||
"""
|
||||
Authentication Endpoints - ServiceManagerWeb
|
||||
|
||||
Endpoints para autenticación y autorización
|
||||
Endpoints para autenticación y autorización
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status, Depends
|
||||
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
@@ -18,7 +18,18 @@ from app.core.config import get_settings
|
||||
from app.models.user import User
|
||||
from app.models.tenant import Tenant
|
||||
from app.services.audit_service import AuditService
|
||||
from app.services.token_service import TokenService
|
||||
from app.api.deps import oauth2_scheme, get_current_user
|
||||
from app.core.cache import cache, cache_key
|
||||
from app.core.limiter import limiter
|
||||
|
||||
# Nombres de cookie por tipo de usuario
|
||||
CLIENT_ROLES = {"CLIENT_ADMIN", "CLIENT_USER"}
|
||||
|
||||
|
||||
def _cookie_name_for_role(role: str) -> str:
|
||||
"""Devuelve el nombre de cookie según el rol del usuario."""
|
||||
return "client_access_token" if role in CLIENT_ROLES else "internal_access_token"
|
||||
from app.api.schemas.auth import (
|
||||
LoginRequest, LoginResponse, RefreshTokenRequest, TokenResponse,
|
||||
TwoFactorStatusResponse, TwoFactorSetupResponse,
|
||||
@@ -36,8 +47,11 @@ settings = get_settings()
|
||||
# ===================================
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
@limiter.limit("10/minute")
|
||||
async def login(
|
||||
login_data: LoginRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
@@ -58,20 +72,93 @@ async def login(
|
||||
email=login_data.email,
|
||||
tenant_slug=login_data.tenant_slug
|
||||
)
|
||||
|
||||
# Rate limiting (best-effort): by IP before any tenant/user lookup.
|
||||
if settings.RATE_LIMIT_ENABLED and not settings.TESTING:
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
ip_key = cache_key("rl", "login", "ip", client_ip)
|
||||
ip_count = await cache.incr(ip_key, 1)
|
||||
if ip_count == 1:
|
||||
await cache.expire(ip_key, settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)
|
||||
|
||||
if ip_count is not None and ip_count > settings.LOGIN_RATE_LIMIT_IP_MAX_ATTEMPTS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Too many login attempts. Try again later.",
|
||||
headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)},
|
||||
)
|
||||
|
||||
# 1. Buscar usuario en base de datos
|
||||
query = select(User).where(User.email == login_data.email)
|
||||
# 1. Validar tenant - por slug si viene, sino buscar por email
|
||||
if login_data.tenant_slug:
|
||||
tenant_result = await db.execute(
|
||||
select(Tenant).where(Tenant.slug == login_data.tenant_slug)
|
||||
)
|
||||
tenant = tenant_result.scalar_one_or_none()
|
||||
if tenant is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tenant not found",
|
||||
)
|
||||
else:
|
||||
tenant = None
|
||||
|
||||
# Rate limiting (best-effort): by (tenant,email) to slow brute force.
|
||||
ident_key = None
|
||||
if settings.RATE_LIMIT_ENABLED and not settings.TESTING and tenant:
|
||||
email_norm = login_data.email.strip().lower()
|
||||
ident_key = cache_key("rl", "login", "id", str(tenant.id), email_norm)
|
||||
ident_count = await cache.incr(ident_key, 1)
|
||||
if ident_count == 1:
|
||||
await cache.expire(ident_key, settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)
|
||||
|
||||
if ident_count is not None and ident_count > settings.LOGIN_RATE_LIMIT_ID_MAX_ATTEMPTS:
|
||||
try:
|
||||
await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=tenant.id,
|
||||
user_id=None,
|
||||
action="user.login_rate_limited",
|
||||
resource_type="user",
|
||||
resource_id=None,
|
||||
metadata={
|
||||
"email": email_norm,
|
||||
"tenant_slug": login_data.tenant_slug,
|
||||
"ip": request.client.host if request.client else None,
|
||||
"scope": "tenant_email",
|
||||
"window_seconds": settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS,
|
||||
"max_attempts": settings.LOGIN_RATE_LIMIT_ID_MAX_ATTEMPTS,
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to log rate limit audit entry", error=str(e))
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Too many login attempts. Try again later.",
|
||||
headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)},
|
||||
)
|
||||
|
||||
# 2. Buscar usuario - filtrar por tenant si se detecto, sino buscar por email
|
||||
if tenant:
|
||||
query = select(User).where(
|
||||
User.email == login_data.email,
|
||||
User.tenant_id == tenant.id,
|
||||
)
|
||||
else:
|
||||
query = select(User).where(User.email == login_data.email)
|
||||
result = await db.execute(query)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
# 2. Verificar usuario y contraseña
|
||||
# 3. Verificar usuario y contraseña
|
||||
if not user or not security.verify_password(login_data.password, user.password_hash):
|
||||
logger.warning(
|
||||
"Login failed - invalid credentials",
|
||||
email=login_data.email
|
||||
)
|
||||
|
||||
# Registrar intento fallido en auditoría (si el usuario existe)
|
||||
# Registrar intento fallido en auditorÃa (si el usuario existe)
|
||||
if user:
|
||||
try:
|
||||
await AuditService.log(
|
||||
@@ -89,33 +176,33 @@ async def login(
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Credenciales inválidas"
|
||||
detail="Invalid credentials",
|
||||
)
|
||||
|
||||
# 3. Verificar si está activo
|
||||
# 4. Verificar si está activo
|
||||
if not user.is_active:
|
||||
logger.warning(
|
||||
"Login failed - user inactive",
|
||||
email=login_data.email
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Usuario inactivo"
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User inactive",
|
||||
)
|
||||
|
||||
# 4. Verificar 2FA si está habilitado
|
||||
# 5. Verificar 2FA si está habilitado
|
||||
if user.totp_enabled:
|
||||
if not login_data.totp_code:
|
||||
# Indicar al frontend que debe pedir el código TOTP
|
||||
# Indicar al frontend que debe pedir el código TOTP
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Se requiere autenticación de dos factores (2FA). Ingresa tu código."
|
||||
detail="Se requiere autenticación de dos factores (2FA). Ingresa tu código."
|
||||
)
|
||||
if not security.verify_totp(user.totp_secret, login_data.totp_code):
|
||||
logger.warning("Login failed - invalid 2FA code", email=login_data.email)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Código 2FA inválido o expirado"
|
||||
detail="Código 2FA inválido o expirado"
|
||||
)
|
||||
|
||||
# Create tokens
|
||||
@@ -128,8 +215,26 @@ async def login(
|
||||
|
||||
access_token = security.create_access_token(token_data)
|
||||
refresh_token = security.create_refresh_token(token_data)
|
||||
|
||||
# Persist refresh token so it can be revoked/validated later
|
||||
try:
|
||||
await TokenService.create_refresh_token(
|
||||
db=db,
|
||||
user=user,
|
||||
refresh_token=refresh_token,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
ip_address=request.client.host if request.client else None,
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
# If persistence fails, do not leak tokens
|
||||
logger.error("Failed to persist refresh token", error=str(e), user_id=str(user.id))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Service temporarily unavailable",
|
||||
)
|
||||
|
||||
# Registrar login exitoso en auditoría
|
||||
# Registrar login exitoso en auditorÃa
|
||||
try:
|
||||
await AuditService.log(
|
||||
db=db,
|
||||
@@ -150,7 +255,24 @@ async def login(
|
||||
tenant_slug=login_data.tenant_slug,
|
||||
user_id=str(user.id)
|
||||
)
|
||||
|
||||
|
||||
# Best-effort: clear per-identity limiter on success.
|
||||
if ident_key:
|
||||
await cache.delete(ident_key)
|
||||
|
||||
# Cookie diferenciada por rol para aislar sesiones entre frontends
|
||||
cookie_name = _cookie_name_for_role(
|
||||
user.role.value if hasattr(user.role, "value") else user.role
|
||||
)
|
||||
response.set_cookie(
|
||||
key=cookie_name,
|
||||
value=access_token,
|
||||
httponly=True,
|
||||
secure=settings.is_production(),
|
||||
samesite="strict" if settings.is_production() else "lax",
|
||||
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||
)
|
||||
|
||||
return LoginResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
@@ -162,6 +284,7 @@ async def login(
|
||||
"last_name": user.last_name,
|
||||
"role": user.role,
|
||||
"tenant_id": str(user.tenant_id),
|
||||
"tenant_slug": tenant.slug if tenant else str(user.tenant_id),
|
||||
"is_active": user.is_active,
|
||||
"is_two_factor_enabled": user.totp_enabled or False,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None
|
||||
@@ -198,7 +321,20 @@ async def refresh_token(
|
||||
detail="Invalid refresh token"
|
||||
)
|
||||
|
||||
# TODO: Check if refresh token exists in database and is not revoked
|
||||
# Check token exists in database and is not revoked/expired
|
||||
db_token = await TokenService.verify_refresh_token(db=db, refresh_token=refresh_data.refresh_token)
|
||||
if db_token is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token",
|
||||
)
|
||||
|
||||
# Defensive: ensure DB token belongs to same subject
|
||||
if str(db_token.user_id) != str(payload.get("sub")):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token",
|
||||
)
|
||||
|
||||
# Create new access token
|
||||
token_data = {
|
||||
@@ -220,6 +356,7 @@ async def refresh_token(
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
response: Response,
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
@@ -243,9 +380,21 @@ async def logout(
|
||||
detail="Invalid token"
|
||||
)
|
||||
|
||||
# TODO: Revoke refresh token in database
|
||||
# Revoke all active refresh tokens for this user (logout invalidates refresh)
|
||||
try:
|
||||
import uuid
|
||||
|
||||
user_id = uuid.UUID(payload["sub"])
|
||||
await TokenService.revoke_all_user_tokens(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
revoked_by_user_id=user_id,
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to revoke refresh tokens on logout", error=str(e))
|
||||
|
||||
# Registrar logout en auditoría
|
||||
# Registrar logout en auditorÃa
|
||||
try:
|
||||
import uuid
|
||||
user_id = uuid.UUID(payload["sub"])
|
||||
@@ -265,7 +414,10 @@ async def logout(
|
||||
logger.warning("Failed to log audit entry", error=str(e))
|
||||
|
||||
logger.info("Logout successful", user_id=payload["sub"])
|
||||
|
||||
|
||||
# Borrar la cookie correcta según el rol del usuario
|
||||
cookie_name = _cookie_name_for_role(payload.get("role", ""))
|
||||
response.delete_cookie(key=cookie_name)
|
||||
return {"message": "Successfully logged out"}
|
||||
|
||||
|
||||
@@ -329,6 +481,7 @@ async def get_current_user(
|
||||
"role": user.role.value if hasattr(user.role, 'value') else user.role,
|
||||
"tenant_id": str(user.tenant_id),
|
||||
"tenant_name": user.tenant.name if user.tenant else None,
|
||||
"tenant_slug": user.tenant.slug if user.tenant else None,
|
||||
"is_active": user.is_active,
|
||||
"is_two_factor_enabled": user.totp_secret is not None,
|
||||
"last_login": user.last_login.isoformat() if user.last_login else None,
|
||||
@@ -352,7 +505,7 @@ async def get_2fa_status(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Consultar si el 2FA está habilitado para el usuario actual.
|
||||
Consultar si el 2FA está habilitado para el usuario actual.
|
||||
|
||||
Returns:
|
||||
Estado de 2FA del usuario autenticado.
|
||||
@@ -366,10 +519,10 @@ async def setup_2fa(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Iniciar configuración de 2FA: genera un nuevo TOTP secret y QR URI.
|
||||
Iniciar configuración de 2FA: genera un nuevo TOTP secret y QR URI.
|
||||
|
||||
El secret se guarda en BD pero 2FA NO se activa todavía.
|
||||
Se necesita llamar a /2fa/enable con un código válido para activarlo.
|
||||
El secret se guarda en BD pero 2FA NO se activa todavÃa.
|
||||
Se necesita llamar a /2fa/enable con un código válido para activarlo.
|
||||
|
||||
Returns:
|
||||
Secret y QR URI para escanear con la app autenticadora.
|
||||
@@ -377,7 +530,7 @@ async def setup_2fa(
|
||||
new_secret = security.generate_totp_secret()
|
||||
qr_uri = security.generate_totp_uri(new_secret, current_user.email)
|
||||
|
||||
# Guardar el secret (sin habilitar aún)
|
||||
# Guardar el secret (sin habilitar aún)
|
||||
current_user.totp_secret = new_secret
|
||||
await db.commit()
|
||||
|
||||
@@ -393,29 +546,29 @@ async def enable_2fa(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Activar 2FA verificando que el usuario escaneó correctamente el QR.
|
||||
Activar 2FA verificando que el usuario escaneó correctamente el QR.
|
||||
|
||||
Requiere que /2fa/setup haya sido llamado previamente.
|
||||
|
||||
Args:
|
||||
data: Código TOTP generado por la app autenticadora.
|
||||
data: Código TOTP generado por la app autenticadora.
|
||||
|
||||
Returns:
|
||||
Confirmación y lista de códigos de respaldo.
|
||||
Confirmación y lista de códigos de respaldo.
|
||||
"""
|
||||
if not current_user.totp_secret:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Primero inicia el proceso de configuración con /2fa/setup"
|
||||
detail="Primero inicia el proceso de configuración con /2fa/setup"
|
||||
)
|
||||
|
||||
if not security.verify_totp(current_user.totp_secret, data.totp_code):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Código TOTP inválido. Verifica la hora de tu dispositivo e intenta de nuevo."
|
||||
detail="Código TOTP inválido. Verifica la hora de tu dispositivo e intenta de nuevo."
|
||||
)
|
||||
|
||||
# Activar 2FA y generar códigos de respaldo
|
||||
# Activar 2FA y generar códigos de respaldo
|
||||
backup_codes = security.generate_backup_codes()
|
||||
current_user.totp_enabled = True
|
||||
current_user.backup_codes = backup_codes
|
||||
@@ -443,21 +596,21 @@ async def disable_2fa(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Deshabilitar 2FA verificando con código TOTP o código de respaldo.
|
||||
Deshabilitar 2FA verificando con código TOTP o código de respaldo.
|
||||
|
||||
Args:
|
||||
data: totp_code o backup_code para verificar identidad.
|
||||
|
||||
Returns:
|
||||
Mensaje de confirmación.
|
||||
Mensaje de confirmación.
|
||||
"""
|
||||
if not current_user.totp_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="El 2FA no está habilitado en esta cuenta"
|
||||
detail="El 2FA no está habilitado en esta cuenta"
|
||||
)
|
||||
|
||||
# Verificar con TOTP o código de respaldo
|
||||
# Verificar con TOTP o código de respaldo
|
||||
verified = False
|
||||
|
||||
if data.totp_code:
|
||||
@@ -465,7 +618,7 @@ async def disable_2fa(
|
||||
elif data.backup_code and current_user.backup_codes:
|
||||
if data.backup_code in current_user.backup_codes:
|
||||
verified = True
|
||||
# Invalidar el código de respaldo usado
|
||||
# Invalidar el código de respaldo usado
|
||||
current_user.backup_codes = [
|
||||
c for c in current_user.backup_codes if c != data.backup_code
|
||||
]
|
||||
@@ -473,7 +626,7 @@ async def disable_2fa(
|
||||
if not verified:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Verificación fallida. Proporciona un código TOTP o un código de respaldo válido."
|
||||
detail="Verificación fallida. Proporciona un código TOTP o un código de respaldo válido."
|
||||
)
|
||||
|
||||
# Deshabilitar 2FA
|
||||
@@ -494,7 +647,7 @@ async def disable_2fa(
|
||||
|
||||
logger.info("2FA disabled", user_id=str(current_user.id))
|
||||
|
||||
return {"message": "Autenticación de dos factores deshabilitada correctamente"}
|
||||
return {"message": "Autenticación de dos factores deshabilitada correctamente"}
|
||||
|
||||
|
||||
@router.post("/change-password", status_code=status.HTTP_200_OK)
|
||||
@@ -504,32 +657,32 @@ async def change_password(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Cambiar la contraseña del usuario autenticado.
|
||||
Cambiar la contraseña del usuario autenticado.
|
||||
|
||||
Verifica la contraseña actual antes de actualizar.
|
||||
Requiere autenticación activa.
|
||||
Verifica la contraseña actual antes de actualizar.
|
||||
Requiere autenticación activa.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
# Validar longitud mínima
|
||||
# Validar longitud mÃnima
|
||||
if len(data.new_password) < 8:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="La nueva contraseña debe tener al menos 8 caracteres"
|
||||
detail="La nueva contraseña debe tener al menos 8 caracteres"
|
||||
)
|
||||
|
||||
# Verificar que la contraseña actual sea correcta
|
||||
# Verificar que la contraseña actual sea correcta
|
||||
if not security.verify_password(data.current_password, current_user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="La contraseña actual es incorrecta"
|
||||
detail="La contraseña actual es incorrecta"
|
||||
)
|
||||
|
||||
# No permitir que la nueva sea igual a la actual
|
||||
if security.verify_password(data.new_password, current_user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="La nueva contraseña no puede ser igual a la actual"
|
||||
detail="La nueva contraseña no puede ser igual a la actual"
|
||||
)
|
||||
|
||||
current_user.password_hash = security.hash_password(data.new_password)
|
||||
@@ -547,11 +700,11 @@ async def change_password(
|
||||
await db.commit()
|
||||
|
||||
logger.info("Password changed", user_id=str(current_user.id))
|
||||
return {"message": "Contraseña actualizada correctamente"}
|
||||
return {"message": "Contraseña actualizada correctamente"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Recuperación de contraseña (forgot / reset)
|
||||
# Recuperación de contraseña (forgot / reset)
|
||||
# ============================================================
|
||||
|
||||
_RESET_TOKEN_TTL = 1800 # 30 minutos en segundos
|
||||
@@ -559,15 +712,17 @@ _RESET_KEY_PREFIX = "pwd_reset:"
|
||||
|
||||
|
||||
@router.post("/forgot-password", status_code=status.HTTP_200_OK)
|
||||
@limiter.limit("5/minute")
|
||||
async def forgot_password(
|
||||
request: Request,
|
||||
data: ForgotPasswordRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Solicitar reseteo de contraseña.
|
||||
Solicitar reseteo de contraseña.
|
||||
|
||||
Siempre retorna 200 aunque el email no exista, para no revelar
|
||||
si una dirección está registrada en el sistema.
|
||||
si una dirección está registrada en el sistema.
|
||||
"""
|
||||
import secrets
|
||||
from redis.asyncio import from_url as redis_from_url
|
||||
@@ -583,9 +738,9 @@ async def forgot_password(
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
# Respuesta idéntica — no revelar existencia
|
||||
# Respuesta idéntica — no revelar existencia
|
||||
logger.info("Forgot password: email not found", email=data.email)
|
||||
return {"message": "Si el correo está registrado recibirás un enlace en breve."}
|
||||
return {"message": "Si el correo está registrado recibirás un enlace en breve."}
|
||||
|
||||
# Generar token seguro
|
||||
token = secrets.token_urlsafe(32)
|
||||
@@ -605,7 +760,7 @@ async def forgot_password(
|
||||
|
||||
await send_email(
|
||||
to_email=user.email,
|
||||
subject="Restablece tu contraseña — ServiceManager",
|
||||
subject="Restablece tu contraseña — ServiceManager",
|
||||
html_content=html,
|
||||
text_content=text,
|
||||
)
|
||||
@@ -622,16 +777,18 @@ async def forgot_password(
|
||||
await db.commit()
|
||||
|
||||
logger.info("Password reset email sent", user_id=str(user.id))
|
||||
return {"message": "Si el correo está registrado recibirás un enlace en breve."}
|
||||
return {"message": "Si el correo está registrado recibirás un enlace en breve."}
|
||||
|
||||
|
||||
@router.post("/reset-password", status_code=status.HTTP_200_OK)
|
||||
@limiter.limit("5/minute")
|
||||
async def reset_password(
|
||||
request: Request,
|
||||
data: ResetPasswordRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Aplicar nueva contraseña usando el token recibido por email.
|
||||
Aplicar nueva contraseña usando el token recibido por email.
|
||||
|
||||
El token es de un solo uso: se elimina de Redis al usarse.
|
||||
"""
|
||||
@@ -642,7 +799,7 @@ async def reset_password(
|
||||
if len(data.new_password) < 8:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="La contraseña debe tener al menos 8 caracteres"
|
||||
detail="La contraseña debe tener al menos 8 caracteres"
|
||||
)
|
||||
|
||||
redis_key = f"{_RESET_KEY_PREFIX}{data.token}"
|
||||
@@ -653,7 +810,7 @@ async def reset_password(
|
||||
if not user_id_str:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="El enlace de reseteo es inválido o ya expiró. Solicita uno nuevo."
|
||||
detail="El enlace de reseteo es inválido o ya expiró. Solicita uno nuevo."
|
||||
)
|
||||
|
||||
# Eliminar token inmediatamente (un solo uso)
|
||||
@@ -684,4 +841,4 @@ async def reset_password(
|
||||
await db.commit()
|
||||
|
||||
logger.info("Password reset completed", user_id=str(user.id))
|
||||
return {"message": "Contraseña actualizada correctamente. Ya puedes iniciar sesión."}
|
||||
return {"message": "Contraseña actualizada correctamente. Ya puedes iniciar sesión."}
|
||||
@@ -79,11 +79,16 @@ async def create_category(
|
||||
|
||||
✅ Implementa multi-tenancy: asigna automáticamente tenant_id del usuario.
|
||||
"""
|
||||
# ✅ CORREGIDO: Asignar tenant_id del usuario actual
|
||||
db_category = Category(
|
||||
**category.model_dump(),
|
||||
tenant_id=current_user.tenant_id # ✅ Multi-tenancy automático
|
||||
)
|
||||
# ADMIN global puede especificar tenant_id; otros usan el suyo
|
||||
from app.models.user import UserRole as _R
|
||||
data = category.model_dump()
|
||||
if current_user.role == _R.ADMIN and data.get("tenant_id"):
|
||||
target_tenant_id = data["tenant_id"]
|
||||
else:
|
||||
target_tenant_id = current_user.tenant_id
|
||||
data["tenant_id"] = target_tenant_id
|
||||
|
||||
db_category = Category(**data)
|
||||
|
||||
db.add(db_category)
|
||||
await db.commit()
|
||||
|
||||
130
backend/app/api/v1/endpoints/participants.py
Normal file
130
backend/app/api/v1/endpoints/participants.py
Normal file
@@ -0,0 +1,130 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
import uuid
|
||||
|
||||
from app.api import deps
|
||||
from app.core.database import get_db
|
||||
from app.models.ticket import Ticket, TicketParticipant
|
||||
from app.models.user import User
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class ParticipantAdd(BaseModel):
|
||||
user_id: uuid.UUID
|
||||
|
||||
class ParticipantResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
ticket_id: uuid.UUID
|
||||
role: str
|
||||
user_email: str
|
||||
user_name: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@router.get("/{ticket_id}/participants", response_model=list[ParticipantResponse])
|
||||
async def list_participants(
|
||||
ticket_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_user)
|
||||
):
|
||||
result = await db.execute(
|
||||
select(TicketParticipant)
|
||||
.where(TicketParticipant.ticket_id == ticket_id)
|
||||
.options(selectinload(TicketParticipant.user))
|
||||
)
|
||||
participants = result.scalars().all()
|
||||
return [
|
||||
ParticipantResponse(
|
||||
id=p.id,
|
||||
user_id=p.user_id,
|
||||
ticket_id=p.ticket_id,
|
||||
role=p.role,
|
||||
user_email=p.user.email,
|
||||
user_name=f"{p.user.first_name or ''} {p.user.last_name or ''}".strip()
|
||||
)
|
||||
for p in participants
|
||||
]
|
||||
|
||||
@router.post("/{ticket_id}/participants", response_model=ParticipantResponse)
|
||||
async def add_participant(
|
||||
ticket_id: uuid.UUID,
|
||||
data: ParticipantAdd,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_user)
|
||||
):
|
||||
# Verificar que el ticket existe
|
||||
ticket_result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
|
||||
ticket = ticket_result.scalar_one_or_none()
|
||||
if not ticket:
|
||||
raise HTTPException(status_code=404, detail="Ticket not found")
|
||||
|
||||
# Solo el creador puede agregar participantes
|
||||
if ticket.created_by != current_user.id and not current_user.role.is_global:
|
||||
raise HTTPException(status_code=403, detail="Only the ticket creator can add participants")
|
||||
|
||||
# Verificar que el usuario existe
|
||||
user_result = await db.execute(select(User).where(User.id == data.user_id))
|
||||
user = user_result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Verificar que no sea duplicado
|
||||
existing = await db.execute(
|
||||
select(TicketParticipant).where(
|
||||
TicketParticipant.ticket_id == ticket_id,
|
||||
TicketParticipant.user_id == data.user_id
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="User is already a participant")
|
||||
|
||||
participant = TicketParticipant(
|
||||
ticket_id=ticket_id,
|
||||
user_id=data.user_id,
|
||||
role="participant"
|
||||
)
|
||||
db.add(participant)
|
||||
await db.commit()
|
||||
await db.refresh(participant)
|
||||
|
||||
return ParticipantResponse(
|
||||
id=participant.id,
|
||||
user_id=participant.user_id,
|
||||
ticket_id=participant.ticket_id,
|
||||
role=participant.role,
|
||||
user_email=user.email,
|
||||
user_name=f"{user.first_name or ''} {user.last_name or ''}".strip()
|
||||
)
|
||||
|
||||
@router.delete("/{ticket_id}/participants/{user_id}", status_code=204)
|
||||
async def remove_participant(
|
||||
ticket_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_user)
|
||||
):
|
||||
ticket_result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
|
||||
ticket = ticket_result.scalar_one_or_none()
|
||||
if not ticket:
|
||||
raise HTTPException(status_code=404, detail="Ticket not found")
|
||||
|
||||
if ticket.created_by != current_user.id and not current_user.role.is_global:
|
||||
raise HTTPException(status_code=403, detail="Only the ticket creator can remove participants")
|
||||
|
||||
result = await db.execute(
|
||||
select(TicketParticipant).where(
|
||||
TicketParticipant.ticket_id == ticket_id,
|
||||
TicketParticipant.user_id == user_id
|
||||
)
|
||||
)
|
||||
participant = result.scalar_one_or_none()
|
||||
if not participant:
|
||||
raise HTTPException(status_code=404, detail="Participant not found")
|
||||
|
||||
await db.delete(participant)
|
||||
await db.commit()
|
||||
764
backend/app/api/v1/endpoints/reports.py
Normal file
764
backend/app/api/v1/endpoints/reports.py
Normal file
@@ -0,0 +1,764 @@
|
||||
"""
|
||||
Reports Endpoints - ServiceManagerWeb
|
||||
|
||||
Módulo de reportes y estadísticas del sistema.
|
||||
Accesible por ADMIN y SUPPORT_MANAGER.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, case, text, literal_column
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import uuid
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.api.deps import get_current_user
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.ticket import Ticket, TicketStatus, TicketPriority
|
||||
from app.models.category import Category
|
||||
from app.models.system import System
|
||||
from app.models.tenant import Tenant, TenantStatus
|
||||
from app.api.schemas.reports import (
|
||||
ReportSummaryResponse,
|
||||
TicketsByStatus,
|
||||
TicketsByPriority,
|
||||
AgentReportResponse,
|
||||
AgentReportRow,
|
||||
CategoryReportResponse,
|
||||
CategoryReportRow,
|
||||
ClientReportResponse,
|
||||
ClientReportRow,
|
||||
TrendsReportResponse,
|
||||
TrendDataPoint,
|
||||
CSATReportResponse,
|
||||
CSATDistribution,
|
||||
SystemReportResponse,
|
||||
SystemReportRow,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
CLOSED_STATUSES = {TicketStatus.RESOLVED, TicketStatus.CLOSED}
|
||||
|
||||
# ===================================
|
||||
# HELPERS
|
||||
# ===================================
|
||||
|
||||
def require_reports_access(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""ADMIN, SUPPORT_MANAGER y AUDITOR pueden leer reportes."""
|
||||
allowed = [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AUDITOR]
|
||||
if current_user.role not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Solo ADMIN, SUPPORT_MANAGER y AUDITOR pueden acceder a los reportes.",
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
def require_admin(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""Solo ADMIN puede ver reportes entre tenants."""
|
||||
if current_user.role != UserRole.ADMIN:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Solo ADMIN puede ver reportes de todos los clientes.",
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
def _period_dates(days: int) -> tuple[datetime, datetime]:
|
||||
"""Devuelve (inicio, fin) del período solicitado en UTC."""
|
||||
end = datetime.now(timezone.utc)
|
||||
start = end - timedelta(days=days)
|
||||
return start, end
|
||||
|
||||
|
||||
# ===================================
|
||||
# 1. RESUMEN GENERAL
|
||||
# ===================================
|
||||
|
||||
@router.get("/summary", response_model=ReportSummaryResponse)
|
||||
async def get_report_summary(
|
||||
days: int = Query(default=30, ge=1, le=365, description="Días hacia atrás del período"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_reports_access),
|
||||
):
|
||||
"""
|
||||
Resumen ejecutivo del período seleccionado.
|
||||
|
||||
Incluye:
|
||||
- Total de tickets creados
|
||||
- Tickets abiertos vs resueltos
|
||||
- Tiempo promedio de resolución
|
||||
- Calificación promedio (CSAT)
|
||||
- Desglose por estado y prioridad
|
||||
- Comparación con el período anterior
|
||||
"""
|
||||
period_start, period_end = _period_dates(days)
|
||||
prev_start = period_start - timedelta(days=days)
|
||||
|
||||
tenant_filter = Ticket.tenant_id == current_user.tenant_id
|
||||
|
||||
# ── Conteos por estado ──
|
||||
status_rows = (await db.execute(
|
||||
select(Ticket.status, func.count(Ticket.id).label("cnt"))
|
||||
.where(and_(tenant_filter, Ticket.created_at >= period_start))
|
||||
.group_by(Ticket.status)
|
||||
)).all()
|
||||
|
||||
by_status = TicketsByStatus()
|
||||
for row in status_rows:
|
||||
s = row.status.value if hasattr(row.status, "value") else str(row.status)
|
||||
setattr(by_status, s.lower(), row.cnt)
|
||||
by_status.total = sum(
|
||||
[by_status.new, by_status.triage, by_status.in_progress,
|
||||
by_status.waiting_customer, by_status.resolved, by_status.closed, by_status.reopened]
|
||||
)
|
||||
|
||||
# ── Conteos por prioridad ──
|
||||
priority_rows = (await db.execute(
|
||||
select(Ticket.priority, func.count(Ticket.id).label("cnt"))
|
||||
.where(and_(tenant_filter, Ticket.created_at >= period_start))
|
||||
.group_by(Ticket.priority)
|
||||
)).all()
|
||||
|
||||
by_priority = TicketsByPriority()
|
||||
for row in priority_rows:
|
||||
p = row.priority.value if hasattr(row.priority, "value") else str(row.priority)
|
||||
setattr(by_priority, p.lower(), row.cnt)
|
||||
by_priority.total = sum([by_priority.low, by_priority.medium, by_priority.high, by_priority.urgent])
|
||||
|
||||
total_tickets = by_status.total
|
||||
resolved_tickets = by_status.resolved + by_status.closed
|
||||
open_tickets = total_tickets - resolved_tickets
|
||||
|
||||
# ── Promedio de tiempo de resolución (segundos → horas) ──
|
||||
res_time_row = (await db.execute(
|
||||
select(func.avg(
|
||||
func.extract("epoch", Ticket.resolved_at - Ticket.created_at)
|
||||
).label("avg_seconds"))
|
||||
.where(and_(
|
||||
tenant_filter,
|
||||
Ticket.created_at >= period_start,
|
||||
Ticket.resolved_at.isnot(None),
|
||||
))
|
||||
)).scalar_one_or_none()
|
||||
avg_resolution_hours = round(res_time_row / 3600, 2) if res_time_row else None
|
||||
|
||||
# ── Promedio de primera respuesta ──
|
||||
resp_time_row = (await db.execute(
|
||||
select(func.avg(
|
||||
func.extract("epoch", Ticket.first_response_at - Ticket.created_at)
|
||||
).label("avg_seconds"))
|
||||
.where(and_(
|
||||
tenant_filter,
|
||||
Ticket.created_at >= period_start,
|
||||
Ticket.first_response_at.isnot(None),
|
||||
))
|
||||
)).scalar_one_or_none()
|
||||
avg_first_response_hours = round(resp_time_row / 3600, 2) if resp_time_row else None
|
||||
|
||||
# ── CSAT ──
|
||||
csat_row = (await db.execute(
|
||||
select(func.avg(Ticket.rating).label("avg"), func.count(Ticket.rating).label("cnt"))
|
||||
.where(and_(tenant_filter, Ticket.created_at >= period_start, Ticket.rating.isnot(None)))
|
||||
)).one()
|
||||
avg_rating = round(float(csat_row.avg), 2) if csat_row.avg else None
|
||||
total_rated = csat_row.cnt or 0
|
||||
|
||||
# ── Comparación con período anterior ──
|
||||
prev_total = (await db.execute(
|
||||
select(func.count(Ticket.id))
|
||||
.where(and_(tenant_filter, Ticket.created_at >= prev_start, Ticket.created_at < period_start))
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
prev_resolved = (await db.execute(
|
||||
select(func.count(Ticket.id))
|
||||
.where(and_(
|
||||
tenant_filter,
|
||||
Ticket.created_at >= prev_start,
|
||||
Ticket.created_at < period_start,
|
||||
Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]),
|
||||
))
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
tickets_change_pct = None
|
||||
if prev_total > 0:
|
||||
tickets_change_pct = round(((total_tickets - prev_total) / prev_total) * 100, 1)
|
||||
|
||||
resolution_change_pct = None
|
||||
if prev_total > 0 and total_tickets > 0:
|
||||
cur_rate = resolved_tickets / total_tickets * 100
|
||||
prev_rate = prev_resolved / prev_total * 100 if prev_total > 0 else 0
|
||||
resolution_change_pct = round(cur_rate - prev_rate, 1)
|
||||
|
||||
return ReportSummaryResponse(
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
total_tickets=total_tickets,
|
||||
open_tickets=open_tickets,
|
||||
resolved_tickets=resolved_tickets,
|
||||
avg_resolution_hours=avg_resolution_hours,
|
||||
avg_first_response_hours=avg_first_response_hours,
|
||||
avg_rating=avg_rating,
|
||||
total_rated=total_rated,
|
||||
by_status=by_status,
|
||||
by_priority=by_priority,
|
||||
tickets_change_pct=tickets_change_pct,
|
||||
resolution_change_pct=resolution_change_pct,
|
||||
)
|
||||
|
||||
|
||||
# ===================================
|
||||
# 2. RENDIMIENTO POR AGENTE
|
||||
# ===================================
|
||||
|
||||
@router.get("/by-agent", response_model=AgentReportResponse)
|
||||
async def get_report_by_agent(
|
||||
days: int = Query(default=30, ge=1, le=365),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_reports_access),
|
||||
):
|
||||
"""
|
||||
Rendimiento de cada agente en el período:
|
||||
- Tickets asignados y resueltos
|
||||
- Tasa de resolución
|
||||
- Tiempo promedio de resolución
|
||||
- Calificación promedio (CSAT)
|
||||
"""
|
||||
period_start, period_end = _period_dates(days)
|
||||
tenant_filter = and_(
|
||||
Ticket.tenant_id == current_user.tenant_id,
|
||||
Ticket.created_at >= period_start,
|
||||
Ticket.assigned_to.isnot(None),
|
||||
)
|
||||
|
||||
# Obtener todos los agentes del tenant
|
||||
agents_result = await db.execute(
|
||||
select(User).where(
|
||||
and_(
|
||||
User.tenant_id == current_user.tenant_id,
|
||||
User.role.in_([UserRole.AGENT, UserRole.SUPPORT_MANAGER, UserRole.ADMIN]),
|
||||
User.is_active == True,
|
||||
)
|
||||
)
|
||||
)
|
||||
agents = agents_result.scalars().all()
|
||||
|
||||
rows: List[AgentReportRow] = []
|
||||
for agent in agents:
|
||||
agent_filter = and_(tenant_filter, Ticket.assigned_to == agent.id)
|
||||
|
||||
total_assigned = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(agent_filter)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
if total_assigned == 0:
|
||||
continue # omitir agentes sin tickets en el período
|
||||
|
||||
resolved = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(agent_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]))
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
avg_res_seconds = (await db.execute(
|
||||
select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at)))
|
||||
.where(and_(agent_filter, Ticket.resolved_at.isnot(None)))
|
||||
)).scalar_one_or_none()
|
||||
|
||||
csat = (await db.execute(
|
||||
select(func.avg(Ticket.rating), func.count(Ticket.rating))
|
||||
.where(and_(agent_filter, Ticket.rating.isnot(None)))
|
||||
)).one()
|
||||
|
||||
urgent_handled = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(agent_filter, Ticket.priority == TicketPriority.URGENT)
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
rows.append(AgentReportRow(
|
||||
agent_id=str(agent.id),
|
||||
agent_name=f"{agent.first_name} {agent.last_name}",
|
||||
agent_email=agent.email,
|
||||
total_assigned=total_assigned,
|
||||
resolved=resolved,
|
||||
open=total_assigned - resolved,
|
||||
resolution_rate=round((resolved / total_assigned * 100), 1) if total_assigned else 0,
|
||||
avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None,
|
||||
avg_rating=round(float(csat[0]), 2) if csat[0] else None,
|
||||
total_rated=csat[1] or 0,
|
||||
urgent_handled=urgent_handled,
|
||||
))
|
||||
|
||||
rows.sort(key=lambda r: r.resolved, reverse=True)
|
||||
|
||||
return AgentReportResponse(
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
agents=rows,
|
||||
total_agents=len(rows),
|
||||
)
|
||||
|
||||
|
||||
# ===================================
|
||||
# 3. TICKETS POR CATEGORÍA
|
||||
# ===================================
|
||||
|
||||
@router.get("/by-category", response_model=CategoryReportResponse)
|
||||
async def get_report_by_category(
|
||||
days: int = Query(default=30, ge=1, le=365),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_reports_access),
|
||||
):
|
||||
"""
|
||||
Tickets agrupados por categoría con tasa de cumplimiento SLA.
|
||||
"""
|
||||
period_start, _ = _period_dates(days)
|
||||
period_end = datetime.now(timezone.utc)
|
||||
tenant_filter = and_(
|
||||
Ticket.tenant_id == current_user.tenant_id,
|
||||
Ticket.created_at >= period_start,
|
||||
)
|
||||
|
||||
categories_result = await db.execute(
|
||||
select(Category).where(
|
||||
and_(Category.tenant_id == current_user.tenant_id, Category.is_active == True)
|
||||
)
|
||||
)
|
||||
categories = categories_result.scalars().all()
|
||||
|
||||
rows: List[CategoryReportRow] = []
|
||||
|
||||
for cat in categories:
|
||||
cat_filter = and_(tenant_filter, Ticket.category_id == cat.id)
|
||||
|
||||
total = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(cat_filter)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
if total == 0:
|
||||
continue
|
||||
|
||||
resolved = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(cat_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]))
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
avg_res_seconds = (await db.execute(
|
||||
select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at)))
|
||||
.where(and_(cat_filter, Ticket.resolved_at.isnot(None)))
|
||||
)).scalar_one_or_none()
|
||||
|
||||
# SLA compliance: tickets resueltos ANTES del deadline
|
||||
sla_met = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(
|
||||
cat_filter,
|
||||
Ticket.resolved_at.isnot(None),
|
||||
Ticket.sla_resolution_due.isnot(None),
|
||||
Ticket.resolved_at <= Ticket.sla_resolution_due,
|
||||
)
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
tickets_with_sla = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(cat_filter, Ticket.sla_resolution_due.isnot(None), Ticket.resolved_at.isnot(None))
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
sla_compliance_pct = round((sla_met / tickets_with_sla * 100), 1) if tickets_with_sla else 0.0
|
||||
|
||||
rows.append(CategoryReportRow(
|
||||
category_id=str(cat.id),
|
||||
category_name=cat.name,
|
||||
total_tickets=total,
|
||||
open_tickets=total - resolved,
|
||||
resolved_tickets=resolved,
|
||||
avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None,
|
||||
sla_response_hours=cat.sla_response_hours,
|
||||
sla_resolution_hours=cat.sla_resolution_hours,
|
||||
sla_compliance_pct=sla_compliance_pct,
|
||||
))
|
||||
|
||||
# Sin categoría
|
||||
uncategorized = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(tenant_filter, Ticket.category_id.is_(None))
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
rows.sort(key=lambda r: r.total_tickets, reverse=True)
|
||||
|
||||
return CategoryReportResponse(
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
categories=rows,
|
||||
uncategorized_count=uncategorized,
|
||||
)
|
||||
|
||||
|
||||
# ===================================
|
||||
# 4. TICKETS POR CLIENTE (solo ADMIN)
|
||||
# ===================================
|
||||
|
||||
@router.get("/by-client", response_model=ClientReportResponse)
|
||||
async def get_report_by_client(
|
||||
days: int = Query(default=30, ge=1, le=365),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""
|
||||
Tickets agrupados por cliente (tenant). Solo accesible por ADMIN.
|
||||
Útil para ver qué clientes generan más trabajo.
|
||||
"""
|
||||
period_start, period_end = _period_dates(days)
|
||||
|
||||
tenants_result = await db.execute(select(Tenant).where(Tenant.status == TenantStatus.ACTIVE))
|
||||
tenants = tenants_result.scalars().all()
|
||||
|
||||
rows: List[ClientReportRow] = []
|
||||
|
||||
for tenant in tenants:
|
||||
t_filter = and_(
|
||||
Ticket.tenant_id == tenant.id,
|
||||
Ticket.created_at >= period_start,
|
||||
)
|
||||
|
||||
total = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(t_filter)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
if total == 0:
|
||||
continue
|
||||
|
||||
resolved = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(t_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]))
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
urgent = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(t_filter, Ticket.priority == TicketPriority.URGENT)
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
csat_row = (await db.execute(
|
||||
select(func.avg(Ticket.rating))
|
||||
.where(and_(t_filter, Ticket.rating.isnot(None)))
|
||||
)).scalar_one_or_none()
|
||||
|
||||
avg_res_seconds = (await db.execute(
|
||||
select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at)))
|
||||
.where(and_(t_filter, Ticket.resolved_at.isnot(None)))
|
||||
)).scalar_one_or_none()
|
||||
|
||||
last_ticket = (await db.execute(
|
||||
select(func.max(Ticket.created_at)).where(t_filter)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
rows.append(ClientReportRow(
|
||||
tenant_id=str(tenant.id),
|
||||
tenant_name=tenant.name,
|
||||
total_tickets=total,
|
||||
open_tickets=total - resolved,
|
||||
resolved_tickets=resolved,
|
||||
urgent_tickets=urgent,
|
||||
avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None,
|
||||
avg_rating=round(float(csat_row), 2) if csat_row else None,
|
||||
last_ticket_at=last_ticket,
|
||||
))
|
||||
|
||||
rows.sort(key=lambda r: r.total_tickets, reverse=True)
|
||||
|
||||
return ClientReportResponse(
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
clients=rows,
|
||||
total_clients=len(rows),
|
||||
)
|
||||
|
||||
|
||||
# ===================================
|
||||
# 5. TENDENCIAS (TICKETS EN EL TIEMPO)
|
||||
# ===================================
|
||||
|
||||
@router.get("/trends", response_model=TrendsReportResponse)
|
||||
async def get_report_trends(
|
||||
days: int = Query(default=30, ge=7, le=90, description="Número de días (7-90)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_reports_access),
|
||||
):
|
||||
"""
|
||||
Evolución diaria de tickets creados y resueltos.
|
||||
Útil para detectar picos de trabajo.
|
||||
"""
|
||||
period_start, period_end = _period_dates(days)
|
||||
tenant_filter = Ticket.tenant_id == current_user.tenant_id
|
||||
|
||||
# Tickets creados por día
|
||||
# literal_column("'day'") evita que SQLAlchemy genere múltiples parámetros
|
||||
# ($1, $4, $5) para 'day', lo que confunde a PostgreSQL en el GROUP BY.
|
||||
_day_lit = literal_column("'day'")
|
||||
created_rows = (await db.execute(
|
||||
select(
|
||||
func.date_trunc(_day_lit, Ticket.created_at).label("day"),
|
||||
func.count(Ticket.id).label("cnt"),
|
||||
)
|
||||
.where(and_(tenant_filter, Ticket.created_at >= period_start))
|
||||
.group_by(func.date_trunc(_day_lit, Ticket.created_at))
|
||||
.order_by(func.date_trunc(_day_lit, Ticket.created_at))
|
||||
)).all()
|
||||
|
||||
# Tickets resueltos por día (según resolved_at)
|
||||
resolved_rows = (await db.execute(
|
||||
select(
|
||||
func.date_trunc(_day_lit, Ticket.resolved_at).label("day"),
|
||||
func.count(Ticket.id).label("cnt"),
|
||||
)
|
||||
.where(and_(
|
||||
tenant_filter,
|
||||
Ticket.resolved_at >= period_start,
|
||||
Ticket.resolved_at.isnot(None),
|
||||
))
|
||||
.group_by(func.date_trunc(_day_lit, Ticket.resolved_at))
|
||||
.order_by(func.date_trunc(_day_lit, Ticket.resolved_at))
|
||||
)).all()
|
||||
|
||||
created_map: dict[str, int] = {r.day.strftime("%Y-%m-%d"): r.cnt for r in created_rows}
|
||||
resolved_map: dict[str, int] = {r.day.strftime("%Y-%m-%d"): r.cnt for r in resolved_rows}
|
||||
|
||||
# Un punto por cada día del período
|
||||
data_points: List[TrendDataPoint] = []
|
||||
current = period_start
|
||||
while current <= period_end:
|
||||
date_str = current.strftime("%Y-%m-%d")
|
||||
c = created_map.get(date_str, 0)
|
||||
r = resolved_map.get(date_str, 0)
|
||||
data_points.append(TrendDataPoint(date=date_str, created=c, resolved=r, net_open=c - r))
|
||||
current += timedelta(days=1)
|
||||
|
||||
return TrendsReportResponse(
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
data_points=data_points,
|
||||
total_days=len(data_points),
|
||||
)
|
||||
|
||||
|
||||
# ===================================
|
||||
# 6. SATISFACCIÓN DEL CLIENTE (CSAT)
|
||||
# ===================================
|
||||
|
||||
@router.get("/csat", response_model=CSATReportResponse)
|
||||
async def get_report_csat(
|
||||
days: int = Query(default=30, ge=1, le=365),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_reports_access),
|
||||
):
|
||||
"""
|
||||
Reporte de satisfacción del cliente (calificaciones 1-5).
|
||||
Incluye distribución, promedio por categoría y por agente.
|
||||
"""
|
||||
period_start, period_end = _period_dates(days)
|
||||
tenant_filter = and_(
|
||||
Ticket.tenant_id == current_user.tenant_id,
|
||||
Ticket.created_at >= period_start,
|
||||
)
|
||||
|
||||
# Total y promedio general
|
||||
general = (await db.execute(
|
||||
select(func.avg(Ticket.rating).label("avg"), func.count(Ticket.rating).label("rated"))
|
||||
.where(and_(tenant_filter, Ticket.rating.isnot(None)))
|
||||
)).one()
|
||||
total_tickets = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(tenant_filter)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
# Distribución por estrellas
|
||||
dist_rows = (await db.execute(
|
||||
select(Ticket.rating, func.count(Ticket.id).label("cnt"))
|
||||
.where(and_(tenant_filter, Ticket.rating.isnot(None)))
|
||||
.group_by(Ticket.rating)
|
||||
)).all()
|
||||
|
||||
dist = CSATDistribution()
|
||||
for row in dist_rows:
|
||||
setattr(dist, f"rating_{row.rating}", row.cnt)
|
||||
|
||||
# Promedio por categoría
|
||||
cat_rows = (await db.execute(
|
||||
select(
|
||||
Category.name.label("cat_name"),
|
||||
func.avg(Ticket.rating).label("avg"),
|
||||
func.count(Ticket.rating).label("cnt"),
|
||||
)
|
||||
.join(Category, Ticket.category_id == Category.id, isouter=True)
|
||||
.where(and_(tenant_filter, Ticket.rating.isnot(None)))
|
||||
.group_by(Category.name)
|
||||
.order_by(func.avg(Ticket.rating).desc())
|
||||
)).all()
|
||||
|
||||
by_category = [
|
||||
{
|
||||
"category": row.cat_name or "Sin categoría",
|
||||
"avg_rating": round(float(row.avg), 2) if row.avg else None,
|
||||
"total_rated": row.cnt,
|
||||
}
|
||||
for row in cat_rows
|
||||
]
|
||||
|
||||
# Promedio por agente
|
||||
agent_rows = (await db.execute(
|
||||
select(
|
||||
User.first_name.label("fname"),
|
||||
User.last_name.label("lname"),
|
||||
func.avg(Ticket.rating).label("avg"),
|
||||
func.count(Ticket.rating).label("cnt"),
|
||||
)
|
||||
.join(User, Ticket.assigned_to == User.id, isouter=True)
|
||||
.where(and_(tenant_filter, Ticket.rating.isnot(None)))
|
||||
.group_by(User.first_name, User.last_name)
|
||||
.order_by(func.avg(Ticket.rating).desc())
|
||||
)).all()
|
||||
|
||||
by_agent = [
|
||||
{
|
||||
"agent": f"{row.fname or ''} {row.lname or ''}".strip() or "Sin asignar",
|
||||
"avg_rating": round(float(row.avg), 2) if row.avg else None,
|
||||
"total_rated": row.cnt,
|
||||
}
|
||||
for row in agent_rows
|
||||
]
|
||||
|
||||
# Últimos comentarios de calificación (rating_comment)
|
||||
comment_rows = (await db.execute(
|
||||
select(Ticket.rating, Ticket.rating_comment, Ticket.rated_at)
|
||||
.where(and_(
|
||||
tenant_filter,
|
||||
Ticket.rating.isnot(None),
|
||||
Ticket.rating_comment.isnot(None),
|
||||
Ticket.rating_comment != "",
|
||||
))
|
||||
.order_by(Ticket.rated_at.desc())
|
||||
.limit(10)
|
||||
)).all()
|
||||
|
||||
recent_comments = [
|
||||
{
|
||||
"rating": row.rating,
|
||||
"comment": row.rating_comment,
|
||||
"rated_at": row.rated_at.isoformat() if row.rated_at else None,
|
||||
}
|
||||
for row in comment_rows
|
||||
]
|
||||
|
||||
total_rated = general.rated or 0
|
||||
response_rate = round((total_rated / total_tickets * 100), 1) if total_tickets else 0.0
|
||||
|
||||
return CSATReportResponse(
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
avg_rating=round(float(general.avg), 2) if general.avg else None,
|
||||
total_rated=total_rated,
|
||||
total_tickets=total_tickets,
|
||||
response_rate=response_rate,
|
||||
distribution=dist,
|
||||
by_category=by_category,
|
||||
by_agent=by_agent,
|
||||
recent_comments=recent_comments,
|
||||
)
|
||||
|
||||
|
||||
# ===================================
|
||||
# 7. TICKETS POR SISTEMA AFECTADO
|
||||
# ===================================
|
||||
|
||||
@router.get("/by-system", response_model=SystemReportResponse)
|
||||
async def get_report_by_system(
|
||||
days: int = Query(default=30, ge=1, le=365),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_reports_access),
|
||||
):
|
||||
"""
|
||||
Tickets agrupados por sistema afectado.
|
||||
Útil para detectar qué sistemas generan más incidentes.
|
||||
"""
|
||||
period_start, period_end = _period_dates(days)
|
||||
tenant_filter = and_(
|
||||
Ticket.tenant_id == current_user.tenant_id,
|
||||
Ticket.created_at >= period_start,
|
||||
)
|
||||
|
||||
systems_result = await db.execute(
|
||||
select(System).where(
|
||||
and_(System.tenant_id == current_user.tenant_id, System.is_active == True)
|
||||
)
|
||||
)
|
||||
systems = systems_result.scalars().all()
|
||||
|
||||
rows: List[SystemReportRow] = []
|
||||
|
||||
for sys in systems:
|
||||
sys_filter = and_(tenant_filter, Ticket.affected_system_id == sys.id)
|
||||
|
||||
total = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(sys_filter)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
if total == 0:
|
||||
continue
|
||||
|
||||
resolved = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(sys_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]))
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
urgent = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(sys_filter, Ticket.priority == TicketPriority.URGENT)
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
avg_res_seconds = (await db.execute(
|
||||
select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at)))
|
||||
.where(and_(sys_filter, Ticket.resolved_at.isnot(None)))
|
||||
)).scalar_one_or_none()
|
||||
|
||||
rows.append(SystemReportRow(
|
||||
system_id=str(sys.id),
|
||||
system_name=sys.name,
|
||||
total_tickets=total,
|
||||
open_tickets=total - resolved,
|
||||
resolved_tickets=resolved,
|
||||
urgent_tickets=urgent,
|
||||
avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None,
|
||||
))
|
||||
|
||||
# Sin sistema asignado
|
||||
no_system = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(tenant_filter, Ticket.affected_system_id.is_(None))
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
rows.sort(key=lambda r: r.total_tickets, reverse=True)
|
||||
|
||||
return SystemReportResponse(
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
systems=rows,
|
||||
no_system_count=no_system,
|
||||
)
|
||||
@@ -91,7 +91,7 @@ async def get_sla_dashboard(
|
||||
days=days
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
period_start = now - timedelta(days=days)
|
||||
|
||||
# Usar func.now() para comparaciones en SQL (evita timezone issues)
|
||||
@@ -357,7 +357,7 @@ async def get_sla_violations(
|
||||
sla_type=sla_type
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
db_now = func.now()
|
||||
|
||||
# Base query con carga de relaciones
|
||||
@@ -417,18 +417,16 @@ async def get_sla_violations(
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Aplicar paginación
|
||||
query = query.order_by(desc(Ticket.created_at)).offset(skip).limit(limit)
|
||||
|
||||
# Obtener todos los tickets sin paginación primero (los ordenaremos por tiempo vencido después)
|
||||
result = await db.execute(query)
|
||||
tickets = result.scalars().all()
|
||||
|
||||
# Formatear response
|
||||
violations = []
|
||||
for ticket in tickets:
|
||||
# Asegurar que los datetimes de BD sean timezone-aware
|
||||
sla_response_due = ticket.sla_response_due.replace(tzinfo=timezone.utc) if ticket.sla_response_due and ticket.sla_response_due.tzinfo is None else ticket.sla_response_due
|
||||
sla_resolution_due = ticket.sla_resolution_due.replace(tzinfo=timezone.utc) if ticket.sla_resolution_due and ticket.sla_resolution_due.tzinfo is None else ticket.sla_resolution_due
|
||||
# Todos los campos son timezone-naive (TIMESTAMP WITHOUT TIME ZONE)
|
||||
sla_response_due = ticket.sla_response_due
|
||||
sla_resolution_due = ticket.sla_resolution_due
|
||||
|
||||
# Determinar tipo de violación
|
||||
response_violated = ticket.first_response_at is None and sla_response_due and now > sla_response_due
|
||||
@@ -479,10 +477,16 @@ async def get_sla_violations(
|
||||
resolved_at=ticket.resolved_at
|
||||
))
|
||||
|
||||
# Ordenar por tiempo vencido (de mayor a menor)
|
||||
violations.sort(key=lambda v: v.hours_overdue, reverse=True)
|
||||
|
||||
# Aplicar paginación en Python
|
||||
paginated_violations = violations[skip:skip + limit]
|
||||
|
||||
total_pages = (total + limit - 1) // limit
|
||||
|
||||
return SLAViolationsListResponse(
|
||||
violations=violations,
|
||||
violations=paginated_violations,
|
||||
total=total,
|
||||
page=(skip // limit) + 1,
|
||||
per_page=limit,
|
||||
@@ -515,13 +519,16 @@ async def get_tickets_at_risk(
|
||||
threshold=threshold
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
db_now = func.now()
|
||||
threshold_decimal = threshold / 100.0
|
||||
|
||||
# Query para tickets en riesgo
|
||||
# Query para tickets en riesgo con relaciones precargadas
|
||||
# Un ticket está en riesgo si: (now - created_at) / (due_at - created_at) >= threshold
|
||||
query = select(Ticket).where(
|
||||
query = select(Ticket).options(
|
||||
selectinload(Ticket.assigned_to_user),
|
||||
selectinload(Ticket.category)
|
||||
).where(
|
||||
and_(
|
||||
Ticket.tenant_id == current_tenant.id,
|
||||
Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED]),
|
||||
@@ -576,14 +583,15 @@ async def get_tickets_at_risk(
|
||||
else:
|
||||
continue
|
||||
|
||||
# Normalizar created_at a timezone-naive para evitar errores de comparación
|
||||
created_at = ticket.created_at.replace(tzinfo=None) if ticket.created_at.tzinfo else ticket.created_at
|
||||
|
||||
time_remaining = (due_at - now).total_seconds() / 3600
|
||||
total_time = (due_at - ticket.created_at).total_seconds() / 3600
|
||||
total_time = (due_at - created_at).total_seconds() / 3600
|
||||
elapsed_time = total_time - time_remaining
|
||||
risk_percentage = (elapsed_time / total_time * 100) if total_time > 0 else 0
|
||||
|
||||
# Cargar relaciones
|
||||
await db.refresh(ticket, ['assigned_to', 'category'])
|
||||
|
||||
# Las relaciones ya están cargadas por selectinload
|
||||
at_risk_tickets.append(SLATicketAtRisk(
|
||||
ticket=TicketBasicInfo(
|
||||
id=ticket.id,
|
||||
@@ -599,11 +607,11 @@ async def get_tickets_at_risk(
|
||||
sla_resolution_hours=ticket.category.sla_resolution_hours
|
||||
) if ticket.category else None,
|
||||
assigned_to=UserBasicInfo(
|
||||
id=ticket.assigned_to.id,
|
||||
first_name=ticket.assigned_to.first_name,
|
||||
last_name=ticket.assigned_to.last_name,
|
||||
email=ticket.assigned_to.email
|
||||
) if ticket.assigned_to else None,
|
||||
id=ticket.assigned_to_user.id,
|
||||
first_name=ticket.assigned_to_user.first_name,
|
||||
last_name=ticket.assigned_to_user.last_name,
|
||||
email=ticket.assigned_to_user.email
|
||||
) if ticket.assigned_to_user else None,
|
||||
sla_type=sla_type,
|
||||
sla_due_at=due_at,
|
||||
time_remaining_hours=time_remaining,
|
||||
|
||||
@@ -28,14 +28,36 @@ async def create_tenant(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(deps.get_current_active_superuser)
|
||||
):
|
||||
from app.models.permission import TenantPermission, DEFAULT_PERMISSIONS
|
||||
from datetime import datetime
|
||||
|
||||
# Check existing slug
|
||||
query = select(Tenant).where(Tenant.slug == tenant.slug)
|
||||
result = await db.execute(query)
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Tenant slug already exists")
|
||||
|
||||
db_tenant = Tenant(**tenant.model_dump())
|
||||
data = tenant.model_dump()
|
||||
data['slug'] = data['slug'].lower().strip()
|
||||
db_tenant = Tenant(**data)
|
||||
db.add(db_tenant)
|
||||
await db.flush() # genera el id sin hacer commit
|
||||
|
||||
# Inicializar permisos por defecto para el nuevo tenant
|
||||
now = datetime.utcnow()
|
||||
for role, perms in DEFAULT_PERMISSIONS.items():
|
||||
for permission, granted in perms.items():
|
||||
db.add(TenantPermission(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=db_tenant.id,
|
||||
user_id=None,
|
||||
role=role,
|
||||
permission=permission,
|
||||
granted=granted,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
))
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(db_tenant)
|
||||
return db_tenant
|
||||
|
||||
@@ -7,11 +7,13 @@ from sqlalchemy.orm import selectinload
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timedelta
|
||||
import uuid
|
||||
from app.models.issue import TicketIssue, ticket_issue_tagged_users
|
||||
from app.api.schemas.issue import IssueCreate, IssueResponse
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.api.deps import get_current_user, get_current_tenant
|
||||
from app.models.ticket import Ticket, TicketStatus, TicketPriority
|
||||
from app.models.user import User
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.category import Category
|
||||
from app.models.system import System
|
||||
@@ -19,7 +21,7 @@ from app.models.comment import TicketComment
|
||||
from app.models.attachment import TicketAttachment
|
||||
from app.api.schemas.attachment import AttachmentResponse
|
||||
from app.api.schemas.ticket import (
|
||||
TicketCreate, TicketUpdate, TicketResponse,
|
||||
TicketCreate, TicketUpdate, TicketResponse,
|
||||
TicketCloseRequest, CommentCreate, CommentResponse
|
||||
)
|
||||
from app.core.file_handler import file_handler
|
||||
@@ -28,118 +30,68 @@ from app.api.v1.helpers import (
|
||||
safe_audit_log, generate_next_ticket_number, calculate_sla_deadlines, ticket_to_dict
|
||||
)
|
||||
from app.services.audit_service import AuditService
|
||||
from app.services.ticket_service import TicketService, get_ticket_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/", response_model=TicketResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_ticket(ticket: TicketCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
async def create_ticket(
|
||||
ticket: TicketCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
ticket_service: TicketService = Depends(get_ticket_service),
|
||||
):
|
||||
"""Crear un nuevo ticket"""
|
||||
max_retries = 3
|
||||
last_error = None
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
ticket_number = await generate_next_ticket_number(db, current_user.tenant_id)
|
||||
category_uuid = uuid.UUID(ticket.category_id) if ticket.category_id else None
|
||||
system_uuid = uuid.UUID(ticket.affected_system_id) if ticket.affected_system_id else None
|
||||
|
||||
category = None
|
||||
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.")
|
||||
|
||||
if system_uuid:
|
||||
system = await db.get(System, system_uuid)
|
||||
if not system:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"El sistema con ID {ticket.affected_system_id} no existe.")
|
||||
|
||||
sla_response_due, sla_resolution_due = calculate_sla_deadlines(category)
|
||||
assigned_to_user = category.auto_assign_to if category and category.auto_assign_to else None
|
||||
|
||||
db_ticket = Ticket(
|
||||
id=uuid.uuid4(), tenant_id=current_user.tenant_id, ticket_number=ticket_number,
|
||||
subject=ticket.subject, description=ticket.description, category_id=category_uuid,
|
||||
affected_system_id=system_uuid, priority=TicketPriority[ticket.priority.upper()],
|
||||
created_by=current_user.id, assigned_to=assigned_to_user, status=TicketStatus.NEW,
|
||||
sla_response_due=sla_response_due, sla_resolution_due=sla_resolution_due,
|
||||
created_at=datetime.utcnow(), updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
db.add(db_ticket)
|
||||
await db.commit()
|
||||
await db.refresh(db_ticket)
|
||||
|
||||
await safe_audit_log(db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
|
||||
action="ticket.create", resource_type="ticket", resource_id=db_ticket.id,
|
||||
new_values={"ticket_number": db_ticket.ticket_number, "subject": db_ticket.subject,
|
||||
"priority": db_ticket.priority.value, "status": db_ticket.status.value})
|
||||
|
||||
return {
|
||||
"id": str(db_ticket.id), "ticket_number": db_ticket.ticket_number, "subject": db_ticket.subject,
|
||||
"title": db_ticket.subject, "description": db_ticket.description, "status": db_ticket.status.value,
|
||||
"priority": db_ticket.priority.value, "category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
|
||||
"affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None,
|
||||
"created_by": str(db_ticket.created_by), "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
|
||||
"created_at": db_ticket.created_at, "updated_at": db_ticket.updated_at
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid UUID format: {str(e)}")
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
last_error = e
|
||||
if "duplicate key" in str(e).lower() and "ticket_number" in str(e).lower():
|
||||
if attempt < max_retries - 1:
|
||||
continue
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Error creating ticket: {str(e)}")
|
||||
|
||||
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)}")
|
||||
return await ticket_service.create_ticket(ticket, current_user.tenant_id, current_user.id)
|
||||
|
||||
|
||||
@router.get("/", response_model=List[TicketResponse])
|
||||
async def get_tickets(skip: int = 0, limit: int = 100, status: Optional[str] = None, priority: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
async def get_tickets(
|
||||
skip: int = 0, limit: int = 100,
|
||||
status: Optional[str] = None, priority: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Obtener tickets con filtros opcionales"""
|
||||
query = select(Ticket).where(Ticket.tenant_id == current_user.tenant_id)
|
||||
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
|
||||
if current_user.role == UserRole.CLIENT_USER:
|
||||
query = query.where(Ticket.created_by == current_user.id)
|
||||
|
||||
|
||||
query = apply_enum_filter(query, Ticket.status, status, TicketStatus, "status")
|
||||
query = apply_enum_filter(query, Ticket.priority, priority, TicketPriority, "priority")
|
||||
query = query.options(
|
||||
selectinload(Ticket.category),
|
||||
selectinload(Ticket.affected_system),
|
||||
selectinload(Ticket.assigned_to_user)
|
||||
)
|
||||
query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit)
|
||||
|
||||
|
||||
result = await db.execute(query)
|
||||
tickets = result.scalars().all()
|
||||
|
||||
return [
|
||||
{"id": str(t.id), "ticket_number": t.ticket_number, "subject": t.subject, "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,
|
||||
"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, "sla_response_due": t.sla_response_due,
|
||||
"sla_resolution_due": t.sla_resolution_due, "first_response_at": t.first_response_at, "resolved_at": t.resolved_at}
|
||||
for t in tickets
|
||||
]
|
||||
return [ticket_to_dict(t) for t in tickets]
|
||||
|
||||
|
||||
@router.get("/admin/all", response_model=List[dict])
|
||||
async def get_all_tickets_admin(skip: int = 0, limit: int = 100, status_filter: Optional[str] = None,
|
||||
priority_filter: Optional[str] = None, tenant_id_filter: Optional[str] = None, category_filter: Optional[str] = None,
|
||||
assigned_to_filter: Optional[str] = None, search: Optional[str] = None, date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""Obtener todos los tickets de todos los tenants (solo para administradores)"""
|
||||
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
|
||||
async def get_all_tickets_admin(
|
||||
skip: int = 0, limit: int = 100,
|
||||
status_filter: Optional[str] = None, priority_filter: Optional[str] = None,
|
||||
tenant_id_filter: Optional[str] = None, category_filter: Optional[str] = None,
|
||||
assigned_to_filter: Optional[str] = None, search: Optional[str] = None,
|
||||
date_from: Optional[str] = None, date_to: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Obtener todos los tickets del tenant del administrador (ADMIN/SUPPORT_MANAGER)."""
|
||||
if current_user.role not in (UserRole.ADMIN, UserRole.SUPPORT_MANAGER):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No tienes permisos para acceder a esta función")
|
||||
|
||||
|
||||
query = select(Ticket, Tenant, User).join(Tenant, Ticket.tenant_id == Tenant.id).join(User, Ticket.created_by == User.id)
|
||||
|
||||
|
||||
if current_user.role == UserRole.SUPPORT_MANAGER:
|
||||
query = query.where(Ticket.tenant_id == current_user.tenant_id)
|
||||
|
||||
query = apply_enum_filter(query, Ticket.status, status_filter, TicketStatus, "status")
|
||||
query = apply_enum_filter(query, Ticket.priority, priority_filter, TicketPriority, "priority")
|
||||
query = apply_enum_filter(query, Ticket.priority, priority_filter, TicketPriority, "priority")
|
||||
if tenant_id_filter:
|
||||
query = query.where(Ticket.tenant_id == validate_uuid_param(tenant_id_filter, "tenant ID"))
|
||||
if category_filter:
|
||||
@@ -161,55 +113,86 @@ async def get_all_tickets_admin(skip: int = 0, limit: int = 100, status_filter:
|
||||
query = query.where(Ticket.created_at < date_to_parsed)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid date_to format. Use YYYY-MM-DD")
|
||||
|
||||
|
||||
query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit)
|
||||
result = await db.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
|
||||
category_ids = list({ticket.category_id for ticket, _, _ in rows if ticket.category_id})
|
||||
categories_map = {}
|
||||
if category_ids:
|
||||
from app.models.category import Category as CategoryModel
|
||||
cat_result = await db.execute(select(CategoryModel).where(CategoryModel.id.in_(category_ids)))
|
||||
categories_map = {c.id: c.name for c in cat_result.scalars().all()}
|
||||
|
||||
return [
|
||||
{"id": str(ticket.id), "ticket_number": ticket.ticket_number, "subject": ticket.subject,
|
||||
"description": ticket.description, "status": ticket.status.value, "priority": ticket.priority.value,
|
||||
"tenant_id": str(ticket.tenant_id), "tenant_name": tenant.name, "tenant_slug": tenant.slug,
|
||||
"category_id": str(ticket.category_id) if ticket.category_id else None,
|
||||
"category_name": categories_map.get(ticket.category_id) if ticket.category_id else None,
|
||||
"created_by": str(ticket.created_by), "creator_name": f"{creator.first_name} {creator.last_name}",
|
||||
"creator_email": creator.email, "assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None,
|
||||
"created_at": ticket.created_at, "updated_at": ticket.updated_at, "sla_response_due": ticket.sla_response_due,
|
||||
"sla_resolution_due": ticket.sla_resolution_due, "first_response_at": ticket.first_response_at,
|
||||
"resolved_at": ticket.resolved_at}
|
||||
"created_at": ticket.created_at, "updated_at": ticket.updated_at,
|
||||
"sla_response_due": ticket.sla_response_due, "sla_resolution_due": ticket.sla_resolution_due,
|
||||
"first_response_at": ticket.first_response_at, "resolved_at": ticket.resolved_at}
|
||||
for ticket, tenant, creator in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{ticket_id}", response_model=TicketResponse)
|
||||
async def get_ticket(ticket_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
async def get_ticket(
|
||||
ticket_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Obtener un ticket por ID"""
|
||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||
query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id)
|
||||
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
|
||||
query = select(Ticket).where(Ticket.id == ticket_uuid)
|
||||
if current_user.role != UserRole.ADMIN:
|
||||
query = query.where(Ticket.tenant_id == current_user.tenant_id)
|
||||
if current_user.role.is_client:
|
||||
query = query.where(Ticket.created_by == current_user.id)
|
||||
|
||||
query = query.options(selectinload(Ticket.category), selectinload(Ticket.affected_system), selectinload(Ticket.assigned_to_user))
|
||||
|
||||
query = query.options(
|
||||
selectinload(Ticket.category),
|
||||
selectinload(Ticket.affected_system),
|
||||
selectinload(Ticket.assigned_to_user)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
db_ticket = result.scalars().first()
|
||||
|
||||
|
||||
if not db_ticket:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
|
||||
|
||||
|
||||
return ticket_to_dict(db_ticket)
|
||||
|
||||
|
||||
@router.patch("/{ticket_id}", response_model=TicketResponse)
|
||||
async def update_ticket(ticket_id: str, ticket: TicketUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
async def update_ticket(
|
||||
ticket_id: str, ticket: TicketUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Actualizar un ticket"""
|
||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||
query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id)
|
||||
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
|
||||
query = select(Ticket).where(Ticket.id == ticket_uuid)
|
||||
if current_user.role != UserRole.ADMIN:
|
||||
query = query.where(Ticket.tenant_id == current_user.tenant_id)
|
||||
if current_user.role.is_client:
|
||||
query = query.where(Ticket.created_by == current_user.id)
|
||||
|
||||
|
||||
result = await db.execute(query)
|
||||
db_ticket = result.scalars().first()
|
||||
if not db_ticket:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
|
||||
|
||||
old_values = {"status": db_ticket.status.value, "priority": db_ticket.priority.value, "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None}
|
||||
|
||||
|
||||
old_values = {
|
||||
"status": db_ticket.status.value,
|
||||
"priority": db_ticket.priority.value,
|
||||
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None
|
||||
}
|
||||
|
||||
update_data = ticket.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
if field == "status" and value:
|
||||
@@ -220,37 +203,48 @@ async def update_ticket(ticket_id: str, ticket: TicketUpdate, db: AsyncSession =
|
||||
setattr(db_ticket, field, uuid.UUID(value))
|
||||
elif value is not None:
|
||||
setattr(db_ticket, field, value)
|
||||
|
||||
|
||||
db_ticket.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(db_ticket, ["category", "affected_system", "assigned_to_user"])
|
||||
|
||||
new_values = {"status": db_ticket.status.value, "priority": db_ticket.priority.value, "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None}
|
||||
await safe_audit_log(db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
|
||||
|
||||
new_values = {
|
||||
"status": db_ticket.status.value,
|
||||
"priority": db_ticket.priority.value,
|
||||
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None
|
||||
}
|
||||
await safe_audit_log(
|
||||
db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
|
||||
action="ticket.update", resource_type="ticket", resource_id=db_ticket.id,
|
||||
old_values=old_values, new_values=new_values)
|
||||
|
||||
old_values=old_values, new_values=new_values
|
||||
)
|
||||
|
||||
return ticket_to_dict(db_ticket)
|
||||
|
||||
|
||||
@router.patch("/{ticket_id}/close", response_model=TicketResponse)
|
||||
async def close_ticket(ticket_id: str, close_request: TicketCloseRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
async def close_ticket(
|
||||
ticket_id: str, close_request: TicketCloseRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Cerrar un ticket"""
|
||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||
query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id)
|
||||
result = await db.execute(query)
|
||||
db_ticket = result.scalars().first()
|
||||
|
||||
|
||||
if not db_ticket:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
|
||||
|
||||
|
||||
if db_ticket.status in [TicketStatus.CLOSED, TicketStatus.RESOLVED]:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ticket ya está cerrado o resuelto")
|
||||
|
||||
|
||||
old_status = db_ticket.status.value
|
||||
db_ticket.status = TicketStatus.CLOSED
|
||||
db_ticket.resolved_at = datetime.utcnow()
|
||||
db_ticket.updated_at = datetime.utcnow()
|
||||
|
||||
|
||||
if close_request.resolution_notes:
|
||||
comment = TicketComment(
|
||||
id=uuid.uuid4(), ticket_id=ticket_uuid, author_id=current_user.id,
|
||||
@@ -258,194 +252,430 @@ async def close_ticket(ticket_id: str, close_request: TicketCloseRequest, db: As
|
||||
is_internal=False, created_at=datetime.utcnow(), updated_at=datetime.utcnow()
|
||||
)
|
||||
db.add(comment)
|
||||
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(db_ticket, ["category", "affected_system", "assigned_to_user"])
|
||||
|
||||
await safe_audit_log(db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
|
||||
|
||||
await safe_audit_log(
|
||||
db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
|
||||
action="ticket.close", resource_type="ticket", resource_id=db_ticket.id,
|
||||
old_values={"status": old_status}, new_values={"status": db_ticket.status.value, "resolution_notes": close_request.resolution_notes})
|
||||
|
||||
old_values={"status": old_status},
|
||||
new_values={"status": db_ticket.status.value, "resolution_notes": close_request.resolution_notes}
|
||||
)
|
||||
|
||||
return ticket_to_dict(db_ticket)
|
||||
|
||||
|
||||
@router.get("/{ticket_id}/comments", response_model=List[CommentResponse])
|
||||
async def get_ticket_comments(ticket_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
async def get_ticket_comments(
|
||||
ticket_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Obtener comentarios de un ticket"""
|
||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||
query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id)
|
||||
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
|
||||
query = select(Ticket).where(Ticket.id == ticket_uuid)
|
||||
if current_user.role != UserRole.ADMIN:
|
||||
query = query.where(Ticket.tenant_id == current_user.tenant_id)
|
||||
if current_user.role.is_client:
|
||||
query = query.where(Ticket.created_by == current_user.id)
|
||||
|
||||
|
||||
result = await db.execute(query)
|
||||
ticket_obj = result.scalars().first()
|
||||
if not ticket_obj:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
|
||||
|
||||
comments_query = select(TicketComment).where(TicketComment.ticket_id == ticket_uuid).options(selectinload(TicketComment.author)).order_by(TicketComment.created_at.desc())
|
||||
|
||||
comments_query = (
|
||||
select(TicketComment)
|
||||
.where(TicketComment.ticket_id == ticket_uuid)
|
||||
.options(selectinload(TicketComment.author))
|
||||
.order_by(TicketComment.created_at.desc())
|
||||
)
|
||||
result = await db.execute(comments_query)
|
||||
comments = result.scalars().all()
|
||||
|
||||
|
||||
return [
|
||||
{"id": str(c.id), "ticket_id": str(c.ticket_id), "author_id": str(c.author_id),
|
||||
"author_name": f"{c.author.first_name} {c.author.last_name}" if c.author else "Unknown",
|
||||
"content": c.content, "is_internal": c.is_internal, "created_at": c.created_at, "updated_at": c.updated_at}
|
||||
"content": c.content, "is_internal": c.is_internal,
|
||||
"created_at": c.created_at, "updated_at": c.updated_at}
|
||||
for c in comments
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_comment(ticket_id: str, comment: CommentCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
async def create_comment(
|
||||
ticket_id: str, comment: CommentCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Crear un comentario en un ticket"""
|
||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||
query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id)
|
||||
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
|
||||
if current_user.role.is_client:
|
||||
query = query.where(Ticket.created_by == current_user.id)
|
||||
|
||||
|
||||
result = await db.execute(query)
|
||||
ticket_obj = result.scalars().first()
|
||||
if not ticket_obj:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
|
||||
|
||||
|
||||
# Fix is_internal — clientes no pueden crear comentarios internos
|
||||
if comment.is_internal and current_user.role.is_client:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Clientes no pueden crear comentarios internos"
|
||||
)
|
||||
|
||||
new_comment = TicketComment(
|
||||
id=uuid.uuid4(), ticket_id=ticket_uuid, author_id=current_user.id,
|
||||
content=comment.content, is_internal=comment.is_internal,
|
||||
created_at=datetime.utcnow(), updated_at=datetime.utcnow()
|
||||
)
|
||||
db.add(new_comment)
|
||||
|
||||
|
||||
staff_roles = ["ADMIN", "SUPPORT_MANAGER", "AGENT"]
|
||||
if current_user.role in staff_roles and not comment.is_internal and ticket_obj.first_response_at is None:
|
||||
ticket_obj.first_response_at = datetime.utcnow()
|
||||
|
||||
|
||||
ticket_obj.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(new_comment)
|
||||
|
||||
|
||||
return {
|
||||
"id": str(new_comment.id), "ticket_id": str(new_comment.ticket_id), "author_id": str(new_comment.author_id),
|
||||
"id": str(new_comment.id), "ticket_id": str(new_comment.ticket_id),
|
||||
"author_id": str(new_comment.author_id),
|
||||
"author_name": f"{current_user.first_name} {current_user.last_name}",
|
||||
"content": new_comment.content, "is_internal": new_comment.is_internal,
|
||||
"created_at": new_comment.created_at, "updated_at": new_comment.updated_at
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{ticket_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_ticket(ticket_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
async def delete_ticket(
|
||||
ticket_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Eliminar un ticket (solo admin/manager)"""
|
||||
if current_user.role not in (UserRole.ADMIN, UserRole.SUPPORT_MANAGER):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="No tienes permisos para eliminar tickets"
|
||||
)
|
||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||
query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id)
|
||||
result = await db.execute(query)
|
||||
db_ticket = result.scalars().first()
|
||||
|
||||
|
||||
if not db_ticket:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
|
||||
|
||||
old_values = {"ticket_number": db_ticket.ticket_number, "subject": db_ticket.subject,
|
||||
"status": db_ticket.status.value, "priority": db_ticket.priority.value}
|
||||
|
||||
|
||||
old_values = {
|
||||
"ticket_number": db_ticket.ticket_number, "subject": db_ticket.subject,
|
||||
"status": db_ticket.status.value, "priority": db_ticket.priority.value
|
||||
}
|
||||
|
||||
await db.delete(db_ticket)
|
||||
await db.commit()
|
||||
|
||||
await safe_audit_log(db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
|
||||
action="ticket.delete", resource_type="ticket", resource_id=ticket_uuid, old_values=old_values)
|
||||
|
||||
|
||||
await safe_audit_log(
|
||||
db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
|
||||
action="ticket.delete", resource_type="ticket", resource_id=ticket_uuid,
|
||||
old_values=old_values
|
||||
)
|
||||
|
||||
return {"message": "Ticket deleted successfully"}
|
||||
|
||||
|
||||
@router.get("/{ticket_id}/attachments", response_model=List[AttachmentResponse])
|
||||
async def get_ticket_attachments(ticket_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), current_tenant: Tenant = Depends(get_current_tenant)):
|
||||
async def get_ticket_attachments(
|
||||
ticket_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_tenant: Tenant = Depends(get_current_tenant)
|
||||
):
|
||||
"""Obtener adjuntos de un ticket"""
|
||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||
result = await db.execute(select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_tenant.id))
|
||||
|
||||
query = select(Ticket).where(Ticket.id == ticket_uuid)
|
||||
if current_user.role != UserRole.ADMIN:
|
||||
query = query.where(Ticket.tenant_id == current_tenant.id)
|
||||
result = await db.execute(query)
|
||||
ticket = result.scalar_one_or_none()
|
||||
|
||||
|
||||
if not ticket:
|
||||
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
||||
|
||||
result = await db.execute(select(TicketAttachment).where(TicketAttachment.ticket_id == ticket_uuid).options(selectinload(TicketAttachment.uploaded_by_user)).order_by(TicketAttachment.created_at.desc()))
|
||||
|
||||
if current_user.role.is_client and ticket.created_by != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
||||
|
||||
result = await db.execute(
|
||||
select(TicketAttachment)
|
||||
.where(TicketAttachment.ticket_id == ticket_uuid)
|
||||
.options(selectinload(TicketAttachment.uploaded_by_user))
|
||||
.order_by(TicketAttachment.created_at.desc())
|
||||
)
|
||||
attachments = result.scalars().all()
|
||||
|
||||
|
||||
return [
|
||||
AttachmentResponse(
|
||||
id=att.id, ticket_id=att.ticket_id, comment_id=att.comment_id, uploaded_by=att.uploaded_by,
|
||||
filename=att.filename, original_filename=att.original_filename, mime_type=att.mime_type,
|
||||
id=att.id, ticket_id=att.ticket_id, comment_id=att.comment_id,
|
||||
uploaded_by=att.uploaded_by, filename=att.filename,
|
||||
original_filename=att.original_filename, mime_type=att.mime_type,
|
||||
file_size=att.file_size, file_path=att.file_path,
|
||||
uploaded_by_name=f"{att.uploaded_by_user.first_name} {att.uploaded_by_user.last_name}" if att.uploaded_by_user else "Unknown",
|
||||
created_at=att.created_at, download_url=f"/api/v1/tickets/{ticket_id}/attachments/{att.id}/download"
|
||||
created_at=att.created_at,
|
||||
download_url=f"/api/v1/tickets/{ticket_id}/attachments/{att.id}/download"
|
||||
) for att in attachments
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/attachments", status_code=status.HTTP_201_CREATED)
|
||||
async def upload_attachment(ticket_id: str, file: UploadFile = File(...), db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user), current_tenant: Tenant = Depends(get_current_tenant)):
|
||||
async def upload_attachment(
|
||||
ticket_id: str, file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_tenant: Tenant = Depends(get_current_tenant)
|
||||
):
|
||||
"""Subir un archivo adjunto a un ticket"""
|
||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||
result = await db.execute(select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_tenant.id))
|
||||
|
||||
query = select(Ticket).where(Ticket.id == ticket_uuid)
|
||||
if current_user.role != UserRole.ADMIN:
|
||||
query = query.where(Ticket.tenant_id == current_tenant.id)
|
||||
result = await db.execute(query)
|
||||
ticket = result.scalar_one_or_none()
|
||||
|
||||
|
||||
if not ticket:
|
||||
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
||||
|
||||
file_metadata = await file_handler.save_upload(file, current_tenant.id, ticket_uuid)
|
||||
|
||||
|
||||
if current_user.role.is_client and ticket.created_by != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
||||
|
||||
# Para el file_handler usamos el tenant real del ticket
|
||||
upload_tenant_id = ticket.tenant_id
|
||||
file_metadata = await file_handler.save_upload(file, upload_tenant_id, ticket_uuid)
|
||||
|
||||
attachment = TicketAttachment(
|
||||
id=uuid.uuid4(), ticket_id=ticket_uuid, uploaded_by=current_user.id, filename=file_metadata["filename"],
|
||||
original_filename=file_metadata["original_filename"], mime_type=file_metadata["mime_type"],
|
||||
file_size=file_metadata["file_size"], file_path=file_metadata["file_path"],
|
||||
md5_hash=file_metadata["md5_hash"], sha256_hash=file_metadata["sha256_hash"], created_at=datetime.utcnow()
|
||||
id=uuid.uuid4(), ticket_id=ticket_uuid, uploaded_by=current_user.id,
|
||||
filename=file_metadata["filename"], original_filename=file_metadata["original_filename"],
|
||||
mime_type=file_metadata["mime_type"], file_size=file_metadata["file_size"],
|
||||
file_path=file_metadata["file_path"], md5_hash=file_metadata["md5_hash"],
|
||||
sha256_hash=file_metadata["sha256_hash"], created_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
|
||||
db.add(attachment)
|
||||
await db.commit()
|
||||
await db.refresh(attachment, ["uploaded_by_user"])
|
||||
|
||||
|
||||
return {
|
||||
"success": True, "message": "Archivo subido exitosamente",
|
||||
"data": AttachmentResponse(
|
||||
id=attachment.id, ticket_id=attachment.ticket_id, comment_id=attachment.comment_id,
|
||||
uploaded_by=attachment.uploaded_by, filename=attachment.filename, original_filename=attachment.original_filename,
|
||||
mime_type=attachment.mime_type, file_size=attachment.file_size, file_path=attachment.file_path,
|
||||
uploaded_by=attachment.uploaded_by, filename=attachment.filename,
|
||||
original_filename=attachment.original_filename, mime_type=attachment.mime_type,
|
||||
file_size=attachment.file_size, file_path=attachment.file_path,
|
||||
uploaded_by_name=f"{attachment.uploaded_by_user.first_name} {attachment.uploaded_by_user.last_name}",
|
||||
created_at=attachment.created_at, download_url=f"/api/v1/tickets/{ticket_id}/attachments/{attachment.id}/download"
|
||||
created_at=attachment.created_at,
|
||||
download_url=f"/api/v1/tickets/{ticket_id}/attachments/{attachment.id}/download"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{ticket_id}/attachments/{attachment_id}/download")
|
||||
async def download_attachment(ticket_id: str, attachment_id: str, db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user), current_tenant: Tenant = Depends(get_current_tenant)):
|
||||
async def download_attachment(
|
||||
ticket_id: str, attachment_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_tenant: Tenant = Depends(get_current_tenant)
|
||||
):
|
||||
"""Descargar un archivo adjunto"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger.info(f"Download request - ticket_id: {ticket_id}, attachment_id: {attachment_id}")
|
||||
|
||||
|
||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||
attachment_uuid = validate_uuid_param(attachment_id, "attachment ID")
|
||||
|
||||
result = await db.execute(select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_tenant.id))
|
||||
|
||||
query = select(Ticket).where(Ticket.id == ticket_uuid)
|
||||
if current_user.role != UserRole.ADMIN:
|
||||
query = query.where(Ticket.tenant_id == current_tenant.id)
|
||||
result = await db.execute(query)
|
||||
ticket = result.scalar_one_or_none()
|
||||
|
||||
|
||||
if not ticket:
|
||||
logger.error(f"Ticket not found - ticket_id: {ticket_id}")
|
||||
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
||||
|
||||
result = await db.execute(select(TicketAttachment).where(TicketAttachment.id == attachment_uuid, TicketAttachment.ticket_id == ticket_uuid))
|
||||
|
||||
if current_user.role.is_client and ticket.created_by != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
||||
|
||||
result = await db.execute(
|
||||
select(TicketAttachment).where(
|
||||
TicketAttachment.id == attachment_uuid,
|
||||
TicketAttachment.ticket_id == ticket_uuid
|
||||
)
|
||||
)
|
||||
attachment = result.scalar_one_or_none()
|
||||
|
||||
|
||||
if not attachment:
|
||||
logger.error(f"Attachment not found - attachment_id: {attachment_id}")
|
||||
raise HTTPException(status_code=404, detail="Adjunto no encontrado")
|
||||
|
||||
logger.info(f"Attachment found - file_path: {attachment.file_path}, original_filename: {attachment.original_filename}")
|
||||
|
||||
|
||||
try:
|
||||
file_path = file_handler.get_file_path(attachment.file_path)
|
||||
logger.info(f"Absolute file path: {file_path}")
|
||||
|
||||
if not file_path.exists():
|
||||
logger.error(f"File does not exist at path: {file_path}")
|
||||
raise HTTPException(status_code=404, detail="Archivo no encontrado en el sistema")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting file path: {str(e)}")
|
||||
raise
|
||||
|
||||
logger.info(f"Returning file: {attachment.original_filename}")
|
||||
return FileResponse(path=file_path, filename=attachment.original_filename, media_type=attachment.mime_type)
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename=attachment.original_filename,
|
||||
media_type=attachment.mime_type
|
||||
)
|
||||
|
||||
@router.get("/{ticket_id}/issues", response_model=List[IssueResponse])
|
||||
async def get_ticket_issues(
|
||||
ticket_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Obtener asuntos de un ticket."""
|
||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||
|
||||
query = select(Ticket).where(Ticket.id == ticket_uuid)
|
||||
if current_user.role != UserRole.ADMIN:
|
||||
query = query.where(Ticket.tenant_id == current_user.tenant_id)
|
||||
if current_user.role.is_client:
|
||||
query = query.where(Ticket.created_by == current_user.id)
|
||||
|
||||
result = await db.execute(query)
|
||||
ticket_obj = result.scalars().first()
|
||||
if not ticket_obj:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ticket no encontrado")
|
||||
|
||||
issues_query = (
|
||||
select(TicketIssue)
|
||||
.where(TicketIssue.ticket_id == ticket_uuid)
|
||||
.options(
|
||||
selectinload(TicketIssue.created_by_user),
|
||||
selectinload(TicketIssue.tagged_users),
|
||||
)
|
||||
.order_by(TicketIssue.created_at.desc())
|
||||
)
|
||||
result = await db.execute(issues_query)
|
||||
issues = result.scalars().all()
|
||||
|
||||
return [
|
||||
IssueResponse(
|
||||
id=issue.id, ticket_id=issue.ticket_id, tenant_id=issue.tenant_id,
|
||||
content=issue.content, priority=issue.priority.value,
|
||||
created_by=issue.created_by,
|
||||
created_by_name=f"{issue.created_by_user.first_name} {issue.created_by_user.last_name}",
|
||||
tagged_users=[
|
||||
{"id": u.id, "full_name": f"{u.first_name} {u.last_name}", "email": u.email}
|
||||
for u in issue.tagged_users
|
||||
],
|
||||
attachment_filename=issue.attachment_filename,
|
||||
attachment_mime_type=issue.attachment_mime_type,
|
||||
created_at=issue.created_at, updated_at=issue.updated_at,
|
||||
)
|
||||
for issue in issues
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/issues", response_model=IssueResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_ticket_issue(
|
||||
ticket_id: str,
|
||||
issue: IssueCreate,
|
||||
file: Optional[UploadFile] = File(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
):
|
||||
"""Crear un asunto de escalación en un ticket."""
|
||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||
|
||||
result = await db.execute(
|
||||
select(Ticket).where(
|
||||
Ticket.id == ticket_uuid,
|
||||
Ticket.tenant_id == current_user.tenant_id,
|
||||
)
|
||||
)
|
||||
ticket_obj = result.scalars().first()
|
||||
if not ticket_obj:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ticket no encontrado")
|
||||
|
||||
is_ticket_owner = ticket_obj.created_by == current_user.id
|
||||
is_client_admin = current_user.role == UserRole.CLIENT_ADMIN
|
||||
is_staff = current_user.role.is_global
|
||||
|
||||
if not is_ticket_owner and not is_client_admin and not is_staff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Solo el creador del ticket o el administrador pueden crear asuntos",
|
||||
)
|
||||
|
||||
tagged_users = []
|
||||
if issue.tagged_user_ids:
|
||||
result = await db.execute(
|
||||
select(User).where(
|
||||
User.id.in_(issue.tagged_user_ids),
|
||||
User.is_active == True,
|
||||
)
|
||||
)
|
||||
tagged_users = result.scalars().all()
|
||||
|
||||
found_ids = {u.id for u in tagged_users}
|
||||
missing = [str(uid) for uid in issue.tagged_user_ids if uid not in found_ids]
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Usuarios no encontrados: {missing}",
|
||||
)
|
||||
|
||||
attachment_path = None
|
||||
attachment_filename = None
|
||||
attachment_mime_type = None
|
||||
if file and file.filename:
|
||||
file_metadata = await file_handler.save_upload(file, current_tenant.id, ticket_uuid)
|
||||
attachment_path = file_metadata["file_path"]
|
||||
attachment_filename = file_metadata["original_filename"]
|
||||
attachment_mime_type = file_metadata["mime_type"]
|
||||
|
||||
new_issue = TicketIssue(
|
||||
id=uuid.uuid4(), ticket_id=ticket_uuid,
|
||||
tenant_id=current_user.tenant_id, created_by=current_user.id,
|
||||
content=issue.content, priority=TicketPriority[issue.priority],
|
||||
attachment_path=attachment_path, attachment_filename=attachment_filename,
|
||||
attachment_mime_type=attachment_mime_type,
|
||||
created_at=datetime.utcnow(), updated_at=datetime.utcnow(),
|
||||
)
|
||||
new_issue.tagged_users = tagged_users
|
||||
db.add(new_issue)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(new_issue, ["created_by_user", "tagged_users"])
|
||||
|
||||
await safe_audit_log(
|
||||
db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
|
||||
action="ticket.issue.create", resource_type="ticket_issue", resource_id=new_issue.id,
|
||||
new_values={
|
||||
"ticket_id": str(ticket_uuid), "priority": issue.priority,
|
||||
"tagged_users": [str(uid) for uid in issue.tagged_user_ids],
|
||||
},
|
||||
)
|
||||
|
||||
return IssueResponse(
|
||||
id=new_issue.id, ticket_id=new_issue.ticket_id, tenant_id=new_issue.tenant_id,
|
||||
content=new_issue.content, priority=new_issue.priority.value,
|
||||
created_by=new_issue.created_by,
|
||||
created_by_name=f"{new_issue.created_by_user.first_name} {new_issue.created_by_user.last_name}",
|
||||
tagged_users=[
|
||||
{"id": u.id, "full_name": f"{u.first_name} {u.last_name}", "email": u.email}
|
||||
for u in new_issue.tagged_users
|
||||
],
|
||||
attachment_filename=new_issue.attachment_filename,
|
||||
attachment_mime_type=new_issue.attachment_mime_type,
|
||||
created_at=new_issue.created_at, updated_at=new_issue.updated_at,
|
||||
)
|
||||
@@ -19,6 +19,14 @@ router = APIRouter()
|
||||
# ENDPOINTS
|
||||
# ===================================
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def read_current_user(
|
||||
current_user: User = Depends(deps.get_current_user),
|
||||
):
|
||||
"""Obtener el perfil del usuario actual."""
|
||||
return current_user
|
||||
|
||||
@router.get("/", response_model=List[UserResponse])
|
||||
async def read_users(
|
||||
skip: int = 0,
|
||||
@@ -37,8 +45,12 @@ async def read_users(
|
||||
- role: filtrar por rol
|
||||
- is_active: filtrar por estado activo
|
||||
"""
|
||||
# ✅ CORREGIDO: Filtrar por tenant_id
|
||||
query = select(User).where(User.tenant_id == current_user.tenant_id)
|
||||
# ADMIN global ve todos los tenants; el resto solo ve su propio tenant
|
||||
from app.models.user import UserRole as _UserRole
|
||||
if current_user.role != _UserRole.ADMIN:
|
||||
query = select(User).where(User.tenant_id == current_user.tenant_id)
|
||||
else:
|
||||
query = select(User)
|
||||
|
||||
# Aplicar filtros opcionales
|
||||
if role:
|
||||
@@ -136,10 +148,13 @@ async def read_user(
|
||||
|
||||
✅ Implementa multi-tenancy: solo permite acceso a usuarios del propio tenant.
|
||||
"""
|
||||
query = select(User).where(
|
||||
User.id == user_id,
|
||||
User.tenant_id == current_user.tenant_id # ✅ Seguridad multi-tenant
|
||||
)
|
||||
if current_user.role.value == 'ADMIN':
|
||||
query = select(User).where(User.id == user_id)
|
||||
else:
|
||||
query = select(User).where(
|
||||
User.id == user_id,
|
||||
User.tenant_id == current_user.tenant_id
|
||||
)
|
||||
result = await db.execute(query)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
@@ -175,11 +190,14 @@ async def update_user(
|
||||
detail="You don't have permission to update users"
|
||||
)
|
||||
|
||||
# Buscar usuario
|
||||
query = select(User).where(
|
||||
User.id == user_id,
|
||||
User.tenant_id == current_user.tenant_id
|
||||
)
|
||||
# Buscar usuario - ADMIN global puede editar cualquier tenant
|
||||
if current_user.role.value == "ADMIN":
|
||||
query = select(User).where(User.id == user_id)
|
||||
else:
|
||||
query = select(User).where(
|
||||
User.id == user_id,
|
||||
User.tenant_id == current_user.tenant_id
|
||||
)
|
||||
result = await db.execute(query)
|
||||
db_user = result.scalar_one_or_none()
|
||||
|
||||
@@ -268,8 +286,9 @@ async def delete_user(
|
||||
- No se puede eliminar a sí mismo
|
||||
- No se puede eliminar el último ADMIN del tenant
|
||||
"""
|
||||
# Verificar permisos - solo ADMIN puede eliminar
|
||||
if current_user.role != UserRole.ADMIN:
|
||||
# Verificar permisos - ADMIN global o CLIENT_ADMIN pueden eliminar
|
||||
allowed = [UserRole.ADMIN, UserRole.CLIENT_ADMIN]
|
||||
if current_user.role not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only admins can delete users"
|
||||
@@ -282,11 +301,14 @@ async def delete_user(
|
||||
detail="You cannot delete yourself"
|
||||
)
|
||||
|
||||
# Buscar usuario
|
||||
query = select(User).where(
|
||||
User.id == user_id,
|
||||
User.tenant_id == current_user.tenant_id
|
||||
)
|
||||
# Buscar usuario - ADMIN global puede editar cualquier tenant
|
||||
if current_user.role.value == "ADMIN":
|
||||
query = select(User).where(User.id == user_id)
|
||||
else:
|
||||
query = select(User).where(
|
||||
User.id == user_id,
|
||||
User.tenant_id == current_user.tenant_id
|
||||
)
|
||||
result = await db.execute(query)
|
||||
db_user = result.scalar_one_or_none()
|
||||
|
||||
@@ -313,8 +335,8 @@ async def delete_user(
|
||||
detail="Cannot delete the last active admin of the tenant"
|
||||
)
|
||||
|
||||
# Soft delete
|
||||
db_user.is_active = False
|
||||
# Hard delete
|
||||
await db.delete(db_user)
|
||||
await db.commit()
|
||||
|
||||
# Registrar eliminación en auditoría
|
||||
@@ -359,11 +381,14 @@ async def activate_user(
|
||||
detail="You don't have permission to activate users"
|
||||
)
|
||||
|
||||
# Buscar usuario
|
||||
query = select(User).where(
|
||||
User.id == user_id,
|
||||
User.tenant_id == current_user.tenant_id
|
||||
)
|
||||
# Buscar usuario - ADMIN global puede editar cualquier tenant
|
||||
if current_user.role.value == "ADMIN":
|
||||
query = select(User).where(User.id == user_id)
|
||||
else:
|
||||
query = select(User).where(
|
||||
User.id == user_id,
|
||||
User.tenant_id == current_user.tenant_id
|
||||
)
|
||||
result = await db.execute(query)
|
||||
db_user = result.scalar_one_or_none()
|
||||
|
||||
|
||||
@@ -68,11 +68,11 @@ async def generate_next_ticket_number(db: AsyncSession, tenant_id: uuid.UUID) ->
|
||||
last_ticket_number = result.scalar_one_or_none()
|
||||
|
||||
if last_ticket_number:
|
||||
last_number = int(last_ticket_number.split('-')[1])
|
||||
last_number = int(last_ticket_number.split('-')[-1])
|
||||
next_number = last_number + 1
|
||||
else:
|
||||
next_number = 1
|
||||
|
||||
|
||||
return f"TK-{next_number:06d}"
|
||||
|
||||
|
||||
@@ -100,7 +100,10 @@ def ticket_to_dict(ticket: Ticket) -> dict:
|
||||
"category_id": str(ticket.category_id) if ticket.category_id else None,
|
||||
"category_name": ticket.category.name if ticket.category else None,
|
||||
"affected_system_id": str(ticket.affected_system_id) if ticket.affected_system_id else None,
|
||||
"system_id": str(ticket.affected_system_id) if ticket.affected_system_id else None,
|
||||
"affected_system_name": ticket.affected_system.name if ticket.affected_system else None,
|
||||
"contact_email": None,
|
||||
"contact_phone": None,
|
||||
"created_by": str(ticket.created_by),
|
||||
"assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None,
|
||||
"assigned_to_name": f"{ticket.assigned_to_user.first_name} {ticket.assigned_to_user.last_name}" if ticket.assigned_to_user else None,
|
||||
|
||||
@@ -6,7 +6,7 @@ Router principal para la API v1
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla
|
||||
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla, reports, participants
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -53,6 +53,12 @@ api_router.include_router(
|
||||
prefix="/tickets",
|
||||
tags=["tickets"]
|
||||
)
|
||||
# Participants routes (nested under tickets)
|
||||
api_router.include_router(
|
||||
participants.router,
|
||||
prefix="/tickets",
|
||||
tags=["participants"]
|
||||
)
|
||||
|
||||
# Client Profile routes
|
||||
api_router.include_router(
|
||||
@@ -73,4 +79,11 @@ api_router.include_router(
|
||||
sla.router,
|
||||
prefix="/sla",
|
||||
tags=["sla"]
|
||||
)
|
||||
|
||||
# Reports routes
|
||||
api_router.include_router(
|
||||
reports.router,
|
||||
prefix="/reports",
|
||||
tags=["reports"]
|
||||
)
|
||||
@@ -5,7 +5,6 @@ Configuración centralizada usando Pydantic Settings v2
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import List, Optional
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import field_validator, Field
|
||||
import os
|
||||
@@ -24,6 +23,7 @@ class Settings(BaseSettings):
|
||||
# GENERAL
|
||||
# ===================================
|
||||
ENVIRONMENT: str = Field(default="development")
|
||||
TESTING: bool = Field(default=False)
|
||||
DEBUG: bool = Field(default=False)
|
||||
SECRET_KEY: str = Field(...)
|
||||
API_VERSION: str = Field(default="v1")
|
||||
@@ -59,8 +59,8 @@ class Settings(BaseSettings):
|
||||
# ===================================
|
||||
SMTP_HOST: str = Field(default="localhost")
|
||||
SMTP_PORT: int = Field(default=587)
|
||||
SMTP_USER: Optional[str] = Field(default=None)
|
||||
SMTP_PASSWORD: Optional[str] = Field(default=None)
|
||||
SMTP_USER: str | None = Field(default=None)
|
||||
SMTP_PASSWORD: str | None = Field(default=None)
|
||||
SMTP_USE_TLS: bool = Field(default=True)
|
||||
SMTP_USE_SSL: bool = Field(default=False)
|
||||
|
||||
@@ -78,7 +78,7 @@ class Settings(BaseSettings):
|
||||
UPLOAD_PATH: str = Field(default="/app/uploads")
|
||||
|
||||
@property
|
||||
def ALLOWED_FILE_EXTENSIONS(self) -> List[str]:
|
||||
def ALLOWED_FILE_EXTENSIONS(self) -> list[str]:
|
||||
"""Parse the comma-separated file extensions."""
|
||||
return [ext.strip().lower() for ext in self.ALLOWED_FILE_EXTENSIONS_STR.split(",")]
|
||||
|
||||
@@ -86,6 +86,9 @@ class Settings(BaseSettings):
|
||||
# SECURITY
|
||||
# ===================================
|
||||
RATE_LIMIT_ENABLED: bool = Field(default=True)
|
||||
LOGIN_RATE_LIMIT_WINDOW_SECONDS: int = Field(default=300)
|
||||
LOGIN_RATE_LIMIT_IP_MAX_ATTEMPTS: int = Field(default=30)
|
||||
LOGIN_RATE_LIMIT_ID_MAX_ATTEMPTS: int = Field(default=10)
|
||||
PASSWORD_MIN_LENGTH: int = Field(default=8)
|
||||
|
||||
# Argon2 settings
|
||||
@@ -98,7 +101,7 @@ class Settings(BaseSettings):
|
||||
# ===================================
|
||||
LOG_LEVEL: str = Field(default="INFO")
|
||||
LOG_FORMAT: str = Field(default="json")
|
||||
LOG_FILE: Optional[str] = Field(default=None)
|
||||
LOG_FILE: str | None = Field(default=None)
|
||||
|
||||
# ===================================
|
||||
# FRONTEND URLS
|
||||
|
||||
@@ -6,7 +6,9 @@ SQLAlchemy 2.0 async setup con PostgreSQL
|
||||
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
from sqlalchemy import String, DateTime, func
|
||||
from sqlalchemy import String, DateTime, func, text
|
||||
from sqlalchemy.types import TypeDecorator, CHAR
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
from typing import AsyncGenerator
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
@@ -34,18 +36,46 @@ AsyncSessionLocal = async_sessionmaker(
|
||||
autoflush=True,
|
||||
autocommit=False
|
||||
)
|
||||
class GUID(TypeDecorator):
|
||||
"""UUID portable: UUID nativo en Postgres, CHAR(36) en otros dialectos (SQLite para tests)."""
|
||||
|
||||
impl = CHAR
|
||||
cache_ok = True
|
||||
|
||||
def load_dialect_impl(self, dialect):
|
||||
if dialect.name == "postgresql":
|
||||
return dialect.type_descriptor(PG_UUID(as_uuid=True))
|
||||
return dialect.type_descriptor(CHAR(36))
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if dialect.name == "postgresql":
|
||||
return value
|
||||
|
||||
if isinstance(value, uuid.UUID):
|
||||
return str(value)
|
||||
return str(uuid.UUID(str(value)))
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, uuid.UUID):
|
||||
return value
|
||||
return uuid.UUID(str(value))
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class para todos los modelos SQLAlchemy."""
|
||||
|
||||
|
||||
# Columnas comunes para auditoría
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now()
|
||||
onupdate=func.now(),
|
||||
)
|
||||
|
||||
|
||||
@@ -89,7 +119,7 @@ async def check_database_health() -> bool:
|
||||
"""
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
await session.execute("SELECT 1")
|
||||
await session.execute(text("SELECT 1"))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
@@ -16,6 +16,8 @@ settings = get_settings()
|
||||
|
||||
class FileHandler:
|
||||
"""Handler simple para archivos adjuntos"""
|
||||
|
||||
_CHUNK_SIZE_BYTES = 1024 * 1024 # 1MB
|
||||
|
||||
def __init__(self):
|
||||
self.upload_path = Path(settings.UPLOAD_PATH)
|
||||
@@ -24,8 +26,8 @@ class FileHandler:
|
||||
# Crear directorio si no existe
|
||||
self.upload_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _validate_file(self, filename: str, file_size: int) -> None:
|
||||
"""Validar archivo"""
|
||||
def _validate_extension(self, filename: str) -> str:
|
||||
"""Validar extensión del archivo y retornarla."""
|
||||
extension = Path(filename).suffix.lower().lstrip('.')
|
||||
|
||||
if extension not in self.allowed_extensions:
|
||||
@@ -33,32 +35,52 @@ class FileHandler:
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Extensión no permitida: {extension}"
|
||||
)
|
||||
|
||||
if file_size > self.max_size_bytes:
|
||||
|
||||
return extension
|
||||
|
||||
def _validate_magic_bytes(self, extension: str, first_bytes: bytes) -> None:
|
||||
"""Validación básica por firma (magic bytes) para tipos comunes."""
|
||||
|
||||
signatures = {
|
||||
# PDFs start with %PDF-
|
||||
"pdf": [b"%PDF-"],
|
||||
# PNG signature
|
||||
"png": [b"\x89PNG\r\n\x1a\n"],
|
||||
# JPEG starts with FF D8 FF
|
||||
"jpg": [b"\xff\xd8\xff"],
|
||||
"jpeg": [b"\xff\xd8\xff"],
|
||||
# Legacy MS Office (OLE Compound File)
|
||||
"doc": [b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"],
|
||||
"xls": [b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"],
|
||||
# OOXML (zip-based)
|
||||
"docx": [b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08"],
|
||||
"xlsx": [b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08"],
|
||||
}
|
||||
|
||||
# For plain text, we can't reliably validate via magic bytes.
|
||||
if extension == "txt":
|
||||
return
|
||||
|
||||
allowed = signatures.get(extension)
|
||||
if not allowed:
|
||||
return
|
||||
|
||||
if not any(first_bytes.startswith(sig) for sig in allowed):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f"Archivo muy grande. Máximo: {settings.MAX_UPLOAD_SIZE_MB}MB"
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Contenido de archivo no coincide con la extensión declarada",
|
||||
)
|
||||
|
||||
def _calculate_checksums(self, content: bytes) -> Tuple[str, str]:
|
||||
"""Calcular MD5 y SHA256"""
|
||||
return hashlib.md5(content).hexdigest(), hashlib.sha256(content).hexdigest()
|
||||
|
||||
async def save_upload(self, file: UploadFile, tenant_id: uuid.UUID, ticket_id: uuid.UUID) -> dict:
|
||||
"""Guardar archivo y retornar metadata"""
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="Filename requerido")
|
||||
|
||||
content = await file.read()
|
||||
file_size = len(content)
|
||||
|
||||
self._validate_file(file.filename, file_size)
|
||||
|
||||
md5_hash, sha256_hash = self._calculate_checksums(content)
|
||||
|
||||
|
||||
extension = self._validate_extension(file.filename)
|
||||
|
||||
# Nombre único
|
||||
extension = Path(file.filename).suffix.lower()
|
||||
safe_filename = f"{uuid.uuid4().hex}{extension}"
|
||||
original_extension = Path(file.filename).suffix.lower()
|
||||
safe_filename = f"{uuid.uuid4().hex}{original_extension}"
|
||||
|
||||
# Estructura: uploads/tenant_id/tickets/ticket_id/
|
||||
file_directory = self.upload_path / str(tenant_id) / "tickets" / str(ticket_id)
|
||||
@@ -66,10 +88,61 @@ class FileHandler:
|
||||
|
||||
file_path = file_directory / safe_filename
|
||||
relative_path = str(file_path.relative_to(self.upload_path))
|
||||
|
||||
# Guardar archivo
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
# Guardar archivo (streaming) + checksums incrementales
|
||||
md5 = hashlib.md5()
|
||||
sha256 = hashlib.sha256()
|
||||
file_size = 0
|
||||
validated_magic = False
|
||||
first_bytes: bytes = b""
|
||||
|
||||
try:
|
||||
with open(file_path, "wb") as f:
|
||||
while True:
|
||||
chunk = await file.read(self._CHUNK_SIZE_BYTES)
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
if not validated_magic:
|
||||
first_bytes = chunk[:16]
|
||||
self._validate_magic_bytes(extension, first_bytes)
|
||||
validated_magic = True
|
||||
|
||||
file_size += len(chunk)
|
||||
if file_size > self.max_size_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f"Archivo muy grande. Máximo: {settings.MAX_UPLOAD_SIZE_MB}MB",
|
||||
)
|
||||
|
||||
md5.update(chunk)
|
||||
sha256.update(chunk)
|
||||
f.write(chunk)
|
||||
|
||||
if file_size == 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Archivo vacío",
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
# Eliminar archivo parcial si existe
|
||||
try:
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
except Exception as exc:
|
||||
try:
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error guardando archivo: {exc}",
|
||||
)
|
||||
|
||||
import mimetypes
|
||||
mime_type = mimetypes.guess_type(file.filename)[0] or "application/octet-stream"
|
||||
@@ -80,8 +153,8 @@ class FileHandler:
|
||||
"file_path": relative_path,
|
||||
"file_size": file_size,
|
||||
"mime_type": mime_type,
|
||||
"md5_hash": md5_hash,
|
||||
"sha256_hash": sha256_hash
|
||||
"md5_hash": md5.hexdigest(),
|
||||
"sha256_hash": sha256.hexdigest(),
|
||||
}
|
||||
|
||||
def get_file_path(self, relative_path: str) -> Path:
|
||||
|
||||
20
backend/app/core/limiter.py
Normal file
20
backend/app/core/limiter.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
Rate Limiter - ServiceManagerWeb
|
||||
|
||||
Configura slowapi con Redis como storage backend.
|
||||
Respeta settings.RATE_LIMIT_ENABLED: si está desactivado usa memoria
|
||||
y el limiter queda en modo noop (enabled=False).
|
||||
"""
|
||||
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
limiter = Limiter(
|
||||
key_func=get_remote_address,
|
||||
storage_uri=settings.REDIS_URL if settings.RATE_LIMIT_ENABLED else "memory://",
|
||||
enabled=settings.RATE_LIMIT_ENABLED,
|
||||
)
|
||||
@@ -13,6 +13,7 @@ import pyotp
|
||||
import secrets
|
||||
import base64
|
||||
import struct
|
||||
import uuid
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
@@ -100,7 +101,8 @@ class SecurityUtils:
|
||||
"""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
to_encode.update({"exp": expire, "type": "refresh"})
|
||||
# Add a unique identifier so refresh tokens are never deterministic.
|
||||
to_encode.update({"exp": expire, "type": "refresh", "jti": str(uuid.uuid4())})
|
||||
|
||||
encoded_jwt = jwt.encode(
|
||||
to_encode,
|
||||
|
||||
@@ -9,6 +9,9 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from contextlib import asynccontextmanager
|
||||
from slowapi import _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.middleware import SlowAPIMiddleware
|
||||
import structlog
|
||||
import time
|
||||
import uuid
|
||||
@@ -31,6 +34,8 @@ from app.api.v1.router import api_router
|
||||
from app.middleware.tenant import TenantMiddleware
|
||||
from app.middleware.correlation_id import CorrelationIDMiddleware
|
||||
from app.core.cache import cache
|
||||
from app.core.limiter import limiter
|
||||
from app.models.issue import TicketIssue, ticket_issue_tagged_users
|
||||
|
||||
settings = get_settings()
|
||||
setup_logging()
|
||||
@@ -70,18 +75,43 @@ app = FastAPI(
|
||||
openapi_url=f"/{settings.API_VERSION}/openapi.json"
|
||||
)
|
||||
|
||||
# SlowAPI rate limiting
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
app.add_middleware(SlowAPIMiddleware)
|
||||
|
||||
# ===================================
|
||||
# MIDDLEWARE
|
||||
# ===================================
|
||||
|
||||
# CORS
|
||||
cors_origins = settings.CORS_ORIGINS.split(",") if isinstance(settings.CORS_ORIGINS, str) else settings.CORS_ORIGINS
|
||||
|
||||
if settings.is_production():
|
||||
cors_allow_methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
||||
cors_allow_headers = [
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-Tenant-ID",
|
||||
"X-Tenant-Slug",
|
||||
"X-Correlation-ID",
|
||||
]
|
||||
else:
|
||||
cors_allow_methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
||||
cors_allow_headers = [
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-Tenant-ID",
|
||||
"X-Tenant-Slug",
|
||||
"X-Correlation-ID",
|
||||
]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_methods=cors_allow_methods,
|
||||
allow_headers=cors_allow_headers,
|
||||
)
|
||||
|
||||
# Compression
|
||||
|
||||
@@ -9,8 +9,9 @@ from starlette.requests import Request
|
||||
from starlette.responses import Response, JSONResponse
|
||||
from sqlalchemy import select
|
||||
import structlog
|
||||
import uuid
|
||||
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.core.database import AsyncSessionLocal, get_db
|
||||
from app.core.config import get_settings
|
||||
from app.models.tenant import Tenant, TenantStatus
|
||||
|
||||
@@ -30,11 +31,19 @@ class TenantMiddleware(BaseHTTPMiddleware):
|
||||
# Rutas que no requieren tenant
|
||||
EXCLUDED_PATHS = {
|
||||
"/health",
|
||||
"/api/v1/health",
|
||||
"/v1/health",
|
||||
"/api/v1/health/detailed",
|
||||
"/v1/health/detailed",
|
||||
"/",
|
||||
"/api/v1/auth/login",
|
||||
"/v1/auth/login",
|
||||
"/api/v1/auth/refresh",
|
||||
"/v1/auth/refresh",
|
||||
"/api/v1/auth/logout",
|
||||
"/v1/auth/logout",
|
||||
"/api/v1/auth/me",
|
||||
"/v1/auth/me",
|
||||
"/api/v1/auth/forgot-password",
|
||||
"/v1/auth/forgot-password",
|
||||
"/api/v1/auth/reset-password",
|
||||
@@ -66,33 +75,58 @@ class TenantMiddleware(BaseHTTPMiddleware):
|
||||
tenant_id = request.headers.get("X-Tenant-ID")
|
||||
tenant_slug = request.headers.get("X-Tenant-Slug")
|
||||
|
||||
# Si no hay headers de tenant
|
||||
if not tenant_id and not tenant_slug:
|
||||
if settings.ENVIRONMENT == "production":
|
||||
tenant_uuid: uuid.UUID | None = None
|
||||
if tenant_id:
|
||||
try:
|
||||
tenant_uuid = uuid.UUID(tenant_id)
|
||||
except ValueError:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"detail": "Tenant information required (X-Tenant-ID or X-Tenant-Slug header)"}
|
||||
content={"detail": "Invalid X-Tenant-ID header (must be UUID)"},
|
||||
)
|
||||
# En desarrollo, continuar sin tenant con advertencia
|
||||
logger.warning(
|
||||
"Request without tenant information",
|
||||
path=request.url.path,
|
||||
method=request.method,
|
||||
|
||||
# Si no hay headers de tenant (requerido para aislamiento multi-tenant)
|
||||
if not tenant_id and not tenant_slug:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"detail": "Tenant information required (X-Tenant-ID or X-Tenant-Slug header)"},
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
# Validar tenant contra la base de datos
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
if tenant_id:
|
||||
result = await session.execute(
|
||||
select(Tenant).where(Tenant.id == tenant_id)
|
||||
)
|
||||
else:
|
||||
result = await session.execute(
|
||||
select(Tenant).where(Tenant.slug == tenant_slug)
|
||||
)
|
||||
tenant = result.scalars().first()
|
||||
# Prefer DB session coming from dependency overrides (tests) when available.
|
||||
# Guard: in unit tests request.app may be a MagicMock, not a real FastAPI app.
|
||||
dependency_overrides = getattr(request.app, "dependency_overrides", None)
|
||||
override_get_db = None
|
||||
if isinstance(dependency_overrides, dict):
|
||||
override_get_db = dependency_overrides.get(get_db)
|
||||
|
||||
if override_get_db is not None:
|
||||
agen = override_get_db()
|
||||
session = await agen.__anext__()
|
||||
try:
|
||||
if tenant_uuid is not None:
|
||||
result = await session.execute(
|
||||
select(Tenant).where(Tenant.id == tenant_uuid)
|
||||
)
|
||||
else:
|
||||
result = await session.execute(
|
||||
select(Tenant).where(Tenant.slug == tenant_slug)
|
||||
)
|
||||
tenant = result.scalars().first()
|
||||
finally:
|
||||
await agen.aclose()
|
||||
else:
|
||||
async with AsyncSessionLocal() as session:
|
||||
if tenant_uuid is not None:
|
||||
result = await session.execute(
|
||||
select(Tenant).where(Tenant.id == tenant_uuid)
|
||||
)
|
||||
else:
|
||||
result = await session.execute(
|
||||
select(Tenant).where(Tenant.slug == tenant_slug)
|
||||
)
|
||||
tenant = result.scalars().first()
|
||||
|
||||
if tenant is None:
|
||||
logger.warning(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from .user import User
|
||||
from .tenant import Tenant
|
||||
from .ticket import Ticket
|
||||
from .issue import TicketIssue, ticket_issue_tagged_users
|
||||
from .comment import TicketComment
|
||||
from .system import System
|
||||
from .category import Category
|
||||
@@ -10,12 +11,15 @@ from .client_profile import ClientProfile
|
||||
from .attachment import TicketAttachment
|
||||
from .audit import AuditLog
|
||||
from .refresh_token import RefreshToken
|
||||
from .permission import TenantPermission
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"Tenant",
|
||||
"Tenant",
|
||||
"Ticket",
|
||||
"TicketIssue",
|
||||
"TicketComment",
|
||||
"TenantPermission",
|
||||
"System",
|
||||
"Category",
|
||||
"ClientProfile",
|
||||
|
||||
@@ -3,12 +3,11 @@ Attachment Model - ServiceManagerWeb
|
||||
"""
|
||||
from sqlalchemy import String, ForeignKey, Integer, DateTime, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from app.core.database import Base
|
||||
from app.core.database import Base, GUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.ticket import Ticket
|
||||
@@ -21,24 +20,24 @@ class TicketAttachment(Base):
|
||||
__tablename__ = "ticket_attachments"
|
||||
|
||||
# Sobrescribir campos heredados de Base para que coincidan con la tabla real
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
# Esta tabla NO tiene updated_at, así que lo excluimos del mapping
|
||||
|
||||
ticket_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("tickets.id", ondelete="CASCADE"),
|
||||
nullable=False
|
||||
)
|
||||
|
||||
comment_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("ticket_comments.id", ondelete="CASCADE"),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
uploaded_by: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("users.id"),
|
||||
nullable=False
|
||||
)
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
"""
|
||||
Audit Log Model - ServiceManagerWeb
|
||||
|
||||
Modelo para bitácora de auditoría y compliance.
|
||||
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 import String, Text, DateTime, ForeignKey, Index, JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, INET, JSONB
|
||||
from sqlalchemy.dialects.postgresql import INET, JSONB
|
||||
from typing import Optional, Dict, Any, TYPE_CHECKING
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.core.database import Base
|
||||
from app.core.database import Base, GUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.tenant import Tenant
|
||||
@@ -21,128 +21,154 @@ if TYPE_CHECKING:
|
||||
|
||||
class AuditLog(Base):
|
||||
"""
|
||||
Bitácora de auditoría para tracking completo de acciones.
|
||||
|
||||
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)
|
||||
- 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
|
||||
|
||||
# Multi-tenancy: cada registro pertenece a un tenant específico
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Usuario que ejecut├│ la acci├│n (NULL = acci├│n del sistema)
|
||||
|
||||
# Usuario que ejecutó la acción (NULL = acción del sistema)
|
||||
user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Acci├│n realizada (ej: "user.login", "ticket.create", "ticket.assign")
|
||||
|
||||
# Acción realizada en formato "recurso.verbo"
|
||||
# Ejemplos: "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),
|
||||
GUID(),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Contexto de la request
|
||||
ip_address: Mapped[Optional[str]] = mapped_column(INET, nullable=True)
|
||||
|
||||
# Contexto de la request: IP y navegador del usuario
|
||||
ip_address: Mapped[Optional[str]] = mapped_column(
|
||||
String(45).with_variant(INET, "postgresql"),
|
||||
nullable=True,
|
||||
)
|
||||
user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Correlation ID para rastrear requests relacionadas
|
||||
|
||||
# Correlation ID para rastrear todas las requests relacionadas
|
||||
# en una misma operación o sesión
|
||||
correlation_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
nullable=True,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Valores antes del cambio (JSON)
|
||||
|
||||
# Estado del recurso antes del cambio (para auditoría de cambios)
|
||||
old_values: Mapped[Optional[Dict[str, Any]]] = mapped_column(
|
||||
JSONB,
|
||||
JSON().with_variant(JSONB, "postgresql"),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Valores despu├®s del cambio (JSON)
|
||||
|
||||
# Estado del recurso después del cambio (para auditoría de cambios)
|
||||
new_values: Mapped[Optional[Dict[str, Any]]] = mapped_column(
|
||||
JSONB,
|
||||
JSON().with_variant(JSONB, "postgresql"),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Metadata adicional (cualquier info relevante)
|
||||
# Nota: 'metadata' está reservado en SQLAlchemy, usamos 'extra_metadata'
|
||||
|
||||
# Metadata adicional con cualquier información relevante del contexto
|
||||
# Nota: 'metadata' está reservado en SQLAlchemy, se usa 'extra_metadata'
|
||||
# como nombre del atributo Python, pero la columna en BD se llama 'metadata'
|
||||
extra_metadata: Mapped[Optional[Dict[str, Any]]] = mapped_column(
|
||||
'metadata', # Nombre real de la columna en BD
|
||||
JSONB,
|
||||
'metadata',
|
||||
JSON().with_variant(JSONB, "postgresql"),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Timestamp
|
||||
|
||||
# Timestamp de creación con timezone
|
||||
# CORRECCIÓN: default=lambda: datetime.now(timezone.utc) genera un
|
||||
# datetime aware en UTC, compatible con DateTime(timezone=True).
|
||||
# El default anterior (datetime.utcnow) generaba datetimes naive,
|
||||
# causando que los filtros de fecha fallaran silenciosamente porque
|
||||
# SQLAlchemy no podía comparar aware vs naive correctamente.
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=datetime.utcnow,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Relaciones
|
||||
|
||||
# Relaciones con otros modelos
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", foreign_keys=[tenant_id])
|
||||
user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[user_id])
|
||||
|
||||
# Índices compuestos para queries comunes
|
||||
|
||||
# Índices compuestos para optimizar las queries más frecuentes
|
||||
__table_args__ = (
|
||||
# Filtrar logs por tenant y tipo de acción (uso más común)
|
||||
Index('idx_audit_logs_tenant_action', 'tenant_id', 'action'),
|
||||
# Buscar el historial de un recurso específico
|
||||
Index('idx_audit_logs_resource', 'resource_type', 'resource_id'),
|
||||
# Ver la actividad de un usuario ordenada por fecha
|
||||
Index('idx_audit_logs_user_created', 'user_id', 'created_at'),
|
||||
)
|
||||
|
||||
# Configuraci├│n del mapper: excluir updated_at porque audit logs son inmutables
|
||||
|
||||
# Los audit logs son inmutables: nunca se actualizan, solo se crean
|
||||
# Por eso se excluye updated_at del mapper
|
||||
__mapper_args__ = {
|
||||
"exclude_properties": ["updated_at"]
|
||||
}
|
||||
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<AuditLog(action='{self.action}', resource='{self.resource_type}:{self.resource_id}')>"
|
||||
|
||||
return (
|
||||
f"<AuditLog("
|
||||
f"action='{self.action}', "
|
||||
f"resource='{self.resource_type}:{self.resource_id}'"
|
||||
f")>"
|
||||
)
|
||||
|
||||
@property
|
||||
def action_display(self) -> str:
|
||||
"""Formato amigable de la acci├│n."""
|
||||
"""
|
||||
Formato legible de la acción para mostrar en la interfaz.
|
||||
|
||||
Convierte el formato interno "recurso.verbo" a texto descriptivo.
|
||||
Ejemplo: "ticket.create" → "creó ticket"
|
||||
"""
|
||||
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├│'
|
||||
'create': 'creó',
|
||||
'update': 'actualizó',
|
||||
'delete': 'eliminó',
|
||||
'login': 'inició sesión',
|
||||
'logout': 'cerró sesión',
|
||||
'login_failed': 'intentó iniciar sesión',
|
||||
'assign': 'asignó',
|
||||
'close': 'cerró',
|
||||
'reopen': 'reabrió'
|
||||
}
|
||||
return f"{verb_map.get(verb, verb)} {resource}"
|
||||
return self.action
|
||||
return self.action
|
||||
@@ -4,11 +4,10 @@ Categorías de tickets por tenant
|
||||
"""
|
||||
from sqlalchemy import String, Text, Boolean, Integer, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
|
||||
from app.core.database import Base
|
||||
from app.core.database import Base, GUID
|
||||
|
||||
class Category(Base):
|
||||
"""Modelo de categorías de tickets (ticket_categories en BD)"""
|
||||
@@ -21,7 +20,7 @@ class Category(Base):
|
||||
|
||||
# ✅ CORREGIDO: tenant_id es obligatorio para multi-tenancy
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False # ✅ Obligatorio
|
||||
)
|
||||
@@ -31,7 +30,7 @@ class Category(Base):
|
||||
sla_response_hours: Mapped[int] = mapped_column(Integer, default=24, nullable=False)
|
||||
sla_resolution_hours: Mapped[int] = mapped_column(Integer, default=72, nullable=False)
|
||||
auto_assign_to: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("users.id"),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
@@ -7,12 +7,11 @@ Almacena información detallada de la empresa cliente
|
||||
|
||||
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Text, Numeric
|
||||
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
|
||||
from app.core.database import Base, GUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.tenant import Tenant
|
||||
@@ -25,7 +24,7 @@ class ClientProfile(Base):
|
||||
|
||||
# Relación con tenant (uno a uno)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
|
||||
@@ -5,12 +5,11 @@ Modelo para comentarios en tickets
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, String, Text, Boolean, ForeignKey, DateTime
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from app.core.database import Base
|
||||
from app.core.database import Base, GUID
|
||||
|
||||
|
||||
class TicketComment(Base):
|
||||
@@ -20,20 +19,20 @@ class TicketComment(Base):
|
||||
|
||||
# Columnas
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4
|
||||
)
|
||||
|
||||
ticket_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("tickets.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
author_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("users.id"),
|
||||
nullable=False,
|
||||
index=True
|
||||
|
||||
102
backend/app/models/issue.py
Normal file
102
backend/app/models/issue.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
TicketIssue Model - ServiceManagerWeb
|
||||
|
||||
Asuntos de escalación asociados a tickets.
|
||||
Creados por el dueño del ticket o el CLIENT_ADMIN del tenant.
|
||||
"""
|
||||
from sqlalchemy import String, ForeignKey, Text, Table, Column
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from app.core.database import Base, GUID
|
||||
from app.models.ticket import TicketPriority
|
||||
from sqlalchemy import Enum as SAEnum
|
||||
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM
|
||||
|
||||
|
||||
# Tabla de relación N:M entre TicketIssue y User (usuarios etiquetados)
|
||||
ticket_issue_tagged_users = Table(
|
||||
"ticket_issue_tagged_users",
|
||||
Base.metadata,
|
||||
Column(
|
||||
"issue_id",
|
||||
GUID(),
|
||||
ForeignKey("ticket_issues.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
nullable=False,
|
||||
),
|
||||
Column(
|
||||
"user_id",
|
||||
GUID(),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TicketIssue(Base):
|
||||
"""
|
||||
Asunto de escalación de un ticket.
|
||||
|
||||
Solo puede crearlo:
|
||||
- El usuario que creó el ticket (created_by del Ticket)
|
||||
- El CLIENT_ADMIN del mismo tenant
|
||||
"""
|
||||
__tablename__ = "ticket_issues"
|
||||
|
||||
# Multi-tenancy — siempre filtrar por esto
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
GUID(),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relación con el ticket padre
|
||||
ticket_id: Mapped[uuid.UUID] = mapped_column(
|
||||
GUID(),
|
||||
ForeignKey("tickets.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Quién lo creó
|
||||
created_by: Mapped[uuid.UUID] = mapped_column(
|
||||
GUID(),
|
||||
ForeignKey("users.id"),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Contenido
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
priority: Mapped[TicketPriority] = mapped_column(
|
||||
SAEnum(TicketPriority, name="ticket_priority_enum", native_enum=False).with_variant(
|
||||
PG_ENUM(TicketPriority, name="ticket_priority_enum", create_type=True),
|
||||
"postgresql",
|
||||
),
|
||||
default=TicketPriority.MEDIUM,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Adjunto opcional (reutiliza file_handler igual que TicketAttachment)
|
||||
attachment_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
attachment_filename: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
attachment_mime_type: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
|
||||
# Relationships
|
||||
ticket: Mapped["Ticket"] = relationship("Ticket", back_populates="issues")
|
||||
|
||||
created_by_user: Mapped["User"] = relationship(
|
||||
"User",
|
||||
foreign_keys=[created_by],
|
||||
)
|
||||
|
||||
tagged_users: Mapped[List["User"]] = relationship(
|
||||
"User",
|
||||
secondary=ticket_issue_tagged_users,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<TicketIssue(id={self.id}, ticket={self.ticket_id}, priority={self.priority})>"
|
||||
67
backend/app/models/permission.py
Normal file
67
backend/app/models/permission.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
TenantPermission Model - ServiceManagerWeb
|
||||
|
||||
Permisos dinámicos por tenant.
|
||||
- Si user_id es None → aplica al rol completo (default del tenant)
|
||||
- Si user_id tiene valor → override individual para ese usuario
|
||||
"""
|
||||
from sqlalchemy import String, Boolean, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from typing import Optional
|
||||
import uuid
|
||||
|
||||
from app.core.database import Base, GUID
|
||||
|
||||
|
||||
CLIENT_PERMISSIONS = [
|
||||
"view_tickets",
|
||||
"create_tickets",
|
||||
"close_tickets",
|
||||
"view_reports",
|
||||
"manage_tenant_users",
|
||||
"create_issues",
|
||||
]
|
||||
|
||||
DEFAULT_PERMISSIONS = {
|
||||
"CLIENT_ADMIN": {p: True for p in CLIENT_PERMISSIONS},
|
||||
"CLIENT_USER": {
|
||||
"view_tickets": True,
|
||||
"create_tickets": True,
|
||||
"close_tickets": False,
|
||||
"view_reports": False,
|
||||
"manage_tenant_users": False,
|
||||
"create_issues": False,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TenantPermission(Base):
|
||||
__tablename__ = "tenant_permissions"
|
||||
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
GUID(),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
GUID(),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
permission: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
granted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant")
|
||||
user: Mapped[Optional["User"]] = relationship("User")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id", "role", "user_id", "permission",
|
||||
name="uq_tenant_permission"
|
||||
),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
scope = f"user:{self.user_id}" if self.user_id else f"role:{self.role}"
|
||||
return f"<TenantPermission({scope} {self.permission}={'✓' if self.granted else '✗'})>"
|
||||
@@ -6,12 +6,11 @@ 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 datetime import datetime, timezone
|
||||
|
||||
from app.core.database import Base
|
||||
from app.core.database import Base, GUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
@@ -36,7 +35,7 @@ class RefreshToken(Base):
|
||||
|
||||
# User relationship
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True
|
||||
@@ -92,7 +91,7 @@ class RefreshToken(Base):
|
||||
)
|
||||
|
||||
revoked_by: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True
|
||||
)
|
||||
@@ -146,12 +145,12 @@ class RefreshToken(Base):
|
||||
- No está revocado
|
||||
- No ha expirado
|
||||
"""
|
||||
return not self.revoked and self.expires_at > datetime.utcnow()
|
||||
return not self.revoked and self.expires_at > datetime.now(timezone.utc)
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""Verificar si el token ha expirado."""
|
||||
return datetime.utcnow() >= self.expires_at
|
||||
return datetime.now(timezone.utc) >= self.expires_at
|
||||
|
||||
def revoke(self, revoked_by: Optional[uuid.UUID] = None) -> None:
|
||||
"""
|
||||
@@ -161,11 +160,11 @@ class RefreshToken(Base):
|
||||
revoked_by: ID del usuario que revoc├│ el token
|
||||
"""
|
||||
self.revoked = True
|
||||
self.revoked_at = datetime.utcnow()
|
||||
self.revoked_at = datetime.now(timezone.utc)
|
||||
if revoked_by:
|
||||
self.revoked_by = revoked_by
|
||||
|
||||
def track_usage(self) -> None:
|
||||
"""Registrar uso del token."""
|
||||
self.last_used_at = datetime.utcnow()
|
||||
self.last_used_at = datetime.now(timezone.utc)
|
||||
self.usage_count += 1
|
||||
|
||||
63
backend/app/models/roles.py
Normal file
63
backend/app/models/roles.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
Definición y helpers de roles para el sistema multi-tenant.
|
||||
|
||||
Fuente única: UserRole en app.models.user.
|
||||
Este módulo expone conjuntos de roles y helpers de verificación
|
||||
para usarse en deps.py y en los endpoints.
|
||||
|
||||
Roles globales (staff interno — alcance multi-tenant):
|
||||
ADMIN → control total sobre todos los tenants
|
||||
SUPPORT_MANAGER → gestiona equipos y SLAs de todos los tenants
|
||||
AGENT → atiende tickets de cualquier tenant
|
||||
AUDITOR → auditoría de solo lectura en todos los tenants
|
||||
|
||||
Roles de cliente (alcance limitado al propio tenant):
|
||||
CLIENT_ADMIN → administra organización: usuarios, configuración, tickets
|
||||
CLIENT_USER → crea y sigue sus propios tickets
|
||||
"""
|
||||
|
||||
from app.models.user import UserRole
|
||||
|
||||
# ── Conjuntos de roles ──────────────────────────────────────────────────────
|
||||
|
||||
GLOBAL_ROLES: frozenset[UserRole] = frozenset({
|
||||
UserRole.ADMIN,
|
||||
UserRole.SUPPORT_MANAGER,
|
||||
UserRole.AGENT,
|
||||
UserRole.AUDITOR,
|
||||
})
|
||||
|
||||
CLIENT_ROLES: frozenset[UserRole] = frozenset({
|
||||
UserRole.CLIENT_ADMIN,
|
||||
UserRole.CLIENT_USER,
|
||||
})
|
||||
|
||||
# ── Permisos por rol ────────────────────────────────────────────────────────
|
||||
|
||||
ROLE_PERMISSIONS: dict[UserRole, list[str]] = {
|
||||
# Staff global
|
||||
UserRole.ADMIN: ["manage_all", "view_all", "audit_all"],
|
||||
UserRole.SUPPORT_MANAGER: ["manage_teams", "view_all_tickets", "manage_sla"],
|
||||
UserRole.AGENT: ["view_all_tickets", "update_any_ticket"],
|
||||
UserRole.AUDITOR: ["view_all", "audit_all"],
|
||||
# Clientes (acotados al tenant)
|
||||
UserRole.CLIENT_ADMIN: ["manage_tenant", "manage_tenant_users", "view_tenant_tickets"],
|
||||
UserRole.CLIENT_USER: ["create_ticket", "view_own_tickets"],
|
||||
}
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def is_global_staff(role: UserRole) -> bool:
|
||||
"""Retorna True si el rol tiene alcance global (staff interno)."""
|
||||
return role.is_global
|
||||
|
||||
|
||||
def is_client_role(role: UserRole) -> bool:
|
||||
"""Retorna True si el rol está acotado al tenant del usuario."""
|
||||
return role.is_client
|
||||
|
||||
|
||||
def has_permission(role: UserRole, permission: str) -> bool:
|
||||
"""Verifica si un rol tiene un permiso específico."""
|
||||
return permission in ROLE_PERMISSIONS.get(role, [])
|
||||
|
||||
@@ -4,11 +4,10 @@ Sistemas afectados por tenant
|
||||
"""
|
||||
from sqlalchemy import String, Text, Boolean, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
|
||||
from app.core.database import Base
|
||||
from app.core.database import Base, GUID
|
||||
|
||||
class System(Base):
|
||||
"""Modelo de sistemas afectados (affected_systems en BD)"""
|
||||
@@ -21,7 +20,7 @@ class System(Base):
|
||||
|
||||
# ✅ AÑADIDO: tenant_id obligatorio para multi-tenancy (faltaba completamente)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False
|
||||
)
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
Tenant Model - ServiceManagerWeb
|
||||
Modelo para organizaciones cliente (multi-tenancy)
|
||||
"""
|
||||
from sqlalchemy import String, Integer, Text, Boolean, ARRAY
|
||||
from sqlalchemy import String, Integer, Text, Boolean, JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, ENUM
|
||||
from sqlalchemy.dialects.postgresql import UUID, ENUM, ARRAY as PG_ARRAY
|
||||
from typing import List, Optional
|
||||
import enum
|
||||
import uuid
|
||||
@@ -40,7 +40,7 @@ class Tenant(Base):
|
||||
max_users: Mapped[int] = mapped_column(Integer, default=50)
|
||||
max_storage_mb: Mapped[int] = mapped_column(Integer, default=1024)
|
||||
allowed_file_types: Mapped[List[str]] = mapped_column(
|
||||
ARRAY(String),
|
||||
JSON().with_variant(PG_ARRAY(String), "postgresql"),
|
||||
default=["pdf", "jpg", "jpeg", "png", "doc", "docx", "xls", "xlsx", "txt"]
|
||||
)
|
||||
|
||||
|
||||
@@ -2,15 +2,20 @@
|
||||
Ticket Model - ServiceManagerWeb
|
||||
Tickets de soporte - Core del negocio
|
||||
"""
|
||||
from sqlalchemy import String, ForeignKey, Text, Integer, CheckConstraint, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, ENUM
|
||||
from sqlalchemy import String, ForeignKey, Text, Integer, CheckConstraint, UniqueConstraint, Enum as SAEnum
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship, synonym
|
||||
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
import enum
|
||||
import uuid
|
||||
|
||||
from app.core.database import Base
|
||||
from app.core.database import Base, GUID
|
||||
|
||||
|
||||
def _generate_fallback_ticket_number() -> str:
|
||||
# Matches helper format "TK-000001" and stays within VARCHAR(20)
|
||||
return f"TK-{(uuid.uuid4().int % 1_000_000):06d}"
|
||||
|
||||
class TicketStatus(str, enum.Enum):
|
||||
"""Estados posibles de un ticket"""
|
||||
@@ -35,50 +40,64 @@ class Ticket(Base):
|
||||
|
||||
# Multi-tenancy
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False
|
||||
)
|
||||
|
||||
# Campos básicos
|
||||
ticket_number: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
ticket_number: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default=_generate_fallback_ticket_number,
|
||||
)
|
||||
subject: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
# Compatibility aliases (API/UI/tests often use these names)
|
||||
title = synonym("subject")
|
||||
system_id = synonym("affected_system_id")
|
||||
|
||||
# Estado y Prioridad
|
||||
status: Mapped[TicketStatus] = mapped_column(
|
||||
ENUM(TicketStatus, name="ticket_status_enum", create_type=False),
|
||||
SAEnum(TicketStatus, name="ticket_status_enum", native_enum=False).with_variant(
|
||||
PG_ENUM(TicketStatus, name="ticket_status_enum", create_type=True),
|
||||
"postgresql",
|
||||
),
|
||||
default=TicketStatus.NEW,
|
||||
nullable=False
|
||||
)
|
||||
priority: Mapped[TicketPriority] = mapped_column(
|
||||
ENUM(TicketPriority, name="ticket_priority_enum", create_type=False),
|
||||
SAEnum(TicketPriority, name="ticket_priority_enum", native_enum=False).with_variant(
|
||||
PG_ENUM(TicketPriority, name="ticket_priority_enum", create_type=True),
|
||||
"postgresql",
|
||||
),
|
||||
default=TicketPriority.MEDIUM,
|
||||
nullable=False
|
||||
)
|
||||
|
||||
# ✅ CORREGIDO: Foreign Keys apuntan a tablas correctas
|
||||
created_by: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("users.id"),
|
||||
nullable=False
|
||||
)
|
||||
assigned_to: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("users.id"),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# ✅ CORREGIDO: Renombrado de system_id a affected_system_id
|
||||
affected_system_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("affected_systems.id"), # ✅ Tabla correcta
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# ✅ CORREGIDO: Foreign key a tabla correcta
|
||||
category_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("ticket_categories.id"), # ✅ Tabla correcta
|
||||
nullable=True
|
||||
)
|
||||
@@ -138,5 +157,43 @@ class Ticket(Base):
|
||||
CheckConstraint('rating >= 1 AND rating <= 5', name='check_rating_range'),
|
||||
)
|
||||
|
||||
participants: Mapped[list["TicketParticipant"]] = relationship(
|
||||
"TicketParticipant",
|
||||
back_populates="ticket",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
issues: Mapped[list["TicketIssue"]] = relationship(
|
||||
"TicketIssue",
|
||||
back_populates="ticket",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Ticket(id={self.id}, number='{self.ticket_number}', status={self.status})>"
|
||||
return f"<Ticket(id={self.id}, number='{self.ticket_number}', status={self.status})>"
|
||||
|
||||
class TicketParticipant(Base):
|
||||
"""Participantes asignados a un ticket"""
|
||||
__tablename__ = "ticket_participants"
|
||||
|
||||
ticket_id: Mapped[uuid.UUID] = mapped_column(
|
||||
GUID(),
|
||||
ForeignKey("tickets.id", ondelete="CASCADE"),
|
||||
nullable=False
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
GUID(),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(50), nullable=False, default="participant")
|
||||
|
||||
ticket: Mapped["Ticket"] = relationship("Ticket", back_populates="participants")
|
||||
user: Mapped["User"] = relationship("User")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("ticket_id", "user_id", name="uq_ticket_participant"),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TicketParticipant(ticket={self.ticket_id}, user={self.user_id})>"
|
||||
|
||||
@@ -4,15 +4,15 @@ User Model - ServiceManagerWeb
|
||||
Modelo para usuarios del sistema (internos y clientes)
|
||||
"""
|
||||
|
||||
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Text, ARRAY
|
||||
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Text, JSON, Enum as SAEnum
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, ENUM
|
||||
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM, ARRAY as PG_ARRAY
|
||||
from typing import Optional, List
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.database import Base
|
||||
from app.core.database import Base, GUID
|
||||
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
@@ -27,6 +27,24 @@ class UserRole(str, enum.Enum):
|
||||
CLIENT_ADMIN = "CLIENT_ADMIN" # Admin de organización cliente
|
||||
CLIENT_USER = "CLIENT_USER" # Usuario final cliente
|
||||
|
||||
@property
|
||||
def is_global(self) -> bool:
|
||||
"""True si el rol tiene alcance global (staff interno cross-tenant)."""
|
||||
return self in (
|
||||
UserRole.ADMIN,
|
||||
UserRole.SUPPORT_MANAGER,
|
||||
UserRole.AGENT,
|
||||
UserRole.AUDITOR,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_client(self) -> bool:
|
||||
"""True si el rol está acotado al tenant del usuario."""
|
||||
return self in (
|
||||
UserRole.CLIENT_ADMIN,
|
||||
UserRole.CLIENT_USER,
|
||||
)
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""Modelo de Usuario."""
|
||||
@@ -35,7 +53,7 @@ class User(Base):
|
||||
|
||||
# Relación con tenant
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False
|
||||
)
|
||||
@@ -48,12 +66,20 @@ class User(Base):
|
||||
|
||||
# Autenticación
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role: Mapped[UserRole] = mapped_column(ENUM(UserRole, name="user_role_enum"), nullable=False)
|
||||
role: Mapped[UserRole] = mapped_column(
|
||||
SAEnum(UserRole, name="user_role_enum", native_enum=False).with_variant(
|
||||
PG_ENUM(UserRole, name="user_role_enum", create_type=True),
|
||||
"postgresql",
|
||||
),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# 2FA (opcional para staff interno)
|
||||
totp_secret: Mapped[Optional[str]] = mapped_column(String(32))
|
||||
totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
backup_codes: Mapped[Optional[List[str]]] = mapped_column(ARRAY(String))
|
||||
backup_codes: Mapped[Optional[List[str]]] = mapped_column(
|
||||
JSON().with_variant(PG_ARRAY(String), "postgresql")
|
||||
)
|
||||
|
||||
# Estado
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
@@ -85,11 +111,6 @@ class User(Base):
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
# Unique constraint por tenant
|
||||
__table_args__ = (
|
||||
{"postgresql_tablespace": "users"},
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User(id={self.id}, email='{self.email}', role='{self.role}')>"
|
||||
|
||||
@@ -110,11 +131,8 @@ class User(Base):
|
||||
|
||||
@property
|
||||
def is_client(self) -> bool:
|
||||
"""Check if user is a client."""
|
||||
return self.role in [
|
||||
UserRole.CLIENT_ADMIN,
|
||||
UserRole.CLIENT_USER
|
||||
]
|
||||
"""Check if user is a client (rol acotado al propio tenant)."""
|
||||
return self.role.is_client
|
||||
|
||||
@property
|
||||
def can_manage_users(self) -> bool:
|
||||
@@ -122,7 +140,7 @@ class User(Base):
|
||||
return self.role in [
|
||||
UserRole.ADMIN,
|
||||
UserRole.SUPPORT_MANAGER,
|
||||
UserRole.CLIENT_ADMIN
|
||||
UserRole.CLIENT_ADMIN,
|
||||
]
|
||||
|
||||
@property
|
||||
@@ -131,7 +149,7 @@ class User(Base):
|
||||
return self.role in [
|
||||
UserRole.ADMIN,
|
||||
UserRole.SUPPORT_MANAGER,
|
||||
UserRole.AGENT
|
||||
UserRole.AGENT,
|
||||
]
|
||||
|
||||
@property
|
||||
|
||||
170
backend/app/services/ticket_service.py
Normal file
170
backend/app/services/ticket_service.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
Ticket Service - ServiceManagerWeb
|
||||
|
||||
Lógica de negocio para creación y gestión de tickets.
|
||||
Inyectable vía Depends() en los endpoints de FastAPI.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.ticket import Ticket, TicketStatus, TicketPriority
|
||||
from app.models.category import Category
|
||||
from app.models.system import System
|
||||
from app.api.schemas.ticket import TicketCreate
|
||||
from app.api.v1.helpers import (
|
||||
generate_next_ticket_number,
|
||||
calculate_sla_deadlines,
|
||||
safe_audit_log,
|
||||
)
|
||||
|
||||
|
||||
class TicketService:
|
||||
"""Servicio de tickets: encapsula lógica de negocio fuera del router."""
|
||||
|
||||
def __init__(self, db: AsyncSession = Depends(get_db)):
|
||||
self.db = db
|
||||
|
||||
async def create_ticket(
|
||||
self,
|
||||
ticket: TicketCreate,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> dict:
|
||||
"""
|
||||
Crea un ticket con validación multi-tenant, cálculo de SLA y auto-asignación.
|
||||
|
||||
Args:
|
||||
ticket: Datos del ticket a crear.
|
||||
tenant_id: Tenant del usuario autenticado.
|
||||
user_id: ID del usuario que crea el ticket.
|
||||
|
||||
Returns:
|
||||
dict compatible con TicketResponse.
|
||||
|
||||
Raises:
|
||||
HTTPException 400: UUID inválido, categoría/sistema no encontrado o de otro tenant.
|
||||
HTTPException 500: Fallo persistente tras max_retries.
|
||||
"""
|
||||
max_retries = 3
|
||||
last_error = None
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
ticket_number = await generate_next_ticket_number(self.db, tenant_id)
|
||||
category_uuid = uuid.UUID(ticket.category_id) if ticket.category_id else None
|
||||
system_uuid = uuid.UUID(ticket.affected_system_id) if ticket.affected_system_id else None
|
||||
|
||||
category = None
|
||||
if category_uuid:
|
||||
category = await self.db.get(Category, category_uuid)
|
||||
if not category or category.tenant_id != tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"La categoría con ID {ticket.category_id} no existe.",
|
||||
)
|
||||
|
||||
if system_uuid:
|
||||
system = await self.db.get(System, system_uuid)
|
||||
if not system or system.tenant_id != tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"El sistema con ID {ticket.affected_system_id} no existe.",
|
||||
)
|
||||
|
||||
sla_response_due, sla_resolution_due = calculate_sla_deadlines(category)
|
||||
assigned_to_user = category.auto_assign_to if category and category.auto_assign_to else None
|
||||
|
||||
db_ticket = Ticket(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant_id,
|
||||
ticket_number=ticket_number,
|
||||
subject=ticket.subject,
|
||||
description=ticket.description,
|
||||
category_id=category_uuid,
|
||||
affected_system_id=system_uuid,
|
||||
priority=TicketPriority[ticket.priority.upper()],
|
||||
created_by=user_id,
|
||||
assigned_to=assigned_to_user,
|
||||
status=TicketStatus.NEW,
|
||||
sla_response_due=sla_response_due,
|
||||
sla_resolution_due=sla_resolution_due,
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
self.db.add(db_ticket)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(db_ticket)
|
||||
|
||||
await safe_audit_log(
|
||||
db=self.db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
action="ticket.create",
|
||||
resource_type="ticket",
|
||||
resource_id=db_ticket.id,
|
||||
new_values={
|
||||
"ticket_number": db_ticket.ticket_number,
|
||||
"subject": db_ticket.subject,
|
||||
"priority": db_ticket.priority.value,
|
||||
"status": db_ticket.status.value,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"id": str(db_ticket.id),
|
||||
"ticket_number": db_ticket.ticket_number,
|
||||
"subject": db_ticket.subject,
|
||||
"title": db_ticket.subject,
|
||||
"description": db_ticket.description,
|
||||
"status": db_ticket.status.value,
|
||||
"priority": db_ticket.priority.value,
|
||||
"category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
|
||||
"affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None,
|
||||
"system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None,
|
||||
"contact_email": ticket.contact_email,
|
||||
"contact_phone": ticket.contact_phone,
|
||||
"created_by": str(db_ticket.created_by),
|
||||
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
|
||||
"created_at": db_ticket.created_at,
|
||||
"updated_at": db_ticket.updated_at,
|
||||
"sla_response_due": db_ticket.sla_response_due,
|
||||
"sla_resolution_due": db_ticket.sla_resolution_due,
|
||||
"first_response_at": db_ticket.first_response_at,
|
||||
"resolved_at": db_ticket.resolved_at,
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
await self.db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid UUID format: {str(e)}",
|
||||
)
|
||||
except HTTPException:
|
||||
await self.db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
await self.db.rollback()
|
||||
last_error = e
|
||||
if "duplicate key" in str(e).lower() and "ticket_number" in str(e).lower():
|
||||
if attempt < max_retries - 1:
|
||||
continue
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Error creating ticket: {str(e)}",
|
||||
)
|
||||
|
||||
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)}",
|
||||
)
|
||||
|
||||
|
||||
def get_ticket_service(db: AsyncSession = Depends(get_db)) -> TicketService:
|
||||
"""Factory function para inyectar TicketService vía Depends()."""
|
||||
return TicketService(db)
|
||||
@@ -6,7 +6,7 @@ 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 datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
import uuid
|
||||
import structlog
|
||||
@@ -56,7 +56,7 @@ class TokenService:
|
||||
RefreshToken creado
|
||||
"""
|
||||
# Calcular expiraci├│n
|
||||
expires_at = datetime.utcnow() + timedelta(
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(
|
||||
days=settings.REFRESH_TOKEN_EXPIRE_DAYS
|
||||
)
|
||||
|
||||
@@ -232,7 +232,7 @@ class TokenService:
|
||||
N├║mero de tokens eliminados
|
||||
"""
|
||||
# Eliminar tokens expirados hace más de 7 días
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=7)
|
||||
cutoff_date = datetime.now(timezone.utc) - timedelta(days=7)
|
||||
|
||||
query = delete(RefreshToken).where(
|
||||
RefreshToken.expires_at < cutoff_date
|
||||
|
||||
373
backend/app/tests/conftest.py
Normal file
373
backend/app/tests/conftest.py
Normal file
@@ -0,0 +1,373 @@
|
||||
"""
|
||||
Integration Test Fixtures - ServiceManagerWeb (Docker / PostgreSQL)
|
||||
|
||||
backend/app/tests/conftest.py
|
||||
|
||||
Usa la BD Docker existente (servicemanager).
|
||||
Los fixtures leen datos reales ya seedeados — no crean ni eliminan nada.
|
||||
Los tests que inserten datos propios quedan aislados por rollback.
|
||||
|
||||
Tenant de referencia : aduanasoft
|
||||
Usuarios de referencia:
|
||||
admin@aduanasoft.com → ADMIN
|
||||
manager@aduanasoft.com → SUPPORT_MANAGER
|
||||
agente@aduanasoft.com → AGENT
|
||||
auditor1@test.com → AUDITOR (tenant aduanasoft)
|
||||
admin-cliente@empresa-demo → CLIENT_ADMIN
|
||||
test_user@aduanasoft.com → CLIENT_USER
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
import pytest
|
||||
from typing import AsyncGenerator, Generator
|
||||
|
||||
# ============================================================
|
||||
# ENV VARS — antes de importar la app
|
||||
# ============================================================
|
||||
os.environ.setdefault("ENVIRONMENT", "testing")
|
||||
os.environ.setdefault("TESTING", "true")
|
||||
os.environ.setdefault("DEBUG", "false")
|
||||
os.environ.setdefault("SECRET_KEY", "integration-secret-key-32chars!!!!")
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "integration-jwt-secret-32chars!!!!")
|
||||
os.environ.setdefault(
|
||||
"DATABASE_URL",
|
||||
"postgresql+asyncpg://servicemanager:servicemanager123@localhost:5432/servicemanager",
|
||||
)
|
||||
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/14")
|
||||
os.environ.setdefault("CELERY_BROKER_URL", "redis://localhost:6379/14")
|
||||
os.environ.setdefault("CELERY_RESULT_BACKEND", "redis://localhost:6379/14")
|
||||
os.environ.setdefault("CORS_ORIGINS", "http://localhost:3000")
|
||||
os.environ.setdefault("ALLOWED_FILE_EXTENSIONS", "pdf,jpg,jpeg,png,doc,docx,txt")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# EVENT LOOP (session-scoped)
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop() -> Generator:
|
||||
"""Event loop compartido para toda la sesión de tests."""
|
||||
policy = asyncio.get_event_loop_policy()
|
||||
loop = policy.new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# ENGINE (session-scoped — reutiliza el pool toda la sesión)
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def engine():
|
||||
"""
|
||||
Conecta al PostgreSQL Docker existente (servicemanager).
|
||||
NO crea ni destruye el schema — la BD ya está lista.
|
||||
"""
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
import app.models # noqa: F401 — registra todos los modelos
|
||||
|
||||
_engine = create_async_engine(os.environ["DATABASE_URL"], echo=False)
|
||||
yield _engine
|
||||
await _engine.dispose()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# DB (function-scoped — rollback para datos creados en el test)
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture
|
||||
async def db(engine) -> AsyncGenerator:
|
||||
"""
|
||||
Sesión con transacción por test.
|
||||
Los datos seedeados son visibles (ya están committed).
|
||||
Cualquier INSERT hecho en el test se revierte al finalizar.
|
||||
"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with factory() as session:
|
||||
await session.begin()
|
||||
yield session
|
||||
await session.rollback()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# TENANT (function-scoped — lee el registro existente)
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture
|
||||
async def tenant_a(db):
|
||||
"""Tenant 'aduanasoft' ya existente en la BD."""
|
||||
from sqlalchemy import select
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
result = await db.execute(select(Tenant).where(Tenant.slug == "aduanasoft"))
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# USUARIOS (function-scoped — leen registros existentes)
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_user(db, tenant_a):
|
||||
"""ADMIN: admin@aduanasoft.com (tenant aduanasoft)."""
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.email == "admin@aduanasoft.com")
|
||||
.where(User.tenant_id == tenant_a.id)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def manager_user(db, tenant_a):
|
||||
"""SUPPORT_MANAGER: manager@aduanasoft.com (tenant aduanasoft)."""
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.email == "manager@aduanasoft.com")
|
||||
.where(User.tenant_id == tenant_a.id)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def agent_user(db, tenant_a):
|
||||
"""AGENT: agente@aduanasoft.com (tenant aduanasoft)."""
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.email == "agente@aduanasoft.com")
|
||||
.where(User.tenant_id == tenant_a.id)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def user_tenant_a(db, tenant_a):
|
||||
"""CLIENT_USER: test_user@aduanasoft.com (tenant aduanasoft)."""
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.email == "test_user@aduanasoft.com")
|
||||
.where(User.tenant_id == tenant_a.id)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# HTTP CLIENT (function-scoped)
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture
|
||||
async def client(db) -> AsyncGenerator:
|
||||
"""
|
||||
httpx.AsyncClient contra la app FastAPI en memoria (sin red).
|
||||
get_db queda sobreescrito para inyectar la sesión de test.
|
||||
Los cambios del test se revierten al terminar (rollback en db).
|
||||
"""
|
||||
import httpx
|
||||
from httpx import ASGITransport
|
||||
from app.main import app
|
||||
from app.core.database import get_db
|
||||
|
||||
async def _override_get_db():
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_db] = _override_get_db
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
) as ac:
|
||||
yield ac
|
||||
|
||||
app.dependency_overrides.pop(get_db, None)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# FIXTURES DE AISLAMIENTO MULTI-TENANT
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture
|
||||
def make_token():
|
||||
"""Factory de JWT tokens para autenticar clientes HTTP en tests."""
|
||||
from app.core.security import security
|
||||
|
||||
def _make(user):
|
||||
return security.create_access_token(data={"sub": str(user.id)})
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def app_with_db(db):
|
||||
"""
|
||||
Override de get_db compartido para todos los HTTP clients de un mismo test.
|
||||
Garantiza que todos los clients usen la misma sesión (y el mismo rollback).
|
||||
"""
|
||||
from app.main import app as _app
|
||||
from app.core.database import get_db
|
||||
|
||||
async def _override():
|
||||
yield db
|
||||
|
||||
_app.dependency_overrides[get_db] = _override
|
||||
yield _app
|
||||
_app.dependency_overrides.pop(get_db, None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def tenant_b(db):
|
||||
"""Tenant 'empresa-test' creado en la transacción del test (se revierte al final)."""
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
t = Tenant(name="Empresa Test", slug="empresa-test")
|
||||
db.add(t)
|
||||
await db.flush()
|
||||
return t
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def user_b(db, tenant_b):
|
||||
"""CLIENT_ADMIN en tenant_b — puede gestionar recursos de su tenant."""
|
||||
from app.models.user import User, UserRole
|
||||
from app.core.security import security
|
||||
|
||||
u = User(
|
||||
tenant_id=tenant_b.id,
|
||||
email="admin@empresa-test.com",
|
||||
first_name="Admin",
|
||||
last_name="Test",
|
||||
password_hash=security.hash_password("Test1234!"),
|
||||
role=UserRole.CLIENT_ADMIN,
|
||||
is_active=True,
|
||||
email_verified=True,
|
||||
)
|
||||
db.add(u)
|
||||
await db.flush()
|
||||
return u
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client_tenant_a(app_with_db, manager_user, make_token):
|
||||
"""HTTP client autenticado como SUPPORT_MANAGER de tenant_a (aduanasoft).
|
||||
Usa manager_user en lugar de admin_user para mantener el aislamiento de
|
||||
tenant en GET /users/ (el ADMIN global bypasa el filtro de tenant).
|
||||
"""
|
||||
import httpx
|
||||
from httpx import ASGITransport
|
||||
|
||||
token = make_token(manager_user)
|
||||
async with httpx.AsyncClient(
|
||||
transport=ASGITransport(app=app_with_db),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
) as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client_tenant_b(app_with_db, user_b, make_token):
|
||||
"""HTTP client autenticado como CLIENT_ADMIN de tenant_b (empresa-test)."""
|
||||
import httpx
|
||||
from httpx import ASGITransport
|
||||
|
||||
token = make_token(user_b)
|
||||
async with httpx.AsyncClient(
|
||||
transport=ASGITransport(app=app_with_db),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
) as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client_admin(app_with_db, admin_user, make_token):
|
||||
"""HTTP client autenticado como ADMIN global."""
|
||||
import httpx
|
||||
from httpx import ASGITransport
|
||||
|
||||
token = make_token(admin_user)
|
||||
async with httpx.AsyncClient(
|
||||
transport=ASGITransport(app=app_with_db),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
) as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_ticket_tenant_a(client_tenant_a):
|
||||
"""Factory: crea un ticket en tenant_a vía HTTP y retorna el JSON de respuesta."""
|
||||
async def _create(subject="Ticket Tenant A", priority="MEDIUM"):
|
||||
resp = await client_tenant_a.post("/v1/tickets/", json={
|
||||
"subject": subject,
|
||||
"description": "Test de aislamiento tenant A",
|
||||
"priority": priority,
|
||||
})
|
||||
assert resp.status_code in (200, 201), f"Error creando ticket A: {resp.text}"
|
||||
return resp.json()
|
||||
|
||||
return _create
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_ticket_tenant_b(client_tenant_b):
|
||||
"""Factory: crea un ticket en tenant_b vía HTTP y retorna el JSON de respuesta."""
|
||||
async def _create(subject="Ticket Tenant B", priority="MEDIUM"):
|
||||
resp = await client_tenant_b.post("/v1/tickets/", json={
|
||||
"subject": subject,
|
||||
"description": "Test de aislamiento tenant B",
|
||||
"priority": priority,
|
||||
})
|
||||
assert resp.status_code in (200, 201), f"Error creando ticket B: {resp.text}"
|
||||
return resp.json()
|
||||
|
||||
return _create
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_user_tenant_a(client_tenant_a):
|
||||
"""Factory: crea un usuario en tenant_a vía HTTP y retorna el JSON de respuesta."""
|
||||
async def _create(email="nuevo_user_a@test.com"):
|
||||
resp = await client_tenant_a.post("/v1/users/", json={
|
||||
"email": email,
|
||||
"first_name": "Usuario",
|
||||
"last_name": "TenantA",
|
||||
"password": "Test1234!",
|
||||
"role": "CLIENT_USER",
|
||||
})
|
||||
assert resp.status_code in (200, 201), f"Error creando user A: {resp.text}"
|
||||
return resp.json()
|
||||
|
||||
return _create
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_user_tenant_b(client_tenant_b):
|
||||
"""Factory: crea un usuario en tenant_b vía HTTP y retorna el JSON de respuesta."""
|
||||
async def _create(email="nuevo_user_b@test.com"):
|
||||
resp = await client_tenant_b.post("/v1/users/", json={
|
||||
"email": email,
|
||||
"first_name": "Usuario",
|
||||
"last_name": "TenantB",
|
||||
"password": "Test1234!",
|
||||
"role": "CLIENT_USER",
|
||||
})
|
||||
assert resp.status_code in (200, 201), f"Error creando user B: {resp.text}"
|
||||
return resp.json()
|
||||
|
||||
return _create
|
||||
137
backend/app/tests/test_smoke.py
Normal file
137
backend/app/tests/test_smoke.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Smoke Tests - ServiceManagerWeb
|
||||
|
||||
Verifican que el stack completo funciona:
|
||||
- Conexión a BD Docker
|
||||
- Fixtures de tenant y usuarios
|
||||
- Login vía HTTP (httpx + FastAPI en memoria)
|
||||
- Endpoint protegido con token
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ============================================================
|
||||
# BD + FIXTURES
|
||||
# ============================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_db_connected(db):
|
||||
"""La sesión de BD está activa y responde."""
|
||||
from sqlalchemy import text
|
||||
result = await db.execute(text("SELECT 1"))
|
||||
assert result.scalar() == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tenant_a_existe(tenant_a):
|
||||
"""El tenant 'aduanasoft' existe y tiene datos válidos."""
|
||||
assert tenant_a.slug == "aduanasoft"
|
||||
assert tenant_a.name is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_user_existe(admin_user):
|
||||
"""El usuario ADMIN existe y pertenece al tenant correcto."""
|
||||
from app.models.user import UserRole
|
||||
assert admin_user.email == "admin@aduanasoft.com"
|
||||
assert admin_user.role == UserRole.ADMIN
|
||||
assert admin_user.is_active is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_user_existe(manager_user):
|
||||
"""El usuario SUPPORT_MANAGER existe."""
|
||||
from app.models.user import UserRole
|
||||
assert manager_user.email == "manager@aduanasoft.com"
|
||||
assert manager_user.role == UserRole.SUPPORT_MANAGER
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_user_existe(agent_user):
|
||||
"""El usuario AGENT existe."""
|
||||
from app.models.user import UserRole
|
||||
assert agent_user.email == "agente@aduanasoft.com"
|
||||
assert agent_user.role == UserRole.AGENT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_user_existe(user_tenant_a):
|
||||
"""El CLIENT_USER existe."""
|
||||
from app.models.user import UserRole
|
||||
assert user_tenant_a.email == "test_user@aduanasoft.com"
|
||||
assert user_tenant_a.role == UserRole.CLIENT_USER
|
||||
|
||||
|
||||
# ============================================================
|
||||
# HTTP — LOGIN
|
||||
# ============================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_admin_ok(client):
|
||||
"""Login con credenciales de admin devuelve access_token."""
|
||||
response = await client.post(
|
||||
"/v1/auth/login",
|
||||
json={
|
||||
"email": "admin@aduanasoft.com",
|
||||
"password": "admin123",
|
||||
"tenant_slug": "aduanasoft",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_credenciales_invalidas(client):
|
||||
"""Login con contraseña incorrecta devuelve 401."""
|
||||
response = await client.post(
|
||||
"/v1/auth/login",
|
||||
json={
|
||||
"email": "admin@aduanasoft.com",
|
||||
"password": "wrongpassword",
|
||||
"tenant_slug": "aduanasoft",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_endpoint_sin_token_devuelve_401(client):
|
||||
"""Acceder a un endpoint protegido sin token devuelve 401."""
|
||||
response = await client.get(
|
||||
"/v1/users/me",
|
||||
headers={"X-Tenant-Slug": "aduanasoft"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_y_me(client):
|
||||
"""Login exitoso → /users/me devuelve el usuario correcto."""
|
||||
# Login
|
||||
login = await client.post(
|
||||
"/v1/auth/login",
|
||||
json={
|
||||
"email": "admin@aduanasoft.com",
|
||||
"password": "admin123",
|
||||
"tenant_slug": "aduanasoft",
|
||||
},
|
||||
)
|
||||
assert login.status_code == 200
|
||||
token = login.json()["access_token"]
|
||||
|
||||
# Endpoint protegido
|
||||
me = await client.get(
|
||||
"/v1/users/me",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-Tenant-Slug": "aduanasoft",
|
||||
},
|
||||
)
|
||||
assert me.status_code == 200
|
||||
data = me.json()
|
||||
assert data["email"] == "admin@aduanasoft.com"
|
||||
assert data["role"] == "ADMIN"
|
||||
123
backend/app/tests/test_tenant_isolation.py
Normal file
123
backend/app/tests/test_tenant_isolation.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
Pruebas de aislamiento multi-tenant para tickets y usuarios.
|
||||
Usa solo los fixtures definidos en conftest.py.
|
||||
|
||||
Roles en juego:
|
||||
client_tenant_a → SUPPORT_MANAGER (aduanasoft) — restringido a su tenant
|
||||
client_tenant_b → CLIENT_ADMIN (empresa-test) — restringido a su tenant
|
||||
client_admin → ADMIN global (aduanasoft) — acceso a todos los tenants
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tenant_a_cannot_see_tenant_b_tickets(
|
||||
client_tenant_a, create_ticket_tenant_b
|
||||
):
|
||||
"""
|
||||
El usuario del tenant B crea un ticket.
|
||||
El usuario del tenant A (SUPPORT_MANAGER) lista sus tickets.
|
||||
El ticket de tenant B NO debe aparecer en la respuesta.
|
||||
"""
|
||||
# El usuario del tenant B crea un ticket
|
||||
ticket_b = await create_ticket_tenant_b()
|
||||
ticket_b_id = ticket_b["id"]
|
||||
|
||||
# El usuario del tenant A lista sus tickets
|
||||
response = await client_tenant_a.get("/v1/tickets/")
|
||||
assert response.status_code == 200
|
||||
|
||||
ids_visibles = {t["id"] for t in response.json()}
|
||||
|
||||
# El ticket de tenant B no debe ser visible para tenant A
|
||||
assert ticket_b_id not in ids_visibles, (
|
||||
f"Fallo de aislamiento: ticket de tenant B ({ticket_b_id}) "
|
||||
f"visible para usuario de tenant A"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tenant_b_cannot_edit_tenant_a_ticket(
|
||||
create_ticket_tenant_a, client_tenant_b
|
||||
):
|
||||
"""
|
||||
El usuario del tenant A crea un ticket.
|
||||
El usuario del tenant B intenta editar ese ticket vía PATCH.
|
||||
Debe recibir 403 (prohibido) o 404 (no encontrado).
|
||||
"""
|
||||
# El usuario del tenant A crea un ticket
|
||||
ticket_a = await create_ticket_tenant_a()
|
||||
ticket_a_id = ticket_a["id"]
|
||||
|
||||
# El usuario del tenant B intenta editar el ticket de tenant A
|
||||
response = await client_tenant_b.patch(
|
||||
f"/v1/tickets/{ticket_a_id}",
|
||||
json={"status": "CLOSED"},
|
||||
)
|
||||
|
||||
# Debe recibir 403 o 404 — nunca 200
|
||||
assert response.status_code in (403, 404), (
|
||||
f"Fallo de aislamiento: tenant B pudo editar ticket de tenant A "
|
||||
f"(HTTP {response.status_code})"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tenant_a_cannot_see_tenant_b_users(
|
||||
client_tenant_a, create_user_tenant_b
|
||||
):
|
||||
"""
|
||||
El usuario del tenant B crea un usuario nuevo.
|
||||
El usuario del tenant A (SUPPORT_MANAGER) lista los usuarios.
|
||||
El usuario de tenant B NO debe aparecer en la respuesta.
|
||||
"""
|
||||
# El usuario del tenant B crea un usuario
|
||||
user_b = await create_user_tenant_b()
|
||||
user_b_id = user_b["id"]
|
||||
|
||||
# El usuario del tenant A lista los usuarios de su tenant
|
||||
response = await client_tenant_a.get("/v1/users/")
|
||||
assert response.status_code == 200
|
||||
|
||||
ids_visibles = {u["id"] for u in response.json()}
|
||||
|
||||
# El usuario de tenant B no debe ser visible para tenant A
|
||||
assert user_b_id not in ids_visibles, (
|
||||
f"Fallo de aislamiento: usuario de tenant B ({user_b_id}) "
|
||||
f"visible para usuario de tenant A"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_sees_all_tenant_data(
|
||||
client_admin,
|
||||
create_ticket_tenant_a,
|
||||
create_ticket_tenant_b,
|
||||
create_user_tenant_a,
|
||||
create_user_tenant_b,
|
||||
):
|
||||
"""
|
||||
El ADMIN global debe poder ver tickets y usuarios de TODOS los tenants.
|
||||
- Tickets: vía /v1/tickets/admin/all (endpoint multi-tenant).
|
||||
- Usuarios: vía /v1/users/ (ADMIN bypasa el filtro de tenant).
|
||||
"""
|
||||
# Crear datos en ambos tenants
|
||||
ticket_a = await create_ticket_tenant_a()
|
||||
ticket_b = await create_ticket_tenant_b()
|
||||
user_a = await create_user_tenant_a()
|
||||
user_b = await create_user_tenant_b()
|
||||
|
||||
# El admin lista todos los tickets (endpoint multi-tenant)
|
||||
resp_tickets = await client_admin.get("/v1/tickets/admin/all")
|
||||
assert resp_tickets.status_code == 200
|
||||
ids_tickets = {t["id"] for t in resp_tickets.json()}
|
||||
assert ticket_a["id"] in ids_tickets, "El ADMIN no ve el ticket de tenant A"
|
||||
assert ticket_b["id"] in ids_tickets, "El ADMIN no ve el ticket de tenant B"
|
||||
|
||||
# El admin lista todos los usuarios (ADMIN bypasa filtro de tenant)
|
||||
resp_users = await client_admin.get("/v1/users/")
|
||||
assert resp_users.status_code == 200
|
||||
ids_users = {u["id"] for u in resp_users.json()}
|
||||
assert user_a["id"] in ids_users, "El ADMIN no ve el usuario de tenant A"
|
||||
assert user_b["id"] in ids_users, "El ADMIN no ve el usuario de tenant B"
|
||||
|
||||
3
backend/check_schema.py
Normal file
3
backend/check_schema.py
Normal file
@@ -0,0 +1,3 @@
|
||||
with open("/app/app/api/schemas/category.py", "r") as f:
|
||||
content = f.read()
|
||||
print(content)
|
||||
19
backend/debug_tenant.py
Normal file
19
backend/debug_tenant.py
Normal file
@@ -0,0 +1,19 @@
|
||||
import asyncio
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.models.user import User
|
||||
from app.models.tenant import Tenant
|
||||
from sqlalchemy import select
|
||||
|
||||
async def check():
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(
|
||||
select(User, Tenant).join(Tenant).where(User.email == 'javier@ventas.com')
|
||||
)
|
||||
user, tenant = result.one()
|
||||
print(f"user.tenant_id: {user.tenant_id}")
|
||||
print(f"tenant.id: {tenant.id}")
|
||||
print(f"tenant.slug: {tenant.slug}")
|
||||
print(f"user.role: {user.role}")
|
||||
print(f"role.is_client: {user.role.is_client}")
|
||||
|
||||
asyncio.run(check())
|
||||
28
backend/fix_categories.py
Normal file
28
backend/fix_categories.py
Normal file
@@ -0,0 +1,28 @@
|
||||
with open("/app/app/api/v1/endpoints/categories.py", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
old = ''' # ✅ CORREGIDO: Asignar tenant_id del usuario actual
|
||||
db_category = Category(
|
||||
**category.model_dump(),
|
||||
tenant_id=current_user.tenant_id # ✅ Multi-tenancy automático
|
||||
)'''
|
||||
|
||||
new = ''' # ADMIN global puede especificar tenant_id; otros usan el suyo
|
||||
from app.models.user import UserRole as _R
|
||||
data = category.model_dump()
|
||||
if current_user.role == _R.ADMIN and data.get("tenant_id"):
|
||||
target_tenant_id = data["tenant_id"]
|
||||
else:
|
||||
target_tenant_id = current_user.tenant_id
|
||||
data["tenant_id"] = target_tenant_id
|
||||
|
||||
db_category = Category(**data)'''
|
||||
|
||||
if old in content:
|
||||
content = content.replace(old, new)
|
||||
print("OK: fix aplicado")
|
||||
else:
|
||||
print("ERROR: bloque no encontrado")
|
||||
|
||||
with open("/app/app/api/v1/endpoints/categories.py", "w") as f:
|
||||
f.write(content)
|
||||
15
backend/fix_delete.py
Normal file
15
backend/fix_delete.py
Normal file
@@ -0,0 +1,15 @@
|
||||
with open("/app/app/api/v1/endpoints/users.py", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
old = ' # Verificar permisos - solo ADMIN puede eliminar\n if current_user.role != UserRole.ADMIN:\n raise HTTPException(\n status_code=status.HTTP_403_FORBIDDEN,\n detail="Only admins can delete users"\n )'
|
||||
|
||||
new = ' # Verificar permisos - ADMIN global o CLIENT_ADMIN pueden eliminar\n allowed = [UserRole.ADMIN, UserRole.CLIENT_ADMIN]\n if current_user.role not in allowed:\n raise HTTPException(\n status_code=status.HTTP_403_FORBIDDEN,\n detail="Only admins can delete users"\n )'
|
||||
|
||||
if old in content:
|
||||
content = content.replace(old, new)
|
||||
print("OK: permisos delete actualizados")
|
||||
else:
|
||||
print("ERROR: bloque no encontrado")
|
||||
|
||||
with open("/app/app/api/v1/endpoints/users.py", "w") as f:
|
||||
f.write(content)
|
||||
14
backend/fix_delete2.py
Normal file
14
backend/fix_delete2.py
Normal file
@@ -0,0 +1,14 @@
|
||||
with open("/app/app/api/v1/endpoints/users.py", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
old = " # Soft delete\n db_user.is_active = False\n await db.commit()"
|
||||
new = " # Hard delete\n await db.delete(db_user)\n await db.commit()"
|
||||
|
||||
if old in content:
|
||||
content = content.replace(old, new)
|
||||
print("OK: hard delete aplicado")
|
||||
else:
|
||||
print("ERROR: bloque no encontrado")
|
||||
|
||||
with open("/app/app/api/v1/endpoints/users.py", "w") as f:
|
||||
f.write(content)
|
||||
34
backend/fix_router.py
Normal file
34
backend/fix_router.py
Normal file
@@ -0,0 +1,34 @@
|
||||
with open("/app/app/api/v1/router.py", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
old = "from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla, reports"
|
||||
new = "from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla, reports, participants"
|
||||
|
||||
old2 = "# Tickets routes\napi_router.include_router(\n tickets.router,\n prefix=\"/tickets\",\n tags=[\"tickets\"]\n)"
|
||||
new2 = """# Tickets routes
|
||||
api_router.include_router(
|
||||
tickets.router,
|
||||
prefix="/tickets",
|
||||
tags=["tickets"]
|
||||
)
|
||||
# Participants routes (nested under tickets)
|
||||
api_router.include_router(
|
||||
participants.router,
|
||||
prefix="/tickets",
|
||||
tags=["participants"]
|
||||
)"""
|
||||
|
||||
if old in content:
|
||||
content = content.replace(old, new)
|
||||
print("OK: import agregado")
|
||||
else:
|
||||
print("ERROR: import no encontrado")
|
||||
|
||||
if old2 in content:
|
||||
content = content.replace(old2, new2)
|
||||
print("OK: router agregado")
|
||||
else:
|
||||
print("ERROR: router no encontrado")
|
||||
|
||||
with open("/app/app/api/v1/router.py", "w") as f:
|
||||
f.write(content)
|
||||
30
backend/fix_schema.py
Normal file
30
backend/fix_schema.py
Normal file
@@ -0,0 +1,30 @@
|
||||
with open("/app/app/api/schemas/category.py", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
old = '''class CategoryCreate(BaseModel):
|
||||
"""Schema para crear categoría. No incluye tenant_id (se asigna automáticamente)."""
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
sla_response_hours: int = 24
|
||||
sla_resolution_hours: int = 72
|
||||
auto_assign_to: Optional[uuid.UUID] = None'''
|
||||
|
||||
new = '''class CategoryCreate(BaseModel):
|
||||
"""Schema para crear categoría. tenant_id opcional para ADMIN global."""
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
sla_response_hours: int = 24
|
||||
sla_resolution_hours: int = 72
|
||||
auto_assign_to: Optional[uuid.UUID] = None
|
||||
tenant_id: Optional[uuid.UUID] = None'''
|
||||
|
||||
if old in content:
|
||||
content = content.replace(old, new)
|
||||
print("OK")
|
||||
else:
|
||||
print("ERROR: bloque no encontrado")
|
||||
|
||||
with open("/app/app/api/schemas/category.py", "w") as f:
|
||||
f.write(content)
|
||||
@@ -0,0 +1,282 @@
|
||||
"""add_tenant_permissions
|
||||
|
||||
Revision ID: 0aec0a6e294a
|
||||
Revises: b2dcb926e091
|
||||
Create Date: 2026-03-17 19:33:30.229993
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '0aec0a6e294a'
|
||||
down_revision = 'b2dcb926e091'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index('idx_email_templates_tenant_id', table_name='email_templates')
|
||||
op.drop_table('email_templates')
|
||||
op.drop_index('idx_ticket_status_history_changed_by', table_name='ticket_status_history')
|
||||
op.drop_index('idx_ticket_status_history_created_at', table_name='ticket_status_history')
|
||||
op.drop_index('idx_ticket_status_history_ticket_id', table_name='ticket_status_history')
|
||||
op.drop_table('ticket_status_history')
|
||||
op.drop_index('idx_notification_logs_created_at', table_name='notification_logs')
|
||||
op.drop_index('idx_notification_logs_recipient', table_name='notification_logs')
|
||||
op.drop_index('idx_notification_logs_status', table_name='notification_logs')
|
||||
op.drop_index('idx_notification_logs_tenant_id', table_name='notification_logs')
|
||||
op.drop_index('idx_notification_logs_ticket_id', table_name='notification_logs')
|
||||
op.drop_table('notification_logs')
|
||||
op.add_column('audit_logs', sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False))
|
||||
op.alter_column('audit_logs', 'created_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('now()'))
|
||||
op.drop_index('idx_audit_logs_action', table_name='audit_logs')
|
||||
op.drop_index('idx_audit_logs_correlation_id', table_name='audit_logs')
|
||||
op.drop_index('idx_audit_logs_created_at', table_name='audit_logs')
|
||||
op.drop_index('idx_audit_logs_tenant_id', table_name='audit_logs')
|
||||
op.drop_index('idx_audit_logs_user_id', table_name='audit_logs')
|
||||
op.create_index('idx_audit_logs_tenant_action', 'audit_logs', ['tenant_id', 'action'], unique=False)
|
||||
op.create_index('idx_audit_logs_user_created', 'audit_logs', ['user_id', 'created_at'], unique=False)
|
||||
op.create_index(op.f('ix_audit_logs_action'), 'audit_logs', ['action'], unique=False)
|
||||
op.create_index(op.f('ix_audit_logs_correlation_id'), 'audit_logs', ['correlation_id'], unique=False)
|
||||
op.create_index(op.f('ix_audit_logs_created_at'), 'audit_logs', ['created_at'], unique=False)
|
||||
op.create_index(op.f('ix_audit_logs_resource_type'), 'audit_logs', ['resource_type'], unique=False)
|
||||
op.create_index(op.f('ix_audit_logs_tenant_id'), 'audit_logs', ['tenant_id'], unique=False)
|
||||
op.create_index(op.f('ix_audit_logs_user_id'), 'audit_logs', ['user_id'], unique=False)
|
||||
op.drop_constraint('audit_logs_user_id_fkey', 'audit_logs', type_='foreignkey')
|
||||
op.drop_constraint('audit_logs_tenant_id_fkey', 'audit_logs', type_='foreignkey')
|
||||
op.create_foreign_key(None, 'audit_logs', 'users', ['user_id'], ['id'], ondelete='SET NULL')
|
||||
op.create_foreign_key(None, 'audit_logs', 'tenants', ['tenant_id'], ['id'], ondelete='CASCADE')
|
||||
op.drop_table_comment(
|
||||
'audit_logs',
|
||||
existing_comment='Bit??cora de acciones para auditor??a y compliance',
|
||||
schema=None
|
||||
)
|
||||
op.drop_index('idx_refresh_tokens_expires', table_name='refresh_tokens')
|
||||
op.drop_index('idx_refresh_tokens_user_id', table_name='refresh_tokens')
|
||||
op.create_index('idx_refresh_tokens_user_expires', 'refresh_tokens', ['user_id', 'expires_at'], unique=False)
|
||||
op.create_index(op.f('ix_refresh_tokens_expires_at'), 'refresh_tokens', ['expires_at'], unique=False)
|
||||
op.create_index(op.f('ix_refresh_tokens_user_id'), 'refresh_tokens', ['user_id'], unique=False)
|
||||
op.drop_index('idx_tenants_domain', table_name='tenants')
|
||||
op.drop_index('idx_tenants_slug', table_name='tenants')
|
||||
op.drop_index('idx_tenants_status', table_name='tenants')
|
||||
op.drop_table_comment(
|
||||
'tenants',
|
||||
existing_comment='Organizaciones cliente en el sistema multi-tenant',
|
||||
schema=None
|
||||
)
|
||||
op.drop_index('idx_ticket_attachments_comment_id', table_name='ticket_attachments')
|
||||
op.drop_index('idx_ticket_attachments_ticket_id', table_name='ticket_attachments')
|
||||
op.drop_index('idx_ticket_attachments_uploaded_by', table_name='ticket_attachments')
|
||||
op.drop_table_comment(
|
||||
'ticket_attachments',
|
||||
existing_comment='Archivos adjuntos en tickets',
|
||||
schema=None
|
||||
)
|
||||
op.drop_index('idx_ticket_comments_author_id', table_name='ticket_comments')
|
||||
op.drop_index('idx_ticket_comments_created_at', table_name='ticket_comments')
|
||||
op.drop_index('idx_ticket_comments_ticket_id', table_name='ticket_comments')
|
||||
op.drop_table_comment(
|
||||
'ticket_comments',
|
||||
existing_comment='Comentarios en tickets',
|
||||
schema=None
|
||||
)
|
||||
op.drop_index('idx_tickets_assigned_to', table_name='tickets', postgresql_where='(assigned_to IS NOT NULL)')
|
||||
op.drop_index('idx_tickets_category', table_name='tickets')
|
||||
op.drop_index('idx_tickets_created_at', table_name='tickets')
|
||||
op.drop_index('idx_tickets_created_by', table_name='tickets')
|
||||
op.drop_index('idx_tickets_number', table_name='tickets')
|
||||
op.drop_index('idx_tickets_priority', table_name='tickets')
|
||||
op.drop_index('idx_tickets_sla_resolution', table_name='tickets')
|
||||
op.drop_index('idx_tickets_sla_response', table_name='tickets')
|
||||
op.drop_index('idx_tickets_status', table_name='tickets')
|
||||
op.drop_index('idx_tickets_tenant_id', table_name='tickets')
|
||||
op.drop_index('idx_tickets_tenant_status', table_name='tickets')
|
||||
op.drop_table_comment(
|
||||
'tickets',
|
||||
existing_comment='Tickets de soporte - core del negocio',
|
||||
schema=None
|
||||
)
|
||||
op.drop_index('idx_users_active', table_name='users')
|
||||
op.drop_index('idx_users_email', table_name='users')
|
||||
op.drop_index('idx_users_role', table_name='users')
|
||||
op.drop_index('idx_users_tenant_email', table_name='users')
|
||||
op.drop_index('idx_users_tenant_id', table_name='users')
|
||||
op.drop_table_comment(
|
||||
'users',
|
||||
existing_comment='Usuarios del sistema (internos y clientes)',
|
||||
schema=None
|
||||
)
|
||||
op.drop_column('users', 'backup_codes_temp')
|
||||
op.drop_column('users', 'password_changed_at')
|
||||
op.drop_column('users', 'totp_secret_temp')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('users', sa.Column('totp_secret_temp', sa.VARCHAR(length=32), autoincrement=False, nullable=True))
|
||||
op.add_column('users', sa.Column('password_changed_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True))
|
||||
op.add_column('users', sa.Column('backup_codes_temp', postgresql.ARRAY(sa.VARCHAR()), autoincrement=False, nullable=True))
|
||||
op.create_table_comment(
|
||||
'users',
|
||||
'Usuarios del sistema (internos y clientes)',
|
||||
existing_comment=None,
|
||||
schema=None
|
||||
)
|
||||
op.create_index('idx_users_tenant_id', 'users', ['tenant_id'], unique=False)
|
||||
op.create_index('idx_users_tenant_email', 'users', ['tenant_id', 'email'], unique=False)
|
||||
op.create_index('idx_users_role', 'users', ['role'], unique=False)
|
||||
op.create_index('idx_users_email', 'users', ['email'], unique=False)
|
||||
op.create_index('idx_users_active', 'users', ['is_active'], unique=False)
|
||||
op.create_table_comment(
|
||||
'tickets',
|
||||
'Tickets de soporte - core del negocio',
|
||||
existing_comment=None,
|
||||
schema=None
|
||||
)
|
||||
op.create_index('idx_tickets_tenant_status', 'tickets', ['tenant_id', 'status'], unique=False)
|
||||
op.create_index('idx_tickets_tenant_id', 'tickets', ['tenant_id'], unique=False)
|
||||
op.create_index('idx_tickets_status', 'tickets', ['status'], unique=False)
|
||||
op.create_index('idx_tickets_sla_response', 'tickets', ['sla_response_due'], unique=False)
|
||||
op.create_index('idx_tickets_sla_resolution', 'tickets', ['sla_resolution_due'], unique=False)
|
||||
op.create_index('idx_tickets_priority', 'tickets', ['priority'], unique=False)
|
||||
op.create_index('idx_tickets_number', 'tickets', ['ticket_number'], unique=False)
|
||||
op.create_index('idx_tickets_created_by', 'tickets', ['created_by'], unique=False)
|
||||
op.create_index('idx_tickets_created_at', 'tickets', ['created_at'], unique=False)
|
||||
op.create_index('idx_tickets_category', 'tickets', ['category_id'], unique=False)
|
||||
op.create_index('idx_tickets_assigned_to', 'tickets', ['assigned_to'], unique=False, postgresql_where='(assigned_to IS NOT NULL)')
|
||||
op.create_table_comment(
|
||||
'ticket_comments',
|
||||
'Comentarios en tickets',
|
||||
existing_comment=None,
|
||||
schema=None
|
||||
)
|
||||
op.create_index('idx_ticket_comments_ticket_id', 'ticket_comments', ['ticket_id'], unique=False)
|
||||
op.create_index('idx_ticket_comments_created_at', 'ticket_comments', ['created_at'], unique=False)
|
||||
op.create_index('idx_ticket_comments_author_id', 'ticket_comments', ['author_id'], unique=False)
|
||||
op.create_table_comment(
|
||||
'ticket_attachments',
|
||||
'Archivos adjuntos en tickets',
|
||||
existing_comment=None,
|
||||
schema=None
|
||||
)
|
||||
op.create_index('idx_ticket_attachments_uploaded_by', 'ticket_attachments', ['uploaded_by'], unique=False)
|
||||
op.create_index('idx_ticket_attachments_ticket_id', 'ticket_attachments', ['ticket_id'], unique=False)
|
||||
op.create_index('idx_ticket_attachments_comment_id', 'ticket_attachments', ['comment_id'], unique=False)
|
||||
op.create_table_comment(
|
||||
'tenants',
|
||||
'Organizaciones cliente en el sistema multi-tenant',
|
||||
existing_comment=None,
|
||||
schema=None
|
||||
)
|
||||
op.create_index('idx_tenants_status', 'tenants', ['status'], unique=False)
|
||||
op.create_index('idx_tenants_slug', 'tenants', ['slug'], unique=False)
|
||||
op.create_index('idx_tenants_domain', 'tenants', ['domain'], unique=False)
|
||||
op.drop_index(op.f('ix_refresh_tokens_user_id'), table_name='refresh_tokens')
|
||||
op.drop_index(op.f('ix_refresh_tokens_expires_at'), table_name='refresh_tokens')
|
||||
op.drop_index('idx_refresh_tokens_user_expires', table_name='refresh_tokens')
|
||||
op.create_index('idx_refresh_tokens_user_id', 'refresh_tokens', ['user_id'], unique=False)
|
||||
op.create_index('idx_refresh_tokens_expires', 'refresh_tokens', ['expires_at'], unique=False)
|
||||
op.create_table_comment(
|
||||
'audit_logs',
|
||||
'Bit??cora de acciones para auditor??a y compliance',
|
||||
existing_comment=None,
|
||||
schema=None
|
||||
)
|
||||
op.drop_constraint(None, 'audit_logs', type_='foreignkey')
|
||||
op.drop_constraint(None, 'audit_logs', type_='foreignkey')
|
||||
op.create_foreign_key('audit_logs_tenant_id_fkey', 'audit_logs', 'tenants', ['tenant_id'], ['id'])
|
||||
op.create_foreign_key('audit_logs_user_id_fkey', 'audit_logs', 'users', ['user_id'], ['id'])
|
||||
op.drop_index(op.f('ix_audit_logs_user_id'), table_name='audit_logs')
|
||||
op.drop_index(op.f('ix_audit_logs_tenant_id'), table_name='audit_logs')
|
||||
op.drop_index(op.f('ix_audit_logs_resource_type'), table_name='audit_logs')
|
||||
op.drop_index(op.f('ix_audit_logs_created_at'), table_name='audit_logs')
|
||||
op.drop_index(op.f('ix_audit_logs_correlation_id'), table_name='audit_logs')
|
||||
op.drop_index(op.f('ix_audit_logs_action'), table_name='audit_logs')
|
||||
op.drop_index('idx_audit_logs_user_created', table_name='audit_logs')
|
||||
op.drop_index('idx_audit_logs_tenant_action', table_name='audit_logs')
|
||||
op.create_index('idx_audit_logs_user_id', 'audit_logs', ['user_id'], unique=False)
|
||||
op.create_index('idx_audit_logs_tenant_id', 'audit_logs', ['tenant_id'], unique=False)
|
||||
op.create_index('idx_audit_logs_created_at', 'audit_logs', ['created_at'], unique=False)
|
||||
op.create_index('idx_audit_logs_correlation_id', 'audit_logs', ['correlation_id'], unique=False)
|
||||
op.create_index('idx_audit_logs_action', 'audit_logs', ['action'], unique=False)
|
||||
op.alter_column('audit_logs', 'created_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('now()'))
|
||||
op.drop_column('audit_logs', 'updated_at')
|
||||
op.create_table('notification_logs',
|
||||
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
|
||||
sa.Column('tenant_id', sa.UUID(), autoincrement=False, nullable=False),
|
||||
sa.Column('recipient_email', sa.VARCHAR(length=320), autoincrement=False, nullable=False),
|
||||
sa.Column('subject', sa.VARCHAR(length=500), autoincrement=False, nullable=False),
|
||||
sa.Column('template_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('ticket_id', sa.UUID(), autoincrement=False, nullable=True),
|
||||
sa.Column('user_id', sa.UUID(), autoincrement=False, nullable=True),
|
||||
sa.Column('status', sa.VARCHAR(length=20), server_default=sa.text("'pending'::character varying"), autoincrement=False, nullable=True),
|
||||
sa.Column('error_message', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('provider', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('external_id', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
|
||||
sa.Column('sent_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
|
||||
sa.CheckConstraint("status::text = ANY (ARRAY['pending'::character varying, 'sent'::character varying, 'failed'::character varying, 'bounced'::character varying]::text[])", name='notification_logs_status_check'),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name='notification_logs_tenant_id_fkey'),
|
||||
sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], name='notification_logs_ticket_id_fkey'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name='notification_logs_user_id_fkey'),
|
||||
sa.PrimaryKeyConstraint('id', name='notification_logs_pkey')
|
||||
)
|
||||
op.create_index('idx_notification_logs_ticket_id', 'notification_logs', ['ticket_id'], unique=False)
|
||||
op.create_index('idx_notification_logs_tenant_id', 'notification_logs', ['tenant_id'], unique=False)
|
||||
op.create_index('idx_notification_logs_status', 'notification_logs', ['status'], unique=False)
|
||||
op.create_index('idx_notification_logs_recipient', 'notification_logs', ['recipient_email'], unique=False)
|
||||
op.create_index('idx_notification_logs_created_at', 'notification_logs', ['created_at'], unique=False)
|
||||
op.create_table('ticket_status_history',
|
||||
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
|
||||
sa.Column('ticket_id', sa.UUID(), autoincrement=False, nullable=False),
|
||||
sa.Column('changed_by', sa.UUID(), autoincrement=False, nullable=False),
|
||||
sa.Column('old_status', postgresql.ENUM('NEW', 'TRIAGE', 'IN_PROGRESS', 'WAITING_CUSTOMER', 'RESOLVED', 'CLOSED', 'REOPENED', name='ticket_status_enum'), autoincrement=False, nullable=True),
|
||||
sa.Column('new_status', postgresql.ENUM('NEW', 'TRIAGE', 'IN_PROGRESS', 'WAITING_CUSTOMER', 'RESOLVED', 'CLOSED', 'REOPENED', name='ticket_status_enum'), autoincrement=False, nullable=False),
|
||||
sa.Column('old_assigned_to', sa.UUID(), autoincrement=False, nullable=True),
|
||||
sa.Column('new_assigned_to', sa.UUID(), autoincrement=False, nullable=True),
|
||||
sa.Column('comment', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['changed_by'], ['users.id'], name='ticket_status_history_changed_by_fkey'),
|
||||
sa.ForeignKeyConstraint(['new_assigned_to'], ['users.id'], name='ticket_status_history_new_assigned_to_fkey'),
|
||||
sa.ForeignKeyConstraint(['old_assigned_to'], ['users.id'], name='ticket_status_history_old_assigned_to_fkey'),
|
||||
sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], name='ticket_status_history_ticket_id_fkey', ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name='ticket_status_history_pkey')
|
||||
)
|
||||
op.create_index('idx_ticket_status_history_ticket_id', 'ticket_status_history', ['ticket_id'], unique=False)
|
||||
op.create_index('idx_ticket_status_history_created_at', 'ticket_status_history', ['created_at'], unique=False)
|
||||
op.create_index('idx_ticket_status_history_changed_by', 'ticket_status_history', ['changed_by'], unique=False)
|
||||
op.create_table('email_templates',
|
||||
sa.Column('tenant_id', sa.UUID(), autoincrement=False, nullable=False),
|
||||
sa.Column('template_key', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||
sa.Column('name', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
|
||||
sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('subject_template', sa.TEXT(), autoincrement=False, nullable=False),
|
||||
sa.Column('html_template', sa.TEXT(), autoincrement=False, nullable=False),
|
||||
sa.Column('text_template', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=False),
|
||||
sa.Column('is_system', sa.BOOLEAN(), autoincrement=False, nullable=False),
|
||||
sa.Column('required_variables', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True),
|
||||
sa.Column('default_variables', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True),
|
||||
sa.Column('from_name', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
|
||||
sa.Column('from_email', sa.VARCHAR(length=320), autoincrement=False, nullable=True),
|
||||
sa.Column('version', sa.INTEGER(), autoincrement=False, nullable=False),
|
||||
sa.Column('last_used_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True),
|
||||
sa.Column('usage_count', sa.INTEGER(), autoincrement=False, nullable=False),
|
||||
sa.Column('id', sa.UUID(), autoincrement=False, nullable=False),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name='email_templates_tenant_id_fkey', ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name='email_templates_pkey')
|
||||
)
|
||||
op.create_index('idx_email_templates_tenant_id', 'email_templates', ['tenant_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,28 @@
|
||||
"""add_ticket_participants
|
||||
|
||||
Revision ID: 46bccd948688
|
||||
Revises: d2e3f4a5b6c7
|
||||
Create Date: 2026-03-11 17:59:55.409878
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '46bccd948688'
|
||||
down_revision = 'd2e3f4a5b6c7'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,63 @@
|
||||
"""add_ticket_issues
|
||||
|
||||
Revision ID: b2dcb926e091
|
||||
Revises: 46bccd948688
|
||||
Create Date: 2026-03-17 16:25:34.452826
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = 'b2dcb926e091'
|
||||
down_revision = '46bccd948688'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = inspector.get_table_names()
|
||||
|
||||
if 'ticket_issues' not in existing_tables:
|
||||
op.create_table(
|
||||
'ticket_issues',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('tenant_id', sa.UUID(), nullable=False),
|
||||
sa.Column('ticket_id', sa.UUID(), nullable=False),
|
||||
sa.Column('created_by', sa.UUID(), nullable=False),
|
||||
sa.Column('content', sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
'priority',
|
||||
postgresql.ENUM('LOW', 'MEDIUM', 'HIGH', 'URGENT',
|
||||
name='ticket_priority_enum', create_type=False),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('attachment_path', sa.String(500), nullable=True),
|
||||
sa.Column('attachment_filename', sa.String(255), nullable=True),
|
||||
sa.Column('attachment_mime_type', sa.String(100), nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True),
|
||||
server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True),
|
||||
server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['created_by'], ['users.id']),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
if 'ticket_issue_tagged_users' not in existing_tables:
|
||||
op.create_table(
|
||||
'ticket_issue_tagged_users',
|
||||
sa.Column('issue_id', sa.UUID(), nullable=False),
|
||||
sa.Column('user_id', sa.UUID(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['issue_id'], ['ticket_issues.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('issue_id', 'user_id'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('ticket_issue_tagged_users')
|
||||
op.drop_table('ticket_issues')
|
||||
@@ -0,0 +1,43 @@
|
||||
"""add_ticket_indexes
|
||||
|
||||
Revision ID: b7c8d9e0f1a2
|
||||
Revises: fix_client_timestamps
|
||||
Create Date: 2026-03-03 00:00:00.000000
|
||||
|
||||
Agrega índices a la tabla tickets para optimizar queries frecuentes:
|
||||
- idx_tickets_status → filtros por estado
|
||||
- idx_tickets_priority → filtros por prioridad
|
||||
- idx_tickets_assigned_to → tickets por agente asignado
|
||||
- idx_tickets_tenant_status → compuesto multi-tenant (tenant_id, status)
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'b7c8d9e0f1a2'
|
||||
down_revision = 'fix_client_timestamps'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets (status)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_tickets_priority ON tickets (priority)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_tickets_assigned_to "
|
||||
"ON tickets (assigned_to) WHERE assigned_to IS NOT NULL"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_tickets_tenant_status "
|
||||
"ON tickets (tenant_id, status)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS idx_tickets_tenant_status")
|
||||
op.execute("DROP INDEX IF EXISTS idx_tickets_assigned_to")
|
||||
op.execute("DROP INDEX IF EXISTS idx_tickets_priority")
|
||||
op.execute("DROP INDEX IF EXISTS idx_tickets_status")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Add CLIENT_MANAGER, CLIENT_AGENT, CLIENT_AUDITOR to user_role_enum
|
||||
|
||||
Revision ID: c1d2e3f4a5b6
|
||||
Revises: b7c8d9e0f1a2
|
||||
Create Date: 2026-03-03 10:00:00.000000
|
||||
|
||||
Agrega tres nuevos roles de cliente al enum PostgreSQL:
|
||||
- CLIENT_MANAGER → gestiona tickets y usuarios del tenant
|
||||
- CLIENT_AGENT → atiende tickets del tenant
|
||||
- CLIENT_AUDITOR → auditoría de solo lectura del tenant
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'c1d2e3f4a5b6'
|
||||
down_revision = 'b7c8d9e0f1a2'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# PostgreSQL permite agregar valores a un enum con ADD VALUE.
|
||||
# IF NOT EXISTS evita error si la migración se aplica dos veces.
|
||||
op.execute("ALTER TYPE user_role_enum ADD VALUE IF NOT EXISTS 'CLIENT_MANAGER'")
|
||||
op.execute("ALTER TYPE user_role_enum ADD VALUE IF NOT EXISTS 'CLIENT_AGENT'")
|
||||
op.execute("ALTER TYPE user_role_enum ADD VALUE IF NOT EXISTS 'CLIENT_AUDITOR'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# PostgreSQL no permite eliminar valores de un enum con ALTER TYPE DROP VALUE.
|
||||
# Para revertir habría que recrear el tipo completo desde cero, lo que requiere
|
||||
# actualizar todas las columnas que lo usan. Se documenta como no reversible
|
||||
# automáticamente — usar con precaución.
|
||||
pass
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Remove CLIENT_MANAGER, CLIENT_AGENT, CLIENT_AUDITOR from user_role_enum
|
||||
|
||||
Revision ID: d2e3f4a5b6c7
|
||||
Revises: c1d2e3f4a5b6
|
||||
Create Date: 2026-03-03 14:00:00.000000
|
||||
|
||||
Consolida 9 roles → 6 roles migrando datos primero y luego recreando
|
||||
el tipo enum de PostgreSQL (única forma de eliminar valores en PG).
|
||||
|
||||
Mapeo de datos:
|
||||
CLIENT_MANAGER → CLIENT_ADMIN (conserva nivel de gestión)
|
||||
CLIENT_AGENT → CLIENT_USER (acceso básico de cliente)
|
||||
CLIENT_AUDITOR → CLIENT_USER (acceso básico de cliente)
|
||||
|
||||
ADVERTENCIA DOWNGRADE: La migración inversa restaura los valores del
|
||||
enum pero NO puede recuperar la distinción original entre CLIENT_AGENT
|
||||
y CLIENT_AUDITOR (ambos quedaron como CLIENT_USER). El downgrade es
|
||||
seguro a nivel de integridad de datos, pero irreversible en semántica.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'd2e3f4a5b6c7'
|
||||
down_revision = 'c1d2e3f4a5b6'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ── Paso 1: Migrar datos ANTES de modificar el tipo ────────────────────
|
||||
# CLIENT_MANAGER → CLIENT_ADMIN (conserva acceso de gestión)
|
||||
op.execute("UPDATE users SET role = 'CLIENT_ADMIN' WHERE role = 'CLIENT_MANAGER'")
|
||||
# CLIENT_AGENT → CLIENT_USER (acceso básico de cliente)
|
||||
op.execute("UPDATE users SET role = 'CLIENT_USER' WHERE role = 'CLIENT_AGENT'")
|
||||
# CLIENT_AUDITOR → CLIENT_USER (acceso básico de cliente)
|
||||
op.execute("UPDATE users SET role = 'CLIENT_USER' WHERE role = 'CLIENT_AUDITOR'")
|
||||
|
||||
# ── Paso 2: Soltar la restricción de tipo para poder recrear el enum ───
|
||||
# PostgreSQL no permite DROP VALUE en un enum; hay que recrear el tipo.
|
||||
op.execute("ALTER TABLE users ALTER COLUMN role TYPE TEXT")
|
||||
|
||||
# ── Paso 3: Eliminar tipo actual y recrearlo solo con los 6 roles ──────
|
||||
op.execute("DROP TYPE user_role_enum")
|
||||
op.execute("""
|
||||
CREATE TYPE user_role_enum AS ENUM (
|
||||
'ADMIN',
|
||||
'SUPPORT_MANAGER',
|
||||
'AGENT',
|
||||
'AUDITOR',
|
||||
'CLIENT_ADMIN',
|
||||
'CLIENT_USER'
|
||||
)
|
||||
""")
|
||||
|
||||
# ── Paso 4: Restaurar columna al tipo enum ──────────────────────────────
|
||||
op.execute(
|
||||
"ALTER TABLE users ALTER COLUMN role TYPE user_role_enum "
|
||||
"USING role::user_role_enum"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ── Paso 1: Soltar la restricción de tipo para recrear el enum ─────────
|
||||
op.execute("ALTER TABLE users ALTER COLUMN role TYPE TEXT")
|
||||
|
||||
# ── Paso 2: Recrear enum con los 9 valores originales ──────────────────
|
||||
op.execute("DROP TYPE user_role_enum")
|
||||
op.execute("""
|
||||
CREATE TYPE user_role_enum AS ENUM (
|
||||
'ADMIN',
|
||||
'SUPPORT_MANAGER',
|
||||
'AGENT',
|
||||
'AUDITOR',
|
||||
'CLIENT_ADMIN',
|
||||
'CLIENT_MANAGER',
|
||||
'CLIENT_AGENT',
|
||||
'CLIENT_AUDITOR',
|
||||
'CLIENT_USER'
|
||||
)
|
||||
""")
|
||||
|
||||
# ── Paso 3: Restaurar columna al tipo enum ──────────────────────────────
|
||||
op.execute(
|
||||
"ALTER TABLE users ALTER COLUMN role TYPE user_role_enum "
|
||||
"USING role::user_role_enum"
|
||||
)
|
||||
|
||||
# ── Nota sobre pérdida de datos ─────────────────────────────────────────
|
||||
# Los usuarios que eran CLIENT_MANAGER ahora son CLIENT_ADMIN.
|
||||
# Los usuarios que eran CLIENT_AGENT o CLIENT_AUDITOR ahora son CLIENT_USER.
|
||||
# No es posible restaurar la distinción original automáticamente.
|
||||
@@ -1,4 +1,4 @@
|
||||
[tool:pytest]
|
||||
[pytest]
|
||||
testpaths = tests tests/unit tests/integration
|
||||
python_files = test_*.py
|
||||
python_functions = test_*
|
||||
|
||||
@@ -31,6 +31,7 @@ pyotp==2.9.0 # TOTP/2FA support
|
||||
# ===================================
|
||||
celery==5.3.4
|
||||
redis==5.0.1
|
||||
slowapi==0.1.9 # Rate limiting middleware
|
||||
|
||||
# ===================================
|
||||
# EMAIL
|
||||
|
||||
193
backend/scripts/README_SECURITY_TESTS.md
Normal file
193
backend/scripts/README_SECURITY_TESTS.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# Scripts de Prueba de Seguridad
|
||||
|
||||
Scripts para generar datos de prueba para el análisis de seguridad.
|
||||
|
||||
## 📋 Scripts Disponibles
|
||||
|
||||
### 1. `generate_security_test_data.py`
|
||||
|
||||
Genera un conjunto completo de logs de auditoría para probar todas las funcionalidades del análisis de seguridad.
|
||||
|
||||
#### Uso
|
||||
|
||||
```bash
|
||||
# Asegúrate de estar en el entorno virtual
|
||||
cd backend
|
||||
python scripts/generate_security_test_data.py
|
||||
```
|
||||
|
||||
#### Qué Genera
|
||||
|
||||
- **25 intentos fallidos de login** → Amenaza HIGH de fuerza bruta
|
||||
- **55 eliminaciones masivas** → Amenaza CRITICAL
|
||||
- **5 cambios de privilegios** → Amenaza HIGH de escalación de privilegios
|
||||
- **20 logs normales** → Actividad regular para contexto
|
||||
|
||||
#### Limpiar Datos de Prueba
|
||||
|
||||
```bash
|
||||
python scripts/generate_security_test_data.py cleanup
|
||||
```
|
||||
|
||||
Esto eliminará **TODOS** los logs de auditoría de las últimas 24 horas.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Escenarios de Prueba
|
||||
|
||||
### Escenario 1: Sistema Limpio (Sin Amenazas)
|
||||
|
||||
```bash
|
||||
# Limpiar todos los logs
|
||||
python scripts/generate_security_test_data.py cleanup
|
||||
```
|
||||
|
||||
**Resultado esperado:**
|
||||
- Dashboard con todos los contadores en 0
|
||||
- Tab "Crítico" vacío
|
||||
- Mensaje: "Sistema Seguro"
|
||||
|
||||
---
|
||||
|
||||
### Escenario 2: Solo Amenazas Leves
|
||||
|
||||
Modifica el script para generar solo 6 intentos fallidos (MEDIUM severity):
|
||||
|
||||
```python
|
||||
# En generate_security_test_data.py, línea ~70
|
||||
for i in range(6): # Cambiar de 25 a 6
|
||||
```
|
||||
|
||||
**Resultado esperado:**
|
||||
- 1 amenaza MEDIUM en tab correspondiente
|
||||
- Tab "Crítico" vacío
|
||||
- Nivel de riesgo: LOW o MEDIUM
|
||||
|
||||
---
|
||||
|
||||
### Escenario 3: Amenazas Críticas
|
||||
|
||||
Ejecuta el script completo:
|
||||
|
||||
```bash
|
||||
python scripts/generate_security_test_data.py
|
||||
```
|
||||
|
||||
**Resultado esperado:**
|
||||
- 1 amenaza CRÍTICA (eliminaciones masivas)
|
||||
- 2 amenazas HIGH (login fallidos + privilegios)
|
||||
- Tab "Crítico" con 1 amenaza
|
||||
- Nivel de riesgo: CRITICAL
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Umbrales de Detección
|
||||
|
||||
| Tipo de Amenaza | Umbral Detección | Severidades |
|
||||
|-----------------|------------------|-------------|
|
||||
| **Fuerza Bruta** | ≥5 intentos fallidos | MEDIUM (5-19), HIGH (≥20) |
|
||||
| **Eliminaciones Masivas** | ≥10 eliminaciones | HIGH (10-49), **CRITICAL (≥50)** |
|
||||
| **Cambios de Privilegios** | ≥3 cambios de rol | HIGH (siempre) |
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Verificar Resultados
|
||||
|
||||
1. **Accede al panel de auditoría**: http://localhost:3001/audit/security
|
||||
|
||||
2. **Verifica los contadores del dashboard:**
|
||||
- Nivel de Riesgo
|
||||
- Amenazas Detectadas
|
||||
- Intentos Fallidos
|
||||
- IPs Sospechosas
|
||||
- Acciones Críticas
|
||||
|
||||
3. **Prueba los tabs:**
|
||||
- Todas: Debe mostrar amenazas activas
|
||||
- Crítico: Solo amenazas critical (si hay)
|
||||
- High: Amenazas de alta severidad
|
||||
- Medium: Amenazas de severidad media
|
||||
- Low: Amenazas de baja severidad
|
||||
- Resueltas: Amenazas marcadas como resueltas
|
||||
|
||||
4. **Prueba la búsqueda:**
|
||||
- Busca por IP: `192.168.1.100`
|
||||
- Busca por descripción: `intentos fallidos`
|
||||
- Busca por tipo: `brute_force`
|
||||
|
||||
5. **Prueba los filtros:**
|
||||
- Filtra por tipo de amenaza
|
||||
- Combina búsqueda + filtro
|
||||
|
||||
6. **Prueba las acciones:**
|
||||
- Selecciona múltiples amenazas
|
||||
- Resuelve en batch
|
||||
- Marca como resuelta individualmente
|
||||
- Reabre amenazas resueltas
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Advertencias
|
||||
|
||||
- **NO ejecutar en producción**: Estos scripts son SOLO para desarrollo/testing
|
||||
- **Los datos son ficticios**: IPs, usuarios y acciones son simulados
|
||||
- **Cleanup elimina TODO**: El comando cleanup elimina TODOS los logs de las últimas 24h, no solo los de prueba
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Error: "No se encontró ningún tenant"
|
||||
```bash
|
||||
# Ejecuta las migraciones
|
||||
cd backend
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
### Error: "No se encontró ningún usuario"
|
||||
```bash
|
||||
# Crea un usuario de prueba
|
||||
python scripts/create_test_user.py
|
||||
```
|
||||
|
||||
### La página no muestra amenazas
|
||||
- Verifica que el backend esté corriendo: `uvicorn app.main:app --reload`
|
||||
- Revisa la consola del navegador para errores
|
||||
- Verifica que los logs se crearon: `SELECT COUNT(*) FROM audit_logs WHERE created_at >= NOW() - INTERVAL '24 hours';`
|
||||
|
||||
### Las fechas no son de hoy
|
||||
- Los logs se crean con timestamps aleatorios en las últimas 24h
|
||||
- Si todos tienen la misma fecha, es porque se generaron en el mismo segundo (normal)
|
||||
|
||||
---
|
||||
|
||||
## 📝 Personalizar Generación
|
||||
|
||||
Para crear escenarios personalizados, edita `generate_security_test_data.py`:
|
||||
|
||||
```python
|
||||
# Cambiar cantidad de intentos fallidos
|
||||
for i in range(50): # Más intentos = mayor severidad
|
||||
|
||||
# Cambiar IPs sospechosas
|
||||
suspicious_ips = ["1.2.3.4", "5.6.7.8"]
|
||||
|
||||
# Cambiar período temporal
|
||||
time_offset = timedelta(hours=12) # Todos en las últimas 12h
|
||||
|
||||
# Agregar más tipos de amenazas
|
||||
# Agrega nuevos bloques de generación siguiendo el patrón
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Flujo Recomendado de Prueba
|
||||
|
||||
1. **Limpia el sistema**: `python scripts/generate_security_test_data.py cleanup`
|
||||
2. **Verifica sistema limpio**: Accede a la página, debe estar vacía
|
||||
3. **Genera datos completos**: `python scripts/generate_security_test_data.py`
|
||||
4. **Prueba todas las funcionalidades**: tabs, filtros, búsqueda, acciones
|
||||
5. **Marca algunas como resueltas**: Prueba el flujo de resolución
|
||||
6. **Verifica tab "Resueltas"**: Confirma que aparecen ahí
|
||||
7. **Reabre algunas**: Prueba el flujo de reapertura
|
||||
8. **Limpia al finalizar**: `python scripts/generate_security_test_data.py cleanup`
|
||||
240
backend/scripts/generate_security_test_data.py
Normal file
240
backend/scripts/generate_security_test_data.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
Script para generar datos de prueba de seguridad en logs de auditoría.
|
||||
Esto permite probar la funcionalidad de análisis de seguridad con diferentes tipos de amenazas.
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import uuid
|
||||
import random
|
||||
|
||||
# Agregar el directorio raíz al path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.user import User
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
|
||||
async def generate_test_data():
|
||||
"""Genera logs de auditoría de prueba para análisis de seguridad."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
# Obtener tenant y usuarios de prueba
|
||||
tenant_result = await db.execute(select(Tenant).limit(1))
|
||||
tenant = tenant_result.scalar_one_or_none()
|
||||
|
||||
if not tenant:
|
||||
print("❌ No se encontró ningún tenant. Ejecuta las migraciones primero.")
|
||||
return
|
||||
|
||||
user_result = await db.execute(select(User).where(User.tenant_id == tenant.id).limit(1))
|
||||
user = user_result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
print("❌ No se encontró ningún usuario. Crea un usuario primero.")
|
||||
return
|
||||
|
||||
print(f"✅ Usando tenant: {tenant.name}")
|
||||
print(f"✅ Usando usuario: {user.email}")
|
||||
print()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# IPs de prueba
|
||||
suspicious_ips = [
|
||||
"192.168.1.100",
|
||||
"10.0.0.50",
|
||||
"172.16.0.10",
|
||||
"203.0.113.42",
|
||||
"198.51.100.88"
|
||||
]
|
||||
|
||||
logs_created = 0
|
||||
|
||||
# ============================================
|
||||
# 1. GENERAR INTENTOS FALLIDOS DE LOGIN (Fuerza Bruta)
|
||||
# ============================================
|
||||
print("🔐 Generando intentos fallidos de login...")
|
||||
|
||||
# Generar 25 intentos fallidos (esto hará que sea HIGH severity)
|
||||
for i in range(25):
|
||||
time_offset = timedelta(hours=random.randint(0, 23), minutes=random.randint(0, 59))
|
||||
log = AuditLog(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant.id,
|
||||
user_id=user.id,
|
||||
action="user.login_failed",
|
||||
resource_type="auth",
|
||||
resource_id=None,
|
||||
ip_address=random.choice(suspicious_ips),
|
||||
user_agent="Mozilla/5.0 (Test Browser)",
|
||||
metadata={"reason": "invalid_credentials", "username": f"test_user_{i}"},
|
||||
created_at=now - time_offset
|
||||
)
|
||||
db.add(log)
|
||||
logs_created += 1
|
||||
|
||||
print(f" ✓ Creados {25} intentos fallidos de login (HIGH severity)")
|
||||
|
||||
# ============================================
|
||||
# 2. GENERAR ELIMINACIONES MASIVAS (CRITICAL)
|
||||
# ============================================
|
||||
print("🗑️ Generando eliminaciones masivas...")
|
||||
|
||||
resources = ["ticket", "comment", "attachment", "category", "user"]
|
||||
|
||||
# Generar 55 eliminaciones (esto hará que sea CRITICAL severity)
|
||||
for i in range(55):
|
||||
time_offset = timedelta(hours=random.randint(0, 23), minutes=random.randint(0, 59))
|
||||
resource = random.choice(resources)
|
||||
log = AuditLog(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant.id,
|
||||
user_id=user.id,
|
||||
action=f"{resource}.delete",
|
||||
resource_type=resource,
|
||||
resource_id=uuid.uuid4(),
|
||||
ip_address=random.choice(suspicious_ips),
|
||||
user_agent="Mozilla/5.0 (Test Browser)",
|
||||
metadata={"deleted_by": user.email},
|
||||
created_at=now - time_offset
|
||||
)
|
||||
db.add(log)
|
||||
logs_created += 1
|
||||
|
||||
print(f" ✓ Creadas {55} eliminaciones masivas (CRITICAL severity)")
|
||||
|
||||
# ============================================
|
||||
# 3. GENERAR CAMBIOS DE PRIVILEGIOS (HIGH)
|
||||
# ============================================
|
||||
print("👤 Generando cambios de privilegios...")
|
||||
|
||||
roles = ["AGENT", "CLIENT_USER", "AUDITOR", "SUPPORT_MANAGER", "ADMIN"]
|
||||
|
||||
# Generar 5 cambios de rol (esto hará que sea HIGH severity)
|
||||
for i in range(5):
|
||||
time_offset = timedelta(hours=random.randint(0, 23), minutes=random.randint(0, 59))
|
||||
old_role = random.choice(roles)
|
||||
new_role = random.choice([r for r in roles if r != old_role])
|
||||
|
||||
log = AuditLog(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant.id,
|
||||
user_id=user.id,
|
||||
action="user.update",
|
||||
resource_type="user",
|
||||
resource_id=uuid.uuid4(),
|
||||
ip_address=random.choice(suspicious_ips),
|
||||
user_agent="Mozilla/5.0 (Test Browser)",
|
||||
old_values={"role": old_role},
|
||||
new_values={"role": new_role},
|
||||
metadata={"changed_by": user.email},
|
||||
created_at=now - time_offset
|
||||
)
|
||||
db.add(log)
|
||||
logs_created += 1
|
||||
|
||||
print(f" ✓ Creados {5} cambios de privilegios (HIGH severity)")
|
||||
|
||||
# ============================================
|
||||
# 4. GENERAR LOGS NORMALES (para dar contexto)
|
||||
# ============================================
|
||||
print("📋 Generando logs de actividad normal...")
|
||||
|
||||
normal_actions = [
|
||||
"ticket.create",
|
||||
"ticket.update",
|
||||
"comment.create",
|
||||
"user.login",
|
||||
"ticket.view",
|
||||
]
|
||||
|
||||
for i in range(20):
|
||||
time_offset = timedelta(hours=random.randint(0, 23), minutes=random.randint(0, 59))
|
||||
action = random.choice(normal_actions)
|
||||
|
||||
log = AuditLog(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant.id,
|
||||
user_id=user.id,
|
||||
action=action,
|
||||
resource_type=action.split('.')[0],
|
||||
resource_id=uuid.uuid4(),
|
||||
ip_address=random.choice(suspicious_ips),
|
||||
user_agent="Mozilla/5.0 (Test Browser)",
|
||||
metadata={"action": "normal_activity"},
|
||||
created_at=now - time_offset
|
||||
)
|
||||
db.add(log)
|
||||
logs_created += 1
|
||||
|
||||
print(f" ✓ Creados {20} logs de actividad normal")
|
||||
|
||||
# Guardar todo
|
||||
await db.commit()
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(f"✅ GENERACIÓN COMPLETADA")
|
||||
print(f" Total de logs creados: {logs_created}")
|
||||
print()
|
||||
print("📊 Amenazas esperadas en el análisis:")
|
||||
print(" 🔴 1 amenaza CRÍTICA: 55 eliminaciones masivas")
|
||||
print(" 🟠 1 amenaza HIGH: 25 intentos fallidos de login")
|
||||
print(" 🟠 1 amenaza HIGH: 5 cambios de privilegios")
|
||||
print()
|
||||
print("🌐 Accede a la página de seguridad para ver el análisis")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
async def cleanup_test_data():
|
||||
"""Elimina los logs de auditoría de prueba."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
tenant_result = await db.execute(select(Tenant).limit(1))
|
||||
tenant = tenant_result.scalar_one_or_none()
|
||||
|
||||
if not tenant:
|
||||
print("❌ No se encontró ningún tenant.")
|
||||
return
|
||||
|
||||
# Eliminar logs de las últimas 24 horas
|
||||
now = datetime.now(timezone.utc)
|
||||
cutoff = now - timedelta(hours=24)
|
||||
|
||||
result = await db.execute(
|
||||
select(AuditLog).where(
|
||||
AuditLog.tenant_id == tenant.id,
|
||||
AuditLog.created_at >= cutoff
|
||||
)
|
||||
)
|
||||
logs = result.scalars().all()
|
||||
|
||||
if not logs:
|
||||
print("ℹ️ No hay logs de prueba para eliminar.")
|
||||
return
|
||||
|
||||
for log in logs:
|
||||
await db.delete(log)
|
||||
|
||||
await db.commit()
|
||||
|
||||
print(f"✅ Eliminados {len(logs)} logs de prueba de las últimas 24 horas")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "cleanup":
|
||||
print("🧹 Limpiando datos de prueba...")
|
||||
asyncio.run(cleanup_test_data())
|
||||
else:
|
||||
print("🚀 Generando datos de prueba para análisis de seguridad...")
|
||||
print()
|
||||
asyncio.run(generate_test_data())
|
||||
print()
|
||||
print("💡 Para limpiar estos datos de prueba, ejecuta:")
|
||||
print(" python scripts/generate_security_test_data.py cleanup")
|
||||
399
backend/scripts/generate_sla_test_data.py
Normal file
399
backend/scripts/generate_sla_test_data.py
Normal file
@@ -0,0 +1,399 @@
|
||||
"""
|
||||
Script para generar datos de prueba de SLA Management.
|
||||
Crea tickets con diferentes estados de SLA para probar el dashboard.
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import uuid
|
||||
import random
|
||||
|
||||
# Agregar el directorio raíz al path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.models.ticket import Ticket, TicketStatus, TicketPriority
|
||||
from app.models.category import Category
|
||||
from app.models.user import User
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.system import System
|
||||
|
||||
|
||||
async def generate_sla_test_data():
|
||||
"""Genera tickets de prueba con diferentes estados de SLA."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
# Obtener tenant y usuarios
|
||||
tenant_result = await db.execute(select(Tenant).limit(1))
|
||||
tenant = tenant_result.scalar_one_or_none()
|
||||
|
||||
if not tenant:
|
||||
print("❌ No se encontró ningún tenant. Ejecuta las migraciones primero.")
|
||||
return
|
||||
|
||||
# Obtener usuarios
|
||||
users_result = await db.execute(
|
||||
select(User).where(User.tenant_id == tenant.id).limit(5)
|
||||
)
|
||||
users = list(users_result.scalars().all())
|
||||
|
||||
if not users:
|
||||
print("❌ No se encontraron usuarios. Crea usuarios primero.")
|
||||
return
|
||||
|
||||
creator = users[0]
|
||||
agents = users if len(users) > 1 else [creator]
|
||||
|
||||
# Obtener o crear categorías
|
||||
categories_result = await db.execute(
|
||||
select(Category).where(Category.tenant_id == tenant.id)
|
||||
)
|
||||
categories = list(categories_result.scalars().all())
|
||||
|
||||
if not categories:
|
||||
print("📁 Creando categorías de prueba...")
|
||||
category_data = [
|
||||
{"name": "Soporte Técnico", "sla_response_hours": 2, "sla_resolution_hours": 24, "color": "#3B82F6"},
|
||||
{"name": "Facturación", "sla_response_hours": 4, "sla_resolution_hours": 48, "color": "#10B981"},
|
||||
{"name": "Incidente Crítico", "sla_response_hours": 1, "sla_resolution_hours": 8, "color": "#EF4444"},
|
||||
{"name": "Consulta General", "sla_response_hours": 8, "sla_resolution_hours": 72, "color": "#6B7280"},
|
||||
]
|
||||
|
||||
for cat_data in category_data:
|
||||
category = Category(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant.id,
|
||||
name=cat_data["name"],
|
||||
description=f"Categoría de {cat_data['name']}",
|
||||
color=cat_data["color"],
|
||||
sla_response_hours=cat_data["sla_response_hours"],
|
||||
sla_resolution_hours=cat_data["sla_resolution_hours"],
|
||||
is_active=True
|
||||
)
|
||||
db.add(category)
|
||||
categories.append(category)
|
||||
|
||||
await db.commit()
|
||||
print(f" ✓ Creadas {len(categories)} categorías")
|
||||
|
||||
# Obtener o crear sistemas afectados
|
||||
systems_result = await db.execute(
|
||||
select(System).where(System.tenant_id == tenant.id)
|
||||
)
|
||||
systems = list(systems_result.scalars().all())
|
||||
|
||||
if not systems:
|
||||
print("🖥️ Creando sistemas de prueba...")
|
||||
system_names = ["Portal Web", "API REST", "Base de Datos", "Sistema de Pagos"]
|
||||
for sys_name in system_names:
|
||||
system = System(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant.id,
|
||||
name=sys_name,
|
||||
description=f"Sistema {sys_name}",
|
||||
is_active=True
|
||||
)
|
||||
db.add(system)
|
||||
systems.append(system)
|
||||
|
||||
await db.commit()
|
||||
print(f" ✓ Creados {len(systems)} sistemas")
|
||||
|
||||
print(f"✅ Usando tenant: {tenant.name}")
|
||||
print(f"✅ Usuarios disponibles: {len(users)}")
|
||||
print(f"✅ Categorías disponibles: {len(categories)}")
|
||||
print()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
tickets_created = 0
|
||||
|
||||
# Función auxiliar para crear ticket
|
||||
def create_ticket(
|
||||
subject: str,
|
||||
description: str,
|
||||
priority: TicketPriority,
|
||||
status: TicketStatus,
|
||||
category: Category,
|
||||
created_hours_ago: int,
|
||||
first_response_hours_after: int = None,
|
||||
resolved_hours_after: int = None,
|
||||
assigned: bool = True
|
||||
):
|
||||
nonlocal tickets_created
|
||||
|
||||
ticket_id = uuid.uuid4()
|
||||
created_at = now - timedelta(hours=created_hours_ago)
|
||||
|
||||
# Calcular SLA deadlines basados en la categoría (sin timezone para la BD)
|
||||
sla_response_due = (created_at + timedelta(hours=category.sla_response_hours)).replace(tzinfo=None)
|
||||
sla_resolution_due = (created_at + timedelta(hours=category.sla_resolution_hours)).replace(tzinfo=None)
|
||||
|
||||
# Primera respuesta (si aplica)
|
||||
first_response_at = None
|
||||
if first_response_hours_after is not None:
|
||||
first_response_at = (created_at + timedelta(hours=first_response_hours_after)).replace(tzinfo=None)
|
||||
|
||||
# Resolución (si aplica)
|
||||
resolved_at = None
|
||||
if resolved_hours_after is not None:
|
||||
resolved_at = (created_at + timedelta(hours=resolved_hours_after)).replace(tzinfo=None)
|
||||
|
||||
ticket = Ticket(
|
||||
id=ticket_id,
|
||||
tenant_id=tenant.id,
|
||||
ticket_number=f"TKT-{1000 + tickets_created}",
|
||||
subject=subject,
|
||||
description=description,
|
||||
status=status,
|
||||
priority=priority,
|
||||
created_by=creator.id,
|
||||
assigned_to=random.choice(agents).id if assigned else None,
|
||||
category_id=category.id,
|
||||
affected_system_id=random.choice(systems).id if systems else None,
|
||||
sla_response_due=sla_response_due,
|
||||
sla_resolution_due=sla_resolution_due,
|
||||
first_response_at=first_response_at,
|
||||
resolved_at=resolved_at,
|
||||
created_at=created_at,
|
||||
updated_at=resolved_at or first_response_at or created_at
|
||||
)
|
||||
|
||||
db.add(ticket)
|
||||
tickets_created += 1
|
||||
return ticket
|
||||
|
||||
# ============================================
|
||||
# 1. TICKETS CUMPLIENDO SLA RESPONSE (Verde)
|
||||
# ============================================
|
||||
print("✅ Generando tickets CUMPLIENDO Response SLA...")
|
||||
|
||||
for i in range(15):
|
||||
category = random.choice(categories)
|
||||
priority = random.choice([TicketPriority.LOW, TicketPriority.MEDIUM, TicketPriority.HIGH])
|
||||
|
||||
# Creado hace X horas, respondido ANTES del deadline
|
||||
created_hours_ago = random.randint(24, 120)
|
||||
response_time = random.uniform(0.5, category.sla_response_hours * 0.7) # 70% del SLA
|
||||
|
||||
status = random.choice([TicketStatus.IN_PROGRESS, TicketStatus.WAITING_CUSTOMER])
|
||||
|
||||
create_ticket(
|
||||
subject=f"Ticket con respuesta a tiempo #{i+1}",
|
||||
description=f"Este ticket fue respondido dentro del SLA de {category.name}",
|
||||
priority=priority,
|
||||
status=status,
|
||||
category=category,
|
||||
created_hours_ago=created_hours_ago,
|
||||
first_response_hours_after=response_time,
|
||||
assigned=True
|
||||
)
|
||||
|
||||
print(f" ✓ Creados 15 tickets cumpliendo Response SLA")
|
||||
|
||||
# ============================================
|
||||
# 2. TICKETS VIOLANDO SLA RESPONSE (Rojo)
|
||||
# ============================================
|
||||
print("🔴 Generando tickets VIOLANDO Response SLA...")
|
||||
|
||||
for i in range(8):
|
||||
category = random.choice(categories)
|
||||
priority = random.choice([TicketPriority.HIGH, TicketPriority.URGENT])
|
||||
|
||||
# Creado hace más tiempo que el SLA, SIN respuesta
|
||||
created_hours_ago = category.sla_response_hours + random.randint(1, 10)
|
||||
|
||||
create_ticket(
|
||||
subject=f"Ticket SIN respuesta - VIOLACIÓN #{i+1}",
|
||||
description=f"Este ticket lleva {created_hours_ago}h sin respuesta (SLA: {category.sla_response_hours}h)",
|
||||
priority=priority,
|
||||
status=random.choice([TicketStatus.NEW, TicketStatus.TRIAGE]),
|
||||
category=category,
|
||||
created_hours_ago=created_hours_ago,
|
||||
first_response_hours_after=None, # Sin respuesta!
|
||||
assigned=random.choice([True, False])
|
||||
)
|
||||
|
||||
print(f" ✓ Creados 8 tickets VIOLANDO Response SLA")
|
||||
|
||||
# ============================================
|
||||
# 3. TICKETS EN RIESGO Response (Amarillo)
|
||||
# ============================================
|
||||
print("⚠️ Generando tickets EN RIESGO Response SLA...")
|
||||
|
||||
for i in range(10):
|
||||
category = random.choice(categories)
|
||||
priority = random.choice([TicketPriority.MEDIUM, TicketPriority.HIGH, TicketPriority.URGENT])
|
||||
|
||||
# Creado hace tiempo, cerca del deadline (80-95% consumido)
|
||||
sla_hours = category.sla_response_hours
|
||||
time_consumed = random.uniform(0.8, 0.95) * sla_hours
|
||||
created_hours_ago = time_consumed
|
||||
|
||||
create_ticket(
|
||||
subject=f"Ticket cerca de vencer respuesta #{i+1}",
|
||||
description=f"Este ticket está al {int(time_consumed/sla_hours*100)}% del SLA de respuesta",
|
||||
priority=priority,
|
||||
status=random.choice([TicketStatus.TRIAGE, TicketStatus.NEW]),
|
||||
category=category,
|
||||
created_hours_ago=created_hours_ago,
|
||||
first_response_hours_after=None, # Aún sin respuesta
|
||||
assigned=True
|
||||
)
|
||||
|
||||
print(f" ✓ Creados 10 tickets EN RIESGO Response SLA")
|
||||
|
||||
# ============================================
|
||||
# 4. TICKETS CUMPLIENDO SLA RESOLUTION
|
||||
# ============================================
|
||||
print("✅ Generando tickets CUMPLIENDO Resolution SLA...")
|
||||
|
||||
for i in range(20):
|
||||
category = random.choice(categories)
|
||||
priority = random.choice([TicketPriority.LOW, TicketPriority.MEDIUM, TicketPriority.HIGH])
|
||||
|
||||
# Creado, respondido y resuelto dentro del SLA
|
||||
created_hours_ago = random.randint(72, 240)
|
||||
response_time = random.uniform(1, category.sla_response_hours * 0.5)
|
||||
resolution_time = random.uniform(
|
||||
response_time + 1,
|
||||
category.sla_resolution_hours * 0.8
|
||||
)
|
||||
|
||||
create_ticket(
|
||||
subject=f"Ticket resuelto a tiempo #{i+1}",
|
||||
description=f"Este ticket fue resuelto dentro del SLA de {category.name}",
|
||||
priority=priority,
|
||||
status=random.choice([TicketStatus.RESOLVED, TicketStatus.CLOSED]),
|
||||
category=category,
|
||||
created_hours_ago=created_hours_ago,
|
||||
first_response_hours_after=response_time,
|
||||
resolved_hours_after=resolution_time,
|
||||
assigned=True
|
||||
)
|
||||
|
||||
print(f" ✓ Creados 20 tickets cumpliendo Resolution SLA")
|
||||
|
||||
# ============================================
|
||||
# 5. TICKETS VIOLANDO SLA RESOLUTION
|
||||
# ============================================
|
||||
print("🔴 Generando tickets VIOLANDO Resolution SLA...")
|
||||
|
||||
for i in range(6):
|
||||
category = random.choice(categories)
|
||||
priority = random.choice([TicketPriority.HIGH, TicketPriority.URGENT])
|
||||
|
||||
# Creado hace más del SLA de resolución, con respuesta pero sin resolver
|
||||
created_hours_ago = category.sla_resolution_hours + random.randint(5, 48)
|
||||
response_time = random.uniform(1, category.sla_response_hours * 0.5)
|
||||
|
||||
create_ticket(
|
||||
subject=f"Ticket sin resolver - VIOLACIÓN #{i+1}",
|
||||
description=f"Ticket lleva {created_hours_ago}h sin resolver (SLA: {category.sla_resolution_hours}h)",
|
||||
priority=priority,
|
||||
status=random.choice([TicketStatus.IN_PROGRESS, TicketStatus.WAITING_CUSTOMER]),
|
||||
category=category,
|
||||
created_hours_ago=created_hours_ago,
|
||||
first_response_hours_after=response_time,
|
||||
resolved_hours_after=None, # Sin resolver!
|
||||
assigned=True
|
||||
)
|
||||
|
||||
print(f" ✓ Creados 6 tickets VIOLANDO Resolution SLA")
|
||||
|
||||
# ============================================
|
||||
# 6. TICKETS EN RIESGO Resolution
|
||||
# ============================================
|
||||
print("⚠️ Generando tickets EN RIESGO Resolution SLA...")
|
||||
|
||||
for i in range(12):
|
||||
category = random.choice(categories)
|
||||
priority = random.choice([TicketPriority.MEDIUM, TicketPriority.HIGH])
|
||||
|
||||
# Con respuesta, cerca del deadline de resolución
|
||||
sla_hours = category.sla_resolution_hours
|
||||
time_consumed = random.uniform(0.75, 0.95) * sla_hours
|
||||
created_hours_ago = time_consumed
|
||||
response_time = random.uniform(0.5, category.sla_response_hours * 0.5)
|
||||
|
||||
create_ticket(
|
||||
subject=f"Ticket cerca de vencer resolución #{i+1}",
|
||||
description=f"Este ticket está al {int(time_consumed/sla_hours*100)}% del SLA de resolución",
|
||||
priority=priority,
|
||||
status=TicketStatus.IN_PROGRESS,
|
||||
category=category,
|
||||
created_hours_ago=created_hours_ago,
|
||||
first_response_hours_after=response_time,
|
||||
resolved_hours_after=None,
|
||||
assigned=True
|
||||
)
|
||||
|
||||
print(f" ✓ Creados 12 tickets EN RIESGO Resolution SLA")
|
||||
|
||||
# Guardar todos los tickets
|
||||
await db.commit()
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print("✅ GENERACIÓN DE DATOS SLA COMPLETADA")
|
||||
print(f" Total de tickets creados: {tickets_created}")
|
||||
print()
|
||||
print("📊 Distribución esperada:")
|
||||
print(" ✅ Response cumplidos: 15 tickets")
|
||||
print(" 🔴 Response violados: 8 tickets")
|
||||
print(" ⚠️ Response en riesgo: 10 tickets")
|
||||
print(" ✅ Resolution cumplidos: 20 tickets")
|
||||
print(" 🔴 Resolution violados: 6 tickets")
|
||||
print(" ⚠️ Resolution en riesgo: 12 tickets")
|
||||
print()
|
||||
print("🌐 Ve los resultados en:")
|
||||
print(" Dashboard SLA: http://localhost:3001/sla")
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
async def cleanup_sla_test_data():
|
||||
"""Elimina tickets de prueba."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
tenant_result = await db.execute(select(Tenant).limit(1))
|
||||
tenant = tenant_result.scalar_one_or_none()
|
||||
|
||||
if not tenant:
|
||||
print("❌ No se encontró ningún tenant.")
|
||||
return
|
||||
|
||||
# Eliminar tickets que empiezan con TKT-
|
||||
result = await db.execute(
|
||||
select(Ticket).where(
|
||||
Ticket.tenant_id == tenant.id,
|
||||
Ticket.ticket_number.like('TKT-%')
|
||||
)
|
||||
)
|
||||
tickets = result.scalars().all()
|
||||
|
||||
if not tickets:
|
||||
print("ℹ️ No hay tickets de prueba para eliminar.")
|
||||
return
|
||||
|
||||
for ticket in tickets:
|
||||
await db.delete(ticket)
|
||||
|
||||
await db.commit()
|
||||
|
||||
print(f"✅ Eliminados {len(tickets)} tickets de prueba")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "cleanup":
|
||||
print("🧹 Limpiando datos de prueba de SLA...")
|
||||
print()
|
||||
asyncio.run(cleanup_sla_test_data())
|
||||
else:
|
||||
print("🚀 Generando datos de prueba para SLA Management...")
|
||||
print()
|
||||
asyncio.run(generate_sla_test_data())
|
||||
print()
|
||||
print("💡 Para limpiar estos datos de prueba, ejecuta:")
|
||||
print(" python scripts/generate_sla_test_data.py cleanup")
|
||||
49
backend/scripts/reset_passwords.py
Normal file
49
backend/scripts/reset_passwords.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
Script para resetear contraseñas de todos los usuarios a valores conocidos.
|
||||
Ejecutar con: python -m scripts.reset_passwords (desde /app en el contenedor)
|
||||
"""
|
||||
import asyncio
|
||||
from sqlalchemy import select, update
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.core.security import security
|
||||
from app.models.user import User
|
||||
|
||||
# Mapa email -> nueva contraseña
|
||||
PASSWORD_MAP = {
|
||||
"admin@aduanasoft.com": "admin123",
|
||||
"admin@test.com": "admin123",
|
||||
"manager@aduanasoft.com": "manager123",
|
||||
"agente@aduanasoft.com": "agente123",
|
||||
"auditor1@test.com": "auditor123",
|
||||
"admin-cliente@empresa-demo.com": "clienteadmin123",
|
||||
"cliente@empresa-demo.com": "cliente123",
|
||||
"test_user@aduanasoft.com": "test123",
|
||||
}
|
||||
|
||||
async def reset_all_passwords():
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(select(User))
|
||||
users = result.scalars().all()
|
||||
|
||||
updated = 0
|
||||
skipped = 0
|
||||
for user in users:
|
||||
if user.email in PASSWORD_MAP:
|
||||
plain = PASSWORD_MAP[user.email]
|
||||
user.password_hash = security.hash_password(plain)
|
||||
user.email_verified = True
|
||||
user.is_active = True
|
||||
updated += 1
|
||||
print(f" ✅ {user.email} → {plain}")
|
||||
else:
|
||||
skipped += 1
|
||||
print(f" ⚠️ {user.email} (sin contraseña definida, se omite)")
|
||||
|
||||
await db.commit()
|
||||
print(f"\nResumen: {updated} actualizados, {skipped} omitidos")
|
||||
print("\n📋 Credenciales listas:")
|
||||
for email, pwd in PASSWORD_MAP.items():
|
||||
print(f" {email} / {pwd}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(reset_all_passwords())
|
||||
@@ -6,6 +6,7 @@ Fixtures y utilidades para tests de integración con BD real
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
import os
|
||||
from typing import AsyncGenerator, Generator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
@@ -21,8 +22,19 @@ from app.models.system import System
|
||||
from app.models.category import Category
|
||||
|
||||
|
||||
# Database URL para testing (usa la misma BD pero limpia después)
|
||||
TEST_DATABASE_URL = "postgresql+asyncpg://servicemanager:servicemanager123@localhost:5432/servicemanager_test"
|
||||
# Database URL para testing.
|
||||
# - En host/local: usa localhost
|
||||
# - En Docker: deriva de DATABASE_URL (normalmente apunta a host 'postgres')
|
||||
_DEFAULT_TEST_DATABASE_URL = "postgresql+asyncpg://servicemanager:servicemanager123@localhost:5432/servicemanager_test"
|
||||
_ENV_TEST_DATABASE_URL = os.getenv("TEST_DATABASE_URL")
|
||||
_ENV_DATABASE_URL = os.getenv("DATABASE_URL")
|
||||
|
||||
if _ENV_TEST_DATABASE_URL:
|
||||
TEST_DATABASE_URL = _ENV_TEST_DATABASE_URL
|
||||
elif _ENV_DATABASE_URL and "@postgres:" in _ENV_DATABASE_URL:
|
||||
TEST_DATABASE_URL = _ENV_DATABASE_URL.rsplit("/", 1)[0] + "/servicemanager_test"
|
||||
else:
|
||||
TEST_DATABASE_URL = _DEFAULT_TEST_DATABASE_URL
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
@@ -99,8 +111,8 @@ async def test_tenant(db_session: AsyncSession) -> Tenant:
|
||||
slug="test-company",
|
||||
domain="test.company.com",
|
||||
status=TenantStatus.ACTIVE,
|
||||
email="admin@test.company.com",
|
||||
phone="+1234567890"
|
||||
contact_email="admin@test.company.com",
|
||||
contact_phone="+1234567890",
|
||||
)
|
||||
db_session.add(tenant)
|
||||
await db_session.commit()
|
||||
@@ -116,8 +128,8 @@ async def test_tenant_2(db_session: AsyncSession) -> Tenant:
|
||||
slug="test-company-2",
|
||||
domain="test2.company.com",
|
||||
status=TenantStatus.ACTIVE,
|
||||
email="admin@test2.company.com",
|
||||
phone="+9876543210"
|
||||
contact_email="admin@test2.company.com",
|
||||
contact_phone="+9876543210",
|
||||
)
|
||||
db_session.add(tenant)
|
||||
await db_session.commit()
|
||||
|
||||
289
backend/tests/integration/conftest.py
Normal file
289
backend/tests/integration/conftest.py
Normal file
@@ -0,0 +1,289 @@
|
||||
"""Integration Test Configuration - ServiceManagerWeb
|
||||
|
||||
Fixtures y utilidades para tests de integración con BD real.
|
||||
|
||||
Este conftest vive dentro de tests/integration para que sus fixtures (client, db_session,
|
||||
test_tenant, tokens, etc.) apliquen solo a los tests de integración y no colisionen con
|
||||
los fixtures SQLite del conftest global.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
from sqlalchemy import text
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.main import app
|
||||
from app.core.database import Base, get_db
|
||||
from app.core.security import SecurityUtils
|
||||
from app.models.tenant import Tenant, TenantStatus
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.system import System
|
||||
from app.models.category import Category
|
||||
|
||||
|
||||
_DEFAULT_TEST_DATABASE_URL = "postgresql+asyncpg://servicemanager:servicemanager123@localhost:5432/servicemanager_test"
|
||||
_ENV_TEST_DATABASE_URL = os.getenv("TEST_DATABASE_URL")
|
||||
_ENV_DATABASE_URL = os.getenv("DATABASE_URL")
|
||||
|
||||
if _ENV_TEST_DATABASE_URL:
|
||||
TEST_DATABASE_URL = _ENV_TEST_DATABASE_URL
|
||||
elif _ENV_DATABASE_URL and "@postgres:" in _ENV_DATABASE_URL:
|
||||
TEST_DATABASE_URL = _ENV_DATABASE_URL.rsplit("/", 1)[0] + "/servicemanager_test"
|
||||
else:
|
||||
TEST_DATABASE_URL = _DEFAULT_TEST_DATABASE_URL
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def test_engine():
|
||||
"""Create test database engine."""
|
||||
engine = create_async_engine(
|
||||
TEST_DATABASE_URL,
|
||||
echo=False,
|
||||
poolclass=NullPool,
|
||||
)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
yield engine
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Create a fresh database session for each integration test."""
|
||||
async_session = async_sessionmaker(
|
||||
test_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
async with async_session() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
# Rollback any open transaction
|
||||
await session.rollback()
|
||||
|
||||
# Hard reset DB state for next test (tests commit, so rollback alone isn't enough)
|
||||
table_names = [t.name for t in Base.metadata.sorted_tables]
|
||||
if table_names:
|
||||
quoted = ", ".join(f'"{name}"' for name in table_names)
|
||||
await session.execute(text(f"TRUNCATE TABLE {quoted} RESTART IDENTITY CASCADE"))
|
||||
await session.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
|
||||
"""Create test client with overridden database dependency."""
|
||||
|
||||
# Disable login rate limiting during integration tests to avoid flakiness
|
||||
# (tests perform many logins quickly from the same IP).
|
||||
import app.api.v1.endpoints.auth as auth_endpoint
|
||||
|
||||
old_rate_limit_enabled = getattr(auth_endpoint.settings, "RATE_LIMIT_ENABLED", None)
|
||||
old_testing = getattr(auth_endpoint.settings, "TESTING", None)
|
||||
auth_endpoint.settings.RATE_LIMIT_ENABLED = False
|
||||
auth_endpoint.settings.TESTING = True
|
||||
|
||||
async def override_get_db():
|
||||
yield db_session
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
async with AsyncClient(app=app, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
# Restore settings
|
||||
if old_rate_limit_enabled is not None:
|
||||
auth_endpoint.settings.RATE_LIMIT_ENABLED = old_rate_limit_enabled
|
||||
if old_testing is not None:
|
||||
auth_endpoint.settings.TESTING = old_testing
|
||||
|
||||
|
||||
# ===================================
|
||||
# FIXTURES DE DATOS DE TEST
|
||||
# ===================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_tenant(db_session: AsyncSession) -> Tenant:
|
||||
tenant = Tenant(
|
||||
name="Test Company",
|
||||
slug="test-company",
|
||||
domain="test.company.com",
|
||||
status=TenantStatus.ACTIVE,
|
||||
contact_email="admin@test.company.com",
|
||||
contact_phone="+1234567890",
|
||||
)
|
||||
db_session.add(tenant)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(tenant)
|
||||
return tenant
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_tenant_2(db_session: AsyncSession) -> Tenant:
|
||||
tenant = Tenant(
|
||||
name="Test Company 2",
|
||||
slug="test-company-2",
|
||||
domain="test2.company.com",
|
||||
status=TenantStatus.ACTIVE,
|
||||
contact_email="admin@test2.company.com",
|
||||
contact_phone="+9876543210",
|
||||
)
|
||||
db_session.add(tenant)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(tenant)
|
||||
return tenant
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_admin_user(db_session: AsyncSession, test_tenant: Tenant) -> User:
|
||||
user = User(
|
||||
tenant_id=test_tenant.id,
|
||||
email="admin@test.com",
|
||||
first_name="Admin",
|
||||
last_name="User",
|
||||
password_hash=SecurityUtils.hash_password("AdminPass123!"),
|
||||
role=UserRole.ADMIN,
|
||||
is_active=True,
|
||||
email_verified=True,
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_agent_user(db_session: AsyncSession, test_tenant: Tenant) -> User:
|
||||
user = User(
|
||||
tenant_id=test_tenant.id,
|
||||
email="agent@test.com",
|
||||
first_name="Agent",
|
||||
last_name="User",
|
||||
password_hash=SecurityUtils.hash_password("AgentPass123!"),
|
||||
role=UserRole.AGENT,
|
||||
is_active=True,
|
||||
email_verified=True,
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_client_user(db_session: AsyncSession, test_tenant: Tenant) -> User:
|
||||
user = User(
|
||||
tenant_id=test_tenant.id,
|
||||
email="client@test.com",
|
||||
first_name="Client",
|
||||
last_name="User",
|
||||
password_hash=SecurityUtils.hash_password("ClientPass123!"),
|
||||
role=UserRole.CLIENT_USER,
|
||||
is_active=True,
|
||||
email_verified=True,
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_system(db_session: AsyncSession, test_tenant: Tenant) -> System:
|
||||
system = System(
|
||||
name="Test System",
|
||||
description="Test system description",
|
||||
tenant_id=test_tenant.id,
|
||||
is_active=True,
|
||||
)
|
||||
db_session.add(system)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(system)
|
||||
return system
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_category(db_session: AsyncSession, test_tenant: Tenant) -> Category:
|
||||
category = Category(
|
||||
name="Test Category",
|
||||
description="Test category description",
|
||||
tenant_id=test_tenant.id,
|
||||
is_active=True,
|
||||
sla_response_hours=24,
|
||||
sla_resolution_hours=72,
|
||||
)
|
||||
db_session.add(category)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(category)
|
||||
return category
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_token(client: AsyncClient, test_admin_user: User, test_tenant: Tenant) -> str:
|
||||
response = await client.post(
|
||||
"/v1/auth/login",
|
||||
json={
|
||||
"email": test_admin_user.email,
|
||||
"password": "AdminPass123!",
|
||||
"tenant_slug": test_tenant.slug,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return response.json()["access_token"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def agent_token(client: AsyncClient, test_agent_user: User, test_tenant: Tenant) -> str:
|
||||
response = await client.post(
|
||||
"/v1/auth/login",
|
||||
json={
|
||||
"email": test_agent_user.email,
|
||||
"password": "AgentPass123!",
|
||||
"tenant_slug": test_tenant.slug,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return response.json()["access_token"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client_token(client: AsyncClient, test_client_user: User, test_tenant: Tenant) -> str:
|
||||
response = await client.post(
|
||||
"/v1/auth/login",
|
||||
json={
|
||||
"email": test_client_user.email,
|
||||
"password": "ClientPass123!",
|
||||
"tenant_slug": test_tenant.slug,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return response.json()["access_token"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers_admin(admin_token: str) -> dict:
|
||||
return {"Authorization": f"Bearer {admin_token}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers_agent(agent_token: str) -> dict:
|
||||
return {"Authorization": f"Bearer {agent_token}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers_client(client_token: str) -> dict:
|
||||
return {"Authorization": f"Bearer {client_token}"}
|
||||
@@ -16,9 +16,6 @@ from app.models.user import User, UserRole
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
# Importar fixtures desde conftest_integration
|
||||
pytest_plugins = ['tests.conftest_integration']
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.auth
|
||||
class TestAuthentication:
|
||||
@@ -126,6 +123,58 @@ class TestAuthentication:
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
async def test_login_rate_limited_after_too_many_attempts(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
test_admin_user: User,
|
||||
test_tenant: Tenant,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Debe devolver 429 después de demasiados intentos de login (rate limit)."""
|
||||
|
||||
import app.api.v1.endpoints.auth as auth_endpoint
|
||||
|
||||
class _FakeCache:
|
||||
def __init__(self):
|
||||
self._counts = {}
|
||||
self._expires = {}
|
||||
|
||||
async def incr(self, key: str, amount: int = 1):
|
||||
self._counts[key] = self._counts.get(key, 0) + amount
|
||||
return self._counts[key]
|
||||
|
||||
async def expire(self, key: str, ttl: int):
|
||||
self._expires[key] = ttl
|
||||
return True
|
||||
|
||||
async def delete(self, key: str):
|
||||
self._counts.pop(key, None)
|
||||
return True
|
||||
|
||||
fake_cache = _FakeCache()
|
||||
monkeypatch.setattr(auth_endpoint, "cache", fake_cache)
|
||||
monkeypatch.setattr(auth_endpoint.settings, "RATE_LIMIT_ENABLED", True, raising=False)
|
||||
monkeypatch.setattr(auth_endpoint.settings, "TESTING", False, raising=False)
|
||||
monkeypatch.setattr(auth_endpoint.settings, "LOGIN_RATE_LIMIT_WINDOW_SECONDS", 60, raising=False)
|
||||
monkeypatch.setattr(auth_endpoint.settings, "LOGIN_RATE_LIMIT_IP_MAX_ATTEMPTS", 10_000, raising=False)
|
||||
monkeypatch.setattr(auth_endpoint.settings, "LOGIN_RATE_LIMIT_ID_MAX_ATTEMPTS", 2, raising=False)
|
||||
|
||||
payload = {
|
||||
"email": test_admin_user.email,
|
||||
"password": "WrongPassword123!",
|
||||
"tenant_slug": test_tenant.slug,
|
||||
}
|
||||
|
||||
r1 = await client.post("/v1/auth/login", json=payload)
|
||||
assert r1.status_code == 401
|
||||
|
||||
r2 = await client.post("/v1/auth/login", json=payload)
|
||||
assert r2.status_code == 401
|
||||
|
||||
r3 = await client.post("/v1/auth/login", json=payload)
|
||||
assert r3.status_code == 429
|
||||
assert "Retry-After" in r3.headers
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.auth
|
||||
@@ -305,13 +354,17 @@ class TestUserProfile:
|
||||
async def test_get_current_user_profile(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
test_tenant: Tenant,
|
||||
test_admin_user: User,
|
||||
auth_headers_admin: dict
|
||||
):
|
||||
"""Test obtener perfil del usuario actual."""
|
||||
response = await client.get(
|
||||
"/v1/users/me",
|
||||
headers=auth_headers_admin
|
||||
headers={
|
||||
**auth_headers_admin,
|
||||
"X-Tenant-ID": str(test_tenant.id),
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -348,13 +401,17 @@ class TestPasswordSecurity:
|
||||
async def test_password_not_exposed_in_response(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
test_tenant: Tenant,
|
||||
test_admin_user: User,
|
||||
auth_headers_admin: dict
|
||||
):
|
||||
"""Test que el password hash nunca se expone en las respuestas."""
|
||||
response = await client.get(
|
||||
"/v1/users/me",
|
||||
headers=auth_headers_admin
|
||||
headers={
|
||||
**auth_headers_admin,
|
||||
"X-Tenant-ID": str(test_tenant.id),
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -14,9 +14,6 @@ from app.models.tenant import Tenant
|
||||
from app.models.ticket import Ticket, TicketStatus, TicketPriority
|
||||
from app.core.security import SecurityUtils
|
||||
|
||||
pytest_plugins = ['tests.conftest_integration']
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
class TestTenantIsolation:
|
||||
|
||||
@@ -1,78 +1,56 @@
|
||||
"""
|
||||
Quick Test Verification - ServiceManagerWeb
|
||||
"""Quick Test Verification - ServiceManagerWeb
|
||||
|
||||
Test rápido para verificar que la configuración de tests funciona correctamente.
|
||||
Smoke tests para verificar que el setup de tests de integración funciona correctamente.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
pytest_plugins = ['tests.conftest_integration']
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestSetupVerification:
|
||||
"""Verificar que el setup de tests funciona."""
|
||||
|
||||
async def test_client_fixture_works(self, client: AsyncClient):
|
||||
"""Test que el fixture de client HTTP funciona."""
|
||||
assert client is not None
|
||||
assert client.base_url == "http://test"
|
||||
|
||||
assert str(client.base_url) == "http://test"
|
||||
|
||||
async def test_database_connection(self, db_session):
|
||||
"""Test que la conexión a BD de testing funciona."""
|
||||
assert db_session is not None
|
||||
|
||||
# Ejecutar query simple
|
||||
from sqlalchemy import text
|
||||
|
||||
result = await db_session.execute(text("SELECT 1"))
|
||||
assert result.scalar() == 1
|
||||
|
||||
|
||||
async def test_tenant_fixture_creates_tenant(self, test_tenant):
|
||||
"""Test que el fixture de tenant funciona."""
|
||||
assert test_tenant is not None
|
||||
assert test_tenant.name == "Test Company"
|
||||
assert test_tenant.slug == "test-company"
|
||||
|
||||
|
||||
async def test_user_fixtures_work(self, test_admin_user, test_agent_user, test_client_user):
|
||||
"""Test que los fixtures de usuarios funcionan."""
|
||||
assert test_admin_user.role.value == "ADMIN"
|
||||
assert test_agent_user.role.value == "AGENT"
|
||||
assert test_client_user.role.value == "CLIENT_USER"
|
||||
|
||||
async def test_auth_token_generation(self, admin_token):
|
||||
"""Test que la generación de tokens funciona."""
|
||||
assert admin_token is not None
|
||||
|
||||
async def test_auth_token_generation(self, admin_token: str):
|
||||
assert isinstance(admin_token, str)
|
||||
assert len(admin_token) > 20
|
||||
|
||||
|
||||
async def test_health_endpoint(self, client: AsyncClient):
|
||||
"""Test que el endpoint de health funciona."""
|
||||
response = await client.get("/health")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "healthy"
|
||||
assert response.json()["status"] == "healthy"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestBasicEndpoints:
|
||||
"""Tests básicos de endpoints para verificar conectividad."""
|
||||
|
||||
async def test_health_endpoint_detailed(self, client: AsyncClient):
|
||||
"""Test del endpoint de health detallado."""
|
||||
response = await client.get("/v1/health/detailed")
|
||||
assert response.status_code == 200
|
||||
|
||||
assert response.status_code in (200, 503)
|
||||
|
||||
async def test_login_endpoint_exists(self, client: AsyncClient):
|
||||
"""Test que el endpoint de login responde."""
|
||||
# Enviar credenciales inválidas para verificar que el endpoint existe
|
||||
response = await client.post(
|
||||
"/v1/auth/login",
|
||||
json={
|
||||
"email": "nonexistent@test.com",
|
||||
"password": "wrong",
|
||||
"tenant_slug": "nonexistent"
|
||||
}
|
||||
"tenant_slug": "nonexistent",
|
||||
},
|
||||
)
|
||||
# Debe responder (aunque con error)
|
||||
assert response.status_code in [401, 404, 422]
|
||||
assert response.status_code in (401, 404, 422)
|
||||
@@ -6,6 +6,7 @@ Tests completos del CRUD de tickets y funcionalidad relacionada.
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import uuid
|
||||
|
||||
@@ -14,8 +15,7 @@ from app.models.tenant import Tenant
|
||||
from app.models.ticket import Ticket, TicketStatus, TicketPriority
|
||||
from app.models.system import System
|
||||
from app.models.category import Category
|
||||
|
||||
pytest_plugins = ['tests.conftest_integration']
|
||||
from app.core.file_handler import file_handler
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -611,3 +611,66 @@ class TestTicketPermissions:
|
||||
|
||||
# Debe ver ambos tickets
|
||||
assert len(tickets) >= 2
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
class TestTicketAttachmentPermissions:
|
||||
async def test_client_cannot_download_other_users_attachment(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
test_tenant: Tenant,
|
||||
test_category: Category,
|
||||
auth_headers_admin: dict,
|
||||
auth_headers_client: dict,
|
||||
):
|
||||
# Admin crea ticket
|
||||
create_resp = await client.post(
|
||||
"/v1/tickets/",
|
||||
headers={
|
||||
**auth_headers_admin,
|
||||
"X-Tenant-ID": str(test_tenant.id),
|
||||
},
|
||||
json={
|
||||
"title": "Admin ticket",
|
||||
"description": "Ticket with attachment",
|
||||
"priority": "MEDIUM",
|
||||
"category_id": str(test_category.id),
|
||||
},
|
||||
)
|
||||
assert create_resp.status_code == 201
|
||||
ticket_id = create_resp.json()["id"]
|
||||
|
||||
# Admin sube adjunto (PDF válido por magic bytes)
|
||||
pdf_bytes = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF\n"
|
||||
upload_resp = await client.post(
|
||||
f"/v1/tickets/{ticket_id}/attachments",
|
||||
headers={
|
||||
**auth_headers_admin,
|
||||
"X-Tenant-ID": str(test_tenant.id),
|
||||
},
|
||||
files={
|
||||
"file": ("test.pdf", pdf_bytes, "application/pdf"),
|
||||
},
|
||||
)
|
||||
assert upload_resp.status_code == 201
|
||||
attachment_data = upload_resp.json()["data"]
|
||||
attachment_id = attachment_data["id"]
|
||||
|
||||
# Cliente intenta descargar adjunto de ticket ajeno -> 404
|
||||
download_resp = await client.get(
|
||||
f"/v1/tickets/{ticket_id}/attachments/{attachment_id}/download",
|
||||
headers={
|
||||
**auth_headers_client,
|
||||
"X-Tenant-ID": str(test_tenant.id),
|
||||
},
|
||||
)
|
||||
assert download_resp.status_code == 404
|
||||
|
||||
# Limpieza del archivo subido (mejor esfuerzo)
|
||||
try:
|
||||
uploaded_path = file_handler.get_file_path(attachment_data["file_path"])
|
||||
if uploaded_path.exists():
|
||||
uploaded_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
80
backend/tests/unit/test_file_handler.py
Normal file
80
backend/tests/unit/test_file_handler.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Unit Tests - FileHandler - ServiceManagerWeb
|
||||
|
||||
Tests para app.core.file_handler.FileHandler.
|
||||
"""
|
||||
|
||||
import io
|
||||
import uuid
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi import UploadFile
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_upload_pdf_valid_streaming():
|
||||
from app.core.file_handler import FileHandler, settings
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
settings.UPLOAD_PATH = tmp
|
||||
handler = FileHandler()
|
||||
|
||||
tenant_id = uuid.uuid4()
|
||||
ticket_id = uuid.uuid4()
|
||||
|
||||
content = b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n1 0 obj\n<<>>\nendobj\n"
|
||||
up = UploadFile(filename="test.pdf", file=io.BytesIO(content))
|
||||
|
||||
meta = await handler.save_upload(up, tenant_id=tenant_id, ticket_id=ticket_id)
|
||||
assert meta["file_size"] == len(content)
|
||||
assert meta["original_filename"] == "test.pdf"
|
||||
assert meta["filename"].endswith(".pdf")
|
||||
assert meta["md5_hash"]
|
||||
assert meta["sha256_hash"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_upload_pdf_invalid_magic_bytes_rejected():
|
||||
from app.core.file_handler import FileHandler, settings
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
settings.UPLOAD_PATH = tmp
|
||||
handler = FileHandler()
|
||||
|
||||
up = UploadFile(filename="bad.pdf", file=io.BytesIO(b"NOTPDF"))
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await handler.save_upload(up, tenant_id=uuid.uuid4(), ticket_id=uuid.uuid4())
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_upload_oversize_rejected_and_file_removed():
|
||||
from app.core.file_handler import FileHandler, settings
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
settings.UPLOAD_PATH = tmp
|
||||
settings.MAX_UPLOAD_SIZE_MB = 0 # 0MB => max 0 bytes
|
||||
handler = FileHandler()
|
||||
|
||||
up = UploadFile(filename="a.txt", file=io.BytesIO(b"x"))
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await handler.save_upload(up, tenant_id=uuid.uuid4(), ticket_id=uuid.uuid4())
|
||||
|
||||
assert exc.value.status_code == 413
|
||||
|
||||
|
||||
def test_get_file_path_prevents_path_traversal():
|
||||
from app.core.file_handler import FileHandler, settings
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
settings.UPLOAD_PATH = tmp
|
||||
handler = FileHandler()
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
handler.get_file_path("../../etc/passwd")
|
||||
|
||||
assert exc.value.status_code == 403
|
||||
@@ -124,8 +124,8 @@ class TestMiddlewareNoTenantHeaders:
|
||||
"""Tests para requests sin headers de tenant."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_tenant_headers_in_dev_continues(self):
|
||||
"""En entorno de desarrollo, sin tenant headers continúa con advertencia."""
|
||||
async def test_missing_tenant_headers_returns_400(self):
|
||||
"""Sin tenant headers debe retornar 400 (requerido para aislamiento multi-tenant)."""
|
||||
from app.middleware.tenant import TenantMiddleware
|
||||
|
||||
mock_app = AsyncMock()
|
||||
@@ -139,18 +139,15 @@ class TestMiddlewareNoTenantHeaders:
|
||||
|
||||
call_next = AsyncMock(return_value=MagicMock(status_code=200))
|
||||
|
||||
# En modo testing (que hereda de development), debe continuar
|
||||
response = await middleware.dispatch(request, call_next)
|
||||
|
||||
# El request continúa (call_next fue llamado)
|
||||
call_next.assert_called_once()
|
||||
assert response.status_code == 400
|
||||
call_next.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_tenant_headers_in_production_returns_400(self):
|
||||
"""En producción, sin tenant headers retorna 400."""
|
||||
async def test_missing_tenant_headers_does_not_call_next(self):
|
||||
"""Sin tenant headers no debe llegar al handler (call_next)."""
|
||||
from app.middleware.tenant import TenantMiddleware
|
||||
from app.core.config import get_settings
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
mock_app = AsyncMock()
|
||||
middleware = TenantMiddleware(mock_app)
|
||||
@@ -163,13 +160,10 @@ class TestMiddlewareNoTenantHeaders:
|
||||
|
||||
call_next = AsyncMock(return_value=MagicMock(status_code=200))
|
||||
|
||||
with patch.object(get_settings(), "ENVIRONMENT", "production"):
|
||||
response = await middleware.dispatch(request, call_next)
|
||||
response = await middleware.dispatch(request, call_next)
|
||||
|
||||
# En producción sin tenant debe retornar error
|
||||
# (si la response es JSONResponse con status 400, el test pasa)
|
||||
if hasattr(response, "status_code"):
|
||||
assert response.status_code in [400, 200] # depende del env
|
||||
assert response.status_code == 400
|
||||
call_next.assert_not_called()
|
||||
|
||||
|
||||
# ============================================================
|
||||
|
||||
@@ -415,18 +415,19 @@ INSERT INTO tenants (name, slug, contact_email) VALUES
|
||||
('Aduanasoft Demo', 'aduanasoft-demo', 'demo@aduanasoft.com');
|
||||
|
||||
-- Usuario admin por defecto (password: admin123)
|
||||
-- Hash generado con Argon2: $argon2id$v=19$m=65536,t=3,p=4$...
|
||||
-- Hash Argon2id generado con m=65536,t=3,p=4
|
||||
INSERT INTO users (tenant_id, email, first_name, last_name, password_hash, role, is_active, email_verified)
|
||||
SELECT
|
||||
id,
|
||||
'admin@aduanasoft.com',
|
||||
'Admin',
|
||||
'Sistema',
|
||||
'$argon2id$v=19$m=65536,t=3,p=4$example_hash_here',
|
||||
'$argon2id$v=19$m=65536,t=3,p=4$wpjz/t+bM4bQmtM6B6A0pg$ELwnGUL4S1Y6tywp0LS6cre0bvWEoVuJ845spZ9Z9IQ',
|
||||
'ADMIN',
|
||||
true,
|
||||
true
|
||||
FROM tenants WHERE slug = 'aduanasoft-demo';
|
||||
FROM tenants WHERE slug = 'aduanasoft-demo'
|
||||
ON CONFLICT (tenant_id, email) DO NOTHING;
|
||||
|
||||
-- Categorías por defecto
|
||||
INSERT INTO ticket_categories (tenant_id, name, description, sla_response_hours, sla_resolution_hours)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# ===================================
|
||||
# POSTGRES DATABASE
|
||||
@@ -15,9 +13,9 @@ services:
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
|
||||
#- ./db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "5433:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-servicemanager}"]
|
||||
interval: 10s
|
||||
@@ -34,7 +32,7 @@ services:
|
||||
container_name: servicemanager-redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes
|
||||
volumes:
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
ports:
|
||||
- "6379:6379"
|
||||
@@ -113,6 +111,7 @@ services:
|
||||
- ./backend:/backend:ro
|
||||
- uploads_data:/app/uploads
|
||||
- logs_data:/app/logs
|
||||
command: celery -A app.celery worker --loglevel=info -Q default,email,sla,maintenance,notifications
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -165,6 +164,8 @@ services:
|
||||
- NODE_ENV=${ENVIRONMENT:-development}
|
||||
- PUBLIC_API_URL=http://backend:8000
|
||||
- PUBLIC_APP_NAME=ServiceManager Cliente
|
||||
- PORT=3000
|
||||
- HMR_CLIENT_PORT=3000
|
||||
volumes:
|
||||
- ./frontend-client:/app
|
||||
- /app/node_modules
|
||||
@@ -190,6 +191,8 @@ services:
|
||||
- NODE_ENV=${ENVIRONMENT:-development}
|
||||
- PUBLIC_API_URL=http://backend:8000
|
||||
- PUBLIC_APP_NAME=ServiceManager Admin
|
||||
- PORT=3000
|
||||
- HMR_CLIENT_PORT=3001
|
||||
volumes:
|
||||
- ./frontend-internal:/app
|
||||
- /app/node_modules
|
||||
@@ -212,7 +215,7 @@ services:
|
||||
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- uploads_data:/var/www/uploads:ro
|
||||
ports:
|
||||
- "80:80"
|
||||
- "8088:80"
|
||||
depends_on:
|
||||
- backend
|
||||
- frontend-client
|
||||
|
||||
@@ -42,4 +42,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
# Comando por defecto
|
||||
# Development: usar --reload
|
||||
# Production: usar --workers y quitar --reload
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
@@ -55,6 +55,9 @@ http {
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" always;
|
||||
add_header X-Permitted-Cross-Domain-Policies "none" always;
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
|
||||
|
||||
# Hide server version
|
||||
|
||||
11
frontend-client/fix_usuarios.py
Normal file
11
frontend-client/fix_usuarios.py
Normal file
@@ -0,0 +1,11 @@
|
||||
with open("/app/src/routes/usuarios/+page.svelte", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
content = content.replace(
|
||||
"{#if (user as any).can_manage_users && user.role !== 'CLIENT_ADMIN'}",
|
||||
"{#if user.can_manage_users && user.role !== 'CLIENT_ADMIN'}"
|
||||
)
|
||||
|
||||
with open("/app/src/routes/usuarios/+page.svelte", "w") as f:
|
||||
f.write(content)
|
||||
print("Listo")
|
||||
11
frontend-client/src/app.d.ts
vendored
Normal file
11
frontend-client/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { User } from '$lib/stores/auth';
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
interface Locals {
|
||||
user: User | null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" type="image/png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#3b82f6" />
|
||||
|
||||
|
||||
37
frontend-client/src/hooks.server.ts
Normal file
37
frontend-client/src/hooks.server.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
// No restaurar sesión en la página de login
|
||||
if (event.url.pathname === '/login') {
|
||||
event.locals.user = null;
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
const cookieHeader = event.request.headers.get('cookie') ?? '';
|
||||
const cookieMatch = cookieHeader.match(/(?:client_access_token|internal_access_token)=([^;]+)/);
|
||||
const token = cookieMatch?.[1];
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const apiUrl = process.env.PUBLIC_API_URL ?? 'http://backend:8000';
|
||||
const response = await fetch(`${apiUrl}/v1/auth/me`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'X-App': 'client',
|
||||
'X-Tenant-Slug': ''
|
||||
}
|
||||
});
|
||||
if (response.ok) {
|
||||
event.locals.user = await response.json();
|
||||
} else {
|
||||
event.locals.user = null;
|
||||
event.cookies.delete('client_access_token', { path: '/' });
|
||||
event.cookies.delete('internal_access_token', { path: '/' });
|
||||
}
|
||||
} catch {
|
||||
event.locals.user = null;
|
||||
}
|
||||
} else {
|
||||
event.locals.user = null;
|
||||
}
|
||||
return resolve(event);
|
||||
};
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
function handleLogout() {
|
||||
isMenuOpen = false;
|
||||
auth.logout(); // El store maneja la redirección automática
|
||||
auth.logout(); // El store maneja la redirección automática
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -49,6 +49,13 @@
|
||||
>
|
||||
Mis Tickets
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/usuarios"
|
||||
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
Usuarios
|
||||
</a>
|
||||
<a
|
||||
href="/tickets/new"
|
||||
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
|
||||
@@ -133,6 +140,13 @@
|
||||
>
|
||||
Mis Tickets
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/usuarios"
|
||||
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
Usuarios
|
||||
</a>
|
||||
<a
|
||||
href="/tickets/new"
|
||||
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
|
||||
|
||||
@@ -17,7 +17,14 @@
|
||||
eye: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z',
|
||||
clock: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
check: 'M5 13l4 4L19 7',
|
||||
chevronDown: 'M19 9l-7 7-7-7'
|
||||
chevronDown: 'M19 9l-7 7-7-7',
|
||||
'building-2': 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4',
|
||||
'loader-2': 'M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83',
|
||||
'alert-circle': 'M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
'shield-check': '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',
|
||||
mail: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z',
|
||||
lock: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z',
|
||||
'eye-off': 'M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21'
|
||||
};
|
||||
|
||||
$: path = icons[name] || icons.home;
|
||||
|
||||
146
frontend-client/src/lib/components/IssueModal.svelte
Normal file
146
frontend-client/src/lib/components/IssueModal.svelte
Normal file
@@ -0,0 +1,146 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { tickets } from '$lib/stores/tickets';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
|
||||
export let ticketId: string;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const PRIORITIES = [
|
||||
{ value: 'LOW', label: 'Baja' },
|
||||
{ value: 'MEDIUM', label: 'Media' },
|
||||
{ value: 'HIGH', label: 'Alta' },
|
||||
{ value: 'URGENT', label: 'Urgente' }
|
||||
];
|
||||
|
||||
let content = '';
|
||||
let priority = 'MEDIUM';
|
||||
let file: File | null = null;
|
||||
let isSubmitting = false;
|
||||
|
||||
function handleFileChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
file = input.files?.[0] ?? null;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!content.trim()) {
|
||||
toast.error('El contenido del asunto es obligatorio');
|
||||
return;
|
||||
}
|
||||
isSubmitting = true;
|
||||
try {
|
||||
await tickets.createIssue(ticketId, {
|
||||
content: content.trim(),
|
||||
priority,
|
||||
tagged_user_ids: [],
|
||||
file
|
||||
});
|
||||
toast.success('Asunto creado correctamente');
|
||||
dispatch('created');
|
||||
dispatch('close');
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || 'Error al crear el asunto');
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
dispatch('close');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div
|
||||
class="fixed inset-0 bg-gray-500 bg-opacity-75"
|
||||
on:click={handleClose}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
on:keydown={(e) => e.key === 'Escape' && handleClose()}
|
||||
/>
|
||||
|
||||
<div class="relative bg-white rounded-lg shadow-xl w-full max-w-lg z-10">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Crear Asunto</h3>
|
||||
<button type="button" on:click={handleClose} class="text-gray-400 hover:text-gray-500">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-5 space-y-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Descripción <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
rows="4"
|
||||
bind:value={content}
|
||||
disabled={isSubmitting}
|
||||
placeholder="Describe el asunto de escalación..."
|
||||
class="form-input w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Prioridad</label>
|
||||
<select bind:value={priority} disabled={isSubmitting} class="form-input w-full">
|
||||
{#each PRIORITIES as p}
|
||||
<option value={p.value}>{p.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Adjunto (opcional)
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
on:change={handleFileChange}
|
||||
disabled={isSubmitting}
|
||||
class="block w-full text-sm text-gray-500
|
||||
file:mr-4 file:py-2 file:px-4 file:rounded-md
|
||||
file:border-0 file:text-sm file:font-medium
|
||||
file:bg-blue-50 file:text-blue-700
|
||||
hover:file:bg-blue-100 disabled:opacity-50"
|
||||
/>
|
||||
{#if file}
|
||||
<p class="text-xs text-gray-500 mt-1">{file.name}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 px-6 py-4 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
on:click={handleClose}
|
||||
disabled={isSubmitting}
|
||||
class="btn-secondary px-4 py-2"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
on:click={handleSubmit}
|
||||
disabled={isSubmitting || !content.trim()}
|
||||
class="btn-primary px-4 py-2 disabled:opacity-50"
|
||||
>
|
||||
{#if isSubmitting}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="spinner w-4 h-4" />
|
||||
<span>Creando...</span>
|
||||
</div>
|
||||
{:else}
|
||||
Crear Asunto
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
242
frontend-client/src/lib/components/ParticipantsModal.svelte
Normal file
242
frontend-client/src/lib/components/ParticipantsModal.svelte
Normal file
@@ -0,0 +1,242 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { api } from '$lib/utils/api';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
import { auth } from '$lib/stores/auth';
|
||||
|
||||
export let ticketId: string;
|
||||
export let createdBy: string;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
interface Participant {
|
||||
id: string;
|
||||
user_id: string;
|
||||
ticket_id: string;
|
||||
role: string;
|
||||
user_email: string;
|
||||
user_name: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
let participants: Participant[] = [];
|
||||
let allUsers: User[] = [];
|
||||
let isLoading = true;
|
||||
let isAdding = false;
|
||||
let searchQuery = '';
|
||||
let selectedUserId = '';
|
||||
|
||||
$: isCreator = $auth.user?.id === createdBy;
|
||||
$: isGlobalAdmin = $auth.user?.role === 'ADMIN';
|
||||
$: canManage = isCreator || isGlobalAdmin;
|
||||
|
||||
$: participantIds = new Set(participants.map(p => p.user_id));
|
||||
|
||||
$: filteredUsers = allUsers.filter(u => {
|
||||
if (participantIds.has(u.id)) return false;
|
||||
if (u.id === createdBy) return false;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return !q ||
|
||||
u.email.toLowerCase().includes(q) ||
|
||||
`${u.first_name} ${u.last_name}`.toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
await Promise.all([loadParticipants(), loadUsers()]);
|
||||
isLoading = false;
|
||||
});
|
||||
|
||||
async function loadParticipants() {
|
||||
try {
|
||||
participants = await api.get(`/tickets/${ticketId}/participants`);
|
||||
} catch (e: any) {
|
||||
toast.error('Error cargando participantes');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
allUsers = await api.get('/users/');
|
||||
} catch (e: any) {
|
||||
toast.error('Error cargando usuarios');
|
||||
}
|
||||
}
|
||||
|
||||
async function addParticipant(userId: string) {
|
||||
if (!userId) return;
|
||||
isAdding = true;
|
||||
try {
|
||||
const p = await api.post(`/tickets/${ticketId}/participants`, { user_id: userId });
|
||||
participants = [...participants, p];
|
||||
searchQuery = '';
|
||||
toast.success('Participante agregado');
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || 'Error al agregar participante');
|
||||
} finally {
|
||||
isAdding = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeParticipant(userId: string) {
|
||||
try {
|
||||
await api.delete(`/tickets/${ticketId}/participants/${userId}`);
|
||||
participants = participants.filter(p => p.user_id !== userId);
|
||||
toast.success('Participante eliminado');
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || 'Error al eliminar participante');
|
||||
}
|
||||
}
|
||||
|
||||
function avatarInitials(name: string, email: string): string {
|
||||
if (name.trim()) {
|
||||
const parts = name.trim().split(' ');
|
||||
return (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase();
|
||||
}
|
||||
return email[0].toUpperCase();
|
||||
}
|
||||
|
||||
function avatarColor(email: string): string {
|
||||
const colors = ['bg-blue-400', 'bg-violet-400', 'bg-emerald-400', 'bg-rose-400', 'bg-amber-400', 'bg-cyan-400'];
|
||||
let hash = 0;
|
||||
for (const c of email) hash = (hash << 5) - hash + c.charCodeAt(0);
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Overlay -->
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" on:click={() => dispatch('close')}></div>
|
||||
|
||||
<div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-lg z-10 overflow-hidden">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900">Participantes del asunto</h2>
|
||||
<p class="text-xs text-gray-400 mt-0.5">{participants.length} persona{participants.length !== 1 ? 's' : ''} involucrada{participants.length !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
<button type="button" on:click={() => dispatch('close')}
|
||||
class="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 transition-colors">
|
||||
<svg class="w-5 h-5" 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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6 space-y-5 max-h-[70vh] overflow-y-auto">
|
||||
|
||||
<!-- Buscador para agregar -->
|
||||
{#if canManage}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Agregar persona</label>
|
||||
<div class="relative">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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"/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por nombre o email..."
|
||||
bind:value={searchQuery}
|
||||
class="w-full pl-9 pr-4 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Resultados de búsqueda -->
|
||||
{#if searchQuery && filteredUsers.length > 0}
|
||||
<div class="mt-2 border border-gray-200 rounded-lg overflow-hidden shadow-sm">
|
||||
{#each filteredUsers.slice(0, 6) as user (user.id)}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => addParticipant(user.id)}
|
||||
disabled={isAdding}
|
||||
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-blue-50 transition-colors text-left border-b border-gray-100 last:border-0"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-full {avatarColor(user.email)} flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
|
||||
{avatarInitials(`${user.first_name} ${user.last_name}`, user.email)}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">{user.first_name} {user.last_name}</p>
|
||||
<p class="text-xs text-gray-400 truncate">{user.email}</p>
|
||||
</div>
|
||||
<span class="ml-auto text-xs text-blue-600 font-medium flex-shrink-0">+ Agregar</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if searchQuery && filteredUsers.length === 0}
|
||||
<p class="mt-2 text-sm text-gray-400 text-center py-2">No se encontraron usuarios disponibles</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Lista de participantes -->
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-700 mb-3">
|
||||
{canManage ? 'Participantes actuales' : 'Personas involucradas'}
|
||||
</h3>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="flex justify-center py-6">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
|
||||
{:else if participants.length === 0}
|
||||
<div class="text-center py-8 bg-gray-50 rounded-lg border border-dashed border-gray-200">
|
||||
<svg class="w-10 h-10 mx-auto text-gray-300 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
<p class="text-sm text-gray-400">Aún no hay participantes</p>
|
||||
{#if canManage}
|
||||
<p class="text-xs text-gray-300 mt-1">Usa el buscador para agregar personas</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each participants as p (p.id)}
|
||||
<div class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg group">
|
||||
<div class="w-9 h-9 rounded-full {avatarColor(p.user_email)} flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
|
||||
{avatarInitials(p.user_name, p.user_email)}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">{p.user_name || p.user_email}</p>
|
||||
<p class="text-xs text-gray-400 truncate">{p.user_email}</p>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400 bg-white px-2 py-0.5 rounded-full border border-gray-200">
|
||||
Participante
|
||||
</span>
|
||||
{#if canManage}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => removeParticipant(p.user_id)}
|
||||
class="p-1 rounded text-gray-300 hover:text-rose-500 hover:bg-rose-50 transition-colors opacity-0 group-hover:opacity-100"
|
||||
title="Quitar participante"
|
||||
>
|
||||
<svg class="w-4 h-4" 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>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="px-6 py-4 bg-gray-50 border-t border-gray-100 flex justify-end">
|
||||
<button type="button" on:click={() => dispatch('close')}
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
Cerrar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -2,22 +2,25 @@
|
||||
export let ticket: import('$lib/stores/tickets').Ticket;
|
||||
|
||||
// Status mapping
|
||||
const statusConfig = {
|
||||
const statusConfig: Record<string, { label: string; class: string }> = {
|
||||
NEW: { label: 'Nuevo', class: 'badge-new' },
|
||||
IN_PROGRESS: { label: 'En Progreso', class: 'badge-in-progress' },
|
||||
WAITING_CUSTOMER: { label: 'Esperando Cliente', class: 'badge-waiting' },
|
||||
WAITING_FOR_CLIENT: { label: 'Esperando Cliente', class: 'badge-waiting' },
|
||||
RESOLVED: { label: 'Resuelto', class: 'badge-resolved' },
|
||||
CLOSED: { label: 'Cerrado', class: 'badge-closed' },
|
||||
REOPENED: { label: 'Reabierto', class: 'badge-reopened' }
|
||||
};
|
||||
const fallbackStatus = { label: 'Desconocido', class: 'badge-new' };
|
||||
|
||||
// Priority mapping
|
||||
const priorityConfig = {
|
||||
const priorityConfig: Record<string, { label: string; class: string }> = {
|
||||
LOW: { label: 'Baja', class: 'badge-priority-low' },
|
||||
MEDIUM: { label: 'Media', class: 'badge-priority-medium' },
|
||||
HIGH: { label: 'Alta', class: 'badge-priority-high' },
|
||||
URGENT: { label: 'Urgente', class: 'badge-priority-urgent' }
|
||||
};
|
||||
const fallbackPriority = { label: 'Normal', class: 'badge-priority-medium' };
|
||||
|
||||
// Format date
|
||||
function formatDate(dateString: string): string {
|
||||
@@ -58,11 +61,11 @@
|
||||
</a>
|
||||
</h3>
|
||||
<div class="flex items-center space-x-2 ml-4">
|
||||
<span class={`${statusConfig[ticket.status].class}`}>
|
||||
{statusConfig[ticket.status].label}
|
||||
<span class={(statusConfig[ticket.status] ?? fallbackStatus).class}>
|
||||
{(statusConfig[ticket.status] ?? fallbackStatus).label}
|
||||
</span>
|
||||
<span class={`${priorityConfig[ticket.priority].class}`}>
|
||||
{priorityConfig[ticket.priority].label}
|
||||
<span class={(priorityConfig[ticket.priority] ?? fallbackPriority).class}>
|
||||
{(priorityConfig[ticket.priority] ?? fallbackPriority).label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,14 +29,17 @@ const initialState: AppState = {
|
||||
// API helper function
|
||||
async function apiCall(endpoint: string, options: RequestInit = {}) {
|
||||
const authState = get(auth);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-App': 'client',
|
||||
...(authState.user?.tenant_id ? { 'X-Tenant-ID': authState.user.tenant_id } : {}),
|
||||
...(options.headers as Record<string, string> ?? {})
|
||||
};
|
||||
if (authState.token) headers['Authorization'] = `Bearer ${authState.token}`;
|
||||
const response = await fetch(`/api/v1${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authState.token}`,
|
||||
...options.headers
|
||||
}
|
||||
credentials: 'include',
|
||||
headers
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Writable } from 'svelte/store';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
// Types
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
@@ -35,7 +34,6 @@ export interface LoginResponse {
|
||||
user: User;
|
||||
}
|
||||
|
||||
// Initial state
|
||||
const initialState: AuthState = {
|
||||
user: null,
|
||||
token: null,
|
||||
@@ -43,98 +41,86 @@ const initialState: AuthState = {
|
||||
isLoading: false
|
||||
};
|
||||
|
||||
// Create auth store
|
||||
function createAuthStore() {
|
||||
const { subscribe, set, update }: Writable<AuthState> = writable(initialState);
|
||||
let _state = initialState;
|
||||
subscribe(s => { _state = s; });
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
// Initialize auth from localStorage
|
||||
init: () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const user = localStorage.getItem('auth_user');
|
||||
|
||||
if (token && user) {
|
||||
try {
|
||||
const parsedUser = JSON.parse(user);
|
||||
set({
|
||||
user: parsedUser,
|
||||
token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error parsing stored auth data:', error);
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_user');
|
||||
// Verifica sesión activa al cargar la app (cookie HttpOnly)
|
||||
init: async () => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/me', {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'X-App': 'client',
|
||||
// Sin X-Tenant-ID aquí — /auth/me lee el tenant del JWT directamente
|
||||
}
|
||||
});
|
||||
if (response.ok) {
|
||||
const user = await response.json();
|
||||
// Token es null — la autenticación viaja por cookie HttpOnly
|
||||
// El store solo necesita el user para la UI
|
||||
set({ user, token: null, isAuthenticated: true, isLoading: false });
|
||||
} else {
|
||||
// Cookie expirada o inválida — limpiar estado
|
||||
set(initialState);
|
||||
}
|
||||
} catch {
|
||||
set(initialState);
|
||||
}
|
||||
},
|
||||
|
||||
// Login
|
||||
login: async (credentials: LoginRequest): Promise<void> => {
|
||||
update(state => ({ ...state, isLoading: true }));
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-Slug': credentials.tenant_slug,
|
||||
},
|
||||
body: JSON.stringify(credentials)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Login failed');
|
||||
}
|
||||
|
||||
const data: LoginResponse = await response.json();
|
||||
|
||||
// Store auth data
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('auth_token', data.access_token);
|
||||
localStorage.setItem('auth_user', JSON.stringify(data.user));
|
||||
}
|
||||
|
||||
set({
|
||||
user: data.user,
|
||||
token: data.access_token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false
|
||||
});
|
||||
// Guardamos el token en memoria para requests inmediatos
|
||||
// Si la página se recarga, init() recupera la sesión desde la cookie
|
||||
set({ user: data.user, token: data.access_token, isAuthenticated: true, isLoading: false });
|
||||
} catch (error) {
|
||||
update(state => ({ ...state, isLoading: false }));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Logout
|
||||
logout: () => {
|
||||
logout: async () => {
|
||||
try {
|
||||
const token = _state.token;
|
||||
await fetch('/api/v1/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'X-App': 'client',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
}
|
||||
});
|
||||
} catch {}
|
||||
set(initialState);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_user');
|
||||
// Immediate redirect after cleanup
|
||||
window.location.href = '/login';
|
||||
}
|
||||
set(initialState);
|
||||
},
|
||||
|
||||
// Update user data
|
||||
updateUser: (user: User) => {
|
||||
update(state => ({ ...state, user }));
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('auth_user', JSON.stringify(user));
|
||||
}
|
||||
},
|
||||
|
||||
// Set loading state
|
||||
setLoading: (isLoading: boolean) => {
|
||||
update(state => ({ ...state, isLoading }));
|
||||
}
|
||||
updateUser: (user: User) => { update(state => ({ ...state, user })); },
|
||||
setUser: (user: User) => { set({ user, token: null, isAuthenticated: true, isLoading: false }); },
|
||||
setLoading: (isLoading: boolean) => { update(state => ({ ...state, isLoading })); }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
104
frontend-client/src/lib/stores/auth.ts.bak
Normal file
104
frontend-client/src/lib/stores/auth.ts.bak
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { Writable } from 'svelte/store';
|
||||
import { writable } from 'svelte/store';
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
tenant_id: string;
|
||||
role: 'CLIENT_ADMIN' | 'CLIENT_USER';
|
||||
is_active: boolean;
|
||||
is_two_factor_enabled: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
export interface AuthState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
tenant_slug: string;
|
||||
totp_code?: string;
|
||||
}
|
||||
export interface LoginResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
user: User;
|
||||
}
|
||||
const initialState: AuthState = {
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false
|
||||
};
|
||||
function createAuthStore() {
|
||||
const { subscribe, set, update }: Writable<AuthState> = writable(initialState);
|
||||
let _state = initialState;
|
||||
subscribe(s => { _state = s; });
|
||||
return {
|
||||
subscribe,
|
||||
init: async () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/me', {
|
||||
credentials: 'include',
|
||||
headers: { 'X-App': 'client', 'X-Tenant-Slug': 'aduanasoft' }
|
||||
});
|
||||
if (response.ok) {
|
||||
const user = await response.json();
|
||||
set({ user, token: null, isAuthenticated: true, isLoading: false });
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
},
|
||||
login: async (credentials: LoginRequest): Promise<void> => {
|
||||
update(state => ({ ...state, isLoading: true }));
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-Slug': credentials.tenant_slug,
|
||||
},
|
||||
body: JSON.stringify(credentials)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Login failed');
|
||||
}
|
||||
const data: LoginResponse = await response.json();
|
||||
set({ user: data.user, token: data.access_token, isAuthenticated: true, isLoading: false });
|
||||
} catch (error) {
|
||||
update(state => ({ ...state, isLoading: false }));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
logout: async () => {
|
||||
try {
|
||||
const token = _state.token;
|
||||
await fetch('/api/v1/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'X-App': 'client',
|
||||
'X-Tenant-Slug': 'aduanasoft',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
}
|
||||
});
|
||||
} catch {}
|
||||
set(initialState);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
},
|
||||
updateUser: (user: User) => { update(state => ({ ...state, user })); },
|
||||
setUser: (user: User) => { set({ user, token: null, isAuthenticated: true, isLoading: false }); },
|
||||
setLoading: (isLoading: boolean) => { update(state => ({ ...state, isLoading })); }
|
||||
};
|
||||
}
|
||||
export const auth = createAuthStore();
|
||||
@@ -2,7 +2,6 @@ import type { Writable } from 'svelte/store';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { auth } from './auth';
|
||||
|
||||
// Types
|
||||
interface FastAPIValidationError {
|
||||
loc: (string | number)[];
|
||||
msg: string;
|
||||
@@ -24,6 +23,11 @@ export interface Ticket {
|
||||
updated_at: string;
|
||||
due_date: string | null;
|
||||
resolution: string | null;
|
||||
first_response_at?: string | null;
|
||||
resolved_at?: string | null;
|
||||
sla_response_due?: string | null;
|
||||
sla_resolution_due?: string | null;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
export interface TicketComment {
|
||||
@@ -50,6 +54,19 @@ export interface TicketAttachment {
|
||||
uploaded_at: string;
|
||||
}
|
||||
|
||||
export interface TicketIssue {
|
||||
id: string;
|
||||
ticket_id: string;
|
||||
content: string;
|
||||
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
|
||||
created_by: string;
|
||||
created_by_name: string;
|
||||
tagged_users: { id: string; full_name: string; email: string }[];
|
||||
attachment_filename: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateTicketRequest {
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -62,49 +79,53 @@ export interface TicketsState {
|
||||
currentTicket: Ticket | null;
|
||||
comments: TicketComment[];
|
||||
attachments: TicketAttachment[];
|
||||
issues: TicketIssue[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// Initial state
|
||||
const initialState: TicketsState = {
|
||||
tickets: [],
|
||||
currentTicket: null,
|
||||
comments: [],
|
||||
attachments: [],
|
||||
issues: [],
|
||||
isLoading: false,
|
||||
error: null
|
||||
};
|
||||
|
||||
// API helper function
|
||||
async function apiCall(endpoint: string, options: RequestInit = {}) {
|
||||
const authState = get(auth);
|
||||
if (!authState.user) throw new Error('Not authenticated');
|
||||
|
||||
if (!authState.token || !authState.user) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
// Verificar que el usuario sigue autenticado
|
||||
if (!authState.isAuthenticated) throw new Error('Session expired');
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-App': 'client',
|
||||
...(options.headers as Record<string, string>)
|
||||
};
|
||||
// Solo agregar Authorization si el token existe en memoria
|
||||
// Si no está (recarga de página), la cookie HttpOnly lo maneja
|
||||
if (authState.token) headers['Authorization'] = `Bearer ${authState.token}`;
|
||||
if (authState.user.tenant_id) headers['X-Tenant-ID'] = authState.user.tenant_id;
|
||||
|
||||
const response = await fetch(`/api/v1${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authState.token}`,
|
||||
'X-Tenant-ID': authState.user.tenant_id,
|
||||
...options.headers
|
||||
}
|
||||
credentials: 'include',
|
||||
headers
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'Request failed';
|
||||
try {
|
||||
const error = await response.json();
|
||||
console.error('❌ API Error Response:', error);
|
||||
|
||||
// Manejar diferentes formatos de error de FastAPI
|
||||
if (error.detail) {
|
||||
if (Array.isArray(error.detail)) {
|
||||
// Errores de validación de FastAPI
|
||||
errorMessage = error.detail.map((e: FastAPIValidationError) => `${e.loc.join('.')}: ${e.msg}`).join(', ');
|
||||
errorMessage = error.detail
|
||||
.map((e: FastAPIValidationError) => `${e.loc.join('.')}: ${e.msg}`)
|
||||
.join(', ');
|
||||
} else if (typeof error.detail === 'string') {
|
||||
errorMessage = error.detail;
|
||||
} else {
|
||||
@@ -113,32 +134,29 @@ async function apiCall(endpoint: string, options: RequestInit = {}) {
|
||||
} else {
|
||||
errorMessage = JSON.stringify(error);
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Create tickets store
|
||||
function createTicketsStore() {
|
||||
const { subscribe, set, update }: Writable<TicketsState> = writable(initialState);
|
||||
const { subscribe, update }: Writable<TicketsState> = writable(initialState);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
// Load user's tickets
|
||||
loadTickets: async () => {
|
||||
update((state: TicketsState) => ({ ...state, isLoading: true, error: null }));
|
||||
|
||||
update(state => ({ ...state, isLoading: true, error: null }));
|
||||
try {
|
||||
const tickets = await apiCall('/tickets/');
|
||||
update((state: TicketsState) => ({ ...state, tickets, isLoading: false }));
|
||||
const raw = await apiCall('/tickets/');
|
||||
const tickets = raw.map((t: any) => ({ ...t, title: t.subject ?? t.title }));
|
||||
update(state => ({ ...state, tickets, isLoading: false }));
|
||||
} catch (error) {
|
||||
update((state: TicketsState) => ({
|
||||
update(state => ({
|
||||
...state,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to load tickets'
|
||||
@@ -146,76 +164,117 @@ function createTicketsStore() {
|
||||
}
|
||||
},
|
||||
|
||||
// Load specific ticket with details
|
||||
loadTicket: async (ticketId: string) => {
|
||||
update((state: TicketsState) => ({ ...state, isLoading: true, error: null }));
|
||||
|
||||
update(state => ({ ...state, isLoading: true, error: null }));
|
||||
try {
|
||||
const [ticket, comments, attachments] = await Promise.all([
|
||||
const [ticketRaw, comments, attachments, issues] = await Promise.all([
|
||||
apiCall(`/tickets/${ticketId}`),
|
||||
apiCall(`/tickets/${ticketId}/comments`),
|
||||
apiCall(`/tickets/${ticketId}/attachments`)
|
||||
apiCall(`/tickets/${ticketId}/attachments`),
|
||||
apiCall(`/tickets/${ticketId}/issues`)
|
||||
]);
|
||||
|
||||
update((state: TicketsState) => ({
|
||||
const ticket = { ...ticketRaw, title: ticketRaw.subject ?? ticketRaw.title };
|
||||
update(state => ({
|
||||
...state,
|
||||
currentTicket: ticket,
|
||||
comments,
|
||||
attachments,
|
||||
issues,
|
||||
isLoading: false
|
||||
}));
|
||||
} catch (error) {
|
||||
update((state: TicketsState) => ({
|
||||
update(state => ({
|
||||
...state,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to load ticket'
|
||||
}));
|
||||
}
|
||||
},
|
||||
// Reload only comments silently (for polling)
|
||||
|
||||
reloadComments: async (ticketId: string) => {
|
||||
try {
|
||||
const comments = await apiCall(`/tickets/${ticketId}/comments`);
|
||||
update((state: TicketsState) => ({ ...state, comments }));
|
||||
} catch (error) {
|
||||
// Silent fail - no mostrar error en polling
|
||||
console.error('Error reloading comments:', error);
|
||||
update(state => ({ ...state, comments }));
|
||||
} catch {
|
||||
// Silent fail en polling
|
||||
}
|
||||
},
|
||||
|
||||
createIssue: async (ticketId: string, data: {
|
||||
content: string;
|
||||
priority: string;
|
||||
tagged_user_ids: string[];
|
||||
file?: File | null;
|
||||
}) => {
|
||||
const authState = get(auth);
|
||||
if (!authState.user) throw new Error('Not authenticated');
|
||||
if (!authState.isAuthenticated) throw new Error('Session expired');
|
||||
|
||||
const headers: Record<string, string> = { 'X-App': 'client' };
|
||||
// Solo agregar token si existe — sino la cookie HttpOnly lo maneja
|
||||
if (authState.token) headers['Authorization'] = `Bearer ${authState.token}`;
|
||||
if (authState.user.tenant_id) headers['X-Tenant-ID'] = authState.user.tenant_id;
|
||||
|
||||
let body: FormData | string;
|
||||
|
||||
if (data.file) {
|
||||
const formData = new FormData();
|
||||
formData.append('content', data.content);
|
||||
formData.append('priority', data.priority);
|
||||
data.tagged_user_ids.forEach(id => formData.append('tagged_user_ids', id));
|
||||
formData.append('file', data.file);
|
||||
body = formData;
|
||||
// NO poner Content-Type — el browser lo agrega con boundary automáticamente
|
||||
} else {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
body = JSON.stringify({
|
||||
content: data.content,
|
||||
priority: data.priority,
|
||||
tagged_user_ids: data.tagged_user_ids
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/v1/tickets/${ticketId}/issues`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers,
|
||||
body
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Error creating issue' }));
|
||||
throw new Error(
|
||||
typeof error.detail === 'string' ? error.detail : JSON.stringify(error.detail)
|
||||
);
|
||||
}
|
||||
|
||||
const newIssue = await response.json();
|
||||
update(state => ({ ...state, issues: [newIssue, ...state.issues] }));
|
||||
return newIssue;
|
||||
},
|
||||
|
||||
// Create new ticket
|
||||
createTicket: async (ticket: CreateTicketRequest) => {
|
||||
update((state: TicketsState) => ({ ...state, isLoading: true, error: null }));
|
||||
|
||||
update(state => ({ ...state, isLoading: true, error: null }));
|
||||
try {
|
||||
// Mapear campos del frontend al formato del backend
|
||||
const ticketData = {
|
||||
subject: ticket.title, // ← Backend espera "subject" no "title"
|
||||
subject: ticket.title,
|
||||
description: ticket.description,
|
||||
category_id: ticket.category_id,
|
||||
priority: ticket.priority,
|
||||
system_id: null // ← Opcional
|
||||
system_id: null
|
||||
};
|
||||
|
||||
|
||||
console.log('Sending ticket data:', ticketData);
|
||||
|
||||
const newTicket = await apiCall('/tickets/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(ticketData)
|
||||
});
|
||||
|
||||
update((state: TicketsState) => ({
|
||||
update(state => ({
|
||||
...state,
|
||||
tickets: [newTicket, ...state.tickets],
|
||||
isLoading: false
|
||||
}));
|
||||
|
||||
return newTicket;
|
||||
} catch (error) {
|
||||
console.error('Create ticket error:', error);
|
||||
update((state: TicketsState) => ({
|
||||
update(state => ({
|
||||
...state,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to create ticket'
|
||||
@@ -224,22 +283,18 @@ function createTicketsStore() {
|
||||
}
|
||||
},
|
||||
|
||||
// Add comment to ticket
|
||||
addComment: async (ticketId: string, content: string) => {
|
||||
try {
|
||||
const comment = await apiCall(`/tickets/${ticketId}/comments`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content })
|
||||
// is_internal siempre false desde el frontend cliente
|
||||
// el backend además lo bloquearía si fuera true (fix de seguridad pendiente)
|
||||
body: JSON.stringify({ content, is_internal: false })
|
||||
});
|
||||
|
||||
update((state: TicketsState) => ({
|
||||
...state,
|
||||
comments: [...state.comments, comment]
|
||||
}));
|
||||
|
||||
update(state => ({ ...state, comments: [...state.comments, comment] }));
|
||||
return comment;
|
||||
} catch (error) {
|
||||
update((state: TicketsState) => ({
|
||||
update(state => ({
|
||||
...state,
|
||||
error: error instanceof Error ? error.message : 'Failed to add comment'
|
||||
}));
|
||||
@@ -247,24 +302,21 @@ function createTicketsStore() {
|
||||
}
|
||||
},
|
||||
|
||||
// Upload attachment
|
||||
uploadAttachment: async (ticketId: string, file: File) => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const authState = get(auth);
|
||||
if (!authState.user) throw new Error('Not authenticated');
|
||||
|
||||
if (!authState.token || !authState.user) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
const uploadHeaders: Record<string, string> = { 'X-App': 'client' };
|
||||
if (authState.token) uploadHeaders['Authorization'] = `Bearer ${authState.token}`;
|
||||
if (authState.user.tenant_id) uploadHeaders['X-Tenant-ID'] = authState.user.tenant_id;
|
||||
|
||||
const response = await fetch(`/api/v1/tickets/${ticketId}/attachments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authState.token}`,
|
||||
'X-Tenant-ID': authState.user.tenant_id
|
||||
},
|
||||
credentials: 'include',
|
||||
headers: uploadHeaders,
|
||||
body: formData
|
||||
});
|
||||
|
||||
@@ -275,15 +327,10 @@ function createTicketsStore() {
|
||||
|
||||
const result = await response.json();
|
||||
const attachment = result.data || result;
|
||||
|
||||
update((state: TicketsState) => ({
|
||||
...state,
|
||||
attachments: [...state.attachments, attachment]
|
||||
}));
|
||||
|
||||
update(state => ({ ...state, attachments: [...state.attachments, attachment] }));
|
||||
return attachment;
|
||||
} catch (error) {
|
||||
update((state: TicketsState) => ({
|
||||
update(state => ({
|
||||
...state,
|
||||
error: error instanceof Error ? error.message : 'Failed to upload attachment'
|
||||
}));
|
||||
@@ -291,23 +338,20 @@ function createTicketsStore() {
|
||||
}
|
||||
},
|
||||
|
||||
// Close ticket (client can close their own tickets)
|
||||
closeTicket: async (ticketId: string, resolution?: string) => {
|
||||
try {
|
||||
const updatedTicket = await apiCall(`/tickets/${ticketId}/close`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ resolution })
|
||||
body: JSON.stringify({ resolution_notes: resolution })
|
||||
});
|
||||
|
||||
update((state: TicketsState) => ({
|
||||
update(state => ({
|
||||
...state,
|
||||
currentTicket: state.currentTicket?.id === ticketId ? updatedTicket : state.currentTicket,
|
||||
tickets: state.tickets.map((t: Ticket) => t.id === ticketId ? updatedTicket : t)
|
||||
tickets: state.tickets.map(t => t.id === ticketId ? updatedTicket : t)
|
||||
}));
|
||||
|
||||
return updatedTicket;
|
||||
} catch (error) {
|
||||
update((state: TicketsState) => ({
|
||||
update(state => ({
|
||||
...state,
|
||||
error: error instanceof Error ? error.message : 'Failed to close ticket'
|
||||
}));
|
||||
@@ -315,43 +359,36 @@ function createTicketsStore() {
|
||||
}
|
||||
},
|
||||
|
||||
// Clear error
|
||||
clearError: () => {
|
||||
update((state: TicketsState) => ({ ...state, error: null }));
|
||||
},
|
||||
clearError: () => { update(state => ({ ...state, error: null })); },
|
||||
|
||||
// Clear current ticket
|
||||
clearCurrentTicket: () => {
|
||||
update((state: TicketsState) => ({
|
||||
update(state => ({
|
||||
...state,
|
||||
currentTicket: null,
|
||||
comments: [],
|
||||
attachments: []
|
||||
attachments: [],
|
||||
issues: []
|
||||
}));
|
||||
},
|
||||
|
||||
// Download attachment
|
||||
downloadAttachment: async (ticketId: string, attachmentId: string, filename: string) => {
|
||||
const authState = get(auth);
|
||||
if (!authState.user) throw new Error('Not authenticated');
|
||||
|
||||
if (!authState.token || !authState.user) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
const dlHeaders: Record<string, string> = { 'X-App': 'client' };
|
||||
if (authState.token) dlHeaders['Authorization'] = `Bearer ${authState.token}`;
|
||||
if (authState.user.tenant_id) dlHeaders['X-Tenant-ID'] = authState.user.tenant_id;
|
||||
|
||||
const response = await fetch(`/api/v1/tickets/${ticketId}/attachments/${attachmentId}/download`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authState.token}`,
|
||||
'X-Tenant-ID': authState.user.tenant_id
|
||||
}
|
||||
});
|
||||
const response = await fetch(
|
||||
`/api/v1/tickets/${ticketId}/attachments/${attachmentId}/download`,
|
||||
{ method: 'GET', credentials: 'include', headers: dlHeaders }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Download failed' }));
|
||||
throw new Error(error.detail || 'Download failed');
|
||||
}
|
||||
|
||||
// Crear blob y descargar
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
|
||||
112
frontend-client/src/lib/utils/api.ts
Normal file
112
frontend-client/src/lib/utils/api.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { auth } from '$lib/stores/auth';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
const API_BASE = '/api/v1';
|
||||
interface RequestOptions extends RequestInit {
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
|
||||
async function request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { params, ...init } = options;
|
||||
|
||||
let url = `${API_BASE}${endpoint}`;
|
||||
if (params) {
|
||||
const filteredParams = Object.entries(params)
|
||||
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
||||
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {});
|
||||
if (Object.keys(filteredParams).length > 0) {
|
||||
url += `?${new URLSearchParams(filteredParams).toString()}`;
|
||||
}
|
||||
}
|
||||
|
||||
const authState = get(auth);
|
||||
const headers = new Headers(init.headers);
|
||||
|
||||
if (authState.token) {
|
||||
headers.set('Authorization', `Bearer ${authState.token}`);
|
||||
}
|
||||
|
||||
if (!headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
headers.set('X-App', 'client');
|
||||
const slug = get(auth)?.user?.tenant_slug || get(auth)?.user?.tenant_id || '';
|
||||
headers.set('X-Tenant-Slug', slug);
|
||||
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
credentials: 'include',
|
||||
headers
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `API error: ${response.statusText}`);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function downloadFile(endpoint: string, filename: string): Promise<void> {
|
||||
const authState = get(auth);
|
||||
const headers = new Headers();
|
||||
|
||||
if (authState.token) {
|
||||
headers.set('Authorization', `Bearer ${authState.token}`);
|
||||
}
|
||||
}
|
||||
headers.set('X-App', 'client');
|
||||
const slug = get(auth)?.user?.tenant_slug || get(auth)?.user?.tenant_id || '';
|
||||
headers.set('X-Tenant-Slug', slug);
|
||||
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
if (typeof window !== 'undefined') window.location.href = '/login';
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `Download error: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(endpoint: string, params?: Record<string, string>) =>
|
||||
request<T>(endpoint, { method: 'GET', params }),
|
||||
post: <T>(endpoint: string, body?: any) =>
|
||||
request<T>(endpoint, { method: 'POST', body: body !== undefined ? JSON.stringify(body) : undefined }),
|
||||
put: <T>(endpoint: string, body?: any) =>
|
||||
request<T>(endpoint, { method: 'PUT', body: body !== undefined ? JSON.stringify(body) : undefined }),
|
||||
patch: <T>(endpoint: string, body?: any) =>
|
||||
request<T>(endpoint, { method: 'PATCH', body: body !== undefined ? JSON.stringify(body) : undefined }),
|
||||
delete: <T>(endpoint: string) =>
|
||||
request<T>(endpoint, { method: 'DELETE' }),
|
||||
downloadFile: (endpoint: string, filename: string) =>
|
||||
downloadFile(endpoint, filename)
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user