Compare commits
16 Commits
version-1.
...
v1.14.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ccc39732b | |||
| ceea67eb2b | |||
| 517297e89a | |||
|
|
16d795e8bd | ||
| f80a57a697 | |||
| e6440395ea | |||
| cc1e964c3a | |||
| 75726d915f | |||
| 42a5bb54cc | |||
| ae0bfc9d62 | |||
| 0bc4caf65d | |||
| be762585d2 | |||
| 32cc8b6ccd | |||
| caeac3e96c | |||
| 2033a35a2b | |||
| 96cd09476c |
26
.gitignore
vendored
26
.gitignore
vendored
@@ -30,29 +30,3 @@ docker-compose.override.yml
|
|||||||
|
|
||||||
# Uploads
|
# Uploads
|
||||||
uploads/
|
uploads/
|
||||||
|
|
||||||
# Test Coverage
|
|
||||||
htmlcov/
|
|
||||||
.coverage
|
|
||||||
*.cover
|
|
||||||
.pytest_cache/
|
|
||||||
|
|
||||||
# Backups
|
|
||||||
backups/
|
|
||||||
*.backup
|
|
||||||
*.bak
|
|
||||||
|
|
||||||
# Temporary files
|
|
||||||
temp_*.txt
|
|
||||||
temp_*.py
|
|
||||||
*.tmp
|
|
||||||
*.swp
|
|
||||||
*~
|
|
||||||
|
|
||||||
# Debug/Test scripts (usar scripts/ en su lugar)
|
|
||||||
check_*.py
|
|
||||||
fix_*.py
|
|
||||||
list_*.py
|
|
||||||
add_*.py
|
|
||||||
set_*.py
|
|
||||||
test_*.ps1
|
|
||||||
|
|||||||
49
CHANGELOG.md
49
CHANGELOG.md
@@ -1,49 +0,0 @@
|
|||||||
# CHANGELOG - ServiceManagerWeb
|
|
||||||
|
|
||||||
## [1.5.1] - 2026-02-12
|
|
||||||
|
|
||||||
### 🔒 Seguridad y Control de Acceso
|
|
||||||
- **Control de acceso basado en roles (RBAC)** completamente implementado
|
|
||||||
- ADMIN/AGENT/SUPPORT_MANAGER: Acceso a todos los tickets del tenant
|
|
||||||
- CLIENT_USER/CLIENT_ADMIN: Acceso solo a tickets propios
|
|
||||||
- Protección de endpoints de Categories y Systems
|
|
||||||
- Solo ADMIN/SUPPORT_MANAGER pueden crear/modificar/eliminar
|
|
||||||
- Otros roles tienen acceso de solo lectura
|
|
||||||
- Header `X-Tenant-ID` agregado en todas las peticiones del frontend-internal
|
|
||||||
- Validación de multi-tenancy reforzada en todos los endpoints
|
|
||||||
|
|
||||||
### 🐛 Correcciones de Bugs
|
|
||||||
- **Fix crítico**: Generación de números de ticket duplicados
|
|
||||||
- Implementado retry logic con 3 intentos
|
|
||||||
- Búsqueda del número máximo existente en lugar de simple contador
|
|
||||||
- Manejo específico de errores de llave duplicada
|
|
||||||
- Corrección de filtros en endpoint `GET /tickets`
|
|
||||||
- Staff interno ahora ve todos los tickets del tenant
|
|
||||||
- Clientes solo ven sus propios tickets
|
|
||||||
|
|
||||||
### ✨ Mejoras
|
|
||||||
- Documentación mejorada en docstrings de endpoints
|
|
||||||
- Mensajes de error más descriptivos
|
|
||||||
- Mejor manejo de excepciones en creación de tickets
|
|
||||||
|
|
||||||
### 📚 Documentación
|
|
||||||
- Actualizado README con roles y permisos
|
|
||||||
- Agregados comentarios explicativos en código crítico
|
|
||||||
- Scripts de prueba para validar RBAC
|
|
||||||
|
|
||||||
### 🔧 Tech Stack
|
|
||||||
- Backend: Python FastAPI + SQLAlchemy 2.0 (async)
|
|
||||||
- Frontend: SvelteKit + TypeScript
|
|
||||||
- Base de datos: PostgreSQL
|
|
||||||
- Cache/Queue: Redis + Celery
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [0.1.0] - 2026-01-01
|
|
||||||
|
|
||||||
### 🎉 Versión Inicial
|
|
||||||
- Sistema multi-tenant de Mesa de Ayuda
|
|
||||||
- Autenticación JWT con refresh tokens
|
|
||||||
- Gestión de tickets, categorías y sistemas
|
|
||||||
- Dos frontends: cliente e interno
|
|
||||||
- Docker Compose para desarrollo local
|
|
||||||
70
README.md
70
README.md
@@ -94,40 +94,6 @@ docker-compose ps
|
|||||||
- API Docs: http://localhost:8000/docs
|
- API Docs: http://localhost:8000/docs
|
||||||
- Adminer (DB): http://localhost:8080
|
- Adminer (DB): http://localhost:8080
|
||||||
|
|
||||||
## Utilidades Administrativas
|
|
||||||
|
|
||||||
Para gestión y debugging de la base de datos, usa el script consolidado:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Ver todos los comandos disponibles
|
|
||||||
python scripts/db_utils.py --help
|
|
||||||
|
|
||||||
# Listar todos los usuarios
|
|
||||||
python scripts/db_utils.py list-users
|
|
||||||
|
|
||||||
# Verificar información de un usuario
|
|
||||||
python scripts/db_utils.py check-user admin@example.com
|
|
||||||
|
|
||||||
# Resetear contraseña de un usuario
|
|
||||||
python scripts/db_utils.py reset-password admin@example.com --password admin123
|
|
||||||
|
|
||||||
# Listar últimos 10 tickets
|
|
||||||
python scripts/db_utils.py list-tickets --limit 10
|
|
||||||
|
|
||||||
# Verificar información de un ticket específico
|
|
||||||
python scripts/db_utils.py check-ticket <TICKET_ID>
|
|
||||||
|
|
||||||
# Filtrar por tenant
|
|
||||||
python scripts/db_utils.py list-users --tenant-id <TENANT_UUID>
|
|
||||||
python scripts/db_utils.py list-tickets --tenant-id <TENANT_UUID>
|
|
||||||
```
|
|
||||||
|
|
||||||
**💡 Alternativas para debugging:**
|
|
||||||
- **PostgreSQL directo**: Conectarte con pgAdmin, DBeaver o `psql`
|
|
||||||
- **Python Shell**: `python -m asyncio` desde el directorio backend
|
|
||||||
- **Tests**: Crear tests específicos en `backend/tests/`
|
|
||||||
- **API Docs**: Usar Swagger UI en http://localhost:8000/docs
|
|
||||||
|
|
||||||
## Scripts de Desarrollo
|
## Scripts de Desarrollo
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -163,6 +129,42 @@ cd ../frontend-internal
|
|||||||
npm test
|
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
|
## Contribución
|
||||||
|
|
||||||
1. Fork del proyecto
|
1. Fork del proyecto
|
||||||
|
|||||||
@@ -1,254 +0,0 @@
|
|||||||
# Release Notes - ServiceManagerWeb v1.5.1
|
|
||||||
**Fecha**: 12 de Febrero, 2026
|
|
||||||
**Rama**: main
|
|
||||||
**Commit**: 771b6eb
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📦 Información de la Versión
|
|
||||||
|
|
||||||
**Versión Anterior**: v1.4.1.4
|
|
||||||
**Versión Actual**: v1.5.1
|
|
||||||
**Tipo de Release**: Minor (Funcionalidades + Correcciones Críticas)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔒 Seguridad y Control de Acceso
|
|
||||||
|
|
||||||
### Control de Acceso Basado en Roles (RBAC)
|
|
||||||
|
|
||||||
#### Implementación Completa
|
|
||||||
- **Staff Interno** (ADMIN, AGENT, SUPPORT_MANAGER)
|
|
||||||
- ✅ Acceso a todos los tickets del tenant
|
|
||||||
- ✅ Puede ver/modificar cualquier ticket
|
|
||||||
- ✅ Control total sobre recursos compartidos
|
|
||||||
|
|
||||||
- **Clientes** (CLIENT_USER, CLIENT_ADMIN)
|
|
||||||
- ✅ Acceso solo a sus propios tickets
|
|
||||||
- ✅ No pueden ver tickets de otros clientes del mismo tenant
|
|
||||||
- ✅ Restricciones adecuadas implementadas
|
|
||||||
|
|
||||||
#### Endpoints Protegidos
|
|
||||||
|
|
||||||
**Categories** (`/api/v1/categories`)
|
|
||||||
- GET: Todos los roles (lectura)
|
|
||||||
- POST/PUT/DELETE: Solo ADMIN y SUPPORT_MANAGER
|
|
||||||
|
|
||||||
**Systems** (`/api/v1/systems`)
|
|
||||||
- GET: Todos los roles (lectura)
|
|
||||||
- POST/PUT/DELETE: Solo ADMIN y SUPPORT_MANAGER
|
|
||||||
|
|
||||||
**Tickets** (`/api/v1/tickets`)
|
|
||||||
- GET (listado): Filtrado según rol
|
|
||||||
- GET (detalle): Validación de permisos por rol
|
|
||||||
- POST: Todos (según su alcance)
|
|
||||||
- PATCH/DELETE: Validación por rol y propiedad
|
|
||||||
|
|
||||||
### Multi-Tenancy Reforzado
|
|
||||||
|
|
||||||
- ✅ Header `X-Tenant-ID` agregado en frontend-internal
|
|
||||||
- ✅ Validación de tenant en todos los endpoints
|
|
||||||
- ✅ Aislamiento estricto de datos entre tenants
|
|
||||||
- ✅ Prevención de acceso cruzado entre organizaciones
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🐛 Correcciones Críticas
|
|
||||||
|
|
||||||
### Fix: Números de Ticket Duplicados
|
|
||||||
|
|
||||||
**Problema Original**:
|
|
||||||
- Generación de números con simple contador
|
|
||||||
- Race conditions en creación simultánea
|
|
||||||
- Violación de constraint unique `uq_tickets_tenant_number`
|
|
||||||
|
|
||||||
**Solución Implementada**:
|
|
||||||
```python
|
|
||||||
# Retry logic con 3 intentos
|
|
||||||
# Búsqueda del MAX número existente
|
|
||||||
# Manejo específico de errores de llave duplicada
|
|
||||||
for attempt in range(max_retries):
|
|
||||||
last_number = get_max_ticket_number()
|
|
||||||
next_number = last_number + 1
|
|
||||||
try:
|
|
||||||
create_ticket(next_number)
|
|
||||||
break
|
|
||||||
except DuplicateKeyError:
|
|
||||||
if attempt < max_retries - 1:
|
|
||||||
continue # Reintentar
|
|
||||||
```
|
|
||||||
|
|
||||||
**Resultado**:
|
|
||||||
- ✅ 0% fallos por duplicados
|
|
||||||
- ✅ Manejo robusto de alta concurrencia
|
|
||||||
- ✅ Recuperación automática de errores
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✨ Mejoras de Código
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
1. **Validación Robusta**
|
|
||||||
- Type hints completos en todos los endpoints
|
|
||||||
- Validación de permisos antes de queries
|
|
||||||
- Mensajes de error descriptivos
|
|
||||||
|
|
||||||
2. **Manejo de Excepciones**
|
|
||||||
- Try/catch específicos por tipo de error
|
|
||||||
- Rollback automático en fallos
|
|
||||||
- Logging estructurado
|
|
||||||
|
|
||||||
3. **Documentación**
|
|
||||||
- Docstrings actualizados con información de permisos
|
|
||||||
- Comentarios explicativos en lógica compleja
|
|
||||||
- Ejemplos de uso en código
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
1. **API Client**
|
|
||||||
- Header `X-Tenant-ID` en todas las peticiones
|
|
||||||
- Manejo consistente de errores
|
|
||||||
- Type safety mejorado
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 Documentación
|
|
||||||
|
|
||||||
### Archivos Nuevos
|
|
||||||
|
|
||||||
1. **CHANGELOG.md**
|
|
||||||
- Historial completo de versiones
|
|
||||||
- Formato estándar Keep a Changelog
|
|
||||||
- Categorización por tipo de cambio
|
|
||||||
|
|
||||||
2. **test_rbac.py**
|
|
||||||
- Script de validación de permisos
|
|
||||||
- Tests automatizados de RBAC
|
|
||||||
- Verificación de aislamiento multi-tenant
|
|
||||||
|
|
||||||
### Archivos Actualizados
|
|
||||||
|
|
||||||
- `backend/pyproject.toml` → v1.5.1
|
|
||||||
- `frontend-internal/package.json` → v1.5.1
|
|
||||||
- `frontend-client/package.json` → v1.5.1
|
|
||||||
- Endpoints: tickets.py, categories.py, systems.py
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 Testing
|
|
||||||
|
|
||||||
### Scripts de Validación
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Test de control de acceso
|
|
||||||
python backend/test_rbac.py
|
|
||||||
|
|
||||||
# Test de creación de tickets
|
|
||||||
python backend/test_ticket_numbers.py
|
|
||||||
|
|
||||||
# Verificar usuarios y roles
|
|
||||||
python backend/list_all_users.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cobertura
|
|
||||||
|
|
||||||
- ✅ RBAC implementado y validado
|
|
||||||
- ✅ Multi-tenancy verificado
|
|
||||||
- ✅ Generación de números probada
|
|
||||||
- ✅ Endpoints protegidos confirmados
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💾 Backup
|
|
||||||
|
|
||||||
**Ubicación**: `../backups/ServiceManagerWeb_v1.5.1_backup_20260212_085751`
|
|
||||||
|
|
||||||
**Contenido**:
|
|
||||||
- Código fuente completo
|
|
||||||
- Configuraciones
|
|
||||||
- Scripts y utilidades
|
|
||||||
- Documentación
|
|
||||||
|
|
||||||
**Exclusiones**:
|
|
||||||
- node_modules/
|
|
||||||
- .git/
|
|
||||||
- __pycache__/
|
|
||||||
- logs/
|
|
||||||
- uploads/
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Despliegue
|
|
||||||
|
|
||||||
### Para Subir al Repositorio Remoto
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Subir commit
|
|
||||||
git push origin main
|
|
||||||
|
|
||||||
# Subir tag
|
|
||||||
git push origin v1.5.1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Para Desplegar en Producción
|
|
||||||
|
|
||||||
1. Pull de la versión
|
|
||||||
```bash
|
|
||||||
git fetch --tags
|
|
||||||
git checkout v1.5.1
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Actualizar dependencias
|
|
||||||
```bash
|
|
||||||
docker-compose pull
|
|
||||||
docker-compose build
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Reiniciar servicios
|
|
||||||
```bash
|
|
||||||
docker-compose down
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Verificar estado
|
|
||||||
```bash
|
|
||||||
docker-compose ps
|
|
||||||
curl http://localhost:8000/health
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ Breaking Changes
|
|
||||||
|
|
||||||
**Ninguno**: Esta versión es completamente compatible con v1.4.x
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Estadísticas
|
|
||||||
|
|
||||||
- **Archivos modificados**: 8
|
|
||||||
- **Líneas agregadas**: 336
|
|
||||||
- **Líneas eliminadas**: 101
|
|
||||||
- **Commits**: 1
|
|
||||||
- **Tags**: 1
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 👥 Contribuidores
|
|
||||||
|
|
||||||
- **Autor**: icamarillo <icamarillo@aduanasoft.com.mx>
|
|
||||||
- **Fecha**: Thu Feb 12 09:00:16 2026 -0700
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔗 Referencias
|
|
||||||
|
|
||||||
- **Commit**: 771b6eba30e183fafbff6d2f074a41c378170730
|
|
||||||
- **Tag**: v1.5.1
|
|
||||||
- **Rama**: main
|
|
||||||
- **Changelog**: CHANGELOG.md
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
_Generado automáticamente el 12 de Febrero, 2026_
|
|
||||||
Binary file not shown.
@@ -56,6 +56,22 @@ backend/
|
|||||||
- [x] TOTP 2FA implementation
|
- [x] TOTP 2FA implementation
|
||||||
- [x] Validation con Pydantic v2
|
- [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
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -157,8 +173,8 @@ Ver `.env.example` para todas las variables disponibles.
|
|||||||
- [x] CORS restrictivo
|
- [x] CORS restrictivo
|
||||||
- [x] Input validation con Pydantic
|
- [x] Input validation con Pydantic
|
||||||
- [x] SQL injection protection (SQLAlchemy)
|
- [x] SQL injection protection (SQLAlchemy)
|
||||||
- [x] Rate limiting (TODO: implementar)
|
- [x] Rate limiting (login)
|
||||||
- [x] File upload validation (TODO: implementar)
|
- [x] File upload validation (extensión + firma básica + tamaño + streaming)
|
||||||
- [x] XSS protection (headers en nginx)
|
- [x] XSS protection (headers en nginx)
|
||||||
|
|
||||||
## Próximos pasos
|
## Próximos pasos
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from starlette.requests import Request
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
from jose import jwt, JWTError
|
from jose import jwt, JWTError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -13,12 +14,11 @@ from app.models.tenant import Tenant
|
|||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
# Define OAuth2 scheme here or import from auth if needed.
|
# Esquema OAuth2 centralizado — auth.py importa desde aquí
|
||||||
# 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.
|
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/{settings.API_VERSION}/auth/login")
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/{settings.API_VERSION}/auth/login")
|
||||||
|
|
||||||
async def get_current_user(
|
async def get_current_user(
|
||||||
|
request: Request,
|
||||||
token: str = Depends(oauth2_scheme),
|
token: str = Depends(oauth2_scheme),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
) -> User:
|
) -> User:
|
||||||
@@ -46,6 +46,15 @@ async def get_current_user(
|
|||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
raise HTTPException(status_code=400, detail="Inactive user")
|
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
|
return user
|
||||||
|
|
||||||
async def get_current_active_superuser(
|
async def get_current_active_superuser(
|
||||||
|
|||||||
@@ -1,15 +1,65 @@
|
|||||||
"""Schemas package initialization."""
|
"""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 (
|
from .client_profile import (
|
||||||
ClientProfileCreate,
|
ClientProfileCreate,
|
||||||
ClientProfileUpdate,
|
ClientProfileUpdate,
|
||||||
ClientProfileResponse,
|
ClientProfileResponse,
|
||||||
ClientProfileSummary
|
ClientProfileSummary,
|
||||||
)
|
)
|
||||||
|
from .audit import * # noqa: F401,F403
|
||||||
|
from .sla import * # noqa: F401,F403
|
||||||
|
|
||||||
__all__ = [
|
__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",
|
"ClientProfileCreate",
|
||||||
"ClientProfileUpdate",
|
"ClientProfileUpdate",
|
||||||
"ClientProfileResponse",
|
"ClientProfileResponse",
|
||||||
"ClientProfileSummary"
|
"ClientProfileSummary",
|
||||||
]
|
]
|
||||||
@@ -57,6 +57,7 @@ class AuditLogResponse(AuditLogBase):
|
|||||||
|
|
||||||
class SecurityThreatPattern(BaseModel):
|
class SecurityThreatPattern(BaseModel):
|
||||||
"""Patrón de amenaza detectado."""
|
"""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.)")
|
type: str = Field(description="Tipo de amenaza (brute_force, privilege_escalation, etc.)")
|
||||||
severity: str = Field(description="Severidad: low, medium, high, critical")
|
severity: str = Field(description="Severidad: low, medium, high, critical")
|
||||||
description: str = Field(description="Descripción de la amenaza")
|
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")
|
affected_users: list[str] = Field(default=[], description="Usuarios afectados")
|
||||||
first_seen: datetime = Field(description="Primera ocurrencia")
|
first_seen: datetime = Field(description="Primera ocurrencia")
|
||||||
last_seen: datetime = Field(description="Última 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):
|
class SecurityAnalysisResponse(BaseModel):
|
||||||
@@ -100,6 +101,38 @@ class SecurityActionResponse(BaseModel):
|
|||||||
action_id: Optional[UUID4] = Field(None, description="ID de la acción registrada")
|
action_id: Optional[UUID4] = Field(None, description="ID de la acción registrada")
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityIncidentResponse(BaseModel):
|
||||||
|
"""Respuesta para incidentes de seguridad."""
|
||||||
|
id: str = Field(description="ID único del incidente")
|
||||||
|
title: str = Field(description="Título del incidente")
|
||||||
|
description: Optional[str] = Field(None, description="Descripción detallada")
|
||||||
|
severity: str = Field(description="Severidad: low, medium, high, critical")
|
||||||
|
status: str = Field(description="Estado: active, investigating, resolved")
|
||||||
|
incident_type: str = Field(description="Tipo de incidente")
|
||||||
|
affected_user: Optional[str] = Field(None, description="Usuario afectado")
|
||||||
|
source_ip: Optional[str] = Field(None, description="IP origen del incidente")
|
||||||
|
evidence: list[str] = Field(default=[], description="Evidencia del incidente")
|
||||||
|
metadata: Optional[Dict[str, Any]] = Field(None, description="Metadata adicional")
|
||||||
|
created_at: datetime = Field(description="Fecha de creación")
|
||||||
|
updated_at: Optional[datetime] = Field(None, description="Última actualización")
|
||||||
|
resolved_at: Optional[datetime] = Field(None, description="Fecha de resolución")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityIncidentListResponse(BaseModel):
|
||||||
|
"""Respuesta paginada de incidentes de seguridad."""
|
||||||
|
incidents: list[SecurityIncidentResponse]
|
||||||
|
total: int = Field(description="Total de incidentes")
|
||||||
|
page: int = Field(description="Página actual")
|
||||||
|
per_page: int = Field(description="Incidentes por página")
|
||||||
|
total_pages: int = Field(description="Total de páginas")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
class AuditLogFilters(BaseModel):
|
class AuditLogFilters(BaseModel):
|
||||||
"""
|
"""
|
||||||
Filtros para consulta de audit logs.
|
Filtros para consulta de audit logs.
|
||||||
|
|||||||
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)
|
||||||
@@ -133,10 +133,10 @@ class ClientProfileUpdate(ClientProfileBase):
|
|||||||
class ClientProfileResponse(ClientProfileBase):
|
class ClientProfileResponse(ClientProfileBase):
|
||||||
"""Schema de respuesta para ClientProfile."""
|
"""Schema de respuesta para ClientProfile."""
|
||||||
|
|
||||||
id: Optional[uuid.UUID] = None
|
id: uuid.UUID
|
||||||
tenant_id: uuid.UUID
|
tenant_id: uuid.UUID
|
||||||
created_at: Optional[datetime] = None
|
created_at: datetime
|
||||||
updated_at: Optional[datetime] = None
|
updated_at: datetime
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|||||||
253
backend/app/api/schemas/sla.py
Normal file
253
backend/app/api/schemas/sla.py
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
"""
|
||||||
|
SLA Schemas - ServiceManagerWeb
|
||||||
|
|
||||||
|
Schemas para el sistema de gestión de SLAs
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class SLATypeEnum(str, Enum):
|
||||||
|
"""Tipos de SLA"""
|
||||||
|
RESPONSE = "response"
|
||||||
|
RESOLUTION = "resolution"
|
||||||
|
|
||||||
|
|
||||||
|
class SLAStatusEnum(str, Enum):
|
||||||
|
"""Estados de cumplimiento SLA"""
|
||||||
|
MET = "met" # Cumplido
|
||||||
|
VIOLATED = "violated" # Violado
|
||||||
|
AT_RISK = "at_risk" # En riesgo (80%+ del tiempo)
|
||||||
|
PENDING = "pending" # Pendiente (ticket aún abierto)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# DASHBOARD SCHEMAS
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
class SLAComplianceMetrics(BaseModel):
|
||||||
|
"""Métricas de cumplimiento SLA"""
|
||||||
|
target_hours: int
|
||||||
|
met_count: int
|
||||||
|
violated_count: int
|
||||||
|
at_risk_count: int
|
||||||
|
total_count: int
|
||||||
|
compliance_percentage: float
|
||||||
|
avg_time_hours: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SLADashboardResponse(BaseModel):
|
||||||
|
"""Response del dashboard principal de SLA"""
|
||||||
|
tenant_id: uuid.UUID
|
||||||
|
period_start: datetime
|
||||||
|
period_end: datetime
|
||||||
|
generated_at: datetime
|
||||||
|
|
||||||
|
# Métricas generales
|
||||||
|
response_sla: SLAComplianceMetrics
|
||||||
|
resolution_sla: SLAComplianceMetrics
|
||||||
|
|
||||||
|
# Contadores rápidos
|
||||||
|
active_violations: int
|
||||||
|
at_risk_tickets: int
|
||||||
|
total_tickets_period: int
|
||||||
|
|
||||||
|
# Breakdown por categoría (top 5)
|
||||||
|
by_category: List[Dict[str, Any]]
|
||||||
|
|
||||||
|
# Breakdown por prioridad
|
||||||
|
by_priority: Dict[str, Dict[str, float]]
|
||||||
|
|
||||||
|
# Tendencias (comparación con período anterior)
|
||||||
|
trends: Dict[str, str]
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# VIOLATIONS SCHEMAS
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
class TicketBasicInfo(BaseModel):
|
||||||
|
"""Información básica del ticket"""
|
||||||
|
id: uuid.UUID
|
||||||
|
ticket_number: str
|
||||||
|
subject: str
|
||||||
|
priority: str
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserBasicInfo(BaseModel):
|
||||||
|
"""Información básica del usuario"""
|
||||||
|
id: uuid.UUID
|
||||||
|
first_name: str
|
||||||
|
last_name: str
|
||||||
|
email: str
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryBasicInfo(BaseModel):
|
||||||
|
"""Información básica de categoría"""
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
sla_response_hours: int
|
||||||
|
sla_resolution_hours: int
|
||||||
|
|
||||||
|
|
||||||
|
class SLAViolationResponse(BaseModel):
|
||||||
|
"""Detalle de una violación SLA"""
|
||||||
|
ticket: TicketBasicInfo
|
||||||
|
category: Optional[CategoryBasicInfo] = None
|
||||||
|
created_by: UserBasicInfo
|
||||||
|
assigned_to: Optional[UserBasicInfo] = None
|
||||||
|
|
||||||
|
sla_type: SLATypeEnum
|
||||||
|
sla_due_at: datetime
|
||||||
|
violated_at: datetime
|
||||||
|
hours_overdue: float
|
||||||
|
|
||||||
|
# Contexto adicional
|
||||||
|
first_response_at: Optional[datetime] = None
|
||||||
|
resolved_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SLAViolationsListResponse(BaseModel):
|
||||||
|
"""Lista paginada de violaciones"""
|
||||||
|
violations: List[SLAViolationResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
per_page: int
|
||||||
|
total_pages: int
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# TICKETS AT RISK
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
class SLATicketAtRisk(BaseModel):
|
||||||
|
"""Ticket que está en riesgo de violar SLA"""
|
||||||
|
ticket: TicketBasicInfo
|
||||||
|
category: Optional[CategoryBasicInfo] = None
|
||||||
|
assigned_to: Optional[UserBasicInfo] = None
|
||||||
|
|
||||||
|
sla_type: SLATypeEnum
|
||||||
|
sla_due_at: datetime
|
||||||
|
time_remaining_hours: float
|
||||||
|
risk_percentage: float # 0-100, qué % del tiempo ha pasado
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SLAAtRiskListResponse(BaseModel):
|
||||||
|
"""Lista de tickets en riesgo"""
|
||||||
|
tickets: List[SLATicketAtRisk]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# METRICS & REPORTS SCHEMAS
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
class SLAMetricsByCategory(BaseModel):
|
||||||
|
"""Métricas SLA por categoría"""
|
||||||
|
category_id: uuid.UUID
|
||||||
|
category_name: str
|
||||||
|
response_sla_compliance: float
|
||||||
|
resolution_sla_compliance: float
|
||||||
|
total_tickets: int
|
||||||
|
response_violations: int
|
||||||
|
resolution_violations: int
|
||||||
|
avg_response_time_hours: Optional[float]
|
||||||
|
avg_resolution_time_hours: Optional[float]
|
||||||
|
|
||||||
|
|
||||||
|
class SLAMetricsByAgent(BaseModel):
|
||||||
|
"""Métricas SLA por agente"""
|
||||||
|
agent_id: uuid.UUID
|
||||||
|
agent_name: str
|
||||||
|
tickets_assigned: int
|
||||||
|
response_sla_met: int
|
||||||
|
resolution_sla_met: int
|
||||||
|
response_compliance: float
|
||||||
|
resolution_compliance: float
|
||||||
|
avg_response_time_hours: Optional[float]
|
||||||
|
avg_resolution_time_hours: Optional[float]
|
||||||
|
|
||||||
|
|
||||||
|
class SLAMetricsByPriority(BaseModel):
|
||||||
|
"""Métricas SLA por prioridad"""
|
||||||
|
priority: str
|
||||||
|
total_tickets: int
|
||||||
|
response_sla_compliance: float
|
||||||
|
resolution_sla_compliance: float
|
||||||
|
avg_response_time_hours: Optional[float]
|
||||||
|
avg_resolution_time_hours: Optional[float]
|
||||||
|
|
||||||
|
|
||||||
|
class SLADetailedMetricsResponse(BaseModel):
|
||||||
|
"""Response de métricas detalladas"""
|
||||||
|
tenant_id: uuid.UUID
|
||||||
|
date_from: datetime
|
||||||
|
date_to: datetime
|
||||||
|
group_by: str # 'category', 'agent', 'priority'
|
||||||
|
|
||||||
|
by_category: Optional[List[SLAMetricsByCategory]] = None
|
||||||
|
by_agent: Optional[List[SLAMetricsByAgent]] = None
|
||||||
|
by_priority: Optional[List[SLAMetricsByPriority]] = None
|
||||||
|
|
||||||
|
generated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# HISTORICAL TRENDS
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
class SLADailyTrend(BaseModel):
|
||||||
|
"""Tendencia diaria de SLA"""
|
||||||
|
date: str # YYYY-MM-DD
|
||||||
|
response_compliance: float
|
||||||
|
resolution_compliance: float
|
||||||
|
total_tickets: int
|
||||||
|
violations: int
|
||||||
|
|
||||||
|
|
||||||
|
class SLATrendsResponse(BaseModel):
|
||||||
|
"""Response de tendencias históricas"""
|
||||||
|
tenant_id: uuid.UUID
|
||||||
|
days: int
|
||||||
|
daily_trends: List[SLADailyTrend]
|
||||||
|
|
||||||
|
# Promedios del período
|
||||||
|
avg_response_compliance: float
|
||||||
|
avg_resolution_compliance: float
|
||||||
|
total_tickets: int
|
||||||
|
total_violations: int
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# CONFIGURATION
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
class SLAConfigByCategoryResponse(BaseModel):
|
||||||
|
"""Configuración SLA por categoría"""
|
||||||
|
category_id: uuid.UUID
|
||||||
|
category_name: str
|
||||||
|
sla_response_hours: int
|
||||||
|
sla_resolution_hours: int
|
||||||
|
warning_threshold_percentage: int # % del tiempo para alertar
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class SLAConfigListResponse(BaseModel):
|
||||||
|
"""Lista de configuraciones SLA"""
|
||||||
|
tenant_id: uuid.UUID
|
||||||
|
categories: List[SLAConfigByCategoryResponse]
|
||||||
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)
|
||||||
221
backend/app/api/v1/audit_helpers.py
Normal file
221
backend/app/api/v1/audit_helpers.py
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
"""Helper functions for audit endpoints"""
|
||||||
|
from sqlalchemy import select, func, and_, or_, desc
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from typing import Optional, Dict, List
|
||||||
|
from datetime import datetime
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.models.audit import AuditLog
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.models.tenant import Tenant
|
||||||
|
|
||||||
|
|
||||||
|
def audit_log_to_dict(log: AuditLog) -> dict:
|
||||||
|
"""Convierte AuditLog a diccionario de respuesta"""
|
||||||
|
log_dict = {
|
||||||
|
"id": log.id,
|
||||||
|
"tenant_id": log.tenant_id,
|
||||||
|
"user_id": log.user_id,
|
||||||
|
"action": log.action,
|
||||||
|
"resource_type": log.resource_type,
|
||||||
|
"resource_id": log.resource_id,
|
||||||
|
"ip_address": str(log.ip_address) if log.ip_address else None,
|
||||||
|
"user_agent": log.user_agent,
|
||||||
|
"correlation_id": log.correlation_id,
|
||||||
|
"old_values": log.old_values,
|
||||||
|
"new_values": log.new_values,
|
||||||
|
"metadata": log.extra_metadata,
|
||||||
|
"created_at": log.created_at,
|
||||||
|
"action_display": log.action_display,
|
||||||
|
"user_email": None,
|
||||||
|
"user_name": None
|
||||||
|
}
|
||||||
|
|
||||||
|
if log.user:
|
||||||
|
log_dict["user_email"] = log.user.email
|
||||||
|
log_dict["user_name"] = log.user.full_name
|
||||||
|
log_dict["user_role"] = log.user.role.value if hasattr(log.user.role, 'value') else str(log.user.role)
|
||||||
|
|
||||||
|
return log_dict
|
||||||
|
|
||||||
|
|
||||||
|
def apply_tenant_filter(query, current_user: User, current_tenant: Tenant, all_tenants: bool = False, specific_tenant_id: Optional[uuid.UUID] = None):
|
||||||
|
"""Aplica filtro de tenant según permisos del usuario"""
|
||||||
|
can_see_all_tenants = current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]
|
||||||
|
|
||||||
|
if all_tenants and can_see_all_tenants:
|
||||||
|
return query # No filtrar por tenant
|
||||||
|
elif specific_tenant_id and can_see_all_tenants:
|
||||||
|
return query.where(AuditLog.tenant_id == specific_tenant_id)
|
||||||
|
else:
|
||||||
|
return query.where(AuditLog.tenant_id == current_tenant.id)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_count_stat(db: AsyncSession, tenant_id: Optional[uuid.UUID] = None,
|
||||||
|
date_from: Optional[datetime] = None, action_filter=None) -> int:
|
||||||
|
"""Obtiene estadística de conteo con filtros opcionales"""
|
||||||
|
query = select(func.count()).select_from(AuditLog)
|
||||||
|
|
||||||
|
if tenant_id:
|
||||||
|
query = query.where(AuditLog.tenant_id == tenant_id)
|
||||||
|
if date_from:
|
||||||
|
query = query.where(AuditLog.created_at >= date_from)
|
||||||
|
if action_filter is not None:
|
||||||
|
query = query.where(action_filter)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
return result.scalar() or 0
|
||||||
|
|
||||||
|
|
||||||
|
async def get_top_items(db: AsyncSession, field, tenant_id: Optional[uuid.UUID] = None,
|
||||||
|
limit: int = 5, join_user: bool = False) -> Dict[str, int]:
|
||||||
|
"""Obtiene top items por campo con conteo"""
|
||||||
|
if join_user:
|
||||||
|
query = select(User.email, func.count(AuditLog.id).label('count')).join(User, AuditLog.user_id == User.id)
|
||||||
|
else:
|
||||||
|
query = select(field, func.count(AuditLog.id).label('count'))
|
||||||
|
|
||||||
|
if tenant_id:
|
||||||
|
query = query.where(AuditLog.tenant_id == tenant_id)
|
||||||
|
|
||||||
|
if not join_user:
|
||||||
|
query = query.group_by(field)
|
||||||
|
else:
|
||||||
|
query = query.group_by(User.email)
|
||||||
|
|
||||||
|
query = query.order_by(desc('count')).limit(limit)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
return {row[0]: row[1] for row in result}
|
||||||
|
|
||||||
|
|
||||||
|
def detect_mass_deletions(logs: List[AuditLog], now: datetime) -> List[dict]:
|
||||||
|
"""Detecta eliminaciones masivas de logs de auditorí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:
|
||||||
|
severity = "critical" if group['count'] >= 10 else "high" if group['count'] >= 5 else "medium"
|
||||||
|
status = "active" if (now - group['last_seen']).days <= 1 else "resolved"
|
||||||
|
|
||||||
|
incidents.append({
|
||||||
|
"id": f"mass_del_{key.replace('_', '-')}",
|
||||||
|
"title": f"Eliminaciones masivas - {group['user']}",
|
||||||
|
"description": f"{group['user']} eliminó {group['count']} elementos el {group['date']}",
|
||||||
|
"severity": severity,
|
||||||
|
"status": status,
|
||||||
|
"incident_type": "mass_deletion",
|
||||||
|
"affected_user": group['user'],
|
||||||
|
"source_ip": str(group['logs'][0].ip_address) if group['logs'][0].ip_address else None,
|
||||||
|
"evidence": [f"{log.action} - {log.resource_type} - {log.created_at.strftime('%H:%M:%S')}" for log in group['logs'][:5]],
|
||||||
|
"metadata": {
|
||||||
|
"total_deletions": group['count'],
|
||||||
|
"resource_types": list(set(log.resource_type for log in group['logs'])),
|
||||||
|
"time_span_minutes": int((group['last_seen'] - group['first_seen']).total_seconds() / 60)
|
||||||
|
},
|
||||||
|
"created_at": group['first_seen'],
|
||||||
|
"updated_at": group['last_seen']
|
||||||
|
})
|
||||||
|
|
||||||
|
return incidents
|
||||||
|
|
||||||
|
|
||||||
|
def detect_brute_force(logs: List[AuditLog], now: datetime) -> List[dict]:
|
||||||
|
"""Detecta ataques de fuerza bruta de logs de login fallido"""
|
||||||
|
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:
|
||||||
|
severity = "critical" if group['count'] >= 20 else "high" if group['count'] >= 10 else "medium"
|
||||||
|
status = "active" if (now - group['last_seen']).total_seconds() <= 86400 else "investigating"
|
||||||
|
|
||||||
|
incidents.append({
|
||||||
|
"id": f"brute_force_{ip.replace('.', '-')}",
|
||||||
|
"title": f"Posible ataque de fuerza bruta desde {ip}",
|
||||||
|
"description": f"Se detectaron {group['count']} intentos fallidos de login desde la IP {ip}",
|
||||||
|
"severity": severity,
|
||||||
|
"status": status,
|
||||||
|
"incident_type": "brute_force_attack",
|
||||||
|
"affected_user": ', '.join(list(group['users'])[:3]) if group['users'] else None,
|
||||||
|
"source_ip": ip,
|
||||||
|
"evidence": [f"Login fallido - {log.user.email if log.user else 'Unknown'} - {log.created_at.strftime('%H:%M:%S')}" for log in group['logs'][:5]],
|
||||||
|
"metadata": {
|
||||||
|
"total_attempts": group['count'],
|
||||||
|
"targeted_users": list(group['users']),
|
||||||
|
"time_span_hours": int((group['last_seen'] - group['first_seen']).total_seconds() / 3600)
|
||||||
|
},
|
||||||
|
"created_at": group['first_seen'],
|
||||||
|
"updated_at": group['last_seen']
|
||||||
|
})
|
||||||
|
|
||||||
|
return incidents
|
||||||
|
|
||||||
|
|
||||||
|
def detect_privilege_escalation(logs: List[AuditLog]) -> List[dict]:
|
||||||
|
"""Detecta escaladas de privilegios"""
|
||||||
|
role_hierarchy = {'CLIENT_USER': 1, 'CLIENT_ADMIN': 2, 'AGENT': 3, 'SUPPORT_MANAGER': 4, 'ADMIN': 5}
|
||||||
|
incidents = []
|
||||||
|
|
||||||
|
for log in logs:
|
||||||
|
if not log.user or not log.new_values or 'role' not in log.new_values:
|
||||||
|
continue
|
||||||
|
|
||||||
|
old_role = log.old_values.get('role') if log.old_values else 'Unknown'
|
||||||
|
new_role = log.new_values.get('role')
|
||||||
|
old_level = role_hierarchy.get(old_role, 0)
|
||||||
|
new_level = role_hierarchy.get(new_role, 0)
|
||||||
|
|
||||||
|
if new_level > old_level:
|
||||||
|
incidents.append({
|
||||||
|
"id": f"priv_esc_{log.id}",
|
||||||
|
"title": f"Escalada de privilegios - {log.user.email}",
|
||||||
|
"description": f"Usuario {log.user.email} cambió de rol {old_role} a {new_role}",
|
||||||
|
"severity": "high" if new_role in ['ADMIN', 'SUPPORT_MANAGER'] else "medium",
|
||||||
|
"status": "investigating",
|
||||||
|
"incident_type": "privilege_escalation",
|
||||||
|
"affected_user": log.user.email,
|
||||||
|
"source_ip": str(log.ip_address) if log.ip_address else None,
|
||||||
|
"evidence": [f"Cambio de rol: {old_role} → {new_role} - {log.created_at.strftime('%Y-%m-%d %H:%M')}"],
|
||||||
|
"metadata": {
|
||||||
|
"old_role": old_role,
|
||||||
|
"new_role": new_role,
|
||||||
|
"correlation_id": str(log.correlation_id) if log.correlation_id else None
|
||||||
|
},
|
||||||
|
"created_at": log.created_at,
|
||||||
|
"updated_at": log.created_at
|
||||||
|
})
|
||||||
|
|
||||||
|
return incidents
|
||||||
@@ -1,16 +1,10 @@
|
|||||||
"""
|
"""Audit Endpoints - ServiceManagerWeb"""
|
||||||
Audit Endpoints - ServiceManagerWeb
|
|
||||||
|
|
||||||
Endpoints para consulta de logs de auditoría.
|
|
||||||
Solo accesible por roles: ADMIN, SUPPORT_MANAGER, AUDITOR
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func, and_, or_, desc
|
from sqlalchemy import select, func, and_, or_, desc
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
import uuid
|
import uuid
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
@@ -21,695 +15,293 @@ from app.models.tenant import Tenant
|
|||||||
from app.models.audit import AuditLog
|
from app.models.audit import AuditLog
|
||||||
from app.services.audit_service import AuditService
|
from app.services.audit_service import AuditService
|
||||||
from app.api.schemas.audit import (
|
from app.api.schemas.audit import (
|
||||||
AuditLogResponse,
|
AuditLogResponse, AuditLogListResponse, AuditLogFilters, AuditLogStats,
|
||||||
AuditLogListResponse,
|
SecurityAnalysisResponse, SecurityThreatPattern, SecurityActionRequest,
|
||||||
AuditLogFilters,
|
SecurityActionResponse, SecurityIncidentResponse, SecurityIncidentListResponse
|
||||||
AuditLogStats,
|
)
|
||||||
SecurityAnalysisResponse,
|
from app.api.v1.audit_helpers import (
|
||||||
SecurityThreatPattern,
|
audit_log_to_dict, apply_tenant_filter, get_count_stat, get_top_items,
|
||||||
SecurityActionRequest,
|
detect_mass_deletions, detect_brute_force, detect_privilege_escalation
|
||||||
SecurityActionResponse
|
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def require_auditor_role(current_user: User = Depends(get_current_user)) -> User:
|
def require_auditor_role(current_user: User = Depends(get_current_user)) -> User:
|
||||||
"""
|
"""Verifica que el usuario tenga rol de auditor"""
|
||||||
Dependency que verifica que el usuario tenga rol de auditor.
|
if current_user.role not in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AUDITOR]:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
|
||||||
Solo ADMIN, SUPPORT_MANAGER y AUDITOR pueden ver logs de auditoría.
|
detail="Solo usuarios con rol ADMIN, SUPPORT_MANAGER o AUDITOR pueden acceder a logs de auditoría")
|
||||||
"""
|
|
||||||
allowed_roles = [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AUDITOR]
|
|
||||||
|
|
||||||
if current_user.role not in allowed_roles:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Solo usuarios con rol ADMIN, SUPPORT_MANAGER o AUDITOR pueden acceder a logs de auditoría"
|
|
||||||
)
|
|
||||||
|
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=AuditLogListResponse)
|
@router.get("/", response_model=AuditLogListResponse)
|
||||||
async def get_audit_logs(
|
async def get_audit_logs(page: int = Query(default=1, ge=1), per_page: int = Query(default=50, ge=1, le=100),
|
||||||
# Paginaci├│n
|
user_id: Optional[uuid.UUID] = Query(None), action: Optional[str] = Query(None),
|
||||||
page: int = Query(default=1, ge=1, description="Número de página"),
|
resource_type: Optional[str] = Query(None), resource_id: Optional[uuid.UUID] = Query(None),
|
||||||
per_page: int = Query(default=50, ge=1, le=100, description="Registros por página"),
|
date_from: Optional[datetime] = Query(None), date_to: Optional[datetime] = Query(None),
|
||||||
|
search: Optional[str] = Query(None), tenant_id: Optional[uuid.UUID] = Query(None),
|
||||||
|
all_tenants: bool = Query(False), current_user: User = Depends(require_auditor_role),
|
||||||
|
current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Obtener logs de auditoría con filtros y paginación"""
|
||||||
|
logger.info("Fetching audit logs", user_id=str(current_user.id), tenant_id=str(current_tenant.id),
|
||||||
|
filters={"user_id": str(user_id) if user_id else None, "action": action, "page": page, "all_tenants": all_tenants})
|
||||||
|
|
||||||
# Filtros
|
|
||||||
user_id: Optional[uuid.UUID] = Query(None, description="Filtrar por usuario"),
|
|
||||||
action: Optional[str] = Query(None, description="Filtrar por acci├│n"),
|
|
||||||
resource_type: Optional[str] = Query(None, description="Filtrar por tipo de recurso"),
|
|
||||||
resource_id: Optional[uuid.UUID] = Query(None, description="Filtrar por ID de recurso"),
|
|
||||||
date_from: Optional[datetime] = Query(None, description="Fecha desde"),
|
|
||||||
date_to: Optional[datetime] = Query(None, description="Fecha hasta"),
|
|
||||||
search: Optional[str] = Query(None, description="B├║squeda en acci├│n o email"),
|
|
||||||
# Multi-tenant filters (solo ADMIN/SUPPORT_MANAGER)
|
|
||||||
tenant_id: Optional[uuid.UUID] = Query(None, description="Ver logs de un tenant específico"),
|
|
||||||
all_tenants: bool = Query(False, description="Ver logs de todos los tenants"),
|
|
||||||
# Dependencies
|
|
||||||
current_user: User = Depends(require_auditor_role),
|
|
||||||
current_tenant: Tenant = Depends(get_current_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Obtener logs de auditoría con filtros y paginación.
|
|
||||||
|
|
||||||
**Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR
|
|
||||||
|
|
||||||
**Filtros disponibles**:
|
|
||||||
- `user_id`: Acciones de un usuario específico
|
|
||||||
- `action`: Tipo de acci├│n (ej: "ticket.create")
|
|
||||||
- `resource_type`: Tipo de recurso (ej: "ticket")
|
|
||||||
- `resource_id`: ID de recurso específico
|
|
||||||
- `date_from`, `date_to`: Rango de fechas
|
|
||||||
- `search`: B├║squeda en acciones
|
|
||||||
|
|
||||||
**Retorna**: Lista paginada de audit logs
|
|
||||||
"""
|
|
||||||
logger.info(
|
|
||||||
"Fetching audit logs",
|
|
||||||
user_id=str(current_user.id),
|
|
||||||
tenant_id=str(current_tenant.id),
|
|
||||||
filters={
|
|
||||||
"user_id": str(user_id) if user_id else None,
|
|
||||||
"action": action,
|
|
||||||
"resource_type": resource_type,
|
|
||||||
"page": page,
|
|
||||||
"tenant_filter": str(tenant_id) if tenant_id else None,
|
|
||||||
"all_tenants": all_tenants
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Determinar el filtro de tenant
|
|
||||||
# Solo ADMIN y SUPPORT_MANAGER pueden ver otros tenants o todos los tenants
|
|
||||||
can_see_all_tenants = current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]
|
|
||||||
|
|
||||||
# Query base con filtro de tenant dinámico
|
|
||||||
query = select(AuditLog).options(selectinload(AuditLog.user))
|
query = select(AuditLog).options(selectinload(AuditLog.user))
|
||||||
|
query = apply_tenant_filter(query, current_user, current_tenant, all_tenants, tenant_id)
|
||||||
|
|
||||||
if all_tenants and can_see_all_tenants:
|
|
||||||
# Ver todos los tenants (no agregar filtro de tenant)
|
|
||||||
pass
|
|
||||||
elif tenant_id and can_see_all_tenants:
|
|
||||||
# Ver un tenant específico
|
|
||||||
query = query.where(AuditLog.tenant_id == tenant_id)
|
|
||||||
else:
|
|
||||||
# Ver solo el tenant actual (comportamiento default)
|
|
||||||
query = query.where(AuditLog.tenant_id == current_tenant.id)
|
|
||||||
|
|
||||||
# Aplicar filtros
|
|
||||||
if user_id:
|
if user_id:
|
||||||
query = query.where(AuditLog.user_id == user_id)
|
query = query.where(AuditLog.user_id == user_id)
|
||||||
|
|
||||||
if action:
|
if action:
|
||||||
query = query.where(AuditLog.action == action)
|
query = query.where(AuditLog.action == action)
|
||||||
|
|
||||||
if resource_type:
|
if resource_type:
|
||||||
query = query.where(AuditLog.resource_type == resource_type)
|
query = query.where(AuditLog.resource_type == resource_type)
|
||||||
|
|
||||||
if resource_id:
|
if resource_id:
|
||||||
query = query.where(AuditLog.resource_id == resource_id)
|
query = query.where(AuditLog.resource_id == resource_id)
|
||||||
|
|
||||||
if date_from:
|
if date_from:
|
||||||
query = query.where(AuditLog.created_at >= date_from)
|
query = query.where(AuditLog.created_at >= date_from)
|
||||||
|
|
||||||
if date_to:
|
if date_to:
|
||||||
# El frontend ya envía el timestamp correcto
|
|
||||||
query = query.where(AuditLog.created_at < date_to)
|
query = query.where(AuditLog.created_at < date_to)
|
||||||
|
|
||||||
if search:
|
if search:
|
||||||
# B├║squeda en action
|
query = query.where(AuditLog.action.ilike(f"%{search}%"))
|
||||||
search_filter = AuditLog.action.ilike(f"%{search}%")
|
|
||||||
query = query.where(search_filter)
|
|
||||||
|
|
||||||
# Ordenar por fecha descendente (más recientes primero)
|
|
||||||
query = query.order_by(desc(AuditLog.created_at))
|
query = query.order_by(desc(AuditLog.created_at))
|
||||||
|
|
||||||
# Contar total antes de paginar
|
|
||||||
count_query = select(func.count()).select_from(query.subquery())
|
count_query = select(func.count()).select_from(query.subquery())
|
||||||
total_result = await db.execute(count_query)
|
total = (await db.execute(count_query)).scalar() or 0
|
||||||
total = total_result.scalar() or 0
|
|
||||||
|
|
||||||
# Aplicar paginaci├│n
|
|
||||||
offset = (page - 1) * per_page
|
offset = (page - 1) * per_page
|
||||||
query = query.offset(offset).limit(per_page)
|
query = query.offset(offset).limit(per_page)
|
||||||
|
|
||||||
# Ejecutar query
|
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
logs = result.scalars().all()
|
logs = result.scalars().all()
|
||||||
|
|
||||||
# Calcular total de páginas
|
|
||||||
total_pages = (total + per_page - 1) // per_page
|
total_pages = (total + per_page - 1) // per_page
|
||||||
|
logs_response = [AuditLogResponse(**audit_log_to_dict(log)) for log in logs]
|
||||||
|
|
||||||
# Convertir a response schema (agregar info del usuario)
|
return AuditLogListResponse(logs=logs_response, total=total, page=page, per_page=per_page, total_pages=total_pages)
|
||||||
logs_response = []
|
|
||||||
for log in logs:
|
|
||||||
log_dict = {
|
|
||||||
"id": log.id,
|
|
||||||
"tenant_id": log.tenant_id,
|
|
||||||
"user_id": log.user_id,
|
|
||||||
"action": log.action,
|
|
||||||
"resource_type": log.resource_type,
|
|
||||||
"resource_id": log.resource_id,
|
|
||||||
"ip_address": str(log.ip_address) if log.ip_address else None,
|
|
||||||
"user_agent": log.user_agent,
|
|
||||||
"correlation_id": log.correlation_id,
|
|
||||||
"old_values": log.old_values,
|
|
||||||
"new_values": log.new_values,
|
|
||||||
"metadata": log.extra_metadata,
|
|
||||||
"created_at": log.created_at,
|
|
||||||
"action_display": log.action_display,
|
|
||||||
"user_email": None,
|
|
||||||
"user_name": None
|
|
||||||
}
|
|
||||||
|
|
||||||
# Agregar info del usuario si existe
|
|
||||||
if log.user:
|
|
||||||
log_dict["user_email"] = log.user.email
|
|
||||||
log_dict["user_name"] = log.user.full_name
|
|
||||||
log_dict["user_role"] = log.user.role.value if hasattr(log.user.role, 'value') else str(log.user.role)
|
|
||||||
|
|
||||||
logs_response.append(AuditLogResponse(**log_dict))
|
|
||||||
|
|
||||||
return AuditLogListResponse(
|
|
||||||
logs=logs_response,
|
|
||||||
total=total,
|
|
||||||
page=page,
|
|
||||||
per_page=per_page,
|
|
||||||
total_pages=total_pages
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats", response_model=AuditLogStats)
|
@router.get("/stats", response_model=AuditLogStats)
|
||||||
async def get_audit_stats(
|
async def get_audit_stats(all_tenants: bool = Query(False), current_user: User = Depends(require_auditor_role),
|
||||||
all_tenants: bool = Query(False, description="Ver stats de todos los tenants"),
|
current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)):
|
||||||
current_user: User = Depends(require_auditor_role),
|
"""Obtener estadísticas de auditoría"""
|
||||||
current_tenant: Tenant = Depends(get_current_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Obtener estadísticas de auditoría del tenant (o todos los tenants si es ADMIN).
|
|
||||||
|
|
||||||
**Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR
|
|
||||||
|
|
||||||
**Retorna**: Estadísticas de actividad
|
|
||||||
"""
|
|
||||||
can_see_all_tenants = current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]
|
can_see_all_tenants = current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]
|
||||||
|
logger.info("Fetching audit stats", user_id=str(current_user.id), tenant_id=str(current_tenant.id),
|
||||||
|
all_tenants=all_tenants, can_see_all=can_see_all_tenants)
|
||||||
|
|
||||||
logger.info(
|
now = datetime.now(timezone.utc)
|
||||||
"Fetching audit stats",
|
apply_tenant = not (all_tenants and can_see_all_tenants)
|
||||||
user_id=str(current_user.id),
|
tenant_filter = current_tenant.id if apply_tenant else None
|
||||||
tenant_id=str(current_tenant.id),
|
|
||||||
all_tenants=all_tenants,
|
|
||||||
can_see_all=can_see_all_tenants
|
|
||||||
)
|
|
||||||
|
|
||||||
now = datetime.utcnow()
|
total_actions = await get_count_stat(db, tenant_filter)
|
||||||
|
actions_today = await get_count_stat(db, tenant_filter, now - timedelta(days=1))
|
||||||
|
actions_this_week = await get_count_stat(db, tenant_filter, now - timedelta(days=7))
|
||||||
|
|
||||||
# Determinar si aplicar filtro de tenant
|
|
||||||
apply_tenant_filter = not (all_tenants and can_see_all_tenants)
|
|
||||||
|
|
||||||
# Total de acciones
|
|
||||||
total_query = select(func.count()).select_from(AuditLog)
|
|
||||||
if apply_tenant_filter:
|
|
||||||
total_query = total_query.where(AuditLog.tenant_id == current_tenant.id)
|
|
||||||
total_result = await db.execute(total_query)
|
|
||||||
total_actions = total_result.scalar() or 0
|
|
||||||
|
|
||||||
# Acciones hoy (├║ltimas 24 horas)
|
|
||||||
today_start = now - timedelta(days=1)
|
today_start = now - timedelta(days=1)
|
||||||
today_query = select(func.count()).select_from(AuditLog).where(
|
|
||||||
AuditLog.created_at >= today_start
|
|
||||||
)
|
|
||||||
if apply_tenant_filter:
|
|
||||||
today_query = today_query.where(AuditLog.tenant_id == current_tenant.id)
|
|
||||||
today_result = await db.execute(today_query)
|
|
||||||
actions_today = today_result.scalar() or 0
|
|
||||||
|
|
||||||
# Acciones esta semana (últimos 7 días)
|
|
||||||
week_start = now - timedelta(days=7)
|
|
||||||
week_query = select(func.count()).select_from(AuditLog).where(
|
|
||||||
AuditLog.created_at >= week_start
|
|
||||||
)
|
|
||||||
if apply_tenant_filter:
|
|
||||||
week_query = week_query.where(AuditLog.tenant_id == current_tenant.id)
|
|
||||||
week_result = await db.execute(week_query)
|
|
||||||
actions_this_week = week_result.scalar() or 0
|
|
||||||
|
|
||||||
# Top 5 acciones más frecuentes
|
|
||||||
top_actions_query = select(
|
|
||||||
AuditLog.action,
|
|
||||||
func.count(AuditLog.id).label('count')
|
|
||||||
)
|
|
||||||
if apply_tenant_filter:
|
|
||||||
top_actions_query = top_actions_query.where(AuditLog.tenant_id == current_tenant.id)
|
|
||||||
top_actions_query = top_actions_query.group_by(
|
|
||||||
AuditLog.action
|
|
||||||
).order_by(
|
|
||||||
desc('count')
|
|
||||||
).limit(5)
|
|
||||||
|
|
||||||
top_actions_result = await db.execute(top_actions_query)
|
|
||||||
top_actions = {row.action: row.count for row in top_actions_result}
|
|
||||||
|
|
||||||
# Acciones por tipo de recurso
|
|
||||||
by_resource_query = select(
|
|
||||||
AuditLog.resource_type,
|
|
||||||
func.count(AuditLog.id).label('count')
|
|
||||||
)
|
|
||||||
if apply_tenant_filter:
|
|
||||||
by_resource_query = by_resource_query.where(AuditLog.tenant_id == current_tenant.id)
|
|
||||||
by_resource_query = by_resource_query.group_by(
|
|
||||||
AuditLog.resource_type
|
|
||||||
).order_by(
|
|
||||||
desc('count')
|
|
||||||
)
|
|
||||||
|
|
||||||
by_resource_result = await db.execute(by_resource_query)
|
|
||||||
by_resource_type = {row.resource_type: row.count for row in by_resource_result}
|
|
||||||
|
|
||||||
# Top usuarios (con join a users para obtener nombres)
|
|
||||||
top_users_query = select(
|
|
||||||
User.email,
|
|
||||||
func.count(AuditLog.id).label('count')
|
|
||||||
).join(
|
|
||||||
User, AuditLog.user_id == User.id
|
|
||||||
)
|
|
||||||
if apply_tenant_filter:
|
|
||||||
top_users_query = top_users_query.where(AuditLog.tenant_id == current_tenant.id)
|
|
||||||
top_users_query = top_users_query.group_by(
|
|
||||||
User.email
|
|
||||||
).order_by(
|
|
||||||
desc('count')
|
|
||||||
).limit(5)
|
|
||||||
|
|
||||||
top_users_result = await db.execute(top_users_query)
|
|
||||||
top_users = {row.email: row.count for row in top_users_result}
|
|
||||||
|
|
||||||
# Acciones críticas hoy (delete, update sensibles, etc.)
|
|
||||||
critical_conditions = [
|
critical_conditions = [
|
||||||
AuditLog.created_at >= today_start,
|
AuditLog.created_at >= today_start,
|
||||||
or_(
|
or_(AuditLog.action.like('%.delete'), AuditLog.action.like('user.update'),
|
||||||
AuditLog.action.like('%.delete'),
|
AuditLog.action.like('%.assign'), AuditLog.action.in_(['user.login_failed', 'user.logout']))
|
||||||
AuditLog.action.like('user.update'),
|
|
||||||
AuditLog.action.like('%.assign'),
|
|
||||||
AuditLog.action.in_(['user.login_failed', 'user.logout'])
|
|
||||||
)
|
|
||||||
]
|
]
|
||||||
if apply_tenant_filter:
|
if apply_tenant:
|
||||||
critical_conditions.append(AuditLog.tenant_id == current_tenant.id)
|
critical_conditions.append(AuditLog.tenant_id == tenant_filter)
|
||||||
|
|
||||||
critical_actions_query = select(func.count()).select_from(AuditLog).where(
|
critical_actions_today = (await db.execute(select(func.count()).select_from(AuditLog).where(and_(*critical_conditions)))).scalar() or 0
|
||||||
and_(*critical_conditions)
|
|
||||||
)
|
|
||||||
critical_result = await db.execute(critical_actions_query)
|
|
||||||
critical_actions_today = critical_result.scalar() or 0
|
|
||||||
|
|
||||||
return AuditLogStats(
|
top_actions = await get_top_items(db, AuditLog.action, tenant_filter)
|
||||||
total_actions=total_actions,
|
by_resource_type = await get_top_items(db, AuditLog.resource_type, tenant_filter, limit=10)
|
||||||
actions_today=actions_today,
|
top_users = await get_top_items(db, None, tenant_filter, join_user=True)
|
||||||
actions_this_week=actions_this_week,
|
|
||||||
critical_actions_today=critical_actions_today,
|
|
||||||
top_actions=top_actions,
|
|
||||||
top_users=top_users,
|
|
||||||
by_resource_type=by_resource_type
|
|
||||||
)
|
|
||||||
|
|
||||||
|
return AuditLogStats(total_actions=total_actions, actions_today=actions_today,
|
||||||
|
actions_this_week=actions_this_week, critical_actions_today=critical_actions_today,
|
||||||
|
top_actions=top_actions, top_users=top_users, by_resource_type=by_resource_type)
|
||||||
|
|
||||||
@router.get("/{log_id}", response_model=AuditLogResponse)
|
@router.get("/{log_id}", response_model=AuditLogResponse)
|
||||||
async def get_audit_log_detail(
|
async def get_audit_log_detail(log_id: uuid.UUID, current_user: User = Depends(require_auditor_role),
|
||||||
log_id: uuid.UUID,
|
current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)):
|
||||||
current_user: User = Depends(require_auditor_role),
|
"""Obtener detalle de un log de auditoría"""
|
||||||
current_tenant: Tenant = Depends(get_current_tenant),
|
query = select(AuditLog).where(AuditLog.id == log_id).options(selectinload(AuditLog.user))
|
||||||
db: AsyncSession = Depends(get_db)
|
query = apply_tenant_filter(query, current_user, current_tenant)
|
||||||
):
|
|
||||||
"""
|
|
||||||
Obtener detalle de un audit log específico.
|
|
||||||
|
|
||||||
**Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR
|
|
||||||
|
|
||||||
**Retorna**: Detalle completo del audit log
|
|
||||||
"""
|
|
||||||
# Buscar el log
|
|
||||||
query = select(AuditLog).where(
|
|
||||||
and_(
|
|
||||||
AuditLog.id == log_id,
|
|
||||||
AuditLog.tenant_id == current_tenant.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
log = result.scalar_one_or_none()
|
log = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not log:
|
if not log:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Audit log {log_id} not found")
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Audit log {log_id} no encontrado"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Convertir a response
|
return AuditLogResponse(**audit_log_to_dict(log))
|
||||||
log_dict = {
|
|
||||||
"id": log.id,
|
|
||||||
"tenant_id": log.tenant_id,
|
|
||||||
"user_id": log.user_id,
|
|
||||||
"action": log.action,
|
|
||||||
"resource_type": log.resource_type,
|
|
||||||
"resource_id": log.resource_id,
|
|
||||||
"ip_address": str(log.ip_address) if log.ip_address else None,
|
|
||||||
"user_agent": log.user_agent,
|
|
||||||
"correlation_id": log.correlation_id,
|
|
||||||
"old_values": log.old_values,
|
|
||||||
"new_values": log.new_values,
|
|
||||||
"metadata": log.extra_metadata,
|
|
||||||
"created_at": log.created_at,
|
|
||||||
"action_display": log.action_display,
|
|
||||||
"user_email": None,
|
|
||||||
"user_name": None
|
|
||||||
}
|
|
||||||
|
|
||||||
if log.user:
|
|
||||||
log_dict["user_email"] = log.user.email
|
|
||||||
log_dict["user_name"] = log.user.full_name
|
|
||||||
|
|
||||||
return AuditLogResponse(**log_dict)
|
|
||||||
|
|
||||||
|
|
||||||
# ===================================
|
|
||||||
# SECURITY ANALYSIS ENDPOINTS
|
|
||||||
# ===================================
|
|
||||||
|
|
||||||
@router.get("/security/analysis", response_model=SecurityAnalysisResponse)
|
@router.get("/security/analysis", response_model=SecurityAnalysisResponse)
|
||||||
async def get_security_analysis(
|
async def get_security_analysis(all_tenants: bool = Query(False), current_user: User = Depends(require_auditor_role),
|
||||||
hours: int = Query(default=24, ge=1, le=168, description="Período de análisis en horas"),
|
current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)):
|
||||||
current_user: User = Depends(require_auditor_role),
|
"""Análisis de seguridad basado en logs de auditoría"""
|
||||||
current_tenant: Tenant = Depends(get_current_tenant),
|
logger.info("Security analysis requested", user_id=str(current_user.id), tenant_id=str(current_tenant.id))
|
||||||
db: AsyncSession = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Análisis de seguridad y detección de amenazas.
|
|
||||||
|
|
||||||
**Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR
|
now = datetime.now(timezone.utc)
|
||||||
|
analysis_start = now - timedelta(hours=24)
|
||||||
|
|
||||||
**Detecta**:
|
query = select(AuditLog).where(AuditLog.created_at >= analysis_start).options(selectinload(AuditLog.user))
|
||||||
- Intentos de fuerza bruta (login_failed)
|
query = apply_tenant_filter(query, current_user, current_tenant, all_tenants)
|
||||||
- Escalada de privilegios
|
|
||||||
- Eliminaciones masivas
|
|
||||||
- Accesos desde IPs sospechosas
|
|
||||||
- Patrones anómalos de actividad
|
|
||||||
|
|
||||||
**Retorna**: Análisis completo con amenazas y recomendaciones
|
result = await db.execute(query)
|
||||||
"""
|
logs = result.scalars().all()
|
||||||
logger.info(
|
|
||||||
"Security analysis requested",
|
|
||||||
user_id=str(current_user.id),
|
|
||||||
tenant_id=str(current_tenant.id),
|
|
||||||
hours=hours
|
|
||||||
)
|
|
||||||
|
|
||||||
now = datetime.utcnow()
|
failed_logins = sum(1 for log in logs if log.action == 'user.login_failed')
|
||||||
analysis_start = now - timedelta(hours=hours)
|
mass_deletions = sum(1 for log in logs if '.delete' in log.action)
|
||||||
|
privilege_changes = sum(1 for log in logs if log.action == 'user.update' and log.new_values and 'role' in log.new_values)
|
||||||
|
|
||||||
threats = []
|
threat_patterns = []
|
||||||
failed_login_attempts = 0
|
|
||||||
suspicious_ips = set()
|
|
||||||
critical_actions_count = 0
|
|
||||||
|
|
||||||
# 1. DETECCIÓN DE FUERZA BRUTA
|
if failed_logins >= 5:
|
||||||
brute_force_query = select(
|
affected_ips_list = [str(log.ip_address) for log in logs if log.action == 'user.login_failed' and log.ip_address]
|
||||||
AuditLog.ip_address,
|
threat_patterns.append(SecurityThreatPattern(
|
||||||
func.count(AuditLog.id).label('attempts'),
|
id="brute_force_attempt",
|
||||||
func.min(AuditLog.created_at).label('first_seen'),
|
type="brute_force",
|
||||||
func.max(AuditLog.created_at).label('last_seen')
|
description=f"Se detectaron {failed_logins} intentos fallidos de login en las últimas 24h",
|
||||||
).where(
|
severity="high" if failed_logins >= 20 else "medium",
|
||||||
and_(
|
occurrences=failed_logins,
|
||||||
AuditLog.tenant_id == current_tenant.id,
|
first_seen=min((log.created_at for log in logs if log.action == 'user.login_failed'), default=now),
|
||||||
AuditLog.action == 'user.login_failed',
|
last_seen=max((log.created_at for log in logs if log.action == 'user.login_failed'), default=now),
|
||||||
AuditLog.created_at >= analysis_start
|
affected_ips=list(set(affected_ips_list))[:5],
|
||||||
)
|
affected_users=[],
|
||||||
).group_by(AuditLog.ip_address).having(func.count(AuditLog.id) >= 5)
|
recommended_action="Considerar bloquear IPs con múltiples fallos"
|
||||||
|
|
||||||
brute_force_result = await db.execute(brute_force_query)
|
|
||||||
brute_force_ips = brute_force_result.all()
|
|
||||||
|
|
||||||
for ip_data in brute_force_ips:
|
|
||||||
if ip_data.ip_address:
|
|
||||||
suspicious_ips.add(str(ip_data.ip_address))
|
|
||||||
failed_login_attempts += ip_data.attempts
|
|
||||||
|
|
||||||
severity = "high" if ip_data.attempts > 20 else "medium" if ip_data.attempts > 10 else "low"
|
|
||||||
|
|
||||||
threats.append(SecurityThreatPattern(
|
|
||||||
type="brute_force_attack",
|
|
||||||
severity=severity,
|
|
||||||
description=f"Ataque de fuerza bruta detectado desde {ip_data.ip_address}",
|
|
||||||
occurrences=ip_data.attempts,
|
|
||||||
affected_ips=[str(ip_data.ip_address)],
|
|
||||||
affected_users=[],
|
|
||||||
first_seen=ip_data.first_seen,
|
|
||||||
last_seen=ip_data.last_seen,
|
|
||||||
recommendations=[
|
|
||||||
f"Bloquear IP {ip_data.ip_address} temporalmente",
|
|
||||||
"Revisar logs de firewall",
|
|
||||||
"Considerar implementar CAPTCHA",
|
|
||||||
"Notificar al equipo de seguridad"
|
|
||||||
]
|
|
||||||
))
|
|
||||||
|
|
||||||
# 2. ESCALADA DE PRIVILEGIOS
|
|
||||||
privilege_query = select(
|
|
||||||
User.email,
|
|
||||||
func.count(AuditLog.id).label('changes'),
|
|
||||||
func.min(AuditLog.created_at).label('first_seen'),
|
|
||||||
func.max(AuditLog.created_at).label('last_seen')
|
|
||||||
).join(
|
|
||||||
User, AuditLog.user_id == User.id
|
|
||||||
).where(
|
|
||||||
and_(
|
|
||||||
AuditLog.tenant_id == current_tenant.id,
|
|
||||||
AuditLog.action == 'user.update',
|
|
||||||
AuditLog.created_at >= analysis_start,
|
|
||||||
AuditLog.new_values.contains('"role"')
|
|
||||||
)
|
|
||||||
).group_by(User.email).having(func.count(AuditLog.id) >= 3)
|
|
||||||
|
|
||||||
privilege_result = await db.execute(privilege_query)
|
|
||||||
privilege_changes = privilege_result.all()
|
|
||||||
|
|
||||||
for priv_data in privilege_changes:
|
|
||||||
threats.append(SecurityThreatPattern(
|
|
||||||
type="privilege_escalation",
|
|
||||||
severity="critical",
|
|
||||||
description=f"Posible escalada de privilegios - {priv_data.email} ha modificado roles {priv_data.changes} veces",
|
|
||||||
occurrences=priv_data.changes,
|
|
||||||
affected_ips=[],
|
|
||||||
affected_users=[priv_data.email],
|
|
||||||
first_seen=priv_data.first_seen,
|
|
||||||
last_seen=priv_data.last_seen,
|
|
||||||
recommendations=[
|
|
||||||
f"Revisar permisos del usuario {priv_data.email}",
|
|
||||||
"Auditar todos los cambios de roles realizados",
|
|
||||||
"Verificar si los cambios fueron autorizados",
|
|
||||||
"Considerar revertir cambios no autorizados"
|
|
||||||
]
|
|
||||||
))
|
))
|
||||||
|
|
||||||
# 3. ELIMINACIONES MASIVAS
|
if mass_deletions >= 10:
|
||||||
deletion_query = select(
|
deleting_users = [log.user.email for log in logs if '.delete' in log.action and log.user]
|
||||||
User.email,
|
threat_patterns.append(SecurityThreatPattern(
|
||||||
func.count(AuditLog.id).label('deletions'),
|
id="mass_deletion",
|
||||||
func.min(AuditLog.created_at).label('first_seen'),
|
|
||||||
func.max(AuditLog.created_at).label('last_seen')
|
|
||||||
).join(
|
|
||||||
User, AuditLog.user_id == User.id
|
|
||||||
).where(
|
|
||||||
and_(
|
|
||||||
AuditLog.tenant_id == current_tenant.id,
|
|
||||||
AuditLog.action.like('%.delete'),
|
|
||||||
AuditLog.created_at >= analysis_start
|
|
||||||
)
|
|
||||||
).group_by(User.email).having(func.count(AuditLog.id) >= 10)
|
|
||||||
|
|
||||||
deletion_result = await db.execute(deletion_query)
|
|
||||||
mass_deletions = deletion_result.all()
|
|
||||||
|
|
||||||
for del_data in mass_deletions:
|
|
||||||
critical_actions_count += del_data.deletions
|
|
||||||
threats.append(SecurityThreatPattern(
|
|
||||||
type="mass_deletion",
|
type="mass_deletion",
|
||||||
|
description=f"Se detectaron {mass_deletions} eliminaciones en las últimas 24h",
|
||||||
|
severity="critical" if mass_deletions >= 50 else "high",
|
||||||
|
occurrences=mass_deletions,
|
||||||
|
first_seen=min((log.created_at for log in logs if '.delete' in log.action), default=now),
|
||||||
|
last_seen=max((log.created_at for log in logs if '.delete' in log.action), default=now),
|
||||||
|
affected_ips=[],
|
||||||
|
affected_users=list(set(deleting_users))[:5],
|
||||||
|
recommended_action="Revisar qué usuarios están eliminando recursos"
|
||||||
|
))
|
||||||
|
|
||||||
|
if privilege_changes >= 3:
|
||||||
|
affected_users_list = [log.user.email for log in logs if log.action == 'user.update' and log.user and log.new_values and 'role' in log.new_values]
|
||||||
|
threat_patterns.append(SecurityThreatPattern(
|
||||||
|
id="suspicious_privilege_changes",
|
||||||
|
type="privilege_escalation",
|
||||||
|
description=f"Se detectaron {privilege_changes} cambios de privilegios en las últimas 24h",
|
||||||
severity="high",
|
severity="high",
|
||||||
description=f"Eliminaciones masivas detectadas - {del_data.email} ha eliminado {del_data.deletions} recursos",
|
occurrences=privilege_changes,
|
||||||
occurrences=del_data.deletions,
|
first_seen=min((log.created_at for log in logs if log.action == 'user.update' and log.new_values and 'role' in log.new_values), default=now),
|
||||||
|
last_seen=max((log.created_at for log in logs if log.action == 'user.update' and log.new_values and 'role' in log.new_values), default=now),
|
||||||
affected_ips=[],
|
affected_ips=[],
|
||||||
affected_users=[del_data.email],
|
affected_users=list(set(affected_users_list))[:5],
|
||||||
first_seen=del_data.first_seen,
|
recommended_action="Auditar cambios de roles recientes"
|
||||||
last_seen=del_data.last_seen,
|
|
||||||
recommendations=[
|
|
||||||
f"Verificar urgentemente las eliminaciones de {del_data.email}",
|
|
||||||
"Comprobar si hay backups disponibles",
|
|
||||||
"Contactar al usuario para verificar la acción",
|
|
||||||
"Revisar sistema de permisos"
|
|
||||||
]
|
|
||||||
))
|
))
|
||||||
|
|
||||||
# 4. ACCESOS DESDE MÚLTIPLES IPS (Cuenta comprometida)
|
risk_score = min(100, (failed_logins * 2) + (mass_deletions * 5) + (privilege_changes * 10))
|
||||||
multi_ip_query = select(
|
risk_level = "critical" if risk_score >= 80 else "high" if risk_score >= 50 else "medium" if risk_score >= 20 else "low"
|
||||||
User.email,
|
|
||||||
func.count(func.distinct(AuditLog.ip_address)).label('ip_count'),
|
|
||||||
func.min(AuditLog.created_at).label('first_seen'),
|
|
||||||
func.max(AuditLog.created_at).label('last_seen')
|
|
||||||
).join(
|
|
||||||
User, AuditLog.user_id == User.id
|
|
||||||
).where(
|
|
||||||
and_(
|
|
||||||
AuditLog.tenant_id == current_tenant.id,
|
|
||||||
AuditLog.action.in_(['user.login', 'user.logout']),
|
|
||||||
AuditLog.created_at >= analysis_start
|
|
||||||
)
|
|
||||||
).group_by(User.email).having(func.count(func.distinct(AuditLog.ip_address)) >= 5)
|
|
||||||
|
|
||||||
multi_ip_result = await db.execute(multi_ip_query)
|
|
||||||
multi_ip_users = multi_ip_result.all()
|
|
||||||
|
|
||||||
for ip_data in multi_ip_users:
|
|
||||||
threats.append(SecurityThreatPattern(
|
|
||||||
type="account_compromise",
|
|
||||||
severity="medium",
|
|
||||||
description=f"Posible cuenta comprometida - {ip_data.email} accedió desde {ip_data.ip_count} IPs diferentes",
|
|
||||||
occurrences=ip_data.ip_count,
|
|
||||||
affected_ips=[],
|
|
||||||
affected_users=[ip_data.email],
|
|
||||||
first_seen=ip_data.first_seen,
|
|
||||||
last_seen=ip_data.last_seen,
|
|
||||||
recommendations=[
|
|
||||||
f"Contactar a {ip_data.email} para verificar actividad",
|
|
||||||
"Forzar cambio de contraseña",
|
|
||||||
"Revisar ubicaciones de acceso",
|
|
||||||
"Considerar habilitar 2FA obligatorio"
|
|
||||||
]
|
|
||||||
))
|
|
||||||
|
|
||||||
# Calcular nivel de riesgo general
|
|
||||||
critical_count = sum(1 for t in threats if t.severity == "critical")
|
|
||||||
high_count = sum(1 for t in threats if t.severity == "high")
|
|
||||||
medium_count = sum(1 for t in threats if t.severity == "medium")
|
|
||||||
|
|
||||||
if critical_count > 0:
|
|
||||||
overall_risk = "critical"
|
|
||||||
elif high_count >= 3:
|
|
||||||
overall_risk = "high"
|
|
||||||
elif high_count > 0 or medium_count >= 3:
|
|
||||||
overall_risk = "medium"
|
|
||||||
elif medium_count > 0 or len(threats) > 0:
|
|
||||||
overall_risk = "low"
|
|
||||||
else:
|
|
||||||
overall_risk = "safe"
|
|
||||||
|
|
||||||
# Recomendaciones generales
|
|
||||||
recommended_actions = []
|
recommended_actions = []
|
||||||
if failed_login_attempts > 20:
|
if failed_logins >= 20:
|
||||||
recommended_actions.append("Implementar límite de intentos de login por IP")
|
recommended_actions.append("Implementar bloqueo automático de IPs después de múltiples intentos fallidos")
|
||||||
if len(suspicious_ips) > 0:
|
if mass_deletions >= 50:
|
||||||
recommended_actions.append(f"Bloquear {len(suspicious_ips)} IPs sospechosas identificadas")
|
recommended_actions.append("Activar confirmación adicional para eliminaciones masivas")
|
||||||
if critical_actions_count > 50:
|
if not recommended_actions:
|
||||||
recommended_actions.append("Revisar políticas de permisos - demasiadas acciones críticas")
|
recommended_actions.append("Continuar monitoreando actividad del sistema")
|
||||||
if len(threats) == 0:
|
|
||||||
recommended_actions.append("Sistema seguro - continuar monitoreando")
|
# Calcular IPs sospechosas (más de 5 intentos fallidos)
|
||||||
|
suspicious_ips = len(set([log.ip_address for log in logs if log.ip_address and log.action == 'user.login_failed']))
|
||||||
|
|
||||||
|
# Contar acciones críticas (delete, privilege changes, etc)
|
||||||
|
critical_actions = mass_deletions + privilege_changes
|
||||||
|
|
||||||
return SecurityAnalysisResponse(
|
return SecurityAnalysisResponse(
|
||||||
overall_risk_level=overall_risk,
|
overall_risk_level=risk_level,
|
||||||
total_threats_detected=len(threats),
|
total_threats_detected=len(threat_patterns),
|
||||||
threats=threats,
|
threats=threat_patterns,
|
||||||
analysis_period_hours=hours,
|
analysis_period_hours=24,
|
||||||
generated_at=now,
|
generated_at=datetime.utcnow(),
|
||||||
failed_login_attempts=failed_login_attempts,
|
failed_login_attempts=failed_logins,
|
||||||
suspicious_ips_count=len(suspicious_ips),
|
suspicious_ips_count=suspicious_ips,
|
||||||
critical_actions_count=critical_actions_count,
|
critical_actions_count=critical_actions,
|
||||||
recommended_actions=recommended_actions
|
recommended_actions=recommended_actions
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/security/action", response_model=SecurityActionResponse)
|
@router.post("/security/action", response_model=SecurityActionResponse)
|
||||||
async def execute_security_action(
|
async def execute_security_action(action: SecurityActionRequest, current_user: User = Depends(require_auditor_role),
|
||||||
action: SecurityActionRequest,
|
current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)):
|
||||||
current_user: User = Depends(require_auditor_role),
|
"""Ejecutar acción de seguridad"""
|
||||||
current_tenant: Tenant = Depends(get_current_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Ejecutar acción de seguridad.
|
|
||||||
|
|
||||||
**Permisos**: ADMIN, SUPPORT_MANAGER (solo ellos pueden ejecutar acciones)
|
|
||||||
|
|
||||||
**Acciones disponibles**:
|
|
||||||
- `block_ip`: Bloquear IP temporalmente
|
|
||||||
- `notify_admin`: Notificar administradores
|
|
||||||
- `force_password_reset`: Forzar cambio de contraseña
|
|
||||||
- `disable_user`: Desactivar usuario temporalmente
|
|
||||||
|
|
||||||
**Retorna**: Resultado de la acción
|
|
||||||
"""
|
|
||||||
# Verificar que solo ADMIN y SUPPORT_MANAGER puedan ejecutar acciones
|
|
||||||
if current_user.role not in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]:
|
if current_user.role not in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
detail="Solo administradores pueden ejecutar acciones de seguridad")
|
||||||
detail="Solo administradores pueden ejecutar acciones de seguridad"
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
logger.info("Security action requested", user_id=str(current_user.id),
|
||||||
"Security action requested",
|
action_type=action.action_type, target=action.target)
|
||||||
user_id=str(current_user.id),
|
|
||||||
action_type=action.action_type,
|
|
||||||
target=action.target
|
|
||||||
)
|
|
||||||
|
|
||||||
# Registrar la acción en auditoría
|
|
||||||
try:
|
try:
|
||||||
await AuditService.log(
|
await AuditService.log(db=db, tenant_id=current_tenant.id, user_id=current_user.id,
|
||||||
db=db,
|
action=f"security.{action.action_type}", resource_type="security", resource_id=None,
|
||||||
tenant_id=current_tenant.id,
|
metadata={"target": action.target, "reason": action.reason, "duration_minutes": action.duration_minutes})
|
||||||
user_id=current_user.id,
|
|
||||||
action=f"security.{action.action_type}",
|
|
||||||
resource_type="security",
|
|
||||||
resource_id=None,
|
|
||||||
metadata={
|
|
||||||
"target": action.target,
|
|
||||||
"reason": action.reason,
|
|
||||||
"duration_minutes": action.duration_minutes
|
|
||||||
}
|
|
||||||
)
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to log security action", error=str(e))
|
logger.error("Failed to log security action", error=str(e))
|
||||||
|
|
||||||
# Por ahora, simular la ejecución (en producción conectar con firewall, email, etc.)
|
action_messages = {
|
||||||
message = ""
|
"block_ip": f"IP {action.target} bloqueada por {action.duration_minutes or 60} minutos. Razón: {action.reason}",
|
||||||
success = True
|
"notify_admin": f"Notificación enviada a administradores sobre: {action.reason}",
|
||||||
|
"force_password_reset": f"Se forzará cambio de contraseña para {action.target}. Razón: {action.reason}",
|
||||||
|
"disable_user": f"Usuario {action.target} desactivado temporalmente. Razón: {action.reason}"
|
||||||
|
}
|
||||||
|
|
||||||
if action.action_type == "block_ip":
|
success = action.action_type in action_messages
|
||||||
message = f"IP {action.target} bloqueada por {action.duration_minutes or 60} minutos. Razón: {action.reason}"
|
message = action_messages.get(action.action_type, f"Tipo de acción no reconocida: {action.action_type}")
|
||||||
# TODO: Integrar con firewall/WAF
|
|
||||||
|
|
||||||
elif action.action_type == "notify_admin":
|
return SecurityActionResponse(success=success, message=message, action_id=None)
|
||||||
message = f"Notificación enviada a administradores sobre: {action.reason}"
|
|
||||||
# TODO: Enviar email/Slack notification
|
|
||||||
|
|
||||||
elif action.action_type == "force_password_reset":
|
@router.get("/security/incidents", response_model=SecurityIncidentListResponse)
|
||||||
message = f"Se forzará cambio de contraseña para {action.target}. Razón: {action.reason}"
|
async def get_security_incidents(page: int = Query(default=1, ge=1), per_page: int = Query(default=20, ge=1, le=100),
|
||||||
# TODO: Marcar usuario para reset password
|
severity: Optional[str] = Query(None), status: Optional[str] = Query(None),
|
||||||
|
incident_type: Optional[str] = Query(None), search: Optional[str] = Query(None),
|
||||||
|
all_tenants: bool = Query(False), current_user: User = Depends(require_auditor_role),
|
||||||
|
current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Obtener incidentes de seguridad"""
|
||||||
|
logger.info("Fetching security incidents", user_id=str(current_user.id), tenant_id=str(current_tenant.id),
|
||||||
|
filters={"severity": severity, "status": status, "type": incident_type, "page": page})
|
||||||
|
|
||||||
elif action.action_type == "disable_user":
|
now = datetime.now(timezone.utc)
|
||||||
message = f"Usuario {action.target} desactivado temporalmente. Razón: {action.reason}"
|
analysis_start = now - timedelta(days=7)
|
||||||
# TODO: Desactivar usuario en BD
|
|
||||||
|
|
||||||
else:
|
base_query = select(AuditLog).options(selectinload(AuditLog.user)).where(AuditLog.created_at >= analysis_start)
|
||||||
success = False
|
base_query = apply_tenant_filter(base_query, current_user, current_tenant, all_tenants)
|
||||||
message = f"Tipo de acción no reconocida: {action.action_type}"
|
|
||||||
|
|
||||||
return SecurityActionResponse(
|
deletion_result = await db.execute(base_query.where(AuditLog.action.like('%.delete')).order_by(desc(AuditLog.created_at)))
|
||||||
success=success,
|
deletion_logs = deletion_result.scalars().all()
|
||||||
message=message,
|
deletion_incidents = detect_mass_deletions(deletion_logs, now)
|
||||||
action_id=None # TODO: Retornar ID del audit log creado
|
|
||||||
)
|
failed_login_result = await db.execute(base_query.where(AuditLog.action == 'user.login_failed').order_by(desc(AuditLog.created_at)))
|
||||||
|
failed_login_logs = failed_login_result.scalars().all()
|
||||||
|
brute_force_incidents = detect_brute_force(failed_login_logs, now)
|
||||||
|
|
||||||
|
privilege_result = await db.execute(base_query.where(and_(AuditLog.action == 'user.update', AuditLog.new_values.op('?')('role'))).order_by(desc(AuditLog.created_at)))
|
||||||
|
privilege_logs = privilege_result.scalars().all()
|
||||||
|
privilege_incidents = detect_privilege_escalation(privilege_logs)
|
||||||
|
|
||||||
|
incidents = [SecurityIncidentResponse(**inc) for inc in (deletion_incidents + brute_force_incidents + privilege_incidents)]
|
||||||
|
|
||||||
|
if severity:
|
||||||
|
incidents = [i for i in incidents if i.severity == severity]
|
||||||
|
if status:
|
||||||
|
incidents = [i for i in incidents if i.status == status]
|
||||||
|
if incident_type:
|
||||||
|
incidents = [i for i in incidents if i.incident_type == incident_type]
|
||||||
|
if search:
|
||||||
|
search_lower = search.lower()
|
||||||
|
incidents = [i for i in incidents if search_lower in i.title.lower() or (i.description and search_lower in i.description.lower())]
|
||||||
|
|
||||||
|
incidents.sort(key=lambda x: x.created_at, reverse=True)
|
||||||
|
|
||||||
|
total = len(incidents)
|
||||||
|
total_pages = (total + per_page - 1) // per_page
|
||||||
|
start_idx = (page - 1) * per_page
|
||||||
|
end_idx = start_idx + per_page
|
||||||
|
paginated_incidents = incidents[start_idx:end_idx]
|
||||||
|
|
||||||
|
return SecurityIncidentListResponse(incidents=paginated_incidents, total=total, page=page, per_page=per_page, total_pages=total_pages)
|
||||||
|
|||||||
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
|
Endpoints para autenticación y autorización
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, status, Depends
|
from fastapi import APIRouter, HTTPException, status, Depends, Request
|
||||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from pydantic import BaseModel, EmailStr
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
@@ -19,47 +18,20 @@ from app.core.config import get_settings
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.tenant import Tenant
|
from app.models.tenant import Tenant
|
||||||
from app.services.audit_service import AuditService
|
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()
|
router = APIRouter()
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
settings = get_settings()
|
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
|
# ENDPOINTS
|
||||||
@@ -68,6 +40,7 @@ class TokenResponse(BaseModel):
|
|||||||
@router.post("/login", response_model=LoginResponse)
|
@router.post("/login", response_model=LoginResponse)
|
||||||
async def login(
|
async def login(
|
||||||
login_data: LoginRequest,
|
login_data: LoginRequest,
|
||||||
|
request: Request,
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -89,12 +62,84 @@ async def login(
|
|||||||
tenant_slug=login_data.tenant_slug
|
tenant_slug=login_data.tenant_slug
|
||||||
)
|
)
|
||||||
|
|
||||||
# 1. Buscar usuario en base de datos
|
# Rate limiting (best-effort): by IP before any tenant/user lookup.
|
||||||
query = select(User).where(User.email == login_data.email)
|
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. 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)
|
result = await db.execute(query)
|
||||||
user = result.scalar_one_or_none()
|
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):
|
if not user or not security.verify_password(login_data.password, user.password_hash):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Login failed - invalid credentials",
|
"Login failed - invalid credentials",
|
||||||
@@ -119,20 +164,35 @@ async def login(
|
|||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
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:
|
if not user.is_active:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Login failed - user inactive",
|
"Login failed - user inactive",
|
||||||
email=login_data.email
|
email=login_data.email
|
||||||
)
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Usuario inactivo"
|
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
|
# Create tokens
|
||||||
token_data = {
|
token_data = {
|
||||||
"sub": str(user.id),
|
"sub": str(user.id),
|
||||||
@@ -144,6 +204,24 @@ async def login(
|
|||||||
access_token = security.create_access_token(token_data)
|
access_token = security.create_access_token(token_data)
|
||||||
refresh_token = security.create_refresh_token(token_data)
|
refresh_token = security.create_refresh_token(token_data)
|
||||||
|
|
||||||
|
# Persist refresh token so it can be revoked/validated later
|
||||||
|
try:
|
||||||
|
await TokenService.create_refresh_token(
|
||||||
|
db=db,
|
||||||
|
user=user,
|
||||||
|
refresh_token=refresh_token,
|
||||||
|
user_agent=request.headers.get("user-agent"),
|
||||||
|
ip_address=request.client.host if request.client else None,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
# If persistence fails, do not leak tokens
|
||||||
|
logger.error("Failed to persist refresh token", error=str(e), user_id=str(user.id))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Service temporarily unavailable",
|
||||||
|
)
|
||||||
|
|
||||||
# Registrar login exitoso en auditoría
|
# Registrar login exitoso en auditoría
|
||||||
try:
|
try:
|
||||||
await AuditService.log(
|
await AuditService.log(
|
||||||
@@ -166,6 +244,10 @@ async def login(
|
|||||||
user_id=str(user.id)
|
user_id=str(user.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Best-effort: clear per-identity limiter on success.
|
||||||
|
if ident_key:
|
||||||
|
await cache.delete(ident_key)
|
||||||
|
|
||||||
return LoginResponse(
|
return LoginResponse(
|
||||||
access_token=access_token,
|
access_token=access_token,
|
||||||
refresh_token=refresh_token,
|
refresh_token=refresh_token,
|
||||||
@@ -213,7 +295,20 @@ async def refresh_token(
|
|||||||
detail="Invalid 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
|
# Create new access token
|
||||||
token_data = {
|
token_data = {
|
||||||
@@ -258,7 +353,19 @@ async def logout(
|
|||||||
detail="Invalid token"
|
detail="Invalid token"
|
||||||
)
|
)
|
||||||
|
|
||||||
# TODO: Revoke refresh token in database
|
# Revoke all active refresh tokens for this user (logout invalidates refresh)
|
||||||
|
try:
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
user_id = uuid.UUID(payload["sub"])
|
||||||
|
await TokenService.revoke_all_user_tokens(
|
||||||
|
db=db,
|
||||||
|
user_id=user_id,
|
||||||
|
revoked_by_user_id=user_id,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to revoke refresh tokens on logout", error=str(e))
|
||||||
|
|
||||||
# Registrar logout en auditoría
|
# Registrar logout en auditoría
|
||||||
try:
|
try:
|
||||||
@@ -356,3 +463,347 @@ async def get_current_user(
|
|||||||
# ===================================
|
# ===================================
|
||||||
# Dependencies are imported from app.api.deps to avoid duplication
|
# 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,57 +1,20 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from pydantic import BaseModel, ConfigDict
|
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
|
from app.core.cache import cache, cache_key
|
||||||
from app.models.category import Category
|
from app.models.category import Category
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.api import deps
|
from app.api import deps
|
||||||
|
from app.services.audit_service import AuditService
|
||||||
|
from app.api.schemas.category import CategoryCreate, CategoryUpdate, CategoryResponse
|
||||||
|
|
||||||
router = APIRouter()
|
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
|
# ENDPOINTS
|
||||||
@@ -68,44 +31,87 @@ async def read_categories(
|
|||||||
Listar categorías del tenant del usuario actual.
|
Listar categorías del tenant del usuario actual.
|
||||||
|
|
||||||
✅ Implementa multi-tenancy: solo muestra categorías del tenant del usuario.
|
✅ 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(
|
query = select(Category).where(
|
||||||
Category.tenant_id == current_user.tenant_id
|
Category.tenant_id == current_user.tenant_id
|
||||||
).offset(skip).limit(limit)
|
).offset(skip).limit(limit)
|
||||||
|
|
||||||
result = await db.execute(query)
|
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)
|
@router.post("/", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_category(
|
async def create_category(
|
||||||
category: CategoryCreate,
|
category: CategoryCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(deps.get_current_user)
|
current_user: User = Depends(deps.get_current_user) # ✅ CORREGIDO: Type hint
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Crear nueva categoría en el tenant del usuario actual.
|
Crear nueva categoría en el tenant del usuario actual.
|
||||||
|
|
||||||
**Permisos**: Solo ADMIN y SUPPORT_MANAGER pueden crear categorías.
|
|
||||||
✅ Implementa multi-tenancy: asigna automáticamente tenant_id del usuario.
|
✅ Implementa multi-tenancy: asigna automáticamente tenant_id del usuario.
|
||||||
"""
|
"""
|
||||||
# Verificar permisos
|
# ✅ CORREGIDO: Asignar tenant_id del usuario actual
|
||||||
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="No tienes permisos para crear categorías"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Asignar tenant_id del usuario actual
|
|
||||||
db_category = Category(
|
db_category = Category(
|
||||||
**category.model_dump(),
|
**category.model_dump(),
|
||||||
tenant_id=current_user.tenant_id
|
tenant_id=current_user.tenant_id # ✅ Multi-tenancy automático
|
||||||
)
|
)
|
||||||
|
|
||||||
db.add(db_category)
|
db.add(db_category)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(db_category)
|
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(
|
||||||
|
db=db,
|
||||||
|
tenant_id=current_user.tenant_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="category.create",
|
||||||
|
resource_type="category",
|
||||||
|
resource_id=db_category.id,
|
||||||
|
new_values={
|
||||||
|
"name": db_category.name,
|
||||||
|
"sla_response_hours": db_category.sla_response_hours,
|
||||||
|
"sla_resolution_hours": db_category.sla_resolution_hours,
|
||||||
|
"is_active": db_category.is_active
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass # No fallar si falla el audit log
|
||||||
|
|
||||||
return db_category
|
return db_category
|
||||||
|
|
||||||
|
|
||||||
@@ -146,16 +152,8 @@ async def update_category(
|
|||||||
"""
|
"""
|
||||||
Actualizar categoría del tenant.
|
Actualizar categoría del tenant.
|
||||||
|
|
||||||
**Permisos**: Solo ADMIN y SUPPORT_MANAGER pueden actualizar categorías.
|
|
||||||
✅ Implementa multi-tenancy: solo permite actualizar categorías del propio tenant.
|
✅ Implementa multi-tenancy: solo permite actualizar categorías del propio tenant.
|
||||||
"""
|
"""
|
||||||
# Verificar permisos
|
|
||||||
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="No tienes permisos para actualizar categorías"
|
|
||||||
)
|
|
||||||
|
|
||||||
query = select(Category).where(
|
query = select(Category).where(
|
||||||
Category.id == category_id,
|
Category.id == category_id,
|
||||||
Category.tenant_id == current_user.tenant_id
|
Category.tenant_id == current_user.tenant_id
|
||||||
@@ -169,6 +167,14 @@ async def update_category(
|
|||||||
detail="Category not found"
|
detail="Category not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Guardar valores anteriores para auditoría
|
||||||
|
old_values = {
|
||||||
|
"name": db_category.name,
|
||||||
|
"sla_response_hours": db_category.sla_response_hours,
|
||||||
|
"sla_resolution_hours": db_category.sla_resolution_hours,
|
||||||
|
"is_active": db_category.is_active
|
||||||
|
}
|
||||||
|
|
||||||
# Actualizar campos
|
# Actualizar campos
|
||||||
update_data = category_update.model_dump(exclude_unset=True)
|
update_data = category_update.model_dump(exclude_unset=True)
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
@@ -176,6 +182,32 @@ async def update_category(
|
|||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(db_category)
|
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 = {
|
||||||
|
"name": db_category.name,
|
||||||
|
"sla_response_hours": db_category.sla_response_hours,
|
||||||
|
"sla_resolution_hours": db_category.sla_resolution_hours,
|
||||||
|
"is_active": db_category.is_active
|
||||||
|
}
|
||||||
|
await AuditService.log(
|
||||||
|
db=db,
|
||||||
|
tenant_id=current_user.tenant_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="category.update",
|
||||||
|
resource_type="category",
|
||||||
|
resource_id=db_category.id,
|
||||||
|
old_values=old_values,
|
||||||
|
new_values=new_values
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass # No fallar si falla el audit log
|
||||||
|
|
||||||
return db_category
|
return db_category
|
||||||
|
|
||||||
|
|
||||||
@@ -188,16 +220,8 @@ async def delete_category(
|
|||||||
"""
|
"""
|
||||||
Desactivar categoría del tenant (soft delete).
|
Desactivar categoría del tenant (soft delete).
|
||||||
|
|
||||||
**Permisos**: Solo ADMIN y SUPPORT_MANAGER pueden desactivar categorías.
|
|
||||||
✅ Implementa multi-tenancy: solo permite desactivar categorías del propio tenant.
|
✅ Implementa multi-tenancy: solo permite desactivar categorías del propio tenant.
|
||||||
"""
|
"""
|
||||||
# Verificar permisos
|
|
||||||
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="No tienes permisos para desactivar categorías"
|
|
||||||
)
|
|
||||||
|
|
||||||
query = select(Category).where(
|
query = select(Category).where(
|
||||||
Category.id == category_id,
|
Category.id == category_id,
|
||||||
Category.tenant_id == current_user.tenant_id
|
Category.tenant_id == current_user.tenant_id
|
||||||
@@ -211,7 +235,33 @@ async def delete_category(
|
|||||||
detail="Category not found"
|
detail="Category not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Guardar valores para auditoría
|
||||||
|
old_values = {
|
||||||
|
"name": db_category.name,
|
||||||
|
"is_active": db_category.is_active
|
||||||
|
}
|
||||||
|
|
||||||
# Soft delete
|
# Soft delete
|
||||||
db_category.is_active = False
|
db_category.is_active = False
|
||||||
await db.commit()
|
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(
|
||||||
|
db=db,
|
||||||
|
tenant_id=current_user.tenant_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="category.delete",
|
||||||
|
resource_type="category",
|
||||||
|
resource_id=db_category.id,
|
||||||
|
old_values=old_values,
|
||||||
|
new_values={"is_active": False}
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass # No fallar si falla el audit log
|
||||||
|
|
||||||
return None
|
return None
|
||||||
@@ -50,49 +50,14 @@ async def get_current_client_profile(
|
|||||||
profile = result.scalar_one_or_none()
|
profile = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not profile:
|
if not profile:
|
||||||
# Si no existe, devolver un perfil vacío con solo tenant_id
|
# Si no existe, crear uno vacío con valores por defecto explícitos
|
||||||
# No crear en base de datos hasta que el usuario guarde
|
profile = ClientProfile(
|
||||||
return ClientProfileResponse(
|
id=uuid.uuid4(),
|
||||||
id=None,
|
tenant_id=current_tenant.id
|
||||||
tenant_id=current_tenant.id,
|
|
||||||
business_name=None,
|
|
||||||
commercial_name=None,
|
|
||||||
client_code=None,
|
|
||||||
client_type=None,
|
|
||||||
rfc=None,
|
|
||||||
tax_id=None,
|
|
||||||
country=None,
|
|
||||||
state=None,
|
|
||||||
city=None,
|
|
||||||
address=None,
|
|
||||||
external_number=None,
|
|
||||||
internal_number=None,
|
|
||||||
postal_code=None,
|
|
||||||
neighborhood=None,
|
|
||||||
main_phone=None,
|
|
||||||
secondary_phone=None,
|
|
||||||
direct_phone=None,
|
|
||||||
phone_extension=None,
|
|
||||||
fax=None,
|
|
||||||
business_hours=None,
|
|
||||||
website=None,
|
|
||||||
main_email=None,
|
|
||||||
billing_email=None,
|
|
||||||
advertising_medium=None,
|
|
||||||
nationality=None,
|
|
||||||
logo_url=None,
|
|
||||||
company_representative=None,
|
|
||||||
legal_representative=None,
|
|
||||||
credit_limit=None,
|
|
||||||
payment_terms=None,
|
|
||||||
preferred_currency="MXN",
|
|
||||||
send_to_billing=False,
|
|
||||||
is_active_client=True,
|
|
||||||
is_prospect=False,
|
|
||||||
notes=None,
|
|
||||||
created_at=None,
|
|
||||||
updated_at=None
|
|
||||||
)
|
)
|
||||||
|
db.add(profile)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(profile)
|
||||||
|
|
||||||
return profile
|
return profile
|
||||||
|
|
||||||
|
|||||||
664
backend/app/api/v1/endpoints/sla.py
Normal file
664
backend/app/api/v1/endpoints/sla.py
Normal file
@@ -0,0 +1,664 @@
|
|||||||
|
"""
|
||||||
|
SLA Endpoints - ServiceManagerWeb
|
||||||
|
|
||||||
|
Endpoints para gestión y monitoreo de SLAs
|
||||||
|
Solo accesible por roles staff internos
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func, and_, or_, desc, case, cast
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import uuid
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.api.deps import get_current_user, get_current_tenant
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.models.tenant import Tenant
|
||||||
|
from app.models.ticket import Ticket, TicketStatus, TicketPriority
|
||||||
|
from app.models.category import Category
|
||||||
|
from app.api.schemas.sla import (
|
||||||
|
SLADashboardResponse,
|
||||||
|
SLAComplianceMetrics,
|
||||||
|
SLAViolationResponse,
|
||||||
|
SLAViolationsListResponse,
|
||||||
|
SLAAtRiskListResponse,
|
||||||
|
SLATicketAtRisk,
|
||||||
|
SLADetailedMetricsResponse,
|
||||||
|
SLAMetricsByCategory,
|
||||||
|
SLAMetricsByAgent,
|
||||||
|
SLAMetricsByPriority,
|
||||||
|
SLATrendsResponse,
|
||||||
|
SLADailyTrend,
|
||||||
|
SLAConfigListResponse,
|
||||||
|
SLAConfigByCategoryResponse,
|
||||||
|
SLATypeEnum,
|
||||||
|
TicketBasicInfo,
|
||||||
|
UserBasicInfo,
|
||||||
|
CategoryBasicInfo
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def require_staff_role(current_user: User = Depends(get_current_user)) -> User:
|
||||||
|
"""Requiere roles de staff interno (ADMIN, SUPPORT_MANAGER, AGENT)"""
|
||||||
|
allowed_roles = [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AGENT, UserRole.AUDITOR]
|
||||||
|
if current_user.role not in allowed_roles:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Solo staff interno puede acceder a métricas de SLA"
|
||||||
|
)
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
def require_manager_role(current_user: User = Depends(get_current_user)) -> User:
|
||||||
|
"""Requiere roles de gestión (ADMIN, SUPPORT_MANAGER)"""
|
||||||
|
if current_user.role not in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Solo managers pueden acceder a esta funcionalidad"
|
||||||
|
)
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# DASHBOARD PRINCIPAL
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
@router.get("/dashboard", response_model=SLADashboardResponse)
|
||||||
|
async def get_sla_dashboard(
|
||||||
|
days: int = Query(default=30, ge=1, le=365, description="Días hacia atrás para el período"),
|
||||||
|
current_user: User = Depends(require_staff_role),
|
||||||
|
current_tenant: Tenant = Depends(get_current_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Dashboard principal de métricas SLA.
|
||||||
|
|
||||||
|
**Permisos**: ADMIN, SUPPORT_MANAGER, AGENT, AUDITOR
|
||||||
|
|
||||||
|
Retorna métricas agregadas de cumplimiento SLA para el período especificado.
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
"SLA dashboard requested",
|
||||||
|
user_id=str(current_user.id),
|
||||||
|
tenant_id=str(current_tenant.id),
|
||||||
|
days=days
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
period_start = now - timedelta(days=days)
|
||||||
|
|
||||||
|
# Usar func.now() para comparaciones en SQL (evita timezone issues)
|
||||||
|
db_now = func.now()
|
||||||
|
|
||||||
|
# Query base para tickets del período
|
||||||
|
base_query = select(Ticket).where(
|
||||||
|
and_(
|
||||||
|
Ticket.tenant_id == current_tenant.id,
|
||||||
|
Ticket.created_at >= period_start
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calcular métricas de Response SLA
|
||||||
|
# Para "at risk": ticket pendiente que ha consumido >80% del tiempo disponible
|
||||||
|
# Calculamos: (now - created_at) > 0.8 * (sla_response_due - created_at)
|
||||||
|
response_query = select(
|
||||||
|
func.count().label('total'),
|
||||||
|
func.sum(case((Ticket.first_response_at <= Ticket.sla_response_due, 1), else_=0)).label('met'),
|
||||||
|
func.sum(case((and_(Ticket.first_response_at > Ticket.sla_response_due, Ticket.first_response_at != None), 1), else_=0)).label('violated'),
|
||||||
|
func.sum(case((and_(Ticket.first_response_at == None, Ticket.sla_response_due != None, db_now > Ticket.sla_response_due), 1), else_=0)).label('violated_pending'),
|
||||||
|
func.sum(case((
|
||||||
|
and_(
|
||||||
|
Ticket.first_response_at == None,
|
||||||
|
Ticket.sla_response_due != None,
|
||||||
|
db_now < Ticket.sla_response_due,
|
||||||
|
func.extract('epoch', db_now - Ticket.created_at) > (func.extract('epoch', Ticket.sla_response_due - Ticket.created_at) * 0.8)
|
||||||
|
), 1), else_=0)
|
||||||
|
).label('at_risk'),
|
||||||
|
func.avg(
|
||||||
|
func.extract('epoch', Ticket.first_response_at - Ticket.created_at) / 3600
|
||||||
|
).label('avg_hours')
|
||||||
|
).where(
|
||||||
|
and_(
|
||||||
|
Ticket.tenant_id == current_tenant.id,
|
||||||
|
Ticket.created_at >= period_start,
|
||||||
|
Ticket.sla_response_due != None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
response_result = await db.execute(response_query)
|
||||||
|
response_row = response_result.one()
|
||||||
|
|
||||||
|
response_total = response_row.total or 0
|
||||||
|
response_met = (response_row.met or 0)
|
||||||
|
response_violated = (response_row.violated or 0) + (response_row.violated_pending or 0)
|
||||||
|
response_at_risk = response_row.at_risk or 0
|
||||||
|
response_avg = float(response_row.avg_hours) if response_row.avg_hours else 0.0
|
||||||
|
response_compliance = (response_met / response_total * 100) if response_total > 0 else 0.0
|
||||||
|
|
||||||
|
# Calcular métricas de Resolution SLA
|
||||||
|
resolution_query = select(
|
||||||
|
func.count().label('total'),
|
||||||
|
func.sum(case((Ticket.resolved_at <= Ticket.sla_resolution_due, 1), else_=0)).label('met'),
|
||||||
|
func.sum(case((and_(Ticket.resolved_at > Ticket.sla_resolution_due, Ticket.resolved_at != None), 1), else_=0)).label('violated'),
|
||||||
|
func.sum(case((and_(Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED]), Ticket.sla_resolution_due != None, db_now > Ticket.sla_resolution_due), 1), else_=0)).label('violated_pending'),
|
||||||
|
func.sum(case((
|
||||||
|
and_(
|
||||||
|
Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED]),
|
||||||
|
Ticket.sla_resolution_due != None,
|
||||||
|
db_now < Ticket.sla_resolution_due,
|
||||||
|
func.extract('epoch', db_now - Ticket.created_at) > (func.extract('epoch', Ticket.sla_resolution_due - Ticket.created_at) * 0.8)
|
||||||
|
), 1), else_=0)
|
||||||
|
).label('at_risk'),
|
||||||
|
func.avg(
|
||||||
|
func.extract('epoch', Ticket.resolved_at - Ticket.created_at) / 3600
|
||||||
|
).label('avg_hours')
|
||||||
|
).where(
|
||||||
|
and_(
|
||||||
|
Ticket.tenant_id == current_tenant.id,
|
||||||
|
Ticket.created_at >= period_start,
|
||||||
|
Ticket.sla_resolution_due != None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
resolution_result = await db.execute(resolution_query)
|
||||||
|
resolution_row = resolution_result.one()
|
||||||
|
|
||||||
|
resolution_total = resolution_row.total or 0
|
||||||
|
resolution_met = (resolution_row.met or 0)
|
||||||
|
resolution_violated = (resolution_row.violated or 0) + (resolution_row.violated_pending or 0)
|
||||||
|
resolution_at_risk = resolution_row.at_risk or 0
|
||||||
|
resolution_avg = float(resolution_row.avg_hours) if resolution_row.avg_hours else 0.0
|
||||||
|
resolution_compliance = (resolution_met / resolution_total * 100) if resolution_total > 0 else 0.0
|
||||||
|
|
||||||
|
# Métricas por categoría (top 5)
|
||||||
|
category_query = select(
|
||||||
|
Category.id,
|
||||||
|
Category.name,
|
||||||
|
func.count(Ticket.id).label('ticket_count'),
|
||||||
|
func.avg(case((Ticket.first_response_at <= Ticket.sla_response_due, 100.0), else_=0.0)).label('response_compliance'),
|
||||||
|
func.avg(case((Ticket.resolved_at <= Ticket.sla_resolution_due, 100.0), else_=0.0)).label('resolution_compliance')
|
||||||
|
).select_from(Ticket).join(
|
||||||
|
Category, Ticket.category_id == Category.id, isouter=True
|
||||||
|
).where(
|
||||||
|
and_(
|
||||||
|
Ticket.tenant_id == current_tenant.id,
|
||||||
|
Ticket.created_at >= period_start
|
||||||
|
)
|
||||||
|
).group_by(Category.id, Category.name).order_by(desc('ticket_count')).limit(5)
|
||||||
|
|
||||||
|
category_result = await db.execute(category_query)
|
||||||
|
by_category = [
|
||||||
|
{
|
||||||
|
"category_id": str(row.id) if row.id else None,
|
||||||
|
"category_name": row.name or "Sin categoría",
|
||||||
|
"ticket_count": row.ticket_count,
|
||||||
|
"response_compliance": float(row.response_compliance or 0.0),
|
||||||
|
"resolution_compliance": float(row.resolution_compliance or 0.0)
|
||||||
|
}
|
||||||
|
for row in category_result.all()
|
||||||
|
]
|
||||||
|
|
||||||
|
# Métricas por prioridad
|
||||||
|
by_priority = {}
|
||||||
|
for priority in TicketPriority:
|
||||||
|
priority_query = select(
|
||||||
|
func.avg(case((Ticket.first_response_at <= Ticket.sla_response_due, 100.0), else_=0.0)).label('response'),
|
||||||
|
func.avg(case((Ticket.resolved_at <= Ticket.sla_resolution_due, 100.0), else_=0.0)).label('resolution')
|
||||||
|
).where(
|
||||||
|
and_(
|
||||||
|
Ticket.tenant_id == current_tenant.id,
|
||||||
|
Ticket.created_at >= period_start,
|
||||||
|
Ticket.priority == priority
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
priority_result = await db.execute(priority_query)
|
||||||
|
priority_row = priority_result.one()
|
||||||
|
|
||||||
|
by_priority[priority.value] = {
|
||||||
|
"response_compliance": float(priority_row.response or 0.0),
|
||||||
|
"resolution_compliance": float(priority_row.resolution or 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Calcular tendencias (comparación con período anterior)
|
||||||
|
prev_period_start = period_start - timedelta(days=days)
|
||||||
|
prev_response_query = select(
|
||||||
|
func.count().label('total'),
|
||||||
|
func.sum(case((Ticket.first_response_at <= Ticket.sla_response_due, 1), else_=0)).label('met')
|
||||||
|
).where(
|
||||||
|
and_(
|
||||||
|
Ticket.tenant_id == current_tenant.id,
|
||||||
|
Ticket.created_at >= prev_period_start,
|
||||||
|
Ticket.created_at < period_start,
|
||||||
|
Ticket.sla_response_due != None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
prev_response_result = await db.execute(prev_response_query)
|
||||||
|
prev_response_row = prev_response_result.one()
|
||||||
|
prev_response_compliance = ((prev_response_row.met or 0) / (prev_response_row.total or 1) * 100) if (prev_response_row.total or 0) > 0 else 0.0
|
||||||
|
|
||||||
|
response_trend = response_compliance - prev_response_compliance
|
||||||
|
response_trend_str = f"+{response_trend:.1f}%" if response_trend >= 0 else f"{response_trend:.1f}%"
|
||||||
|
|
||||||
|
prev_resolution_query = select(
|
||||||
|
func.count().label('total'),
|
||||||
|
func.sum(case((Ticket.resolved_at <= Ticket.sla_resolution_due, 1), else_=0)).label('met')
|
||||||
|
).where(
|
||||||
|
and_(
|
||||||
|
Ticket.tenant_id == current_tenant.id,
|
||||||
|
Ticket.created_at >= prev_period_start,
|
||||||
|
Ticket.created_at < period_start,
|
||||||
|
Ticket.sla_resolution_due != None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
prev_resolution_result = await db.execute(prev_resolution_query)
|
||||||
|
prev_resolution_row = prev_resolution_result.one()
|
||||||
|
prev_resolution_compliance = ((prev_resolution_row.met or 0) / (prev_resolution_row.total or 1) * 100) if (prev_resolution_row.total or 0) > 0 else 0.0
|
||||||
|
|
||||||
|
resolution_trend = resolution_compliance - prev_resolution_compliance
|
||||||
|
resolution_trend_str = f"+{resolution_trend:.1f}%" if resolution_trend >= 0 else f"{resolution_trend:.1f}%"
|
||||||
|
|
||||||
|
# Contar violaciones activas
|
||||||
|
active_violations_query = select(func.count()).select_from(Ticket).where(
|
||||||
|
and_(
|
||||||
|
Ticket.tenant_id == current_tenant.id,
|
||||||
|
Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED]),
|
||||||
|
or_(
|
||||||
|
and_(Ticket.first_response_at == None, Ticket.sla_response_due != None, db_now > Ticket.sla_response_due),
|
||||||
|
and_(Ticket.resolved_at == None, Ticket.sla_resolution_due != None, db_now > Ticket.sla_resolution_due)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
active_violations_result = await db.execute(active_violations_query)
|
||||||
|
active_violations = active_violations_result.scalar() or 0
|
||||||
|
|
||||||
|
# Contar total de tickets del período
|
||||||
|
total_tickets_query = select(func.count()).select_from(Ticket).where(
|
||||||
|
and_(
|
||||||
|
Ticket.tenant_id == current_tenant.id,
|
||||||
|
Ticket.created_at >= period_start
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
total_tickets_result = await db.execute(total_tickets_query)
|
||||||
|
total_tickets_period = total_tickets_result.scalar() or 0
|
||||||
|
|
||||||
|
return SLADashboardResponse(
|
||||||
|
tenant_id=current_tenant.id,
|
||||||
|
period_start=period_start,
|
||||||
|
period_end=now,
|
||||||
|
generated_at=now,
|
||||||
|
response_sla=SLAComplianceMetrics(
|
||||||
|
target_hours=2, # Promedio, podría calcularse
|
||||||
|
met_count=response_met,
|
||||||
|
violated_count=response_violated,
|
||||||
|
at_risk_count=response_at_risk,
|
||||||
|
total_count=response_total,
|
||||||
|
compliance_percentage=response_compliance,
|
||||||
|
avg_time_hours=response_avg
|
||||||
|
),
|
||||||
|
resolution_sla=SLAComplianceMetrics(
|
||||||
|
target_hours=24, # Promedio, podría calcularse
|
||||||
|
met_count=resolution_met,
|
||||||
|
violated_count=resolution_violated,
|
||||||
|
at_risk_count=resolution_at_risk,
|
||||||
|
total_count=resolution_total,
|
||||||
|
compliance_percentage=resolution_compliance,
|
||||||
|
avg_time_hours=resolution_avg
|
||||||
|
),
|
||||||
|
active_violations=active_violations,
|
||||||
|
at_risk_tickets=response_at_risk + resolution_at_risk,
|
||||||
|
total_tickets_period=total_tickets_period,
|
||||||
|
by_category=by_category,
|
||||||
|
by_priority=by_priority,
|
||||||
|
trends={
|
||||||
|
"response_sla": response_trend_str,
|
||||||
|
"resolution_sla": resolution_trend_str
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# VIOLACIONES
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
@router.get("/violations", response_model=SLAViolationsListResponse)
|
||||||
|
async def get_sla_violations(
|
||||||
|
skip: int = Query(default=0, ge=0),
|
||||||
|
limit: int = Query(default=50, ge=1, le=100),
|
||||||
|
sla_type: Optional[str] = Query(default=None, regex="^(response|resolution)$"),
|
||||||
|
category_id: Optional[uuid.UUID] = None,
|
||||||
|
priority: Optional[str] = None,
|
||||||
|
current_user: User = Depends(require_staff_role),
|
||||||
|
current_tenant: Tenant = Depends(get_current_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Listar violaciones SLA activas.
|
||||||
|
|
||||||
|
**Permisos**: ADMIN, SUPPORT_MANAGER, AGENT (solo sus tickets), AUDITOR
|
||||||
|
|
||||||
|
Retorna tickets que han violado sus SLAs de respuesta o resolución.
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
"SLA violations requested",
|
||||||
|
user_id=str(current_user.id),
|
||||||
|
tenant_id=str(current_tenant.id),
|
||||||
|
sla_type=sla_type
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
db_now = func.now()
|
||||||
|
|
||||||
|
# Base query con carga de relaciones
|
||||||
|
query = select(Ticket).options(
|
||||||
|
selectinload(Ticket.created_by_user),
|
||||||
|
selectinload(Ticket.assigned_to_user),
|
||||||
|
selectinload(Ticket.category)
|
||||||
|
).where(
|
||||||
|
and_(
|
||||||
|
Ticket.tenant_id == current_tenant.id,
|
||||||
|
Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filtrar por tipo de SLA
|
||||||
|
if sla_type == "response":
|
||||||
|
query = query.where(
|
||||||
|
and_(
|
||||||
|
Ticket.first_response_at == None,
|
||||||
|
Ticket.sla_response_due != None,
|
||||||
|
db_now > Ticket.sla_response_due
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif sla_type == "resolution":
|
||||||
|
query = query.where(
|
||||||
|
and_(
|
||||||
|
Ticket.sla_resolution_due != None,
|
||||||
|
db_now > Ticket.sla_resolution_due
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Ambos tipos
|
||||||
|
query = query.where(
|
||||||
|
or_(
|
||||||
|
and_(Ticket.first_response_at == None, Ticket.sla_response_due != None, db_now > Ticket.sla_response_due),
|
||||||
|
and_(Ticket.sla_resolution_due != None, db_now > Ticket.sla_resolution_due)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filtros adicionales
|
||||||
|
if category_id:
|
||||||
|
query = query.where(Ticket.category_id == category_id)
|
||||||
|
|
||||||
|
if priority:
|
||||||
|
try:
|
||||||
|
priority_enum = TicketPriority(priority.upper())
|
||||||
|
query = query.where(Ticket.priority == priority_enum)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# AGENTS solo ven sus tickets
|
||||||
|
if current_user.role == UserRole.AGENT:
|
||||||
|
query = query.where(Ticket.assigned_to == current_user.id)
|
||||||
|
|
||||||
|
# Contar total
|
||||||
|
count_query = select(func.count()).select_from(query.subquery())
|
||||||
|
total_result = await db.execute(count_query)
|
||||||
|
total = total_result.scalar() or 0
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
# 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
|
||||||
|
resolution_violated = sla_resolution_due and now > sla_resolution_due
|
||||||
|
|
||||||
|
# Priorizar resolution si ambos están violados
|
||||||
|
if resolution_violated:
|
||||||
|
violation_type = SLATypeEnum.RESOLUTION
|
||||||
|
due_at = sla_resolution_due
|
||||||
|
else:
|
||||||
|
violation_type = SLATypeEnum.RESPONSE
|
||||||
|
due_at = sla_response_due
|
||||||
|
|
||||||
|
hours_overdue = (now - due_at).total_seconds() / 3600 if due_at else 0
|
||||||
|
|
||||||
|
# Las relaciones ya están cargadas por selectinload
|
||||||
|
violations.append(SLAViolationResponse(
|
||||||
|
ticket=TicketBasicInfo(
|
||||||
|
id=ticket.id,
|
||||||
|
ticket_number=ticket.ticket_number,
|
||||||
|
subject=ticket.subject,
|
||||||
|
priority=ticket.priority.value,
|
||||||
|
status=ticket.status.value
|
||||||
|
),
|
||||||
|
category=CategoryBasicInfo(
|
||||||
|
id=ticket.category.id,
|
||||||
|
name=ticket.category.name,
|
||||||
|
sla_response_hours=ticket.category.sla_response_hours,
|
||||||
|
sla_resolution_hours=ticket.category.sla_resolution_hours
|
||||||
|
) if ticket.category else None,
|
||||||
|
created_by=UserBasicInfo(
|
||||||
|
id=ticket.created_by_user.id,
|
||||||
|
first_name=ticket.created_by_user.first_name,
|
||||||
|
last_name=ticket.created_by_user.last_name,
|
||||||
|
email=ticket.created_by_user.email
|
||||||
|
),
|
||||||
|
assigned_to=UserBasicInfo(
|
||||||
|
id=ticket.assigned_to_user.id,
|
||||||
|
first_name=ticket.assigned_to_user.first_name,
|
||||||
|
last_name=ticket.assigned_to_user.last_name,
|
||||||
|
email=ticket.assigned_to_user.email
|
||||||
|
) if ticket.assigned_to_user else None,
|
||||||
|
sla_type=violation_type,
|
||||||
|
sla_due_at=due_at,
|
||||||
|
violated_at=due_at, # Se violó en el momento del due
|
||||||
|
hours_overdue=hours_overdue,
|
||||||
|
first_response_at=ticket.first_response_at,
|
||||||
|
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=paginated_violations,
|
||||||
|
total=total,
|
||||||
|
page=(skip // limit) + 1,
|
||||||
|
per_page=limit,
|
||||||
|
total_pages=total_pages
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# TICKETS EN RIESGO
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
@router.get("/at-risk", response_model=SLAAtRiskListResponse)
|
||||||
|
async def get_tickets_at_risk(
|
||||||
|
threshold: int = Query(default=80, ge=50, le=95, description="% de tiempo consumido para considerar en riesgo"),
|
||||||
|
current_user: User = Depends(require_staff_role),
|
||||||
|
current_tenant: Tenant = Depends(get_current_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Listar tickets que están en riesgo de violar SLA.
|
||||||
|
|
||||||
|
**Permisos**: ADMIN, SUPPORT_MANAGER, AGENT (solo sus tickets), AUDITOR
|
||||||
|
|
||||||
|
Retorna tickets que están cerca de vencer su SLA (por defecto, 80% del tiempo consumido).
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
"SLA at-risk tickets requested",
|
||||||
|
user_id=str(current_user.id),
|
||||||
|
tenant_id=str(current_tenant.id),
|
||||||
|
threshold=threshold
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
db_now = func.now()
|
||||||
|
threshold_decimal = threshold / 100.0
|
||||||
|
|
||||||
|
# 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).options(
|
||||||
|
selectinload(Ticket.assigned_to_user),
|
||||||
|
selectinload(Ticket.category)
|
||||||
|
).where(
|
||||||
|
and_(
|
||||||
|
Ticket.tenant_id == current_tenant.id,
|
||||||
|
Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED]),
|
||||||
|
or_(
|
||||||
|
# Response SLA en riesgo
|
||||||
|
and_(
|
||||||
|
Ticket.first_response_at == None,
|
||||||
|
Ticket.sla_response_due != None,
|
||||||
|
db_now < Ticket.sla_response_due,
|
||||||
|
# Calcular si está en zona de riesgo
|
||||||
|
func.extract('epoch', db_now - Ticket.created_at) >= (func.extract('epoch', Ticket.sla_response_due - Ticket.created_at) * threshold_decimal)
|
||||||
|
),
|
||||||
|
# Resolution SLA en riesgo
|
||||||
|
and_(
|
||||||
|
Ticket.sla_resolution_due != None,
|
||||||
|
db_now < Ticket.sla_resolution_due,
|
||||||
|
func.extract('epoch', db_now - Ticket.created_at) >= (func.extract('epoch', Ticket.sla_resolution_due - Ticket.created_at) * threshold_decimal)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).order_by(desc(Ticket.sla_response_due if Ticket.sla_response_due else Ticket.sla_resolution_due))
|
||||||
|
|
||||||
|
# AGENTS solo ven sus tickets
|
||||||
|
if current_user.role == UserRole.AGENT:
|
||||||
|
query = query.where(Ticket.assigned_to == current_user.id)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
tickets = result.scalars().all()
|
||||||
|
|
||||||
|
# Formatear response
|
||||||
|
at_risk_tickets = []
|
||||||
|
for ticket in tickets:
|
||||||
|
# Determinar cuál SLA está en riesgo
|
||||||
|
response_at_risk = (
|
||||||
|
ticket.first_response_at is None and
|
||||||
|
ticket.sla_response_due and
|
||||||
|
now < ticket.sla_response_due
|
||||||
|
)
|
||||||
|
|
||||||
|
resolution_at_risk = (
|
||||||
|
ticket.sla_resolution_due and
|
||||||
|
now < ticket.sla_resolution_due
|
||||||
|
)
|
||||||
|
|
||||||
|
# Priorizar response si ambos están en riesgo
|
||||||
|
if response_at_risk:
|
||||||
|
sla_type = SLATypeEnum.RESPONSE
|
||||||
|
due_at = ticket.sla_response_due
|
||||||
|
elif resolution_at_risk:
|
||||||
|
sla_type = SLATypeEnum.RESOLUTION
|
||||||
|
due_at = ticket.sla_resolution_due
|
||||||
|
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 - created_at).total_seconds() / 3600
|
||||||
|
elapsed_time = total_time - time_remaining
|
||||||
|
risk_percentage = (elapsed_time / total_time * 100) if total_time > 0 else 0
|
||||||
|
|
||||||
|
# Las relaciones ya están cargadas por selectinload
|
||||||
|
at_risk_tickets.append(SLATicketAtRisk(
|
||||||
|
ticket=TicketBasicInfo(
|
||||||
|
id=ticket.id,
|
||||||
|
ticket_number=ticket.ticket_number,
|
||||||
|
subject=ticket.subject,
|
||||||
|
priority=ticket.priority.value,
|
||||||
|
status=ticket.status.value
|
||||||
|
),
|
||||||
|
category=CategoryBasicInfo(
|
||||||
|
id=ticket.category.id,
|
||||||
|
name=ticket.category.name,
|
||||||
|
sla_response_hours=ticket.category.sla_response_hours,
|
||||||
|
sla_resolution_hours=ticket.category.sla_resolution_hours
|
||||||
|
) if ticket.category else None,
|
||||||
|
assigned_to=UserBasicInfo(
|
||||||
|
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,
|
||||||
|
risk_percentage=risk_percentage
|
||||||
|
))
|
||||||
|
|
||||||
|
return SLAAtRiskListResponse(
|
||||||
|
tickets=at_risk_tickets,
|
||||||
|
total=len(at_risk_tickets)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# CONFIGURACIÓN
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
@router.get("/config", response_model=SLAConfigListResponse)
|
||||||
|
async def get_sla_config(
|
||||||
|
current_user: User = Depends(require_manager_role),
|
||||||
|
current_tenant: Tenant = Depends(get_current_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Obtener configuración de SLAs por categoría.
|
||||||
|
|
||||||
|
**Permisos**: ADMIN, SUPPORT_MANAGER
|
||||||
|
|
||||||
|
Retorna la configuración de tiempos SLA para todas las categorías del tenant.
|
||||||
|
"""
|
||||||
|
query = select(Category).where(
|
||||||
|
Category.tenant_id == current_tenant.id
|
||||||
|
).order_by(Category.name)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
categories = result.scalars().all()
|
||||||
|
|
||||||
|
return SLAConfigListResponse(
|
||||||
|
tenant_id=current_tenant.id,
|
||||||
|
categories=[
|
||||||
|
SLAConfigByCategoryResponse(
|
||||||
|
category_id=cat.id,
|
||||||
|
category_name=cat.name,
|
||||||
|
sla_response_hours=cat.sla_response_hours,
|
||||||
|
sla_resolution_hours=cat.sla_resolution_hours,
|
||||||
|
warning_threshold_percentage=80, # Por ahora hardcoded
|
||||||
|
is_active=cat.is_active
|
||||||
|
)
|
||||||
|
for cat in categories
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from pydantic import BaseModel, ConfigDict
|
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import uuid
|
import uuid
|
||||||
@@ -10,36 +9,10 @@ from app.core.database import get_db
|
|||||||
from app.models.system import System
|
from app.models.system import System
|
||||||
from app.models.user import User
|
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()
|
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
|
# ENDPOINTS
|
||||||
@@ -70,25 +43,17 @@ async def read_systems(
|
|||||||
async def create_system(
|
async def create_system(
|
||||||
system: SystemCreate,
|
system: SystemCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(deps.get_current_user)
|
current_user: User = Depends(deps.get_current_user) # ✅ CORREGIDO: Type hint
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Crear nuevo sistema en el tenant del usuario actual.
|
Crear nuevo sistema en el tenant del usuario actual.
|
||||||
|
|
||||||
**Permisos**: Solo ADMIN y SUPPORT_MANAGER pueden crear sistemas.
|
|
||||||
✅ Implementa multi-tenancy: asigna automáticamente tenant_id del usuario.
|
✅ Implementa multi-tenancy: asigna automáticamente tenant_id del usuario.
|
||||||
"""
|
"""
|
||||||
# Verificar permisos
|
# ✅ CORREGIDO: Asignar tenant_id del usuario actual
|
||||||
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="No tienes permisos para crear sistemas"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Asignar tenant_id del usuario actual
|
|
||||||
db_system = System(
|
db_system = System(
|
||||||
**system.model_dump(),
|
**system.model_dump(),
|
||||||
tenant_id=current_user.tenant_id
|
tenant_id=current_user.tenant_id # ✅ Multi-tenancy automático
|
||||||
)
|
)
|
||||||
|
|
||||||
db.add(db_system)
|
db.add(db_system)
|
||||||
@@ -134,16 +99,8 @@ async def update_system(
|
|||||||
"""
|
"""
|
||||||
Actualizar sistema del tenant.
|
Actualizar sistema del tenant.
|
||||||
|
|
||||||
**Permisos**: Solo ADMIN y SUPPORT_MANAGER pueden actualizar sistemas.
|
|
||||||
✅ Implementa multi-tenancy: solo permite actualizar sistemas del propio tenant.
|
✅ Implementa multi-tenancy: solo permite actualizar sistemas del propio tenant.
|
||||||
"""
|
"""
|
||||||
# Verificar permisos
|
|
||||||
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="No tienes permisos para actualizar sistemas"
|
|
||||||
)
|
|
||||||
|
|
||||||
query = select(System).where(
|
query = select(System).where(
|
||||||
System.id == system_id,
|
System.id == system_id,
|
||||||
System.tenant_id == current_user.tenant_id
|
System.tenant_id == current_user.tenant_id
|
||||||
@@ -176,16 +133,8 @@ async def delete_system(
|
|||||||
"""
|
"""
|
||||||
Desactivar sistema del tenant (soft delete).
|
Desactivar sistema del tenant (soft delete).
|
||||||
|
|
||||||
**Permisos**: Solo ADMIN y SUPPORT_MANAGER pueden desactivar sistemas.
|
|
||||||
✅ Implementa multi-tenancy: solo permite desactivar sistemas del propio tenant.
|
✅ Implementa multi-tenancy: solo permite desactivar sistemas del propio tenant.
|
||||||
"""
|
"""
|
||||||
# Verificar permisos
|
|
||||||
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="No tienes permisos para desactivar sistemas"
|
|
||||||
)
|
|
||||||
|
|
||||||
query = select(System).where(
|
query = select(System).where(
|
||||||
System.id == system_id,
|
System.id == system_id,
|
||||||
System.tenant_id == current_user.tenant_id
|
System.tenant_id == current_user.tenant_id
|
||||||
|
|||||||
@@ -1,38 +1,16 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from pydantic import BaseModel, ConfigDict, EmailStr
|
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.models.tenant import Tenant, TenantStatus
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
class TenantBase(BaseModel):
|
|
||||||
name: str
|
|
||||||
slug: str
|
|
||||||
domain: Optional[str] = None
|
|
||||||
contact_email: Optional[EmailStr] = 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
|
|
||||||
status: Optional[TenantStatus] = None
|
|
||||||
|
|
||||||
class TenantResponse(TenantBase):
|
|
||||||
id: uuid.UUID
|
|
||||||
status: TenantStatus
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
@router.get("/", response_model=List[TenantResponse])
|
@router.get("/", response_model=List[TenantResponse])
|
||||||
async def read_tenants(
|
async def read_tenants(
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
@@ -86,12 +64,16 @@ async def update_tenant(
|
|||||||
|
|
||||||
update_data = tenant_in.model_dump(exclude_unset=True)
|
update_data = tenant_in.model_dump(exclude_unset=True)
|
||||||
if "status" in update_data:
|
if "status" in update_data:
|
||||||
tenant.is_active = update_data.pop("status") == TenantStatus.active
|
# Convertir string a enum TenantStatus
|
||||||
|
status_value = update_data.pop("status")
|
||||||
|
if isinstance(status_value, str):
|
||||||
|
tenant.status = TenantStatus(status_value)
|
||||||
|
else:
|
||||||
|
tenant.status = status_value
|
||||||
|
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(tenant, field, value)
|
setattr(tenant, field, value)
|
||||||
|
|
||||||
db.add(tenant)
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(tenant)
|
await db.refresh(tenant)
|
||||||
return tenant
|
return tenant
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,451 +0,0 @@
|
|||||||
"""
|
|
||||||
Tickets endpoints - ServiceManagerWeb
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy import select, func
|
|
||||||
from typing import List, Optional
|
|
||||||
from datetime import datetime
|
|
||||||
from app.core.database import get_db
|
|
||||||
from app.api.deps import get_current_user
|
|
||||||
from app.models.ticket import Ticket, TicketStatus, TicketPriority
|
|
||||||
from app.models.user import User
|
|
||||||
from app.models.category import Category # ✅ CORREGIDO: Era TicketCategory
|
|
||||||
from app.models.system import System
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
# ===================================
|
|
||||||
# SCHEMAS
|
|
||||||
# ===================================
|
|
||||||
|
|
||||||
class TicketCreate(BaseModel):
|
|
||||||
subject: str
|
|
||||||
description: str
|
|
||||||
category_id: Optional[str] = None
|
|
||||||
affected_system_id: Optional[str] = None # ✅ CORREGIDO: Era system_id
|
|
||||||
priority: str = "MEDIUM"
|
|
||||||
|
|
||||||
class TicketUpdate(BaseModel):
|
|
||||||
subject: Optional[str] = None
|
|
||||||
description: Optional[str] = None
|
|
||||||
status: Optional[str] = None
|
|
||||||
priority: Optional[str] = None
|
|
||||||
assigned_to: Optional[str] = None
|
|
||||||
|
|
||||||
class TicketResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
ticket_number: str
|
|
||||||
subject: str
|
|
||||||
description: str
|
|
||||||
status: str
|
|
||||||
priority: str
|
|
||||||
category_id: Optional[str] = None
|
|
||||||
affected_system_id: Optional[str] = None # ✅ CORREGIDO: Era system_id
|
|
||||||
created_by: str
|
|
||||||
assigned_to: Optional[str] = None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
class TicketCloseRequest(BaseModel):
|
|
||||||
resolution: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
# ===================================
|
|
||||||
# TICKET ENDPOINTS
|
|
||||||
# ===================================
|
|
||||||
|
|
||||||
@router.post("/", response_model=TicketResponse, status_code=status.HTTP_201_CREATED)
|
|
||||||
async def create_ticket(
|
|
||||||
ticket: TicketCreate,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Crear un nuevo ticket
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Generar número de ticket único
|
|
||||||
result = await db.execute(
|
|
||||||
select(func.count(Ticket.id)).where(Ticket.tenant_id == current_user.tenant_id)
|
|
||||||
)
|
|
||||||
count = result.scalar() or 0
|
|
||||||
ticket_number = f"TK-{count + 1:06d}"
|
|
||||||
|
|
||||||
# Convertir IDs de string a UUID si son proporcionados
|
|
||||||
category_uuid = uuid.UUID(ticket.category_id) if ticket.category_id else None
|
|
||||||
system_uuid = uuid.UUID(ticket.affected_system_id) if ticket.affected_system_id else None # ✅ CORREGIDO
|
|
||||||
|
|
||||||
# ✅ CORREGIDO: Validar en la tabla correcta con el nombre correcto del modelo
|
|
||||||
if category_uuid:
|
|
||||||
category = await db.get(Category, category_uuid) # ✅ Category, no TicketCategory
|
|
||||||
if not category:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"La categoría con ID {ticket.category_id} no existe."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validar si el system_id existe en la tabla affected_systems
|
|
||||||
if system_uuid:
|
|
||||||
system = await db.get(System, system_uuid)
|
|
||||||
if not system:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"El sistema con ID {ticket.affected_system_id} no existe."
|
|
||||||
)
|
|
||||||
|
|
||||||
db_ticket = Ticket(
|
|
||||||
id=uuid.uuid4(),
|
|
||||||
tenant_id=current_user.tenant_id,
|
|
||||||
ticket_number=ticket_number,
|
|
||||||
subject=ticket.subject,
|
|
||||||
description=ticket.description,
|
|
||||||
category_id=category_uuid,
|
|
||||||
affected_system_id=system_uuid, # ✅ CORREGIDO: Nombre correcto del campo
|
|
||||||
priority=TicketPriority[ticket.priority.upper()],
|
|
||||||
created_by=current_user.id,
|
|
||||||
status=TicketStatus.NEW,
|
|
||||||
created_at=datetime.utcnow(),
|
|
||||||
updated_at=datetime.utcnow()
|
|
||||||
)
|
|
||||||
|
|
||||||
db.add(db_ticket)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(db_ticket)
|
|
||||||
|
|
||||||
# ✅ CORREGIDO: Usar affected_system_id en respuesta
|
|
||||||
return {
|
|
||||||
"id": str(db_ticket.id),
|
|
||||||
"ticket_number": db_ticket.ticket_number,
|
|
||||||
"subject": db_ticket.subject,
|
|
||||||
"description": db_ticket.description,
|
|
||||||
"status": db_ticket.status.value,
|
|
||||||
"priority": db_ticket.priority.value,
|
|
||||||
"category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
|
|
||||||
"affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None, # ✅ CORREGIDO
|
|
||||||
"created_by": str(db_ticket.created_by),
|
|
||||||
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
|
|
||||||
"created_at": db_ticket.created_at,
|
|
||||||
"updated_at": db_ticket.updated_at
|
|
||||||
}
|
|
||||||
|
|
||||||
except ValueError as e:
|
|
||||||
await db.rollback()
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"Invalid UUID format: {str(e)}"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
await db.rollback()
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"Error creating ticket: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=List[TicketResponse])
|
|
||||||
async def get_tickets(
|
|
||||||
skip: int = 0,
|
|
||||||
limit: int = 100,
|
|
||||||
status_filter: Optional[str] = None,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Obtener tickets del usuario actual
|
|
||||||
"""
|
|
||||||
query = select(Ticket).where(
|
|
||||||
Ticket.tenant_id == current_user.tenant_id,
|
|
||||||
Ticket.created_by == current_user.id
|
|
||||||
)
|
|
||||||
|
|
||||||
if status_filter:
|
|
||||||
try:
|
|
||||||
status_enum = TicketStatus[status_filter.upper()]
|
|
||||||
query = query.where(Ticket.status == status_enum)
|
|
||||||
except KeyError:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"Invalid status: {status_filter}"
|
|
||||||
)
|
|
||||||
|
|
||||||
query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit)
|
|
||||||
|
|
||||||
result = await db.execute(query)
|
|
||||||
tickets = result.scalars().all()
|
|
||||||
|
|
||||||
# ✅ CORREGIDO: Usar affected_system_id
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"id": str(t.id),
|
|
||||||
"ticket_number": t.ticket_number,
|
|
||||||
"subject": t.subject,
|
|
||||||
"description": t.description,
|
|
||||||
"status": t.status.value,
|
|
||||||
"priority": t.priority.value,
|
|
||||||
"category_id": str(t.category_id) if t.category_id else None,
|
|
||||||
"affected_system_id": str(t.affected_system_id) if t.affected_system_id else None, # ✅ CORREGIDO
|
|
||||||
"created_by": str(t.created_by),
|
|
||||||
"assigned_to": str(t.assigned_to) if t.assigned_to else None,
|
|
||||||
"created_at": t.created_at,
|
|
||||||
"updated_at": t.updated_at
|
|
||||||
}
|
|
||||||
for t in tickets
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{ticket_id}", response_model=TicketResponse)
|
|
||||||
async def get_ticket(
|
|
||||||
ticket_id: str,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Obtener un ticket específico
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
ticket_uuid = uuid.UUID(ticket_id)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Invalid ticket ID format"
|
|
||||||
)
|
|
||||||
|
|
||||||
query = select(Ticket).where(
|
|
||||||
Ticket.id == ticket_uuid,
|
|
||||||
Ticket.tenant_id == current_user.tenant_id,
|
|
||||||
Ticket.created_by == current_user.id
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await db.execute(query)
|
|
||||||
ticket = result.scalars().first()
|
|
||||||
|
|
||||||
if not ticket:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Ticket {ticket_id} not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ✅ CORREGIDO: Usar affected_system_id
|
|
||||||
return {
|
|
||||||
"id": str(ticket.id),
|
|
||||||
"ticket_number": ticket.ticket_number,
|
|
||||||
"subject": ticket.subject,
|
|
||||||
"description": ticket.description,
|
|
||||||
"status": ticket.status.value,
|
|
||||||
"priority": ticket.priority.value,
|
|
||||||
"category_id": str(ticket.category_id) if ticket.category_id else None,
|
|
||||||
"affected_system_id": str(ticket.affected_system_id) if ticket.affected_system_id else None, # ✅ CORREGIDO
|
|
||||||
"created_by": str(ticket.created_by),
|
|
||||||
"assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None,
|
|
||||||
"created_at": ticket.created_at,
|
|
||||||
"updated_at": ticket.updated_at
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{ticket_id}", response_model=TicketResponse)
|
|
||||||
async def update_ticket(
|
|
||||||
ticket_id: str,
|
|
||||||
ticket_update: TicketUpdate,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Actualizar un ticket
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
ticket_uuid = uuid.UUID(ticket_id)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Invalid ticket ID format"
|
|
||||||
)
|
|
||||||
|
|
||||||
query = select(Ticket).where(
|
|
||||||
Ticket.id == ticket_uuid,
|
|
||||||
Ticket.tenant_id == current_user.tenant_id,
|
|
||||||
Ticket.created_by == current_user.id
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await db.execute(query)
|
|
||||||
db_ticket = result.scalars().first()
|
|
||||||
|
|
||||||
if not db_ticket:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Ticket {ticket_id} not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
update_data = ticket_update.dict(exclude_unset=True)
|
|
||||||
|
|
||||||
for field, value in update_data.items():
|
|
||||||
if field == "status" and value:
|
|
||||||
setattr(db_ticket, field, TicketStatus[value.upper()])
|
|
||||||
elif field == "priority" and value:
|
|
||||||
setattr(db_ticket, field, TicketPriority[value.upper()])
|
|
||||||
elif field == "assigned_to" and value:
|
|
||||||
setattr(db_ticket, field, uuid.UUID(value))
|
|
||||||
else:
|
|
||||||
setattr(db_ticket, field, value)
|
|
||||||
|
|
||||||
db_ticket.updated_at = datetime.utcnow()
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(db_ticket)
|
|
||||||
|
|
||||||
# ✅ CORREGIDO: Usar affected_system_id
|
|
||||||
return {
|
|
||||||
"id": str(db_ticket.id),
|
|
||||||
"ticket_number": db_ticket.ticket_number,
|
|
||||||
"subject": db_ticket.subject,
|
|
||||||
"description": db_ticket.description,
|
|
||||||
"status": db_ticket.status.value,
|
|
||||||
"priority": db_ticket.priority.value,
|
|
||||||
"category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
|
|
||||||
"affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None, # ✅ CORREGIDO
|
|
||||||
"created_by": str(db_ticket.created_by),
|
|
||||||
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
|
|
||||||
"created_at": db_ticket.created_at,
|
|
||||||
"updated_at": db_ticket.updated_at
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
await db.rollback()
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"Error updating ticket: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{ticket_id}/close", response_model=TicketResponse)
|
|
||||||
async def close_ticket(
|
|
||||||
ticket_id: str,
|
|
||||||
close_request: TicketCloseRequest,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Cerrar un ticket
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
ticket_uuid = uuid.UUID(ticket_id)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Invalid ticket ID format"
|
|
||||||
)
|
|
||||||
|
|
||||||
query = select(Ticket).where(
|
|
||||||
Ticket.id == ticket_uuid,
|
|
||||||
Ticket.tenant_id == current_user.tenant_id,
|
|
||||||
Ticket.created_by == current_user.id
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await db.execute(query)
|
|
||||||
db_ticket = result.scalars().first()
|
|
||||||
|
|
||||||
if not db_ticket:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Ticket {ticket_id} not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
db_ticket.status = TicketStatus.CLOSED
|
|
||||||
db_ticket.updated_at = datetime.utcnow()
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(db_ticket)
|
|
||||||
|
|
||||||
# ✅ CORREGIDO: Usar affected_system_id
|
|
||||||
return {
|
|
||||||
"id": str(db_ticket.id),
|
|
||||||
"ticket_number": db_ticket.ticket_number,
|
|
||||||
"subject": db_ticket.subject,
|
|
||||||
"description": db_ticket.description,
|
|
||||||
"status": db_ticket.status.value,
|
|
||||||
"priority": db_ticket.priority.value,
|
|
||||||
"category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
|
|
||||||
"affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None, # ✅ CORREGIDO
|
|
||||||
"created_by": str(db_ticket.created_by),
|
|
||||||
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
|
|
||||||
"created_at": db_ticket.created_at,
|
|
||||||
"updated_at": db_ticket.updated_at
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
await db.rollback()
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"Error closing ticket: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ===================================
|
|
||||||
# COMMENT ENDPOINTS (placeholder)
|
|
||||||
# ===================================
|
|
||||||
|
|
||||||
@router.get("/{ticket_id}/comments")
|
|
||||||
async def get_ticket_comments(
|
|
||||||
ticket_id: str,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Obtener comentarios de un ticket
|
|
||||||
"""
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{ticket_id}/comments", status_code=status.HTTP_201_CREATED)
|
|
||||||
async def create_comment(
|
|
||||||
ticket_id: str,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Agregar un comentario a un ticket
|
|
||||||
"""
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
|
||||||
detail="Comments not yet implemented"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ===================================
|
|
||||||
# ATTACHMENT ENDPOINTS (placeholder)
|
|
||||||
# ===================================
|
|
||||||
|
|
||||||
@router.get("/{ticket_id}/attachments")
|
|
||||||
async def get_ticket_attachments(
|
|
||||||
ticket_id: str,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Obtener adjuntos de un ticket
|
|
||||||
"""
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{ticket_id}/attachments", status_code=status.HTTP_201_CREATED)
|
|
||||||
async def upload_attachment(
|
|
||||||
ticket_id: str,
|
|
||||||
file: UploadFile = File(...),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Subir un archivo adjunto a un ticket
|
|
||||||
"""
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
|
||||||
detail="File uploads not yet implemented"
|
|
||||||
)
|
|
||||||
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 fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from pydantic import BaseModel, ConfigDict, EmailStr
|
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import uuid
|
import uuid
|
||||||
@@ -11,62 +10,23 @@ from app.core.security import security
|
|||||||
from app.models.user import User, UserRole
|
from app.models.user import User, UserRole
|
||||||
from app.services.audit_service import AuditService
|
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()
|
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
|
# 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])
|
@router.get("/", response_model=List[UserResponse])
|
||||||
async def read_users(
|
async def read_users(
|
||||||
skip: int = 0,
|
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 fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit
|
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
@@ -67,3 +67,10 @@ api_router.include_router(
|
|||||||
prefix="/audit",
|
prefix="/audit",
|
||||||
tags=["audit"]
|
tags=["audit"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# SLA routes
|
||||||
|
api_router.include_router(
|
||||||
|
sla.router,
|
||||||
|
prefix="/sla",
|
||||||
|
tags=["sla"]
|
||||||
|
)
|
||||||
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
|
# GENERAL
|
||||||
# ===================================
|
# ===================================
|
||||||
ENVIRONMENT: str = Field(default="development")
|
ENVIRONMENT: str = Field(default="development")
|
||||||
|
TESTING: bool = Field(default=False)
|
||||||
DEBUG: bool = Field(default=False)
|
DEBUG: bool = Field(default=False)
|
||||||
SECRET_KEY: str = Field(...)
|
SECRET_KEY: str = Field(...)
|
||||||
API_VERSION: str = Field(default="v1")
|
API_VERSION: str = Field(default="v1")
|
||||||
APP_VERSION: str = Field(default="1.6.0")
|
APP_VERSION: str = Field(default="1.9.0")
|
||||||
|
|
||||||
# ===================================
|
# ===================================
|
||||||
# DATABASE
|
# DATABASE
|
||||||
@@ -86,6 +87,9 @@ class Settings(BaseSettings):
|
|||||||
# SECURITY
|
# SECURITY
|
||||||
# ===================================
|
# ===================================
|
||||||
RATE_LIMIT_ENABLED: bool = Field(default=True)
|
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)
|
PASSWORD_MIN_LENGTH: int = Field(default=8)
|
||||||
|
|
||||||
# Argon2 settings
|
# 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.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
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
|
from typing import AsyncGenerator
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -19,10 +21,11 @@ settings = get_settings()
|
|||||||
engine = create_async_engine(
|
engine = create_async_engine(
|
||||||
settings.DATABASE_URL,
|
settings.DATABASE_URL,
|
||||||
echo=settings.DEBUG,
|
echo=settings.DEBUG,
|
||||||
pool_size=5,
|
pool_size=20, # Increased for better concurrency
|
||||||
max_overflow=10,
|
max_overflow=30, # Increased for peak loads
|
||||||
pool_pre_ping=True, # Verify connections before use
|
pool_pre_ping=True, # Verify connections before use
|
||||||
pool_recycle=3600, # Recycle connections after 1 hour
|
pool_recycle=3600, # Recycle connections after 1 hour
|
||||||
|
pool_timeout=30, # Wait up to 30s for connection from pool
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create session factory
|
# Create session factory
|
||||||
@@ -33,18 +36,46 @@ AsyncSessionLocal = async_sessionmaker(
|
|||||||
autoflush=True,
|
autoflush=True,
|
||||||
autocommit=False
|
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):
|
class Base(DeclarativeBase):
|
||||||
"""Base class para todos los modelos SQLAlchemy."""
|
"""Base class para todos los modelos SQLAlchemy."""
|
||||||
|
|
||||||
# Columnas comunes para auditoría
|
# 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())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True),
|
DateTime(timezone=True),
|
||||||
server_default=func.now(),
|
server_default=func.now(),
|
||||||
onupdate=func.now()
|
onupdate=func.now(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -88,7 +119,7 @@ async def check_database_health() -> bool:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
await session.execute("SELECT 1")
|
await session.execute(text("SELECT 1"))
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
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
|
||||||
@@ -17,6 +17,8 @@ settings = get_settings()
|
|||||||
class FileHandler:
|
class FileHandler:
|
||||||
"""Handler simple para archivos adjuntos"""
|
"""Handler simple para archivos adjuntos"""
|
||||||
|
|
||||||
|
_CHUNK_SIZE_BYTES = 1024 * 1024 # 1MB
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.upload_path = Path(settings.UPLOAD_PATH)
|
self.upload_path = Path(settings.UPLOAD_PATH)
|
||||||
self.max_size_bytes = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
|
self.max_size_bytes = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
|
||||||
@@ -24,8 +26,8 @@ class FileHandler:
|
|||||||
# Crear directorio si no existe
|
# Crear directorio si no existe
|
||||||
self.upload_path.mkdir(parents=True, exist_ok=True)
|
self.upload_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
def _validate_file(self, filename: str, file_size: int) -> None:
|
def _validate_extension(self, filename: str) -> str:
|
||||||
"""Validar archivo"""
|
"""Validar extensión del archivo y retornarla."""
|
||||||
extension = Path(filename).suffix.lower().lstrip('.')
|
extension = Path(filename).suffix.lower().lstrip('.')
|
||||||
|
|
||||||
if extension not in self.allowed_extensions:
|
if extension not in self.allowed_extensions:
|
||||||
@@ -34,31 +36,51 @@ class FileHandler:
|
|||||||
detail=f"Extensión no permitida: {extension}"
|
detail=f"Extensión no permitida: {extension}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if file_size > self.max_size_bytes:
|
return extension
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
|
||||||
detail=f"Archivo muy grande. Máximo: {settings.MAX_UPLOAD_SIZE_MB}MB"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _calculate_checksums(self, content: bytes) -> Tuple[str, str]:
|
def _validate_magic_bytes(self, extension: str, first_bytes: bytes) -> None:
|
||||||
"""Calcular MD5 y SHA256"""
|
"""Validación básica por firma (magic bytes) para tipos comunes."""
|
||||||
return hashlib.md5(content).hexdigest(), hashlib.sha256(content).hexdigest()
|
|
||||||
|
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_400_BAD_REQUEST,
|
||||||
|
detail="Contenido de archivo no coincide con la extensión declarada",
|
||||||
|
)
|
||||||
|
|
||||||
async def save_upload(self, file: UploadFile, tenant_id: uuid.UUID, ticket_id: uuid.UUID) -> dict:
|
async def save_upload(self, file: UploadFile, tenant_id: uuid.UUID, ticket_id: uuid.UUID) -> dict:
|
||||||
"""Guardar archivo y retornar metadata"""
|
"""Guardar archivo y retornar metadata"""
|
||||||
if not file.filename:
|
if not file.filename:
|
||||||
raise HTTPException(status_code=400, detail="Filename requerido")
|
raise HTTPException(status_code=400, detail="Filename requerido")
|
||||||
|
|
||||||
content = await file.read()
|
extension = self._validate_extension(file.filename)
|
||||||
file_size = len(content)
|
|
||||||
|
|
||||||
self._validate_file(file.filename, file_size)
|
|
||||||
|
|
||||||
md5_hash, sha256_hash = self._calculate_checksums(content)
|
|
||||||
|
|
||||||
# Nombre único
|
# Nombre único
|
||||||
extension = Path(file.filename).suffix.lower()
|
original_extension = Path(file.filename).suffix.lower()
|
||||||
safe_filename = f"{uuid.uuid4().hex}{extension}"
|
safe_filename = f"{uuid.uuid4().hex}{original_extension}"
|
||||||
|
|
||||||
# Estructura: uploads/tenant_id/tickets/ticket_id/
|
# Estructura: uploads/tenant_id/tickets/ticket_id/
|
||||||
file_directory = self.upload_path / str(tenant_id) / "tickets" / str(ticket_id)
|
file_directory = self.upload_path / str(tenant_id) / "tickets" / str(ticket_id)
|
||||||
@@ -67,9 +89,60 @@ class FileHandler:
|
|||||||
file_path = file_directory / safe_filename
|
file_path = file_directory / safe_filename
|
||||||
relative_path = str(file_path.relative_to(self.upload_path))
|
relative_path = str(file_path.relative_to(self.upload_path))
|
||||||
|
|
||||||
# Guardar archivo
|
# Guardar archivo (streaming) + checksums incrementales
|
||||||
with open(file_path, "wb") as f:
|
md5 = hashlib.md5()
|
||||||
f.write(content)
|
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
|
import mimetypes
|
||||||
mime_type = mimetypes.guess_type(file.filename)[0] or "application/octet-stream"
|
mime_type = mimetypes.guess_type(file.filename)[0] or "application/octet-stream"
|
||||||
@@ -80,8 +153,8 @@ class FileHandler:
|
|||||||
"file_path": relative_path,
|
"file_path": relative_path,
|
||||||
"file_size": file_size,
|
"file_size": file_size,
|
||||||
"mime_type": mime_type,
|
"mime_type": mime_type,
|
||||||
"md5_hash": md5_hash,
|
"md5_hash": md5.hexdigest(),
|
||||||
"sha256_hash": sha256_hash
|
"sha256_hash": sha256.hexdigest(),
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_file_path(self, relative_path: str) -> Path:
|
def get_file_path(self, relative_path: str) -> Path:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import pyotp
|
|||||||
import secrets
|
import secrets
|
||||||
import base64
|
import base64
|
||||||
import struct
|
import struct
|
||||||
|
import uuid
|
||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
|
|
||||||
@@ -100,7 +101,8 @@ class SecurityUtils:
|
|||||||
"""
|
"""
|
||||||
to_encode = data.copy()
|
to_encode = data.copy()
|
||||||
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
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(
|
encoded_jwt = jwt.encode(
|
||||||
to_encode,
|
to_encode,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from app.core.logging import setup_logging
|
|||||||
from app.api.v1.router import api_router
|
from app.api.v1.router import api_router
|
||||||
from app.middleware.tenant import TenantMiddleware
|
from app.middleware.tenant import TenantMiddleware
|
||||||
from app.middleware.correlation_id import CorrelationIDMiddleware
|
from app.middleware.correlation_id import CorrelationIDMiddleware
|
||||||
|
from app.core.cache import cache
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
setup_logging()
|
setup_logging()
|
||||||
@@ -42,6 +43,10 @@ async def lifespan(app: FastAPI):
|
|||||||
# Startup
|
# Startup
|
||||||
logger.info("Iniciando ServiceManagerWeb Backend", version=settings.API_VERSION)
|
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":
|
if settings.ENVIRONMENT == "development":
|
||||||
await create_tables()
|
await create_tables()
|
||||||
logger.info("Tablas de base de datos verificadas")
|
logger.info("Tablas de base de datos verificadas")
|
||||||
@@ -50,6 +55,8 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
# Shutdown
|
# Shutdown
|
||||||
logger.info("Cerrando ServiceManagerWeb Backend")
|
logger.info("Cerrando ServiceManagerWeb Backend")
|
||||||
|
await cache.disconnect()
|
||||||
|
logger.info("Caché Redis desconectado")
|
||||||
|
|
||||||
|
|
||||||
# Crear aplicación FastAPI
|
# Crear aplicación FastAPI
|
||||||
@@ -69,12 +76,26 @@ app = FastAPI(
|
|||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
cors_origins = settings.CORS_ORIGINS.split(",") if isinstance(settings.CORS_ORIGINS, str) else settings.CORS_ORIGINS
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=cors_origins,
|
allow_origins=cors_origins,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=cors_allow_methods,
|
||||||
allow_headers=["*"],
|
allow_headers=cors_allow_headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Compression
|
# Compression
|
||||||
|
|||||||
@@ -4,69 +4,174 @@ Tenant Middleware - ServiceManagerWeb
|
|||||||
Middleware para manejo de multi-tenancy
|
Middleware para manejo de multi-tenancy
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import Request, HTTPException, status
|
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
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 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__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
class TenantMiddleware(BaseHTTPMiddleware):
|
class TenantMiddleware(BaseHTTPMiddleware):
|
||||||
"""
|
"""
|
||||||
Middleware para extraer y validar información del tenant.
|
Middleware para extraer y validar información del tenant.
|
||||||
|
|
||||||
Extrae el tenant_id del header X-Tenant-ID y lo almacena
|
Extrae el tenant_id del header X-Tenant-ID o el slug del header
|
||||||
en el estado de la request para uso posterior.
|
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
|
# Rutas que no requieren tenant
|
||||||
EXCLUDED_PATHS = {
|
EXCLUDED_PATHS = {
|
||||||
"/health",
|
"/health",
|
||||||
|
"/api/v1/health",
|
||||||
|
"/v1/health",
|
||||||
|
"/api/v1/health/detailed",
|
||||||
|
"/v1/health/detailed",
|
||||||
"/",
|
"/",
|
||||||
|
"/api/v1/auth/login",
|
||||||
"/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",
|
"/docs",
|
||||||
|
"/api/v1/docs",
|
||||||
|
"/v1/docs",
|
||||||
"/openapi.json",
|
"/openapi.json",
|
||||||
"/redoc"
|
"/api/v1/openapi.json",
|
||||||
|
"/v1/openapi.json",
|
||||||
|
"/redoc",
|
||||||
|
"/api/v1/redoc",
|
||||||
|
"/v1/redoc",
|
||||||
}
|
}
|
||||||
|
|
||||||
async def dispatch(self, request: Request, call_next) -> Response:
|
async def dispatch(self, request: Request, call_next) -> Response:
|
||||||
"""Process request and add tenant information."""
|
"""Valida el tenant en cada request y lo almacena en request.state."""
|
||||||
|
|
||||||
# Skip tenant validation for excluded paths
|
# 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"):
|
if request.url.path in self.EXCLUDED_PATHS or request.url.path.startswith("/docs"):
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
# Extract tenant from header
|
# Extraer headers de tenant
|
||||||
tenant_id = request.headers.get("X-Tenant-ID")
|
tenant_id = request.headers.get("X-Tenant-ID")
|
||||||
tenant_slug = request.headers.get("X-Tenant-Slug")
|
tenant_slug = request.headers.get("X-Tenant-Slug")
|
||||||
|
|
||||||
# For now, we'll be more permissive in development
|
tenant_uuid: uuid.UUID | None = None
|
||||||
# In production, tenant should be strictly required
|
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:
|
if not tenant_id and not tenant_slug:
|
||||||
logger.warning(
|
return JSONResponse(
|
||||||
"Request without tenant information",
|
status_code=400,
|
||||||
path=request.url.path,
|
content={"detail": "Tenant information required (X-Tenant-ID or X-Tenant-Slug header)"},
|
||||||
method=request.method
|
|
||||||
)
|
)
|
||||||
# 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
|
# Validar tenant contra la base de datos
|
||||||
request.state.tenant_id = tenant_id
|
try:
|
||||||
request.state.tenant_slug = tenant_slug
|
# 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)
|
||||||
|
|
||||||
# TODO: Validate tenant exists and is active
|
if override_get_db is not None:
|
||||||
# This would involve a database query which we'll implement later
|
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()
|
||||||
|
|
||||||
logger.debug(
|
if tenant is None:
|
||||||
"Tenant middleware processed",
|
logger.warning(
|
||||||
tenant_id=tenant_id,
|
"Tenant not found",
|
||||||
tenant_slug=tenant_slug,
|
tenant_id=tenant_id,
|
||||||
path=request.url.path
|
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)
|
return await call_next(request)
|
||||||
@@ -3,12 +3,11 @@ Attachment Model - ServiceManagerWeb
|
|||||||
"""
|
"""
|
||||||
from sqlalchemy import String, ForeignKey, Integer, DateTime, func
|
from sqlalchemy import String, ForeignKey, Integer, DateTime, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
from typing import Optional, TYPE_CHECKING
|
from typing import Optional, TYPE_CHECKING
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base, GUID
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.models.ticket import Ticket
|
from app.models.ticket import Ticket
|
||||||
@@ -21,24 +20,24 @@ class TicketAttachment(Base):
|
|||||||
__tablename__ = "ticket_attachments"
|
__tablename__ = "ticket_attachments"
|
||||||
|
|
||||||
# Sobrescribir campos heredados de Base para que coincidan con la tabla real
|
# 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())
|
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
|
# Esta tabla NO tiene updated_at, así que lo excluimos del mapping
|
||||||
|
|
||||||
ticket_id: Mapped[uuid.UUID] = mapped_column(
|
ticket_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
GUID(),
|
||||||
ForeignKey("tickets.id", ondelete="CASCADE"),
|
ForeignKey("tickets.id", ondelete="CASCADE"),
|
||||||
nullable=False
|
nullable=False
|
||||||
)
|
)
|
||||||
|
|
||||||
comment_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
comment_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
GUID(),
|
||||||
ForeignKey("ticket_comments.id", ondelete="CASCADE"),
|
ForeignKey("ticket_comments.id", ondelete="CASCADE"),
|
||||||
nullable=True
|
nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
uploaded_by: Mapped[uuid.UUID] = mapped_column(
|
uploaded_by: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
GUID(),
|
||||||
ForeignKey("users.id"),
|
ForeignKey("users.id"),
|
||||||
nullable=False
|
nullable=False
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ Modelo para bitácora de auditoría y compliance.
|
|||||||
Registra todas las acciones importantes del sistema.
|
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.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
|
from typing import Optional, Dict, Any, TYPE_CHECKING
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base, GUID
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.models.tenant import Tenant
|
from app.models.tenant import Tenant
|
||||||
@@ -36,7 +36,7 @@ class AuditLog(Base):
|
|||||||
|
|
||||||
# Multi-tenancy
|
# Multi-tenancy
|
||||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
GUID(),
|
||||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
index=True
|
index=True
|
||||||
@@ -44,7 +44,7 @@ class AuditLog(Base):
|
|||||||
|
|
||||||
# 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(
|
user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
GUID(),
|
||||||
ForeignKey("users.id", ondelete="SET NULL"),
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
index=True
|
index=True
|
||||||
@@ -66,30 +66,33 @@ class AuditLog(Base):
|
|||||||
|
|
||||||
# ID del recurso afectado
|
# ID del recurso afectado
|
||||||
resource_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
resource_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
GUID(),
|
||||||
nullable=True
|
nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Contexto de la request
|
# Contexto de la request
|
||||||
ip_address: Mapped[Optional[str]] = mapped_column(INET, nullable=True)
|
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)
|
user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
# Correlation ID para rastrear requests relacionadas
|
# Correlation ID para rastrear requests relacionadas
|
||||||
correlation_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
correlation_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
GUID(),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
index=True
|
index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Valores antes del cambio (JSON)
|
# Valores antes del cambio (JSON)
|
||||||
old_values: Mapped[Optional[Dict[str, Any]]] = mapped_column(
|
old_values: Mapped[Optional[Dict[str, Any]]] = mapped_column(
|
||||||
JSONB,
|
JSON().with_variant(JSONB, "postgresql"),
|
||||||
nullable=True
|
nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Valores despu├®s del cambio (JSON)
|
# Valores despu├®s del cambio (JSON)
|
||||||
new_values: Mapped[Optional[Dict[str, Any]]] = mapped_column(
|
new_values: Mapped[Optional[Dict[str, Any]]] = mapped_column(
|
||||||
JSONB,
|
JSON().with_variant(JSONB, "postgresql"),
|
||||||
nullable=True
|
nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -97,7 +100,7 @@ class AuditLog(Base):
|
|||||||
# Nota: 'metadata' está reservado en SQLAlchemy, usamos 'extra_metadata'
|
# Nota: 'metadata' está reservado en SQLAlchemy, usamos 'extra_metadata'
|
||||||
extra_metadata: Mapped[Optional[Dict[str, Any]]] = mapped_column(
|
extra_metadata: Mapped[Optional[Dict[str, Any]]] = mapped_column(
|
||||||
'metadata', # Nombre real de la columna en BD
|
'metadata', # Nombre real de la columna en BD
|
||||||
JSONB,
|
JSON().with_variant(JSONB, "postgresql"),
|
||||||
nullable=True
|
nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,10 @@ Categorías de tickets por tenant
|
|||||||
"""
|
"""
|
||||||
from sqlalchemy import String, Text, Boolean, Integer, ForeignKey, UniqueConstraint
|
from sqlalchemy import String, Text, Boolean, Integer, ForeignKey, UniqueConstraint
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base, GUID
|
||||||
|
|
||||||
class Category(Base):
|
class Category(Base):
|
||||||
"""Modelo de categorías de tickets (ticket_categories en BD)"""
|
"""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
|
# ✅ CORREGIDO: tenant_id es obligatorio para multi-tenancy
|
||||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
GUID(),
|
||||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||||
nullable=False # ✅ Obligatorio
|
nullable=False # ✅ Obligatorio
|
||||||
)
|
)
|
||||||
@@ -31,7 +30,7 @@ class Category(Base):
|
|||||||
sla_response_hours: Mapped[int] = mapped_column(Integer, default=24, nullable=False)
|
sla_response_hours: Mapped[int] = mapped_column(Integer, default=24, nullable=False)
|
||||||
sla_resolution_hours: Mapped[int] = mapped_column(Integer, default=72, nullable=False)
|
sla_resolution_hours: Mapped[int] = mapped_column(Integer, default=72, nullable=False)
|
||||||
auto_assign_to: Mapped[Optional[uuid.UUID]] = mapped_column(
|
auto_assign_to: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
GUID(),
|
||||||
ForeignKey("users.id"),
|
ForeignKey("users.id"),
|
||||||
nullable=True
|
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 import String, Boolean, DateTime, ForeignKey, Text, Numeric
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
from typing import Optional, TYPE_CHECKING
|
from typing import Optional, TYPE_CHECKING
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base, GUID
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.models.tenant import Tenant
|
from app.models.tenant import Tenant
|
||||||
@@ -25,7 +24,7 @@ class ClientProfile(Base):
|
|||||||
|
|
||||||
# Relación con tenant (uno a uno)
|
# Relación con tenant (uno a uno)
|
||||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
GUID(),
|
||||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||||
unique=True,
|
unique=True,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
|
|||||||
@@ -5,12 +5,11 @@ Modelo para comentarios en tickets
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from sqlalchemy import Column, String, Text, Boolean, ForeignKey, DateTime
|
from sqlalchemy import Column, String, Text, Boolean, ForeignKey, DateTime
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base, GUID
|
||||||
|
|
||||||
|
|
||||||
class TicketComment(Base):
|
class TicketComment(Base):
|
||||||
@@ -20,20 +19,20 @@ class TicketComment(Base):
|
|||||||
|
|
||||||
# Columnas
|
# Columnas
|
||||||
id: Mapped[uuid.UUID] = mapped_column(
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
GUID(),
|
||||||
primary_key=True,
|
primary_key=True,
|
||||||
default=uuid.uuid4
|
default=uuid.uuid4
|
||||||
)
|
)
|
||||||
|
|
||||||
ticket_id: Mapped[uuid.UUID] = mapped_column(
|
ticket_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
GUID(),
|
||||||
ForeignKey("tickets.id", ondelete="CASCADE"),
|
ForeignKey("tickets.id", ondelete="CASCADE"),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
index=True
|
index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
author_id: Mapped[uuid.UUID] = mapped_column(
|
author_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
GUID(),
|
||||||
ForeignKey("users.id"),
|
ForeignKey("users.id"),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
index=True
|
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 import String, Boolean, DateTime, ForeignKey, Index, Integer
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
from typing import Optional, TYPE_CHECKING
|
from typing import Optional, TYPE_CHECKING
|
||||||
import uuid
|
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:
|
if TYPE_CHECKING:
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
@@ -36,7 +35,7 @@ class RefreshToken(Base):
|
|||||||
|
|
||||||
# User relationship
|
# User relationship
|
||||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
GUID(),
|
||||||
ForeignKey("users.id", ondelete="CASCADE"),
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
index=True
|
index=True
|
||||||
@@ -92,7 +91,7 @@ class RefreshToken(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
revoked_by: Mapped[Optional[uuid.UUID]] = mapped_column(
|
revoked_by: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
GUID(),
|
||||||
ForeignKey("users.id", ondelete="SET NULL"),
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
nullable=True
|
nullable=True
|
||||||
)
|
)
|
||||||
@@ -146,12 +145,12 @@ class RefreshToken(Base):
|
|||||||
- No está revocado
|
- No está revocado
|
||||||
- No ha expirado
|
- 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
|
@property
|
||||||
def is_expired(self) -> bool:
|
def is_expired(self) -> bool:
|
||||||
"""Verificar si el token ha expirado."""
|
"""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:
|
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
|
revoked_by: ID del usuario que revoc├│ el token
|
||||||
"""
|
"""
|
||||||
self.revoked = True
|
self.revoked = True
|
||||||
self.revoked_at = datetime.utcnow()
|
self.revoked_at = datetime.now(timezone.utc)
|
||||||
if revoked_by:
|
if revoked_by:
|
||||||
self.revoked_by = revoked_by
|
self.revoked_by = revoked_by
|
||||||
|
|
||||||
def track_usage(self) -> None:
|
def track_usage(self) -> None:
|
||||||
"""Registrar uso del token."""
|
"""Registrar uso del token."""
|
||||||
self.last_used_at = datetime.utcnow()
|
self.last_used_at = datetime.now(timezone.utc)
|
||||||
self.usage_count += 1
|
self.usage_count += 1
|
||||||
|
|||||||
@@ -4,11 +4,10 @@ Sistemas afectados por tenant
|
|||||||
"""
|
"""
|
||||||
from sqlalchemy import String, Text, Boolean, ForeignKey, UniqueConstraint
|
from sqlalchemy import String, Text, Boolean, ForeignKey, UniqueConstraint
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base, GUID
|
||||||
|
|
||||||
class System(Base):
|
class System(Base):
|
||||||
"""Modelo de sistemas afectados (affected_systems en BD)"""
|
"""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)
|
# ✅ AÑADIDO: tenant_id obligatorio para multi-tenancy (faltaba completamente)
|
||||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
GUID(),
|
||||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||||
nullable=False
|
nullable=False
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
Tenant Model - ServiceManagerWeb
|
Tenant Model - ServiceManagerWeb
|
||||||
Modelo para organizaciones cliente (multi-tenancy)
|
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.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
|
from typing import List, Optional
|
||||||
import enum
|
import enum
|
||||||
import uuid
|
import uuid
|
||||||
@@ -40,7 +40,7 @@ class Tenant(Base):
|
|||||||
max_users: Mapped[int] = mapped_column(Integer, default=50)
|
max_users: Mapped[int] = mapped_column(Integer, default=50)
|
||||||
max_storage_mb: Mapped[int] = mapped_column(Integer, default=1024)
|
max_storage_mb: Mapped[int] = mapped_column(Integer, default=1024)
|
||||||
allowed_file_types: Mapped[List[str]] = mapped_column(
|
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"]
|
default=["pdf", "jpg", "jpeg", "png", "doc", "docx", "xls", "xlsx", "txt"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,20 @@
|
|||||||
Ticket Model - ServiceManagerWeb
|
Ticket Model - ServiceManagerWeb
|
||||||
Tickets de soporte - Core del negocio
|
Tickets de soporte - Core del negocio
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import String, ForeignKey, Text, Integer, CheckConstraint, UniqueConstraint
|
from sqlalchemy import String, ForeignKey, Text, Integer, CheckConstraint, UniqueConstraint, Enum as SAEnum
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship, synonym
|
||||||
from sqlalchemy.dialects.postgresql import UUID, ENUM
|
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import enum
|
import enum
|
||||||
import uuid
|
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):
|
class TicketStatus(str, enum.Enum):
|
||||||
"""Estados posibles de un ticket"""
|
"""Estados posibles de un ticket"""
|
||||||
@@ -35,50 +40,64 @@ class Ticket(Base):
|
|||||||
|
|
||||||
# Multi-tenancy
|
# Multi-tenancy
|
||||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
GUID(),
|
||||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||||
nullable=False
|
nullable=False
|
||||||
)
|
)
|
||||||
|
|
||||||
# Campos básicos
|
# 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)
|
subject: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
description: Mapped[str] = mapped_column(Text, 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
|
# Estado y Prioridad
|
||||||
status: Mapped[TicketStatus] = mapped_column(
|
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,
|
default=TicketStatus.NEW,
|
||||||
nullable=False
|
nullable=False
|
||||||
)
|
)
|
||||||
priority: Mapped[TicketPriority] = mapped_column(
|
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,
|
default=TicketPriority.MEDIUM,
|
||||||
nullable=False
|
nullable=False
|
||||||
)
|
)
|
||||||
|
|
||||||
# ✅ CORREGIDO: Foreign Keys apuntan a tablas correctas
|
# ✅ CORREGIDO: Foreign Keys apuntan a tablas correctas
|
||||||
created_by: Mapped[uuid.UUID] = mapped_column(
|
created_by: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
GUID(),
|
||||||
ForeignKey("users.id"),
|
ForeignKey("users.id"),
|
||||||
nullable=False
|
nullable=False
|
||||||
)
|
)
|
||||||
assigned_to: Mapped[Optional[uuid.UUID]] = mapped_column(
|
assigned_to: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
GUID(),
|
||||||
ForeignKey("users.id"),
|
ForeignKey("users.id"),
|
||||||
nullable=True
|
nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# ✅ CORREGIDO: Renombrado de system_id a affected_system_id
|
# ✅ CORREGIDO: Renombrado de system_id a affected_system_id
|
||||||
affected_system_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
affected_system_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
GUID(),
|
||||||
ForeignKey("affected_systems.id"), # ✅ Tabla correcta
|
ForeignKey("affected_systems.id"), # ✅ Tabla correcta
|
||||||
nullable=True
|
nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# ✅ CORREGIDO: Foreign key a tabla correcta
|
# ✅ CORREGIDO: Foreign key a tabla correcta
|
||||||
category_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
category_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
GUID(),
|
||||||
ForeignKey("ticket_categories.id"), # ✅ Tabla correcta
|
ForeignKey("ticket_categories.id"), # ✅ Tabla correcta
|
||||||
nullable=True
|
nullable=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ User Model - ServiceManagerWeb
|
|||||||
Modelo para usuarios del sistema (internos y clientes)
|
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.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
|
from typing import Optional, List
|
||||||
import enum
|
import enum
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base, GUID
|
||||||
|
|
||||||
|
|
||||||
class UserRole(str, enum.Enum):
|
class UserRole(str, enum.Enum):
|
||||||
@@ -35,7 +35,7 @@ class User(Base):
|
|||||||
|
|
||||||
# Relación con tenant
|
# Relación con tenant
|
||||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
GUID(),
|
||||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||||
nullable=False
|
nullable=False
|
||||||
)
|
)
|
||||||
@@ -48,12 +48,20 @@ class User(Base):
|
|||||||
|
|
||||||
# Autenticación
|
# Autenticación
|
||||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
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)
|
# 2FA (opcional para staff interno)
|
||||||
totp_secret: Mapped[Optional[str]] = mapped_column(String(32))
|
totp_secret: Mapped[Optional[str]] = mapped_column(String(32))
|
||||||
totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
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
|
# Estado
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
@@ -85,11 +93,6 @@ class User(Base):
|
|||||||
cascade="all, delete-orphan"
|
cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Unique constraint por tenant
|
|
||||||
__table_args__ = (
|
|
||||||
{"postgresql_tablespace": "users"},
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"<User(id={self.id}, email='{self.email}', role='{self.role}')>"
|
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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, delete
|
from sqlalchemy import select, delete
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import uuid
|
import uuid
|
||||||
import structlog
|
import structlog
|
||||||
@@ -56,7 +56,7 @@ class TokenService:
|
|||||||
RefreshToken creado
|
RefreshToken creado
|
||||||
"""
|
"""
|
||||||
# Calcular expiraci├│n
|
# Calcular expiraci├│n
|
||||||
expires_at = datetime.utcnow() + timedelta(
|
expires_at = datetime.now(timezone.utc) + timedelta(
|
||||||
days=settings.REFRESH_TOKEN_EXPIRE_DAYS
|
days=settings.REFRESH_TOKEN_EXPIRE_DAYS
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -232,7 +232,7 @@ class TokenService:
|
|||||||
N├║mero de tokens eliminados
|
N├║mero de tokens eliminados
|
||||||
"""
|
"""
|
||||||
# Eliminar tokens expirados hace más de 7 días
|
# 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(
|
query = delete(RefreshToken).where(
|
||||||
RefreshToken.expires_at < cutoff_date
|
RefreshToken.expires_at < cutoff_date
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Revises: 13362e8c493a
|
|||||||
Create Date: 2026-02-12 10:00:00.000000
|
Create Date: 2026-02-12 10:00:00.000000
|
||||||
|
|
||||||
Registra el modelo AuditLog en Alembic.
|
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.
|
la registra en el control de versiones de Alembic.
|
||||||
"""
|
"""
|
||||||
from alembic import op
|
from alembic import op
|
||||||
@@ -19,6 +19,62 @@ branch_labels = None
|
|||||||
depends_on = 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():
|
def upgrade():
|
||||||
"""
|
"""
|
||||||
Verificar que audit_logs existe y registrarla en Alembic.
|
Verificar que audit_logs existe y registrarla en Alembic.
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Fix client_profiles timestamps to use server defaults
|
||||||
|
|
||||||
|
Revision ID: fix_client_timestamps
|
||||||
|
Revises: a1b2c3d4e5f6
|
||||||
|
Create Date: 2026-02-17 12:05:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'fix_client_timestamps'
|
||||||
|
down_revision = 'a1b2c3d4e5f6'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Modificar created_at para usar server_default
|
||||||
|
op.alter_column('client_profiles', 'created_at',
|
||||||
|
existing_type=sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text('now()')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Modificar updated_at para usar server_default
|
||||||
|
op.alter_column('client_profiles', 'updated_at',
|
||||||
|
existing_type=sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text('now()')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Remover server_default
|
||||||
|
op.alter_column('client_profiles', 'created_at',
|
||||||
|
existing_type=sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=None
|
||||||
|
)
|
||||||
|
|
||||||
|
op.alter_column('client_profiles', 'updated_at',
|
||||||
|
existing_type=sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=None
|
||||||
|
)
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
[tool:pytest]
|
[pytest]
|
||||||
testpaths = tests
|
testpaths = tests tests/unit tests/integration
|
||||||
python_files = test_*.py
|
python_files = test_*.py
|
||||||
python_functions = test_*
|
python_functions = test_*
|
||||||
python_classes = Test*
|
python_classes = Test*
|
||||||
@@ -17,6 +17,8 @@ markers =
|
|||||||
unit: marks tests as unit tests
|
unit: marks tests as unit tests
|
||||||
auth: marks tests related to authentication
|
auth: marks tests related to authentication
|
||||||
db: marks tests that require database
|
db: marks tests that require database
|
||||||
|
env =
|
||||||
|
TESTING=true
|
||||||
filterwarnings =
|
filterwarnings =
|
||||||
ignore::DeprecationWarning
|
ignore::DeprecationWarning
|
||||||
ignore::PendingDeprecationWarning
|
ignore::PendingDeprecationWarning
|
||||||
115
backend/run_tests.sh
Executable file
115
backend/run_tests.sh
Executable file
@@ -0,0 +1,115 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Script para ejecutar tests de integración de ServiceManagerWeb
|
||||||
|
# Este script configura el ambiente de testing y ejecuta la suite completa
|
||||||
|
|
||||||
|
set -e # Exit on error
|
||||||
|
|
||||||
|
echo "🧪 ServiceManagerWeb - Test Runner"
|
||||||
|
echo "=================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Colores para output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Verificar que estamos en el directorio correcto
|
||||||
|
if [ ! -f "requirements.txt" ]; then
|
||||||
|
echo -e "${RED}❌ Error: Debe ejecutar este script desde el directorio backend/${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verificar que existe la BD de test
|
||||||
|
echo "📦 Verificando base de datos de testing..."
|
||||||
|
if ! docker-compose exec -T postgres psql -U servicemanager -lqt | cut -d \| -f 1 | grep -qw servicemanager_test; then
|
||||||
|
echo "⚙️ Creando base de datos de testing..."
|
||||||
|
docker-compose exec -T postgres psql -U servicemanager -c "CREATE DATABASE servicemanager_test;" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓ Base de datos lista${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Verificar que los servicios estén corriendo
|
||||||
|
echo "🐳 Verificando servicios Docker..."
|
||||||
|
if ! docker-compose ps | grep -q "Up"; then
|
||||||
|
echo -e "${YELLOW}⚠️ Servicios no están corriendo. Iniciando...${NC}"
|
||||||
|
docker-compose up -d postgres redis
|
||||||
|
sleep 5
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓ Servicios activos${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Configuración de tests
|
||||||
|
export TESTING=true
|
||||||
|
export DATABASE_URL="postgresql+asyncpg://servicemanager:servicemanager123@localhost:5432/servicemanager_test"
|
||||||
|
|
||||||
|
# Opciones de pytest
|
||||||
|
PYTEST_ARGS="-v --tb=short --color=yes"
|
||||||
|
|
||||||
|
# Parsear argumentos
|
||||||
|
case "${1:-all}" in
|
||||||
|
auth)
|
||||||
|
echo "🔐 Ejecutando tests de autenticación..."
|
||||||
|
pytest $PYTEST_ARGS tests/integration/test_auth_integration.py
|
||||||
|
;;
|
||||||
|
multitenant)
|
||||||
|
echo "🏢 Ejecutando tests de multi-tenancy..."
|
||||||
|
pytest $PYTEST_ARGS tests/integration/test_multitenant_integration.py
|
||||||
|
;;
|
||||||
|
tickets)
|
||||||
|
echo "🎫 Ejecutando tests de tickets..."
|
||||||
|
pytest $PYTEST_ARGS tests/integration/test_tickets_integration.py
|
||||||
|
;;
|
||||||
|
integration)
|
||||||
|
echo "🔗 Ejecutando todos los tests de integración..."
|
||||||
|
pytest $PYTEST_ARGS tests/integration/
|
||||||
|
;;
|
||||||
|
unit)
|
||||||
|
echo "⚡ Ejecutando tests unitarios..."
|
||||||
|
pytest $PYTEST_ARGS tests/unit/
|
||||||
|
;;
|
||||||
|
coverage)
|
||||||
|
echo "📊 Ejecutando tests con cobertura..."
|
||||||
|
pytest $PYTEST_ARGS --cov=app --cov-report=html --cov-report=term tests/integration/ tests/unit/
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}✓ Reporte de cobertura generado en htmlcov/index.html${NC}"
|
||||||
|
;;
|
||||||
|
all)
|
||||||
|
echo "🎯 Ejecutando suite completa de tests..."
|
||||||
|
pytest $PYTEST_ARGS tests/unit/ tests/integration/
|
||||||
|
;;
|
||||||
|
clean)
|
||||||
|
echo "🧹 Limpiando base de datos de testing..."
|
||||||
|
docker-compose exec -T postgres psql -U servicemanager -c "DROP DATABASE IF EXISTS servicemanager_test;"
|
||||||
|
docker-compose exec -T postgres psql -U servicemanager -c "CREATE DATABASE servicemanager_test;"
|
||||||
|
echo -e "${GREEN}✓ Base de datos limpia${NC}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Uso: $0 [auth|multitenant|tickets|integration|unit|coverage|all|clean]"
|
||||||
|
echo ""
|
||||||
|
echo "Opciones:"
|
||||||
|
echo " auth - Tests de autenticación"
|
||||||
|
echo " multitenant - Tests de aislamiento multi-tenant"
|
||||||
|
echo " tickets - Tests CRUD de tickets"
|
||||||
|
echo " integration - Todos los tests de integración"
|
||||||
|
echo " unit - Tests unitarios"
|
||||||
|
echo " coverage - Tests con reporte de cobertura"
|
||||||
|
echo " all - Todos los tests (default)"
|
||||||
|
echo " clean - Limpiar base de datos de testing"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Mostrar resultado
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}✅ Tests completados exitosamente${NC}"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo -e "${RED}❌ Algunos tests fallaron${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
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`
|
||||||
35
backend/scripts/check_tenants.py
Normal file
35
backend/scripts/check_tenants.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""
|
||||||
|
Utility script to list all tenants in the database
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.core.database import AsyncSessionLocal
|
||||||
|
from app.models.tenant import Tenant
|
||||||
|
|
||||||
|
async def list_tenants():
|
||||||
|
"""List all tenants with their details."""
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
result = await db.execute(select(Tenant))
|
||||||
|
tenants = result.scalars().all()
|
||||||
|
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("📋 TENANTS EN LA BASE DE DATOS")
|
||||||
|
print("="*60 + "\n")
|
||||||
|
|
||||||
|
if not tenants:
|
||||||
|
print("⚠️ No hay tenants en la base de datos\n")
|
||||||
|
print("💡 Ejecuta las migraciones o crea un tenant manualmente")
|
||||||
|
return
|
||||||
|
|
||||||
|
for tenant in tenants:
|
||||||
|
print(f"Slug: {tenant.slug}")
|
||||||
|
print(f"Nombre: {tenant.name}")
|
||||||
|
print(f"Status: {tenant.status}")
|
||||||
|
print(f"Email: {tenant.contact_email or 'N/A'}")
|
||||||
|
print(f"ID: {tenant.id}")
|
||||||
|
print("-" * 60)
|
||||||
|
|
||||||
|
print(f"\nTotal: {len(tenants)} tenant(s)\n")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(list_tenants())
|
||||||
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")
|
||||||
0
backend/scripts/set_test_password.py
Normal file
0
backend/scripts/set_test_password.py
Normal file
@@ -1,109 +0,0 @@
|
|||||||
"""
|
|
||||||
Script de verificación de control de acceso basado en roles
|
|
||||||
"""
|
|
||||||
import asyncio
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
BASE_URL = "http://localhost:8000/api/v1"
|
|
||||||
|
|
||||||
# Credenciales de prueba
|
|
||||||
USERS = {
|
|
||||||
"admin": {"email": "admin@aduanasoft.com", "password": "Admin123!", "tenant_slug": "aduanasoft"},
|
|
||||||
"agent": {"email": "agente@aduanasoft.com", "password": "Agente123!", "tenant_slug": "aduanasoft"},
|
|
||||||
"client": {"email": "test_user@example.com", "password": "TestPassword123!", "tenant_slug": "aduanasoft"}
|
|
||||||
}
|
|
||||||
|
|
||||||
async def login(user_type: str):
|
|
||||||
"""Login y obtener token"""
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
response = await client.post(
|
|
||||||
f"{BASE_URL}/auth/login",
|
|
||||||
json=USERS[user_type]
|
|
||||||
)
|
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json()
|
|
||||||
return data["access_token"], data["user"]
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
async def test_endpoint(method: str, endpoint: str, token: str, tenant_id: str, data: dict = None):
|
|
||||||
"""Probar un endpoint"""
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
headers = {
|
|
||||||
"Authorization": f"Bearer {token}",
|
|
||||||
"X-Tenant-ID": tenant_id
|
|
||||||
}
|
|
||||||
|
|
||||||
if method == "GET":
|
|
||||||
response = await client.get(f"{BASE_URL}{endpoint}", headers=headers)
|
|
||||||
elif method == "POST":
|
|
||||||
response = await client.post(f"{BASE_URL}{endpoint}", headers=headers, json=data)
|
|
||||||
elif method == "PUT":
|
|
||||||
response = await client.put(f"{BASE_URL}{endpoint}", headers=headers, json=data)
|
|
||||||
elif method == "DELETE":
|
|
||||||
response = await client.delete(f"{BASE_URL}{endpoint}", headers=headers)
|
|
||||||
|
|
||||||
return response.status_code
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
print("=" * 80)
|
|
||||||
print("VERIFICACIÓN DE CONTROL DE ACCESO BASADO EN ROLES")
|
|
||||||
print("=" * 80)
|
|
||||||
|
|
||||||
# Login todos los usuarios
|
|
||||||
print("\n1. Autenticando usuarios...")
|
|
||||||
admin_token, admin_user = await login("admin")
|
|
||||||
agent_token, agent_user = await login("agent")
|
|
||||||
client_token, client_user = await login("client")
|
|
||||||
|
|
||||||
if not all([admin_token, agent_token, client_token]):
|
|
||||||
print("❌ Error en autenticación")
|
|
||||||
return
|
|
||||||
|
|
||||||
tenant_id = admin_user["tenant_id"]
|
|
||||||
print(f"✅ Todos autenticados - Tenant ID: {tenant_id}")
|
|
||||||
|
|
||||||
# Test 1: Listar tickets
|
|
||||||
print("\n2. Test GET /tickets (listar tickets)")
|
|
||||||
print(" - Admin:", "✅" if await test_endpoint("GET", "/tickets/", admin_token, tenant_id) == 200 else "❌")
|
|
||||||
print(" - Agent:", "✅" if await test_endpoint("GET", "/tickets/", agent_token, tenant_id) == 200 else "❌")
|
|
||||||
print(" - Client:", "✅" if await test_endpoint("GET", "/tickets/", client_token, tenant_id) == 200 else "❌")
|
|
||||||
|
|
||||||
# Test 2: Crear categoría (solo ADMIN/SUPPORT_MANAGER)
|
|
||||||
print("\n3. Test POST /categories/ (crear categoría)")
|
|
||||||
category_data = {"name": "Test Category", "description": "Test"}
|
|
||||||
admin_status = await test_endpoint("POST", "/categories/", admin_token, tenant_id, category_data)
|
|
||||||
agent_status = await test_endpoint("POST", "/categories/", agent_token, tenant_id, category_data)
|
|
||||||
client_status = await test_endpoint("POST", "/categories/", client_token, tenant_id, category_data)
|
|
||||||
|
|
||||||
print(f" - Admin: {'✅' if admin_status in [200, 201] else '❌'} (esperado: 201)")
|
|
||||||
print(f" - Agent: {'✅' if agent_status == 403 else '❌'} (esperado: 403)")
|
|
||||||
print(f" - Client: {'✅' if client_status == 403 else '❌'} (esperado: 403)")
|
|
||||||
|
|
||||||
# Test 3: Crear sistema (solo ADMIN/SUPPORT_MANAGER)
|
|
||||||
print("\n4. Test POST /systems/ (crear sistema)")
|
|
||||||
system_data = {"name": "Test System", "description": "Test"}
|
|
||||||
admin_status = await test_endpoint("POST", "/systems/", admin_token, tenant_id, system_data)
|
|
||||||
agent_status = await test_endpoint("POST", "/systems/", agent_token, tenant_id, system_data)
|
|
||||||
client_status = await test_endpoint("POST", "/systems/", client_token, tenant_id, system_data)
|
|
||||||
|
|
||||||
print(f" - Admin: {'✅' if admin_status in [200, 201] else '❌'} (esperado: 201)")
|
|
||||||
print(f" - Agent: {'✅' if agent_status == 403 else '❌'} (esperado: 403)")
|
|
||||||
print(f" - Client: {'✅' if client_status == 403 else '❌'} (esperado: 403)")
|
|
||||||
|
|
||||||
# Test 4: Ver tickets de otros usuarios
|
|
||||||
print("\n5. Test de visibilidad de tickets:")
|
|
||||||
print(" - Admin puede ver tickets de clientes: ✅ (implementado)")
|
|
||||||
print(" - Agent puede ver tickets de clientes: ✅ (implementado)")
|
|
||||||
print(" - Client solo ve sus propios tickets: ✅ (implementado)")
|
|
||||||
|
|
||||||
print("\n" + "=" * 80)
|
|
||||||
print("RESUMEN")
|
|
||||||
print("=" * 80)
|
|
||||||
print("✅ Control de acceso basado en roles implementado correctamente")
|
|
||||||
print("✅ Staff interno (ADMIN/AGENT) puede ver todos los tickets del tenant")
|
|
||||||
print("✅ Clientes solo ven sus propios tickets")
|
|
||||||
print("✅ Solo ADMIN/SUPPORT_MANAGER pueden crear/modificar categories/systems")
|
|
||||||
print("=" * 80)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
"""Script para verificar el acceso a tickets con diferentes usuarios"""
|
|
||||||
import asyncio
|
|
||||||
import httpx
|
|
||||||
import os
|
|
||||||
|
|
||||||
BASE_URL = "http://localhost:8000/api/v1"
|
|
||||||
TICKET_ID = "2bd79718-440d-4144-b660-c0c6051fcf73"
|
|
||||||
|
|
||||||
async def login(email: str, password: str, tenant_slug: str = "aduanasoft"):
|
|
||||||
"""Login y obtener token"""
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
response = await client.post(
|
|
||||||
f"{BASE_URL}/auth/login",
|
|
||||||
json={
|
|
||||||
"email": email,
|
|
||||||
"password": password,
|
|
||||||
"tenant_slug": tenant_slug
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json()
|
|
||||||
return data["access_token"], data["user"]
|
|
||||||
else:
|
|
||||||
print(f"❌ Login failed for {email}: {response.text}")
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
async def get_ticket(ticket_id: str, token: str, tenant_id: str):
|
|
||||||
"""Intentar obtener un ticket"""
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
response = await client.get(
|
|
||||||
f"{BASE_URL}/tickets/{ticket_id}",
|
|
||||||
headers={
|
|
||||||
"Authorization": f"Bearer {token}",
|
|
||||||
"X-Tenant-ID": tenant_id
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return response.status_code, response.text
|
|
||||||
|
|
||||||
async def test_access():
|
|
||||||
print("=" * 60)
|
|
||||||
print("PRUEBA DE ACCESO A TICKETS")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
# Test con test_user (CLIENT_USER)
|
|
||||||
print("\n1. Probando con test_user (CLIENT_USER)...")
|
|
||||||
token, user = await login("test_user@example.com", "TestPassword123!")
|
|
||||||
if token and user:
|
|
||||||
print(f" ✅ Login exitoso - Role: {user['role']}, Tenant: {user['tenant_id']}")
|
|
||||||
status, response = await get_ticket(TICKET_ID, token, user['tenant_id'])
|
|
||||||
if status == 200:
|
|
||||||
print(f" ✅ Ticket obtenido correctamente")
|
|
||||||
else:
|
|
||||||
print(f" ❌ Error {status}: {response}")
|
|
||||||
|
|
||||||
# Test con admin
|
|
||||||
print("\n2. Probando con admin (ADMIN)...")
|
|
||||||
token, user = await login("admin@aduanasoft.com", "Admin123!")
|
|
||||||
if token and user:
|
|
||||||
print(f" ✅ Login exitoso - Role: {user['role']}, Tenant: {user['tenant_id']}")
|
|
||||||
status, response = await get_ticket(TICKET_ID, token, user['tenant_id'])
|
|
||||||
if status == 200:
|
|
||||||
print(f" ✅ Ticket obtenido correctamente")
|
|
||||||
else:
|
|
||||||
print(f" ❌ Error {status}: {response}")
|
|
||||||
|
|
||||||
# Test con agente
|
|
||||||
print("\n3. Probando con agente (AGENT)...")
|
|
||||||
token, user = await login("agente@aduanasoft.com", "Agente123!")
|
|
||||||
if token and user:
|
|
||||||
print(f" ✅ Login exitoso - Role: {user['role']}, Tenant: {user['tenant_id']}")
|
|
||||||
status, response = await get_ticket(TICKET_ID, token, user['tenant_id'])
|
|
||||||
if status == 200:
|
|
||||||
print(f" ✅ Ticket obtenido correctamente")
|
|
||||||
else:
|
|
||||||
print(f" ❌ Error {status}: {response}")
|
|
||||||
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print("Nota: Este ticket fue creado por test_user@example.com")
|
|
||||||
print("Ahora todos los usuarios del mismo tenant deberían poder verlo")
|
|
||||||
print("según su rol (admins y agentes: todos, clientes: solo propios)")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(test_access())
|
|
||||||
260
backend/tests/README_TESTS.md
Normal file
260
backend/tests/README_TESTS.md
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
# Tests de Integración - ServiceManagerWeb
|
||||||
|
|
||||||
|
Suite completa de tests de integración para validar funcionalidad crítica del sistema.
|
||||||
|
|
||||||
|
## 📋 Estructura de Tests
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/
|
||||||
|
├── conftest.py # Fixtures básicas (original)
|
||||||
|
├── conftest_integration.py # Fixtures para tests de integración
|
||||||
|
├── test_auth_integration.py # Tests de autenticación
|
||||||
|
├── test_multitenant_integration.py # Tests de aislamiento multi-tenant
|
||||||
|
├── test_tickets_integration.py # Tests CRUD de tickets
|
||||||
|
├── test_basic.py # Tests unitarios básicos (original)
|
||||||
|
└── test_health.py # Tests de health checks (original)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Ejecutar Tests
|
||||||
|
|
||||||
|
### Prerequisitos
|
||||||
|
|
||||||
|
1. **Servicios Docker corriendo:**
|
||||||
|
```bash
|
||||||
|
docker-compose up -d postgres redis
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Base de datos de testing:**
|
||||||
|
```bash
|
||||||
|
# Se crea automáticamente, pero si necesitas crearla manualmente:
|
||||||
|
docker-compose exec postgres psql -U servicemanager -c "CREATE DATABASE servicemanager_test;"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ejecución Rápida
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dar permisos de ejecución al script
|
||||||
|
chmod +x backend/run_tests.sh
|
||||||
|
|
||||||
|
# Ejecutar todos los tests
|
||||||
|
cd backend
|
||||||
|
./run_tests.sh all
|
||||||
|
|
||||||
|
# Ejecutar solo tests de autenticación
|
||||||
|
./run_tests.sh auth
|
||||||
|
|
||||||
|
# Ejecutar solo tests de multi-tenancy
|
||||||
|
./run_tests.sh multitenant
|
||||||
|
|
||||||
|
# Ejecutar solo tests de tickets
|
||||||
|
./run_tests.sh tickets
|
||||||
|
|
||||||
|
# Ejecutar con reporte de cobertura
|
||||||
|
./run_tests.sh coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ejecución Manual con pytest
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# Todos los tests de integración
|
||||||
|
pytest -v -m integration tests/
|
||||||
|
|
||||||
|
# Tests específicos por archivo
|
||||||
|
pytest -v tests/test_auth_integration.py
|
||||||
|
pytest -v tests/test_multitenant_integration.py
|
||||||
|
pytest -v tests/test_tickets_integration.py
|
||||||
|
|
||||||
|
# Con cobertura
|
||||||
|
pytest --cov=app --cov-report=html tests/test_*_integration.py
|
||||||
|
|
||||||
|
# Tests específicos por clase
|
||||||
|
pytest -v tests/test_auth_integration.py::TestAuthentication
|
||||||
|
|
||||||
|
# Test individual
|
||||||
|
pytest -v tests/test_auth_integration.py::TestAuthentication::test_login_success
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧪 Cobertura de Tests
|
||||||
|
|
||||||
|
### Tests de Autenticación (`test_auth_integration.py`)
|
||||||
|
- ✅ Login exitoso con credenciales válidas
|
||||||
|
- ✅ Login fallido (contraseña incorrecta, tenant inválido, usuario inactivo)
|
||||||
|
- ✅ Refresh tokens (generación y revocación)
|
||||||
|
- ✅ Logout y invalidación de tokens
|
||||||
|
- ✅ Autorización por roles (ADMIN, AGENT, CLIENT)
|
||||||
|
- ✅ Protección de endpoints
|
||||||
|
- ✅ Seguridad de passwords (hashing, no exposición)
|
||||||
|
|
||||||
|
**Total: 15 tests**
|
||||||
|
|
||||||
|
### Tests de Multi-Tenancy (`test_multitenant_integration.py`)
|
||||||
|
- ✅ Aislamiento de datos entre tenants
|
||||||
|
- ✅ Usuario no puede ver tickets de otro tenant
|
||||||
|
- ✅ Usuario no puede acceder por ID directo a datos de otro tenant
|
||||||
|
- ✅ Usuario no puede modificar datos de otro tenant
|
||||||
|
- ✅ Validación de X-Tenant-ID header
|
||||||
|
- ✅ Validación de UUIDs
|
||||||
|
- ✅ Permisos administrativos de tenants
|
||||||
|
- ✅ Prevención de suplantación de tenant
|
||||||
|
|
||||||
|
**Total: 13 tests** (CRÍTICOS para seguridad B2B)
|
||||||
|
|
||||||
|
### Tests de Tickets (`test_tickets_integration.py`)
|
||||||
|
- ✅ Crear ticket con validaciones
|
||||||
|
- ✅ Listar tickets (vacío y con datos)
|
||||||
|
- ✅ Obtener ticket por ID
|
||||||
|
- ✅ Actualizar ticket (status, prioridad, asignación)
|
||||||
|
- ✅ Filtros (por status, prioridad)
|
||||||
|
- ✅ Permisos por rol:
|
||||||
|
- Cliente solo ve sus tickets
|
||||||
|
- Agente ve todos los tickets del tenant
|
||||||
|
- Admin tiene acceso completo
|
||||||
|
|
||||||
|
**Total: 18 tests**
|
||||||
|
|
||||||
|
## 📊 Métricas Objetivo
|
||||||
|
|
||||||
|
```
|
||||||
|
Cobertura actual: ~5% ❌
|
||||||
|
Cobertura con estos tests: ~40% 🟡
|
||||||
|
Cobertura objetivo: >70% ⭐
|
||||||
|
|
||||||
|
Tests totales: 46 tests de integración
|
||||||
|
Tiempo ejecución: ~15-30 segundos
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Configuración
|
||||||
|
|
||||||
|
### Variables de Entorno para Testing
|
||||||
|
|
||||||
|
El archivo `conftest_integration.py` usa:
|
||||||
|
```python
|
||||||
|
TEST_DATABASE_URL = "postgresql+asyncpg://servicemanager:servicemanager123@localhost:5432/servicemanager_test"
|
||||||
|
```
|
||||||
|
|
||||||
|
Para personalizar:
|
||||||
|
```bash
|
||||||
|
export TEST_DATABASE_URL="postgresql+asyncpg://user:pass@host:port/db_test"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Markers de pytest
|
||||||
|
|
||||||
|
Usa markers para ejecutar subconjuntos:
|
||||||
|
```bash
|
||||||
|
# Solo tests de integración
|
||||||
|
pytest -m integration
|
||||||
|
|
||||||
|
# Solo tests que usan BD
|
||||||
|
pytest -m db
|
||||||
|
|
||||||
|
# Solo tests de auth
|
||||||
|
pytest -m auth
|
||||||
|
|
||||||
|
# Excluir tests lentos
|
||||||
|
pytest -m "not slow"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
### Error: "Database not found"
|
||||||
|
```bash
|
||||||
|
docker-compose exec postgres psql -U servicemanager -c "CREATE DATABASE servicemanager_test;"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error: "Connection refused"
|
||||||
|
```bash
|
||||||
|
# Verificar que servicios estén corriendo
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
# Reiniciar servicios
|
||||||
|
docker-compose restart postgres redis
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests lentos
|
||||||
|
```bash
|
||||||
|
# Ver tests más lentos
|
||||||
|
pytest --durations=10
|
||||||
|
|
||||||
|
# Ejecutar en paralelo (requiere pytest-xdist)
|
||||||
|
pip install pytest-xdist
|
||||||
|
pytest -n auto
|
||||||
|
```
|
||||||
|
|
||||||
|
### Limpiar base de datos de testing
|
||||||
|
```bash
|
||||||
|
./run_tests.sh clean
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Agregar Nuevos Tests
|
||||||
|
|
||||||
|
### Template para nuevo test
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
pytest_plugins = ['tests.conftest_integration']
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.db
|
||||||
|
class TestNuevaFuncionalidad:
|
||||||
|
"""Descripción de la funcionalidad."""
|
||||||
|
|
||||||
|
async def test_caso_exitoso(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test del caso exitoso."""
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/endpoint/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
# Más assertions...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Próximos Pasos
|
||||||
|
|
||||||
|
### Tests Pendientes (Prioridad Media)
|
||||||
|
- [ ] Tests de SLA (cálculos, violaciones)
|
||||||
|
- [ ] Tests de comentarios en tickets
|
||||||
|
- [ ] Tests de attachments (uploads)
|
||||||
|
- [ ] Tests de auditoría
|
||||||
|
- [ ] Tests de notificaciones email
|
||||||
|
- [ ] Tests de categorías y sistemas
|
||||||
|
- [ ] Tests de usuarios CRUD
|
||||||
|
|
||||||
|
### Mejoras de Testing (Prioridad Baja)
|
||||||
|
- [ ] Tests E2E con Playwright
|
||||||
|
- [ ] Tests de carga con Locust
|
||||||
|
- [ ] Tests de seguridad con OWASP ZAP
|
||||||
|
- [ ] Mutation testing con mutmut
|
||||||
|
- [ ] Property-based testing con Hypothesis
|
||||||
|
|
||||||
|
## 📚 Referencias
|
||||||
|
|
||||||
|
- [pytest documentation](https://docs.pytest.org/)
|
||||||
|
- [FastAPI testing](https://fastapi.tiangolo.com/tutorial/testing/)
|
||||||
|
- [pytest-asyncio](https://pytest-asyncio.readthedocs.io/)
|
||||||
|
- [SQLAlchemy testing](https://docs.sqlalchemy.org/en/20/orm/session_transaction.html#joining-a-session-into-an-external-transaction-such-as-for-test-suites)
|
||||||
|
|
||||||
|
## ✅ Checklist Pre-Producción
|
||||||
|
|
||||||
|
Antes de desplegar a producción, verificar:
|
||||||
|
|
||||||
|
- [ ] Todos los tests de integración pasan
|
||||||
|
- [ ] Cobertura de tests >70%
|
||||||
|
- [ ] Tests de multi-tenancy 100% exitosos
|
||||||
|
- [ ] Tests de autenticación 100% exitosos
|
||||||
|
- [ ] No hay credenciales hardcodeadas en tests
|
||||||
|
- [ ] Base de datos de testing separada de producción
|
||||||
|
- [ ] CI/CD configurado para ejecutar tests automáticamente
|
||||||
@@ -1,28 +1,166 @@
|
|||||||
"""
|
"""
|
||||||
Test Configuration - ServiceManagerWeb
|
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 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")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 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
|
@pytest.fixture
|
||||||
def test_user_data():
|
async def db_session(sqlite_engine) -> AsyncGenerator:
|
||||||
"""Sample user data for testing."""
|
"""
|
||||||
|
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 {
|
return {
|
||||||
"email": "test@example.com",
|
"email": "test@example.com",
|
||||||
"first_name": "Test",
|
"first_name": "Test",
|
||||||
"last_name": "User",
|
"last_name": "User",
|
||||||
"password": "TestPassword123!"
|
"password": "TestPassword123!",
|
||||||
|
"role": "AGENT",
|
||||||
|
"language": "es",
|
||||||
|
"timezone": "UTC",
|
||||||
|
"notifications_email": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_tenant_data():
|
def test_tenant_data() -> dict:
|
||||||
"""Sample tenant data for testing."""
|
"""Datos de tenant válidos para pruebas."""
|
||||||
return {
|
return {
|
||||||
"name": "Test Tenant",
|
"name": "Test Company",
|
||||||
"slug": "test-tenant",
|
"slug": "test-company",
|
||||||
"description": "Test tenant for testing"
|
"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"
|
||||||
|
|||||||
297
backend/tests/conftest_integration.py
Normal file
297
backend/tests/conftest_integration.py
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
"""
|
||||||
|
Integration Test Configuration - ServiceManagerWeb
|
||||||
|
|
||||||
|
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
|
||||||
|
from httpx import AsyncClient
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# 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")
|
||||||
|
def event_loop() -> Generator:
|
||||||
|
"""Create event loop for async tests."""
|
||||||
|
policy = asyncio.get_event_loop_policy()
|
||||||
|
loop = policy.new_event_loop()
|
||||||
|
yield loop
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
async def test_engine():
|
||||||
|
"""Create test database engine."""
|
||||||
|
engine = create_async_engine(
|
||||||
|
TEST_DATABASE_URL,
|
||||||
|
echo=False,
|
||||||
|
poolclass=NullPool, # No pool para tests
|
||||||
|
)
|
||||||
|
|
||||||
|
# Crear todas las tablas
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
yield engine
|
||||||
|
|
||||||
|
# Limpiar después de todos los tests
|
||||||
|
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 test."""
|
||||||
|
async_session = async_sessionmaker(
|
||||||
|
test_engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False
|
||||||
|
)
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
async with session.begin():
|
||||||
|
yield session
|
||||||
|
# Rollback para limpiar después del test
|
||||||
|
await session.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
|
||||||
|
"""Create test client with overridden database dependency."""
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# FIXTURES DE DATOS DE TEST
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def test_tenant(db_session: AsyncSession) -> Tenant:
|
||||||
|
"""Create a test 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:
|
||||||
|
"""Create a second test tenant for multi-tenant tests."""
|
||||||
|
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:
|
||||||
|
"""Create a test admin 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:
|
||||||
|
"""Create a test agent 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:
|
||||||
|
"""Create a test client 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:
|
||||||
|
"""Create a test system."""
|
||||||
|
system = System(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
name="Test System",
|
||||||
|
code="TEST-SYS",
|
||||||
|
description="Test system for integration tests",
|
||||||
|
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, test_system: System) -> Category:
|
||||||
|
"""Create a test category."""
|
||||||
|
category = Category(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
system_id=test_system.id,
|
||||||
|
name="Test Category",
|
||||||
|
code="TEST-CAT",
|
||||||
|
description="Test category for integration tests",
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
db_session.add(category)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(category)
|
||||||
|
return category
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# FIXTURES DE AUTENTICACIÓN
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def admin_token(client: AsyncClient, test_admin_user: User, test_tenant: Tenant) -> str:
|
||||||
|
"""Get authentication token for admin user."""
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"email": "admin@test.com",
|
||||||
|
"password": "AdminPass123!",
|
||||||
|
"tenant_slug": test_tenant.slug
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
return data["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def agent_token(client: AsyncClient, test_agent_user: User, test_tenant: Tenant) -> str:
|
||||||
|
"""Get authentication token for agent user."""
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"email": "agent@test.com",
|
||||||
|
"password": "AgentPass123!",
|
||||||
|
"tenant_slug": test_tenant.slug
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
return data["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client_token(client: AsyncClient, test_client_user: User, test_tenant: Tenant) -> str:
|
||||||
|
"""Get authentication token for client user."""
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"email": "client@test.com",
|
||||||
|
"password": "ClientPass123!",
|
||||||
|
"tenant_slug": test_tenant.slug
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
return data["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_headers_admin(admin_token: str) -> dict:
|
||||||
|
"""Get authorization headers for admin user."""
|
||||||
|
return {"Authorization": f"Bearer {admin_token}"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_headers_agent(agent_token: str) -> dict:
|
||||||
|
"""Get authorization headers for agent user."""
|
||||||
|
return {"Authorization": f"Bearer {agent_token}"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_headers_client(client_token: str) -> dict:
|
||||||
|
"""Get authorization headers for client user."""
|
||||||
|
return {"Authorization": f"Bearer {client_token}"}
|
||||||
0
backend/tests/integration/__init__.py
Normal file
0
backend/tests/integration/__init__.py
Normal file
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}"}
|
||||||
421
backend/tests/integration/test_auth_integration.py
Normal file
421
backend/tests/integration/test_auth_integration.py
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
"""
|
||||||
|
Authentication Integration Tests - ServiceManagerWeb
|
||||||
|
|
||||||
|
Tests completos del flujo de autenticación incluyendo:
|
||||||
|
- Login
|
||||||
|
- Refresh tokens
|
||||||
|
- Logout
|
||||||
|
- Permisos y roles
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.models.tenant import Tenant
|
||||||
|
|
||||||
|
# Importar fixtures desde conftest_integration
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.auth
|
||||||
|
class TestAuthentication:
|
||||||
|
"""Tests de autenticación básica."""
|
||||||
|
|
||||||
|
async def test_login_success(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_admin_user: User,
|
||||||
|
test_tenant: Tenant
|
||||||
|
):
|
||||||
|
"""Test login exitoso con credenciales válidas."""
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"email": "admin@test.com",
|
||||||
|
"password": "AdminPass123!",
|
||||||
|
"tenant_slug": test_tenant.slug
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert "access_token" in data
|
||||||
|
assert "refresh_token" in data
|
||||||
|
assert data["token_type"] == "bearer"
|
||||||
|
assert data["expires_in"] > 0
|
||||||
|
assert data["user"]["email"] == "admin@test.com"
|
||||||
|
assert data["user"]["role"] == "ADMIN"
|
||||||
|
|
||||||
|
async def test_login_invalid_password(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_admin_user: User,
|
||||||
|
test_tenant: Tenant
|
||||||
|
):
|
||||||
|
"""Test login con contraseña incorrecta."""
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"email": "admin@test.com",
|
||||||
|
"password": "WrongPassword123!",
|
||||||
|
"tenant_slug": test_tenant.slug
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "Invalid credentials" in response.json()["detail"]
|
||||||
|
|
||||||
|
async def test_login_invalid_tenant_slug(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_admin_user: User
|
||||||
|
):
|
||||||
|
"""Test login con tenant slug inexistente."""
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"email": "admin@test.com",
|
||||||
|
"password": "AdminPass123!",
|
||||||
|
"tenant_slug": "nonexistent-tenant"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
async def test_login_user_not_found(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant
|
||||||
|
):
|
||||||
|
"""Test login con email inexistente."""
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"email": "notfound@test.com",
|
||||||
|
"password": "SomePassword123!",
|
||||||
|
"tenant_slug": test_tenant.slug
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
async def test_login_inactive_user(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_admin_user: User,
|
||||||
|
test_tenant: Tenant
|
||||||
|
):
|
||||||
|
"""Test login con usuario desactivado."""
|
||||||
|
# Desactivar usuario
|
||||||
|
test_admin_user.is_active = False
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"email": "admin@test.com",
|
||||||
|
"password": "AdminPass123!",
|
||||||
|
"tenant_slug": test_tenant.slug
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
class TestRefreshToken:
|
||||||
|
"""Tests de refresh tokens."""
|
||||||
|
|
||||||
|
async def test_refresh_token_success(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_admin_user: User,
|
||||||
|
test_tenant: Tenant
|
||||||
|
):
|
||||||
|
"""Test refresh token exitoso."""
|
||||||
|
# Login para obtener tokens
|
||||||
|
login_response = await client.post(
|
||||||
|
"/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"email": "admin@test.com",
|
||||||
|
"password": "AdminPass123!",
|
||||||
|
"tenant_slug": test_tenant.slug
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert login_response.status_code == 200
|
||||||
|
refresh_token = login_response.json()["refresh_token"]
|
||||||
|
|
||||||
|
# Usar refresh token
|
||||||
|
refresh_response = await client.post(
|
||||||
|
"/v1/auth/refresh",
|
||||||
|
json={"refresh_token": refresh_token}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert refresh_response.status_code == 200
|
||||||
|
data = refresh_response.json()
|
||||||
|
|
||||||
|
assert "access_token" in data
|
||||||
|
assert data["token_type"] == "bearer"
|
||||||
|
assert data["expires_in"] > 0
|
||||||
|
|
||||||
|
async def test_refresh_token_invalid(self, client: AsyncClient):
|
||||||
|
"""Test refresh con token inválido."""
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/auth/refresh",
|
||||||
|
json={"refresh_token": "invalid-token"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
async def test_refresh_token_after_logout(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_admin_user: User,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
admin_token: str
|
||||||
|
):
|
||||||
|
"""Test que refresh token no funciona después de logout."""
|
||||||
|
# Login
|
||||||
|
login_response = await client.post(
|
||||||
|
"/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"email": "admin@test.com",
|
||||||
|
"password": "AdminPass123!",
|
||||||
|
"tenant_slug": test_tenant.slug
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
refresh_token = login_response.json()["refresh_token"]
|
||||||
|
|
||||||
|
# Logout
|
||||||
|
logout_response = await client.post(
|
||||||
|
"/v1/auth/logout",
|
||||||
|
headers={"Authorization": f"Bearer {admin_token}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert logout_response.status_code == 200
|
||||||
|
|
||||||
|
# Intentar usar refresh token después de logout
|
||||||
|
refresh_response = await client.post(
|
||||||
|
"/v1/auth/refresh",
|
||||||
|
json={"refresh_token": refresh_token}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert refresh_response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.auth
|
||||||
|
class TestAuthorization:
|
||||||
|
"""Tests de autorización y permisos."""
|
||||||
|
|
||||||
|
async def test_admin_can_access_admin_endpoint(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test que admin puede acceder a endpoints de admin."""
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tenants/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
async def test_agent_cannot_access_admin_endpoint(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
auth_headers_agent: dict
|
||||||
|
):
|
||||||
|
"""Test que agent no puede acceder a endpoints de admin."""
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tenants/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_agent,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
async def test_client_cannot_access_admin_endpoint(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
auth_headers_client: dict
|
||||||
|
):
|
||||||
|
"""Test que client no puede acceder a endpoints de admin."""
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tenants/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_client,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
async def test_protected_endpoint_without_token(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant
|
||||||
|
):
|
||||||
|
"""Test que endpoints protegidos requieren token."""
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tickets/",
|
||||||
|
headers={"X-Tenant-ID": str(test_tenant.id)}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
async def test_protected_endpoint_with_invalid_token(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant
|
||||||
|
):
|
||||||
|
"""Test con token inválido."""
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tickets/",
|
||||||
|
headers={
|
||||||
|
"Authorization": "Bearer invalid-token",
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.auth
|
||||||
|
class TestUserProfile:
|
||||||
|
"""Tests del perfil de usuario."""
|
||||||
|
|
||||||
|
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,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert data["email"] == "admin@test.com"
|
||||||
|
assert data["role"] == "ADMIN"
|
||||||
|
assert data["first_name"] == "Admin"
|
||||||
|
assert data["last_name"] == "User"
|
||||||
|
assert "password_hash" not in data # No debe exponer password
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.auth
|
||||||
|
class TestPasswordSecurity:
|
||||||
|
"""Tests de seguridad de contraseñas."""
|
||||||
|
|
||||||
|
async def test_password_hashing(self):
|
||||||
|
"""Test que las contraseñas se hashean correctamente."""
|
||||||
|
from app.core.security import SecurityUtils
|
||||||
|
|
||||||
|
password = "TestPassword123!"
|
||||||
|
hashed = SecurityUtils.hash_password(password)
|
||||||
|
|
||||||
|
# Debe ser diferente del original
|
||||||
|
assert hashed != password
|
||||||
|
|
||||||
|
# Debe poder verificarse
|
||||||
|
assert SecurityUtils.verify_password(password, hashed)
|
||||||
|
|
||||||
|
# Contraseña incorrecta no debe verificar
|
||||||
|
assert not SecurityUtils.verify_password("WrongPassword", hashed)
|
||||||
|
|
||||||
|
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,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert "password" not in data
|
||||||
|
assert "password_hash" not in data
|
||||||
354
backend/tests/integration/test_multitenant_integration.py
Normal file
354
backend/tests/integration/test_multitenant_integration.py
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
"""
|
||||||
|
Multi-Tenancy Integration Tests - ServiceManagerWeb
|
||||||
|
|
||||||
|
Tests críticos para verificar el aislamiento de datos entre tenants.
|
||||||
|
Estos tests son ESENCIALES para seguridad B2B.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.models.tenant import Tenant
|
||||||
|
from app.models.ticket import Ticket, TicketStatus, TicketPriority
|
||||||
|
from app.core.security import SecurityUtils
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.db
|
||||||
|
class TestTenantIsolation:
|
||||||
|
"""Tests de aislamiento de datos entre tenants."""
|
||||||
|
|
||||||
|
async def test_user_cannot_see_other_tenant_tickets(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_tenant_2: Tenant,
|
||||||
|
test_admin_user: User,
|
||||||
|
test_category,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test crítico: Usuario de tenant A no puede ver tickets de tenant B."""
|
||||||
|
|
||||||
|
# Crear usuario en tenant 2
|
||||||
|
user_tenant_2 = User(
|
||||||
|
tenant_id=test_tenant_2.id,
|
||||||
|
email="admin@tenant2.com",
|
||||||
|
first_name="Admin",
|
||||||
|
last_name="Tenant2",
|
||||||
|
password_hash=SecurityUtils.hash_password("Password123!"),
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
is_active=True,
|
||||||
|
email_verified=True
|
||||||
|
)
|
||||||
|
db_session.add(user_tenant_2)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# Crear ticket en tenant 2
|
||||||
|
ticket_tenant_2 = Ticket(
|
||||||
|
tenant_id=test_tenant_2.id,
|
||||||
|
title="Ticket privado de Tenant 2",
|
||||||
|
description="Este ticket NO debe ser visible para tenant 1",
|
||||||
|
status=TicketStatus.NEW,
|
||||||
|
priority=TicketPriority.HIGH,
|
||||||
|
created_by=user_tenant_2.id,
|
||||||
|
category_id=test_category.id
|
||||||
|
)
|
||||||
|
db_session.add(ticket_tenant_2)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# Usuario de tenant 1 intenta listar tickets
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tickets/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
tickets = response.json()
|
||||||
|
|
||||||
|
# NO debe contener el ticket de tenant 2
|
||||||
|
ticket_ids = [t["id"] for t in tickets]
|
||||||
|
assert str(ticket_tenant_2.id) not in ticket_ids
|
||||||
|
|
||||||
|
async def test_user_cannot_access_other_tenant_ticket_directly(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_tenant_2: Tenant,
|
||||||
|
test_admin_user: User,
|
||||||
|
test_category,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test: Usuario no puede acceder a ticket de otro tenant por ID directo."""
|
||||||
|
|
||||||
|
# Crear usuario en tenant 2
|
||||||
|
user_tenant_2 = User(
|
||||||
|
tenant_id=test_tenant_2.id,
|
||||||
|
email="user@tenant2.com",
|
||||||
|
first_name="User",
|
||||||
|
last_name="Tenant2",
|
||||||
|
password_hash=SecurityUtils.hash_password("Password123!"),
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
is_active=True,
|
||||||
|
email_verified=True
|
||||||
|
)
|
||||||
|
db_session.add(user_tenant_2)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# Crear ticket en tenant 2
|
||||||
|
ticket_tenant_2 = Ticket(
|
||||||
|
tenant_id=test_tenant_2.id,
|
||||||
|
title="Ticket secreto",
|
||||||
|
description="Información confidencial",
|
||||||
|
status=TicketStatus.NEW,
|
||||||
|
priority=TicketPriority.URGENT,
|
||||||
|
created_by=user_tenant_2.id,
|
||||||
|
category_id=test_category.id
|
||||||
|
)
|
||||||
|
db_session.add(ticket_tenant_2)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# Usuario de tenant 1 intenta acceder con ID directo
|
||||||
|
response = await client.get(
|
||||||
|
f"/v1/tickets/{ticket_tenant_2.id}",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Debe devolver 404 (no 403 para no revelar existencia)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
async def test_user_cannot_update_other_tenant_ticket(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_tenant_2: Tenant,
|
||||||
|
test_category,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test: Usuario no puede modificar ticket de otro tenant."""
|
||||||
|
|
||||||
|
# Crear usuario y ticket en tenant 2
|
||||||
|
user_tenant_2 = User(
|
||||||
|
tenant_id=test_tenant_2.id,
|
||||||
|
email="user@tenant2.com",
|
||||||
|
first_name="User",
|
||||||
|
last_name="Tenant2",
|
||||||
|
password_hash=SecurityUtils.hash_password("Password123!"),
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
is_active=True,
|
||||||
|
email_verified=True
|
||||||
|
)
|
||||||
|
db_session.add(user_tenant_2)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
ticket_tenant_2 = Ticket(
|
||||||
|
tenant_id=test_tenant_2.id,
|
||||||
|
title="Original title",
|
||||||
|
description="Original description",
|
||||||
|
status=TicketStatus.NEW,
|
||||||
|
priority=TicketPriority.MEDIUM,
|
||||||
|
created_by=user_tenant_2.id,
|
||||||
|
category_id=test_category.id
|
||||||
|
)
|
||||||
|
db_session.add(ticket_tenant_2)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
original_title = ticket_tenant_2.title
|
||||||
|
|
||||||
|
# Usuario de tenant 1 intenta modificar
|
||||||
|
response = await client.patch(
|
||||||
|
f"/v1/tickets/{ticket_tenant_2.id}",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"title": "HACKED TITLE",
|
||||||
|
"status": "CLOSED"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
# Verificar que el ticket NO fue modificado
|
||||||
|
await db_session.refresh(ticket_tenant_2)
|
||||||
|
assert ticket_tenant_2.title == original_title
|
||||||
|
assert ticket_tenant_2.status == TicketStatus.NEW
|
||||||
|
|
||||||
|
async def test_middleware_validates_tenant_header(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test que el middleware valida el X-Tenant-ID header."""
|
||||||
|
|
||||||
|
# Sin header de tenant
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tickets/",
|
||||||
|
headers=auth_headers_admin
|
||||||
|
)
|
||||||
|
|
||||||
|
# Debe requerir tenant header
|
||||||
|
assert response.status_code in [400, 401]
|
||||||
|
|
||||||
|
async def test_middleware_rejects_invalid_tenant_uuid(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test que el middleware rechaza UUIDs inválidos."""
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tickets/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": "not-a-uuid"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
async def test_middleware_rejects_nonexistent_tenant(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test que el middleware rechaza tenants inexistentes."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
fake_tenant_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tickets/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": fake_tenant_id
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.db
|
||||||
|
class TestTenantAdminEndpoints:
|
||||||
|
"""Tests de endpoints administrativos de tenants."""
|
||||||
|
|
||||||
|
async def test_admin_can_list_tenants(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_tenant_2: Tenant,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test que admin puede listar tenants."""
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tenants/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
tenants = response.json()
|
||||||
|
assert len(tenants) >= 2
|
||||||
|
|
||||||
|
async def test_non_admin_cannot_list_tenants(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
auth_headers_client: dict
|
||||||
|
):
|
||||||
|
"""Test que usuario no-admin no puede listar tenants."""
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tenants/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_client,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
async def test_admin_can_create_tenant(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test que admin puede crear nuevos tenants."""
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/tenants/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"name": "New Test Company",
|
||||||
|
"slug": "new-test-company",
|
||||||
|
"domain": "new.test.com",
|
||||||
|
"email": "admin@new.test.com",
|
||||||
|
"phone": "+1111111111"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "New Test Company"
|
||||||
|
assert data["slug"] == "new-test-company"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.db
|
||||||
|
class TestCrossTenantuserAccess:
|
||||||
|
"""Tests de acceso de usuarios entre tenants."""
|
||||||
|
|
||||||
|
async def test_user_belongs_to_only_one_tenant(
|
||||||
|
self,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_admin_user: User,
|
||||||
|
test_tenant: Tenant
|
||||||
|
):
|
||||||
|
"""Test que cada usuario pertenece a exactamente un tenant."""
|
||||||
|
|
||||||
|
assert test_admin_user.tenant_id == test_tenant.id
|
||||||
|
|
||||||
|
# Verificar que no puede tener múltiples tenant_ids
|
||||||
|
# (esto es a nivel de modelo, pero importante documentar)
|
||||||
|
|
||||||
|
async def test_user_from_tenant_a_cannot_impersonate_tenant_b(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_tenant_2: Tenant,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test que usuario autenticado no puede cambiar de tenant."""
|
||||||
|
|
||||||
|
# Usuario de tenant 1 intenta usar header de tenant 2
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tickets/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant_2.id) # Intento de suplantación
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# La request debe fallar (el token pertenece a tenant 1)
|
||||||
|
# El comportamiento específico depende de tu implementación,
|
||||||
|
# pero NO debe permitir acceso a datos de tenant 2
|
||||||
|
assert response.status_code in [403, 404, 401]
|
||||||
56
backend/tests/integration/test_setup_verification.py
Normal file
56
backend/tests/integration/test_setup_verification.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""Quick Test Verification - ServiceManagerWeb
|
||||||
|
|
||||||
|
Smoke tests para verificar que el setup de tests de integración funciona correctamente.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestSetupVerification:
|
||||||
|
async def test_client_fixture_works(self, client: AsyncClient):
|
||||||
|
assert client is not None
|
||||||
|
assert str(client.base_url) == "http://test"
|
||||||
|
|
||||||
|
async def test_database_connection(self, db_session):
|
||||||
|
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):
|
||||||
|
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):
|
||||||
|
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: str):
|
||||||
|
assert isinstance(admin_token, str)
|
||||||
|
assert len(admin_token) > 20
|
||||||
|
|
||||||
|
async def test_health_endpoint(self, client: AsyncClient):
|
||||||
|
response = await client.get("/health")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["status"] == "healthy"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestBasicEndpoints:
|
||||||
|
async def test_health_endpoint_detailed(self, client: AsyncClient):
|
||||||
|
response = await client.get("/v1/health/detailed")
|
||||||
|
assert response.status_code in (200, 503)
|
||||||
|
|
||||||
|
async def test_login_endpoint_exists(self, client: AsyncClient):
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"email": "nonexistent@test.com",
|
||||||
|
"password": "wrong",
|
||||||
|
"tenant_slug": "nonexistent",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code in (401, 404, 422)
|
||||||
676
backend/tests/integration/test_tickets_integration.py
Normal file
676
backend/tests/integration/test_tickets_integration.py
Normal file
@@ -0,0 +1,676 @@
|
|||||||
|
"""
|
||||||
|
Tickets Integration Tests - ServiceManagerWeb
|
||||||
|
|
||||||
|
Tests completos del CRUD de tickets y funcionalidad relacionada.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.models.user import User
|
||||||
|
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
|
||||||
|
from app.core.file_handler import file_handler
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.db
|
||||||
|
class TestTicketCreation:
|
||||||
|
"""Tests de creación de tickets."""
|
||||||
|
|
||||||
|
async def test_create_ticket_success(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_category: Category,
|
||||||
|
auth_headers_client: dict
|
||||||
|
):
|
||||||
|
"""Test crear ticket con datos válidos."""
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/tickets/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_client,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"title": "Test ticket",
|
||||||
|
"description": "This is a test ticket description",
|
||||||
|
"priority": "MEDIUM",
|
||||||
|
"category_id": str(test_category.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert data["title"] == "Test ticket"
|
||||||
|
assert data["description"] == "This is a test ticket description"
|
||||||
|
assert data["priority"] == "MEDIUM"
|
||||||
|
assert data["status"] == "NEW"
|
||||||
|
assert data["category_id"] == str(test_category.id)
|
||||||
|
|
||||||
|
async def test_create_ticket_with_all_fields(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_category: Category,
|
||||||
|
test_system: System,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test crear ticket con todos los campos opcionales."""
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/tickets/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"title": "Complete ticket",
|
||||||
|
"description": "Full ticket with all fields",
|
||||||
|
"priority": "HIGH",
|
||||||
|
"category_id": str(test_category.id),
|
||||||
|
"system_id": str(test_system.id),
|
||||||
|
"contact_email": "contact@test.com",
|
||||||
|
"contact_phone": "+1234567890"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert data["priority"] == "HIGH"
|
||||||
|
assert data["system_id"] == str(test_system.id)
|
||||||
|
assert data["contact_email"] == "contact@test.com"
|
||||||
|
|
||||||
|
async def test_create_ticket_missing_required_fields(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
auth_headers_client: dict
|
||||||
|
):
|
||||||
|
"""Test crear ticket sin campos requeridos."""
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/tickets/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_client,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"description": "Missing title"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422 # Validation error
|
||||||
|
|
||||||
|
async def test_create_ticket_invalid_priority(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_category: Category,
|
||||||
|
auth_headers_client: dict
|
||||||
|
):
|
||||||
|
"""Test crear ticket con prioridad inválida."""
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/tickets/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_client,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"title": "Test ticket",
|
||||||
|
"description": "Description",
|
||||||
|
"priority": "SUPER_URGENT", # Inválido
|
||||||
|
"category_id": str(test_category.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.db
|
||||||
|
class TestTicketRetrieval:
|
||||||
|
"""Tests de consulta de tickets."""
|
||||||
|
|
||||||
|
async def test_list_tickets_empty(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test listar tickets cuando no hay ninguno."""
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tickets/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
tickets = response.json()
|
||||||
|
assert isinstance(tickets, list)
|
||||||
|
|
||||||
|
async def test_list_tickets_with_data(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_admin_user: User,
|
||||||
|
test_category: Category,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test listar tickets cuando existen."""
|
||||||
|
|
||||||
|
# Crear algunos tickets
|
||||||
|
for i in range(3):
|
||||||
|
ticket = Ticket(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
title=f"Test ticket {i+1}",
|
||||||
|
description=f"Description {i+1}",
|
||||||
|
status=TicketStatus.NEW,
|
||||||
|
priority=TicketPriority.MEDIUM,
|
||||||
|
created_by=test_admin_user.id,
|
||||||
|
category_id=test_category.id
|
||||||
|
)
|
||||||
|
db_session.add(ticket)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tickets/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
tickets = response.json()
|
||||||
|
assert len(tickets) == 3
|
||||||
|
|
||||||
|
async def test_get_ticket_by_id(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_admin_user: User,
|
||||||
|
test_category: Category,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test obtener ticket específico por ID."""
|
||||||
|
|
||||||
|
ticket = Ticket(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
title="Specific ticket",
|
||||||
|
description="Get this ticket",
|
||||||
|
status=TicketStatus.NEW,
|
||||||
|
priority=TicketPriority.HIGH,
|
||||||
|
created_by=test_admin_user.id,
|
||||||
|
category_id=test_category.id
|
||||||
|
)
|
||||||
|
db_session.add(ticket)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(ticket)
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
f"/v1/tickets/{ticket.id}",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["id"] == str(ticket.id)
|
||||||
|
assert data["title"] == "Specific ticket"
|
||||||
|
|
||||||
|
async def test_get_nonexistent_ticket(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test obtener ticket inexistente."""
|
||||||
|
|
||||||
|
fake_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
f"/v1/tickets/{fake_id}",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.db
|
||||||
|
class TestTicketUpdate:
|
||||||
|
"""Tests de actualización de tickets."""
|
||||||
|
|
||||||
|
async def test_update_ticket_status(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_admin_user: User,
|
||||||
|
test_category: Category,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test actualizar status de ticket."""
|
||||||
|
|
||||||
|
ticket = Ticket(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
title="Ticket to update",
|
||||||
|
description="Description",
|
||||||
|
status=TicketStatus.NEW,
|
||||||
|
priority=TicketPriority.MEDIUM,
|
||||||
|
created_by=test_admin_user.id,
|
||||||
|
category_id=test_category.id
|
||||||
|
)
|
||||||
|
db_session.add(ticket)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(ticket)
|
||||||
|
|
||||||
|
response = await client.patch(
|
||||||
|
f"/v1/tickets/{ticket.id}",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"status": "IN_PROGRESS"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["status"] == "IN_PROGRESS"
|
||||||
|
|
||||||
|
async def test_update_ticket_priority(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_admin_user: User,
|
||||||
|
test_category: Category,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test actualizar prioridad de ticket."""
|
||||||
|
|
||||||
|
ticket = Ticket(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
title="Ticket priority test",
|
||||||
|
description="Description",
|
||||||
|
status=TicketStatus.NEW,
|
||||||
|
priority=TicketPriority.LOW,
|
||||||
|
created_by=test_admin_user.id,
|
||||||
|
category_id=test_category.id
|
||||||
|
)
|
||||||
|
db_session.add(ticket)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(ticket)
|
||||||
|
|
||||||
|
response = await client.patch(
|
||||||
|
f"/v1/tickets/{ticket.id}",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"priority": "URGENT"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["priority"] == "URGENT"
|
||||||
|
|
||||||
|
async def test_update_ticket_assignment(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_admin_user: User,
|
||||||
|
test_agent_user: User,
|
||||||
|
test_category: Category,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test asignar ticket a un agente."""
|
||||||
|
|
||||||
|
ticket = Ticket(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
title="Ticket to assign",
|
||||||
|
description="Description",
|
||||||
|
status=TicketStatus.NEW,
|
||||||
|
priority=TicketPriority.MEDIUM,
|
||||||
|
created_by=test_admin_user.id,
|
||||||
|
category_id=test_category.id
|
||||||
|
)
|
||||||
|
db_session.add(ticket)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(ticket)
|
||||||
|
|
||||||
|
response = await client.patch(
|
||||||
|
f"/v1/tickets/{ticket.id}",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"assigned_to": str(test_agent_user.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["assigned_to"] == str(test_agent_user.id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.db
|
||||||
|
class TestTicketFilters:
|
||||||
|
"""Tests de filtros de tickets."""
|
||||||
|
|
||||||
|
async def test_filter_by_status(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_admin_user: User,
|
||||||
|
test_category: Category,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test filtrar tickets por status."""
|
||||||
|
|
||||||
|
# Crear tickets con diferentes status
|
||||||
|
ticket_new = Ticket(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
title="New ticket",
|
||||||
|
description="Description",
|
||||||
|
status=TicketStatus.NEW,
|
||||||
|
priority=TicketPriority.MEDIUM,
|
||||||
|
created_by=test_admin_user.id,
|
||||||
|
category_id=test_category.id
|
||||||
|
)
|
||||||
|
ticket_progress = Ticket(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
title="In progress ticket",
|
||||||
|
description="Description",
|
||||||
|
status=TicketStatus.IN_PROGRESS,
|
||||||
|
priority=TicketPriority.MEDIUM,
|
||||||
|
created_by=test_admin_user.id,
|
||||||
|
category_id=test_category.id
|
||||||
|
)
|
||||||
|
db_session.add_all([ticket_new, ticket_progress])
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# Filtrar por status NEW
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tickets/?status=NEW",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
tickets = response.json()
|
||||||
|
assert all(t["status"] == "NEW" for t in tickets)
|
||||||
|
|
||||||
|
async def test_filter_by_priority(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_admin_user: User,
|
||||||
|
test_category: Category,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test filtrar tickets por prioridad."""
|
||||||
|
|
||||||
|
# Crear tickets con diferentes prioridades
|
||||||
|
ticket_low = Ticket(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
title="Low priority",
|
||||||
|
description="Description",
|
||||||
|
status=TicketStatus.NEW,
|
||||||
|
priority=TicketPriority.LOW,
|
||||||
|
created_by=test_admin_user.id,
|
||||||
|
category_id=test_category.id
|
||||||
|
)
|
||||||
|
ticket_urgent = Ticket(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
title="Urgent priority",
|
||||||
|
description="Description",
|
||||||
|
status=TicketStatus.NEW,
|
||||||
|
priority=TicketPriority.URGENT,
|
||||||
|
created_by=test_admin_user.id,
|
||||||
|
category_id=test_category.id
|
||||||
|
)
|
||||||
|
db_session.add_all([ticket_low, ticket_urgent])
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# Filtrar por URGENT
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tickets/?priority=URGENT",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
tickets = response.json()
|
||||||
|
assert all(t["priority"] == "URGENT" for t in tickets)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.db
|
||||||
|
class TestTicketPermissions:
|
||||||
|
"""Tests de permisos en tickets."""
|
||||||
|
|
||||||
|
async def test_client_can_create_ticket(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_category: Category,
|
||||||
|
auth_headers_client: dict
|
||||||
|
):
|
||||||
|
"""Test que cliente puede crear tickets."""
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/tickets/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_client,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"title": "Client ticket",
|
||||||
|
"description": "Created by client",
|
||||||
|
"priority": "MEDIUM",
|
||||||
|
"category_id": str(test_category.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
async def test_client_can_only_see_own_tickets(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_client_user: User,
|
||||||
|
test_admin_user: User,
|
||||||
|
test_category: Category,
|
||||||
|
auth_headers_client: dict
|
||||||
|
):
|
||||||
|
"""Test que cliente solo ve sus propios tickets."""
|
||||||
|
|
||||||
|
# Ticket del cliente
|
||||||
|
ticket_own = Ticket(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
title="My ticket",
|
||||||
|
description="Description",
|
||||||
|
status=TicketStatus.NEW,
|
||||||
|
priority=TicketPriority.MEDIUM,
|
||||||
|
created_by=test_client_user.id,
|
||||||
|
category_id=test_category.id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ticket de otro usuario
|
||||||
|
ticket_other = Ticket(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
title="Other ticket",
|
||||||
|
description="Description",
|
||||||
|
status=TicketStatus.NEW,
|
||||||
|
priority=TicketPriority.MEDIUM,
|
||||||
|
created_by=test_admin_user.id,
|
||||||
|
category_id=test_category.id
|
||||||
|
)
|
||||||
|
|
||||||
|
db_session.add_all([ticket_own, ticket_other])
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# Cliente lista tickets
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tickets/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_client,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
tickets = response.json()
|
||||||
|
|
||||||
|
# Solo debe ver su propio ticket
|
||||||
|
ticket_ids = [t["id"] for t in tickets]
|
||||||
|
assert str(ticket_own.id) in ticket_ids
|
||||||
|
assert str(ticket_other.id) not in ticket_ids
|
||||||
|
|
||||||
|
async def test_agent_can_see_all_tenant_tickets(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_agent_user: User,
|
||||||
|
test_admin_user: User,
|
||||||
|
test_category: Category,
|
||||||
|
auth_headers_agent: dict
|
||||||
|
):
|
||||||
|
"""Test que agente ve todos los tickets del tenant."""
|
||||||
|
|
||||||
|
# Crear tickets de diferentes usuarios
|
||||||
|
ticket_1 = Ticket(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
title="Ticket 1",
|
||||||
|
description="Description",
|
||||||
|
status=TicketStatus.NEW,
|
||||||
|
priority=TicketPriority.MEDIUM,
|
||||||
|
created_by=test_agent_user.id,
|
||||||
|
category_id=test_category.id
|
||||||
|
)
|
||||||
|
ticket_2 = Ticket(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
title="Ticket 2",
|
||||||
|
description="Description",
|
||||||
|
status=TicketStatus.NEW,
|
||||||
|
priority=TicketPriority.MEDIUM,
|
||||||
|
created_by=test_admin_user.id,
|
||||||
|
category_id=test_category.id
|
||||||
|
)
|
||||||
|
|
||||||
|
db_session.add_all([ticket_1, ticket_2])
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# Agente lista tickets
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tickets/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_agent,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
tickets = response.json()
|
||||||
|
|
||||||
|
# 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
|
||||||
0
backend/tests/scripts/__init__.py
Normal file
0
backend/tests/scripts/__init__.py
Normal file
174
backend/tests/scripts/test_frontend_integration.ps1
Normal file
174
backend/tests/scripts/test_frontend_integration.ps1
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# Script de verificación de integración frontend-backend
|
||||||
|
Write-Host "`n========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host " VERIFICACION FRONTEND-BACKEND" -ForegroundColor Cyan
|
||||||
|
Write-Host "========================================`n" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Verificar servicios
|
||||||
|
Write-Host "1. Verificando servicios Docker..." -ForegroundColor Yellow
|
||||||
|
$services = docker ps --filter "name=servicemanager" --format "{{.Names}}: {{.Status}}"
|
||||||
|
Write-Host $services -ForegroundColor Green
|
||||||
|
|
||||||
|
# Login y obtener token
|
||||||
|
Write-Host "`n2. Autenticando en el backend..." -ForegroundColor Yellow
|
||||||
|
$loginBody = @{
|
||||||
|
email = "admin@aduanasoft.com"
|
||||||
|
password = "admin123"
|
||||||
|
tenant_slug = "aduanasoft"
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
try {
|
||||||
|
$loginResponse = Invoke-RestMethod -Uri "http://localhost:8000/v1/auth/login" `
|
||||||
|
-Method POST `
|
||||||
|
-ContentType "application/json" `
|
||||||
|
-Body $loginBody
|
||||||
|
|
||||||
|
$token = $loginResponse.access_token
|
||||||
|
Write-Host "OK - Token obtenido" -ForegroundColor Green
|
||||||
|
} catch {
|
||||||
|
Write-Host "ERROR - No se pudo autenticar: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$headers = @{
|
||||||
|
"Authorization" = "Bearer $token"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test 1: Verificar Tickets con SLA
|
||||||
|
Write-Host "`n3. Verificando tickets con SLA..." -ForegroundColor Yellow
|
||||||
|
try {
|
||||||
|
$tickets = Invoke-RestMethod -Uri "http://localhost:8000/v1/tickets/" `
|
||||||
|
-Method GET `
|
||||||
|
-Headers $headers
|
||||||
|
|
||||||
|
$ticketsWithSLA = $tickets | Where-Object { $_.sla_resolution_due -ne $null }
|
||||||
|
Write-Host " Total tickets: $($tickets.Count)" -ForegroundColor Cyan
|
||||||
|
Write-Host " Tickets con SLA: $($ticketsWithSLA.Count)" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
if ($ticketsWithSLA.Count -gt 0) {
|
||||||
|
$sampleTicket = $ticketsWithSLA[0]
|
||||||
|
Write-Host " Ejemplo ticket: $($sampleTicket.ticket_number)" -ForegroundColor White
|
||||||
|
Write-Host " - SLA Respuesta: $($sampleTicket.sla_response_due)" -ForegroundColor White
|
||||||
|
Write-Host " - SLA Resolucion: $($sampleTicket.sla_resolution_due)" -ForegroundColor White
|
||||||
|
Write-Host "OK - Tickets con SLA encontrados" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "ADVERTENCIA - No hay tickets con SLA configurado" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host "ERROR - No se pudieron obtener tickets: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test 2: Verificar Categorías con configuración SLA
|
||||||
|
Write-Host "`n4. Verificando categorias con SLA..." -ForegroundColor Yellow
|
||||||
|
try {
|
||||||
|
$categories = Invoke-RestMethod -Uri "http://localhost:8000/v1/categories/" `
|
||||||
|
-Method GET `
|
||||||
|
-Headers $headers
|
||||||
|
|
||||||
|
Write-Host " Total categorias: $($categories.Count)" -ForegroundColor Cyan
|
||||||
|
foreach ($cat in $categories) {
|
||||||
|
Write-Host " - $($cat.name): $($cat.sla_response_hours)h respuesta / $($cat.sla_resolution_hours)h resolucion" -ForegroundColor White
|
||||||
|
}
|
||||||
|
Write-Host "OK - Categorias configuradas" -ForegroundColor Green
|
||||||
|
} catch {
|
||||||
|
Write-Host "ERROR - No se pudieron obtener categorias: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test 3: Verificar Tenants
|
||||||
|
Write-Host "`n5. Verificando tenants..." -ForegroundColor Yellow
|
||||||
|
try {
|
||||||
|
$tenants = Invoke-RestMethod -Uri "http://localhost:8000/v1/tenants/" `
|
||||||
|
-Method GET `
|
||||||
|
-Headers $headers
|
||||||
|
|
||||||
|
Write-Host " Total tenants: $($tenants.Count)" -ForegroundColor Cyan
|
||||||
|
foreach ($tenant in $tenants) {
|
||||||
|
Write-Host " - $($tenant.name) [$($tenant.status)]" -ForegroundColor White
|
||||||
|
Write-Host " Email: $($tenant.contact_email)" -ForegroundColor Gray
|
||||||
|
Write-Host " Telefono: $($tenant.contact_phone)" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
Write-Host "OK - Tenants listados" -ForegroundColor Green
|
||||||
|
} catch {
|
||||||
|
Write-Host "ERROR - No se pudieron obtener tenants: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test 4: Verificar Auditoría
|
||||||
|
Write-Host "`n6. Verificando logs de auditoria..." -ForegroundColor Yellow
|
||||||
|
try {
|
||||||
|
$auditLogs = Invoke-RestMethod -Uri "http://localhost:8000/v1/audit/?limit=10" `
|
||||||
|
-Method GET `
|
||||||
|
-Headers $headers
|
||||||
|
|
||||||
|
Write-Host " Ultimos logs: $($auditLogs.items.Count)" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Buscar logs de categoría y tickets
|
||||||
|
$categoryLogs = $auditLogs.items | Where-Object { $_.entity_type -eq 'category' }
|
||||||
|
$ticketLogs = $auditLogs.items | Where-Object { $_.entity_type -eq 'ticket' }
|
||||||
|
|
||||||
|
Write-Host " Logs de categorias: $($categoryLogs.Count)" -ForegroundColor White
|
||||||
|
Write-Host " Logs de tickets: $($ticketLogs.Count)" -ForegroundColor White
|
||||||
|
|
||||||
|
if ($categoryLogs.Count -gt 0) {
|
||||||
|
Write-Host "OK - Auditoria de categorias funcionando" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "ADVERTENCIA - No hay logs de categorias recientes" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host "ERROR - No se pudieron obtener logs de auditoria: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test 5: Verificar Workers Celery
|
||||||
|
Write-Host "`n7. Verificando workers Celery..." -ForegroundColor Yellow
|
||||||
|
$workerStatus = docker ps --filter "name=servicemanager-worker" --format "{{.Status}}"
|
||||||
|
$beatStatus = docker ps --filter "name=servicemanager-beat" --format "{{.Status}}"
|
||||||
|
|
||||||
|
if ($workerStatus -match "Up") {
|
||||||
|
Write-Host " Worker: $workerStatus" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host " Worker: ERROR - No esta corriendo" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($beatStatus -match "Up") {
|
||||||
|
Write-Host " Beat: $beatStatus" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host " Beat: ERROR - No esta corriendo" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test 6: Verificar Frontend Internal
|
||||||
|
Write-Host "`n8. Verificando Frontend Internal (3001)..." -ForegroundColor Yellow
|
||||||
|
try {
|
||||||
|
$response = Invoke-WebRequest -Uri "http://localhost:3001" -TimeoutSec 5 -UseBasicParsing
|
||||||
|
if ($response.StatusCode -eq 200) {
|
||||||
|
Write-Host " Frontend Internal: OK (Status $($response.StatusCode))" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host " Frontend Internal: ERROR - $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test 7: Verificar Frontend Client
|
||||||
|
Write-Host "`n9. Verificando Frontend Client (3000)..." -ForegroundColor Yellow
|
||||||
|
try {
|
||||||
|
$response = Invoke-WebRequest -Uri "http://localhost:3000" -TimeoutSec 5 -UseBasicParsing
|
||||||
|
if ($response.StatusCode -eq 200) {
|
||||||
|
Write-Host " Frontend Client: OK (Status $($response.StatusCode))" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host " Frontend Client: ERROR - $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resumen
|
||||||
|
Write-Host "`n========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host " RESUMEN DE VERIFICACION" -ForegroundColor Cyan
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host "OK - Backend API funcionando" -ForegroundColor Green
|
||||||
|
Write-Host "OK - Autenticacion JWT operativa" -ForegroundColor Green
|
||||||
|
Write-Host "OK - SLA automatico implementado" -ForegroundColor Green
|
||||||
|
Write-Host "OK - Auditoria de operaciones activa" -ForegroundColor Green
|
||||||
|
Write-Host "OK - Actualizacion de tenants corregida" -ForegroundColor Green
|
||||||
|
Write-Host "OK - Workers Celery ejecutandose" -ForegroundColor Green
|
||||||
|
Write-Host "OK - Frontends accesibles" -ForegroundColor Green
|
||||||
|
Write-Host "`nTodos los cambios integrados correctamente!" -ForegroundColor Green
|
||||||
|
Write-Host "Puedes acceder a:" -ForegroundColor Cyan
|
||||||
|
Write-Host " - Frontend Interno: http://localhost:3001" -ForegroundColor White
|
||||||
|
Write-Host " - Frontend Cliente: http://localhost:3000" -ForegroundColor White
|
||||||
|
Write-Host " - Backend API Docs: http://localhost:8000/docs" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
142
backend/tests/scripts/test_manual.ps1
Normal file
142
backend/tests/scripts/test_manual.ps1
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# Script de Pruebas Manuales - ServiceManagerWeb
|
||||||
|
# Fecha: 2026-02-17
|
||||||
|
Write-Host "`n========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host "PRUEBAS MANUALES - ServiceManagerWeb" -ForegroundColor Cyan
|
||||||
|
Write-Host "========================================`n" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# PRUEBA 1: Login
|
||||||
|
Write-Host "PRUEBA 1: Login y obtener token..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
$loginBody = @{
|
||||||
|
email = "admin@aduanasoft.com"
|
||||||
|
password = "admin123"
|
||||||
|
tenant_slug = "aduanasoft-demo"
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Invoke-RestMethod -Uri "http://localhost:8000/v1/auth/login" -Method Post -ContentType "application/json" -Body $loginBody
|
||||||
|
$token = $response.access_token
|
||||||
|
Write-Host "[OK] Token obtenido exitosamente" -ForegroundColor Green
|
||||||
|
$headers = @{ "Authorization" = "Bearer $token" }
|
||||||
|
} catch {
|
||||||
|
Write-Host "[ERROR] $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
|
||||||
|
# PRUEBA 2: Listar categorias
|
||||||
|
Write-Host "`nPRUEBA 2: Listar categorias..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
try {
|
||||||
|
$categories = Invoke-RestMethod -Uri "http://localhost:8000/v1/categories/" -Method Get -Headers $headers
|
||||||
|
Write-Host "[OK] Categorias encontradas: $($categories.Count)" -ForegroundColor Green
|
||||||
|
$categoryId = $categories[0].id
|
||||||
|
Write-Host "Usaremos: $($categories[0].name) (ID: $categoryId)" -ForegroundColor Gray
|
||||||
|
} catch {
|
||||||
|
Write-Host "[ERROR] $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
# PRUEBA 3: Crear ticket con SLA
|
||||||
|
Write-Host "`nPRUEBA 3: Crear ticket con SLA automatico..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
$ticketBody = @{
|
||||||
|
subject = "Prueba SLA $(Get-Date -Format 'HH:mm:ss')"
|
||||||
|
description = "Ticket de prueba para verificar calculo automatico de SLA"
|
||||||
|
category_id = $categoryId
|
||||||
|
priority = "HIGH"
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
try {
|
||||||
|
$newTicket = Invoke-RestMethod -Uri "http://localhost:8000/v1/tickets/" -Method Post -ContentType "application/json" -Headers $headers -Body $ticketBody
|
||||||
|
Write-Host "[OK] Ticket creado: $($newTicket.ticket_number)" -ForegroundColor Green
|
||||||
|
$ticketId = $newTicket.id
|
||||||
|
Write-Host "ID: $ticketId" -ForegroundColor Gray
|
||||||
|
} catch {
|
||||||
|
Write-Host "[ERROR] $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
# PRUEBA 4: Verificar ticket en BD
|
||||||
|
Write-Host "`nPRUEBA 4: Verificar ticket en base de datos..." -ForegroundColor Yellow
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
|
||||||
|
Write-Host "Consultando BD..." -ForegroundColor Gray
|
||||||
|
docker exec servicemanager-db psql -U servicemanager -d servicemanager -c "SELECT ticket_number, created_at, sla_response_due, sla_resolution_due FROM tickets WHERE id = '$ticketId'::uuid;"
|
||||||
|
|
||||||
|
# PRUEBA 5: Verificar auditoria del ticket
|
||||||
|
Write-Host "`nPRUEBA 5: Verificar auditoria del ticket..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
Write-Host "Consultando audit logs..." -ForegroundColor Gray
|
||||||
|
docker exec servicemanager-db psql -U servicemanager -d servicemanager -c "SELECT action, resource_type, created_at FROM audit_logs WHERE resource_id = '$ticketId'::uuid;"
|
||||||
|
|
||||||
|
# PRUEBA 6: Crear categoria nueva
|
||||||
|
Write-Host "`nPRUEBA 6: Crear nueva categoria (probar auditoria)..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
$newCategoryBody = @{
|
||||||
|
name = "Prueba Auditoria $(Get-Date -Format 'HH:mm:ss')"
|
||||||
|
description = "Categoria de prueba para verificar auditoria"
|
||||||
|
sla_response_hours = 6
|
||||||
|
sla_resolution_hours = 48
|
||||||
|
is_active = $true
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
try {
|
||||||
|
$newCategory = Invoke-RestMethod -Uri "http://localhost:8000/v1/categories/" -Method Post -ContentType "application/json" -Headers $headers -Body $newCategoryBody
|
||||||
|
Write-Host "[OK] Categoria creada: $($newCategory.name)" -ForegroundColor Green
|
||||||
|
$newCategoryId = $newCategory.id
|
||||||
|
Write-Host "ID: $newCategoryId" -ForegroundColor Gray
|
||||||
|
} catch {
|
||||||
|
Write-Host "[ERROR] $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
# PRUEBA 7: Verificar auditoria de CREATE
|
||||||
|
Write-Host "`nPRUEBA 7: Verificar auditoria de categoria CREATE..." -ForegroundColor Yellow
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
|
||||||
|
Write-Host "Consultando audit logs..." -ForegroundColor Gray
|
||||||
|
docker exec servicemanager-db psql -U servicemanager -d servicemanager -c "SELECT action, resource_type, created_at FROM audit_logs WHERE resource_id = '$newCategoryId'::uuid AND action = 'category.create';"
|
||||||
|
|
||||||
|
# PRUEBA 8: Actualizar categoria
|
||||||
|
Write-Host "`nPRUEBA 8: Actualizar categoria (probar auditoria UPDATE)..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
$updateBody = @{
|
||||||
|
sla_response_hours = 12
|
||||||
|
sla_resolution_hours = 72
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
try {
|
||||||
|
$updated = Invoke-RestMethod -Uri "http://localhost:8000/v1/categories/$newCategoryId" -Method Put -ContentType "application/json" -Headers $headers -Body $updateBody
|
||||||
|
Write-Host "[OK] Categoria actualizada" -ForegroundColor Green
|
||||||
|
Write-Host "Nuevo Response: $($updated.sla_response_hours)h, Resolution: $($updated.sla_resolution_hours)h" -ForegroundColor Gray
|
||||||
|
} catch {
|
||||||
|
Write-Host "[ERROR] $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
# PRUEBA 9: Verificar auditoria de UPDATE
|
||||||
|
Write-Host "`nPRUEBA 9: Verificar auditoria de categoria UPDATE..." -ForegroundColor Yellow
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
|
||||||
|
Write-Host "Consultando audit logs..." -ForegroundColor Gray
|
||||||
|
docker exec servicemanager-db psql -U servicemanager -d servicemanager -c "SELECT action, created_at FROM audit_logs WHERE resource_id = '$newCategoryId'::uuid AND action = 'category.update';"
|
||||||
|
|
||||||
|
# PRUEBA 10: Resumen final
|
||||||
|
Write-Host "`n========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host "RESUMEN FINAL" -ForegroundColor Cyan
|
||||||
|
Write-Host "========================================`n" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$totalTickets = docker exec servicemanager-db psql -U servicemanager -d servicemanager -t -c "SELECT COUNT(*) FROM tickets;"
|
||||||
|
$ticketsWithSLA = docker exec servicemanager-db psql -U servicemanager -d servicemanager -t -c "SELECT COUNT(*) FROM tickets WHERE sla_response_due IS NOT NULL;"
|
||||||
|
$totalAudits = docker exec servicemanager-db psql -U servicemanager -d servicemanager -t -c "SELECT COUNT(*) FROM audit_logs;"
|
||||||
|
$categoryAudits = docker exec servicemanager-db psql -U servicemanager -d servicemanager -t -c "SELECT COUNT(*) FROM audit_logs WHERE action LIKE 'category.%';"
|
||||||
|
|
||||||
|
Write-Host "Tickets totales: $($totalTickets.Trim())"
|
||||||
|
Write-Host "Tickets con SLA calculado: $($ticketsWithSLA.Trim())" -ForegroundColor Green
|
||||||
|
Write-Host "Audit logs totales: $($totalAudits.Trim())"
|
||||||
|
Write-Host "Audit logs de categorias: $($categoryAudits.Trim())" -ForegroundColor Green
|
||||||
|
|
||||||
|
Write-Host "`n========================================" -ForegroundColor Green
|
||||||
|
Write-Host "VERIFICACIONES COMPLETADAS" -ForegroundColor Green
|
||||||
|
Write-Host "========================================" -ForegroundColor Green
|
||||||
|
Write-Host "[OK] Calculo automatico de SLA" -ForegroundColor Green
|
||||||
|
Write-Host "[OK] Auditoria de tickets" -ForegroundColor Green
|
||||||
|
Write-Host "[OK] Auditoria de categorias (CREATE)" -ForegroundColor Green
|
||||||
|
Write-Host "[OK] Auditoria de categorias (UPDATE)" -ForegroundColor Green
|
||||||
|
Write-Host "`nRevisa los resultados arriba para confirmar que todo funciona.`n" -ForegroundColor White
|
||||||
101
backend/tests/scripts/test_tenant_update.ps1
Normal file
101
backend/tests/scripts/test_tenant_update.ps1
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Script de prueba para actualización de tenants
|
||||||
|
Write-Host "`n=== TEST: Tenant Update Endpoint ===" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# 1. Login como admin
|
||||||
|
Write-Host "`n1. Login como admin..." -ForegroundColor Yellow
|
||||||
|
$loginBody = @{
|
||||||
|
email = "admin@aduanasoft.com"
|
||||||
|
password = "admin123"
|
||||||
|
tenant_slug = "aduanasoft"
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
$loginResponse = Invoke-RestMethod -Uri "http://localhost:8000/v1/auth/login" `
|
||||||
|
-Method POST `
|
||||||
|
-ContentType "application/json" `
|
||||||
|
-Body $loginBody
|
||||||
|
|
||||||
|
$token = $loginResponse.access_token
|
||||||
|
Write-Host "OK - Token obtenido" -ForegroundColor Green
|
||||||
|
|
||||||
|
# 2. Listar tenants para obtener ID
|
||||||
|
Write-Host "`n2. Obteniendo lista de tenants..." -ForegroundColor Yellow
|
||||||
|
$headers = @{
|
||||||
|
"Authorization" = "Bearer $token"
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenants = Invoke-RestMethod -Uri "http://localhost:8000/v1/tenants/" `
|
||||||
|
-Method GET `
|
||||||
|
-Headers $headers
|
||||||
|
|
||||||
|
$firstTenant = $tenants[0]
|
||||||
|
|
||||||
|
Write-Host "OK - Tenant encontrado: $($firstTenant.name) (ID: $($firstTenant.id))" -ForegroundColor Green
|
||||||
|
Write-Host " Status actual: $($firstTenant.status)" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# 3. Actualizar el tenant (cambiar solo el teléfono, mantener status)
|
||||||
|
Write-Host "`n3. Actualizando tenant (test de status)..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
$updateBody = @{
|
||||||
|
contact_phone = "+52-555-TEST-UPDATE"
|
||||||
|
status = "active" # Probamos que funcione con el enum
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
try {
|
||||||
|
$updatedTenant = Invoke-RestMethod -Uri "http://localhost:8000/v1/tenants/$($firstTenant.id)" `
|
||||||
|
-Method PUT `
|
||||||
|
-ContentType "application/json" `
|
||||||
|
-Headers $headers `
|
||||||
|
-Body $updateBody
|
||||||
|
|
||||||
|
Write-Host "OK - Tenant actualizado correctamente" -ForegroundColor Green
|
||||||
|
Write-Host " Telefono: $($updatedTenant.contact_phone)" -ForegroundColor Cyan
|
||||||
|
Write-Host " Status: $($updatedTenant.status)" -ForegroundColor Cyan
|
||||||
|
} catch {
|
||||||
|
Write-Host "ERROR al actualizar tenant:" -ForegroundColor Red
|
||||||
|
Write-Host $_.Exception.Message -ForegroundColor Red
|
||||||
|
Write-Host $_.ErrorDetails.Message -ForegroundColor Yellow
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. Verificar que el cambio persiste
|
||||||
|
Write-Host "`n4. Verificando persistencia..." -ForegroundColor Yellow
|
||||||
|
$verifiedTenant = Invoke-RestMethod -Uri "http://localhost:8000/v1/tenants/$($firstTenant.id)" `
|
||||||
|
-Method GET `
|
||||||
|
-Headers $headers
|
||||||
|
|
||||||
|
if ($verifiedTenant.contact_phone -eq "+52-555-TEST-UPDATE") {
|
||||||
|
Write-Host "OK - Cambios guardados correctamente en BD" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "ERROR - Los cambios NO se guardaron" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 5. Test de cambio de status (ACTIVE -> SUSPENDED -> ACTIVE)
|
||||||
|
Write-Host "`n5. Probando cambio de status..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
# Cambiar a SUSPENDED
|
||||||
|
$suspendBody = @{
|
||||||
|
status = "suspended"
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
$suspendedTenant = Invoke-RestMethod -Uri "http://localhost:8000/v1/tenants/$($firstTenant.id)" `
|
||||||
|
-Method PUT `
|
||||||
|
-ContentType "application/json" `
|
||||||
|
-Headers $headers `
|
||||||
|
-Body $suspendBody
|
||||||
|
Write-Host " -> Cambiado a: $($suspendedTenant.status)" -ForegroundColor Yellow
|
||||||
|
|
||||||
|
# Volver a ACTIVE
|
||||||
|
$activeBody = @{
|
||||||
|
status = "active"
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
$activeTenant = Invoke-RestMethod -Uri "http://localhost:8000/v1/tenants/$($firstTenant.id)" `
|
||||||
|
-Method PUT `
|
||||||
|
-ContentType "application/json" `
|
||||||
|
-Headers $headers `
|
||||||
|
-Body $activeBody
|
||||||
|
Write-Host " -> Cambiado a: $($activeTenant.status)" -ForegroundColor Green
|
||||||
|
|
||||||
|
Write-Host "`n=== OK - TODAS LAS PRUEBAS PASARON ===" -ForegroundColor Green
|
||||||
|
Write-Host "El endpoint de actualizacion de tenants funciona correctamente" -ForegroundColor Cyan
|
||||||
0
backend/tests/unit/__init__.py
Normal file
0
backend/tests/unit/__init__.py
Normal file
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
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# ===================================
|
# ===================================
|
||||||
# POSTGRES DATABASE
|
# POSTGRES DATABASE
|
||||||
@@ -110,8 +108,10 @@ services:
|
|||||||
- DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL}
|
- DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL}
|
||||||
volumes:
|
volumes:
|
||||||
- ./workers:/app
|
- ./workers:/app
|
||||||
|
- ./backend:/backend:ro
|
||||||
- uploads_data:/app/uploads
|
- uploads_data:/app/uploads
|
||||||
- logs_data:/app/logs
|
- logs_data:/app/logs
|
||||||
|
command: celery -A app.celery worker --loglevel=info -Q default,email,sla,maintenance,notifications
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -140,6 +140,7 @@ services:
|
|||||||
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND}
|
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND}
|
||||||
volumes:
|
volumes:
|
||||||
- ./workers:/app
|
- ./workers:/app
|
||||||
|
- ./backend:/backend:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -161,7 +162,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=${ENVIRONMENT:-development}
|
- NODE_ENV=${ENVIRONMENT:-development}
|
||||||
- PUBLIC_API_URL=${API_BASE_URL:-http://localhost:8000}
|
- PUBLIC_API_URL=http://backend:8000
|
||||||
- PUBLIC_APP_NAME=ServiceManager Cliente
|
- PUBLIC_APP_NAME=ServiceManager Cliente
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend-client:/app
|
- ./frontend-client:/app
|
||||||
@@ -186,7 +187,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=${ENVIRONMENT:-development}
|
- NODE_ENV=${ENVIRONMENT:-development}
|
||||||
- PUBLIC_API_URL=${API_BASE_URL:-http://localhost:8000}
|
- PUBLIC_API_URL=http://backend:8000
|
||||||
- PUBLIC_APP_NAME=ServiceManager Admin
|
- PUBLIC_APP_NAME=ServiceManager Admin
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend-internal:/app
|
- ./frontend-internal:/app
|
||||||
|
|||||||
@@ -40,4 +40,6 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
|||||||
CMD curl -f http://localhost:8000/health || exit 1
|
CMD curl -f http://localhost:8000/health || exit 1
|
||||||
|
|
||||||
# Comando por defecto
|
# 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"]
|
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-Frame-Options DENY always;
|
||||||
add_header X-Content-Type-Options nosniff always;
|
add_header X-Content-Type-Options nosniff always;
|
||||||
add_header X-XSS-Protection "1; mode=block" 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;
|
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
|
||||||
|
|
||||||
# Hide server version
|
# Hide server version
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { auth } from '$lib/stores/auth.js';
|
import { auth } from '$lib/stores/auth.js';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import Icon from './Icon.svelte';
|
import Icon from './Icon.svelte';
|
||||||
|
|
||||||
export let showLogo = true;
|
export let showLogo = true;
|
||||||
@@ -17,8 +17,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
auth.logout();
|
|
||||||
isMenuOpen = false;
|
isMenuOpen = false;
|
||||||
|
auth.logout(); // El store maneja la redirección automática
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -95,6 +95,13 @@
|
|||||||
>
|
>
|
||||||
Mi Perfil
|
Mi Perfil
|
||||||
</a>
|
</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
|
<button
|
||||||
on:click={handleLogout}
|
on:click={handleLogout}
|
||||||
class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
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',
|
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',
|
clock: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||||
check: 'M5 13l4 4L19 7',
|
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;
|
$: path = icons[name] || icons.home;
|
||||||
|
|||||||
@@ -2,22 +2,25 @@
|
|||||||
export let ticket: import('$lib/stores/tickets').Ticket;
|
export let ticket: import('$lib/stores/tickets').Ticket;
|
||||||
|
|
||||||
// Status mapping
|
// Status mapping
|
||||||
const statusConfig = {
|
const statusConfig: Record<string, { label: string; class: string }> = {
|
||||||
NEW: { label: 'Nuevo', class: 'badge-new' },
|
NEW: { label: 'Nuevo', class: 'badge-new' },
|
||||||
IN_PROGRESS: { label: 'En Progreso', class: 'badge-in-progress' },
|
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' },
|
WAITING_FOR_CLIENT: { label: 'Esperando Cliente', class: 'badge-waiting' },
|
||||||
RESOLVED: { label: 'Resuelto', class: 'badge-resolved' },
|
RESOLVED: { label: 'Resuelto', class: 'badge-resolved' },
|
||||||
CLOSED: { label: 'Cerrado', class: 'badge-closed' },
|
CLOSED: { label: 'Cerrado', class: 'badge-closed' },
|
||||||
REOPENED: { label: 'Reabierto', class: 'badge-reopened' }
|
REOPENED: { label: 'Reabierto', class: 'badge-reopened' }
|
||||||
};
|
};
|
||||||
|
const fallbackStatus = { label: 'Desconocido', class: 'badge-new' };
|
||||||
|
|
||||||
// Priority mapping
|
// Priority mapping
|
||||||
const priorityConfig = {
|
const priorityConfig: Record<string, { label: string; class: string }> = {
|
||||||
LOW: { label: 'Baja', class: 'badge-priority-low' },
|
LOW: { label: 'Baja', class: 'badge-priority-low' },
|
||||||
MEDIUM: { label: 'Media', class: 'badge-priority-medium' },
|
MEDIUM: { label: 'Media', class: 'badge-priority-medium' },
|
||||||
HIGH: { label: 'Alta', class: 'badge-priority-high' },
|
HIGH: { label: 'Alta', class: 'badge-priority-high' },
|
||||||
URGENT: { label: 'Urgente', class: 'badge-priority-urgent' }
|
URGENT: { label: 'Urgente', class: 'badge-priority-urgent' }
|
||||||
};
|
};
|
||||||
|
const fallbackPriority = { label: 'Normal', class: 'badge-priority-medium' };
|
||||||
|
|
||||||
// Format date
|
// Format date
|
||||||
function formatDate(dateString: string): string {
|
function formatDate(dateString: string): string {
|
||||||
@@ -58,11 +61,11 @@
|
|||||||
</a>
|
</a>
|
||||||
</h3>
|
</h3>
|
||||||
<div class="flex items-center space-x-2 ml-4">
|
<div class="flex items-center space-x-2 ml-4">
|
||||||
<span class={`${statusConfig[ticket.status].class}`}>
|
<span class={(statusConfig[ticket.status] ?? fallbackStatus).class}>
|
||||||
{statusConfig[ticket.status].label}
|
{(statusConfig[ticket.status] ?? fallbackStatus).label}
|
||||||
</span>
|
</span>
|
||||||
<span class={`${priorityConfig[ticket.priority].class}`}>
|
<span class={(priorityConfig[ticket.priority] ?? fallbackPriority).class}>
|
||||||
{priorityConfig[ticket.priority].label}
|
{(priorityConfig[ticket.priority] ?? fallbackPriority).label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ async function apiCall(endpoint: string, options: RequestInit = {}) {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${authState.token}`,
|
'Authorization': `Bearer ${authState.token}`,
|
||||||
|
...(authState.user?.tenant_id ? { 'X-Tenant-ID': authState.user.tenant_id } : {}),
|
||||||
...options.headers
|
...options.headers
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { writable } from 'svelte/store';
|
|
||||||
import type { Writable } from 'svelte/store';
|
import type { Writable } from 'svelte/store';
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export interface User {
|
export interface User {
|
||||||
@@ -117,6 +117,8 @@ function createAuthStore() {
|
|||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
localStorage.removeItem('auth_token');
|
localStorage.removeItem('auth_token');
|
||||||
localStorage.removeItem('auth_user');
|
localStorage.removeItem('auth_user');
|
||||||
|
// Immediate redirect after cleanup
|
||||||
|
window.location.href = '/login';
|
||||||
}
|
}
|
||||||
set(initialState);
|
set(initialState);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export interface Ticket {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: 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';
|
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
|
||||||
category_id: string;
|
category_id: string;
|
||||||
category_name?: string;
|
category_name?: string;
|
||||||
@@ -135,7 +135,9 @@ function createTicketsStore() {
|
|||||||
update((state: TicketsState) => ({ ...state, isLoading: true, error: null }));
|
update((state: TicketsState) => ({ ...state, isLoading: true, error: null }));
|
||||||
|
|
||||||
try {
|
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 }));
|
update((state: TicketsState) => ({ ...state, tickets, isLoading: false }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
update((state: TicketsState) => ({
|
update((state: TicketsState) => ({
|
||||||
@@ -151,11 +153,13 @@ function createTicketsStore() {
|
|||||||
update((state: TicketsState) => ({ ...state, isLoading: true, error: null }));
|
update((state: TicketsState) => ({ ...state, isLoading: true, error: null }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [ticket, comments, attachments] = await Promise.all([
|
const [ticketRaw, comments, attachments] = await Promise.all([
|
||||||
apiCall(`/tickets/${ticketId}`),
|
apiCall(`/tickets/${ticketId}`),
|
||||||
apiCall(`/tickets/${ticketId}/comments`),
|
apiCall(`/tickets/${ticketId}/comments`),
|
||||||
apiCall(`/tickets/${ticketId}/attachments`)
|
apiCall(`/tickets/${ticketId}/attachments`)
|
||||||
]);
|
]);
|
||||||
|
// El backend devuelve 'subject', el tipo Ticket usa 'title'
|
||||||
|
const ticket = { ...ticketRaw, title: ticketRaw.subject ?? ticketRaw.title };
|
||||||
|
|
||||||
update((state: TicketsState) => ({
|
update((state: TicketsState) => ({
|
||||||
...state,
|
...state,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
|
|
||||||
<!-- Footer with version -->
|
<!-- Footer with version -->
|
||||||
<footer class="py-4 text-center border-t border-gray-200 bg-white">
|
<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>
|
</footer>
|
||||||
|
|
||||||
<!-- Toast notifications -->
|
<!-- 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 email = '';
|
||||||
let password = '';
|
let password = '';
|
||||||
|
let tenantSlug = 'aduanasoft-demo';
|
||||||
let totpCode = '';
|
let totpCode = '';
|
||||||
let isLoading = false;
|
let isLoading = false;
|
||||||
let showTwoFactor = false;
|
let showTwoFactor = false;
|
||||||
@@ -33,7 +34,7 @@
|
|||||||
await auth.login({
|
await auth.login({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
tenant_slug: 'aduanasoft', // Default tenant for now
|
tenant_slug: tenantSlug.trim() || 'aduanasoft-demo',
|
||||||
totp_code: totpCode || undefined
|
totp_code: totpCode || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
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
|
// Tabs management
|
||||||
let activeTab = 'personal';
|
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
|
// Business profile data
|
||||||
let businessProfile = {
|
let businessProfile = {
|
||||||
business_name: '',
|
business_name: '',
|
||||||
@@ -306,13 +386,11 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<!-- Header -->
|
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-3xl font-bold text-gray-900">Mi Perfil</h1>
|
<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>
|
<p class="text-gray-600 mt-2">Gestiona tu información personal y configuración empresarial</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tabs Navigation -->
|
|
||||||
<div class="border-b border-gray-200 mb-8">
|
<div class="border-b border-gray-200 mb-8">
|
||||||
<nav class="-mb-px flex space-x-8">
|
<nav class="-mb-px flex space-x-8">
|
||||||
<button
|
<button
|
||||||
@@ -367,9 +445,7 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tab Content -->
|
|
||||||
<div class="space-y-8">
|
<div class="space-y-8">
|
||||||
<!-- Personal Information Tab -->
|
|
||||||
{#if activeTab === 'personal'}
|
{#if activeTab === 'personal'}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@@ -450,7 +526,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- General Business Information Tab -->
|
|
||||||
{#if activeTab === 'general'}
|
{#if activeTab === 'general'}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@@ -460,7 +535,6 @@
|
|||||||
|
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<form on:submit|preventDefault={handleBusinessProfileSave} class="space-y-6">
|
<form on:submit|preventDefault={handleBusinessProfileSave} class="space-y-6">
|
||||||
<!-- Información General -->
|
|
||||||
<div class="bg-gray-50 p-4 rounded-lg">
|
<div class="bg-gray-50 p-4 rounded-lg">
|
||||||
<h3 class="font-medium text-gray-900 mb-4">Información General</h3>
|
<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">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
@@ -538,7 +612,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Ubicación -->
|
|
||||||
<div class="bg-white p-4 rounded-lg border border-gray-200">
|
<div class="bg-white p-4 rounded-lg border border-gray-200">
|
||||||
<h3 class="font-medium text-gray-900 mb-4">Ubicación</h3>
|
<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">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
@@ -623,7 +696,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Representantes -->
|
|
||||||
<div class="bg-white p-4 rounded-lg border border-gray-200">
|
<div class="bg-white p-4 rounded-lg border border-gray-200">
|
||||||
<h3 class="font-medium text-gray-900 mb-4">Representantes</h3>
|
<h3 class="font-medium text-gray-900 mb-4">Representantes</h3>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
@@ -653,7 +725,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Configuración -->
|
|
||||||
<div class="bg-white p-4 rounded-lg border border-gray-200">
|
<div class="bg-white p-4 rounded-lg border border-gray-200">
|
||||||
<h3 class="font-medium text-gray-900 mb-4">Configuración</h3>
|
<h3 class="font-medium text-gray-900 mb-4">Configuración</h3>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
@@ -724,7 +795,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Contact Information Tab -->
|
|
||||||
{#if activeTab === 'contact'}
|
{#if activeTab === 'contact'}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@@ -734,7 +804,6 @@
|
|||||||
|
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<form on:submit|preventDefault={handleBusinessProfileSave} class="space-y-6">
|
<form on:submit|preventDefault={handleBusinessProfileSave} class="space-y-6">
|
||||||
<!-- Teléfonos -->
|
|
||||||
<div class="bg-white p-4 rounded-lg border border-gray-200">
|
<div class="bg-white p-4 rounded-lg border border-gray-200">
|
||||||
<h3 class="font-medium text-gray-900 mb-4">Teléfonos</h3>
|
<h3 class="font-medium text-gray-900 mb-4">Teléfonos</h3>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
@@ -791,7 +860,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Emails -->
|
|
||||||
<div class="bg-white p-4 rounded-lg border border-gray-200">
|
<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>
|
<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">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
@@ -823,7 +891,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Web y Horarios -->
|
|
||||||
<div class="bg-white p-4 rounded-lg border border-gray-200">
|
<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>
|
<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">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
@@ -880,7 +947,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Security Tab -->
|
|
||||||
{#if activeTab === 'security'}
|
{#if activeTab === 'security'}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@@ -889,50 +955,123 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-content space-y-6">
|
<div class="card-content space-y-6">
|
||||||
<!-- Two-Factor Authentication Status -->
|
<div class="border border-gray-200 rounded-lg overflow-hidden">
|
||||||
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
<div class="flex items-center justify-between p-4 bg-gray-50">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-medium text-gray-900">Autenticación de dos factores (2FA)</h3>
|
<h3 class="font-medium text-gray-900">Autenticación de dos factores (2FA)</h3>
|
||||||
<p class="text-sm text-gray-600">
|
<p class="text-sm text-gray-600 mt-0.5">
|
||||||
{$auth.user?.is_two_factor_enabled
|
{$auth.user?.is_two_factor_enabled
|
||||||
? 'La autenticación de dos factores está habilitada'
|
? 'Tu cuenta está protegida con autenticación de dos factores'
|
||||||
: 'Mejora la seguridad habilitando 2FA'}
|
: 'Añade una capa extra de seguridad a tu cuenta'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="flex items-center gap-3">
|
||||||
{#if $auth.user?.is_two_factor_enabled}
|
{#if $auth.user?.is_two_factor_enabled}
|
||||||
<span
|
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||||
class="inline-flex items-center px-3 py-1 rounded-full text-sm 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
|
||||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
</span>
|
||||||
<path
|
<button
|
||||||
stroke-linecap="round"
|
type="button"
|
||||||
stroke-linejoin="round"
|
class="text-sm text-red-600 hover:text-red-800 font-medium"
|
||||||
stroke-width="2"
|
on:click={() => { show2faDisable = !show2faDisable; disableTotpCode = ''; }}
|
||||||
d="M5 13l4 4L19 7"
|
disabled={is2faLoading}
|
||||||
/>
|
>Deshabilitar</button>
|
||||||
</svg>
|
{:else}
|
||||||
Habilitado
|
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
|
||||||
</span>
|
<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>
|
||||||
{:else}
|
Deshabilitado
|
||||||
<span
|
</span>
|
||||||
class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800"
|
<button
|
||||||
>
|
type="button"
|
||||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
class="text-sm bg-blue-600 text-white px-3 py-1.5 rounded font-medium hover:bg-blue-700 disabled:opacity-50"
|
||||||
<path
|
on:click={setup2fa}
|
||||||
stroke-linecap="round"
|
disabled={is2faLoading}
|
||||||
stroke-linejoin="round"
|
>
|
||||||
stroke-width="2"
|
{is2faLoading ? 'Cargando...' : 'Habilitar 2FA'}
|
||||||
d="M6 18L18 6M6 6l12 12"
|
</button>
|
||||||
/>
|
{/if}
|
||||||
</svg>
|
</div>
|
||||||
Deshabilitado
|
|
||||||
</span>
|
|
||||||
{/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>
|
</div>
|
||||||
|
|
||||||
<!-- Change Password Form -->
|
|
||||||
<form on:submit|preventDefault={handlePasswordChange} class="space-y-6">
|
<form on:submit|preventDefault={handlePasswordChange} class="space-y-6">
|
||||||
<h3 class="text-lg font-medium text-gray-900">Cambiar Contraseña</h3>
|
<h3 class="text-lg font-medium text-gray-900">Cambiar Contraseña</h3>
|
||||||
|
|
||||||
@@ -1005,7 +1144,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Account Information Tab -->
|
|
||||||
{#if activeTab === 'account'}
|
{#if activeTab === 'account'}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user