Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd3d7e816f | |||
| 63925fe305 | |||
| c146a6c3c3 | |||
| ba779bde55 | |||
| ba94152074 | |||
| bd21207aae | |||
| 1ccc39732b | |||
| ceea67eb2b | |||
| 517297e89a |
@@ -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*
|
||||
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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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
|
||||
@@ -13,12 +14,11 @@ from app.models.tenant import Tenant
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Define OAuth2 scheme here or import from auth if needed.
|
||||
# Defining here creates a separate instance which is fine as they share config.
|
||||
# Ideally auth.py should import from here, but modifying auth.py is risky now.
|
||||
# Esquema OAuth2 centralizado — auth.py importa desde aquí
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/{settings.API_VERSION}/auth/login")
|
||||
|
||||
async def get_current_user(
|
||||
request: Request,
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> User:
|
||||
@@ -45,6 +45,15 @@ 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.
|
||||
# Prevents cross-tenant header impersonation.
|
||||
request_tenant_id = getattr(getattr(request, "state", None), "tenant_id", None)
|
||||
if request_tenant_id 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
|
||||
|
||||
|
||||
@@ -1,15 +1,65 @@
|
||||
"""Schemas package initialization."""
|
||||
|
||||
from .auth import (
|
||||
LoginRequest, LoginResponse, RefreshTokenRequest, TokenResponse,
|
||||
TwoFactorStatusResponse, TwoFactorSetupResponse,
|
||||
TwoFactorEnableRequest, TwoFactorEnableResponse, TwoFactorDisableRequest,
|
||||
ChangePasswordRequest, ForgotPasswordRequest, ResetPasswordRequest,
|
||||
)
|
||||
from .tenant import TenantBase, TenantCreate, TenantUpdate, TenantResponse
|
||||
from .user import UserCreate, UserUpdate, UserResponse
|
||||
from .category import CategoryCreate, CategoryUpdate, CategoryResponse
|
||||
from .system import SystemCreate, SystemUpdate, SystemResponse
|
||||
from .ticket import (
|
||||
TicketCreate,
|
||||
TicketUpdate,
|
||||
TicketResponse,
|
||||
TicketCloseRequest,
|
||||
CommentCreate,
|
||||
CommentResponse,
|
||||
)
|
||||
from .client_profile import (
|
||||
ClientProfileCreate,
|
||||
ClientProfileUpdate,
|
||||
ClientProfileUpdate,
|
||||
ClientProfileResponse,
|
||||
ClientProfileSummary
|
||||
ClientProfileSummary,
|
||||
)
|
||||
from .audit import * # noqa: F401,F403
|
||||
from .sla import * # noqa: F401,F403
|
||||
|
||||
__all__ = [
|
||||
# Auth
|
||||
"LoginRequest",
|
||||
"LoginResponse",
|
||||
"RefreshTokenRequest",
|
||||
"TokenResponse",
|
||||
# Tenant
|
||||
"TenantBase",
|
||||
"TenantCreate",
|
||||
"TenantUpdate",
|
||||
"TenantResponse",
|
||||
# User
|
||||
"UserCreate",
|
||||
"UserUpdate",
|
||||
"UserResponse",
|
||||
# Category
|
||||
"CategoryCreate",
|
||||
"CategoryUpdate",
|
||||
"CategoryResponse",
|
||||
# System
|
||||
"SystemCreate",
|
||||
"SystemUpdate",
|
||||
"SystemResponse",
|
||||
# Ticket
|
||||
"TicketCreate",
|
||||
"TicketUpdate",
|
||||
"TicketResponse",
|
||||
"TicketCloseRequest",
|
||||
"CommentCreate",
|
||||
"CommentResponse",
|
||||
# Client Profile
|
||||
"ClientProfileCreate",
|
||||
"ClientProfileUpdate",
|
||||
"ClientProfileResponse",
|
||||
"ClientProfileSummary"
|
||||
"ClientProfileResponse",
|
||||
"ClientProfileSummary",
|
||||
]
|
||||
@@ -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):
|
||||
|
||||
96
backend/app/api/schemas/auth.py
Normal file
96
backend/app/api/schemas/auth.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
Auth Schemas - ServiceManagerWeb
|
||||
|
||||
Pydantic schemas para autenticación y autorización.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""Schema para solicitud de login."""
|
||||
email: EmailStr
|
||||
password: str
|
||||
tenant_slug: str
|
||||
totp_code: Optional[str] = None
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
"""Schema de respuesta al login exitoso."""
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
user: dict
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
"""Schema para renovar access token usando refresh token."""
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""Schema de respuesta al renovar token."""
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 2FA / TOTP Schemas
|
||||
# ============================================================
|
||||
|
||||
class TwoFactorStatusResponse(BaseModel):
|
||||
"""Estado actual de 2FA del usuario autenticado."""
|
||||
enabled: bool
|
||||
|
||||
|
||||
class TwoFactorSetupResponse(BaseModel):
|
||||
"""QR URI y clave manual devueltos al iniciar el setup de 2FA."""
|
||||
secret: str
|
||||
qr_uri: str
|
||||
|
||||
|
||||
class TwoFactorEnableRequest(BaseModel):
|
||||
"""Código TOTP para confirmar y activar 2FA."""
|
||||
totp_code: str
|
||||
|
||||
|
||||
class TwoFactorEnableResponse(BaseModel):
|
||||
"""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."""
|
||||
totp_code: Optional[str] = None
|
||||
backup_code: Optional[str] = None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Cambio de contraseña
|
||||
# ============================================================
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
"""Schema para cambio de contraseña del usuario autenticado."""
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
model_config = {"json_schema_extra": {"example": {"current_password": "old_pass", "new_password": "new_secure_pass"}}}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Recuperación de contraseña
|
||||
# ============================================================
|
||||
|
||||
class ForgotPasswordRequest(BaseModel):
|
||||
"""Solicitar enlace de reseteo de contraseña por email."""
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
"""Aplicar nueva contraseña usando token de reseteo."""
|
||||
token: str
|
||||
new_password: str
|
||||
48
backend/app/api/schemas/category.py
Normal file
48
backend/app/api/schemas/category.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
Category Schemas - ServiceManagerWeb
|
||||
|
||||
Pydantic schemas para gestión de categorías de tickets.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class CategoryUpdate(BaseModel):
|
||||
"""Schema para actualizar categoría."""
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
sla_response_hours: Optional[int] = None
|
||||
sla_resolution_hours: Optional[int] = None
|
||||
auto_assign_to: Optional[uuid.UUID] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class CategoryResponse(BaseModel):
|
||||
"""Schema de respuesta con todos los campos públicos de la categoría."""
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
sla_response_hours: int
|
||||
sla_resolution_hours: int
|
||||
auto_assign_to: Optional[uuid.UUID] = None
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(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)
|
||||
36
backend/app/api/schemas/system.py
Normal file
36
backend/app/api/schemas/system.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
System Schemas - ServiceManagerWeb
|
||||
|
||||
Pydantic schemas para gestión de sistemas afectados en tickets.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
|
||||
class SystemCreate(BaseModel):
|
||||
"""Schema para crear sistema. No incluye tenant_id (se asigna automáticamente)."""
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class SystemUpdate(BaseModel):
|
||||
"""Schema para actualizar sistema."""
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class SystemResponse(BaseModel):
|
||||
"""Schema de respuesta con todos los campos públicos del sistema."""
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
43
backend/app/api/schemas/tenant.py
Normal file
43
backend/app/api/schemas/tenant.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
Tenant Schemas - ServiceManagerWeb
|
||||
|
||||
Pydantic schemas para gestión de tenants (organizaciones cliente).
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr
|
||||
from typing import Optional
|
||||
import uuid
|
||||
|
||||
from app.models.tenant import TenantStatus
|
||||
|
||||
|
||||
class TenantBase(BaseModel):
|
||||
"""Campos base compartidos entre Create y Response."""
|
||||
name: str
|
||||
slug: str
|
||||
domain: Optional[str] = None
|
||||
contact_email: Optional[EmailStr] = None
|
||||
contact_phone: Optional[str] = None
|
||||
|
||||
|
||||
class TenantCreate(TenantBase):
|
||||
"""Schema para crear un nuevo tenant."""
|
||||
pass
|
||||
|
||||
|
||||
class TenantUpdate(BaseModel):
|
||||
"""Schema para actualizar un tenant existente."""
|
||||
name: Optional[str] = None
|
||||
slug: Optional[str] = None
|
||||
domain: Optional[str] = None
|
||||
contact_email: Optional[EmailStr] = None
|
||||
contact_phone: Optional[str] = None
|
||||
status: Optional[TenantStatus] = None
|
||||
|
||||
|
||||
class TenantResponse(TenantBase):
|
||||
"""Schema de respuesta con todos los campos públicos del tenant."""
|
||||
id: uuid.UUID
|
||||
status: TenantStatus
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
97
backend/app/api/schemas/ticket.py
Normal file
97
backend/app/api/schemas/ticket.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
Ticket Schemas - ServiceManagerWeb
|
||||
|
||||
Pydantic schemas para gestión de tickets y comentarios.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, model_validator
|
||||
from typing import Optional, Literal
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TicketCreate(BaseModel):
|
||||
"""Schema para crear un ticket."""
|
||||
subject: str
|
||||
description: str
|
||||
category_id: Optional[str] = None
|
||||
affected_system_id: Optional[str] = None
|
||||
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):
|
||||
"""Schema para actualizar un ticket."""
|
||||
subject: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
priority: Optional[str] = None
|
||||
assigned_to: Optional[str] = None
|
||||
|
||||
|
||||
class TicketResponse(BaseModel):
|
||||
"""Schema de respuesta con todos los campos públicos del ticket."""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
ticket_number: str
|
||||
subject: str
|
||||
title: str
|
||||
description: str
|
||||
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
|
||||
sla_resolution_due: Optional[datetime] = None
|
||||
first_response_at: Optional[datetime] = None
|
||||
resolved_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class TicketCloseRequest(BaseModel):
|
||||
"""Schema para cerrar un ticket con resolución opcional."""
|
||||
resolution: Optional[str] = None
|
||||
|
||||
|
||||
class CommentCreate(BaseModel):
|
||||
"""Schema para crear un comentario en un ticket."""
|
||||
content: str
|
||||
is_internal: bool = False
|
||||
|
||||
|
||||
class CommentResponse(BaseModel):
|
||||
"""Schema de respuesta de comentario."""
|
||||
id: str
|
||||
ticket_id: str
|
||||
author_id: str
|
||||
author_name: str
|
||||
content: str
|
||||
is_internal: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
59
backend/app/api/schemas/user.py
Normal file
59
backend/app/api/schemas/user.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
User Schemas - ServiceManagerWeb
|
||||
|
||||
Pydantic schemas para gestión de usuarios.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from app.models.user import UserRole
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
"""Schema para crear usuario. No incluye tenant_id (se asigna automáticamente)."""
|
||||
email: EmailStr
|
||||
first_name: str
|
||||
last_name: str
|
||||
role: UserRole
|
||||
password: str
|
||||
language: str = "es"
|
||||
timezone: str = "UTC"
|
||||
notifications_email: bool = True
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
"""Schema para actualizar usuario."""
|
||||
email: Optional[EmailStr] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
role: Optional[UserRole] = None
|
||||
is_active: Optional[bool] = None
|
||||
password: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
timezone: Optional[str] = None
|
||||
notifications_email: Optional[bool] = None
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""Schema de respuesta con todos los campos públicos del usuario."""
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
email: EmailStr
|
||||
first_name: str
|
||||
last_name: str
|
||||
avatar_url: Optional[str] = None
|
||||
role: UserRole
|
||||
is_active: bool
|
||||
email_verified: bool
|
||||
last_login: Optional[datetime] = None
|
||||
language: str
|
||||
timezone: str
|
||||
notifications_email: bool
|
||||
totp_enabled: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
517
backend/app/api/v1/audit_helpers.py
Normal file
517
backend/app/api/v1/audit_helpers.py
Normal file
@@ -0,0 +1,517 @@
|
||||
"""
|
||||
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, timezone
|
||||
import uuid
|
||||
|
||||
from app.models.audit import AuditLog
|
||||
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 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,
|
||||
"user_id": log.user_id,
|
||||
"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_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
|
||||
# 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
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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:
|
||||
# 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)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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:
|
||||
# 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
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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:
|
||||
# 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 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 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
|
||||
}
|
||||
|
||||
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:
|
||||
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 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'] += 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:
|
||||
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 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)
|
||||
|
||||
# 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
|
||||
File diff suppressed because it is too large
Load Diff
1033
backend/app/api/v1/endpoints/audit_backup.py
Normal file
1033
backend/app/api/v1/endpoints/audit_backup.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,12 +4,11 @@ Authentication Endpoints - ServiceManagerWeb
|
||||
Endpoints para autenticación y autorización
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status, Depends
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from fastapi import APIRouter, HTTPException, status, Depends, Request
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional
|
||||
import structlog
|
||||
|
||||
@@ -19,47 +18,20 @@ 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.api.schemas.auth import (
|
||||
LoginRequest, LoginResponse, RefreshTokenRequest, TokenResponse,
|
||||
TwoFactorStatusResponse, TwoFactorSetupResponse,
|
||||
TwoFactorEnableRequest, TwoFactorEnableResponse, TwoFactorDisableRequest,
|
||||
ChangePasswordRequest, ForgotPasswordRequest, ResetPasswordRequest,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
logger = structlog.get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
# OAuth2 scheme
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/{settings.API_VERSION}/auth/login")
|
||||
|
||||
|
||||
# ===================================
|
||||
# PYDANTIC SCHEMAS
|
||||
# ===================================
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""Schema for login request."""
|
||||
email: EmailStr
|
||||
password: str
|
||||
tenant_slug: str
|
||||
totp_code: Optional[str] = None
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
"""Schema for login response."""
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
user: dict
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
"""Schema for refresh token request."""
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""Schema for token response."""
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
|
||||
|
||||
# ===================================
|
||||
# ENDPOINTS
|
||||
@@ -68,6 +40,7 @@ class TokenResponse(BaseModel):
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(
|
||||
login_data: LoginRequest,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
@@ -88,13 +61,85 @@ 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
|
||||
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:
|
||||
logger.warning(
|
||||
"Login failed - tenant not found",
|
||||
email=login_data.email,
|
||||
tenant_slug=login_data.tenant_slug,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tenant not found",
|
||||
)
|
||||
|
||||
# Rate limiting (best-effort): by (tenant,email) to slow brute force.
|
||||
ident_key = None
|
||||
if settings.RATE_LIMIT_ENABLED and not settings.TESTING:
|
||||
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 en base de datos (aislado por tenant)
|
||||
query = select(User).where(
|
||||
User.email == login_data.email,
|
||||
User.tenant_id == tenant.id,
|
||||
)
|
||||
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",
|
||||
@@ -119,20 +164,35 @@ 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",
|
||||
)
|
||||
|
||||
|
||||
# 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
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
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"
|
||||
)
|
||||
|
||||
# Create tokens
|
||||
token_data = {
|
||||
"sub": str(user.id),
|
||||
@@ -143,6 +203,24 @@ 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
|
||||
try:
|
||||
@@ -165,6 +243,10 @@ 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)
|
||||
|
||||
return LoginResponse(
|
||||
access_token=access_token,
|
||||
@@ -213,7 +295,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 = {
|
||||
@@ -258,7 +353,19 @@ 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
|
||||
try:
|
||||
@@ -355,4 +462,348 @@ async def get_current_user(
|
||||
# DEPENDENCIES
|
||||
# ===================================
|
||||
# Dependencies are imported from app.api.deps to avoid duplication
|
||||
# Use get_current_user and get_current_active_superuser from deps.py
|
||||
# Use get_current_user and get_current_active_superuser from deps.py
|
||||
|
||||
|
||||
# ===================================
|
||||
# 2FA / TOTP ENDPOINTS
|
||||
# ===================================
|
||||
|
||||
@router.get("/2fa/status", response_model=TwoFactorStatusResponse)
|
||||
async def get_2fa_status(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Consultar si el 2FA está habilitado para el usuario actual.
|
||||
|
||||
Returns:
|
||||
Estado de 2FA del usuario autenticado.
|
||||
"""
|
||||
return TwoFactorStatusResponse(enabled=bool(current_user.totp_enabled))
|
||||
|
||||
|
||||
@router.post("/2fa/setup", response_model=TwoFactorSetupResponse)
|
||||
async def setup_2fa(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
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.
|
||||
|
||||
Returns:
|
||||
Secret y QR URI para escanear con la app autenticadora.
|
||||
"""
|
||||
new_secret = security.generate_totp_secret()
|
||||
qr_uri = security.generate_totp_uri(new_secret, current_user.email)
|
||||
|
||||
# Guardar el secret (sin habilitar aún)
|
||||
current_user.totp_secret = new_secret
|
||||
await db.commit()
|
||||
|
||||
logger.info("2FA setup initiated", user_id=str(current_user.id))
|
||||
|
||||
return TwoFactorSetupResponse(secret=new_secret, qr_uri=qr_uri)
|
||||
|
||||
|
||||
@router.post("/2fa/enable", response_model=TwoFactorEnableResponse)
|
||||
async def enable_2fa(
|
||||
data: TwoFactorEnableRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
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.
|
||||
|
||||
Returns:
|
||||
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"
|
||||
)
|
||||
|
||||
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."
|
||||
)
|
||||
|
||||
# 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
|
||||
await db.commit()
|
||||
|
||||
await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=current_user.tenant_id,
|
||||
user_id=current_user.id,
|
||||
action="user.2fa_enabled",
|
||||
resource_type="user",
|
||||
resource_id=current_user.id,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info("2FA enabled", user_id=str(current_user.id))
|
||||
|
||||
return TwoFactorEnableResponse(enabled=True, backup_codes=backup_codes)
|
||||
|
||||
|
||||
@router.post("/2fa/disable")
|
||||
async def disable_2fa(
|
||||
data: TwoFactorDisableRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
if not current_user.totp_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="El 2FA no está habilitado en esta cuenta"
|
||||
)
|
||||
|
||||
# Verificar con TOTP o código de respaldo
|
||||
verified = False
|
||||
|
||||
if data.totp_code:
|
||||
verified = security.verify_totp(current_user.totp_secret, data.totp_code)
|
||||
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
|
||||
current_user.backup_codes = [
|
||||
c for c in current_user.backup_codes if c != data.backup_code
|
||||
]
|
||||
|
||||
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."
|
||||
)
|
||||
|
||||
# Deshabilitar 2FA
|
||||
current_user.totp_enabled = False
|
||||
current_user.totp_secret = None
|
||||
current_user.backup_codes = None
|
||||
await db.commit()
|
||||
|
||||
await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=current_user.tenant_id,
|
||||
user_id=current_user.id,
|
||||
action="user.2fa_disabled",
|
||||
resource_type="user",
|
||||
resource_id=current_user.id,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info("2FA disabled", user_id=str(current_user.id))
|
||||
|
||||
return {"message": "Autenticación de dos factores deshabilitada correctamente"}
|
||||
|
||||
|
||||
@router.post("/change-password", status_code=status.HTTP_200_OK)
|
||||
async def change_password(
|
||||
data: ChangePasswordRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Cambiar la contraseña del usuario autenticado.
|
||||
|
||||
Verifica la contraseña actual antes de actualizar.
|
||||
Requiere autenticación activa.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
# 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"
|
||||
)
|
||||
|
||||
# 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"
|
||||
)
|
||||
|
||||
# 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"
|
||||
)
|
||||
|
||||
current_user.password_hash = security.hash_password(data.new_password)
|
||||
current_user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=current_user.tenant_id,
|
||||
user_id=current_user.id,
|
||||
action="user.password_changed",
|
||||
resource_type="user",
|
||||
resource_id=current_user.id,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info("Password changed", user_id=str(current_user.id))
|
||||
return {"message": "Contraseña actualizada correctamente"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Recuperación de contraseña (forgot / reset)
|
||||
# ============================================================
|
||||
|
||||
_RESET_TOKEN_TTL = 1800 # 30 minutos en segundos
|
||||
_RESET_KEY_PREFIX = "pwd_reset:"
|
||||
|
||||
|
||||
@router.post("/forgot-password", status_code=status.HTTP_200_OK)
|
||||
async def forgot_password(
|
||||
data: ForgotPasswordRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
import secrets
|
||||
from redis.asyncio import from_url as redis_from_url
|
||||
from app.core.email import send_email, build_password_reset_email
|
||||
|
||||
# Buscar usuario activo con ese email
|
||||
result = await db.execute(
|
||||
select(User).where(
|
||||
User.email == data.email,
|
||||
User.is_active == True, # noqa: E712
|
||||
).limit(1)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
# 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."}
|
||||
|
||||
# Generar token seguro
|
||||
token = secrets.token_urlsafe(32)
|
||||
redis_key = f"{_RESET_KEY_PREFIX}{token}"
|
||||
|
||||
# Guardar en Redis con TTL de 30 min
|
||||
redis = redis_from_url(settings.REDIS_URL, decode_responses=True)
|
||||
try:
|
||||
await redis.setex(redis_key, _RESET_TOKEN_TTL, str(user.id))
|
||||
finally:
|
||||
await redis.aclose()
|
||||
|
||||
# Construir URL y enviar email
|
||||
reset_url = f"{settings.CLIENT_FRONTEND_URL}/reset-password?token={token}"
|
||||
user_name = f"{user.first_name} {user.last_name}".strip() or user.email
|
||||
html, text = build_password_reset_email(reset_url, user_name)
|
||||
|
||||
await send_email(
|
||||
to_email=user.email,
|
||||
subject="Restablece tu contraseña — ServiceManager",
|
||||
html_content=html,
|
||||
text_content=text,
|
||||
)
|
||||
|
||||
await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=user.tenant_id,
|
||||
user_id=user.id,
|
||||
action="user.password_reset_requested",
|
||||
resource_type="user",
|
||||
resource_id=user.id,
|
||||
new_values={"email": user.email},
|
||||
)
|
||||
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."}
|
||||
|
||||
|
||||
@router.post("/reset-password", status_code=status.HTTP_200_OK)
|
||||
async def reset_password(
|
||||
data: ResetPasswordRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Aplicar nueva contraseña usando el token recibido por email.
|
||||
|
||||
El token es de un solo uso: se elimina de Redis al usarse.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from redis.asyncio import from_url as redis_from_url
|
||||
import uuid
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
redis_key = f"{_RESET_KEY_PREFIX}{data.token}"
|
||||
redis = redis_from_url(settings.REDIS_URL, decode_responses=True)
|
||||
|
||||
try:
|
||||
user_id_str = await redis.get(redis_key)
|
||||
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."
|
||||
)
|
||||
|
||||
# Eliminar token inmediatamente (un solo uso)
|
||||
await redis.delete(redis_key)
|
||||
finally:
|
||||
await redis.aclose()
|
||||
|
||||
# Buscar y actualizar usuario
|
||||
user = await db.get(User, uuid.UUID(user_id_str))
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Usuario no encontrado o inactivo"
|
||||
)
|
||||
|
||||
user.password_hash = security.hash_password(data.new_password)
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=user.tenant_id,
|
||||
user_id=user.id,
|
||||
action="user.password_reset_completed",
|
||||
resource_type="user",
|
||||
resource_id=user.id,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info("Password reset completed", user_id=str(user.id))
|
||||
return {"message": "Contraseña actualizada correctamente. Ya puedes iniciar sesión."}
|
||||
@@ -1,58 +1,20 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.cache import cache, cache_key
|
||||
from app.models.category import Category
|
||||
from app.models.user import User
|
||||
from app.api import deps
|
||||
from app.services.audit_service import AuditService
|
||||
from app.services.audit_service import AuditService
|
||||
from app.api.schemas.category import CategoryCreate, CategoryUpdate, CategoryResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ===================================
|
||||
# PYDANTIC SCHEMAS
|
||||
# ===================================
|
||||
|
||||
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
|
||||
|
||||
class CategoryUpdate(BaseModel):
|
||||
"""Schema para actualizar categoría"""
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
sla_response_hours: Optional[int] = None
|
||||
sla_resolution_hours: Optional[int] = None
|
||||
auto_assign_to: Optional[uuid.UUID] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
class CategoryResponse(BaseModel):
|
||||
"""Schema de respuesta - incluye todos los campos"""
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID # ✅ AÑADIDO
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
sla_response_hours: int
|
||||
sla_resolution_hours: int
|
||||
auto_assign_to: Optional[uuid.UUID] = None
|
||||
is_active: bool
|
||||
created_at: datetime # ✅ AÑADIDO
|
||||
updated_at: datetime # ✅ AÑADIDO
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ===================================
|
||||
# ENDPOINTS
|
||||
@@ -69,14 +31,41 @@ async def read_categories(
|
||||
Listar categorías del tenant del usuario actual.
|
||||
|
||||
✅ Implementa multi-tenancy: solo muestra categorías del tenant del usuario.
|
||||
✅ Optimizado con caché Redis (TTL: 10 minutos)
|
||||
"""
|
||||
# ✅ CORREGIDO: Filtrar por tenant_id
|
||||
# Intentar obtener del caché
|
||||
cache_key_str = cache_key("categories", "tenant", str(current_user.tenant_id), f"skip-{skip}", f"limit-{limit}")
|
||||
cached_categories = await cache.get(cache_key_str)
|
||||
|
||||
if cached_categories is not None:
|
||||
return [CategoryResponse(**cat) for cat in cached_categories]
|
||||
|
||||
# Si no está en caché, consultar BD
|
||||
query = select(Category).where(
|
||||
Category.tenant_id == current_user.tenant_id
|
||||
).offset(skip).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
categories = result.scalars().all()
|
||||
|
||||
# Guardar en caché (10 minutos)
|
||||
categories_dict = [
|
||||
{
|
||||
"id": str(cat.id),
|
||||
"name": cat.name,
|
||||
"description": cat.description,
|
||||
"sla_response_hours": cat.sla_response_hours,
|
||||
"sla_resolution_hours": cat.sla_resolution_hours,
|
||||
"is_active": cat.is_active,
|
||||
"tenant_id": str(cat.tenant_id),
|
||||
"created_at": cat.created_at.isoformat(),
|
||||
"updated_at": cat.updated_at.isoformat()
|
||||
}
|
||||
for cat in categories
|
||||
]
|
||||
await cache.set(cache_key_str, categories_dict, ttl=600)
|
||||
|
||||
return categories
|
||||
|
||||
|
||||
@router.post("/", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED)
|
||||
@@ -100,6 +89,9 @@ async def create_category(
|
||||
await db.commit()
|
||||
await db.refresh(db_category)
|
||||
|
||||
# Invalidar caché de categorías para este tenant
|
||||
await cache.delete_pattern(f"categories:tenant:{current_user.tenant_id}:*")
|
||||
|
||||
# Registrar creación en auditoría
|
||||
try:
|
||||
await AuditService.log(
|
||||
@@ -191,6 +183,9 @@ async def update_category(
|
||||
await db.commit()
|
||||
await db.refresh(db_category)
|
||||
|
||||
# Invalidar caché de categorías para este tenant
|
||||
await cache.delete_pattern(f"categories:tenant:{current_user.tenant_id}:*")
|
||||
|
||||
# Registrar actualización en auditoría
|
||||
try:
|
||||
new_values = {
|
||||
@@ -250,6 +245,9 @@ async def delete_category(
|
||||
db_category.is_active = False
|
||||
await db.commit()
|
||||
|
||||
# Invalidar caché de categorías para este tenant
|
||||
await cache.delete_pattern(f"categories:tenant:{current_user.tenant_id}:*")
|
||||
|
||||
# Registrar eliminación en auditoría
|
||||
try:
|
||||
await AuditService.log(
|
||||
|
||||
761
backend/app/api/v1/endpoints/reports.py
Normal file
761
backend/app/api/v1/endpoints/reports.py
Normal file
@@ -0,0 +1,761 @@
|
||||
"""
|
||||
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
|
||||
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
|
||||
created_rows = (await db.execute(
|
||||
select(
|
||||
func.date_trunc("day", 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", Ticket.created_at))
|
||||
.order_by(func.date_trunc("day", Ticket.created_at))
|
||||
)).all()
|
||||
|
||||
# Tickets resueltos por día (según resolved_at)
|
||||
resolved_rows = (await db.execute(
|
||||
select(
|
||||
func.date_trunc("day", 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", Ticket.resolved_at))
|
||||
.order_by(func.date_trunc("day", 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,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
@@ -9,37 +8,11 @@ import uuid
|
||||
from app.core.database import get_db
|
||||
from app.models.system import System
|
||||
from app.models.user import User
|
||||
from app.api import deps
|
||||
from app.api import deps
|
||||
from app.api.schemas.system import SystemCreate, SystemUpdate, SystemResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ===================================
|
||||
# PYDANTIC SCHEMAS
|
||||
# ===================================
|
||||
|
||||
class SystemCreate(BaseModel):
|
||||
"""Schema para crear sistema - NO incluye tenant_id (se asigna automáticamente)"""
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
|
||||
class SystemUpdate(BaseModel):
|
||||
"""Schema para actualizar sistema"""
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
class SystemResponse(BaseModel):
|
||||
"""Schema de respuesta - incluye todos los campos"""
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID # ✅ AÑADIDO
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
is_active: bool
|
||||
created_at: datetime # ✅ AÑADIDO
|
||||
updated_at: datetime # ✅ AÑADIDO
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ===================================
|
||||
# ENDPOINTS
|
||||
|
||||
@@ -1,40 +1,16 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.tenant import Tenant, TenantStatus
|
||||
from app.api import deps
|
||||
from app.api import deps
|
||||
from app.api.schemas.tenant import TenantBase, TenantCreate, TenantUpdate, TenantResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class TenantBase(BaseModel):
|
||||
name: str
|
||||
slug: str
|
||||
domain: Optional[str] = None
|
||||
contact_email: Optional[EmailStr] = None
|
||||
contact_phone: Optional[str] = None
|
||||
|
||||
class TenantCreate(TenantBase):
|
||||
pass
|
||||
|
||||
class TenantUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
slug: Optional[str] = None
|
||||
domain: Optional[str] = None
|
||||
contact_email: Optional[EmailStr] = None
|
||||
contact_phone: Optional[str] = None
|
||||
status: Optional[TenantStatus] = None
|
||||
|
||||
class TenantResponse(TenantBase):
|
||||
id: uuid.UUID
|
||||
status: TenantStatus
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@router.get("/", response_model=List[TenantResponse])
|
||||
async def read_tenants(
|
||||
skip: int = 0,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1072
backend/app/api/v1/endpoints/tickets_backup.py
Normal file
1072
backend/app/api/v1/endpoints/tickets_backup.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
@@ -10,63 +9,24 @@ from app.core.database import get_db
|
||||
from app.core.security import security
|
||||
from app.models.user import User, UserRole
|
||||
from app.services.audit_service import AuditService
|
||||
from app.api import deps
|
||||
from app.api import deps
|
||||
from app.api.schemas.user import UserCreate, UserUpdate, UserResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ===================================
|
||||
# PYDANTIC SCHEMAS
|
||||
# ===================================
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
"""Schema para crear usuario - NO incluye tenant_id (se asigna automáticamente)"""
|
||||
email: EmailStr
|
||||
first_name: str
|
||||
last_name: str
|
||||
role: UserRole
|
||||
password: str
|
||||
language: str = "es"
|
||||
timezone: str = "UTC"
|
||||
notifications_email: bool = True
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
"""Schema para actualizar usuario"""
|
||||
email: Optional[EmailStr] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
role: Optional[UserRole] = None
|
||||
is_active: Optional[bool] = None
|
||||
password: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
timezone: Optional[str] = None
|
||||
notifications_email: Optional[bool] = None
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""Schema de respuesta - incluye todos los campos públicos"""
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
email: EmailStr
|
||||
first_name: str
|
||||
last_name: str
|
||||
avatar_url: Optional[str] = None
|
||||
role: UserRole
|
||||
is_active: bool
|
||||
email_verified: bool
|
||||
last_login: Optional[datetime] = None
|
||||
language: str
|
||||
timezone: str
|
||||
notifications_email: bool
|
||||
totp_enabled: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ===================================
|
||||
# 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,
|
||||
|
||||
117
backend/app/api/v1/helpers.py
Normal file
117
backend/app/api/v1/helpers.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
Helper functions for API endpoints
|
||||
"""
|
||||
import uuid
|
||||
from typing import Any, Type
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Query
|
||||
from datetime import datetime, timedelta
|
||||
from app.models.user import User
|
||||
from app.models.ticket import Ticket
|
||||
from app.models.category import Category
|
||||
from app.services.audit_service import AuditService
|
||||
|
||||
|
||||
def validate_uuid_param(value: str, param_name: str = "ID") -> uuid.UUID:
|
||||
"""Valida y convierte string a UUID"""
|
||||
try:
|
||||
return uuid.UUID(value)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid {param_name} format"
|
||||
)
|
||||
|
||||
|
||||
def apply_client_permissions(query: Query, model: Type, current_user: User) -> Query:
|
||||
"""Aplica filtros de tenant y permisos de cliente"""
|
||||
query = query.where(model.tenant_id == current_user.tenant_id)
|
||||
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
|
||||
query = query.where(model.created_by == current_user.id)
|
||||
return query
|
||||
|
||||
|
||||
def apply_enum_filter(query: Query, model_field: Any, filter_value: str,
|
||||
enum_class: Type, filter_name: str) -> Query:
|
||||
"""Aplica filtro de enum genérico"""
|
||||
if filter_value:
|
||||
try:
|
||||
enum_val = enum_class[filter_value.upper()]
|
||||
return query.where(model_field == enum_val)
|
||||
except KeyError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid {filter_name}: {filter_value}"
|
||||
)
|
||||
return query
|
||||
|
||||
|
||||
async def safe_audit_log(db: AsyncSession, **kwargs):
|
||||
"""Registra en auditoría sin fallar la operación principal"""
|
||||
try:
|
||||
await AuditService.log(db=db, **kwargs)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
pass # Silent fail para audit logs
|
||||
|
||||
|
||||
async def generate_next_ticket_number(db: AsyncSession, tenant_id: uuid.UUID) -> str:
|
||||
"""Genera el siguiente número de ticket único para el tenant"""
|
||||
result = await db.execute(
|
||||
select(Ticket.ticket_number)
|
||||
.where(Ticket.tenant_id == tenant_id)
|
||||
.order_by(Ticket.ticket_number.desc())
|
||||
.limit(1)
|
||||
)
|
||||
last_ticket_number = result.scalar_one_or_none()
|
||||
|
||||
if last_ticket_number:
|
||||
last_number = int(last_ticket_number.split('-')[1])
|
||||
next_number = last_number + 1
|
||||
else:
|
||||
next_number = 1
|
||||
|
||||
return f"TK-{next_number:06d}"
|
||||
|
||||
|
||||
def calculate_sla_deadlines(category: Category = None) -> tuple[datetime, datetime]:
|
||||
"""Calcula SLA response y resolution deadlines"""
|
||||
if not category:
|
||||
return None, None
|
||||
|
||||
now = datetime.utcnow()
|
||||
sla_response_due = now + timedelta(hours=category.sla_response_hours)
|
||||
sla_resolution_due = now + timedelta(hours=category.sla_resolution_hours)
|
||||
return sla_response_due, sla_resolution_due
|
||||
|
||||
|
||||
def ticket_to_dict(ticket: Ticket) -> dict:
|
||||
"""Convierte un modelo Ticket a diccionario de respuesta"""
|
||||
return {
|
||||
"id": str(ticket.id),
|
||||
"ticket_number": ticket.ticket_number,
|
||||
"subject": ticket.subject,
|
||||
"title": ticket.subject,
|
||||
"description": ticket.description,
|
||||
"status": ticket.status.value,
|
||||
"priority": ticket.priority.value,
|
||||
"category_id": str(ticket.category_id) if ticket.category_id else None,
|
||||
"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,
|
||||
"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,
|
||||
"tenant_id": str(ticket.tenant_id)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -73,4 +73,11 @@ api_router.include_router(
|
||||
sla.router,
|
||||
prefix="/sla",
|
||||
tags=["sla"]
|
||||
)
|
||||
|
||||
# Reports routes
|
||||
api_router.include_router(
|
||||
reports.router,
|
||||
prefix="/reports",
|
||||
tags=["reports"]
|
||||
)
|
||||
308
backend/app/core/cache.py
Normal file
308
backend/app/core/cache.py
Normal file
@@ -0,0 +1,308 @@
|
||||
"""
|
||||
Redis Caching Service - ServiceManagerWeb
|
||||
|
||||
Servicio centralizado para manejo de caché con Redis.
|
||||
"""
|
||||
|
||||
from redis import asyncio as aioredis
|
||||
from typing import Optional, Any, Union
|
||||
import json
|
||||
import structlog
|
||||
from functools import wraps
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class CacheService:
|
||||
"""
|
||||
Servicio de caché usando Redis.
|
||||
|
||||
Proporciona métodos para get/set/delete de datos con serialización JSON.
|
||||
Usa un singleton pattern para compartir la conexión Redis.
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_redis = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
async def connect(self):
|
||||
"""Conectar a Redis si aún no está conectado."""
|
||||
if self._redis is None:
|
||||
try:
|
||||
self._redis = await aioredis.from_url(
|
||||
settings.REDIS_URL,
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=5,
|
||||
socket_timeout=5
|
||||
)
|
||||
logger.info("Redis cache connected", url=settings.REDIS_URL)
|
||||
except Exception as e:
|
||||
logger.error("Failed to connect to Redis", error=str(e))
|
||||
self._redis = None
|
||||
|
||||
async def disconnect(self):
|
||||
"""Cerrar conexión Redis."""
|
||||
if self._redis:
|
||||
await self._redis.close()
|
||||
self._redis = None
|
||||
logger.info("Redis cache disconnected")
|
||||
|
||||
async def get(self, key: str) -> Optional[Any]:
|
||||
"""
|
||||
Obtener valor del cache.
|
||||
|
||||
Args:
|
||||
key: Clave del cache
|
||||
|
||||
Returns:
|
||||
Valor deserializado o None si no existe
|
||||
"""
|
||||
if self._redis is None:
|
||||
await self.connect()
|
||||
|
||||
if self._redis is None:
|
||||
logger.warning("Redis not available, skipping cache get", key=key)
|
||||
return None
|
||||
|
||||
try:
|
||||
value = await self._redis.get(key)
|
||||
if value:
|
||||
logger.debug("Cache hit", key=key)
|
||||
return json.loads(value)
|
||||
logger.debug("Cache miss", key=key)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error("Cache get error", key=key, error=str(e))
|
||||
return None
|
||||
|
||||
async def set(
|
||||
self,
|
||||
key: str,
|
||||
value: Any,
|
||||
ttl: int = 300
|
||||
) -> bool:
|
||||
"""
|
||||
Guardar valor en cache.
|
||||
|
||||
Args:
|
||||
key: Clave del cache
|
||||
value: Valor a guardar (será serializado a JSON)
|
||||
ttl: Tiempo de vida en segundos (default: 5 minutos)
|
||||
|
||||
Returns:
|
||||
True si se guardó exitosamente
|
||||
"""
|
||||
if self._redis is None:
|
||||
await self.connect()
|
||||
|
||||
if self._redis is None:
|
||||
logger.warning("Redis not available, skipping cache set", key=key)
|
||||
return False
|
||||
|
||||
try:
|
||||
serialized = json.dumps(value, default=str)
|
||||
await self._redis.setex(key, ttl, serialized)
|
||||
logger.debug("Cache set", key=key, ttl=ttl)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Cache set error", key=key, error=str(e))
|
||||
return False
|
||||
|
||||
async def delete(self, key: str) -> bool:
|
||||
"""
|
||||
Eliminar clave del cache.
|
||||
|
||||
Args:
|
||||
key: Clave a eliminar
|
||||
|
||||
Returns:
|
||||
True si se eliminó exitosamente
|
||||
"""
|
||||
if self._redis is None:
|
||||
await self.connect()
|
||||
|
||||
if self._redis is None:
|
||||
logger.warning("Redis not available, skipping cache delete", key=key)
|
||||
return False
|
||||
|
||||
try:
|
||||
await self._redis.delete(key)
|
||||
logger.debug("Cache delete", key=key)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Cache delete error", key=key, error=str(e))
|
||||
return False
|
||||
|
||||
async def delete_pattern(self, pattern: str) -> int:
|
||||
"""
|
||||
Eliminar todas las claves que coincidan con el patrón.
|
||||
|
||||
Args:
|
||||
pattern: Patrón de búsqueda (ej: "tickets:tenant:*")
|
||||
|
||||
Returns:
|
||||
Número de claves eliminadas
|
||||
"""
|
||||
if self._redis is None:
|
||||
await self.connect()
|
||||
|
||||
if self._redis is None:
|
||||
logger.warning("Redis not available, skipping pattern delete", pattern=pattern)
|
||||
return 0
|
||||
|
||||
try:
|
||||
keys = []
|
||||
async for key in self._redis.scan_iter(pattern):
|
||||
keys.append(key)
|
||||
|
||||
if keys:
|
||||
deleted = await self._redis.delete(*keys)
|
||||
logger.info("Cache pattern delete", pattern=pattern, deleted=deleted)
|
||||
return deleted
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.error("Cache pattern delete error", pattern=pattern, error=str(e))
|
||||
return 0
|
||||
|
||||
async def exists(self, key: str) -> bool:
|
||||
"""
|
||||
Verificar si una clave existe en cache.
|
||||
|
||||
Args:
|
||||
key: Clave a verificar
|
||||
|
||||
Returns:
|
||||
True si existe
|
||||
"""
|
||||
if self._redis is None:
|
||||
await self.connect()
|
||||
|
||||
if self._redis is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
return await self._redis.exists(key) > 0
|
||||
except Exception as e:
|
||||
logger.error("Cache exists error", key=key, error=str(e))
|
||||
return False
|
||||
|
||||
async def incr(self, key: str, amount: int = 1) -> Optional[int]:
|
||||
"""
|
||||
Incrementar un contador en cache.
|
||||
|
||||
Args:
|
||||
key: Clave del contador
|
||||
amount: Cantidad a incrementar
|
||||
|
||||
Returns:
|
||||
Nuevo valor del contador
|
||||
"""
|
||||
if self._redis is None:
|
||||
await self.connect()
|
||||
|
||||
if self._redis is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return await self._redis.incrby(key, amount)
|
||||
except Exception as e:
|
||||
logger.error("Cache incr error", key=key, error=str(e))
|
||||
return None
|
||||
|
||||
async def expire(self, key: str, ttl: int) -> bool:
|
||||
"""
|
||||
Establecer tiempo de expiración a una clave existente.
|
||||
|
||||
Args:
|
||||
key: Clave a expirar
|
||||
ttl: Tiempo de vida en segundos
|
||||
|
||||
Returns:
|
||||
True si se estableció exitosamente
|
||||
"""
|
||||
if self._redis is None:
|
||||
await self.connect()
|
||||
|
||||
if self._redis is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
return await self._redis.expire(key, ttl)
|
||||
except Exception as e:
|
||||
logger.error("Cache expire error", key=key, error=str(e))
|
||||
return False
|
||||
|
||||
|
||||
# Singleton instance
|
||||
cache = CacheService()
|
||||
|
||||
|
||||
def cache_key(*parts: str) -> str:
|
||||
"""
|
||||
Helper para construir claves de cache consistentes.
|
||||
|
||||
Args:
|
||||
*parts: Partes de la clave a unir
|
||||
|
||||
Returns:
|
||||
Clave formateada
|
||||
|
||||
Example:
|
||||
cache_key("tickets", "tenant", tenant_id) -> "tickets:tenant:123"
|
||||
"""
|
||||
return ":".join(str(part) for part in parts)
|
||||
|
||||
|
||||
def cached(
|
||||
key_prefix: str,
|
||||
ttl: int = 300,
|
||||
key_builder: Optional[callable] = None
|
||||
):
|
||||
"""
|
||||
Decorator para cachear resultados de funciones async.
|
||||
|
||||
Args:
|
||||
key_prefix: Prefijo para la clave de cache
|
||||
ttl: Tiempo de vida en segundos
|
||||
key_builder: Función opcional para construir la clave
|
||||
|
||||
Example:
|
||||
@cached("categories", ttl=600)
|
||||
async def get_categories(tenant_id: str):
|
||||
return await db.query(Category).all()
|
||||
"""
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
# Construir clave de cache
|
||||
if key_builder:
|
||||
key = key_builder(*args, **kwargs)
|
||||
else:
|
||||
# Default: usar nombre de función y args
|
||||
key_parts = [key_prefix, func.__name__]
|
||||
key_parts.extend(str(arg) for arg in args)
|
||||
key_parts.extend(f"{k}={v}" for k, v in sorted(kwargs.items()))
|
||||
key = cache_key(*key_parts)
|
||||
|
||||
# Intentar obtener del cache
|
||||
cached_value = await cache.get(key)
|
||||
if cached_value is not None:
|
||||
return cached_value
|
||||
|
||||
# Si no está en cache, ejecutar función
|
||||
result = await func(*args, **kwargs)
|
||||
|
||||
# Guardar en cache
|
||||
await cache.set(key, result, ttl=ttl)
|
||||
|
||||
return result
|
||||
return wrapper
|
||||
return decorator
|
||||
@@ -24,10 +24,11 @@ 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")
|
||||
APP_VERSION: str = Field(default="1.6.0")
|
||||
APP_VERSION: str = Field(default="1.9.0")
|
||||
|
||||
# ===================================
|
||||
# DATABASE
|
||||
@@ -86,6 +87,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
|
||||
|
||||
@@ -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
|
||||
@@ -19,10 +21,11 @@ settings = get_settings()
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DEBUG,
|
||||
pool_size=5,
|
||||
max_overflow=10,
|
||||
pool_size=20, # Increased for better concurrency
|
||||
max_overflow=30, # Increased for peak loads
|
||||
pool_pre_ping=True, # Verify connections before use
|
||||
pool_recycle=3600, # Recycle connections after 1 hour
|
||||
pool_timeout=30, # Wait up to 30s for connection from pool
|
||||
)
|
||||
|
||||
# Create session factory
|
||||
@@ -33,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(),
|
||||
)
|
||||
|
||||
|
||||
@@ -88,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
|
||||
179
backend/app/core/email.py
Normal file
179
backend/app/core/email.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
Email Utility - ServiceManagerWeb
|
||||
|
||||
Envío directo de emails desde el backend para flujos críticos
|
||||
(reseteo de contraseña, verificación) sin depender de Celery.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import smtplib
|
||||
import ssl
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from typing import Optional
|
||||
import structlog
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def _send_smtp_sync(
|
||||
to_email: str,
|
||||
subject: str,
|
||||
html_content: str,
|
||||
text_content: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Enviar email de forma síncrona vía SMTP.
|
||||
Llamar desde asyncio.to_thread para no bloquear el event loop.
|
||||
"""
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = f"{settings.DEFAULT_FROM_NAME} <{settings.DEFAULT_FROM_EMAIL}>"
|
||||
msg["To"] = to_email
|
||||
|
||||
if text_content:
|
||||
msg.attach(MIMEText(text_content, "plain", "utf-8"))
|
||||
msg.attach(MIMEText(html_content, "html", "utf-8"))
|
||||
|
||||
if settings.SMTP_USE_SSL:
|
||||
context = ssl.create_default_context()
|
||||
with smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT, context=context) as server:
|
||||
if settings.SMTP_USER and settings.SMTP_PASSWORD:
|
||||
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
server.sendmail(settings.DEFAULT_FROM_EMAIL, to_email, msg.as_string())
|
||||
else:
|
||||
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as server:
|
||||
if settings.SMTP_USE_TLS:
|
||||
server.starttls()
|
||||
if settings.SMTP_USER and settings.SMTP_PASSWORD:
|
||||
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
server.sendmail(settings.DEFAULT_FROM_EMAIL, to_email, msg.as_string())
|
||||
|
||||
|
||||
async def send_email(
|
||||
to_email: str,
|
||||
subject: str,
|
||||
html_content: str,
|
||||
text_content: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Enviar email de forma asíncrona.
|
||||
|
||||
Retorna True si el envío fue exitoso, False con log de error si falló.
|
||||
Se diseña para no propagar excepciones (fail-silent) en flujos de UI.
|
||||
"""
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
_send_smtp_sync,
|
||||
to_email,
|
||||
subject,
|
||||
html_content,
|
||||
text_content,
|
||||
)
|
||||
logger.info("Email sent", to=to_email, subject=subject)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error("Email send failed", to=to_email, subject=subject, error=str(exc))
|
||||
return False
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Plantillas HTML inline
|
||||
# ============================================================
|
||||
|
||||
def build_password_reset_email(reset_url: str, user_name: str) -> tuple[str, str]:
|
||||
"""
|
||||
Construir HTML y texto plano para email de reseteo de contraseña.
|
||||
|
||||
Returns:
|
||||
(html_content, text_content)
|
||||
"""
|
||||
html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Restablecer contraseña</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#f4f6f8;font-family:Arial,sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f6f8;padding:40px 0;">
|
||||
<tr><td align="center">
|
||||
<table width="560" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,.08);">
|
||||
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="background:#1d4ed8;padding:32px 40px;text-align:center;">
|
||||
<span style="color:#ffffff;font-size:22px;font-weight:700;letter-spacing:-.5px;">ServiceManager</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td style="padding:40px;">
|
||||
<h2 style="margin:0 0 16px;font-size:20px;color:#111827;">Restablece tu contraseña</h2>
|
||||
<p style="margin:0 0 12px;font-size:15px;color:#374151;line-height:1.6;">
|
||||
Hola <strong>{user_name}</strong>,
|
||||
</p>
|
||||
<p style="margin:0 0 24px;font-size:15px;color:#374151;line-height:1.6;">
|
||||
Recibimos una solicitud para restablecer la contraseña de tu cuenta.
|
||||
Haz clic en el botón de abajo para crear una nueva contraseña.
|
||||
Este enlace es válido por <strong>30 minutos</strong>.
|
||||
</p>
|
||||
|
||||
<table cellpadding="0" cellspacing="0" style="margin:0 auto 32px;">
|
||||
<tr>
|
||||
<td style="background:#1d4ed8;border-radius:6px;">
|
||||
<a href="{reset_url}"
|
||||
style="display:inline-block;padding:14px 32px;color:#ffffff;font-size:15px;font-weight:600;text-decoration:none;border-radius:6px;">
|
||||
Restablecer contraseña
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin:0 0 8px;font-size:13px;color:#6b7280;">
|
||||
Si no puedes hacer clic en el botón, copia y pega este enlace en tu navegador:
|
||||
</p>
|
||||
<p style="margin:0 0 24px;font-size:12px;color:#2563eb;word-break:break-all;">
|
||||
<a href="{reset_url}" style="color:#2563eb;">{reset_url}</a>
|
||||
</p>
|
||||
|
||||
<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;">
|
||||
|
||||
<p style="margin:0;font-size:13px;color:#9ca3af;line-height:1.6;">
|
||||
Si no solicitaste restablecer tu contraseña, puedes ignorar este mensaje.
|
||||
Tu contraseña no se modificará.<br>
|
||||
Por seguridad, este enlace expira en 30 minutos y solo puede usarse una vez.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="padding:20px 40px;background:#f9fafb;text-align:center;">
|
||||
<p style="margin:0;font-size:12px;color:#9ca3af;">
|
||||
© 2026 Aduanasoft — Acceso exclusivo autorizado
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
text = (
|
||||
f"Hola {user_name},\n\n"
|
||||
"Recibimos una solicitud para restablecer la contraseña de tu cuenta.\n\n"
|
||||
f"Haz clic en el siguiente enlace (válido por 30 minutos):\n{reset_url}\n\n"
|
||||
"Si no solicitaste este cambio, ignora este mensaje.\n\n"
|
||||
"— ServiceManager"
|
||||
)
|
||||
|
||||
return html, text
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -30,6 +30,7 @@ from app.core.logging import setup_logging
|
||||
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
|
||||
|
||||
settings = get_settings()
|
||||
setup_logging()
|
||||
@@ -42,6 +43,10 @@ async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
logger.info("Iniciando ServiceManagerWeb Backend", version=settings.API_VERSION)
|
||||
|
||||
# Conectar a Redis cache
|
||||
await cache.connect()
|
||||
logger.info("Caché Redis conectado")
|
||||
|
||||
if settings.ENVIRONMENT == "development":
|
||||
await create_tables()
|
||||
logger.info("Tablas de base de datos verificadas")
|
||||
@@ -50,6 +55,8 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
# Shutdown
|
||||
logger.info("Cerrando ServiceManagerWeb Backend")
|
||||
await cache.disconnect()
|
||||
logger.info("Caché Redis desconectado")
|
||||
|
||||
|
||||
# Crear aplicación FastAPI
|
||||
@@ -69,12 +76,26 @@ app = FastAPI(
|
||||
|
||||
# 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 = ["*"]
|
||||
cors_allow_headers = ["*"]
|
||||
|
||||
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
|
||||
|
||||
@@ -4,28 +4,48 @@ Tenant Middleware - ServiceManagerWeb
|
||||
Middleware para manejo de multi-tenancy
|
||||
"""
|
||||
|
||||
from fastapi import Request, HTTPException, status
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import Response
|
||||
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, get_db
|
||||
from app.core.config import get_settings
|
||||
from app.models.tenant import Tenant, TenantStatus
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class TenantMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware para extraer y validar información del tenant.
|
||||
|
||||
Extrae el tenant_id del header X-Tenant-ID y lo almacena
|
||||
en el estado de la request para uso posterior.
|
||||
|
||||
Extrae el tenant_id del header X-Tenant-ID o el slug del header
|
||||
X-Tenant-Slug, valida que exista en la base de datos y que esté
|
||||
activo, y almacena el objeto Tenant en request.state.tenant.
|
||||
"""
|
||||
|
||||
|
||||
# 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/forgot-password",
|
||||
"/v1/auth/forgot-password",
|
||||
"/api/v1/auth/reset-password",
|
||||
"/v1/auth/reset-password",
|
||||
"/docs",
|
||||
"/api/v1/docs",
|
||||
"/v1/docs",
|
||||
@@ -34,46 +54,124 @@ class TenantMiddleware(BaseHTTPMiddleware):
|
||||
"/v1/openapi.json",
|
||||
"/redoc",
|
||||
"/api/v1/redoc",
|
||||
"/v1/redoc"
|
||||
"/v1/redoc",
|
||||
}
|
||||
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
"""Process request and add tenant information."""
|
||||
|
||||
# Skip tenant validation for excluded paths
|
||||
"""Valida el tenant en cada request y lo almacena en request.state."""
|
||||
|
||||
# Inicializar state con valores por defecto
|
||||
request.state.tenant = None
|
||||
request.state.tenant_id = None
|
||||
request.state.tenant_slug = None
|
||||
|
||||
# Saltar validación en rutas excluidas
|
||||
if request.url.path in self.EXCLUDED_PATHS or request.url.path.startswith("/docs"):
|
||||
return await call_next(request)
|
||||
|
||||
# Extract tenant from header
|
||||
|
||||
# Extraer headers de tenant
|
||||
tenant_id = request.headers.get("X-Tenant-ID")
|
||||
tenant_slug = request.headers.get("X-Tenant-Slug")
|
||||
|
||||
# For now, we'll be more permissive in development
|
||||
# In production, tenant should be strictly required
|
||||
|
||||
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": "Invalid X-Tenant-ID header (must be UUID)"},
|
||||
)
|
||||
|
||||
# Si no hay headers de tenant (requerido para aislamiento multi-tenant)
|
||||
if not tenant_id and not tenant_slug:
|
||||
logger.warning(
|
||||
"Request without tenant information",
|
||||
path=request.url.path,
|
||||
method=request.method
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"detail": "Tenant information required (X-Tenant-ID or X-Tenant-Slug header)"},
|
||||
)
|
||||
# For now, continue without tenant for development
|
||||
# raise HTTPException(
|
||||
# status_code=status.HTTP_400_BAD_REQUEST,
|
||||
# detail="Tenant information required (X-Tenant-ID or X-Tenant-Slug header)"
|
||||
# )
|
||||
|
||||
# Store tenant info in request state
|
||||
request.state.tenant_id = tenant_id
|
||||
request.state.tenant_slug = tenant_slug
|
||||
|
||||
# TODO: Validate tenant exists and is active
|
||||
# This would involve a database query which we'll implement later
|
||||
|
||||
logger.debug(
|
||||
"Tenant middleware processed",
|
||||
tenant_id=tenant_id,
|
||||
tenant_slug=tenant_slug,
|
||||
path=request.url.path
|
||||
)
|
||||
|
||||
|
||||
# Validar tenant contra la base de datos
|
||||
try:
|
||||
# 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(
|
||||
"Tenant not found",
|
||||
tenant_id=tenant_id,
|
||||
tenant_slug=tenant_slug,
|
||||
path=request.url.path,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"detail": "Tenant not found"}
|
||||
)
|
||||
|
||||
if tenant.status != TenantStatus.ACTIVE:
|
||||
logger.warning(
|
||||
"Tenant is not active",
|
||||
tenant_id=str(tenant.id),
|
||||
tenant_slug=tenant.slug,
|
||||
status=tenant.status,
|
||||
path=request.url.path,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={"detail": f"Tenant is {tenant.status.value}"}
|
||||
)
|
||||
|
||||
# Almacenar tenant validado en el state
|
||||
request.state.tenant = tenant
|
||||
request.state.tenant_id = str(tenant.id)
|
||||
request.state.tenant_slug = tenant.slug
|
||||
|
||||
logger.debug(
|
||||
"Tenant validated",
|
||||
tenant_id=str(tenant.id),
|
||||
tenant_slug=tenant.slug,
|
||||
path=request.url.path,
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Error validating tenant",
|
||||
error=str(exc),
|
||||
path=request.url.path,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={"detail": "Service temporarily unavailable"}
|
||||
)
|
||||
|
||||
return await call_next(request)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
@@ -35,7 +35,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 +48,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 +93,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}')>"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,7 @@ Revises: 13362e8c493a
|
||||
Create Date: 2026-02-12 10:00:00.000000
|
||||
|
||||
Registra el modelo AuditLog en Alembic.
|
||||
La tabla audit_logs ya existe en schema.sql, esta migraci├│n solo
|
||||
La tabla audit_logs ya existe en schema.sql, esta migración solo
|
||||
la registra en el control de versiones de Alembic.
|
||||
"""
|
||||
from alembic import op
|
||||
@@ -19,6 +19,62 @@ branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""
|
||||
Verificar que audit_logs existe y registrarla en Alembic.
|
||||
|
||||
La tabla fue creada por schema.sql, esta migración solo verifica
|
||||
que exista y esté disponible para usar.
|
||||
"""
|
||||
from sqlalchemy import inspect
|
||||
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
if 'audit_logs' in tables:
|
||||
print("OK Tabla audit_logs encontrada (creada por schema.sql)")
|
||||
print("OK Modelo AuditLog registrado en Alembic")
|
||||
|
||||
# Verificar que tenga los índices necesarios
|
||||
existing_indexes = [idx['name'] for idx in inspector.get_indexes('audit_logs')]
|
||||
|
||||
required_indexes = [
|
||||
'idx_audit_logs_tenant_id',
|
||||
'idx_audit_logs_user_id',
|
||||
'idx_audit_logs_action',
|
||||
'idx_audit_logs_correlation_id',
|
||||
'idx_audit_logs_created_at',
|
||||
]
|
||||
|
||||
missing_indexes = [idx for idx in required_indexes if idx not in existing_indexes]
|
||||
|
||||
if missing_indexes:
|
||||
print(f"WARN Indices faltantes: {', '.join(missing_indexes)}")
|
||||
print(" (Esto es normal si usaste schema.sql completo)")
|
||||
else:
|
||||
print("OK Todos los índices necesarios están presentes")
|
||||
|
||||
else:
|
||||
print("ERROR La tabla audit_logs NO existe")
|
||||
print(" Ejecuta: docker-compose exec -T postgres psql -U postgres -d servicemanager < db/schema.sql")
|
||||
raise Exception(
|
||||
"La tabla audit_logs no existe. "
|
||||
"Por favor ejecuta el schema.sql completo primero."
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""
|
||||
No eliminar la tabla - fue creada por schema.sql.
|
||||
|
||||
Solo des-registrar de Alembic.
|
||||
"""
|
||||
print("INFO Tabla audit_logs NO será eliminada (creada por schema.sql)")
|
||||
print("OK Modelo AuditLog des-registrado de Alembic")
|
||||
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""
|
||||
Verificar que audit_logs existe y registrarla en Alembic.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[tool:pytest]
|
||||
[pytest]
|
||||
testpaths = tests tests/unit tests/integration
|
||||
python_files = test_*.py
|
||||
python_functions = test_*
|
||||
|
||||
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())
|
||||
@@ -1,28 +1,166 @@
|
||||
"""
|
||||
Test Configuration - ServiceManagerWeb
|
||||
|
||||
Configuración básica para testing con pytest
|
||||
Configuración global para todos los tests (unit + integration).
|
||||
Carga variables de entorno de prueba antes de cualquier import de la app,
|
||||
y provee fixtures compartidos sin dependencia de Docker/PostgreSQL.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
import asyncio
|
||||
from typing import AsyncGenerator, Generator
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
import uuid
|
||||
|
||||
# ============================================================
|
||||
# CARGAR VARIABLES DE ENTORNO DE TEST ANTES DE IMPORTAR LA APP
|
||||
# Esto evita que pydantic-settings falle por SECRET_KEY faltante
|
||||
# ============================================================
|
||||
os.environ.setdefault("ENVIRONMENT", "testing")
|
||||
os.environ.setdefault("DEBUG", "true")
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-unit-tests-only-32chars!")
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "test-jwt-secret-key-for-unit-tests-only!")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///./test_unit.db")
|
||||
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/15")
|
||||
os.environ.setdefault("CELERY_BROKER_URL", "redis://localhost:6379/15")
|
||||
os.environ.setdefault("CELERY_RESULT_BACKEND", "redis://localhost:6379/15")
|
||||
os.environ.setdefault("CORS_ORIGINS", "http://localhost:3000")
|
||||
os.environ.setdefault("ALLOWED_FILE_EXTENSIONS", "pdf,jpg,jpeg,png,doc,docx,txt")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_user_data():
|
||||
"""Sample user data for testing."""
|
||||
# ============================================================
|
||||
# IN-MEMORY SQLite DB PARA UNIT TESTS (sin Docker)
|
||||
# ============================================================
|
||||
|
||||
@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()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def sqlite_engine():
|
||||
"""
|
||||
Engine SQLite en memoria para unit tests.
|
||||
No requiere Docker ni PostgreSQL.
|
||||
"""
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from app.core.database import Base
|
||||
# Importar todos los modelos para registrarlos en Base.metadata
|
||||
import app.models # noqa: F401
|
||||
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
echo=False,
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
|
||||
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(sqlite_engine) -> AsyncGenerator:
|
||||
"""
|
||||
Sesión de BD SQLite en memoria para cada test.
|
||||
Hace rollback al finalizar para mantener tests aislados.
|
||||
"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
async_session = async_sessionmaker(
|
||||
sqlite_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
async with async_session() as session:
|
||||
async with session.begin():
|
||||
yield session
|
||||
await session.rollback()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# FIXTURES DE DATOS COMUNES
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture
|
||||
def test_user_data() -> dict:
|
||||
"""Datos de usuario válidos para pruebas."""
|
||||
return {
|
||||
"email": "test@example.com",
|
||||
"first_name": "Test",
|
||||
"last_name": "User",
|
||||
"password": "TestPassword123!"
|
||||
"last_name": "User",
|
||||
"password": "TestPassword123!",
|
||||
"role": "AGENT",
|
||||
"language": "es",
|
||||
"timezone": "UTC",
|
||||
"notifications_email": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_tenant_data():
|
||||
"""Sample tenant data for testing."""
|
||||
@pytest.fixture
|
||||
def test_tenant_data() -> dict:
|
||||
"""Datos de tenant válidos para pruebas."""
|
||||
return {
|
||||
"name": "Test Tenant",
|
||||
"slug": "test-tenant",
|
||||
"description": "Test tenant for testing"
|
||||
}
|
||||
"name": "Test Company",
|
||||
"slug": "test-company",
|
||||
"contact_email": "admin@testcompany.com",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_ticket_data() -> dict:
|
||||
"""Datos de ticket válidos para pruebas."""
|
||||
return {
|
||||
"subject": "Test ticket subject",
|
||||
"description": "Detailed description of the test ticket",
|
||||
"priority": "MEDIUM",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db_session():
|
||||
"""Sesión de BD completamente mockeada (sin SQLite, sin red)."""
|
||||
session = AsyncMock()
|
||||
session.execute = AsyncMock()
|
||||
session.add = MagicMock()
|
||||
session.commit = AsyncMock()
|
||||
session.refresh = AsyncMock()
|
||||
session.rollback = AsyncMock()
|
||||
return session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_request():
|
||||
"""Request HTTP mockeado para tests de middleware y endpoints."""
|
||||
request = MagicMock()
|
||||
request.url.path = "/v1/tickets/"
|
||||
request.method = "GET"
|
||||
request.headers = {}
|
||||
request.state = MagicMock()
|
||||
return request
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_tenant_id() -> str:
|
||||
"""UUID de tenant fijo para pruebas."""
|
||||
return "12345678-1234-5678-1234-567812345678"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_user_id() -> str:
|
||||
"""UUID de usuario fijo para pruebas."""
|
||||
return "87654321-4321-8765-4321-876543218765"
|
||||
|
||||
@@ -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
|
||||
|
||||
191
backend/tests/unit/test_audit_service.py
Normal file
191
backend/tests/unit/test_audit_service.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
Unit Tests - Audit Service - ServiceManagerWeb
|
||||
|
||||
Tests para app.services.audit_service usando mocks de BD.
|
||||
No requieren base de datos real ni red.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
||||
class TestAuditServiceLog:
|
||||
"""Tests para AuditService.log()."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_creates_audit_entry(self):
|
||||
"""AuditService.log() debe crear un registro en la BD."""
|
||||
from app.services.audit_service import AuditService
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_db.add = MagicMock()
|
||||
mock_db.commit = AsyncMock()
|
||||
mock_db.refresh = AsyncMock()
|
||||
|
||||
tenant_id = uuid.uuid4()
|
||||
user_id = uuid.uuid4()
|
||||
resource_id = uuid.uuid4()
|
||||
|
||||
result = await AuditService.log(
|
||||
db=mock_db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
action="ticket.create",
|
||||
resource_type="ticket",
|
||||
resource_id=resource_id,
|
||||
new_values={"subject": "Test ticket", "status": "NEW"},
|
||||
)
|
||||
|
||||
# Se debe haber llamado a db.add con el AuditLog
|
||||
mock_db.add.assert_called_once()
|
||||
# El resultado debe ser un AuditLog
|
||||
assert result is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_without_user_id(self):
|
||||
"""AuditService.log() funciona sin user_id (acciones del sistema)."""
|
||||
from app.services.audit_service import AuditService
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_db.add = MagicMock()
|
||||
mock_db.commit = AsyncMock()
|
||||
mock_db.refresh = AsyncMock()
|
||||
|
||||
result = await AuditService.log(
|
||||
db=mock_db,
|
||||
tenant_id=uuid.uuid4(),
|
||||
action="system.startup",
|
||||
resource_type="system",
|
||||
)
|
||||
|
||||
mock_db.add.assert_called_once()
|
||||
assert result is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_with_old_and_new_values(self):
|
||||
"""AuditService.log() acepta old_values y new_values para auditoría de cambios."""
|
||||
from app.services.audit_service import AuditService
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_db.add = MagicMock()
|
||||
mock_db.commit = AsyncMock()
|
||||
mock_db.refresh = AsyncMock()
|
||||
|
||||
await AuditService.log(
|
||||
db=mock_db,
|
||||
tenant_id=uuid.uuid4(),
|
||||
user_id=uuid.uuid4(),
|
||||
action="ticket.update",
|
||||
resource_type="ticket",
|
||||
resource_id=uuid.uuid4(),
|
||||
old_values={"status": "NEW", "priority": "LOW"},
|
||||
new_values={"status": "IN_PROGRESS", "priority": "HIGH"},
|
||||
)
|
||||
|
||||
mock_db.add.assert_called_once()
|
||||
# Verificar que el AuditLog tiene old_values y new_values
|
||||
audit_log = mock_db.add.call_args[0][0]
|
||||
assert audit_log.old_values == {"status": "NEW", "priority": "LOW"}
|
||||
assert audit_log.new_values == {"status": "IN_PROGRESS", "priority": "HIGH"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_action_stored_correctly(self):
|
||||
"""AuditService.log() almacena la acción correctamente."""
|
||||
from app.services.audit_service import AuditService
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_db.add = MagicMock()
|
||||
mock_db.commit = AsyncMock()
|
||||
mock_db.refresh = AsyncMock()
|
||||
|
||||
await AuditService.log(
|
||||
db=mock_db,
|
||||
tenant_id=uuid.uuid4(),
|
||||
action="user.login",
|
||||
resource_type="user",
|
||||
)
|
||||
|
||||
audit_log = mock_db.add.call_args[0][0]
|
||||
assert audit_log.action == "user.login"
|
||||
assert audit_log.resource_type == "user"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_tenant_id_stored_correctly(self):
|
||||
"""AuditService.log() almacena el tenant_id correctamente."""
|
||||
from app.services.audit_service import AuditService
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_db.add = MagicMock()
|
||||
mock_db.commit = AsyncMock()
|
||||
mock_db.refresh = AsyncMock()
|
||||
|
||||
tenant_id = uuid.uuid4()
|
||||
|
||||
await AuditService.log(
|
||||
db=mock_db,
|
||||
tenant_id=tenant_id,
|
||||
action="ticket.delete",
|
||||
resource_type="ticket",
|
||||
)
|
||||
|
||||
audit_log = mock_db.add.call_args[0][0]
|
||||
assert audit_log.tenant_id == tenant_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_with_request_extracts_ip(self):
|
||||
"""AuditService.log() extrae información del request si se provee."""
|
||||
from app.services.audit_service import AuditService
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_db.add = MagicMock()
|
||||
mock_db.commit = AsyncMock()
|
||||
mock_db.refresh = AsyncMock()
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.client.host = "192.168.1.100"
|
||||
mock_request.headers = {"user-agent": "TestBrowser/1.0"}
|
||||
mock_request.state.correlation_id = "test-correlation-id"
|
||||
|
||||
await AuditService.log(
|
||||
db=mock_db,
|
||||
tenant_id=uuid.uuid4(),
|
||||
action="ticket.view",
|
||||
resource_type="ticket",
|
||||
request=mock_request,
|
||||
)
|
||||
|
||||
mock_db.add.assert_called_once()
|
||||
|
||||
|
||||
class TestAuditServiceMetadata:
|
||||
"""Tests para metadata adicional en registros de auditoría."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_with_custom_metadata(self):
|
||||
"""AuditService.log() almacena metadata personalizada en extra_metadata.
|
||||
|
||||
Nota: El campo Python es 'extra_metadata' (no 'metadata') porque
|
||||
SQLAlchemy reserva el atributo 'metadata' para MetaData de la tabla.
|
||||
La columna en BD sí se llama 'metadata'.
|
||||
"""
|
||||
from app.services.audit_service import AuditService
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_db.add = MagicMock()
|
||||
mock_db.commit = AsyncMock()
|
||||
mock_db.refresh = AsyncMock()
|
||||
|
||||
metadata = {"source": "api", "version": "1.9.0", "client_ip": "10.0.0.1"}
|
||||
|
||||
await AuditService.log(
|
||||
db=mock_db,
|
||||
tenant_id=uuid.uuid4(),
|
||||
action="tenant.update",
|
||||
resource_type="tenant",
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
audit_log = mock_db.add.call_args[0][0]
|
||||
# El atributo Python es extra_metadata (columna BD: metadata)
|
||||
assert audit_log.extra_metadata == metadata
|
||||
136
backend/tests/unit/test_config.py
Normal file
136
backend/tests/unit/test_config.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
Unit Tests - Configuration - ServiceManagerWeb
|
||||
|
||||
Tests para app.core.config: carga de settings, valores por defecto
|
||||
y propiedades derivadas. No requieren base de datos ni red.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestSettings:
|
||||
"""Tests para la configuración centralizada de la aplicación."""
|
||||
|
||||
def test_settings_loads_without_error(self):
|
||||
"""get_settings() debe cargar sin lanzar excepciones."""
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
assert settings is not None
|
||||
|
||||
def test_settings_is_singleton(self):
|
||||
"""get_settings() debe retornar la misma instancia (lru_cache)."""
|
||||
from app.core.config import get_settings
|
||||
s1 = get_settings()
|
||||
s2 = get_settings()
|
||||
assert s1 is s2
|
||||
|
||||
def test_environment_is_valid(self):
|
||||
"""ENVIRONMENT debe ser uno de los valores válidos del sistema."""
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
valid_envs = {"development", "staging", "production", "testing"}
|
||||
assert settings.ENVIRONMENT in valid_envs, (
|
||||
f"ENVIRONMENT='{settings.ENVIRONMENT}' no es un valor válido. "
|
||||
f"Debe ser uno de: {valid_envs}"
|
||||
)
|
||||
|
||||
def test_app_version_is_set(self):
|
||||
"""APP_VERSION debe estar definido."""
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
assert settings.APP_VERSION is not None
|
||||
assert len(settings.APP_VERSION) > 0
|
||||
|
||||
def test_app_version_is_1_9_0(self):
|
||||
"""APP_VERSION debe ser 1.9.0 en esta versión del proyecto."""
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
assert settings.APP_VERSION == "1.9.0"
|
||||
|
||||
def test_api_version_default(self):
|
||||
"""API_VERSION debe ser v1 por defecto."""
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
assert settings.API_VERSION == "v1"
|
||||
|
||||
def test_jwt_algorithm_default(self):
|
||||
"""JWT_ALGORITHM debe ser HS256 por defecto."""
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
assert settings.JWT_ALGORITHM == "HS256"
|
||||
|
||||
def test_access_token_expire_minutes(self):
|
||||
"""ACCESS_TOKEN_EXPIRE_MINUTES debe ser un entero positivo."""
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
assert isinstance(settings.ACCESS_TOKEN_EXPIRE_MINUTES, int)
|
||||
assert settings.ACCESS_TOKEN_EXPIRE_MINUTES > 0
|
||||
|
||||
def test_refresh_token_expire_days(self):
|
||||
"""REFRESH_TOKEN_EXPIRE_DAYS debe ser un entero positivo."""
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
assert isinstance(settings.REFRESH_TOKEN_EXPIRE_DAYS, int)
|
||||
assert settings.REFRESH_TOKEN_EXPIRE_DAYS > 0
|
||||
|
||||
def test_secret_key_is_set(self):
|
||||
"""SECRET_KEY debe estar definido y no vacío."""
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
assert settings.SECRET_KEY
|
||||
assert len(settings.SECRET_KEY) > 0
|
||||
|
||||
def test_allowed_file_extensions_is_list(self):
|
||||
"""ALLOWED_FILE_EXTENSIONS debe retornar una lista."""
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
extensions = settings.ALLOWED_FILE_EXTENSIONS
|
||||
assert isinstance(extensions, list)
|
||||
assert len(extensions) > 0
|
||||
|
||||
def test_allowed_file_extensions_lowercase(self):
|
||||
"""Las extensiones de archivo deben estar en minúsculas."""
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
for ext in settings.ALLOWED_FILE_EXTENSIONS:
|
||||
assert ext == ext.lower(), f"Extensión '{ext}' no está en minúsculas"
|
||||
|
||||
def test_is_development_consistent(self):
|
||||
"""is_development() debe ser consistente con el valor de ENVIRONMENT."""
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
expected = settings.ENVIRONMENT == "development"
|
||||
assert settings.is_development() is expected
|
||||
|
||||
def test_is_testing_consistent(self):
|
||||
"""is_testing() debe ser consistente con el valor de ENVIRONMENT."""
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
expected = settings.ENVIRONMENT == "testing"
|
||||
assert settings.is_testing() is expected
|
||||
|
||||
def test_is_production_returns_false_in_testing(self):
|
||||
"""is_production() debe retornar False en entorno de test."""
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
assert settings.is_production() is False
|
||||
|
||||
def test_argon2_settings_positive(self):
|
||||
"""Los parámetros de Argon2 deben ser enteros positivos."""
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
assert settings.ARGON2_TIME_COST > 0
|
||||
assert settings.ARGON2_MEMORY_COST > 0
|
||||
assert settings.ARGON2_PARALLELISM > 0
|
||||
|
||||
def test_max_upload_size_positive(self):
|
||||
"""MAX_UPLOAD_SIZE_MB debe ser positivo."""
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
assert settings.MAX_UPLOAD_SIZE_MB > 0
|
||||
|
||||
def test_password_min_length(self):
|
||||
"""PASSWORD_MIN_LENGTH debe ser al menos 8."""
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
assert settings.PASSWORD_MIN_LENGTH >= 8
|
||||
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
|
||||
279
backend/tests/unit/test_middleware.py
Normal file
279
backend/tests/unit/test_middleware.py
Normal file
@@ -0,0 +1,279 @@
|
||||
"""
|
||||
Unit Tests - Tenant Middleware - ServiceManagerWeb
|
||||
|
||||
Tests para app.middleware.tenant: extracción de headers, rutas excluidas,
|
||||
y comportamiento con tenants válidos/inválidos usando mocks.
|
||||
No requieren base de datos real ni red.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
||||
# ============================================================
|
||||
# EXCLUDED PATHS
|
||||
# ============================================================
|
||||
|
||||
class TestExcludedPaths:
|
||||
"""Tests para las rutas que no requieren validación de tenant."""
|
||||
|
||||
def test_excluded_paths_contains_health(self):
|
||||
"""El health check debe estar en rutas excluidas."""
|
||||
from app.middleware.tenant import TenantMiddleware
|
||||
assert "/health" in TenantMiddleware.EXCLUDED_PATHS
|
||||
|
||||
def test_excluded_paths_contains_login(self):
|
||||
"""El endpoint de login debe estar excluido."""
|
||||
from app.middleware.tenant import TenantMiddleware
|
||||
assert "/api/v1/auth/login" in TenantMiddleware.EXCLUDED_PATHS
|
||||
assert "/v1/auth/login" in TenantMiddleware.EXCLUDED_PATHS
|
||||
|
||||
def test_excluded_paths_contains_refresh(self):
|
||||
"""El endpoint de refresh token debe estar excluido."""
|
||||
from app.middleware.tenant import TenantMiddleware
|
||||
assert "/api/v1/auth/refresh" in TenantMiddleware.EXCLUDED_PATHS
|
||||
assert "/v1/auth/refresh" in TenantMiddleware.EXCLUDED_PATHS
|
||||
|
||||
def test_excluded_paths_contains_docs(self):
|
||||
"""Los endpoints de documentación deben estar excluidos."""
|
||||
from app.middleware.tenant import TenantMiddleware
|
||||
assert "/docs" in TenantMiddleware.EXCLUDED_PATHS
|
||||
assert "/redoc" in TenantMiddleware.EXCLUDED_PATHS
|
||||
|
||||
def test_excluded_paths_contains_openapi(self):
|
||||
"""El endpoint openapi.json debe estar excluido."""
|
||||
from app.middleware.tenant import TenantMiddleware
|
||||
assert "/openapi.json" in TenantMiddleware.EXCLUDED_PATHS
|
||||
|
||||
def test_root_path_is_excluded(self):
|
||||
"""La ruta raíz debe estar excluida."""
|
||||
from app.middleware.tenant import TenantMiddleware
|
||||
assert "/" in TenantMiddleware.EXCLUDED_PATHS
|
||||
|
||||
|
||||
# ============================================================
|
||||
# MIDDLEWARE DISPATCH — RUTAS EXCLUIDAS
|
||||
# ============================================================
|
||||
|
||||
class TestMiddlewareExcludedRoutes:
|
||||
"""Tests que verifican que las rutas excluidas pasan sin validación."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_route_bypasses_tenant_validation(self):
|
||||
"""La ruta /health pasa sin validación de tenant."""
|
||||
from app.middleware.tenant import TenantMiddleware
|
||||
|
||||
mock_app = AsyncMock()
|
||||
middleware = TenantMiddleware(mock_app)
|
||||
|
||||
# Simular request a /health sin headers de tenant
|
||||
request = MagicMock()
|
||||
request.url.path = "/health"
|
||||
request.headers = {}
|
||||
request.state = MagicMock()
|
||||
|
||||
call_next = AsyncMock(return_value=MagicMock(status_code=200))
|
||||
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
# call_next debe haberse llamado (pasó sin bloquear)
|
||||
call_next.assert_called_once_with(request)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_route_bypasses_tenant_validation(self):
|
||||
"""La ruta /api/v1/auth/login pasa sin validación de tenant."""
|
||||
from app.middleware.tenant import TenantMiddleware
|
||||
|
||||
mock_app = AsyncMock()
|
||||
middleware = TenantMiddleware(mock_app)
|
||||
|
||||
request = MagicMock()
|
||||
request.url.path = "/api/v1/auth/login"
|
||||
request.headers = {}
|
||||
request.state = MagicMock()
|
||||
|
||||
call_next = AsyncMock(return_value=MagicMock(status_code=200))
|
||||
|
||||
await middleware.dispatch(request, call_next)
|
||||
call_next.assert_called_once_with(request)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_docs_prefix_bypasses_tenant_validation(self):
|
||||
"""Rutas que empiezan con /docs pasan sin validación."""
|
||||
from app.middleware.tenant import TenantMiddleware
|
||||
|
||||
mock_app = AsyncMock()
|
||||
middleware = TenantMiddleware(mock_app)
|
||||
|
||||
request = MagicMock()
|
||||
request.url.path = "/docs/swagger-ui"
|
||||
request.headers = {}
|
||||
request.state = MagicMock()
|
||||
|
||||
call_next = AsyncMock(return_value=MagicMock(status_code=200))
|
||||
|
||||
await middleware.dispatch(request, call_next)
|
||||
call_next.assert_called_once_with(request)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# MIDDLEWARE DISPATCH — SIN HEADERS DE TENANT
|
||||
# ============================================================
|
||||
|
||||
class TestMiddlewareNoTenantHeaders:
|
||||
"""Tests para requests sin headers de tenant."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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()
|
||||
middleware = TenantMiddleware(mock_app)
|
||||
|
||||
request = MagicMock()
|
||||
request.url.path = "/v1/tickets/"
|
||||
request.method = "GET"
|
||||
request.headers = {}
|
||||
request.state = MagicMock()
|
||||
|
||||
call_next = AsyncMock(return_value=MagicMock(status_code=200))
|
||||
|
||||
response = await middleware.dispatch(request, call_next)
|
||||
|
||||
assert response.status_code == 400
|
||||
call_next.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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
|
||||
|
||||
mock_app = AsyncMock()
|
||||
middleware = TenantMiddleware(mock_app)
|
||||
|
||||
request = MagicMock()
|
||||
request.url.path = "/v1/tickets/"
|
||||
request.method = "GET"
|
||||
request.headers = {}
|
||||
request.state = MagicMock()
|
||||
|
||||
call_next = AsyncMock(return_value=MagicMock(status_code=200))
|
||||
|
||||
response = await middleware.dispatch(request, call_next)
|
||||
|
||||
assert response.status_code == 400
|
||||
call_next.assert_not_called()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# MIDDLEWARE DISPATCH — CON TENANT VÁLIDO
|
||||
# ============================================================
|
||||
|
||||
class TestMiddlewareValidTenant:
|
||||
"""Tests para requests con tenant válido."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_tenant_id_sets_state(self):
|
||||
"""Un tenant_id válido debe almacenarse en request.state."""
|
||||
from app.middleware.tenant import TenantMiddleware
|
||||
from app.models.tenant import TenantStatus
|
||||
|
||||
mock_app = AsyncMock()
|
||||
middleware = TenantMiddleware(mock_app)
|
||||
|
||||
# Crear tenant mock
|
||||
mock_tenant = MagicMock()
|
||||
mock_tenant.id = "12345678-1234-5678-1234-567812345678"
|
||||
mock_tenant.slug = "test-company"
|
||||
mock_tenant.status = TenantStatus.ACTIVE
|
||||
|
||||
request = MagicMock()
|
||||
request.url.path = "/v1/tickets/"
|
||||
request.method = "GET"
|
||||
request.headers = {"X-Tenant-ID": str(mock_tenant.id)}
|
||||
request.state = MagicMock()
|
||||
|
||||
call_next = AsyncMock(return_value=MagicMock(status_code=200))
|
||||
|
||||
# Mock de la sesión de BD
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.first.return_value = mock_tenant
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch("app.middleware.tenant.AsyncSessionLocal", return_value=mock_session):
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
# El tenant debe haber sido asignado al state
|
||||
assert request.state.tenant == mock_tenant
|
||||
call_next.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inactive_tenant_returns_403(self):
|
||||
"""Un tenant suspendido debe retornar 403."""
|
||||
from app.middleware.tenant import TenantMiddleware
|
||||
from app.models.tenant import TenantStatus
|
||||
|
||||
mock_app = AsyncMock()
|
||||
middleware = TenantMiddleware(mock_app)
|
||||
|
||||
mock_tenant = MagicMock()
|
||||
mock_tenant.id = "12345678-1234-5678-1234-567812345678"
|
||||
mock_tenant.slug = "suspended-company"
|
||||
mock_tenant.status = TenantStatus.SUSPENDED
|
||||
|
||||
request = MagicMock()
|
||||
request.url.path = "/v1/tickets/"
|
||||
request.method = "GET"
|
||||
request.headers = {"X-Tenant-ID": str(mock_tenant.id)}
|
||||
request.state = MagicMock()
|
||||
|
||||
call_next = AsyncMock(return_value=MagicMock(status_code=200))
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.first.return_value = mock_tenant
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch("app.middleware.tenant.AsyncSessionLocal", return_value=mock_session):
|
||||
response = await middleware.dispatch(request, call_next)
|
||||
|
||||
assert response.status_code == 403
|
||||
call_next.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nonexistent_tenant_returns_404(self):
|
||||
"""Un tenant_id que no existe en BD debe retornar 404."""
|
||||
from app.middleware.tenant import TenantMiddleware
|
||||
|
||||
mock_app = AsyncMock()
|
||||
middleware = TenantMiddleware(mock_app)
|
||||
|
||||
request = MagicMock()
|
||||
request.url.path = "/v1/tickets/"
|
||||
request.method = "GET"
|
||||
request.headers = {"X-Tenant-ID": "00000000-0000-0000-0000-000000000000"}
|
||||
request.state = MagicMock()
|
||||
|
||||
call_next = AsyncMock(return_value=MagicMock(status_code=200))
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.first.return_value = None # No encontrado
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch("app.middleware.tenant.AsyncSessionLocal", return_value=mock_session):
|
||||
response = await middleware.dispatch(request, call_next)
|
||||
|
||||
assert response.status_code == 404
|
||||
call_next.assert_not_called()
|
||||
264
backend/tests/unit/test_schemas.py
Normal file
264
backend/tests/unit/test_schemas.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""
|
||||
Unit Tests - Pydantic Schemas - ServiceManagerWeb
|
||||
|
||||
Tests para validación de schemas en app.api.schemas.
|
||||
No requieren base de datos ni red.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
import uuid
|
||||
|
||||
|
||||
# ============================================================
|
||||
# AUTH SCHEMAS
|
||||
# ============================================================
|
||||
|
||||
class TestAuthSchemas:
|
||||
"""Tests para schemas de autenticación."""
|
||||
|
||||
def test_login_request_valid(self):
|
||||
"""LoginRequest acepta datos válidos."""
|
||||
from app.api.schemas.auth import LoginRequest
|
||||
schema = LoginRequest(
|
||||
email="user@example.com",
|
||||
password="Pass123!",
|
||||
tenant_slug="my-tenant",
|
||||
)
|
||||
assert schema.email == "user@example.com"
|
||||
assert schema.tenant_slug == "my-tenant"
|
||||
assert schema.totp_code is None
|
||||
|
||||
def test_login_request_invalid_email(self):
|
||||
"""LoginRequest rechaza email inválido."""
|
||||
from app.api.schemas.auth import LoginRequest
|
||||
with pytest.raises(ValidationError):
|
||||
LoginRequest(email="not-an-email", password="Pass123!", tenant_slug="t")
|
||||
|
||||
def test_login_request_with_totp(self):
|
||||
"""LoginRequest acepta código TOTP opcional."""
|
||||
from app.api.schemas.auth import LoginRequest
|
||||
schema = LoginRequest(
|
||||
email="user@example.com",
|
||||
password="Pass123!",
|
||||
tenant_slug="my-tenant",
|
||||
totp_code="123456",
|
||||
)
|
||||
assert schema.totp_code == "123456"
|
||||
|
||||
def test_token_response_default_type(self):
|
||||
"""TokenResponse tiene token_type=bearer por defecto."""
|
||||
from app.api.schemas.auth import TokenResponse
|
||||
schema = TokenResponse(access_token="abc123", expires_in=3600)
|
||||
assert schema.token_type == "bearer"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# TENANT SCHEMAS
|
||||
# ============================================================
|
||||
|
||||
class TestTenantSchemas:
|
||||
"""Tests para schemas de tenants."""
|
||||
|
||||
def test_tenant_create_valid(self):
|
||||
"""TenantCreate acepta datos mínimos válidos."""
|
||||
from app.api.schemas.tenant import TenantCreate
|
||||
schema = TenantCreate(name="ACME Corp", slug="acme-corp")
|
||||
assert schema.name == "ACME Corp"
|
||||
assert schema.slug == "acme-corp"
|
||||
assert schema.domain is None
|
||||
|
||||
def test_tenant_create_with_all_fields(self):
|
||||
"""TenantCreate acepta todos los campos opcionales."""
|
||||
from app.api.schemas.tenant import TenantCreate
|
||||
schema = TenantCreate(
|
||||
name="ACME Corp",
|
||||
slug="acme-corp",
|
||||
domain="acme.com",
|
||||
contact_email="admin@acme.com",
|
||||
contact_phone="+1234567890",
|
||||
)
|
||||
assert schema.contact_email == "admin@acme.com"
|
||||
|
||||
def test_tenant_create_invalid_email(self):
|
||||
"""TenantCreate rechaza email de contacto inválido."""
|
||||
from app.api.schemas.tenant import TenantCreate
|
||||
with pytest.raises(ValidationError):
|
||||
TenantCreate(name="Corp", slug="corp", contact_email="bad-email")
|
||||
|
||||
def test_tenant_update_all_optional(self):
|
||||
"""TenantUpdate permite actualización parcial (todos opcionales)."""
|
||||
from app.api.schemas.tenant import TenantUpdate
|
||||
schema = TenantUpdate()
|
||||
assert schema.name is None
|
||||
assert schema.slug is None
|
||||
assert schema.status is None
|
||||
|
||||
def test_tenant_update_only_name(self):
|
||||
"""TenantUpdate permite actualizar solo el nombre."""
|
||||
from app.api.schemas.tenant import TenantUpdate
|
||||
schema = TenantUpdate(name="New Name")
|
||||
assert schema.name == "New Name"
|
||||
assert schema.slug is None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# USER SCHEMAS
|
||||
# ============================================================
|
||||
|
||||
class TestUserSchemas:
|
||||
"""Tests para schemas de usuarios."""
|
||||
|
||||
def test_user_create_valid(self):
|
||||
"""UserCreate acepta datos válidos con defaults."""
|
||||
from app.api.schemas.user import UserCreate
|
||||
from app.models.user import UserRole
|
||||
schema = UserCreate(
|
||||
email="agent@company.com",
|
||||
first_name="John",
|
||||
last_name="Doe",
|
||||
role=UserRole.AGENT,
|
||||
password="SecurePass123!",
|
||||
)
|
||||
assert schema.email == "agent@company.com"
|
||||
assert schema.language == "es"
|
||||
assert schema.timezone == "UTC"
|
||||
assert schema.notifications_email is True
|
||||
|
||||
def test_user_create_invalid_email(self):
|
||||
"""UserCreate rechaza email inválido."""
|
||||
from app.api.schemas.user import UserCreate
|
||||
from app.models.user import UserRole
|
||||
with pytest.raises(ValidationError):
|
||||
UserCreate(
|
||||
email="not-valid",
|
||||
first_name="John",
|
||||
last_name="Doe",
|
||||
role=UserRole.AGENT,
|
||||
password="Pass123!",
|
||||
)
|
||||
|
||||
def test_user_create_invalid_role(self):
|
||||
"""UserCreate rechaza rol inválido."""
|
||||
from app.api.schemas.user import UserCreate
|
||||
with pytest.raises(ValidationError):
|
||||
UserCreate(
|
||||
email="user@test.com",
|
||||
first_name="John",
|
||||
last_name="Doe",
|
||||
role="SUPER_VILLAIN",
|
||||
password="Pass123!",
|
||||
)
|
||||
|
||||
def test_user_update_all_optional(self):
|
||||
"""UserUpdate permite actualización parcial."""
|
||||
from app.api.schemas.user import UserUpdate
|
||||
schema = UserUpdate()
|
||||
assert schema.email is None
|
||||
assert schema.first_name is None
|
||||
assert schema.is_active is None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# TICKET SCHEMAS
|
||||
# ============================================================
|
||||
|
||||
class TestTicketSchemas:
|
||||
"""Tests para schemas de tickets."""
|
||||
|
||||
def test_ticket_create_valid_minimal(self):
|
||||
"""TicketCreate acepta datos mínimos con priority por defecto."""
|
||||
from app.api.schemas.ticket import TicketCreate
|
||||
schema = TicketCreate(
|
||||
subject="Mi impresora no funciona",
|
||||
description="La impresora del piso 3 no enciende desde esta mañana.",
|
||||
)
|
||||
assert schema.subject == "Mi impresora no funciona"
|
||||
assert schema.priority == "MEDIUM"
|
||||
assert schema.category_id is None
|
||||
assert schema.affected_system_id is None
|
||||
|
||||
def test_ticket_create_with_priority(self):
|
||||
"""TicketCreate acepta prioridad personalizada."""
|
||||
from app.api.schemas.ticket import TicketCreate
|
||||
schema = TicketCreate(
|
||||
subject="Sistema caído",
|
||||
description="El sistema principal no responde.",
|
||||
priority="URGENT",
|
||||
)
|
||||
assert schema.priority == "URGENT"
|
||||
|
||||
def test_ticket_update_all_optional(self):
|
||||
"""TicketUpdate permite actualización parcial."""
|
||||
from app.api.schemas.ticket import TicketUpdate
|
||||
schema = TicketUpdate()
|
||||
assert schema.subject is None
|
||||
assert schema.status is None
|
||||
assert schema.assigned_to is None
|
||||
|
||||
def test_ticket_close_request_optional_resolution(self):
|
||||
"""TicketCloseRequest acepta resolución vacía."""
|
||||
from app.api.schemas.ticket import TicketCloseRequest
|
||||
schema = TicketCloseRequest()
|
||||
assert schema.resolution is None
|
||||
|
||||
def test_comment_create_defaults(self):
|
||||
"""CommentCreate tiene is_internal=False por defecto."""
|
||||
from app.api.schemas.ticket import CommentCreate
|
||||
schema = CommentCreate(content="Este es un comentario de prueba.")
|
||||
assert schema.is_internal is False
|
||||
|
||||
def test_comment_create_internal(self):
|
||||
"""CommentCreate acepta comentario interno."""
|
||||
from app.api.schemas.ticket import CommentCreate
|
||||
schema = CommentCreate(content="Nota interna.", is_internal=True)
|
||||
assert schema.is_internal is True
|
||||
|
||||
|
||||
# ============================================================
|
||||
# CATEGORY SCHEMAS
|
||||
# ============================================================
|
||||
|
||||
class TestCategorySchemas:
|
||||
"""Tests para schemas de categorías."""
|
||||
|
||||
def test_category_create_defaults(self):
|
||||
"""CategoryCreate tiene SLAs por defecto correctos."""
|
||||
from app.api.schemas.category import CategoryCreate
|
||||
schema = CategoryCreate(name="Hardware")
|
||||
assert schema.sla_response_hours == 24
|
||||
assert schema.sla_resolution_hours == 72
|
||||
assert schema.is_active if hasattr(schema, "is_active") else True
|
||||
|
||||
def test_category_create_custom_sla(self):
|
||||
"""CategoryCreate acepta SLAs personalizados."""
|
||||
from app.api.schemas.category import CategoryCreate
|
||||
schema = CategoryCreate(
|
||||
name="Urgente",
|
||||
sla_response_hours=1,
|
||||
sla_resolution_hours=4,
|
||||
)
|
||||
assert schema.sla_response_hours == 1
|
||||
assert schema.sla_resolution_hours == 4
|
||||
|
||||
|
||||
# ============================================================
|
||||
# SYSTEM SCHEMAS
|
||||
# ============================================================
|
||||
|
||||
class TestSystemSchemas:
|
||||
"""Tests para schemas de sistemas."""
|
||||
|
||||
def test_system_create_valid(self):
|
||||
"""SystemCreate acepta datos válidos."""
|
||||
from app.api.schemas.system import SystemCreate
|
||||
schema = SystemCreate(name="ERP Principal")
|
||||
assert schema.name == "ERP Principal"
|
||||
assert schema.description is None
|
||||
|
||||
def test_system_update_all_optional(self):
|
||||
"""SystemUpdate permite actualización parcial."""
|
||||
from app.api.schemas.system import SystemUpdate
|
||||
schema = SystemUpdate(is_active=False)
|
||||
assert schema.is_active is False
|
||||
assert schema.name is None
|
||||
192
backend/tests/unit/test_security.py
Normal file
192
backend/tests/unit/test_security.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""
|
||||
Unit Tests - Security Utils - ServiceManagerWeb
|
||||
|
||||
Tests para app.core.security: hash de passwords, JWT tokens y TOTP.
|
||||
No requieren base de datos ni red.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
# ============================================================
|
||||
# PASSWORD HASHING
|
||||
# ============================================================
|
||||
|
||||
class TestPasswordHashing:
|
||||
"""Tests para hash y verificación de contraseñas."""
|
||||
|
||||
def test_hash_password_returns_string(self):
|
||||
"""El hash debe retornar un string."""
|
||||
from app.core.security import SecurityUtils
|
||||
result = SecurityUtils.hash_password("MyPassword123!")
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_hash_is_not_plain_password(self):
|
||||
"""El hash no debe ser igual al password original."""
|
||||
from app.core.security import SecurityUtils
|
||||
password = "MyPassword123!"
|
||||
hashed = SecurityUtils.hash_password(password)
|
||||
assert hashed != password
|
||||
|
||||
def test_verify_correct_password(self):
|
||||
"""Verificar password correcto debe retornar True."""
|
||||
from app.core.security import SecurityUtils
|
||||
password = "CorrectPassword99!"
|
||||
hashed = SecurityUtils.hash_password(password)
|
||||
assert SecurityUtils.verify_password(password, hashed) is True
|
||||
|
||||
def test_verify_wrong_password(self):
|
||||
"""Verificar password incorrecto debe retornar False."""
|
||||
from app.core.security import SecurityUtils
|
||||
password = "CorrectPassword99!"
|
||||
hashed = SecurityUtils.hash_password(password)
|
||||
assert SecurityUtils.verify_password("WrongPassword!", hashed) is False
|
||||
|
||||
def test_two_hashes_of_same_password_are_different(self):
|
||||
"""Cada hash debe ser único (salt diferente)."""
|
||||
from app.core.security import SecurityUtils
|
||||
password = "SamePassword123!"
|
||||
hash1 = SecurityUtils.hash_password(password)
|
||||
hash2 = SecurityUtils.hash_password(password)
|
||||
assert hash1 != hash2
|
||||
|
||||
def test_verify_empty_password_against_hash(self):
|
||||
"""Verificar string vacío contra hash de otra contraseña debe fallar."""
|
||||
from app.core.security import SecurityUtils
|
||||
hashed = SecurityUtils.hash_password("SomePassword!")
|
||||
assert SecurityUtils.verify_password("", hashed) is False
|
||||
|
||||
|
||||
# ============================================================
|
||||
# JWT ACCESS TOKENS
|
||||
# ============================================================
|
||||
|
||||
class TestAccessTokens:
|
||||
"""Tests para creación y verificación de JWT access tokens."""
|
||||
|
||||
def test_create_access_token_returns_string(self):
|
||||
"""create_access_token debe retornar un string."""
|
||||
from app.core.security import SecurityUtils
|
||||
token = SecurityUtils.create_access_token(data={"sub": "user-123"})
|
||||
assert isinstance(token, str)
|
||||
assert len(token) > 20
|
||||
|
||||
def test_verify_valid_access_token(self):
|
||||
"""Un token válido debe retornar el payload."""
|
||||
from app.core.security import SecurityUtils
|
||||
payload_in = {"sub": "user-abc", "role": "AGENT"}
|
||||
token = SecurityUtils.create_access_token(data=payload_in)
|
||||
payload_out = SecurityUtils.verify_token(token)
|
||||
assert payload_out is not None
|
||||
assert payload_out["sub"] == "user-abc"
|
||||
assert payload_out["role"] == "AGENT"
|
||||
|
||||
def test_verify_invalid_token_returns_none(self):
|
||||
"""Un token inválido debe retornar None."""
|
||||
from app.core.security import SecurityUtils
|
||||
result = SecurityUtils.verify_token("this.is.not.a.valid.token")
|
||||
assert result is None
|
||||
|
||||
def test_verify_tampered_token_returns_none(self):
|
||||
"""Un token modificado debe retornar None."""
|
||||
from app.core.security import SecurityUtils
|
||||
token = SecurityUtils.create_access_token(data={"sub": "user-123"})
|
||||
# Modificar el token
|
||||
parts = token.split(".")
|
||||
tampered = parts[0] + "." + parts[1] + "XXXXX." + parts[2]
|
||||
assert SecurityUtils.verify_token(tampered) is None
|
||||
|
||||
def test_create_token_with_custom_expiry(self):
|
||||
"""Token con expiración personalizada debe ser verificable."""
|
||||
from app.core.security import SecurityUtils
|
||||
token = SecurityUtils.create_access_token(
|
||||
data={"sub": "user-xyz"},
|
||||
expires_delta=timedelta(minutes=30)
|
||||
)
|
||||
payload = SecurityUtils.verify_token(token)
|
||||
assert payload is not None
|
||||
assert payload["sub"] == "user-xyz"
|
||||
|
||||
def test_expired_token_returns_none(self):
|
||||
"""Token expirado debe retornar None."""
|
||||
from app.core.security import SecurityUtils
|
||||
token = SecurityUtils.create_access_token(
|
||||
data={"sub": "user-exp"},
|
||||
expires_delta=timedelta(seconds=-1) # Expirado en el pasado
|
||||
)
|
||||
result = SecurityUtils.verify_token(token)
|
||||
assert result is None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# JWT REFRESH TOKENS
|
||||
# ============================================================
|
||||
|
||||
class TestRefreshTokens:
|
||||
"""Tests para creación de refresh tokens."""
|
||||
|
||||
def test_create_refresh_token_returns_string(self):
|
||||
"""create_refresh_token debe retornar un string."""
|
||||
from app.core.security import SecurityUtils
|
||||
token = SecurityUtils.create_refresh_token(data={"sub": "user-456"})
|
||||
assert isinstance(token, str)
|
||||
|
||||
def test_refresh_token_has_type_field(self):
|
||||
"""El refresh token debe contener el campo type=refresh."""
|
||||
from app.core.security import SecurityUtils
|
||||
token = SecurityUtils.create_refresh_token(data={"sub": "user-456"})
|
||||
payload = SecurityUtils.verify_token(token)
|
||||
assert payload is not None
|
||||
assert payload.get("type") == "refresh"
|
||||
|
||||
def test_refresh_token_preserves_subject(self):
|
||||
"""El refresh token debe preservar el campo sub."""
|
||||
from app.core.security import SecurityUtils
|
||||
token = SecurityUtils.create_refresh_token(data={"sub": "user-999"})
|
||||
payload = SecurityUtils.verify_token(token)
|
||||
assert payload["sub"] == "user-999"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# TOTP / 2FA
|
||||
# ============================================================
|
||||
|
||||
class TestTOTP:
|
||||
"""Tests para generación y verificación de TOTP."""
|
||||
|
||||
def test_generate_totp_secret_returns_string(self):
|
||||
"""generate_totp_secret debe retornar un string base32."""
|
||||
from app.core.security import SecurityUtils
|
||||
secret = SecurityUtils.generate_totp_secret()
|
||||
assert isinstance(secret, str)
|
||||
assert len(secret) > 0
|
||||
|
||||
def test_two_secrets_are_different(self):
|
||||
"""Dos secrets consecutivos deben ser distintos."""
|
||||
from app.core.security import SecurityUtils
|
||||
secret1 = SecurityUtils.generate_totp_secret()
|
||||
secret2 = SecurityUtils.generate_totp_secret()
|
||||
assert secret1 != secret2
|
||||
|
||||
def test_verify_valid_totp_code(self):
|
||||
"""Un código TOTP válido debe verificarse correctamente."""
|
||||
import pyotp
|
||||
from app.core.security import SecurityUtils
|
||||
secret = SecurityUtils.generate_totp_secret()
|
||||
totp = pyotp.TOTP(secret)
|
||||
valid_code = totp.now()
|
||||
assert SecurityUtils.verify_totp(secret, valid_code) is True
|
||||
|
||||
def test_verify_invalid_totp_code(self):
|
||||
"""Un código TOTP inválido debe retornar False."""
|
||||
from app.core.security import SecurityUtils
|
||||
secret = SecurityUtils.generate_totp_secret()
|
||||
assert SecurityUtils.verify_totp(secret, "000000") is False
|
||||
|
||||
def test_generate_totp_uri_contains_email(self):
|
||||
"""El URI de TOTP debe contener el email del usuario."""
|
||||
from app.core.security import SecurityUtils
|
||||
secret = SecurityUtils.generate_totp_secret()
|
||||
uri = SecurityUtils.generate_totp_uri(secret, "user@test.com")
|
||||
assert "user%40test.com" in uri or "user@test.com" in uri
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -40,4 +40,6 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD curl -f http://localhost:8000/health || exit 1
|
||||
|
||||
# Comando por defecto
|
||||
# Development: usar --reload
|
||||
# Production: usar --workers y quitar --reload
|
||||
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
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import { onMount } from 'svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
|
||||
export let showLogo = true;
|
||||
@@ -96,6 +95,13 @@
|
||||
>
|
||||
Mi Perfil
|
||||
</a>
|
||||
<a
|
||||
href="/organization"
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
on:click={() => (isMenuOpen = false)}
|
||||
>
|
||||
Mi Organización
|
||||
</a>
|
||||
<button
|
||||
on:click={handleLogout}
|
||||
class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -35,6 +35,7 @@ async function apiCall(endpoint: string, options: RequestInit = {}) {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authState.token}`,
|
||||
...(authState.user?.tenant_id ? { 'X-Tenant-ID': authState.user.tenant_id } : {}),
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface Ticket {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: 'NEW' | 'IN_PROGRESS' | 'WAITING_FOR_CLIENT' | 'RESOLVED' | 'CLOSED' | 'REOPENED';
|
||||
status: 'NEW' | 'IN_PROGRESS' | 'WAITING_CUSTOMER' | 'RESOLVED' | 'CLOSED' | 'REOPENED';
|
||||
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
|
||||
category_id: string;
|
||||
category_name?: string;
|
||||
@@ -135,7 +135,9 @@ function createTicketsStore() {
|
||||
update((state: TicketsState) => ({ ...state, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const tickets = await apiCall('/tickets/');
|
||||
const raw = await apiCall('/tickets/');
|
||||
// El backend devuelve 'subject', el tipo Ticket usa 'title'
|
||||
const tickets = raw.map((t: any) => ({ ...t, title: t.subject ?? t.title }));
|
||||
update((state: TicketsState) => ({ ...state, tickets, isLoading: false }));
|
||||
} catch (error) {
|
||||
update((state: TicketsState) => ({
|
||||
@@ -151,11 +153,13 @@ function createTicketsStore() {
|
||||
update((state: TicketsState) => ({ ...state, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const [ticket, comments, attachments] = await Promise.all([
|
||||
const [ticketRaw, comments, attachments] = await Promise.all([
|
||||
apiCall(`/tickets/${ticketId}`),
|
||||
apiCall(`/tickets/${ticketId}/comments`),
|
||||
apiCall(`/tickets/${ticketId}/attachments`)
|
||||
]);
|
||||
// El backend devuelve 'subject', el tipo Ticket usa 'title'
|
||||
const ticket = { ...ticketRaw, title: ticketRaw.subject ?? ticketRaw.title };
|
||||
|
||||
update((state: TicketsState) => ({
|
||||
...state,
|
||||
|
||||
@@ -4,14 +4,26 @@
|
||||
import Toast from '$lib/components/Toast.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { browser } from '$app/environment';
|
||||
import '../app.css';
|
||||
|
||||
let mounted = false;
|
||||
|
||||
onMount(() => {
|
||||
auth.init();
|
||||
mounted = true;
|
||||
});
|
||||
|
||||
$: showHeader = !$page.url.pathname.startsWith('/login') && !$page.url.pathname.startsWith('/register');
|
||||
// Guard reactivo global: redirige a /login si no está autenticado en rutas protegidas
|
||||
const publicRoutes = ['/login', '/register', '/forgot-password', '/reset-password'];
|
||||
$: if (browser && mounted && !$auth.isAuthenticated &&
|
||||
!publicRoutes.some(r => $page.url.pathname.startsWith(r))) {
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
$: showHeader = !publicRoutes.some(r => $page.url.pathname.startsWith(r));
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-gray-50 font-sans">
|
||||
@@ -25,7 +37,7 @@
|
||||
|
||||
<!-- Footer with version -->
|
||||
<footer class="py-4 text-center border-t border-gray-200 bg-white">
|
||||
<p class="text-xs text-gray-400">ServiceManagerWeb v1.6.0 · © 2026 Aduanasoft</p>
|
||||
<p class="text-xs text-gray-400">ServiceManagerWeb v1.9.0 · © 2026 Aduanasoft</p>
|
||||
</footer>
|
||||
|
||||
<!-- Toast notifications -->
|
||||
|
||||
151
frontend-client/src/routes/forgot-password/+page.svelte
Normal file
151
frontend-client/src/routes/forgot-password/+page.svelte
Normal file
@@ -0,0 +1,151 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
|
||||
let email = '';
|
||||
let isLoading = false;
|
||||
let submitted = false;
|
||||
let errorMessage = '';
|
||||
|
||||
onMount(() => {
|
||||
if ($auth.isAuthenticated) goto('/');
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!email) {
|
||||
errorMessage = 'Ingresa tu correo electrónico';
|
||||
return;
|
||||
}
|
||||
isLoading = true;
|
||||
errorMessage = '';
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/forgot-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
// Siempre mostramos el mensaje de éxito (backend no revela si el email existe)
|
||||
submitted = true;
|
||||
} catch {
|
||||
errorMessage = 'Error de conexión. Intenta de nuevo.';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Olvidé mi contraseña - ServiceManager</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div class="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<!-- Logo -->
|
||||
<div class="flex justify-center mb-6">
|
||||
<a href="/login" class="flex items-center space-x-2">
|
||||
<div class="w-10 h-10 bg-blue-700 rounded-lg flex items-center justify-center">
|
||||
<Icon name="ticket" class="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<span class="text-xl font-bold text-gray-900">ServiceManager</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-white py-10 px-8 shadow-sm rounded-xl border border-gray-200">
|
||||
{#if submitted}
|
||||
<!-- Estado de éxito -->
|
||||
<div class="text-center space-y-4">
|
||||
<div class="w-14 h-14 bg-green-100 rounded-full flex items-center justify-center mx-auto">
|
||||
<Icon name="mail" class="w-7 h-7 text-green-600" />
|
||||
</div>
|
||||
<h2 class="text-xl font-bold text-gray-900">Revisa tu correo</h2>
|
||||
<p class="text-sm text-gray-600 leading-relaxed">
|
||||
Si <strong>{email}</strong> está registrado en el sistema, recibirás un correo
|
||||
con un enlace para restablecer tu contraseña en los próximos minutos.
|
||||
</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
El enlace es válido por 30 minutos y solo puede usarse una vez.
|
||||
</p>
|
||||
<div class="pt-4 space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full py-2.5 px-4 text-sm font-medium text-white bg-blue-700 rounded-lg hover:bg-blue-800 transition-colors"
|
||||
on:click={() => { submitted = false; email = ''; }}
|
||||
>
|
||||
Enviar otro correo
|
||||
</button>
|
||||
<a
|
||||
href="/login"
|
||||
class="block text-center text-sm text-gray-500 hover:text-gray-700 py-2"
|
||||
>
|
||||
Volver al inicio de sesión
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Formulario -->
|
||||
<div class="space-y-6">
|
||||
<div class="text-center space-y-1">
|
||||
<h2 class="text-2xl font-bold text-gray-900">¿Olvidaste tu contraseña?</h2>
|
||||
<p class="text-sm text-gray-500">
|
||||
Ingresa tu correo y te enviaremos un enlace para restablecerla.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if errorMessage}
|
||||
<div class="p-3 rounded-lg bg-red-50 border border-red-100 flex items-center gap-2 text-sm text-red-600">
|
||||
<Icon name="alert-circle" class="w-4 h-4 shrink-0" />
|
||||
{errorMessage}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form on:submit|preventDefault={handleSubmit} class="space-y-5">
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-semibold text-gray-700 mb-1.5">
|
||||
Correo electrónico
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Icon name="mail" class="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
class="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-lg text-sm text-gray-900 focus:ring-2 focus:ring-blue-600 focus:border-transparent outline-none transition-all"
|
||||
placeholder="tu@empresa.com"
|
||||
bind:value={email}
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full flex justify-center items-center gap-2 py-3.5 px-4 text-sm font-bold text-white bg-blue-700 rounded-lg hover:bg-blue-800 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{#if isLoading}
|
||||
<Icon name="loader-2" class="w-4 h-4 animate-spin" />
|
||||
Enviando...
|
||||
{:else}
|
||||
Enviar enlace de restablecimiento
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="text-center pt-2">
|
||||
<a href="/login" class="text-sm text-blue-600 hover:text-blue-500 font-medium">
|
||||
← Volver al inicio de sesión
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="mt-6 text-center text-xs text-gray-400">
|
||||
© 2026 Aduanasoft. Acceso exclusivo autorizado.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
let email = '';
|
||||
let password = '';
|
||||
let tenantSlug = 'aduanasoft-demo';
|
||||
let totpCode = '';
|
||||
let isLoading = false;
|
||||
let showTwoFactor = false;
|
||||
@@ -33,7 +34,7 @@
|
||||
await auth.login({
|
||||
email,
|
||||
password,
|
||||
tenant_slug: 'aduanasoft', // Default tenant for now
|
||||
tenant_slug: tenantSlug.trim() || 'aduanasoft-demo',
|
||||
totp_code: totpCode || undefined
|
||||
});
|
||||
|
||||
@@ -215,12 +216,13 @@
|
||||
>Recordar en este equipo</label
|
||||
>
|
||||
</div>
|
||||
<a
|
||||
href="/forgot-password"
|
||||
class="text-sm font-medium text-blue-600 hover:text-blue-500"
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm font-medium text-blue-600 hover:text-blue-500 bg-transparent border-none p-0 cursor-pointer"
|
||||
on:click={() => goto('/forgot-password')}
|
||||
>
|
||||
Olvide mi clave
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
346
frontend-client/src/routes/organization/+page.svelte
Normal file
346
frontend-client/src/routes/organization/+page.svelte
Normal file
@@ -0,0 +1,346 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import { toast } from '$lib/stores/toast.js';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let profile: any = null;
|
||||
let isLoading = true;
|
||||
let isSaving = false;
|
||||
let isEditing = false;
|
||||
|
||||
let form = {
|
||||
business_name: '',
|
||||
commercial_name: '',
|
||||
rfc: '',
|
||||
client_type: '',
|
||||
country: '',
|
||||
state: '',
|
||||
city: '',
|
||||
address: '',
|
||||
postal_code: '',
|
||||
main_phone: '',
|
||||
main_email: '',
|
||||
website: '',
|
||||
business_hours: '',
|
||||
company_representative: '',
|
||||
notes: ''
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
if (!$auth.isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
await loadProfile();
|
||||
});
|
||||
|
||||
async function loadProfile() {
|
||||
isLoading = true;
|
||||
try {
|
||||
const response = await fetch('/api/v1/client-profile/', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${$auth.token}`,
|
||||
'X-Tenant-ID': $auth.user?.tenant_id ?? ''
|
||||
}
|
||||
});
|
||||
if (!response.ok) throw new Error((await response.json()).detail);
|
||||
profile = await response.json();
|
||||
// Poblar form con datos existentes
|
||||
for (const key of Object.keys(form)) {
|
||||
if (profile[key] !== undefined && profile[key] !== null) {
|
||||
(form as any)[key] = profile[key];
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || 'Error al cargar el perfil de organización');
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
isSaving = true;
|
||||
try {
|
||||
const response = await fetch('/api/v1/client-profile/', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${$auth.token}`,
|
||||
'X-Tenant-ID': $auth.user?.tenant_id ?? ''
|
||||
},
|
||||
body: JSON.stringify(form)
|
||||
});
|
||||
if (!response.ok) throw new Error((await response.json()).detail);
|
||||
profile = await response.json();
|
||||
isEditing = false;
|
||||
toast.success('Perfil de organización actualizado');
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || 'Error al guardar el perfil');
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
for (const key of Object.keys(form)) {
|
||||
(form as any)[key] = (profile?.[key] !== undefined && profile?.[key] !== null)
|
||||
? profile[key]
|
||||
: '';
|
||||
}
|
||||
isEditing = false;
|
||||
}
|
||||
|
||||
function val(key: string): string {
|
||||
return profile?.[key] ?? '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Mi Organización - ServiceManager</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-start mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Mi Organización</h1>
|
||||
<p class="text-gray-600 mt-1">Información empresarial de tu organización</p>
|
||||
</div>
|
||||
{#if !isEditing && !isLoading}
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary px-4 py-2"
|
||||
on:click={() => (isEditing = true)}
|
||||
>
|
||||
Editar información
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="text-center py-16">
|
||||
<div class="spinner w-8 h-8 mx-auto mb-4"></div>
|
||||
<p class="text-gray-500">Cargando información de la organización...</p>
|
||||
</div>
|
||||
{:else}
|
||||
<form on:submit|preventDefault={saveProfile} class="space-y-8">
|
||||
|
||||
<!-- Información general -->
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-200 px-6 py-4">
|
||||
<h2 class="text-base font-semibold text-gray-900">Información general</h2>
|
||||
</div>
|
||||
<div class="px-6 py-5">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
||||
|
||||
<div>
|
||||
<label class="form-label">Razón social</label>
|
||||
{#if isEditing}
|
||||
<input type="text" class="form-input" bind:value={form.business_name} />
|
||||
{:else}
|
||||
<p class="text-sm text-gray-900 mt-1">{val('business_name') || '—'}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label">Nombre comercial</label>
|
||||
{#if isEditing}
|
||||
<input type="text" class="form-input" bind:value={form.commercial_name} />
|
||||
{:else}
|
||||
<p class="text-sm text-gray-900 mt-1">{val('commercial_name') || '—'}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label">RFC</label>
|
||||
{#if isEditing}
|
||||
<input type="text" class="form-input" style="text-transform:uppercase" bind:value={form.rfc} maxlength="13" placeholder="XAXX010101000" />
|
||||
{:else}
|
||||
<p class="text-sm font-mono text-gray-900 mt-1">{val('rfc') || '—'}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label">Tipo de cliente</label>
|
||||
{#if isEditing}
|
||||
<select class="form-input" bind:value={form.client_type}>
|
||||
<option value="">Seleccionar...</option>
|
||||
<option value="EMPRESA">Empresa</option>
|
||||
<option value="PERSONA_FISICA">Persona Física</option>
|
||||
<option value="GOBIERNO">Gobierno</option>
|
||||
<option value="OTRO">Otro</option>
|
||||
</select>
|
||||
{:else}
|
||||
<p class="text-sm text-gray-900 mt-1">{val('client_type') || '—'}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label">Representante</label>
|
||||
{#if isEditing}
|
||||
<input type="text" class="form-input" bind:value={form.company_representative} />
|
||||
{:else}
|
||||
<p class="text-sm text-gray-900 mt-1">{val('company_representative') || '—'}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label">Sitio web</label>
|
||||
{#if isEditing}
|
||||
<input type="url" class="form-input" bind:value={form.website} placeholder="https://..." />
|
||||
{:else}
|
||||
{#if val('website')}
|
||||
<p class="text-sm mt-1">
|
||||
<a href={val('website')} target="_blank" rel="noopener noreferrer" class="text-primary-600 hover:underline">{val('website')}</a>
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-sm text-gray-400 mt-1">—</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ubicación -->
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-200 px-6 py-4">
|
||||
<h2 class="text-base font-semibold text-gray-900">Ubicación</h2>
|
||||
</div>
|
||||
<div class="px-6 py-5">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
||||
|
||||
<div>
|
||||
<label class="form-label">País</label>
|
||||
{#if isEditing}
|
||||
<input type="text" class="form-input" bind:value={form.country} />
|
||||
{:else}
|
||||
<p class="text-sm text-gray-900 mt-1">{val('country') || '—'}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label">Estado / Provincia</label>
|
||||
{#if isEditing}
|
||||
<input type="text" class="form-input" bind:value={form.state} />
|
||||
{:else}
|
||||
<p class="text-sm text-gray-900 mt-1">{val('state') || '—'}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label">Ciudad</label>
|
||||
{#if isEditing}
|
||||
<input type="text" class="form-input" bind:value={form.city} />
|
||||
{:else}
|
||||
<p class="text-sm text-gray-900 mt-1">{val('city') || '—'}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label">Código postal</label>
|
||||
{#if isEditing}
|
||||
<input type="text" class="form-input" bind:value={form.postal_code} maxlength="10" />
|
||||
{:else}
|
||||
<p class="text-sm text-gray-900 mt-1">{val('postal_code') || '—'}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-2">
|
||||
<label class="form-label">Dirección</label>
|
||||
{#if isEditing}
|
||||
<input type="text" class="form-input" bind:value={form.address} />
|
||||
{:else}
|
||||
<p class="text-sm text-gray-900 mt-1">{val('address') || '—'}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contacto -->
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-200 px-6 py-4">
|
||||
<h2 class="text-base font-semibold text-gray-900">Contacto</h2>
|
||||
</div>
|
||||
<div class="px-6 py-5">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
||||
|
||||
<div>
|
||||
<label class="form-label">Teléfono principal</label>
|
||||
{#if isEditing}
|
||||
<input type="tel" class="form-input" bind:value={form.main_phone} />
|
||||
{:else}
|
||||
<p class="text-sm text-gray-900 mt-1">{val('main_phone') || '—'}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label">Email principal</label>
|
||||
{#if isEditing}
|
||||
<input type="email" class="form-input" bind:value={form.main_email} />
|
||||
{:else}
|
||||
<p class="text-sm text-gray-900 mt-1">{val('main_email') || '—'}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label">Horario de atención</label>
|
||||
{#if isEditing}
|
||||
<input type="text" class="form-input" bind:value={form.business_hours} placeholder="Lun-Vie 9:00-18:00" />
|
||||
{:else}
|
||||
<p class="text-sm text-gray-900 mt-1">{val('business_hours') || '—'}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notas -->
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-200 px-6 py-4">
|
||||
<h2 class="text-base font-semibold text-gray-900">Notas internas</h2>
|
||||
</div>
|
||||
<div class="px-6 py-5">
|
||||
{#if isEditing}
|
||||
<textarea
|
||||
class="form-input resize-none"
|
||||
rows="4"
|
||||
bind:value={form.notes}
|
||||
placeholder="Información adicional sobre la organización..."
|
||||
></textarea>
|
||||
{:else}
|
||||
{#if val('notes')}
|
||||
<p class="text-sm text-gray-900 whitespace-pre-wrap">{val('notes')}</p>
|
||||
{:else}
|
||||
<p class="text-sm text-gray-400">Sin notas</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Acciones -->
|
||||
{#if isEditing}
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary px-5 py-2"
|
||||
on:click={cancelEdit}
|
||||
disabled={isSaving}
|
||||
>Cancelar</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary px-5 py-2"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? 'Guardando...' : 'Guardar cambios'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -19,6 +19,86 @@
|
||||
// Tabs management
|
||||
let activeTab = 'personal';
|
||||
|
||||
// 2FA management
|
||||
let is2faLoading = false;
|
||||
let show2faSetup = false;
|
||||
let qrUri = '';
|
||||
let totpSetupCode = '';
|
||||
let backupCodes: string[] = [];
|
||||
let showBackupCodes = false;
|
||||
let show2faDisable = false;
|
||||
let disableTotpCode = '';
|
||||
|
||||
async function setup2fa() {
|
||||
is2faLoading = true;
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/2fa/setup', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${$auth.token}` }
|
||||
});
|
||||
if (!response.ok) throw new Error((await response.json()).detail);
|
||||
const data = await response.json();
|
||||
qrUri = data.qr_uri;
|
||||
show2faSetup = true;
|
||||
totpSetupCode = '';
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || 'Error al iniciar configuración de 2FA');
|
||||
} finally {
|
||||
is2faLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function enable2fa() {
|
||||
if (!totpSetupCode || totpSetupCode.length !== 6) {
|
||||
toast.error('Ingresa el código de 6 dígitos de tu app autenticadora');
|
||||
return;
|
||||
}
|
||||
is2faLoading = true;
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/2fa/enable', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${$auth.token}` },
|
||||
body: JSON.stringify({ totp_code: totpSetupCode })
|
||||
});
|
||||
if (!response.ok) throw new Error((await response.json()).detail);
|
||||
const data = await response.json();
|
||||
backupCodes = data.backup_codes;
|
||||
showBackupCodes = true;
|
||||
show2faSetup = false;
|
||||
// Actualizar estado en el store
|
||||
if ($auth.user) auth.updateUser({ ...$auth.user, is_two_factor_enabled: true });
|
||||
toast.success('¡2FA activado correctamente!');
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || 'Código inválido. Verifica tu app autenticadora.');
|
||||
} finally {
|
||||
is2faLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function disable2fa() {
|
||||
if (!disableTotpCode || disableTotpCode.length < 6) {
|
||||
toast.error('Ingresa el código de 6 dígitos para confirmar');
|
||||
return;
|
||||
}
|
||||
is2faLoading = true;
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/2fa/disable', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${$auth.token}` },
|
||||
body: JSON.stringify({ totp_code: disableTotpCode })
|
||||
});
|
||||
if (!response.ok) throw new Error((await response.json()).detail);
|
||||
show2faDisable = false;
|
||||
disableTotpCode = '';
|
||||
if ($auth.user) auth.updateUser({ ...$auth.user, is_two_factor_enabled: false });
|
||||
toast.success('2FA deshabilitado correctamente');
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || 'Código inválido');
|
||||
} finally {
|
||||
is2faLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Business profile data
|
||||
let businessProfile = {
|
||||
business_name: '',
|
||||
@@ -306,13 +386,11 @@
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Mi Perfil</h1>
|
||||
<p class="text-gray-600 mt-2">Gestiona tu información personal y configuración empresarial</p>
|
||||
</div>
|
||||
|
||||
<!-- Tabs Navigation -->
|
||||
<div class="border-b border-gray-200 mb-8">
|
||||
<nav class="-mb-px flex space-x-8">
|
||||
<button
|
||||
@@ -367,9 +445,7 @@
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="space-y-8">
|
||||
<!-- Personal Information Tab -->
|
||||
{#if activeTab === 'personal'}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
@@ -450,7 +526,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- General Business Information Tab -->
|
||||
{#if activeTab === 'general'}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
@@ -460,7 +535,6 @@
|
||||
|
||||
<div class="card-content">
|
||||
<form on:submit|preventDefault={handleBusinessProfileSave} class="space-y-6">
|
||||
<!-- Información General -->
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<h3 class="font-medium text-gray-900 mb-4">Información General</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
@@ -538,7 +612,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ubicación -->
|
||||
<div class="bg-white p-4 rounded-lg border border-gray-200">
|
||||
<h3 class="font-medium text-gray-900 mb-4">Ubicación</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
@@ -623,7 +696,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Representantes -->
|
||||
<div class="bg-white p-4 rounded-lg border border-gray-200">
|
||||
<h3 class="font-medium text-gray-900 mb-4">Representantes</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
@@ -653,7 +725,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuración -->
|
||||
<div class="bg-white p-4 rounded-lg border border-gray-200">
|
||||
<h3 class="font-medium text-gray-900 mb-4">Configuración</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
@@ -724,7 +795,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Contact Information Tab -->
|
||||
{#if activeTab === 'contact'}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
@@ -734,7 +804,6 @@
|
||||
|
||||
<div class="card-content">
|
||||
<form on:submit|preventDefault={handleBusinessProfileSave} class="space-y-6">
|
||||
<!-- Teléfonos -->
|
||||
<div class="bg-white p-4 rounded-lg border border-gray-200">
|
||||
<h3 class="font-medium text-gray-900 mb-4">Teléfonos</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
@@ -791,7 +860,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Emails -->
|
||||
<div class="bg-white p-4 rounded-lg border border-gray-200">
|
||||
<h3 class="font-medium text-gray-900 mb-4">Correos Electrónicos</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
@@ -823,7 +891,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Web y Horarios -->
|
||||
<div class="bg-white p-4 rounded-lg border border-gray-200">
|
||||
<h3 class="font-medium text-gray-900 mb-4">Web y Horarios</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
@@ -880,7 +947,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Security Tab -->
|
||||
{#if activeTab === 'security'}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
@@ -889,50 +955,123 @@
|
||||
</div>
|
||||
|
||||
<div class="card-content space-y-6">
|
||||
<!-- Two-Factor Authentication Status -->
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-900">Autenticación de dos factores (2FA)</h3>
|
||||
<p class="text-sm text-gray-600">
|
||||
{$auth.user?.is_two_factor_enabled
|
||||
? 'La autenticación de dos factores está habilitada'
|
||||
: 'Mejora la seguridad habilitando 2FA'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
{#if $auth.user?.is_two_factor_enabled}
|
||||
<span
|
||||
class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Habilitado
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-1" 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>
|
||||
Deshabilitado
|
||||
</span>
|
||||
{/if}
|
||||
<div class="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50">
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-900">Autenticación de dos factores (2FA)</h3>
|
||||
<p class="text-sm text-gray-600 mt-0.5">
|
||||
{$auth.user?.is_two_factor_enabled
|
||||
? 'Tu cuenta está protegida con autenticación de dos factores'
|
||||
: 'Añade una capa extra de seguridad a tu cuenta'}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
{#if $auth.user?.is_two_factor_enabled}
|
||||
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
<svg class="w-3.5 h-3.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||
Habilitado
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm text-red-600 hover:text-red-800 font-medium"
|
||||
on:click={() => { show2faDisable = !show2faDisable; disableTotpCode = ''; }}
|
||||
disabled={is2faLoading}
|
||||
>Deshabilitar</button>
|
||||
{:else}
|
||||
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
|
||||
<svg class="w-3.5 h-3.5 mr-1" 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>
|
||||
Deshabilitado
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm bg-blue-600 text-white px-3 py-1.5 rounded font-medium hover:bg-blue-700 disabled:opacity-50"
|
||||
on:click={setup2fa}
|
||||
disabled={is2faLoading}
|
||||
>
|
||||
{is2faLoading ? 'Cargando...' : 'Habilitar 2FA'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if show2faSetup && qrUri}
|
||||
<div class="p-5 border-t border-gray-200 space-y-4">
|
||||
<p class="text-sm font-medium text-gray-700">1. Escanea este código QR en Google Authenticator, Authy o cualquier app TOTP:</p>
|
||||
<div class="flex justify-center bg-white p-4 border border-gray-200 rounded">
|
||||
<img src="https://api.qrserver.com/v1/create-qr-code/?size=180x180&data={encodeURIComponent(qrUri)}" alt="Código QR 2FA" class="w-44 h-44" />
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-700 mt-3">2. Ingresa el código de 6 dígitos para confirmar:</p>
|
||||
<div class="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
class="form-input w-40 text-center tracking-widest font-mono text-lg"
|
||||
placeholder="000000"
|
||||
maxlength="6"
|
||||
bind:value={totpSetupCode}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="bg-green-600 text-white px-4 py-2 rounded font-medium hover:bg-green-700 disabled:opacity-50"
|
||||
on:click={enable2fa}
|
||||
disabled={is2faLoading}
|
||||
>
|
||||
{is2faLoading ? 'Verificando...' : 'Confirmar y activar'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-500 hover:text-gray-700 text-sm font-medium"
|
||||
on:click={() => { show2faSetup = false; }}
|
||||
>Cancelar</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showBackupCodes && backupCodes.length > 0}
|
||||
<div class="p-5 border-t border-green-200 bg-green-50">
|
||||
<h4 class="font-medium text-green-900 mb-2">✅ 2FA activado — Guarda tus códigos de respaldo</h4>
|
||||
<p class="text-sm text-green-700 mb-3">Estos códigos son de un solo uso. Guárdalos en un lugar seguro.</p>
|
||||
<div class="grid grid-cols-2 gap-2 font-mono text-sm">
|
||||
{#each backupCodes as code}
|
||||
<span class="bg-white border border-green-200 px-3 py-1.5 rounded text-center">{code}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-4 text-sm text-green-700 underline"
|
||||
on:click={() => { showBackupCodes = false; backupCodes = []; }}
|
||||
>He guardado mis códigos</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if show2faDisable}
|
||||
<div class="p-5 border-t border-red-200 bg-red-50">
|
||||
<p class="text-sm font-medium text-red-800 mb-3">Ingresa el código de tu app autenticadora para deshabilitar 2FA:</p>
|
||||
<div class="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
class="form-input w-40 text-center tracking-widest font-mono text-lg border-red-300"
|
||||
placeholder="000000"
|
||||
maxlength="6"
|
||||
bind:value={disableTotpCode}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="bg-red-600 text-white px-4 py-2 rounded font-medium hover:bg-red-700 disabled:opacity-50"
|
||||
on:click={disable2fa}
|
||||
disabled={is2faLoading}
|
||||
>
|
||||
{is2faLoading ? 'Verificando...' : 'Confirmar y deshabilitar'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-500 hover:text-gray-700 text-sm"
|
||||
on:click={() => { show2faDisable = false; }}
|
||||
>Cancelar</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Change Password Form -->
|
||||
<form on:submit|preventDefault={handlePasswordChange} class="space-y-6">
|
||||
<h3 class="text-lg font-medium text-gray-900">Cambiar Contraseña</h3>
|
||||
|
||||
@@ -1005,7 +1144,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Account Information Tab -->
|
||||
{#if activeTab === 'account'}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
|
||||
206
frontend-client/src/routes/reset-password/+page.svelte
Normal file
206
frontend-client/src/routes/reset-password/+page.svelte
Normal file
@@ -0,0 +1,206 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
|
||||
let token = '';
|
||||
let newPassword = '';
|
||||
let confirmPassword = '';
|
||||
let showPassword = false;
|
||||
let isLoading = false;
|
||||
let errorMessage = '';
|
||||
let success = false;
|
||||
|
||||
onMount(() => {
|
||||
if ($auth.isAuthenticated) { goto('/'); return; }
|
||||
token = $page.url.searchParams.get('token') ?? '';
|
||||
if (!token) {
|
||||
errorMessage = 'El enlace es inválido. Asegúrate de usar el enlace completo del correo.';
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
errorMessage = '';
|
||||
|
||||
if (!newPassword || !confirmPassword) {
|
||||
errorMessage = 'Completa todos los campos';
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
errorMessage = 'La contraseña debe tener al menos 8 caracteres';
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
errorMessage = 'Las contraseñas no coinciden';
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/reset-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, new_password: newPassword })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Error al restablecer la contraseña');
|
||||
}
|
||||
|
||||
success = true;
|
||||
// Redirigir al login después de 3 segundos
|
||||
setTimeout(() => goto('/login'), 3000);
|
||||
} catch (e: any) {
|
||||
errorMessage = e.message;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Nueva contraseña - ServiceManager</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div class="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<!-- Logo -->
|
||||
<div class="flex justify-center mb-6">
|
||||
<a href="/login" class="flex items-center space-x-2">
|
||||
<div class="w-10 h-10 bg-blue-700 rounded-lg flex items-center justify-center">
|
||||
<Icon name="ticket" class="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<span class="text-xl font-bold text-gray-900">ServiceManager</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-white py-10 px-8 shadow-sm rounded-xl border border-gray-200">
|
||||
{#if success}
|
||||
<!-- Estado de éxito -->
|
||||
<div class="text-center space-y-4">
|
||||
<div class="w-14 h-14 bg-green-100 rounded-full flex items-center justify-center mx-auto">
|
||||
<Icon name="check-circle" class="w-7 h-7 text-green-600" />
|
||||
</div>
|
||||
<h2 class="text-xl font-bold text-gray-900">¡Contraseña actualizada!</h2>
|
||||
<p class="text-sm text-gray-600">
|
||||
Tu contraseña ha sido restablecida correctamente.
|
||||
Serás redirigido al inicio de sesión en unos segundos.
|
||||
</p>
|
||||
<a
|
||||
href="/login"
|
||||
class="inline-block mt-4 py-2.5 px-6 text-sm font-bold text-white bg-blue-700 rounded-lg hover:bg-blue-800 transition-colors"
|
||||
>
|
||||
Ir al inicio de sesión
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Formulario -->
|
||||
<div class="space-y-6">
|
||||
<div class="text-center space-y-1">
|
||||
<h2 class="text-2xl font-bold text-gray-900">Nueva contraseña</h2>
|
||||
<p class="text-sm text-gray-500">
|
||||
Crea una contraseña segura para tu cuenta.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if errorMessage}
|
||||
<div class="p-3 rounded-lg bg-red-50 border border-red-100 flex items-start gap-2 text-sm text-red-600">
|
||||
<Icon name="alert-circle" class="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form on:submit|preventDefault={handleSubmit} class="space-y-5">
|
||||
<div>
|
||||
<label for="new-password" class="block text-sm font-semibold text-gray-700 mb-1.5">
|
||||
Nueva contraseña
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Icon name="lock" class="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="new-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
class="block w-full pl-10 pr-10 py-3 border border-gray-300 rounded-lg text-sm text-gray-900 focus:ring-2 focus:ring-blue-600 focus:border-transparent outline-none transition-all"
|
||||
placeholder="Mínimo 8 caracteres"
|
||||
bind:value={newPassword}
|
||||
disabled={isLoading || !token}
|
||||
minlength="8"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600"
|
||||
on:click={() => (showPassword = !showPassword)}
|
||||
tabindex="-1"
|
||||
>
|
||||
<Icon name={showPassword ? 'eye-off' : 'eye'} class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="confirm-password" class="block text-sm font-semibold text-gray-700 mb-1.5">
|
||||
Confirmar contraseña
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Icon name="lock" class="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="confirm-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
class="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-lg text-sm text-gray-900 focus:ring-2 focus:ring-blue-600 focus:border-transparent outline-none transition-all
|
||||
{confirmPassword && confirmPassword !== newPassword ? 'border-red-400 focus:ring-red-400' : ''}
|
||||
{confirmPassword && confirmPassword === newPassword ? 'border-green-400' : ''}"
|
||||
placeholder="Repite la contraseña"
|
||||
bind:value={confirmPassword}
|
||||
disabled={isLoading || !token}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{#if confirmPassword && confirmPassword !== newPassword}
|
||||
<p class="mt-1 text-xs text-red-500">Las contraseñas no coinciden</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Password strength hint -->
|
||||
<div class="bg-gray-50 rounded-lg px-4 py-3 text-xs text-gray-500 space-y-1">
|
||||
<p class="font-medium text-gray-600">Requisitos:</p>
|
||||
<p class:text-green-600={newPassword.length >= 8} class:text-gray-400={newPassword.length < 8}>
|
||||
✓ Mínimo 8 caracteres
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full flex justify-center items-center gap-2 py-3.5 px-4 text-sm font-bold text-white bg-blue-700 rounded-lg hover:bg-blue-800 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||
disabled={isLoading || !token}
|
||||
>
|
||||
{#if isLoading}
|
||||
<Icon name="loader-2" class="w-4 h-4 animate-spin" />
|
||||
Guardando...
|
||||
{:else}
|
||||
Establecer nueva contraseña
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="text-center pt-2">
|
||||
<a href="/login" class="text-sm text-blue-600 hover:text-blue-500 font-medium">
|
||||
← Volver al inicio de sesión
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="mt-6 text-center text-xs text-gray-400">
|
||||
© 2026 Aduanasoft. Acceso exclusivo autorizado.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,7 +120,7 @@
|
||||
<div class="ml-3">
|
||||
<p class="text-sm font-medium text-gray-500">Esperando</p>
|
||||
<p class="text-2xl font-semibold text-gray-900">
|
||||
{statusCounts['WAITING_FOR_CLIENT'] || 0}
|
||||
{statusCounts['WAITING_CUSTOMER'] || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -171,7 +171,7 @@
|
||||
<option value="">Todos los estados</option>
|
||||
<option value="NEW">Nuevo</option>
|
||||
<option value="IN_PROGRESS">En Progreso</option>
|
||||
<option value="WAITING_FOR_CLIENT">Esperando Cliente</option>
|
||||
<option value="WAITING_CUSTOMER">Esperando Cliente</option>
|
||||
<option value="RESOLVED">Resuelto</option>
|
||||
<option value="CLOSED">Cerrado</option>
|
||||
<option value="REOPENED">Reabierto</option>
|
||||
|
||||
@@ -63,22 +63,25 @@
|
||||
}
|
||||
|
||||
// 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_FOR_CLIENT: { label: 'Esperando Cliente', class: 'badge-waiting' },
|
||||
WAITING_CUSTOMER: { 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' };
|
||||
|
||||
async function handleAddComment() {
|
||||
if (!newComment.trim()) return;
|
||||
@@ -175,7 +178,7 @@
|
||||
// Check if user can close ticket
|
||||
$: canClose =
|
||||
$tickets.currentTicket &&
|
||||
['RESOLVED', 'WAITING_FOR_CLIENT'].includes($tickets.currentTicket.status);
|
||||
['RESOLVED', 'WAITING_CUSTOMER'].includes($tickets.currentTicket.status);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -230,11 +233,11 @@
|
||||
{$tickets.currentTicket.title}
|
||||
</h1>
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class={statusConfig[$tickets.currentTicket.status].class}>
|
||||
{statusConfig[$tickets.currentTicket.status].label}
|
||||
<span class={(statusConfig[$tickets.currentTicket.status] ?? fallbackStatus).class}>
|
||||
{(statusConfig[$tickets.currentTicket.status] ?? fallbackStatus).label}
|
||||
</span>
|
||||
<span class={priorityConfig[$tickets.currentTicket.priority].class}>
|
||||
{priorityConfig[$tickets.currentTicket.priority].label}
|
||||
<span class={(priorityConfig[$tickets.currentTicket.priority] ?? fallbackPriority).class}>
|
||||
{(priorityConfig[$tickets.currentTicket.priority] ?? fallbackPriority).label}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500">
|
||||
Creado {formatDate($tickets.currentTicket.created_at)}
|
||||
@@ -505,6 +508,45 @@
|
||||
<dd class="text-sm text-gray-900">{formatDate($tickets.currentTicket.updated_at)}</dd>
|
||||
</div>
|
||||
|
||||
<!-- SLA -->
|
||||
{#if $tickets.currentTicket.sla_response_due || $tickets.currentTicket.sla_resolution_due}
|
||||
<div class="pt-3 border-t border-gray-100">
|
||||
<dt class="text-sm font-medium text-gray-500 mb-2">Tiempos de SLA</dt>
|
||||
|
||||
{#if $tickets.currentTicket.sla_response_due}
|
||||
{@const respDue = new Date($tickets.currentTicket.sla_response_due)}
|
||||
{@const respVencido = respDue < new Date() && !$tickets.currentTicket.first_response_at}
|
||||
<div class="mb-2">
|
||||
<dt class="text-xs text-gray-400">Respuesta límite</dt>
|
||||
<dd class="text-sm {respVencido ? 'text-red-600 font-medium' : 'text-gray-900'}">
|
||||
{formatDate($tickets.currentTicket.sla_response_due)}
|
||||
{#if respVencido}
|
||||
<span class="block text-xs text-red-500">¡Vencido!</span>
|
||||
{:else if $tickets.currentTicket.first_response_at}
|
||||
<span class="block text-xs text-green-600">✓ Respondido</span>
|
||||
{/if}
|
||||
</dd>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $tickets.currentTicket.sla_resolution_due}
|
||||
{@const resDue = new Date($tickets.currentTicket.sla_resolution_due)}
|
||||
{@const resVencido = resDue < new Date() && !$tickets.currentTicket.resolved_at}
|
||||
<div>
|
||||
<dt class="text-xs text-gray-400">Resolución límite</dt>
|
||||
<dd class="text-sm {resVencido ? 'text-red-600 font-medium' : 'text-gray-900'}">
|
||||
{formatDate($tickets.currentTicket.sla_resolution_due)}
|
||||
{#if resVencido}
|
||||
<span class="block text-xs text-red-500">¡Vencido!</span>
|
||||
{:else if $tickets.currentTicket.resolved_at}
|
||||
<span class="block text-xs text-green-600">✓ Resuelto</span>
|
||||
{/if}
|
||||
</dd>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $tickets.currentTicket.due_date}
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Fecha límite</dt>
|
||||
|
||||
@@ -6,9 +6,23 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0',
|
||||
watch: {
|
||||
usePolling: true,
|
||||
interval: 500
|
||||
},
|
||||
// HMR: el browser llega al contenedor en el mismo puerto 3000
|
||||
hmr: {
|
||||
host: 'localhost',
|
||||
clientPort: parseInt(process.env.HMR_CLIENT_PORT || '3000')
|
||||
},
|
||||
// Permitir que Vite sirva archivos del filesystem del contenedor
|
||||
fs: {
|
||||
allow: ['/app', '.'],
|
||||
strict: false
|
||||
},
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.PUBLIC_API_URL || 'http://backend:8000',
|
||||
target: process.env.PUBLIC_API_URL || 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, '')
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "@servicemanager/internal-frontend",
|
||||
"version": "1.6.0",
|
||||
"version": "1.9.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 3000 --host 0.0.0.0",
|
||||
"dev": "vite dev --host 0.0.0.0",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 3000 --host 0.0.0.0",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
);
|
||||
}
|
||||
|
||||
if (role === 'ADMIN') {
|
||||
if (role === 'ADMIN' || role === 'SUPPORT_MANAGER') {
|
||||
baseNavigation.push(
|
||||
{
|
||||
name: 'SLA Management',
|
||||
@@ -57,7 +57,12 @@
|
||||
name: 'Reportes',
|
||||
href: '/reports',
|
||||
icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z'
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (role === 'ADMIN') {
|
||||
baseNavigation.push(
|
||||
{
|
||||
name: 'Auditoría',
|
||||
href: '/audit',
|
||||
@@ -67,6 +72,11 @@
|
||||
name: 'Seguridad',
|
||||
href: '/audit/security',
|
||||
icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z'
|
||||
},
|
||||
{
|
||||
name: 'Reporte Endpoints',
|
||||
href: '/test-report',
|
||||
icon: 'M9 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v18m0 0h10a2 2 0 002-2V9M9 21H5a2 2 0 01-2-2V9m0 0h18'
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -160,14 +170,17 @@
|
||||
|
||||
<!-- User info -->
|
||||
<div class="px-4 py-4 border-t border-gray-200">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center">
|
||||
<a
|
||||
href="/profile"
|
||||
class="flex items-center space-x-3 rounded-lg p-1 -m-1 hover:bg-gray-100 transition-colors group"
|
||||
>
|
||||
<div class="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center shrink-0">
|
||||
<span class="text-primary-600 text-sm font-medium">
|
||||
{$auth.user?.first_name?.[0]}{$auth.user?.last_name?.[0]}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">
|
||||
<p class="text-sm font-medium text-gray-900 truncate group-hover:text-primary-600">
|
||||
{$auth.user?.first_name}
|
||||
{$auth.user?.last_name}
|
||||
</p>
|
||||
@@ -181,12 +194,20 @@
|
||||
: 'Auditor'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
class="w-4 h-4 text-gray-400 group-hover:text-primary-500 shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Version info -->
|
||||
<div class="px-4 py-2 border-t border-gray-100">
|
||||
<p class="text-xs text-gray-400 text-center">v1.6.0</p>
|
||||
<p class="text-xs text-gray-400 text-center">v1.9.0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
161
frontend-internal/src/lib/stores/dashboardConfig.ts
Normal file
161
frontend-internal/src/lib/stores/dashboardConfig.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
/** All available dashboard modules */
|
||||
export interface DashboardModule {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
href: string;
|
||||
color: string;
|
||||
/** Minimum role required to see this module */
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
export const ALL_MODULES: DashboardModule[] = [
|
||||
{
|
||||
id: 'tenants',
|
||||
title: 'Clientes',
|
||||
description: 'Gestión de organizaciones y tenants',
|
||||
icon: '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',
|
||||
href: '/tenants',
|
||||
color: 'bg-blue-600',
|
||||
roles: ['ADMIN', 'SUPPORT_MANAGER']
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
title: 'Usuarios',
|
||||
description: 'Administración de usuarios y roles',
|
||||
icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z',
|
||||
href: '/users',
|
||||
color: 'bg-green-600',
|
||||
roles: ['ADMIN', 'SUPPORT_MANAGER']
|
||||
},
|
||||
{
|
||||
id: 'tickets',
|
||||
title: 'Tickets',
|
||||
description: 'Gestión y seguimiento de tickets de soporte',
|
||||
icon: 'M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2',
|
||||
href: '/tickets',
|
||||
color: 'bg-indigo-600',
|
||||
roles: ['ADMIN', 'SUPPORT_MANAGER', 'AGENT']
|
||||
},
|
||||
{
|
||||
id: 'systems',
|
||||
title: 'Sistemas',
|
||||
description: 'Catálogo de sistemas soportados',
|
||||
icon: 'M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01',
|
||||
href: '/systems',
|
||||
color: 'bg-gray-700',
|
||||
roles: ['ADMIN', 'SUPPORT_MANAGER']
|
||||
},
|
||||
{
|
||||
id: 'categories',
|
||||
title: 'Categorías',
|
||||
description: 'Clasificación de tickets por área',
|
||||
icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10',
|
||||
href: '/categories',
|
||||
color: 'bg-orange-600',
|
||||
roles: ['ADMIN', 'SUPPORT_MANAGER']
|
||||
},
|
||||
{
|
||||
id: 'sla',
|
||||
title: 'SLA Management',
|
||||
description: 'Monitoreo de tiempos de respuesta y SLAs',
|
||||
icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
href: '/sla',
|
||||
color: 'bg-teal-600',
|
||||
roles: ['ADMIN', 'SUPPORT_MANAGER']
|
||||
},
|
||||
{
|
||||
id: 'reports',
|
||||
title: 'Reportes',
|
||||
description: 'Informes estadísticos y análisis de rendimiento',
|
||||
icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z',
|
||||
href: '/reports',
|
||||
color: 'bg-purple-600',
|
||||
roles: ['ADMIN', 'SUPPORT_MANAGER']
|
||||
},
|
||||
{
|
||||
id: 'audit',
|
||||
title: 'Auditoría',
|
||||
description: 'Bitácora de acciones y trazabilidad del sistema',
|
||||
icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
|
||||
href: '/audit',
|
||||
color: 'bg-red-700',
|
||||
roles: ['ADMIN', 'AUDITOR']
|
||||
},
|
||||
{
|
||||
id: 'security',
|
||||
title: 'Seguridad',
|
||||
description: 'Análisis de amenazas y eventos de seguridad',
|
||||
icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z',
|
||||
href: '/audit/security',
|
||||
color: 'bg-yellow-600',
|
||||
roles: ['ADMIN']
|
||||
},
|
||||
{
|
||||
id: 'endpoints',
|
||||
title: 'Reporte de Endpoints',
|
||||
description: 'Estado y diagnóstico de todos los endpoints API',
|
||||
icon: 'M9 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v18m0 0h10a2 2 0 002-2V9M9 21H5a2 2 0 01-2-2V9m0 0h18',
|
||||
href: '/test-report',
|
||||
color: 'bg-cyan-600',
|
||||
roles: ['ADMIN']
|
||||
}
|
||||
];
|
||||
|
||||
const STORAGE_KEY = 'dashboard_module_visibility';
|
||||
|
||||
function getInitialVisibility(): Record<string, boolean> {
|
||||
if (typeof window === 'undefined') {
|
||||
return Object.fromEntries(ALL_MODULES.map(m => [m.id, true]));
|
||||
}
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) return JSON.parse(stored);
|
||||
} catch { /* ignore */ }
|
||||
return Object.fromEntries(ALL_MODULES.map(m => [m.id, true]));
|
||||
}
|
||||
|
||||
function createDashboardConfig() {
|
||||
const { subscribe, set, update } = writable<Record<string, boolean>>(getInitialVisibility());
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
toggle(id: string) {
|
||||
update(state => {
|
||||
const next = { ...state, [id]: !state[id] };
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
setVisible(id: string, visible: boolean) {
|
||||
update(state => {
|
||||
const next = { ...state, [id]: visible };
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
showAll() {
|
||||
const all = Object.fromEntries(ALL_MODULES.map(m => [m.id, true]));
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
|
||||
}
|
||||
set(all);
|
||||
},
|
||||
reset() {
|
||||
const defaults = Object.fromEntries(ALL_MODULES.map(m => [m.id, true]));
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(defaults));
|
||||
}
|
||||
set(defaults);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const dashboardConfig = createDashboardConfig();
|
||||
@@ -26,10 +26,22 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
|
||||
const authState = get(auth);
|
||||
const token = authState.token || (typeof window !== 'undefined' ? localStorage.getItem('internal_auth_token') : null);
|
||||
|
||||
// Resolve tenant_id from store or from the persisted user object in localStorage
|
||||
let tenantId = authState.user?.tenant_id ?? null;
|
||||
if (!tenantId && typeof window !== 'undefined') {
|
||||
try {
|
||||
const stored = localStorage.getItem('internal_auth_user');
|
||||
if (stored) tenantId = JSON.parse(stored)?.tenant_id ?? null;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const headers = new Headers(init.headers);
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
if (tenantId && !headers.has('X-Tenant-ID')) {
|
||||
headers.set('X-Tenant-ID', tenantId);
|
||||
}
|
||||
if (!headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
@@ -66,10 +78,21 @@ async function downloadFile(endpoint: string, filename: string): Promise<void> {
|
||||
const authState = get(auth);
|
||||
const token = authState.token || (typeof window !== 'undefined' ? localStorage.getItem('internal_auth_token') : null);
|
||||
|
||||
let tenantId = authState.user?.tenant_id ?? null;
|
||||
if (!tenantId && typeof window !== 'undefined') {
|
||||
try {
|
||||
const stored = localStorage.getItem('internal_auth_user');
|
||||
if (stored) tenantId = JSON.parse(stored)?.tenant_id ?? null;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const headers = new Headers();
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
if (tenantId) {
|
||||
headers.set('X-Tenant-ID', tenantId);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
method: 'GET',
|
||||
|
||||
73
frontend-internal/src/lib/utils/colorUtils.ts
Normal file
73
frontend-internal/src/lib/utils/colorUtils.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// Utilidades de colores para diferentes estados y severidades
|
||||
type ColorType = 'severity' | 'status' | 'action' | 'priority';
|
||||
|
||||
const COLOR_MAPS = {
|
||||
severity: {
|
||||
'critical': 'bg-red-600 text-white',
|
||||
'high': 'bg-orange-600 text-white',
|
||||
'medium': 'bg-yellow-500 text-white',
|
||||
'low': 'bg-blue-600 text-white'
|
||||
},
|
||||
status: {
|
||||
'active': 'bg-blue-600 text-white',
|
||||
'open': 'bg-blue-600 text-white',
|
||||
'resolved': 'bg-green-600 text-white',
|
||||
'closed': 'bg-green-600 text-white',
|
||||
'investigating': 'bg-yellow-500 text-white',
|
||||
'new': 'bg-blue-500 text-white',
|
||||
'in_progress': 'bg-purple-600 text-white',
|
||||
'waiting_customer': 'bg-orange-500 text-white',
|
||||
'reopened': 'bg-red-500 text-white'
|
||||
},
|
||||
action: {
|
||||
'delete': 'bg-red-600 text-white',
|
||||
'update': 'bg-blue-600 text-white',
|
||||
'login': 'bg-indigo-600 text-white',
|
||||
'logout': 'bg-indigo-600 text-white',
|
||||
'create': 'bg-green-600 text-white',
|
||||
'failed': 'bg-red-500 text-white'
|
||||
},
|
||||
priority: {
|
||||
'urgent': 'bg-red-600 text-white',
|
||||
'high': 'bg-orange-500 text-white',
|
||||
'medium': 'bg-yellow-500 text-white',
|
||||
'low': 'bg-blue-500 text-white'
|
||||
}
|
||||
};
|
||||
|
||||
export function getColorClass(value: string, type: ColorType = 'status'): string {
|
||||
const map = COLOR_MAPS[type];
|
||||
const key = value?.toLowerCase();
|
||||
|
||||
if (type === 'action') {
|
||||
const matchKey = Object.keys(map).find(k => key?.includes(k));
|
||||
return map[matchKey as keyof typeof map] || 'bg-gray-600 text-white';
|
||||
}
|
||||
|
||||
return map[key as keyof typeof map] || 'bg-gray-600 text-white';
|
||||
}
|
||||
|
||||
export function getStatusIcon(status: string): string {
|
||||
const icons = {
|
||||
'active': '🔴',
|
||||
'open': '📂',
|
||||
'resolved': '✅',
|
||||
'closed': '🔒',
|
||||
'investigating': '🔍',
|
||||
'new': '🆕',
|
||||
'in_progress': '⚙️',
|
||||
'waiting_customer': '⏳',
|
||||
'reopened': '🔄'
|
||||
};
|
||||
return icons[status?.toLowerCase() as keyof typeof icons] || '📋';
|
||||
}
|
||||
|
||||
export function getSeverityIcon(severity: string): string {
|
||||
const icons = {
|
||||
'critical': '🚨',
|
||||
'high': '⚠️',
|
||||
'medium': '⚡',
|
||||
'low': 'ℹ️'
|
||||
};
|
||||
return icons[severity?.toLowerCase() as keyof typeof icons] || '📊';
|
||||
}
|
||||
77
frontend-internal/src/lib/utils/dateFormats.ts
Normal file
77
frontend-internal/src/lib/utils/dateFormats.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
// Utilidades de formato de fecha
|
||||
export type DateFormat = 'full' | 'short' | 'simple' | 'time';
|
||||
|
||||
export function formatDate(dateString: string, format: DateFormat = 'full'): string {
|
||||
const date = new Date(dateString);
|
||||
const today = new Date();
|
||||
const isToday = date.toDateString() === today.toDateString();
|
||||
|
||||
const formats = {
|
||||
full: () => date.toLocaleString('es-MX', {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
}),
|
||||
short: () => isToday
|
||||
? date.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' })
|
||||
: date.toLocaleDateString('es-MX', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }),
|
||||
simple: () => date.toLocaleDateString('es-MX', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
}),
|
||||
time: () => date.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' })
|
||||
};
|
||||
|
||||
return formats[format]();
|
||||
}
|
||||
|
||||
export function getRelativeTime(dateString: string): string {
|
||||
const now = new Date().getTime();
|
||||
const then = new Date(dateString).getTime();
|
||||
const diffMs = now - then;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'Hace un momento';
|
||||
if (diffMins < 60) return `Hace ${diffMins} minuto${diffMins > 1 ? 's' : ''}`;
|
||||
if (diffHours < 24) return `Hace ${diffHours} hora${diffHours > 1 ? 's' : ''}`;
|
||||
if (diffDays < 7) return `Hace ${diffDays} día${diffDays > 1 ? 's' : ''}`;
|
||||
return formatDate(dateString, 'short');
|
||||
}
|
||||
|
||||
export function getDateRangeForPeriod(periodFilter: string, customDateFrom?: string, customDateTo?: string): { from: string; to: string } {
|
||||
const now = new Date();
|
||||
let from: Date;
|
||||
let to: Date = new Date();
|
||||
|
||||
switch (periodFilter) {
|
||||
case 'today':
|
||||
from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0));
|
||||
break;
|
||||
case 'yesterday':
|
||||
from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - 1, 0, 0, 0));
|
||||
to = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0));
|
||||
break;
|
||||
case 'last7days':
|
||||
from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - 7, 0, 0, 0));
|
||||
break;
|
||||
case 'last30days':
|
||||
from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - 30, 0, 0, 0));
|
||||
break;
|
||||
case 'custom':
|
||||
if (!customDateFrom || !customDateTo) {
|
||||
from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0));
|
||||
} else {
|
||||
from = new Date(customDateFrom + 'T00:00:00Z');
|
||||
to = new Date(customDateTo + 'T23:59:59Z');
|
||||
}
|
||||
break;
|
||||
default:
|
||||
from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0));
|
||||
}
|
||||
|
||||
return {
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString()
|
||||
};
|
||||
}
|
||||
@@ -5,14 +5,24 @@
|
||||
import { toast } from '$lib/stores/toast.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { browser } from '$app/environment';
|
||||
import '../app.css';
|
||||
|
||||
let sidebarOpen = false;
|
||||
let mounted = false;
|
||||
|
||||
onMount(() => {
|
||||
auth.init();
|
||||
mounted = true;
|
||||
});
|
||||
|
||||
// Guard reactivo global: redirige a /login si no está autenticado
|
||||
$: if (browser && mounted && !$auth.isAuthenticated && $page.url.pathname !== '/login') {
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarOpen = !sidebarOpen;
|
||||
}
|
||||
@@ -50,4 +60,4 @@
|
||||
on:dismiss={() => toast.dismiss(toastMessage.id)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,44 +2,27 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
|
||||
import { dashboardConfig, ALL_MODULES, type DashboardModule } from '$lib/stores/dashboardConfig.js';
|
||||
|
||||
let showSettings = false;
|
||||
|
||||
onMount(() => {
|
||||
if (!$auth.isAuthenticated) {
|
||||
goto('/login');
|
||||
}
|
||||
});
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: 'Clientes',
|
||||
description: 'Gestión de organizaciones y tenants',
|
||||
icon: 'users',
|
||||
href: '/tenants',
|
||||
color: 'bg-blue-500'
|
||||
},
|
||||
{
|
||||
title: 'Usuarios',
|
||||
description: 'Administración de usuarios y roles',
|
||||
icon: 'user-plus',
|
||||
href: '/users',
|
||||
color: 'bg-green-500'
|
||||
},
|
||||
{
|
||||
title: 'Sistemas',
|
||||
description: 'Catálogo de sistemas soportados',
|
||||
icon: 'server',
|
||||
href: '/systems',
|
||||
color: 'bg-purple-500'
|
||||
},
|
||||
{
|
||||
title: 'Categorías',
|
||||
description: 'Clasificación de tickets',
|
||||
icon: 'tag',
|
||||
href: '/categories',
|
||||
color: 'bg-orange-500'
|
||||
}
|
||||
];
|
||||
const role = $auth.user?.role ?? '';
|
||||
|
||||
/** Only modules the current role can access */
|
||||
$: accessibleModules = ALL_MODULES.filter(m => m.roles.includes(role) || role === 'ADMIN');
|
||||
|
||||
/** Modules that are visible (enabled by user + accessible by role) */
|
||||
$: visibleModules = accessibleModules.filter(m => $dashboardConfig[m.id] !== false);
|
||||
|
||||
function toggleSettings() {
|
||||
showSettings = !showSettings;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -47,52 +30,106 @@
|
||||
</svelte:head>
|
||||
|
||||
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
|
||||
<!-- Header -->
|
||||
<div class="md:flex md:items-center md:justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:text-3xl sm:truncate">
|
||||
Panel de Administración
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Bienvenido al sistema de gestión interna.
|
||||
Bienvenido al sistema de gestión interna.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4 flex md:mt-0 md:ml-4 gap-2">
|
||||
<button
|
||||
on:click={toggleSettings}
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Configurar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{#each cards as card}
|
||||
<a href={card.href} class="bg-white overflow-hidden shadow rounded-lg hover:shadow-md transition-shadow duration-200 cursor-pointer group">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="{card.color} rounded-md p-3">
|
||||
<!-- Simple SVG Icon placeholder since Icon component might expect specific names that map to SVGs -->
|
||||
<svg class="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
<!-- Settings Panel -->
|
||||
{#if showSettings}
|
||||
<div class="mt-6 bg-white border border-gray-200 rounded-lg shadow-sm p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-base font-semibold text-gray-900">Módulos visibles en el dashboard</h3>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
on:click={() => dashboardConfig.showAll()}
|
||||
class="text-xs text-blue-600 hover:text-blue-800 underline"
|
||||
>
|
||||
Mostrar todos
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{#each accessibleModules as mod}
|
||||
<label class="flex items-center gap-2 p-3 border rounded-lg cursor-pointer hover:bg-gray-50 {$dashboardConfig[mod.id] !== false ? 'border-blue-300 bg-blue-50' : 'border-gray-200'}">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={$dashboardConfig[mod.id] !== false}
|
||||
on:change={() => dashboardConfig.toggle(mod.id)}
|
||||
class="rounded text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="w-2 h-2 rounded-full {mod.color} flex-shrink-0"></span>
|
||||
<span class="text-sm font-medium text-gray-800 truncate">{mod.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">
|
||||
{card.title}
|
||||
</dt>
|
||||
<dd>
|
||||
<div class="text-xs text-gray-900 font-light mt-1">
|
||||
{card.description}
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-gray-400">Las preferencias se guardan automáticamente en este navegador.</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Module Cards -->
|
||||
{#if visibleModules.length === 0}
|
||||
<div class="mt-10 text-center py-16 bg-white rounded-lg border-2 border-dashed border-gray-200">
|
||||
<svg class="mx-auto h-10 w-10 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
<p class="mt-3 text-sm text-gray-500">No hay módulos visibles.</p>
|
||||
<button on:click={() => dashboardConfig.showAll()} class="mt-3 text-sm text-blue-600 hover:underline">
|
||||
Restaurar todos los módulos
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-8 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{#each visibleModules as mod}
|
||||
<a
|
||||
href={mod.href}
|
||||
class="bg-white overflow-hidden shadow rounded-lg hover:shadow-md transition-all duration-200 cursor-pointer group flex flex-col"
|
||||
>
|
||||
<div class="p-5 flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="w-9 h-9 rounded-lg {mod.color} flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={mod.icon} />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-800 group-hover:text-blue-700 transition-colors">
|
||||
{mod.title}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 leading-relaxed">{mod.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-5 py-3">
|
||||
<div class="text-sm">
|
||||
<span class="font-medium text-cyan-700 hover:text-cyan-900">
|
||||
Ver detalles
|
||||
<div class="bg-gray-50 px-5 py-2.5 border-t border-gray-100">
|
||||
<span class="text-xs font-medium text-blue-600 group-hover:text-blue-800 transition-colors">
|
||||
Abrir módulo →
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1350
frontend-internal/src/routes/audit/+page.svelte.tmp
Normal file
1350
frontend-internal/src/routes/audit/+page.svelte.tmp
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,19 +7,61 @@
|
||||
|
||||
// Estado
|
||||
let isLoading = false;
|
||||
let analysis = null;
|
||||
let analysis: any = null;
|
||||
let analysisHours = 24;
|
||||
let selectedThreat = null;
|
||||
let selectedThreat: any = null;
|
||||
let showActionModal = false;
|
||||
let actionType = '';
|
||||
let actionTarget = '';
|
||||
let actionReason = '';
|
||||
let actionDuration = 60;
|
||||
|
||||
// Filtros y búsqueda
|
||||
let activeTab: 'all' | 'critical' | 'high' | 'medium' | 'low' | 'resolved' = 'all';
|
||||
let searchQuery = '';
|
||||
let filterType = '';
|
||||
let showFilters = false;
|
||||
let selectedThreats = new Set<string>();
|
||||
|
||||
// Estado de amenazas resueltas (simulado - idealmente vendría del backend)
|
||||
let resolvedThreats = new Set<string>();
|
||||
|
||||
// Usuario actual
|
||||
$: currentUser = $auth.user;
|
||||
$: canExecuteActions = currentUser && (currentUser.role === 'ADMIN' || currentUser.role === 'SUPPORT_MANAGER');
|
||||
|
||||
// Amenazas filtradas
|
||||
$: filteredThreats = analysis?.threats?.filter((threat: any) => {
|
||||
const matchesTab =
|
||||
activeTab === 'all' ? !resolvedThreats.has(threat.id) :
|
||||
activeTab === 'resolved' ? resolvedThreats.has(threat.id) :
|
||||
(threat.severity === activeTab && !resolvedThreats.has(threat.id));
|
||||
|
||||
const matchesSearch = !searchQuery ||
|
||||
threat.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
threat.type.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
threat.affected_ips.some((ip: string) => ip.includes(searchQuery)) ||
|
||||
threat.affected_users.some((user: string) => user.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
|
||||
const matchesType = !filterType || threat.type === filterType;
|
||||
|
||||
return matchesTab && matchesSearch && matchesType;
|
||||
}) || [];
|
||||
|
||||
// Contadores por tab
|
||||
$: tabCounts = {
|
||||
all: analysis?.threats?.filter((t: any) => !resolvedThreats.has(t.id)).length || 0,
|
||||
critical: analysis?.threats?.filter((t: any) => t.severity === 'critical' && !resolvedThreats.has(t.id)).length || 0,
|
||||
high: analysis?.threats?.filter((t: any) => t.severity === 'high' && !resolvedThreats.has(t.id)).length || 0,
|
||||
medium: analysis?.threats?.filter((t: any) => t.severity === 'medium' && !resolvedThreats.has(t.id)).length || 0,
|
||||
low: analysis?.threats?.filter((t: any) => t.severity === 'low' && !resolvedThreats.has(t.id)).length || 0,
|
||||
resolved: resolvedThreats.size
|
||||
};
|
||||
|
||||
// Tipos únicos de amenazas
|
||||
$: threatTypes = analysis?.threats ?
|
||||
[...new Set(analysis.threats.map((t: any) => t.type))] : [];
|
||||
|
||||
/**
|
||||
* Cargar análisis de seguridad
|
||||
*/
|
||||
@@ -44,32 +86,77 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener color según nivel de riesgo
|
||||
* Obtener color según nivel de riesgo (neutral)
|
||||
*/
|
||||
function getRiskColor(level: string) {
|
||||
const colors: any = {
|
||||
safe: 'bg-green-100 text-green-800 border-green-300',
|
||||
low: 'bg-blue-100 text-blue-800 border-blue-300',
|
||||
medium: 'bg-yellow-100 text-yellow-800 border-yellow-300',
|
||||
high: 'bg-orange-100 text-orange-800 border-orange-300',
|
||||
critical: 'bg-red-100 text-red-800 border-red-300'
|
||||
safe: 'bg-gray-50 text-green-700 border border-green-200',
|
||||
low: 'bg-gray-50 text-blue-700 border border-blue-200',
|
||||
medium: 'bg-gray-50 text-yellow-800 border border-yellow-200',
|
||||
high: 'bg-gray-50 text-orange-700 border border-orange-200',
|
||||
critical: 'bg-gray-50 text-red-700 border border-red-200'
|
||||
};
|
||||
return colors[level] || colors.low;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener color de severidad de amenaza
|
||||
* Obtener color de severidad de amenaza (neutral)
|
||||
*/
|
||||
function getSeverityColor(severity: string) {
|
||||
const colors: any = {
|
||||
low: 'bg-blue-600 text-white',
|
||||
medium: 'bg-yellow-500 text-white',
|
||||
high: 'bg-orange-600 text-white',
|
||||
critical: 'bg-red-600 text-white'
|
||||
low: 'bg-gray-50 text-blue-700 border border-blue-200',
|
||||
medium: 'bg-gray-50 text-yellow-800 border border-yellow-200',
|
||||
high: 'bg-gray-50 text-orange-700 border border-orange-200',
|
||||
critical: 'bg-gray-50 text-red-700 border border-red-200'
|
||||
};
|
||||
return colors[severity] || colors.low;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marcar amenaza como resuelta
|
||||
*/
|
||||
function toggleThreatResolved(threatId: string) {
|
||||
if (resolvedThreats.has(threatId)) {
|
||||
resolvedThreats.delete(threatId);
|
||||
} else {
|
||||
resolvedThreats.add(threatId);
|
||||
}
|
||||
resolvedThreats = resolvedThreats; // Trigger reactivity
|
||||
toast.success(resolvedThreats.has(threatId) ? 'Amenaza marcada como resuelta' : 'Amenaza marcada como activa');
|
||||
}
|
||||
|
||||
/**
|
||||
* Seleccionar/deseleccionar amenaza
|
||||
*/
|
||||
function toggleThreatSelection(threatId: string) {
|
||||
if (selectedThreats.has(threatId)) {
|
||||
selectedThreats.delete(threatId);
|
||||
} else {
|
||||
selectedThreats.add(threatId);
|
||||
}
|
||||
selectedThreats = selectedThreats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolver amenazas en lote
|
||||
*/
|
||||
function resolveSelectedThreats() {
|
||||
selectedThreats.forEach(id => resolvedThreats.add(id));
|
||||
resolvedThreats = resolvedThreats;
|
||||
selectedThreats.clear();
|
||||
selectedThreats = selectedThreats;
|
||||
toast.success(`${resolvedThreats.size} amenazas resueltas`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpiar filtros
|
||||
*/
|
||||
function clearFilters() {
|
||||
searchQuery = '';
|
||||
filterType = '';
|
||||
activeTab = 'all';
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener icono de tipo de amenaza
|
||||
*/
|
||||
@@ -174,130 +261,140 @@
|
||||
</script>
|
||||
|
||||
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
<!-- Header -->
|
||||
<!-- Header con Breadcrumb -->
|
||||
<div class="mb-6">
|
||||
<nav class="flex mb-3" aria-label="Breadcrumb">
|
||||
<ol class="flex items-center space-x-2">
|
||||
<li>
|
||||
<a href="/audit" class="text-gray-500 hover:text-gray-700 text-sm">Auditoría</a>
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<span class="text-gray-400 mx-2">/</span>
|
||||
<span class="text-sm font-medium text-gray-900">Análisis de Seguridad</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<svg class="w-8 h-8 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
Análisis de Seguridad
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Detección de amenazas y análisis de vulnerabilidades
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Análisis de Seguridad</h1>
|
||||
<p class="text-sm text-gray-500">Detección de amenazas y gestión de incidentes</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
on:click={loadSecurityAnalysis}
|
||||
class="px-4 py-2 bg-blue-700 text-white rounded-lg text-sm font-medium hover:bg-blue-800 transition-colors"
|
||||
>
|
||||
Actualizar
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
on:click={() => loadSecurityAnalysis()}
|
||||
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 flex items-center gap-2"
|
||||
>
|
||||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Actualizar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selector de Período -->
|
||||
<div class="bg-white shadow rounded-lg p-4 mb-6">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-gray-700">Período de Análisis</span>
|
||||
<div class="bg-white shadow-sm rounded-lg p-4 mb-6 border border-gray-200">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-sm font-semibold text-gray-700">Período de Análisis</span>
|
||||
</div>
|
||||
{#if analysis}
|
||||
<span class="text-xs text-gray-500">
|
||||
Última actualización: {formatDate(analysis.generated_at)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
on:click={() => changeAnalysisPeriod(24)}
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 24 ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-indigo-50'}"
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 24 ? 'bg-blue-700 text-white' : 'bg-gray-100 text-gray-700 hover:bg-blue-50'}"
|
||||
>
|
||||
Últimas 24 horas
|
||||
Últimas 24h
|
||||
</button>
|
||||
<button
|
||||
on:click={() => changeAnalysisPeriod(48)}
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 48 ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-indigo-50'}"
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 48 ? 'bg-blue-700 text-white' : 'bg-gray-100 text-gray-700 hover:bg-blue-50'}"
|
||||
>
|
||||
Últimas 48 horas
|
||||
Últimas 48h
|
||||
</button>
|
||||
<button
|
||||
on:click={() => changeAnalysisPeriod(168)}
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 168 ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-indigo-50'}"
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 168 ? 'bg-blue-700 text-white' : 'bg-gray-100 text-gray-700 hover:bg-blue-50'}"
|
||||
>
|
||||
Última semana
|
||||
</button>
|
||||
<button
|
||||
on:click={() => changeAnalysisPeriod(720)}
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 720 ? 'bg-blue-700 text-white' : 'bg-gray-100 text-gray-700 hover:bg-blue-50'}"
|
||||
>
|
||||
Último mes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="flex justify-center items-center py-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-600"></div>
|
||||
<div class="flex justify-center items-center py-20">
|
||||
<div class="text-center">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-700 mx-auto mb-4"></div>
|
||||
<p class="text-sm text-gray-500">Analizando seguridad...</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if analysis}
|
||||
<!-- Resumen de Riesgo -->
|
||||
<div class="bg-white shadow rounded-lg p-6 mb-6 border-l-4 {getRiskColor(analysis.overall_risk_level)}">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">Nivel de Riesgo General</h3>
|
||||
<p class="text-sm text-gray-600 mt-1">Análisis de {analysis.analysis_period_hours} horas</p>
|
||||
<!-- Dashboard KPIs -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
|
||||
<!-- Nivel de Riesgo -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-5 border border-gray-200 lg:col-span-1">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-semibold text-gray-600 uppercase">Nivel de Riesgo</span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="inline-block px-4 py-2 text-2xl font-bold rounded-lg {getRiskColor(analysis.overall_risk_level)}">
|
||||
<div class="mt-2">
|
||||
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-sm font-bold {getRiskColor(analysis.overall_risk_level)}">
|
||||
{analysis.overall_risk_level.toUpperCase()}
|
||||
</span>
|
||||
<p class="text-xs text-gray-500 mt-1">Generado: {formatDate(analysis.generated_at)}</p>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">{analysis.analysis_period_hours}h análisis</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Estadísticas Rápidas -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<!-- Amenazas Detectadas -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-5 border-l-4 border-red-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Amenazas Detectadas</p>
|
||||
<p class="text-2xl font-bold text-red-600">{analysis.total_threats_detected}</p>
|
||||
<div class="flex-1">
|
||||
<p class="text-xs font-semibold text-gray-600 uppercase mb-1">Amenazas</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{analysis.total_threats_detected}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">Detectadas</p>
|
||||
</div>
|
||||
<svg class="w-10 h-10 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<!-- Intentos Fallidos -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-5 border-l-4 border-orange-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Intentos Fallidos</p>
|
||||
<p class="text-2xl font-bold text-orange-600">{analysis.failed_login_attempts}</p>
|
||||
<div class="flex-1">
|
||||
<p class="text-xs font-semibold text-gray-600 uppercase mb-1">Intentos Fallidos</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{analysis.failed_login_attempts}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">Logins rechazados</p>
|
||||
</div>
|
||||
<svg class="w-10 h-10 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<!-- IPs Sospechosas -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-5 border-l-4 border-yellow-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">IPs Sospechosas</p>
|
||||
<p class="text-2xl font-bold text-yellow-600">{analysis.suspicious_ips_count}</p>
|
||||
<div class="flex-1">
|
||||
<p class="text-xs font-semibold text-gray-600 uppercase mb-1">IPs Sospechosas</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{analysis.suspicious_ips_count}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">En seguimiento</p>
|
||||
</div>
|
||||
<svg class="w-10 h-10 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<!-- Acciones Críticas -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-5 border-l-4 border-blue-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Acciones Críticas</p>
|
||||
<p class="text-2xl font-bold text-red-600">{analysis.critical_actions_count}</p>
|
||||
<div class="flex-1">
|
||||
<p class="text-xs font-semibold text-gray-600 uppercase mb-1">Acciones Críticas</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{analysis.critical_actions_count}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">Registradas</p>
|
||||
</div>
|
||||
<svg class="w-10 h-10 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -305,162 +402,335 @@
|
||||
<!-- Recomendaciones Generales -->
|
||||
{#if analysis.recommended_actions && analysis.recommended_actions.length > 0}
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-6 h-6 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<h4 class="text-sm font-semibold text-blue-900 mb-2">Acciones Recomendadas</h4>
|
||||
<ul class="space-y-1">
|
||||
{#each analysis.recommended_actions as action}
|
||||
<li class="text-sm text-blue-800 flex items-start gap-2">
|
||||
<svg class="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
{action}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-blue-900 mb-2">Acciones Recomendadas</h4>
|
||||
<ul class="space-y-1">
|
||||
{#each analysis.recommended_actions as action}
|
||||
<li class="text-sm text-blue-800">{action}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Tabs y Filtros -->
|
||||
<div class="bg-white shadow-sm rounded-lg mb-6 border border-gray-200 overflow-hidden">
|
||||
<!-- Tabs -->
|
||||
<div class="border-b border-gray-200 bg-gray-50">
|
||||
<div class="flex overflow-x-auto">
|
||||
<button
|
||||
on:click={() => activeTab = 'all'}
|
||||
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap {activeTab === 'all' ? 'border-blue-700 text-blue-700 bg-white' : 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'}"
|
||||
>
|
||||
Todas
|
||||
{#if tabCounts.all > 0}
|
||||
<span class="ml-2 px-2 py-0.5 text-xs rounded-full {activeTab === 'all' ? 'bg-blue-100 text-blue-700' : 'bg-gray-200 text-gray-700'}">
|
||||
{tabCounts.all}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
on:click={() => activeTab = 'critical'}
|
||||
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap {activeTab === 'critical' ? 'border-red-600 text-red-700 bg-white' : 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'}"
|
||||
>
|
||||
Críticas
|
||||
{#if tabCounts.critical > 0}
|
||||
<span class="ml-2 px-2 py-0.5 text-xs rounded-full {activeTab === 'critical' ? 'bg-red-100 text-red-700' : 'bg-gray-200 text-gray-700'}">
|
||||
{tabCounts.critical}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
on:click={() => activeTab = 'high'}
|
||||
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap {activeTab === 'high' ? 'border-orange-500 text-orange-700 bg-white' : 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'}"
|
||||
>
|
||||
Altas
|
||||
{#if tabCounts.high > 0}
|
||||
<span class="ml-2 px-2 py-0.5 text-xs rounded-full {activeTab === 'high' ? 'bg-orange-100 text-orange-700' : 'bg-gray-200 text-gray-700'}">
|
||||
{tabCounts.high}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
on:click={() => activeTab = 'medium'}
|
||||
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap {activeTab === 'medium' ? 'border-yellow-500 text-yellow-800 bg-white' : 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'}"
|
||||
>
|
||||
Medias
|
||||
{#if tabCounts.medium > 0}
|
||||
<span class="ml-2 px-2 py-0.5 text-xs rounded-full {activeTab === 'medium' ? 'bg-yellow-100 text-yellow-800' : 'bg-gray-200 text-gray-700'}">
|
||||
{tabCounts.medium}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
on:click={() => activeTab = 'low'}
|
||||
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap {activeTab === 'low' ? 'border-blue-500 text-blue-700 bg-white' : 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'}"
|
||||
>
|
||||
Bajas
|
||||
{#if tabCounts.low > 0}
|
||||
<span class="ml-2 px-2 py-0.5 text-xs rounded-full {activeTab === 'low' ? 'bg-blue-100 text-blue-700' : 'bg-gray-200 text-gray-700'}">
|
||||
{tabCounts.low}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
on:click={() => activeTab = 'resolved'}
|
||||
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap {activeTab === 'resolved' ? 'border-green-500 text-green-700 bg-white' : 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'}"
|
||||
>
|
||||
Resueltas
|
||||
{#if tabCounts.resolved > 0}
|
||||
<span class="ml-2 px-2 py-0.5 text-xs rounded-full {activeTab === 'resolved' ? 'bg-green-100 text-green-700' : 'bg-gray-200 text-gray-700'}">
|
||||
{tabCounts.resolved}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Barra de Búsqueda y Filtros -->
|
||||
<div class="p-4 bg-white">
|
||||
<div class="flex flex-col md:flex-row gap-3">
|
||||
<!-- Búsqueda -->
|
||||
<div class="flex-1 relative">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
placeholder="Buscar por descripción, IP, usuario..."
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Filtro por Tipo -->
|
||||
<select
|
||||
bind:value={filterType}
|
||||
class="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
|
||||
>
|
||||
<option value="">Todos los tipos</option>
|
||||
{#each threatTypes as type}
|
||||
<option value={type}>{getThreatTypeText(type)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<!-- Limpiar -->
|
||||
{#if searchQuery || filterType}
|
||||
<button
|
||||
on:click={clearFilters}
|
||||
class="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
Limpiar
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Acciones en Lote -->
|
||||
{#if selectedThreats.size > 0 && canExecuteActions}
|
||||
<div class="mt-3 p-3 bg-blue-50 border border-blue-200 rounded-lg flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-blue-900">
|
||||
{selectedThreats.size} amenaza{selectedThreats.size > 1 ? 's' : ''} seleccionada{selectedThreats.size > 1 ? 's' : ''}
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
on:click={resolveSelectedThreats}
|
||||
class="px-3 py-1.5 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 font-medium"
|
||||
>
|
||||
Resolver
|
||||
</button>
|
||||
<button
|
||||
on:click={() => { selectedThreats.clear(); selectedThreats = selectedThreats; }}
|
||||
class="px-3 py-1.5 bg-white border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lista de Amenazas -->
|
||||
{#if analysis.threats && analysis.threats.length > 0}
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Amenazas Detectadas</h3>
|
||||
|
||||
{#each analysis.threats as threat}
|
||||
<div class="bg-white shadow rounded-lg p-6 border-l-4 {threat.severity === 'critical' ? 'border-gray-900' : threat.severity === 'high' ? 'border-gray-600' : threat.severity === 'medium' ? 'border-gray-400' : 'border-gray-200'}">
|
||||
<!-- Header de Amenaza -->
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex items-start gap-3 flex-1">
|
||||
<div class="p-2 rounded-lg {threat.severity === 'critical' ? 'bg-gray-100' : threat.severity === 'high' ? 'bg-gray-100' : threat.severity === 'medium' ? 'bg-gray-100' : 'bg-gray-50'}">
|
||||
<svg class="w-6 h-6 {threat.severity === 'critical' ? 'text-gray-900' : threat.severity === 'high' ? 'text-gray-700' : threat.severity === 'medium' ? 'text-gray-600' : 'text-gray-500'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={getThreatIcon(threat.type)} />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<h4 class="text-lg font-semibold text-gray-900">{getThreatTypeText(threat.type)}</h4>
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full {getSeverityColor(threat.severity)}">
|
||||
{threat.severity.toUpperCase()}
|
||||
</span>
|
||||
{#if filteredThreats.length > 0}
|
||||
<div class="space-y-3">
|
||||
{#each filteredThreats as threat}
|
||||
<div class="bg-white shadow-sm rounded-lg border border-gray-200 overflow-hidden {resolvedThreats.has(threat.id) ? 'opacity-60' : ''}">
|
||||
<!-- Header Compacto -->
|
||||
<div class="p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex items-start gap-3 flex-1">
|
||||
<!-- Checkbox de Selección -->
|
||||
{#if canExecuteActions && !resolvedThreats.has(threat.id)}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedThreats.has(threat.id)}
|
||||
on:change={() => toggleThreatSelection(threat.id)}
|
||||
class="mt-1 h-4 w-4 text-blue-700 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Icono -->
|
||||
|
||||
<!-- Información Principal -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<h4 class="text-base font-semibold text-gray-900">{getThreatTypeText(threat.type)}</h4>
|
||||
<span class="px-2 py-0.5 text-xs font-semibold rounded {getSeverityColor(threat.severity)}">
|
||||
{threat.severity.toUpperCase()}
|
||||
</span>
|
||||
{#if resolvedThreats.has(threat.id)}
|
||||
<span class="px-2 py-0.5 text-xs font-semibold rounded bg-gray-50 text-green-700 border border-green-200">
|
||||
RESUELTA
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm text-gray-700 mb-2">{threat.description}</p>
|
||||
|
||||
<!-- Stats Rápidos -->
|
||||
<div class="flex flex-wrap gap-4 text-xs text-gray-600">
|
||||
<span><strong>{threat.occurrences}</strong> ocurrencias</span>
|
||||
{#if threat.affected_ips.length > 0}
|
||||
<span><strong>{threat.affected_ips.length}</strong> IPs</span>
|
||||
{/if}
|
||||
{#if threat.affected_users.length > 0}
|
||||
<span><strong>{threat.affected_users.length}</strong> usuarios</span>
|
||||
{/if}
|
||||
<span class="text-gray-500">|</span>
|
||||
<span>{formatDate(threat.last_seen)}</span>
|
||||
</div>
|
||||
|
||||
<!-- IPs y Usuarios (Collapsibles) -->
|
||||
{#if threat.affected_ips.length > 0 || threat.affected_users.length > 0}
|
||||
<details class="mt-3 group">
|
||||
<summary class="cursor-pointer text-xs font-medium text-blue-700 hover:text-blue-800">
|
||||
Ver detalles afectados
|
||||
</summary>
|
||||
<div class="mt-2 pl-5 space-y-2">
|
||||
{#if threat.affected_ips.length > 0}
|
||||
<div>
|
||||
<span class="text-xs font-medium text-gray-600">IPs:</span>
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
{#each threat.affected_ips as ip}
|
||||
<code class="px-2 py-0.5 bg-gray-100 rounded text-xs font-mono">{ip}</code>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if threat.affected_users.length > 0}
|
||||
<div>
|
||||
<span class="text-xs font-medium text-gray-600">Usuarios:</span>
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
{#each threat.affected_users as user}
|
||||
<span class="px-2 py-0.5 bg-gray-100 rounded text-xs">{user}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</details>
|
||||
{/if}
|
||||
|
||||
<!-- Recomendaciones (Collapsibles) -->
|
||||
{#if threat.recommendations && threat.recommendations.length > 0}
|
||||
<details class="mt-2 group">
|
||||
<summary class="cursor-pointer text-xs font-medium text-blue-700 hover:text-blue-800">
|
||||
Ver recomendaciones
|
||||
</summary>
|
||||
<div class="mt-2 pl-5">
|
||||
<ul class="space-y-1">
|
||||
{#each threat.recommendations as rec}
|
||||
<li class="text-xs text-gray-600">
|
||||
{rec}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm text-gray-700">{threat.description}</p>
|
||||
</div>
|
||||
|
||||
<!-- Acciones Rápidas -->
|
||||
{#if canExecuteActions}
|
||||
<div class="flex flex-col gap-2 flex-shrink-0">
|
||||
{#if !resolvedThreats.has(threat.id)}
|
||||
<button
|
||||
on:click={() => toggleThreatResolved(threat.id)}
|
||||
class="px-3 py-1.5 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 font-medium whitespace-nowrap"
|
||||
title="Marcar como resuelta"
|
||||
>
|
||||
Resolver
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
on:click={() => toggleThreatResolved(threat.id)}
|
||||
class="px-3 py-1.5 bg-gray-200 text-gray-700 text-xs rounded-lg hover:bg-gray-300 font-medium whitespace-nowrap"
|
||||
title="Marcar como activa"
|
||||
>
|
||||
Reoprir
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if !resolvedThreats.has(threat.id)}
|
||||
<div class="relative group/actions">
|
||||
<button class="px-3 py-1.5 bg-gray-100 text-gray-700 text-xs rounded-lg hover:bg-gray-200 font-medium whitespace-nowrap">
|
||||
Acciones
|
||||
</button>
|
||||
<div class="hidden group-hover/actions:block absolute right-0 mt-1 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
|
||||
{#if threat.affected_ips.length > 0}
|
||||
<button
|
||||
on:click={() => openActionModal(threat, 'block_ip')}
|
||||
class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Bloquear IP
|
||||
</button>
|
||||
{/if}
|
||||
{#if threat.affected_users.length > 0}
|
||||
<button
|
||||
on:click={() => openActionModal(threat, 'force_password_reset')}
|
||||
class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Resetear Contraseña
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
on:click={() => openActionModal(threat, 'notify_admin')}
|
||||
class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Notificar Admin
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detalles -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4 text-sm">
|
||||
<div>
|
||||
<span class="font-medium text-gray-600">Ocurrencias:</span>
|
||||
<span class="ml-2 text-gray-900 font-semibold">{threat.occurrences}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-gray-600">Primera detección:</span>
|
||||
<span class="ml-2 text-gray-900">{formatDate(threat.first_seen)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-gray-600">Última detección:</span>
|
||||
<span class="ml-2 text-gray-900">{formatDate(threat.last_seen)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IPs y Usuarios Afectados -->
|
||||
{#if threat.affected_ips.length > 0 || threat.affected_users.length > 0}
|
||||
<div class="mb-4 text-sm">
|
||||
{#if threat.affected_ips.length > 0}
|
||||
<div class="mb-2">
|
||||
<span class="font-medium text-gray-600">IPs involucradas:</span>
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
{#each threat.affected_ips as ip}
|
||||
<code class="px-2 py-1 bg-gray-100 rounded text-xs font-mono">{ip}</code>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if threat.affected_users.length > 0}
|
||||
<div>
|
||||
<span class="font-medium text-gray-600">Usuarios afectados:</span>
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
{#each threat.affected_users as user}
|
||||
<span class="px-2 py-1 bg-gray-100 rounded text-xs">{user}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Recomendaciones -->
|
||||
{#if threat.recommendations && threat.recommendations.length > 0}
|
||||
<div class="bg-gray-50 rounded-lg p-3 mb-4">
|
||||
<p class="text-xs font-semibold text-gray-700 mb-2">Recomendaciones:</p>
|
||||
<ul class="space-y-1">
|
||||
{#each threat.recommendations as rec}
|
||||
<li class="text-xs text-gray-600 flex items-start gap-2">
|
||||
<svg class="w-3 h-3 text-gray-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
{rec}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Acciones -->
|
||||
{#if canExecuteActions}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#if threat.affected_ips.length > 0}
|
||||
<button
|
||||
on:click={() => openActionModal(threat, 'block_ip')}
|
||||
class="px-3 py-1.5 bg-red-600 text-white text-sm rounded hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 flex items-center gap-1"
|
||||
>
|
||||
<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="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
</svg>
|
||||
Bloquear IP
|
||||
</button>
|
||||
{/if}
|
||||
{#if threat.affected_users.length > 0}
|
||||
<button
|
||||
on:click={() => openActionModal(threat, 'force_password_reset')}
|
||||
class="px-3 py-1.5 bg-orange-600 text-white text-sm rounded hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-orange-500 flex items-center gap-1"
|
||||
>
|
||||
<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="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
Resetear Contraseña
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
on:click={() => openActionModal(threat, 'notify_admin')}
|
||||
class="px-3 py-1.5 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 flex items-center gap-1"
|
||||
>
|
||||
<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="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||
</svg>
|
||||
Notificar Admin
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- No hay amenazas -->
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
|
||||
<svg class="w-16 h-16 text-gray-500 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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" />
|
||||
</svg>
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">Sistema Seguro</h3>
|
||||
<p class="text-sm text-gray-600">No se detectaron amenazas en el período analizado</p>
|
||||
<!-- No hay amenazas filtradas -->
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-12 text-center">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">
|
||||
{activeTab === 'resolved' ? 'No hay amenazas resueltas' : searchQuery || filterType ? 'No se encontraron resultados' : 'Sistema Seguro'}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600">
|
||||
{activeTab === 'resolved' ? 'No has resuelto ninguna amenaza aún.' : searchQuery || filterType ? 'Intenta ajustar los filtros de búsqueda.' : 'No se detectaron amenazas en este período.'}
|
||||
</p>
|
||||
{#if searchQuery || filterType}
|
||||
<button
|
||||
on:click={clearFilters}
|
||||
class="mt-4 px-4 py-2 bg-blue-700 text-white rounded-lg text-sm font-medium hover:bg-blue-800"
|
||||
>
|
||||
Limpiar Filtros
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Estado vacío inicial -->
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-12 text-center">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">Sin Datos de Análisis</h3>
|
||||
<p class="text-sm text-gray-600">Carga el análisis de seguridad para ver amenazas detectadas.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -522,7 +792,7 @@
|
||||
</button>
|
||||
<button
|
||||
on:click={executeSecurityAction}
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-700 rounded-md hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
Ejecutar Acción
|
||||
</button>
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
<button
|
||||
type="button"
|
||||
on:click={openCreateModal}
|
||||
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 sm:w-auto"
|
||||
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-blue-700 border border-transparent rounded-md shadow-sm hover:bg-blue-800 sm:w-auto"
|
||||
>
|
||||
Nueva Categoría
|
||||
</button>
|
||||
@@ -145,27 +145,27 @@
|
||||
</td>
|
||||
<td class="px-3 py-4 text-sm text-gray-500 max-w-xs truncate">{category.description || '-'}</td>
|
||||
<td class="px-3 py-4 text-sm text-center">
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800">
|
||||
⏱️ {category.sla_response_hours || 24}h
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-blue-700 border border-blue-200">
|
||||
{category.sla_response_hours || 24}h
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-4 text-sm text-center">
|
||||
<span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
|
||||
✅ {category.sla_resolution_hours || 72}h
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-green-700 border border-green-200">
|
||||
{category.sla_resolution_hours || 72}h
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
<span class:bg-blue-100={!category.tenant_id} class:text-blue-800={!category.tenant_id} class:bg-gray-100={category.tenant_id} class:text-gray-800={category.tenant_id} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
|
||||
<span class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 border {!category.tenant_id ? 'bg-gray-100 text-blue-700 border-blue-200' : 'bg-gray-100 text-gray-700 border-gray-200'}">
|
||||
{getTenantName(category.tenant_id)}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
<span class:bg-green-100={category.is_active} class:text-green-800={category.is_active} class:bg-red-100={!category.is_active} class:text-red-800={!category.is_active} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
|
||||
<span class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 border {category.is_active ? 'bg-gray-50 text-green-700 border-green-200' : 'bg-gray-50 text-red-700 border-red-200'}">
|
||||
{category.is_active ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
|
||||
<button on:click={() => openEditModal(category)} class="text-indigo-600 hover:text-indigo-900">Editar</button>
|
||||
<button on:click={() => openEditModal(category)} class="text-blue-700 hover:text-blue-900">Editar</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
@@ -182,18 +182,18 @@
|
||||
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700">Nombre *</label>
|
||||
<input type="text" id="name" bind:value={formData.name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
|
||||
<input type="text" id="name" bind:value={formData.name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700">Descripción</label>
|
||||
<textarea id="description" bind:value={formData.description} rows="3" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"></textarea>
|
||||
<textarea id="description" bind:value={formData.description} rows="3" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="color" class="block text-sm font-medium text-gray-700">Color</label>
|
||||
<input type="color" id="color" bind:value={formData.color} class="mt-1 block w-full h-10 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border">
|
||||
<input type="color" id="color" bind:value={formData.color} class="mt-1 block w-full h-10 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -212,7 +212,7 @@
|
||||
min="1"
|
||||
max="168"
|
||||
required
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2"
|
||||
>
|
||||
<p class="mt-1 text-xs text-gray-500">Tiempo máximo para primera respuesta</p>
|
||||
</div>
|
||||
@@ -228,7 +228,7 @@
|
||||
min="1"
|
||||
max="720"
|
||||
required
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2"
|
||||
>
|
||||
<p class="mt-1 text-xs text-gray-500">Tiempo máximo para resolver el ticket</p>
|
||||
</div>
|
||||
@@ -247,7 +247,7 @@
|
||||
|
||||
<div>
|
||||
<label for="tenant" class="block text-sm font-medium text-gray-700">Cliente (Opcional - Específico para un cliente)</label>
|
||||
<select id="tenant" bind:value={formData.tenant_id} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
|
||||
<select id="tenant" bind:value={formData.tenant_id} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
|
||||
<option value="">-- Global (Para todos) --</option>
|
||||
{#each tenants as tenant}
|
||||
<option value={tenant.id}>{tenant.name}</option>
|
||||
@@ -256,15 +256,15 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="is_active" bind:checked={formData.is_active} class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||
<input type="checkbox" id="is_active" bind:checked={formData.is_active} class="h-4 w-4 rounded border-gray-300 text-blue-700 focus:ring-blue-500">
|
||||
<label for="is_active" class="ml-2 block text-sm text-gray-900">Activo</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
|
||||
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm">
|
||||
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-700 text-base font-medium text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:col-start-2 sm:text-sm">
|
||||
Guardar
|
||||
</button>
|
||||
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm">
|
||||
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:col-start-1 sm:text-sm">
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user