Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7003e5cd80 | |||
| d8232fda7c | |||
| 3b46f48655 | |||
| f10b15d91b | |||
| 49dfb3ef24 | |||
| b187aa1b46 | |||
| cd3d7e816f | |||
| 63925fe305 | |||
| c146a6c3c3 | |||
| ba779bde55 | |||
| ba94152074 | |||
| bd21207aae | |||
| 1ccc39732b | |||
| ceea67eb2b | |||
| 517297e89a | |||
|
|
16d795e8bd | ||
| f80a57a697 | |||
| e6440395ea | |||
| cc1e964c3a | |||
| 75726d915f | |||
| 42a5bb54cc | |||
| ae0bfc9d62 | |||
| 0bc4caf65d | |||
| be762585d2 | |||
| 32cc8b6ccd | |||
| caeac3e96c | |||
| 6af80f1960 | |||
| 57944b364c | |||
| 3a061a005c | |||
| d9b783107f | |||
| 5a292daa0b | |||
| 87e094b668 | |||
| 2cb8b58808 | |||
| 9f973464b9 | |||
| 90f9c9c6e6 | |||
| 51a8515b40 | |||
| ff9a8998b2 | |||
| 1543397212 | |||
| 2033a35a2b | |||
| 96cd09476c |
26
.gitignore
vendored
26
.gitignore
vendored
@@ -30,29 +30,3 @@ docker-compose.override.yml
|
||||
|
||||
# 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
|
||||
178
README.legacy.md
Normal file
178
README.legacy.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# ServiceManagerWeb - Mesa de Ayuda B2B
|
||||
|
||||
Sistema multi-tenant de Mesa de Ayuda/Soporte Técnico empresarial para Aduanasoft.
|
||||
|
||||
## Arquitectura
|
||||
|
||||
- **Frontend**: SvelteKit + TypeScript (portal clientes + panel interno)
|
||||
- **Backend**: Python FastAPI + Pydantic v2
|
||||
- **Workers**: Celery + Redis (notificaciones, SLAs, jobs)
|
||||
- **BD**: PostgreSQL + Alembic migrations
|
||||
- **Auth**: JWT + Refresh tokens + 2FA opcional (TOTP)
|
||||
- **Infra**: Docker Compose local, preparado para producción
|
||||
|
||||
## Estructura del Monorepo
|
||||
|
||||
```
|
||||
ServiceManagerWeb/
|
||||
├── backend/ # FastAPI app
|
||||
├── frontend-client/ # SvelteKit app para clientes
|
||||
├── frontend-internal/ # SvelteKit app para staff interno
|
||||
├── workers/ # Celery tasks
|
||||
├── db/ # Migrations y esquemas
|
||||
├── docker/ # Dockerfiles específicos
|
||||
├── docs/ # Documentación adicional
|
||||
├── scripts/ # Scripts de desarrollo/despliegue
|
||||
├── docker-compose.yml # Orquestación completa
|
||||
└── .env.example # Variables de entorno
|
||||
```
|
||||
|
||||
## Stack Tecnológico
|
||||
|
||||
### Backend (Python)
|
||||
- FastAPI (async)
|
||||
- Pydantic v2
|
||||
- SQLAlchemy 2.0 (async)
|
||||
- Alembic (migrations)
|
||||
- Argon2 (hashing passwords)
|
||||
- PyJWT
|
||||
- Celery + Redis
|
||||
|
||||
### Frontend (JavaScript/TypeScript)
|
||||
- SvelteKit
|
||||
- TypeScript
|
||||
- TailwindCSS
|
||||
- shadcn/ui o similar
|
||||
- Zod (validación)
|
||||
|
||||
### Infraestructura
|
||||
- PostgreSQL 15+
|
||||
- Redis 7+
|
||||
- Docker & Docker Compose
|
||||
- Nginx (reverse proxy)
|
||||
|
||||
## Dominios del Sistema
|
||||
|
||||
1. **Auth**: Usuarios, roles, permisos, 2FA
|
||||
2. **Tenants**: Multi-tenancy, organizaciones
|
||||
3. **Tickets**: Gestión de tickets, estados, SLAs
|
||||
4. **Notifications**: Email, plantillas, logs
|
||||
5. **Audit**: Bitácora de acciones
|
||||
|
||||
## Roles de Usuario
|
||||
|
||||
### Internos (Staff)
|
||||
- `ADMIN`: Control total del sistema
|
||||
- `SUPPORT_MANAGER`: Gestión de equipos y SLAs
|
||||
- `AGENT`: Atención de tickets
|
||||
- `AUDITOR`: Solo lectura para auditoría
|
||||
|
||||
### Clientes
|
||||
- `CLIENT_ADMIN`: Gestión de organización cliente
|
||||
- `CLIENT_USER`: Creación y seguimiento de tickets
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Clonar y configurar
|
||||
git clone <repo>
|
||||
cd ServiceManagerWeb
|
||||
cp .env.example .env
|
||||
|
||||
# Levantar servicios
|
||||
docker-compose up -d
|
||||
|
||||
# Verificar estado
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
## URLs por Defecto
|
||||
|
||||
- Frontend Clientes: http://localhost:3000
|
||||
- Frontend Interno: http://localhost:3001
|
||||
- API Backend: http://localhost:8000
|
||||
- API Docs: http://localhost:8000/docs
|
||||
- Adminer (DB): http://localhost:8080
|
||||
|
||||
## Scripts de Desarrollo
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend
|
||||
python -m uvicorn app.main:app --reload --port 8000
|
||||
|
||||
# Frontend Cliente
|
||||
cd frontend-client
|
||||
npm run dev -- --port 3000
|
||||
|
||||
# Frontend Interno
|
||||
cd frontend-internal
|
||||
npm run dev -- --port 3001
|
||||
|
||||
# Workers
|
||||
cd workers
|
||||
celery -A app.worker worker --loglevel=info
|
||||
celery -A app.worker beat --loglevel=info
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Backend tests
|
||||
cd backend
|
||||
pytest
|
||||
|
||||
# Frontend tests
|
||||
cd frontend-client
|
||||
npm test
|
||||
cd ../frontend-internal
|
||||
npm test
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Error 500 en Login / Proxy Error
|
||||
|
||||
**Síntoma**: Error 500 al intentar hacer login, o error de proxy de Vite "connect ECONNREFUSED".
|
||||
|
||||
**Causa**: Configuración incorrecta de la comunicación entre servicios de Docker.
|
||||
|
||||
**Solución**:
|
||||
1. En desarrollo con Docker, los servicios usan nombres de servicio (no `localhost`)
|
||||
2. Verificar `vite.config.js`: el proxy debe apuntar a `http://backend:8000`
|
||||
3. Verificar `docker-compose.yml`: `PUBLIC_API_URL` debe ser `http://backend:8000`
|
||||
4. Después de cambios, reiniciar contenedor: `docker-compose restart frontend-internal`
|
||||
|
||||
**Nota**: Para desarrollo local sin Docker, cambiar el proxy a `http://localhost:8000`.
|
||||
|
||||
### Tenant Slug Incorrecto
|
||||
|
||||
**Síntoma**: Error de autenticación incluso con credenciales correctas.
|
||||
|
||||
**Causa**: El `tenant_slug` en el login no coincide con los tenants en la BD.
|
||||
|
||||
**Solución**:
|
||||
1. Verificar tenants existentes: `docker exec servicemanager-backend python check_tenants.py`
|
||||
2. Actualizar el tenant_slug en el código de login
|
||||
3. Tenants por defecto: `aduanasoft-demo`, `test-tenant`
|
||||
|
||||
### Credenciales de Prueba
|
||||
|
||||
```
|
||||
Email: admin@aduanasoft.com
|
||||
Password: admin123
|
||||
Tenant: aduanasoft-demo
|
||||
Role: ADMIN
|
||||
```
|
||||
|
||||
## Contribución
|
||||
|
||||
1. Fork del proyecto
|
||||
2. Crear feature branch (`git checkout -b feature/nueva-funcionalidad`)
|
||||
3. Commit cambios (`git commit -am 'Agregar nueva funcionalidad'`)
|
||||
4. Push a branch (`git push origin feature/nueva-funcionalidad`)
|
||||
5. Crear Pull Request
|
||||
|
||||
## Licencia
|
||||
|
||||
Propietario - Aduanasoft © 2026
|
||||
843
README.md
843
README.md
@@ -1,176 +1,755 @@
|
||||
# ServiceManagerWeb - Mesa de Ayuda B2B
|
||||
# ServiceManagerWeb — Mesa de Ayuda B2B
|
||||
|
||||
Sistema multi-tenant de Mesa de Ayuda/Soporte Técnico empresarial para Aduanasoft.
|
||||
> **Versión actual:** v1.15.1 — Módulo de reportes implementado
|
||||
>
|
||||
> Sistema multi-tenant de Mesa de Ayuda / Soporte Técnico empresarial desarrollado para Aduanasoft.
|
||||
> Arquitectura Modular Monolith con Clean Architecture, preparado para escalar a microservicios.
|
||||
|
||||
## Arquitectura
|
||||
---
|
||||
|
||||
- **Frontend**: SvelteKit + TypeScript (portal clientes + panel interno)
|
||||
- **Backend**: Python FastAPI + Pydantic v2
|
||||
- **Workers**: Celery + Redis (notificaciones, SLAs, jobs)
|
||||
- **BD**: PostgreSQL + Alembic migrations
|
||||
- **Auth**: JWT + Refresh tokens + 2FA opcional (TOTP)
|
||||
- **Infra**: Docker Compose local, preparado para producción
|
||||
## Tabla de Contenidos
|
||||
|
||||
## Estructura del Monorepo
|
||||
1. [Requisitos previos](#requisitos-previos)
|
||||
2. [Inicio rápido con Docker (recomendado)](#inicio-rápido-con-docker-recomendado)
|
||||
3. [Configuración de variables de entorno](#configuración-de-variables-de-entorno)
|
||||
4. [Cargar datos de prueba](#cargar-datos-de-prueba)
|
||||
5. [URLs y puertos por defecto](#urls-y-puertos-por-defecto)
|
||||
6. [Credenciales de prueba](#credenciales-de-prueba)
|
||||
7. [Desarrollo local sin Docker](#desarrollo-local-sin-docker)
|
||||
8. [Arquitectura del proyecto](#arquitectura-del-proyecto)
|
||||
9. [Roles y permisos](#roles-y-permisos)
|
||||
10. [Comandos útiles](#comandos-útiles)
|
||||
11. [Pruebas (testing)](#pruebas-testing)
|
||||
12. [Solución de problemas](#solución-de-problemas)
|
||||
13. [Contribución](#contribución)
|
||||
14. [Historial de versiones](#historial-de-versiones)
|
||||
|
||||
---
|
||||
|
||||
## Requisitos previos
|
||||
|
||||
Antes de clonar el proyecto, asegúrate de tener instalado:
|
||||
|
||||
| Herramienta | Versión mínima | Descarga |
|
||||
|-------------|---------------|---------|
|
||||
| **Git** | 2.x | https://git-scm.com/downloads |
|
||||
| **Docker Desktop** | 24.x | https://www.docker.com/products/docker-desktop |
|
||||
| **Docker Compose** | v2.x (incluido en Docker Desktop) | — |
|
||||
|
||||
> **Nota para desarrolladores que quieran editar código localmente (sin Docker):**
|
||||
> también necesitarás Python 3.11+ y Node.js 18+. Ver sección
|
||||
> [Desarrollo local sin Docker](#desarrollo-local-sin-docker).
|
||||
|
||||
### Verificar que Docker esté corriendo
|
||||
|
||||
```bash
|
||||
docker --version # Debe mostrar Docker version 24.x o superior
|
||||
docker compose version # Debe mostrar Docker Compose version v2.x
|
||||
```
|
||||
|
||||
Si `docker compose version` falla, prueba `docker-compose --version` (versión standalone).
|
||||
|
||||
---
|
||||
|
||||
## Inicio rápido con Docker (recomendado)
|
||||
|
||||
Este es el método más simple y funciona igual en **Windows, Linux y macOS**.
|
||||
Solo necesitas Docker Desktop instalado y corriendo.
|
||||
|
||||
### Paso 1 — Clonar el repositorio
|
||||
|
||||
```bash
|
||||
git clone https://git.aduanasoft.com/ADUANASOFT/service_manager.git
|
||||
cd service_manager
|
||||
```
|
||||
|
||||
### Paso 2 — Crear el archivo de variables de entorno
|
||||
|
||||
**Linux / macOS:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
Copy-Item .env.example .env
|
||||
```
|
||||
|
||||
**Windows (CMD):**
|
||||
```cmd
|
||||
copy .env.example .env
|
||||
```
|
||||
|
||||
> **Importante:** El archivo `.env` nunca se sube a git (está en `.gitignore`).
|
||||
> Para desarrollo local los valores del `.env.example` funcionan sin cambios.
|
||||
> En producción **debes** generar claves secretas únicas (ver sección de variables de entorno).
|
||||
|
||||
### Paso 3 — Levantar todos los servicios
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Este comando descarga las imágenes, construye los contenedores e inicia todo el stack.
|
||||
La primera vez tarda entre 3 y 8 minutos dependiendo de la conexión a internet.
|
||||
|
||||
> **Alternativa con herramientas de desarrollo** (Adminer, MailHog, Redis Commander):
|
||||
> ```bash
|
||||
> docker compose --profile dev up -d
|
||||
> ```
|
||||
|
||||
### Paso 4 — Verificar que todo esté funcionando
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Deberías ver todos los servicios con estado `Up` o `healthy`:
|
||||
|
||||
```
|
||||
NAME STATUS
|
||||
servicemanager-db Up (healthy)
|
||||
servicemanager-redis Up (healthy)
|
||||
servicemanager-backend Up (healthy)
|
||||
servicemanager-worker Up
|
||||
servicemanager-beat Up
|
||||
servicemanager-client-frontend Up
|
||||
servicemanager-internal-... Up
|
||||
servicemanager-nginx Up
|
||||
```
|
||||
|
||||
Si algún servicio muestra `Exit` o `Restarting`, revisa la sección
|
||||
[Solución de problemas](#solución-de-problemas).
|
||||
|
||||
### Paso 5 — Cargar datos de ejemplo (opcional pero recomendado)
|
||||
|
||||
```bash
|
||||
docker exec servicemanager-backend python /scripts/seed_data.py
|
||||
```
|
||||
|
||||
Esto crea el tenant de demostración, categorías, usuarios y tickets de prueba.
|
||||
|
||||
### ¡Listo! Abre el navegador
|
||||
|
||||
| Aplicación | URL |
|
||||
|------------|-----|
|
||||
| Portal de clientes | http://localhost:3000 |
|
||||
| Panel interno (staff) | http://localhost:3001 |
|
||||
| API REST | http://localhost:8000 |
|
||||
| Documentación API (Swagger) | http://localhost:8000/docs |
|
||||
| Documentación API (ReDoc) | http://localhost:8000/redoc |
|
||||
| Health check | http://localhost:8000/health |
|
||||
|
||||
> **Con perfil dev** activo también tendrás:
|
||||
> - Adminer (gestor visual de PostgreSQL): http://localhost:8080
|
||||
> - MailHog (pruebas de email): http://localhost:8025
|
||||
> - Redis Commander (inspector de Redis): http://localhost:8081
|
||||
|
||||
---
|
||||
|
||||
## Configuración de variables de entorno
|
||||
|
||||
El archivo `.env` controla todo el comportamiento de la aplicación.
|
||||
Copia `.env.example` como `.env` y revisa los valores siguientes:
|
||||
|
||||
### Variables críticas
|
||||
|
||||
| Variable | Descripción | Valor por defecto (dev) |
|
||||
|----------|-------------|-------------------------|
|
||||
| `SECRET_KEY` | Clave secreta general de Flask/FastAPI | _(cambiar en producción)_ |
|
||||
| `JWT_SECRET_KEY` | Clave para firmar tokens JWT | _(cambiar en producción)_ |
|
||||
| `DATABASE_URL` | Cadena de conexión a PostgreSQL | `postgresql+asyncpg://servicemanager:...@postgres:5432/servicemanager` |
|
||||
| `REDIS_URL` | URL de conexión a Redis | `redis://redis:6379/0` |
|
||||
| `ENVIRONMENT` | Entorno actual | `development` |
|
||||
| `DEBUG` | Modo debug (muestra errores detallados) | `true` |
|
||||
|
||||
### Generar claves seguras para producción
|
||||
|
||||
**Linux / macOS:**
|
||||
```bash
|
||||
openssl rand -base64 32 # Genera SECRET_KEY
|
||||
openssl rand -base64 32 # Genera JWT_SECRET_KEY
|
||||
```
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
[Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Maximum 256 }))
|
||||
```
|
||||
|
||||
> **Advertencia:** Nunca uses las claves del `.env.example` en producción.
|
||||
> Cambiar las claves en producción invalida todas las sesiones activas.
|
||||
|
||||
### Desarrollo local vs Docker
|
||||
|
||||
En `.env.example` las URLs apuntan a nombres de servicio Docker (`postgres`, `redis`, `backend`).
|
||||
Si ejecutas el backend directamente en tu máquina (sin Docker), cambia:
|
||||
|
||||
```dotenv
|
||||
# Para desarrollo local sin Docker:
|
||||
DATABASE_URL=postgresql+asyncpg://servicemanager:servicemanager123@localhost:5432/servicemanager
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
CELERY_BROKER_URL=redis://localhost:6379/0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cargar datos de prueba
|
||||
|
||||
El script `seed_data.py` crea datos iniciales en la base de datos.
|
||||
|
||||
**Con Docker (recomendado):**
|
||||
```bash
|
||||
docker exec servicemanager-backend python /scripts/seed_data.py
|
||||
```
|
||||
|
||||
**Sin Docker:**
|
||||
```bash
|
||||
cd backend
|
||||
python ../scripts/seed_data.py
|
||||
```
|
||||
|
||||
El script crea:
|
||||
- Tenant de demostración: `aduanasoft-demo`
|
||||
- Categorías de tickets (Soporte Técnico, Facturación, Incidentes Críticos, etc.)
|
||||
- Sistemas registrados
|
||||
- Usuarios de prueba con distintos roles
|
||||
|
||||
---
|
||||
|
||||
## URLs y puertos por defecto
|
||||
|
||||
| Servicio | Puerto | Descripción |
|
||||
|----------|--------|-------------|
|
||||
| Frontend Clientes | **3000** | Portal para usuarios clientes |
|
||||
| Frontend Interno | **3001** | Panel para staff (agentes, admins) |
|
||||
| Backend API | **8000** | FastAPI — endpoints REST |
|
||||
| PostgreSQL | **5432** | Base de datos (no exponer en producción) |
|
||||
| Redis | **6379** | Cache y broker Celery (no exponer en producción) |
|
||||
| Nginx | **80** | Reverse proxy |
|
||||
| Adminer *(perfil dev)* | **8080** | GUI para PostgreSQL |
|
||||
| MailHog *(perfil dev)* | **8025** | Capturador de emails en desarrollo |
|
||||
| Redis Commander *(perfil dev)* | **8081** | GUI para Redis |
|
||||
|
||||
### ¿Conflicto de puertos?
|
||||
|
||||
Si algún puerto ya está en uso en tu máquina, edita `docker-compose.yml` y cambia
|
||||
el número **izquierdo** del mapeo `host:container`. Por ejemplo, para backend en el 8080:
|
||||
|
||||
```yaml
|
||||
ports:
|
||||
- "8080:8000" # ahora accesible en localhost:8080
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Credenciales de prueba
|
||||
|
||||
Después de ejecutar el seed, puedes iniciar sesión con:
|
||||
|
||||
| Campo | Valor |
|
||||
|-------|-------|
|
||||
| Email | `admin@aduanasoft.com` |
|
||||
| Contraseña | `admin123` |
|
||||
| Tenant | `aduanasoft-demo` |
|
||||
| Rol | `ADMIN` |
|
||||
|
||||
> Otros usuarios creados por el seed tienen el mismo sufijo de contraseña (`123`).
|
||||
> Revisa `scripts/seed_data.py` para ver la lista completa.
|
||||
|
||||
---
|
||||
|
||||
## Desarrollo local sin Docker
|
||||
|
||||
Útil cuando necesitas depurar el código con breakpoints o acelerar el ciclo de desarrollo.
|
||||
Requiere que **PostgreSQL y Redis sí corran en Docker** (o instalación nativa).
|
||||
|
||||
### Requisitos adicionales
|
||||
|
||||
| Herramienta | Versión | Descarga |
|
||||
|------------|---------|---------|
|
||||
| Python | 3.11 o 3.12 | https://www.python.org/downloads/ |
|
||||
| Node.js (con npm) | 18 LTS | https://nodejs.org/ |
|
||||
| pip | incluido con Python | — |
|
||||
|
||||
### Iniciar solo la base de datos y Redis
|
||||
|
||||
```bash
|
||||
docker compose up -d postgres redis
|
||||
```
|
||||
|
||||
### Backend (FastAPI)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Crear entorno virtual (solo la primera vez)
|
||||
python -m venv ../.venv
|
||||
|
||||
# Activar entorno virtual
|
||||
# Linux / macOS:
|
||||
source ../.venv/bin/activate
|
||||
# Windows (PowerShell):
|
||||
..\.venv\Scripts\Activate.ps1
|
||||
# Windows (CMD):
|
||||
..\.venv\Scripts\activate.bat
|
||||
|
||||
# Instalar dependencias (solo la primera vez o cuando cambie requirements.txt)
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Ejecutar migraciones de base de datos
|
||||
alembic upgrade head
|
||||
|
||||
# Iniciar servidor de desarrollo
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
> Si `uvicorn` no se encuentra, asegúrate de que el entorno virtual está activado
|
||||
> (`(.venv)` debe aparecer en tu terminal).
|
||||
|
||||
### Frontend Clientes
|
||||
|
||||
```bash
|
||||
cd frontend-client
|
||||
|
||||
# Instalar dependencias (solo la primera vez)
|
||||
npm install
|
||||
|
||||
# Iniciar servidor de desarrollo en puerto 3000
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Frontend Interno (staff)
|
||||
|
||||
```bash
|
||||
cd frontend-internal
|
||||
|
||||
# Instalar dependencias (solo la primera vez)
|
||||
npm install
|
||||
|
||||
# Iniciar servidor de desarrollo en puerto 3001
|
||||
npm run dev
|
||||
```
|
||||
|
||||
> Los dos frontends tienen puertos distintos (3000 y 3001) para que no haya conflicto
|
||||
> cuando corren al mismo tiempo.
|
||||
|
||||
### Workers Celery (opcional en desarrollo)
|
||||
|
||||
Necesario solo si desarrollas funcionalidades de notificaciones o SLAs automáticos.
|
||||
|
||||
```bash
|
||||
cd workers
|
||||
|
||||
# Activar el mismo entorno virtual del backend:
|
||||
# Linux / macOS:
|
||||
source ../.venv/bin/activate
|
||||
# Windows:
|
||||
..\.venv\Scripts\Activate.ps1
|
||||
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Worker principal
|
||||
celery -A app.celery worker --loglevel=info
|
||||
|
||||
# Scheduler de tareas periódicas (en otra terminal)
|
||||
celery -A app.celery beat --loglevel=info --schedule=/tmp/celerybeat-schedule
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Arquitectura del proyecto
|
||||
|
||||
```
|
||||
ServiceManagerWeb/
|
||||
├── backend/ # FastAPI app
|
||||
├── frontend-client/ # SvelteKit app para clientes
|
||||
├── frontend-internal/ # SvelteKit app para staff interno
|
||||
├── workers/ # Celery tasks
|
||||
├── db/ # Migrations y esquemas
|
||||
├── docker/ # Dockerfiles específicos
|
||||
├── docs/ # Documentación adicional
|
||||
├── scripts/ # Scripts de desarrollo/despliegue
|
||||
├── docker-compose.yml # Orquestación completa
|
||||
└── .env.example # Variables de entorno
|
||||
├── backend/ # Aplicación FastAPI (Python 3.11)
|
||||
│ ├── app/
|
||||
│ │ ├── main.py # Punto de entrada, lifespan, middlewares
|
||||
│ │ ├── api/v1/
|
||||
│ │ │ ├── router.py # Registro de todos los routers
|
||||
│ │ │ └── endpoints/ # Endpoints REST por dominio
|
||||
│ │ ├── core/ # Config, seguridad, base de datos, caché
|
||||
│ │ ├── models/ # Modelos SQLAlchemy (ORM)
|
||||
│ │ ├── services/ # Lógica de negocio
|
||||
│ │ └── middleware/ # Tenant context, Correlation ID
|
||||
│ ├── migrations/ # Migraciones Alembic
|
||||
│ ├── tests/ # Pruebas backend
|
||||
│ └── requirements.txt # Dependencias Python
|
||||
│
|
||||
├── frontend-client/ # Portal de clientes (SvelteKit + TypeScript)
|
||||
│ └── src/routes/ # Páginas: login, tickets, perfil
|
||||
│
|
||||
├── frontend-internal/ # Panel de staff (SvelteKit + TypeScript)
|
||||
│ └── src/routes/ # Páginas: dashboard, tickets, reportes, auditoría
|
||||
│
|
||||
├── workers/ # Tareas asíncronas Celery
|
||||
│ └── app/tasks/ # email_tasks.py, sla_tasks.py, etc.
|
||||
│
|
||||
├── docker/ # Dockerfiles y configuración Nginx
|
||||
├── db/ # schema.sql inicial
|
||||
├── docs/ # Documentación técnica adicional
|
||||
├── scripts/ # seed_data.py, setup-dev.sh, etc.
|
||||
├── docker-compose.yml # Orquestación completa
|
||||
└── .env.example # Plantilla de variables de entorno
|
||||
```
|
||||
|
||||
## Stack Tecnológico
|
||||
### Stack tecnológico
|
||||
|
||||
### Backend (Python)
|
||||
- FastAPI (async)
|
||||
- Pydantic v2
|
||||
- SQLAlchemy 2.0 (async)
|
||||
- Alembic (migrations)
|
||||
- Argon2 (hashing passwords)
|
||||
- PyJWT
|
||||
- Celery + Redis
|
||||
**Backend:** Python 3.11 · FastAPI · Pydantic v2 · SQLAlchemy 2.0 (async) · Alembic · Argon2 · PyJWT · Celery · Redis
|
||||
|
||||
### Frontend (JavaScript/TypeScript)
|
||||
- SvelteKit
|
||||
- TypeScript
|
||||
- TailwindCSS
|
||||
- shadcn/ui o similar
|
||||
- Zod (validación)
|
||||
**Frontend:** Node.js 18 · SvelteKit · TypeScript · TailwindCSS · Zod
|
||||
|
||||
### Infraestructura
|
||||
- PostgreSQL 15+
|
||||
- Redis 7+
|
||||
- Docker & Docker Compose
|
||||
- Nginx (reverse proxy)
|
||||
**Infraestructura:** PostgreSQL 15 · Redis 7 · Docker Compose · Nginx
|
||||
|
||||
## Dominios del Sistema
|
||||
---
|
||||
|
||||
1. **Auth**: Usuarios, roles, permisos, 2FA
|
||||
2. **Tenants**: Multi-tenancy, organizaciones
|
||||
3. **Tickets**: Gestión de tickets, estados, SLAs
|
||||
4. **Notifications**: Email, plantillas, logs
|
||||
5. **Audit**: Bitácora de acciones
|
||||
## Roles y permisos
|
||||
|
||||
## Roles de Usuario
|
||||
|
||||
### Internos (Staff)
|
||||
- `ADMIN`: Control total del sistema
|
||||
- `SUPPORT_MANAGER`: Gestión de equipos y SLAs
|
||||
- `AGENT`: Atención de tickets
|
||||
- `AUDITOR`: Solo lectura para auditoría
|
||||
### Personal interno (staff)
|
||||
| Rol | Descripción |
|
||||
|-----|-------------|
|
||||
| `ADMIN` | Control total del sistema |
|
||||
| `SUPPORT_MANAGER` | Gestión de equipos y configuración de SLAs |
|
||||
| `AGENT` | Atención y resolución de tickets |
|
||||
| `AUDITOR` | Solo lectura para revisiones y cumplimiento |
|
||||
|
||||
### Clientes
|
||||
- `CLIENT_ADMIN`: Gestión de organización cliente
|
||||
- `CLIENT_USER`: Creación y seguimiento de tickets
|
||||
| Rol | Descripción |
|
||||
|-----|-------------|
|
||||
| `CLIENT_ADMIN` | Gestión de su organización cliente |
|
||||
| `CLIENT_USER` | Creación y seguimiento de sus propios tickets |
|
||||
|
||||
## Quick Start
|
||||
---
|
||||
|
||||
## Comandos útiles
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```bash
|
||||
# Clonar y configurar
|
||||
git clone <repo>
|
||||
cd ServiceManagerWeb
|
||||
cp .env.example .env
|
||||
# Levantar todos los servicios (segundo plano)
|
||||
docker compose up -d
|
||||
|
||||
# Levantar servicios
|
||||
docker-compose up -d
|
||||
# Levantar con herramientas de desarrollo
|
||||
docker compose --profile dev up -d
|
||||
|
||||
# Verificar estado
|
||||
docker-compose ps
|
||||
# Ver logs en tiempo real de todos los servicios
|
||||
docker compose logs -f
|
||||
|
||||
# Ver logs de un servicio específico
|
||||
docker compose logs -f backend
|
||||
docker compose logs -f frontend-internal
|
||||
|
||||
# Detener todos los servicios (mantiene los datos)
|
||||
docker compose down
|
||||
|
||||
# Detener Y borrar todos los volúmenes (¡borra la base de datos!)
|
||||
docker compose down -v
|
||||
|
||||
# Reconstruir imagen de un servicio (después de cambiar Dockerfile o requirements)
|
||||
docker compose build backend
|
||||
docker compose up -d backend
|
||||
|
||||
# Reiniciar un servicio
|
||||
docker compose restart backend
|
||||
```
|
||||
|
||||
## URLs por Defecto
|
||||
|
||||
- Frontend Clientes: http://localhost:3000
|
||||
- Frontend Interno: http://localhost:3001
|
||||
- API Backend: http://localhost:8000
|
||||
- API Docs: http://localhost:8000/docs
|
||||
- Adminer (DB): http://localhost:8080
|
||||
|
||||
## Utilidades Administrativas
|
||||
|
||||
Para gestión y debugging de la base de datos, usa el script consolidado:
|
||||
### Base de datos (Alembic)
|
||||
|
||||
```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
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
# Aplicar todas las migraciones pendientes
|
||||
cd backend
|
||||
python -m uvicorn app.main:app --reload --port 8000
|
||||
alembic upgrade head
|
||||
|
||||
# Frontend Cliente
|
||||
cd frontend-client
|
||||
npm run dev -- --port 3000
|
||||
# Ver estado de migraciones
|
||||
alembic current
|
||||
|
||||
# Frontend Interno
|
||||
cd frontend-internal
|
||||
npm run dev -- --port 3001
|
||||
# Revertir última migración
|
||||
alembic downgrade -1
|
||||
|
||||
# Workers
|
||||
cd workers
|
||||
celery -A app.worker worker --loglevel=info
|
||||
celery -A app.worker beat --loglevel=info
|
||||
# Crear nueva migración (después de modificar models/)
|
||||
alembic revision --autogenerate -m "nombre descriptivo del cambio"
|
||||
|
||||
# Con Docker:
|
||||
docker exec servicemanager-backend alembic upgrade head
|
||||
```
|
||||
|
||||
## Testing
|
||||
### Calidad de código
|
||||
|
||||
```bash
|
||||
# Backend tests
|
||||
cd backend
|
||||
|
||||
# Linter y auto-fix
|
||||
ruff check . --fix
|
||||
|
||||
# Formateador
|
||||
black .
|
||||
|
||||
# Verificación de tipos
|
||||
mypy .
|
||||
|
||||
# Todo de una vez
|
||||
ruff check . --fix && black . && mypy .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pruebas (testing)
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Ejecutar todas las pruebas
|
||||
pytest
|
||||
|
||||
# Frontend tests
|
||||
cd frontend-client
|
||||
npm test
|
||||
cd ../frontend-internal
|
||||
npm test
|
||||
# Con cobertura detallada
|
||||
pytest --cov=app --cov-report=html
|
||||
|
||||
# Abrir reporte de cobertura (Linux/macOS)
|
||||
open htmlcov/index.html
|
||||
# Windows
|
||||
start htmlcov/index.html
|
||||
|
||||
# Prueba específica
|
||||
pytest tests/test_auth.py -v
|
||||
|
||||
# Con Docker
|
||||
docker exec servicemanager-backend pytest -v --cov=app
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend-internal # o frontend-client
|
||||
npm test # Ejecutar una vez
|
||||
npm run test:watch # Modo observador
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Solución de problemas
|
||||
|
||||
### El backend no inicia — error en `DATABASE_URL`
|
||||
|
||||
**Síntoma:** El contenedor `servicemanager-backend` reinicia continuamente.
|
||||
|
||||
**Causa frecuente:** El archivo `.env` no existe o tiene `DATABASE_URL` apuntando a `localhost`
|
||||
en lugar del nombre del servicio Docker `postgres`.
|
||||
|
||||
**Solución:**
|
||||
```bash
|
||||
# Verificar que .env existe
|
||||
ls .env # Linux/macOS
|
||||
dir .env # Windows
|
||||
|
||||
# Si no existe, crearlo
|
||||
cp .env.example .env # Linux/macOS
|
||||
Copy-Item .env.example .env # Windows PowerShell
|
||||
|
||||
# Verificar el valor correcto en .env:
|
||||
# DATABASE_URL=postgresql+asyncpg://servicemanager:servicemanager123@postgres:5432/servicemanager
|
||||
# ^^^^^^^
|
||||
# Nombre de servicio Docker, NO localhost
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Error 500 en login / "connect ECONNREFUSED"
|
||||
|
||||
**Síntoma:** El frontend muestra error 500 al hacer login, o la consola del navegador
|
||||
muestra `ECONNREFUSED 127.0.0.1:8000`.
|
||||
|
||||
**Causa:** El proxy de Vite no encuentra el backend.
|
||||
|
||||
**Solución en Docker:** El proxy ya está configurado para usar `PUBLIC_API_URL`.
|
||||
Verifica en `docker-compose.yml` que `frontend-internal` y `frontend-client` tienen:
|
||||
```yaml
|
||||
environment:
|
||||
- PUBLIC_API_URL=http://backend:8000
|
||||
```
|
||||
Después reinicia:
|
||||
```bash
|
||||
docker compose restart frontend-internal frontend-client
|
||||
```
|
||||
|
||||
**Solución en desarrollo local:** Asegúrate de que el backend está corriendo:
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
# Debe responder: {"status": "ok", ...}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### El frontend-internal y frontend-client usan el mismo puerto localmente
|
||||
|
||||
**Síntoma:** Al correr ambos frontends sin Docker, uno de los dos falla
|
||||
con `Port 3000 is already in use`.
|
||||
|
||||
**Solución:**
|
||||
- `frontend-client` → usa el puerto **3000** (por defecto con `npm run dev`)
|
||||
- `frontend-internal` → usa el puerto **3001** (configurado en `vite.config.js`)
|
||||
|
||||
Nunca hay conflicto si los iniciaste con `npm run dev` en cada carpeta por separado.
|
||||
Si aún hay conflicto, mata el proceso en ese puerto:
|
||||
|
||||
```bash
|
||||
# Linux / macOS
|
||||
lsof -ti:3000 | xargs kill -9
|
||||
|
||||
# Windows (PowerShell)
|
||||
Get-Process -Id (Get-NetTCPConnection -LocalPort 3000).OwningProcess | Stop-Process -Force
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### El tenant slug es incorrecto al hacer login
|
||||
|
||||
**Síntoma:** Login falla con "credenciales inválidas" aunque el email y contraseña son correctos.
|
||||
|
||||
**Causa:** El campo `tenant_slug` no corresponde a ningún tenant en la base de datos.
|
||||
|
||||
**Solución:**
|
||||
```bash
|
||||
# Ver los tenants disponibles
|
||||
docker exec servicemanager-backend python -c "
|
||||
import asyncio
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy import text
|
||||
import os
|
||||
async def main():
|
||||
engine = create_async_engine(os.environ['DATABASE_URL'])
|
||||
async with AsyncSession(engine) as s:
|
||||
result = await s.execute(text('SELECT slug, name FROM tenants'))
|
||||
for row in result:
|
||||
print(row)
|
||||
asyncio.run(main())
|
||||
"
|
||||
```
|
||||
Tenant por defecto (después del seed): **`aduanasoft-demo`**
|
||||
|
||||
---
|
||||
|
||||
### Puerto ocupado — cambiar puertos de los servicios
|
||||
|
||||
Edita `docker-compose.yml` y modifica **solo el número izquierdo** del mapeo de puertos:
|
||||
|
||||
```yaml
|
||||
# Ejemplo: mover el backend al puerto 9000
|
||||
backend:
|
||||
ports:
|
||||
- "9000:8000" # accesible en localhost:9000
|
||||
|
||||
# Ejemplo: mover el frontend al puerto 4000
|
||||
frontend-client:
|
||||
ports:
|
||||
- "4000:3000" # accesible en localhost:4000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Migraciones fallidas — `alembic upgrade head` da error
|
||||
|
||||
```bash
|
||||
# Verificar el estado actual
|
||||
docker exec servicemanager-backend alembic current
|
||||
|
||||
# Si hay conflicto, hacer downgrade hasta la base y volver a subir
|
||||
docker exec servicemanager-backend alembic downgrade base
|
||||
docker exec servicemanager-backend alembic upgrade head
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Módulo Python no encontrado (`ModuleNotFoundError`)
|
||||
|
||||
**Con Docker:** El módulo no está en `requirements.txt` o la imagen no fue reconstruida.
|
||||
```bash
|
||||
# Reconstruir la imagen del backend
|
||||
docker compose build backend
|
||||
docker compose up -d backend
|
||||
```
|
||||
|
||||
**Local:** El entorno virtual no está activado.
|
||||
```bash
|
||||
# Verificar que el venv está activo (debe aparecer (.venv) en el prompt)
|
||||
which python # Linux/macOS — debe apuntar a .venv/
|
||||
# Windows:
|
||||
where python # debe apuntar a .venv\Scripts\python.exe
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `npm: command not found` o versión de Node incorrecta
|
||||
|
||||
```bash
|
||||
node --version # Debe ser v18.x o superior
|
||||
npm --version # Debe ser 9.x o superior
|
||||
```
|
||||
|
||||
Si Node no está instalado, descárgalo desde https://nodejs.org/ (elige "LTS").
|
||||
|
||||
En macOS con Homebrew:
|
||||
```bash
|
||||
brew install node@18
|
||||
```
|
||||
|
||||
En Linux (Ubuntu/Debian):
|
||||
```bash
|
||||
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `docker-compose` no se reconoce como comando
|
||||
|
||||
En versiones modernas de Docker Desktop, el comando es `docker compose` (con espacio, sin guion).
|
||||
Si tienes instalación separada de Docker Compose v1, usa `docker-compose` (con guion).
|
||||
|
||||
---
|
||||
|
||||
### Logs de los contenedores
|
||||
|
||||
```bash
|
||||
# Ver qué está fallando
|
||||
docker compose logs backend --tail=50
|
||||
docker compose logs frontend-internal --tail=50
|
||||
docker compose logs postgres --tail=20
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contribución
|
||||
|
||||
1. Fork del proyecto
|
||||
2. Crear feature branch (`git checkout -b feature/nueva-funcionalidad`)
|
||||
3. Commit cambios (`git commit -am 'Agregar nueva funcionalidad'`)
|
||||
4. Push a branch (`git push origin feature/nueva-funcionalidad`)
|
||||
5. Crear Pull Request
|
||||
1. Haz fork del proyecto
|
||||
2. Crea una rama de funcionalidad: `git checkout -b feature/nombre-funcionalidad`
|
||||
3. Realiza tus cambios siguiendo las convenciones del proyecto
|
||||
4. Ejecuta las pruebas: `pytest` y el linter: `ruff check .`
|
||||
5. Haz commit con un mensaje descriptivo: `git commit -m "feat: agregar exportación a CSV"`
|
||||
6. Sube tu rama: `git push origin feature/nombre-funcionalidad`
|
||||
7. Abre un Pull Request hacia `main`
|
||||
|
||||
### Convenciones de nombres
|
||||
|
||||
- **Modelos**: `PascalCase` → `User`, `Ticket`, `TenantOrganization`
|
||||
- **Endpoints (URL)**: `kebab-case` → `/api/v1/user-management/`
|
||||
- **Componentes Svelte**: `PascalCase.svelte` → `TicketCard.svelte`
|
||||
- **Stores**: `camelCase` → `ticketStore.ts`
|
||||
|
||||
---
|
||||
|
||||
## Historial de versiones
|
||||
|
||||
| Versión | Descripción |
|
||||
|---------|-------------|
|
||||
| **v1.15.1** | Módulo de reportes implementado |
|
||||
| v1.14.x | Mejoras al módulo de auditoría |
|
||||
| v1.13.x | Sistema de SLAs automático |
|
||||
| v1.12.x | Notificaciones por email |
|
||||
| v1.0.0 | MVP inicial — tickets, tenants, autenticación |
|
||||
|
||||
---
|
||||
|
||||
## Licencia
|
||||
|
||||
Propietario - Aduanasoft © 2026
|
||||
Propietario — Aduanasoft © 2026. Todos los derechos reservados.
|
||||
@@ -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] Validation con Pydantic v2
|
||||
|
||||
### Rate limiting (login)
|
||||
|
||||
El endpoint `/{API_VERSION}/auth/login` incluye rate limiting (best-effort) usando Redis:
|
||||
|
||||
- Por IP: limita intentos totales por ventana
|
||||
- Por identidad: limita por `(tenant_id, email)` por ventana
|
||||
|
||||
Responde `429 Too Many Requests` con header `Retry-After`.
|
||||
|
||||
Variables de entorno (ver `app/core/config.py`):
|
||||
|
||||
- `RATE_LIMIT_ENABLED` (default: `true`)
|
||||
- `LOGIN_RATE_LIMIT_WINDOW_SECONDS` (default: `300`)
|
||||
- `LOGIN_RATE_LIMIT_IP_MAX_ATTEMPTS` (default: `30`)
|
||||
- `LOGIN_RATE_LIMIT_ID_MAX_ATTEMPTS` (default: `10`)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
@@ -157,8 +173,8 @@ Ver `.env.example` para todas las variables disponibles.
|
||||
- [x] CORS restrictivo
|
||||
- [x] Input validation con Pydantic
|
||||
- [x] SQL injection protection (SQLAlchemy)
|
||||
- [x] Rate limiting (TODO: implementar)
|
||||
- [x] File upload validation (TODO: implementar)
|
||||
- [x] Rate limiting (login)
|
||||
- [x] File upload validation (extensión + firma básica + tamaño + streaming)
|
||||
- [x] XSS protection (headers en nginx)
|
||||
|
||||
## Próximos pasos
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from typing import Optional
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from starlette.requests import Request
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import jwt, JWTError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -13,12 +15,52 @@ from app.models.tenant import Tenant
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Define OAuth2 scheme here or import from auth if needed.
|
||||
# Defining here creates a separate instance which is fine as they share config.
|
||||
# Ideally auth.py should import from here, but modifying auth.py is risky now.
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/{settings.API_VERSION}/auth/login")
|
||||
# Esquema OAuth2 centralizado — auth.py importa desde aquí
|
||||
# Soporta: 1) Authorization: Bearer header (Swagger/API clients)
|
||||
# 2) Cookie access_token HttpOnly (apps web)
|
||||
_bearer_scheme = OAuth2PasswordBearer(
|
||||
tokenUrl=f"/{settings.API_VERSION}/auth/login",
|
||||
auto_error=False,
|
||||
)
|
||||
|
||||
|
||||
async def oauth2_scheme(
|
||||
request: Request,
|
||||
bearer_token: Optional[str] = Depends(_bearer_scheme),
|
||||
) -> str:
|
||||
"""Extrae JWT desde header Authorization (prioridad) o cookie del frontend correcto.
|
||||
|
||||
Usa el header X-App para seleccionar la cookie:
|
||||
- X-App: internal → solo 'internal_access_token'
|
||||
- X-App: client → solo 'client_access_token'
|
||||
- sin header → prueba ambas (compatibilidad con Swagger/CLI)
|
||||
"""
|
||||
if bearer_token:
|
||||
return bearer_token
|
||||
|
||||
app_hint = request.headers.get("X-App", "").lower()
|
||||
if app_hint == "internal":
|
||||
token = request.cookies.get("internal_access_token")
|
||||
elif app_hint == "client":
|
||||
token = request.cookies.get("client_access_token")
|
||||
else:
|
||||
# Fallback para Swagger, tests y clientes sin header
|
||||
token = (
|
||||
request.cookies.get("internal_access_token")
|
||||
or request.cookies.get("client_access_token")
|
||||
)
|
||||
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
request: Request,
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> User:
|
||||
@@ -45,6 +87,16 @@ async def get_current_user(
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=400, detail="Inactive user")
|
||||
|
||||
# Enforce that tenant header (if present) matches the authenticated user's tenant.
|
||||
# Roles globales (is_global) pueden operar en cualquier tenant → omitir chequeo.
|
||||
# Roles de cliente (is_client) deben coincidir con su propio tenant.
|
||||
request_tenant_id = getattr(getattr(request, "state", None), "tenant_id", None)
|
||||
if request_tenant_id and user.role.is_client and str(user.tenant_id) != str(request_tenant_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Tenant header does not match authenticated user",
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@@ -1,15 +1,65 @@
|
||||
"""Schemas package initialization."""
|
||||
|
||||
from .auth import (
|
||||
LoginRequest, LoginResponse, RefreshTokenRequest, TokenResponse,
|
||||
TwoFactorStatusResponse, TwoFactorSetupResponse,
|
||||
TwoFactorEnableRequest, TwoFactorEnableResponse, TwoFactorDisableRequest,
|
||||
ChangePasswordRequest, ForgotPasswordRequest, ResetPasswordRequest,
|
||||
)
|
||||
from .tenant import TenantBase, TenantCreate, TenantUpdate, TenantResponse
|
||||
from .user import UserCreate, UserUpdate, UserResponse
|
||||
from .category import CategoryCreate, CategoryUpdate, CategoryResponse
|
||||
from .system import SystemCreate, SystemUpdate, SystemResponse
|
||||
from .ticket import (
|
||||
TicketCreate,
|
||||
TicketUpdate,
|
||||
TicketResponse,
|
||||
TicketCloseRequest,
|
||||
CommentCreate,
|
||||
CommentResponse,
|
||||
)
|
||||
from .client_profile import (
|
||||
ClientProfileCreate,
|
||||
ClientProfileUpdate,
|
||||
ClientProfileUpdate,
|
||||
ClientProfileResponse,
|
||||
ClientProfileSummary
|
||||
ClientProfileSummary,
|
||||
)
|
||||
from .audit import * # noqa: F401,F403
|
||||
from .sla import * # noqa: F401,F403
|
||||
|
||||
__all__ = [
|
||||
# Auth
|
||||
"LoginRequest",
|
||||
"LoginResponse",
|
||||
"RefreshTokenRequest",
|
||||
"TokenResponse",
|
||||
# Tenant
|
||||
"TenantBase",
|
||||
"TenantCreate",
|
||||
"TenantUpdate",
|
||||
"TenantResponse",
|
||||
# User
|
||||
"UserCreate",
|
||||
"UserUpdate",
|
||||
"UserResponse",
|
||||
# Category
|
||||
"CategoryCreate",
|
||||
"CategoryUpdate",
|
||||
"CategoryResponse",
|
||||
# System
|
||||
"SystemCreate",
|
||||
"SystemUpdate",
|
||||
"SystemResponse",
|
||||
# Ticket
|
||||
"TicketCreate",
|
||||
"TicketUpdate",
|
||||
"TicketResponse",
|
||||
"TicketCloseRequest",
|
||||
"CommentCreate",
|
||||
"CommentResponse",
|
||||
# Client Profile
|
||||
"ClientProfileCreate",
|
||||
"ClientProfileUpdate",
|
||||
"ClientProfileResponse",
|
||||
"ClientProfileSummary"
|
||||
"ClientProfileResponse",
|
||||
"ClientProfileSummary",
|
||||
]
|
||||
192
backend/app/api/schemas/audit.py
Normal file
192
backend/app/api/schemas/audit.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""
|
||||
Audit Schemas - ServiceManagerWeb
|
||||
|
||||
Schemas Pydantic para endpoints de auditoría
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field, UUID4
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class AuditLogBase(BaseModel):
|
||||
"""Schema base para audit logs."""
|
||||
action: str = Field(..., description="Acci├│n realizada (ej: ticket.create)")
|
||||
resource_type: str = Field(..., description="Tipo de recurso (ticket, user, etc.)")
|
||||
resource_id: Optional[UUID4] = Field(None, description="ID del recurso afectado")
|
||||
extra_metadata: Optional[Dict[str, Any]] = Field(None, description="Metadata adicional", alias="metadata")
|
||||
|
||||
|
||||
class AuditLogResponse(AuditLogBase):
|
||||
"""
|
||||
Schema de respuesta para audit logs.
|
||||
|
||||
Incluye toda la informaci├│n del log con datos del usuario.
|
||||
"""
|
||||
id: UUID4
|
||||
tenant_id: UUID4
|
||||
user_id: Optional[UUID4]
|
||||
|
||||
# Informaci├│n del usuario (si existe)
|
||||
user_email: Optional[str] = None
|
||||
user_name: Optional[str] = None
|
||||
user_role: Optional[str] = None
|
||||
|
||||
# Contexto de la acci├│n
|
||||
ip_address: Optional[str]
|
||||
user_agent: Optional[str]
|
||||
correlation_id: Optional[UUID4]
|
||||
|
||||
# Cambios realizados
|
||||
old_values: Optional[Dict[str, Any]]
|
||||
new_values: Optional[Dict[str, Any]]
|
||||
|
||||
# Timestamp
|
||||
created_at: datetime
|
||||
|
||||
# Display friendly
|
||||
action_display: str = Field(description="Acci├│n en formato amigable")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ===================================
|
||||
# SECURITY ANALYSIS SCHEMAS
|
||||
# ===================================
|
||||
|
||||
class SecurityThreatPattern(BaseModel):
|
||||
"""Patrón de amenaza detectado."""
|
||||
id: str = Field(description="ID único de la amenaza (pattern_id)")
|
||||
type: str = Field(description="Tipo de amenaza (brute_force, privilege_escalation, etc.)")
|
||||
severity: str = Field(description="Severidad: low, medium, high, critical")
|
||||
description: str = Field(description="Descripción de la amenaza")
|
||||
occurrences: int = Field(description="Número de ocurrencias")
|
||||
affected_ips: list[str] = Field(default=[], description="IPs involucradas")
|
||||
affected_users: list[str] = Field(default=[], description="Usuarios afectados")
|
||||
first_seen: datetime = Field(description="Primera ocurrencia")
|
||||
last_seen: datetime = Field(description="Última ocurrencia")
|
||||
recommended_action: str = Field(default="", description="Acción recomendada")
|
||||
|
||||
|
||||
class SecurityAnalysisResponse(BaseModel):
|
||||
"""Análisis completo de seguridad."""
|
||||
overall_risk_level: str = Field(description="Nivel de riesgo general: safe, low, medium, high, critical")
|
||||
total_threats_detected: int = Field(description="Total de amenazas detectadas")
|
||||
threats: list[SecurityThreatPattern] = Field(description="Lista de amenazas detectadas")
|
||||
analysis_period_hours: int = Field(description="Período de análisis en horas")
|
||||
generated_at: datetime = Field(description="Timestamp del análisis")
|
||||
|
||||
# Estadísticas de seguridad
|
||||
failed_login_attempts: int = Field(description="Intentos fallidos de login")
|
||||
suspicious_ips_count: int = Field(description="IPs sospechosas detectadas")
|
||||
critical_actions_count: int = Field(description="Acciones críticas realizadas")
|
||||
|
||||
# Opciones de acción
|
||||
recommended_actions: list[str] = Field(default=[], description="Acciones recomendadas")
|
||||
|
||||
|
||||
class SecurityActionRequest(BaseModel):
|
||||
"""Solicitud de acción de seguridad."""
|
||||
action_type: str = Field(description="Tipo de acción: block_ip, notify_admin, reset_password, etc.")
|
||||
target: str = Field(description="Objetivo de la acción (IP, email, etc.)")
|
||||
reason: str = Field(description="Razón de la acción")
|
||||
duration_minutes: Optional[int] = Field(None, description="Duración del bloqueo en minutos")
|
||||
|
||||
|
||||
class SecurityActionResponse(BaseModel):
|
||||
"""Respuesta de acción de seguridad."""
|
||||
success: bool = Field(description="Si la acción fue exitosa")
|
||||
message: str = Field(description="Mensaje descriptivo")
|
||||
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):
|
||||
"""
|
||||
Filtros para consulta de audit logs.
|
||||
|
||||
Permite filtrar por m├║ltiples criterios.
|
||||
"""
|
||||
# Paginaci├│n
|
||||
page: int = Field(default=1, ge=1, description="Número de página")
|
||||
per_page: int = Field(default=50, ge=1, le=100, description="Elementos por página")
|
||||
|
||||
# Filtros
|
||||
user_id: Optional[UUID4] = Field(None, description="Filtrar por usuario")
|
||||
action: Optional[str] = Field(None, description="Filtrar por acción específica")
|
||||
resource_type: Optional[str] = Field(None, description="Filtrar por tipo de recurso")
|
||||
resource_id: Optional[UUID4] = Field(None, description="Filtrar por ID de recurso")
|
||||
|
||||
# Rango de fechas
|
||||
date_from: Optional[datetime] = Field(None, description="Fecha inicio (ISO 8601)")
|
||||
date_to: Optional[datetime] = Field(None, description="Fecha fin (ISO 8601)")
|
||||
|
||||
# B├║squeda
|
||||
search: Optional[str] = Field(None, description="B├║squeda en acciones o recursos")
|
||||
|
||||
|
||||
class AuditLogStats(BaseModel):
|
||||
"""
|
||||
Estadísticas de auditoría.
|
||||
|
||||
Resumen de actividad del sistema.
|
||||
"""
|
||||
total_actions: int = Field(description="Total de acciones registradas")
|
||||
actions_today: int = Field(description="Acciones en las ├║ltimas 24 horas")
|
||||
actions_this_week: int = Field(description="Acciones en los últimos 7 días")
|
||||
critical_actions_today: int = Field(description="Acciones críticas hoy (delete, cambios sensibles)")
|
||||
|
||||
# Top acciones
|
||||
top_actions: Dict[str, int] = Field(description="Acciones más frecuentes")
|
||||
|
||||
# Top usuarios
|
||||
top_users: Dict[str, int] = Field(description="Usuarios más activos")
|
||||
|
||||
# Actividad por tipo de recurso
|
||||
by_resource_type: Dict[str, int] = Field(description="Acciones por tipo de recurso")
|
||||
|
||||
|
||||
class AuditLogListResponse(BaseModel):
|
||||
"""
|
||||
Respuesta paginada de audit logs.
|
||||
"""
|
||||
logs: list[AuditLogResponse]
|
||||
total: int = Field(description="Total de registros")
|
||||
page: int = Field(description="Página actual")
|
||||
per_page: int = Field(description="Registros por página")
|
||||
total_pages: int = Field(description="Total de páginas")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
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: Optional[str] = None
|
||||
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):
|
||||
"""Schema de respuesta para ClientProfile."""
|
||||
|
||||
id: Optional[uuid.UUID] = None
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
227
backend/app/api/schemas/reports.py
Normal file
227
backend/app/api/schemas/reports.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
Reports Schemas - ServiceManagerWeb
|
||||
|
||||
Schemas de respuesta para el módulo de reportes y estadísticas.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# ===================================
|
||||
# RESUMEN GENERAL
|
||||
# ===================================
|
||||
|
||||
class TicketsByStatus(BaseModel):
|
||||
"""Conteo de tickets agrupado por estado"""
|
||||
new: int = 0
|
||||
triage: int = 0
|
||||
in_progress: int = 0
|
||||
waiting_customer: int = 0
|
||||
resolved: int = 0
|
||||
closed: int = 0
|
||||
reopened: int = 0
|
||||
total: int = 0
|
||||
|
||||
|
||||
class TicketsByPriority(BaseModel):
|
||||
"""Conteo de tickets agrupado por prioridad"""
|
||||
low: int = 0
|
||||
medium: int = 0
|
||||
high: int = 0
|
||||
urgent: int = 0
|
||||
total: int = 0
|
||||
|
||||
|
||||
class ReportSummaryResponse(BaseModel):
|
||||
"""Resumen ejecutivo del período seleccionado"""
|
||||
period_start: datetime
|
||||
period_end: datetime
|
||||
generated_at: datetime
|
||||
|
||||
# Totales del período
|
||||
total_tickets: int
|
||||
open_tickets: int # Tickets sin resolver
|
||||
resolved_tickets: int # Tickets resueltos o cerrados
|
||||
avg_resolution_hours: Optional[float] # Promedio de horas para resolver
|
||||
avg_first_response_hours: Optional[float] # Promedio de horas para primera respuesta
|
||||
|
||||
# Satisfacción del cliente
|
||||
avg_rating: Optional[float] # Promedio de calificación (1-5)
|
||||
total_rated: int # Cuántos tickets tienen calificación
|
||||
|
||||
# Desglose por estado y prioridad
|
||||
by_status: TicketsByStatus
|
||||
by_priority: TicketsByPriority
|
||||
|
||||
# Comparación vs período anterior
|
||||
tickets_change_pct: Optional[float] # % cambio vs período anterior
|
||||
resolution_change_pct: Optional[float] # % cambio en tasa de resolución
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ===================================
|
||||
# RENDIMIENTO POR AGENTE
|
||||
# ===================================
|
||||
|
||||
class AgentReportRow(BaseModel):
|
||||
"""Estadísticas de un agente específico"""
|
||||
agent_id: str
|
||||
agent_name: str
|
||||
agent_email: str
|
||||
total_assigned: int # Total asignados en el período
|
||||
resolved: int # Cuántos resolvió
|
||||
open: int # Cuántos siguen abiertos
|
||||
resolution_rate: float # Porcentaje de resolución (0-100)
|
||||
avg_resolution_hours: Optional[float] # Promedio de horas para resolver
|
||||
avg_rating: Optional[float] # Calificación promedio (1-5)
|
||||
total_rated: int # Cuántos tickets calificaron al agente
|
||||
urgent_handled: int # Urgentes atendidos
|
||||
|
||||
|
||||
class AgentReportResponse(BaseModel):
|
||||
"""Reporte de rendimiento por agente"""
|
||||
period_start: datetime
|
||||
period_end: datetime
|
||||
generated_at: datetime
|
||||
agents: List[AgentReportRow]
|
||||
total_agents: int
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ===================================
|
||||
# TICKETS POR CATEGORÍA
|
||||
# ===================================
|
||||
|
||||
class CategoryReportRow(BaseModel):
|
||||
"""Estadísticas de una categoría"""
|
||||
category_id: str
|
||||
category_name: str
|
||||
total_tickets: int
|
||||
open_tickets: int
|
||||
resolved_tickets: int
|
||||
avg_resolution_hours: Optional[float]
|
||||
sla_response_hours: int # SLA configurado para respuesta
|
||||
sla_resolution_hours: int # SLA configurado para resolución
|
||||
sla_compliance_pct: float # % de tickets que cumplieron SLA de resolución
|
||||
|
||||
|
||||
class CategoryReportResponse(BaseModel):
|
||||
"""Reporte de tickets agrupado por categoría"""
|
||||
period_start: datetime
|
||||
period_end: datetime
|
||||
generated_at: datetime
|
||||
categories: List[CategoryReportRow]
|
||||
uncategorized_count: int
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ===================================
|
||||
# TICKETS POR CLIENTE (TENANT)
|
||||
# ===================================
|
||||
|
||||
class ClientReportRow(BaseModel):
|
||||
"""Estadísticas de un cliente (tenant)"""
|
||||
tenant_id: str
|
||||
tenant_name: str
|
||||
total_tickets: int
|
||||
open_tickets: int
|
||||
resolved_tickets: int
|
||||
urgent_tickets: int
|
||||
avg_resolution_hours: Optional[float]
|
||||
avg_rating: Optional[float]
|
||||
last_ticket_at: Optional[datetime]
|
||||
|
||||
|
||||
class ClientReportResponse(BaseModel):
|
||||
"""Reporte de tickets agrupado por cliente — solo ADMIN"""
|
||||
period_start: datetime
|
||||
period_end: datetime
|
||||
generated_at: datetime
|
||||
clients: List[ClientReportRow]
|
||||
total_clients: int
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ===================================
|
||||
# TENDENCIAS (TICKETS EN EL TIEMPO)
|
||||
# ===================================
|
||||
|
||||
class TrendDataPoint(BaseModel):
|
||||
"""Un punto de datos en la línea de tendencia"""
|
||||
date: str # Formato YYYY-MM-DD
|
||||
created: int # Tickets creados ese día
|
||||
resolved: int # Tickets resueltos ese día
|
||||
net_open: int # Diferencia: creados - resueltos
|
||||
|
||||
|
||||
class TrendsReportResponse(BaseModel):
|
||||
"""Evolución de tickets día a día"""
|
||||
period_start: datetime
|
||||
period_end: datetime
|
||||
generated_at: datetime
|
||||
data_points: List[TrendDataPoint]
|
||||
total_days: int
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ===================================
|
||||
# SATISFACCIÓN DEL CLIENTE (CSAT)
|
||||
# ===================================
|
||||
|
||||
class CSATDistribution(BaseModel):
|
||||
"""Distribución de calificaciones 1-5"""
|
||||
rating_1: int = 0
|
||||
rating_2: int = 0
|
||||
rating_3: int = 0
|
||||
rating_4: int = 0
|
||||
rating_5: int = 0
|
||||
|
||||
|
||||
class CSATReportResponse(BaseModel):
|
||||
"""Reporte de satisfacción del cliente"""
|
||||
period_start: datetime
|
||||
period_end: datetime
|
||||
generated_at: datetime
|
||||
avg_rating: Optional[float]
|
||||
total_rated: int
|
||||
total_tickets: int
|
||||
response_rate: float # % de tickets que recibieron calificación
|
||||
distribution: CSATDistribution
|
||||
by_category: List[Dict[str, Any]] # Promedio por categoría
|
||||
by_agent: List[Dict[str, Any]] # Promedio por agente
|
||||
recent_comments: List[Dict[str, Any]] = [] # Últimos comentarios de calificación
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ===================================
|
||||
# TICKETS POR SISTEMA AFECTADO
|
||||
# ===================================
|
||||
|
||||
class SystemReportRow(BaseModel):
|
||||
"""Estadísticas de un sistema afectado"""
|
||||
system_id: str
|
||||
system_name: str
|
||||
total_tickets: int
|
||||
open_tickets: int
|
||||
resolved_tickets: int
|
||||
urgent_tickets: int
|
||||
avg_resolution_hours: Optional[float]
|
||||
|
||||
|
||||
class SystemReportResponse(BaseModel):
|
||||
"""Reporte de tickets agrupado por sistema afectado"""
|
||||
period_start: datetime
|
||||
period_end: datetime
|
||||
generated_at: datetime
|
||||
systems: List[SystemReportRow]
|
||||
no_system_count: int # Tickets sin sistema asignado
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
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)
|
||||
517
backend/app/api/v1/audit_helpers.py
Normal file
517
backend/app/api/v1/audit_helpers.py
Normal file
@@ -0,0 +1,517 @@
|
||||
"""
|
||||
Audit Helpers - ServiceManagerWeb
|
||||
===================================
|
||||
Funciones auxiliares reutilizables para los endpoints de auditoría.
|
||||
|
||||
Este archivo contiene:
|
||||
- audit_log_to_dict: Convierte un modelo AuditLog a diccionario
|
||||
- apply_tenant_filter: Aplica filtro de tenant según permisos
|
||||
- get_count_stat: Cuenta registros con filtros opcionales (CORREGIDO)
|
||||
- get_top_items: Obtiene los items más frecuentes
|
||||
- detect_mass_deletions: Detecta eliminaciones masivas sospechosas
|
||||
- detect_brute_force: Detecta ataques de fuerza bruta
|
||||
- detect_privilege_escalation: Detecta escaladas de privilegios
|
||||
|
||||
CORRECCIÓN APLICADA en get_count_stat:
|
||||
La columna created_at en PostgreSQL es 'timestamp with time zone' (TIMESTAMPTZ),
|
||||
lo que significa que almacena y devuelve fechas CON información de timezone (+00).
|
||||
|
||||
El bug era que se comparaba un datetime naive (sin timezone) contra una columna
|
||||
TIMESTAMPTZ. PostgreSQL no puede comparar ambos tipos directamente, por lo que
|
||||
el filtro se ignoraba silenciosamente y los tres contadores devolvían el mismo
|
||||
valor (el total histórico completo sin ningún filtro de fecha).
|
||||
|
||||
La solución es garantizar que TODAS las fechas que se usen en queries tengan
|
||||
timezone info (aware datetime en UTC) usando _ensure_aware_utc().
|
||||
"""
|
||||
|
||||
from sqlalchemy import select, func, and_, or_, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Optional, Dict, List
|
||||
from datetime import datetime, timezone
|
||||
import uuid
|
||||
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CONVERSIÓN DE MODELOS
|
||||
# =============================================================================
|
||||
|
||||
def audit_log_to_dict(log: AuditLog) -> dict:
|
||||
"""
|
||||
Convierte un objeto AuditLog de SQLAlchemy a un diccionario plano
|
||||
compatible con los schemas de respuesta de Pydantic.
|
||||
|
||||
Incluye los datos del usuario relacionado si están cargados
|
||||
(requiere que la query use selectinload(AuditLog.user)).
|
||||
"""
|
||||
log_dict = {
|
||||
"id": log.id,
|
||||
"tenant_id": log.tenant_id,
|
||||
"user_id": log.user_id,
|
||||
"action": log.action,
|
||||
"resource_type": log.resource_type,
|
||||
"resource_id": log.resource_id,
|
||||
# ip_address puede ser un objeto especial de PostgreSQL, convertir a string
|
||||
"ip_address": str(log.ip_address) if log.ip_address else None,
|
||||
"user_agent": log.user_agent,
|
||||
"correlation_id": log.correlation_id,
|
||||
"old_values": log.old_values,
|
||||
"new_values": log.new_values,
|
||||
# extra_metadata evita conflicto con la palabra reservada 'metadata'
|
||||
"metadata": log.extra_metadata,
|
||||
"created_at": log.created_at,
|
||||
"action_display": log.action_display,
|
||||
# Campos del usuario (se llenan abajo si la relación está cargada)
|
||||
"user_email": None,
|
||||
"user_name": None,
|
||||
"user_role": None,
|
||||
}
|
||||
|
||||
# Solo agregar datos del usuario si la relación fue cargada en la query
|
||||
if log.user:
|
||||
log_dict["user_email"] = log.user.email
|
||||
log_dict["user_name"] = log.user.full_name
|
||||
# El rol puede ser un Enum de Python o un string, manejar ambos casos
|
||||
log_dict["user_role"] = (
|
||||
log.user.role.value
|
||||
if hasattr(log.user.role, 'value')
|
||||
else str(log.user.role)
|
||||
)
|
||||
|
||||
return log_dict
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FILTRO DE MULTI-TENANCY
|
||||
# =============================================================================
|
||||
|
||||
def apply_tenant_filter(
|
||||
query,
|
||||
current_user: User,
|
||||
current_tenant: Tenant,
|
||||
all_tenants: bool = False,
|
||||
specific_tenant_id: Optional[uuid.UUID] = None
|
||||
):
|
||||
"""
|
||||
Aplica el filtro de tenant a una query de SQLAlchemy según los
|
||||
permisos del usuario actual.
|
||||
|
||||
Reglas:
|
||||
- ADMIN y SUPPORT_MANAGER pueden ver todos los tenants si
|
||||
all_tenants=True, o filtrar por un tenant específico.
|
||||
- Cualquier otro rol solo puede ver los datos de su propio tenant.
|
||||
"""
|
||||
can_see_all_tenants = current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]
|
||||
|
||||
if all_tenants and can_see_all_tenants:
|
||||
# Usuario privilegiado pidiendo ver todos los tenants → sin filtro
|
||||
return query
|
||||
elif specific_tenant_id and can_see_all_tenants:
|
||||
# Usuario privilegiado pidiendo un tenant específico
|
||||
return query.where(AuditLog.tenant_id == specific_tenant_id)
|
||||
else:
|
||||
# Cualquier otro caso → solo ver el propio tenant
|
||||
return query.where(AuditLog.tenant_id == current_tenant.id)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# UTILIDAD DE FECHAS
|
||||
# =============================================================================
|
||||
|
||||
def _ensure_aware_utc(dt: datetime) -> datetime:
|
||||
"""
|
||||
Garantiza que un datetime tenga información de timezone en UTC.
|
||||
|
||||
PROBLEMA QUE RESUELVE:
|
||||
La columna created_at en PostgreSQL es 'timestamp with time zone'
|
||||
(TIMESTAMPTZ). Cuando se compara con un datetime naive (sin timezone),
|
||||
PostgreSQL no puede hacer la comparación correctamente y el filtro
|
||||
de fecha se ignora silenciosamente, devolviendo todos los registros
|
||||
sin importar la fecha.
|
||||
|
||||
SOLUCIÓN:
|
||||
Siempre convertir las fechas a aware UTC antes de usarlas en queries.
|
||||
|
||||
Casos que maneja:
|
||||
- datetime naive (sin tzinfo): agrega UTC como timezone
|
||||
- datetime aware (con tzinfo): convierte a UTC si es otra zona horaria
|
||||
|
||||
Ejemplos:
|
||||
datetime(2026, 2, 24, 15, 0, 0) → datetime(2026, 2, 24, 15, 0, 0, tzinfo=UTC)
|
||||
datetime(2026, 2, 24, 9, 0, 0, tzinfo=CST) → datetime(2026, 2, 24, 15, 0, 0, tzinfo=UTC)
|
||||
"""
|
||||
if dt.tzinfo is None:
|
||||
# Datetime naive → asumir que ya es UTC y agregarle timezone info
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
# Datetime aware → convertir a UTC (por si viene en otra zona horaria)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CONTADORES DE ESTADÍSTICAS
|
||||
# =============================================================================
|
||||
|
||||
async def get_count_stat(
|
||||
db: AsyncSession,
|
||||
tenant_id: Optional[uuid.UUID] = None,
|
||||
date_from: Optional[datetime] = None,
|
||||
action_filter=None
|
||||
) -> int:
|
||||
"""
|
||||
Cuenta registros de AuditLog con filtros opcionales.
|
||||
|
||||
Usado por get_audit_stats() para calcular:
|
||||
- total_actions: Sin date_from → cuenta todos los registros
|
||||
- actions_today: date_from = now - 24h → registros del día
|
||||
- actions_this_week: date_from = now - 7d → registros de la semana
|
||||
|
||||
CORRECCIÓN: Las fechas se convierten a aware UTC con _ensure_aware_utc()
|
||||
antes de usarlas en la query, para que sean compatibles con la columna
|
||||
TIMESTAMPTZ de PostgreSQL y el filtro se aplique correctamente.
|
||||
|
||||
Args:
|
||||
db: Sesión de base de datos
|
||||
tenant_id: Si se especifica, filtra por ese tenant
|
||||
date_from: Si se especifica, solo cuenta registros desde esa fecha
|
||||
action_filter: Condición SQLAlchemy adicional opcional
|
||||
|
||||
Returns:
|
||||
Número entero de registros que cumplen los filtros
|
||||
"""
|
||||
query = select(func.count()).select_from(AuditLog)
|
||||
|
||||
if tenant_id:
|
||||
query = query.where(AuditLog.tenant_id == tenant_id)
|
||||
|
||||
if date_from:
|
||||
# CORRECCIÓN: convertir a aware UTC para compatibilidad con TIMESTAMPTZ
|
||||
# Sin esto, el filtro se ignora y los tres contadores son idénticos
|
||||
date_from_aware = _ensure_aware_utc(date_from)
|
||||
query = query.where(AuditLog.created_at >= date_from_aware)
|
||||
|
||||
if action_filter is not None:
|
||||
query = query.where(action_filter)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ITEMS MÁS FRECUENTES
|
||||
# =============================================================================
|
||||
|
||||
async def get_top_items(
|
||||
db: AsyncSession,
|
||||
field,
|
||||
tenant_id: Optional[uuid.UUID] = None,
|
||||
limit: int = 5,
|
||||
join_user: bool = False
|
||||
) -> Dict[str, int]:
|
||||
"""
|
||||
Obtiene los valores más frecuentes de un campo, ordenados por conteo.
|
||||
|
||||
Ejemplos de uso:
|
||||
- get_top_items(db, AuditLog.action, ...) → {"ticket.create": 45}
|
||||
- get_top_items(db, AuditLog.resource_type, ...) → {"ticket": 60}
|
||||
- get_top_items(db, None, ..., join_user=True) → {"admin@empresa.com": 40}
|
||||
|
||||
Args:
|
||||
db: Sesión de base de datos
|
||||
field: Campo de AuditLog por el que agrupar
|
||||
tenant_id: Si se especifica, filtra por ese tenant
|
||||
limit: Máximo de resultados a devolver (por defecto 5)
|
||||
join_user: Si True, agrupa por email de usuario
|
||||
|
||||
Returns:
|
||||
Diccionario {valor: conteo} ordenado de mayor a menor
|
||||
"""
|
||||
if join_user:
|
||||
# Modo usuarios: hacer JOIN con tabla User y agrupar por email
|
||||
query = (
|
||||
select(User.email, func.count(AuditLog.id).label('count'))
|
||||
.join(User, AuditLog.user_id == User.id)
|
||||
)
|
||||
else:
|
||||
# Modo campo: agrupar por el campo especificado
|
||||
query = select(field, func.count(AuditLog.id).label('count'))
|
||||
|
||||
if tenant_id:
|
||||
query = query.where(AuditLog.tenant_id == tenant_id)
|
||||
|
||||
if join_user:
|
||||
query = query.group_by(User.email)
|
||||
else:
|
||||
query = query.group_by(field)
|
||||
|
||||
query = query.order_by(desc('count')).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return {row[0]: row[1] for row in result}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DETECTORES DE INCIDENTES DE SEGURIDAD
|
||||
# =============================================================================
|
||||
|
||||
def detect_mass_deletions(logs: List[AuditLog], now: datetime) -> List[dict]:
|
||||
"""
|
||||
Detecta patrones de eliminación masiva agrupando por usuario y día.
|
||||
|
||||
Lógica:
|
||||
- Agrupa todos los logs de eliminación por (usuario, día)
|
||||
- Si un usuario eliminó >= 3 recursos en un día, genera un incidente
|
||||
- La severidad escala según la cantidad:
|
||||
- >= 3 eliminaciones → medium
|
||||
- >= 5 eliminaciones → high
|
||||
- >= 10 eliminaciones → critical
|
||||
|
||||
El estado del incidente es:
|
||||
- "active": si la última eliminación fue hace menos de 24 horas
|
||||
- "resolved": si fue hace más de 24 horas
|
||||
"""
|
||||
# Agrupar eliminaciones por usuario y día
|
||||
deletion_groups = {}
|
||||
|
||||
for log in logs:
|
||||
if not log.user:
|
||||
continue
|
||||
|
||||
key = f"{log.user.email}_{log.created_at.date()}"
|
||||
|
||||
if key not in deletion_groups:
|
||||
deletion_groups[key] = {
|
||||
'user': log.user.email,
|
||||
'date': log.created_at.date(),
|
||||
'count': 0,
|
||||
'logs': [],
|
||||
'first_seen': log.created_at,
|
||||
'last_seen': log.created_at
|
||||
}
|
||||
|
||||
deletion_groups[key]['count'] += 1
|
||||
deletion_groups[key]['logs'].append(log)
|
||||
deletion_groups[key]['first_seen'] = min(deletion_groups[key]['first_seen'], log.created_at)
|
||||
deletion_groups[key]['last_seen'] = max(deletion_groups[key]['last_seen'], log.created_at)
|
||||
|
||||
incidents = []
|
||||
|
||||
for key, group in deletion_groups.items():
|
||||
if group['count'] < 3:
|
||||
continue
|
||||
|
||||
if group['count'] >= 10:
|
||||
severity = "critical"
|
||||
elif group['count'] >= 5:
|
||||
severity = "high"
|
||||
else:
|
||||
severity = "medium"
|
||||
|
||||
# Convertir ambas fechas a aware UTC para comparación segura
|
||||
now_aware = _ensure_aware_utc(now)
|
||||
last_seen_aware = _ensure_aware_utc(group['last_seen'])
|
||||
hours_since_last = (now_aware - last_seen_aware).total_seconds() / 3600
|
||||
incident_status = "active" if hours_since_last <= 24 else "resolved"
|
||||
|
||||
incidents.append({
|
||||
"id": f"mass_del_{key.replace('_', '-')}",
|
||||
"title": f"Eliminaciones masivas - {group['user']}",
|
||||
"description": (
|
||||
f"{group['user']} elimino {group['count']} elementos "
|
||||
f"el {group['date']}"
|
||||
),
|
||||
"severity": severity,
|
||||
"status": incident_status,
|
||||
"incident_type": "mass_deletion",
|
||||
"affected_user": group['user'],
|
||||
"source_ip": (
|
||||
str(group['logs'][0].ip_address)
|
||||
if group['logs'][0].ip_address
|
||||
else None
|
||||
),
|
||||
"evidence": [
|
||||
f"{log.action} - {log.resource_type} - {log.created_at.strftime('%H:%M:%S')}"
|
||||
for log in group['logs'][:5]
|
||||
],
|
||||
"metadata": {
|
||||
"total_deletions": group['count'],
|
||||
"resource_types": list(set(log.resource_type for log in group['logs'])),
|
||||
"time_span_minutes": int(
|
||||
(group['last_seen'] - group['first_seen']).total_seconds() / 60
|
||||
)
|
||||
},
|
||||
"created_at": group['first_seen'],
|
||||
"updated_at": group['last_seen']
|
||||
})
|
||||
|
||||
return incidents
|
||||
|
||||
|
||||
def detect_brute_force(logs: List[AuditLog], now: datetime) -> List[dict]:
|
||||
"""
|
||||
Detecta ataques de fuerza bruta agrupando intentos fallidos por IP.
|
||||
|
||||
Lógica:
|
||||
- Agrupa todos los intentos fallidos de login por dirección IP
|
||||
- Si una IP tiene >= 5 intentos, genera un incidente
|
||||
- La severidad escala según la cantidad:
|
||||
- >= 5 intentos → medium
|
||||
- >= 10 intentos → high
|
||||
- >= 20 intentos → critical
|
||||
|
||||
El estado del incidente es:
|
||||
- "active": si el último intento fue hace menos de 24 horas
|
||||
- "investigating": si fue hace más de 24 horas
|
||||
"""
|
||||
ip_groups = {}
|
||||
|
||||
for log in logs:
|
||||
if not log.ip_address:
|
||||
continue
|
||||
|
||||
ip = str(log.ip_address)
|
||||
|
||||
if ip not in ip_groups:
|
||||
ip_groups[ip] = {
|
||||
'count': 0,
|
||||
'logs': [],
|
||||
'first_seen': log.created_at,
|
||||
'last_seen': log.created_at,
|
||||
'users': set()
|
||||
}
|
||||
|
||||
ip_groups[ip]['count'] += 1
|
||||
ip_groups[ip]['logs'].append(log)
|
||||
ip_groups[ip]['first_seen'] = min(ip_groups[ip]['first_seen'], log.created_at)
|
||||
ip_groups[ip]['last_seen'] = max(ip_groups[ip]['last_seen'], log.created_at)
|
||||
|
||||
if log.user and log.user.email:
|
||||
ip_groups[ip]['users'].add(log.user.email)
|
||||
|
||||
incidents = []
|
||||
|
||||
for ip, group in ip_groups.items():
|
||||
if group['count'] < 5:
|
||||
continue
|
||||
|
||||
if group['count'] >= 20:
|
||||
severity = "critical"
|
||||
elif group['count'] >= 10:
|
||||
severity = "high"
|
||||
else:
|
||||
severity = "medium"
|
||||
|
||||
# Convertir ambas fechas a aware UTC para comparación segura
|
||||
now_aware = _ensure_aware_utc(now)
|
||||
last_seen_aware = _ensure_aware_utc(group['last_seen'])
|
||||
seconds_since_last = (now_aware - last_seen_aware).total_seconds()
|
||||
incident_status = "active" if seconds_since_last <= 86400 else "investigating"
|
||||
|
||||
incidents.append({
|
||||
"id": f"brute_force_{ip.replace('.', '-')}",
|
||||
"title": f"Posible ataque de fuerza bruta desde {ip}",
|
||||
"description": (
|
||||
f"Se detectaron {group['count']} intentos fallidos de "
|
||||
f"login desde la IP {ip}"
|
||||
),
|
||||
"severity": severity,
|
||||
"status": incident_status,
|
||||
"incident_type": "brute_force_attack",
|
||||
"affected_user": (
|
||||
', '.join(list(group['users'])[:3])
|
||||
if group['users']
|
||||
else None
|
||||
),
|
||||
"source_ip": ip,
|
||||
"evidence": [
|
||||
f"Login fallido - "
|
||||
f"{log.user.email if log.user else 'Desconocido'} - "
|
||||
f"{log.created_at.strftime('%H:%M:%S')}"
|
||||
for log in group['logs'][:5]
|
||||
],
|
||||
"metadata": {
|
||||
"total_attempts": group['count'],
|
||||
"targeted_users": list(group['users']),
|
||||
"time_span_hours": int(
|
||||
(group['last_seen'] - group['first_seen']).total_seconds() / 3600
|
||||
)
|
||||
},
|
||||
"created_at": group['first_seen'],
|
||||
"updated_at": group['last_seen']
|
||||
})
|
||||
|
||||
return incidents
|
||||
|
||||
|
||||
def detect_privilege_escalation(logs: List[AuditLog]) -> List[dict]:
|
||||
"""
|
||||
Detecta escaladas de privilegios comparando el rol anterior y nuevo.
|
||||
|
||||
Lógica:
|
||||
- Analiza cada log de cambio de rol (user.update con campo 'role')
|
||||
- Si el nuevo rol tiene más privilegios que el anterior, es sospechoso
|
||||
- Cada cambio que represente una escalada genera un incidente
|
||||
|
||||
Jerarquía de roles (de menor a mayor privilegio):
|
||||
CLIENT_USER(1) < CLIENT_ADMIN(2) < AGENT(3) < SUPPORT_MANAGER(4) < ADMIN(5)
|
||||
"""
|
||||
role_hierarchy = {
|
||||
'CLIENT_USER': 1,
|
||||
'CLIENT_ADMIN': 2,
|
||||
'AGENT': 3,
|
||||
'SUPPORT_MANAGER': 4,
|
||||
'ADMIN': 5
|
||||
}
|
||||
|
||||
incidents = []
|
||||
|
||||
for log in logs:
|
||||
if not log.user or not log.new_values or 'role' not in log.new_values:
|
||||
continue
|
||||
|
||||
old_role = log.old_values.get('role') if log.old_values else 'Unknown'
|
||||
new_role = log.new_values.get('role')
|
||||
|
||||
old_level = role_hierarchy.get(old_role, 0)
|
||||
new_level = role_hierarchy.get(new_role, 0)
|
||||
|
||||
# Solo generar incidente si el nuevo rol tiene MÁS privilegios
|
||||
if new_level <= old_level:
|
||||
continue
|
||||
|
||||
severity = "high" if new_role in ['ADMIN', 'SUPPORT_MANAGER'] else "medium"
|
||||
|
||||
incidents.append({
|
||||
"id": f"priv_esc_{log.id}",
|
||||
"title": f"Escalada de privilegios - {log.user.email}",
|
||||
"description": (
|
||||
f"Usuario {log.user.email} cambio de rol "
|
||||
f"{old_role} a {new_role}"
|
||||
),
|
||||
"severity": severity,
|
||||
"status": "investigating",
|
||||
"incident_type": "privilege_escalation",
|
||||
"affected_user": log.user.email,
|
||||
"source_ip": str(log.ip_address) if log.ip_address else None,
|
||||
"evidence": [
|
||||
f"Cambio de rol: {old_role} → {new_role} - "
|
||||
f"{log.created_at.strftime('%Y-%m-%d %H:%M')}"
|
||||
],
|
||||
"metadata": {
|
||||
"old_role": old_role,
|
||||
"new_role": new_role,
|
||||
"correlation_id": (
|
||||
str(log.correlation_id)
|
||||
if log.correlation_id
|
||||
else None
|
||||
)
|
||||
},
|
||||
"created_at": log.created_at,
|
||||
"updated_at": log.created_at
|
||||
})
|
||||
|
||||
return incidents
|
||||
779
backend/app/api/v1/endpoints/audit.py
Normal file
779
backend/app/api/v1/endpoints/audit.py
Normal file
@@ -0,0 +1,779 @@
|
||||
"""
|
||||
Audit Endpoints - ServiceManagerWeb
|
||||
====================================
|
||||
Este archivo maneja todos los endpoints de auditoría y seguridad.
|
||||
Rutas disponibles:
|
||||
GET /audit/ → Lista de logs con filtros y paginación
|
||||
GET /audit/stats → Estadísticas generales de auditoría
|
||||
GET /audit/{log_id} → Detalle de un log específico
|
||||
GET /audit/security/analysis → Análisis de amenazas en tiempo real
|
||||
POST /audit/security/action → Ejecutar acción de seguridad (bloquear IP, etc.)
|
||||
GET /audit/security/incidents → Lista de incidentes detectados
|
||||
|
||||
CORRECCIONES APLICADAS:
|
||||
1. Todos los endpoints usan datetime.now(timezone.utc) para generar
|
||||
fechas aware (con timezone info en UTC), compatibles con la columna
|
||||
'timestamp with time zone' (TIMESTAMPTZ) de PostgreSQL.
|
||||
|
||||
2. audit_helpers.get_count_stat() convierte las fechas a aware UTC
|
||||
con _ensure_aware_utc() antes de usarlas en queries, resolviendo
|
||||
el bug donde los tres contadores (total, hoy, semana) devolvían
|
||||
el mismo valor porque el filtro de fecha se ignoraba.
|
||||
|
||||
3. critical_actions_today usa los mismos umbrales que /security/incidents
|
||||
para que el contador del dashboard coincida con la lista de detalles.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, or_, desc
|
||||
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.audit import AuditLog
|
||||
from app.services.audit_service import AuditService
|
||||
from app.api.schemas.audit import (
|
||||
AuditLogResponse, AuditLogListResponse, AuditLogFilters, AuditLogStats,
|
||||
SecurityAnalysisResponse, SecurityThreatPattern, SecurityActionRequest,
|
||||
SecurityActionResponse, SecurityIncidentResponse, SecurityIncidentListResponse
|
||||
)
|
||||
from app.api.v1.audit_helpers import (
|
||||
audit_log_to_dict, apply_tenant_filter, get_count_stat, get_top_items,
|
||||
detect_mass_deletions, detect_brute_force, detect_privilege_escalation
|
||||
)
|
||||
|
||||
# Instancia del router de FastAPI para este módulo
|
||||
router = APIRouter()
|
||||
|
||||
# Logger estructurado para registrar eventos internos del sistema
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DEPENDENCIA DE AUTORIZACIÓN
|
||||
# =============================================================================
|
||||
|
||||
def require_auditor_role(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""
|
||||
Dependencia reutilizable que verifica que el usuario tenga permisos
|
||||
para ver logs de auditoría.
|
||||
|
||||
Solo pueden acceder los roles: ADMIN, SUPPORT_MANAGER, AUDITOR.
|
||||
Si no tiene el rol correcto, lanza un error 403 Forbidden.
|
||||
"""
|
||||
if current_user.role not in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AUDITOR]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Solo usuarios con rol ADMIN, SUPPORT_MANAGER o AUDITOR pueden acceder a logs de auditoría"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENDPOINT: LISTA DE LOGS DE AUDITORÍA
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/", response_model=AuditLogListResponse)
|
||||
async def get_audit_logs(
|
||||
# Paginación
|
||||
page: int = Query(default=1, ge=1),
|
||||
per_page: int = Query(default=50, ge=1, le=100),
|
||||
# Filtros opcionales
|
||||
user_id: Optional[uuid.UUID] = Query(None),
|
||||
action: Optional[str] = Query(None),
|
||||
resource_type: Optional[str] = Query(None),
|
||||
resource_id: Optional[uuid.UUID] = Query(None),
|
||||
date_from: Optional[datetime] = Query(None),
|
||||
date_to: Optional[datetime] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
tenant_id: Optional[uuid.UUID] = Query(None),
|
||||
all_tenants: bool = Query(False),
|
||||
# Dependencias de autenticación y base de datos
|
||||
current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Obtener el historial completo de logs de auditoría con filtros opcionales.
|
||||
|
||||
Soporta filtrar por usuario, tipo de acción, recurso afectado, fechas
|
||||
y búsqueda de texto. También soporta ver logs de todos los tenants
|
||||
si el usuario tiene permisos de ADMIN o SUPPORT_MANAGER.
|
||||
"""
|
||||
logger.info(
|
||||
"Obteniendo logs de auditoria",
|
||||
user_id=str(current_user.id),
|
||||
tenant_id=str(current_tenant.id),
|
||||
filters={
|
||||
"user_id": str(user_id) if user_id else None,
|
||||
"action": action,
|
||||
"page": page,
|
||||
"all_tenants": all_tenants
|
||||
}
|
||||
)
|
||||
|
||||
# Construir la query base con relación al usuario que hizo la acción
|
||||
query = select(AuditLog).options(selectinload(AuditLog.user))
|
||||
|
||||
# Aplicar filtro de tenant según permisos del usuario
|
||||
query = apply_tenant_filter(query, current_user, current_tenant, all_tenants, tenant_id)
|
||||
|
||||
# Aplicar filtros opcionales uno por uno
|
||||
if user_id:
|
||||
query = query.where(AuditLog.user_id == user_id)
|
||||
if action:
|
||||
query = query.where(AuditLog.action == action)
|
||||
if resource_type:
|
||||
query = query.where(AuditLog.resource_type == resource_type)
|
||||
if resource_id:
|
||||
query = query.where(AuditLog.resource_id == resource_id)
|
||||
if date_from:
|
||||
query = query.where(AuditLog.created_at >= date_from)
|
||||
if date_to:
|
||||
query = query.where(AuditLog.created_at < date_to)
|
||||
if search:
|
||||
# Búsqueda parcial en el campo "action" (ej: "ticket" encuentra "ticket.create")
|
||||
query = query.where(AuditLog.action.ilike(f"%{search}%"))
|
||||
|
||||
# Ordenar por fecha descendente (más reciente primero)
|
||||
query = query.order_by(desc(AuditLog.created_at))
|
||||
|
||||
# Contar total de registros para calcular páginas
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(count_query)).scalar() or 0
|
||||
|
||||
# Aplicar paginación
|
||||
offset = (page - 1) * per_page
|
||||
query = query.offset(offset).limit(per_page)
|
||||
|
||||
# Ejecutar query y obtener resultados
|
||||
result = await db.execute(query)
|
||||
logs = result.scalars().all()
|
||||
|
||||
# Calcular número total de páginas
|
||||
total_pages = (total + per_page - 1) // per_page
|
||||
|
||||
# Convertir modelos a schemas de respuesta
|
||||
logs_response = [AuditLogResponse(**audit_log_to_dict(log)) for log in logs]
|
||||
|
||||
return AuditLogListResponse(
|
||||
logs=logs_response,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
total_pages=total_pages
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENDPOINT: ESTADÍSTICAS DE AUDITORÍA
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/stats", response_model=AuditLogStats)
|
||||
async def get_audit_stats(
|
||||
all_tenants: bool = Query(False),
|
||||
current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Obtener estadísticas resumidas de auditoría para el dashboard.
|
||||
|
||||
Incluye:
|
||||
- Total de acciones registradas
|
||||
- Acciones de las últimas 24 horas
|
||||
- Acciones de los últimos 7 días
|
||||
- Incidentes críticos detectados hoy (alineado con /security/incidents)
|
||||
- Acciones más frecuentes
|
||||
- Usuarios más activos
|
||||
- Distribución por tipo de recurso
|
||||
"""
|
||||
can_see_all_tenants = current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]
|
||||
|
||||
logger.info(
|
||||
"Obteniendo estadisticas de auditoria",
|
||||
user_id=str(current_user.id),
|
||||
tenant_id=str(current_tenant.id),
|
||||
all_tenants=all_tenants,
|
||||
can_see_all=can_see_all_tenants
|
||||
)
|
||||
|
||||
# datetime.now(timezone.utc) genera un datetime aware en UTC,
|
||||
# compatible con la columna TIMESTAMPTZ de PostgreSQL
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Determinar si se debe filtrar por tenant o ver todos
|
||||
apply_tenant = not (all_tenants and can_see_all_tenants)
|
||||
tenant_filter = current_tenant.id if apply_tenant else None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CONTADORES GENERALES
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# Total histórico de acciones (sin filtro de fecha)
|
||||
total_actions = await get_count_stat(db, tenant_filter)
|
||||
|
||||
# Acciones en las últimas 24 horas
|
||||
# get_count_stat convierte internamente a aware UTC con _ensure_aware_utc()
|
||||
actions_today = await get_count_stat(db, tenant_filter, now - timedelta(days=1))
|
||||
|
||||
# Acciones en los últimos 7 días
|
||||
actions_this_week = await get_count_stat(db, tenant_filter, now - timedelta(days=7))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CONTADOR DE INCIDENTES CRÍTICOS
|
||||
# ------------------------------------------------------------------
|
||||
# Usa los mismos umbrales que los detectores de /security/incidents
|
||||
# para que el número del dashboard sea consistente con la lista.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
today_start = now - timedelta(days=1)
|
||||
|
||||
# Contar intentos fallidos de login en las últimas 24 horas
|
||||
failed_login_count = (await db.execute(
|
||||
select(func.count()).select_from(AuditLog).where(
|
||||
AuditLog.action == 'user.login_failed',
|
||||
AuditLog.created_at >= today_start,
|
||||
*([AuditLog.tenant_id == tenant_filter] if apply_tenant else [])
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
# Contar eliminaciones en las últimas 24 horas
|
||||
deletion_count = (await db.execute(
|
||||
select(func.count()).select_from(AuditLog).where(
|
||||
AuditLog.action.like('%.delete'),
|
||||
AuditLog.created_at >= today_start,
|
||||
*([AuditLog.tenant_id == tenant_filter] if apply_tenant else [])
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
# Contar cambios de privilegios en las últimas 24 horas
|
||||
privilege_count = (await db.execute(
|
||||
select(func.count()).select_from(AuditLog).where(
|
||||
AuditLog.action == 'user.update',
|
||||
AuditLog.created_at >= today_start,
|
||||
*([AuditLog.tenant_id == tenant_filter] if apply_tenant else [])
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
# Calcular número real de incidentes usando los mismos umbrales
|
||||
# que los detectores en /security/incidents:
|
||||
# - Fuerza bruta: incidente si hay >= 20 intentos fallidos
|
||||
# - Eliminación masiva: incidente si hay >= 50 eliminaciones
|
||||
# - Escalada privilegios: incidente si hay >= 3 cambios de rol
|
||||
critical_actions_today = sum([
|
||||
1 if failed_login_count >= 20 else 0,
|
||||
1 if deletion_count >= 50 else 0,
|
||||
1 if privilege_count >= 3 else 0,
|
||||
])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DATOS PARA GRÁFICAS Y TABLAS DEL DASHBOARD
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# Top acciones más frecuentes (ej: "ticket.create", "user.login")
|
||||
top_actions = await get_top_items(db, AuditLog.action, tenant_filter)
|
||||
|
||||
# Distribución por tipo de recurso (ej: "ticket", "user", "tenant")
|
||||
by_resource_type = await get_top_items(db, AuditLog.resource_type, tenant_filter, limit=10)
|
||||
|
||||
# Usuarios más activos (hace join con tabla de usuarios)
|
||||
top_users = await get_top_items(db, None, tenant_filter, join_user=True)
|
||||
|
||||
return AuditLogStats(
|
||||
total_actions=total_actions,
|
||||
actions_today=actions_today,
|
||||
actions_this_week=actions_this_week,
|
||||
critical_actions_today=critical_actions_today,
|
||||
top_actions=top_actions,
|
||||
top_users=top_users,
|
||||
by_resource_type=by_resource_type
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENDPOINT: DETALLE DE UN LOG ESPECÍFICO
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/{log_id}", response_model=AuditLogResponse)
|
||||
async def get_audit_log_detail(
|
||||
log_id: uuid.UUID,
|
||||
current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Obtener el detalle completo de un log de auditoría por su ID.
|
||||
|
||||
Incluye información del usuario que realizó la acción, valores
|
||||
anteriores y nuevos (para cambios), IP de origen, user agent, etc.
|
||||
|
||||
Retorna 404 si el log no existe o no pertenece al tenant del usuario.
|
||||
"""
|
||||
# Buscar el log por ID incluyendo los datos del usuario relacionado
|
||||
query = select(AuditLog).where(AuditLog.id == log_id).options(selectinload(AuditLog.user))
|
||||
|
||||
# Aplicar filtro de tenant para garantizar aislamiento multi-tenant
|
||||
query = apply_tenant_filter(query, current_user, current_tenant)
|
||||
|
||||
result = await db.execute(query)
|
||||
log = result.scalar_one_or_none()
|
||||
|
||||
if not log:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Registro de auditoria {log_id} no encontrado"
|
||||
)
|
||||
|
||||
return AuditLogResponse(**audit_log_to_dict(log))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENDPOINT: ANÁLISIS DE SEGURIDAD EN TIEMPO REAL
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/security/analysis", response_model=SecurityAnalysisResponse)
|
||||
async def get_security_analysis(
|
||||
hours: int = Query(default=24, ge=1, le=720),
|
||||
all_tenants: bool = Query(False),
|
||||
current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Analizar los logs de auditoría para detectar patrones sospechosos.
|
||||
|
||||
Detecta tres tipos de amenazas:
|
||||
1. Fuerza bruta: Muchos intentos fallidos de login desde las mismas IPs
|
||||
2. Eliminación masiva: Gran cantidad de registros eliminados en poco tiempo
|
||||
3. Escalada privilegios: Cambios de roles sospechosos en usuarios
|
||||
|
||||
Calcula un nivel de riesgo general (low/medium/high/critical) y
|
||||
devuelve recomendaciones de acción.
|
||||
"""
|
||||
logger.info(
|
||||
"Analisis de seguridad solicitado",
|
||||
user_id=str(current_user.id),
|
||||
tenant_id=str(current_tenant.id),
|
||||
hours=hours
|
||||
)
|
||||
|
||||
# aware UTC para compatibilidad con TIMESTAMPTZ de PostgreSQL
|
||||
now = datetime.now(timezone.utc)
|
||||
analysis_start = now - timedelta(hours=hours)
|
||||
|
||||
query = (
|
||||
select(AuditLog)
|
||||
.where(AuditLog.created_at >= analysis_start)
|
||||
.options(selectinload(AuditLog.user))
|
||||
)
|
||||
query = apply_tenant_filter(query, current_user, current_tenant, all_tenants)
|
||||
|
||||
result = await db.execute(query)
|
||||
logs = result.scalars().all()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CONTADORES DE EVENTOS SOSPECHOSOS
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
failed_logins = sum(1 for log in logs if log.action == 'user.login_failed')
|
||||
mass_deletions = sum(1 for log in logs if '.delete' in log.action)
|
||||
privilege_changes = sum(
|
||||
1 for log in logs
|
||||
if log.action == 'user.update'
|
||||
and log.new_values
|
||||
and 'role' in log.new_values
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GENERACIÓN DE PATRONES DE AMENAZA
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
threat_patterns = []
|
||||
|
||||
# Amenaza 1: Fuerza bruta (umbral mínimo: 5 intentos fallidos)
|
||||
if failed_logins >= 5:
|
||||
affected_ips_list = [
|
||||
str(log.ip_address)
|
||||
for log in logs
|
||||
if log.action == 'user.login_failed' and log.ip_address
|
||||
]
|
||||
threat_patterns.append(SecurityThreatPattern(
|
||||
id="brute_force_attempt",
|
||||
type="brute_force",
|
||||
description=(
|
||||
f"Se detectaron {failed_logins} intentos fallidos de "
|
||||
f"login en las ultimas {hours}h"
|
||||
),
|
||||
severity="high" if failed_logins >= 20 else "medium",
|
||||
occurrences=failed_logins,
|
||||
first_seen=min(
|
||||
(log.created_at for log in logs if log.action == 'user.login_failed'),
|
||||
default=now
|
||||
),
|
||||
last_seen=max(
|
||||
(log.created_at for log in logs if log.action == 'user.login_failed'),
|
||||
default=now
|
||||
),
|
||||
affected_ips=list(set(affected_ips_list))[:5],
|
||||
affected_users=[],
|
||||
recommended_action="Considerar bloquear IPs con multiples fallos"
|
||||
))
|
||||
|
||||
# Amenaza 2: Eliminación masiva (umbral mínimo: 10 eliminaciones)
|
||||
if mass_deletions >= 10:
|
||||
deleting_users = [
|
||||
log.user.email
|
||||
for log in logs
|
||||
if '.delete' in log.action and log.user
|
||||
]
|
||||
threat_patterns.append(SecurityThreatPattern(
|
||||
id="mass_deletion",
|
||||
type="mass_deletion",
|
||||
description=(
|
||||
f"Se detectaron {mass_deletions} eliminaciones en "
|
||||
f"las ultimas {hours}h"
|
||||
),
|
||||
severity="critical" if mass_deletions >= 50 else "high",
|
||||
occurrences=mass_deletions,
|
||||
first_seen=min(
|
||||
(log.created_at for log in logs if '.delete' in log.action),
|
||||
default=now
|
||||
),
|
||||
last_seen=max(
|
||||
(log.created_at for log in logs if '.delete' in log.action),
|
||||
default=now
|
||||
),
|
||||
affected_ips=[],
|
||||
affected_users=list(set(deleting_users))[:5],
|
||||
recommended_action="Revisar que usuarios estan eliminando recursos masivamente"
|
||||
))
|
||||
|
||||
# Amenaza 3: Escalada de privilegios (umbral mínimo: 3 cambios de rol)
|
||||
if privilege_changes >= 3:
|
||||
affected_users_list = [
|
||||
log.user.email
|
||||
for log in logs
|
||||
if log.action == 'user.update'
|
||||
and log.user
|
||||
and log.new_values
|
||||
and 'role' in log.new_values
|
||||
]
|
||||
threat_patterns.append(SecurityThreatPattern(
|
||||
id="suspicious_privilege_changes",
|
||||
type="privilege_escalation",
|
||||
description=(
|
||||
f"Se detectaron {privilege_changes} cambios de "
|
||||
f"privilegios en las ultimas {hours}h"
|
||||
),
|
||||
severity="high",
|
||||
occurrences=privilege_changes,
|
||||
first_seen=min(
|
||||
(
|
||||
log.created_at for log in logs
|
||||
if log.action == 'user.update'
|
||||
and log.new_values
|
||||
and 'role' in log.new_values
|
||||
),
|
||||
default=now
|
||||
),
|
||||
last_seen=max(
|
||||
(
|
||||
log.created_at for log in logs
|
||||
if log.action == 'user.update'
|
||||
and log.new_values
|
||||
and 'role' in log.new_values
|
||||
),
|
||||
default=now
|
||||
),
|
||||
affected_ips=[],
|
||||
affected_users=list(set(affected_users_list))[:5],
|
||||
recommended_action="Auditar cambios de roles recientes"
|
||||
))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CÁLCULO DE NIVEL DE RIESGO GENERAL
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
risk_score = min(
|
||||
100,
|
||||
(failed_logins * 2) + (mass_deletions * 5) + (privilege_changes * 10)
|
||||
)
|
||||
|
||||
if risk_score >= 80:
|
||||
risk_level = "critical"
|
||||
elif risk_score >= 50:
|
||||
risk_level = "high"
|
||||
elif risk_score >= 20:
|
||||
risk_level = "medium"
|
||||
else:
|
||||
risk_level = "low"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# RECOMENDACIONES AUTOMÁTICAS
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
recommended_actions = []
|
||||
|
||||
if failed_logins >= 20:
|
||||
recommended_actions.append(
|
||||
"Implementar bloqueo automatico de IPs despues de multiples intentos fallidos"
|
||||
)
|
||||
if mass_deletions >= 50:
|
||||
recommended_actions.append(
|
||||
"Activar confirmacion adicional para eliminaciones masivas"
|
||||
)
|
||||
if privilege_changes >= 3:
|
||||
recommended_actions.append(
|
||||
"Revisar y aprobar manualmente los cambios de roles recientes"
|
||||
)
|
||||
if not recommended_actions:
|
||||
recommended_actions.append("Continuar monitoreando actividad del sistema")
|
||||
|
||||
suspicious_ips = len(set(
|
||||
log.ip_address
|
||||
for log in logs
|
||||
if log.ip_address and log.action == 'user.login_failed'
|
||||
))
|
||||
|
||||
critical_actions = mass_deletions + privilege_changes
|
||||
|
||||
return SecurityAnalysisResponse(
|
||||
overall_risk_level=risk_level,
|
||||
total_threats_detected=len(threat_patterns),
|
||||
threats=threat_patterns,
|
||||
analysis_period_hours=hours,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
failed_login_attempts=failed_logins,
|
||||
suspicious_ips_count=suspicious_ips,
|
||||
critical_actions_count=critical_actions,
|
||||
recommended_actions=recommended_actions
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENDPOINT: EJECUTAR ACCIÓN DE SEGURIDAD
|
||||
# =============================================================================
|
||||
|
||||
@router.post("/security/action", response_model=SecurityActionResponse)
|
||||
async def execute_security_action(
|
||||
action: SecurityActionRequest,
|
||||
current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Ejecutar una acción de seguridad manual sobre una amenaza detectada.
|
||||
|
||||
Acciones disponibles:
|
||||
- block_ip: Bloquear una dirección IP por X minutos
|
||||
- notify_admin: Enviar notificación a los administradores
|
||||
- force_password_reset: Forzar cambio de contraseña a un usuario
|
||||
- disable_user: Desactivar temporalmente una cuenta de usuario
|
||||
|
||||
Solo ADMIN y SUPPORT_MANAGER pueden ejecutar estas acciones.
|
||||
Todas las acciones quedan registradas en el log de auditoría.
|
||||
"""
|
||||
if current_user.role not in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Solo administradores pueden ejecutar acciones de seguridad"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Accion de seguridad solicitada",
|
||||
user_id=str(current_user.id),
|
||||
action_type=action.action_type,
|
||||
target=action.target
|
||||
)
|
||||
|
||||
# Registrar en auditoría para trazabilidad completa
|
||||
try:
|
||||
await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=current_tenant.id,
|
||||
user_id=current_user.id,
|
||||
action=f"security.{action.action_type}",
|
||||
resource_type="security",
|
||||
resource_id=None,
|
||||
metadata={
|
||||
"target": action.target,
|
||||
"reason": action.reason,
|
||||
"duration_minutes": action.duration_minutes
|
||||
}
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.error("Fallo al registrar accion de seguridad en auditoria", error=str(e))
|
||||
|
||||
action_messages = {
|
||||
"block_ip": (
|
||||
f"IP {action.target} bloqueada por "
|
||||
f"{action.duration_minutes or 60} minutos. Razon: {action.reason}"
|
||||
),
|
||||
"notify_admin": (
|
||||
f"Notificacion enviada a administradores sobre: {action.reason}"
|
||||
),
|
||||
"force_password_reset": (
|
||||
f"Se forzara cambio de contrasena para {action.target}. "
|
||||
f"Razon: {action.reason}"
|
||||
),
|
||||
"disable_user": (
|
||||
f"Usuario {action.target} desactivado temporalmente. "
|
||||
f"Razon: {action.reason}"
|
||||
)
|
||||
}
|
||||
|
||||
success = action.action_type in action_messages
|
||||
message = action_messages.get(
|
||||
action.action_type,
|
||||
f"Tipo de accion no reconocida: {action.action_type}"
|
||||
)
|
||||
|
||||
return SecurityActionResponse(success=success, message=message, action_id=None)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENDPOINT: LISTA DE INCIDENTES DE SEGURIDAD
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/security/incidents", response_model=SecurityIncidentListResponse)
|
||||
async def get_security_incidents(
|
||||
# Paginación
|
||||
page: int = Query(default=1, ge=1),
|
||||
per_page: int = Query(default=20, ge=1, le=100),
|
||||
# Filtros opcionales
|
||||
severity: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
incident_type: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
all_tenants: bool = Query(False),
|
||||
# Dependencias
|
||||
current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Obtener la lista de incidentes de seguridad detectados.
|
||||
|
||||
Los incidentes se generan dinámicamente analizando los logs de
|
||||
auditoría de los últimos 7 días usando tres detectores:
|
||||
|
||||
1. detect_brute_force: Analiza intentos fallidos de login
|
||||
2. detect_mass_deletions: Analiza eliminaciones masivas
|
||||
3. detect_privilege_escalation: Analiza cambios de rol sospechosos
|
||||
|
||||
Los umbrales son los mismos que usa /stats para critical_actions_today,
|
||||
garantizando consistencia entre el contador y la lista.
|
||||
"""
|
||||
logger.info(
|
||||
"Obteniendo incidentes de seguridad",
|
||||
user_id=str(current_user.id),
|
||||
tenant_id=str(current_tenant.id),
|
||||
filters={
|
||||
"severity": severity,
|
||||
"status": status,
|
||||
"type": incident_type,
|
||||
"page": page
|
||||
}
|
||||
)
|
||||
|
||||
# aware UTC para compatibilidad con TIMESTAMPTZ de PostgreSQL
|
||||
now = datetime.now(timezone.utc)
|
||||
analysis_start = now - timedelta(days=7)
|
||||
|
||||
base_query = (
|
||||
select(AuditLog)
|
||||
.options(selectinload(AuditLog.user))
|
||||
.where(AuditLog.created_at >= analysis_start)
|
||||
)
|
||||
base_query = apply_tenant_filter(base_query, current_user, current_tenant, all_tenants)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DETECTOR 1: ELIMINACIONES MASIVAS
|
||||
# ------------------------------------------------------------------
|
||||
deletion_result = await db.execute(
|
||||
base_query
|
||||
.where(AuditLog.action.like('%.delete'))
|
||||
.order_by(desc(AuditLog.created_at))
|
||||
)
|
||||
deletion_logs = deletion_result.scalars().all()
|
||||
deletion_incidents = detect_mass_deletions(deletion_logs, now)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DETECTOR 2: FUERZA BRUTA
|
||||
# ------------------------------------------------------------------
|
||||
failed_login_result = await db.execute(
|
||||
base_query
|
||||
.where(AuditLog.action == 'user.login_failed')
|
||||
.order_by(desc(AuditLog.created_at))
|
||||
)
|
||||
failed_login_logs = failed_login_result.scalars().all()
|
||||
brute_force_incidents = detect_brute_force(failed_login_logs, now)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DETECTOR 3: ESCALADA DE PRIVILEGIOS
|
||||
# El operador '?' verifica si el campo JSON contiene la clave 'role'
|
||||
# ------------------------------------------------------------------
|
||||
privilege_result = await db.execute(
|
||||
base_query
|
||||
.where(and_(
|
||||
AuditLog.action == 'user.update',
|
||||
AuditLog.new_values.op('?')('role')
|
||||
))
|
||||
.order_by(desc(AuditLog.created_at))
|
||||
)
|
||||
privilege_logs = privilege_result.scalars().all()
|
||||
privilege_incidents = detect_privilege_escalation(privilege_logs)
|
||||
|
||||
# Combinar todos los incidentes
|
||||
incidents = [
|
||||
SecurityIncidentResponse(**inc)
|
||||
for inc in (deletion_incidents + brute_force_incidents + privilege_incidents)
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# FILTROS EN MEMORIA (los incidentes son generados dinámicamente)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
if severity:
|
||||
incidents = [i for i in incidents if i.severity == severity]
|
||||
if status:
|
||||
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())
|
||||
]
|
||||
|
||||
# Ordenar por fecha descendente
|
||||
incidents.sort(key=lambda x: x.created_at, reverse=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PAGINACIÓN MANUAL
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
total = len(incidents)
|
||||
total_pages = (total + per_page - 1) // per_page
|
||||
start_idx = (page - 1) * per_page
|
||||
end_idx = start_idx + per_page
|
||||
paginated_incidents = incidents[start_idx:end_idx]
|
||||
|
||||
return SecurityIncidentListResponse(
|
||||
incidents=paginated_incidents,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
total_pages=total_pages
|
||||
)
|
||||
@@ -1,15 +1,14 @@
|
||||
"""
|
||||
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.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional
|
||||
import structlog
|
||||
|
||||
@@ -18,55 +17,41 @@ from app.core.security import security
|
||||
from app.core.config import get_settings
|
||||
from app.models.user import User
|
||||
from app.models.tenant import Tenant
|
||||
from app.services.audit_service import AuditService
|
||||
from app.services.token_service import TokenService
|
||||
from app.api.deps import oauth2_scheme, get_current_user
|
||||
from app.core.cache import cache, cache_key
|
||||
from app.core.limiter import limiter
|
||||
|
||||
# Nombres de cookie por tipo de usuario
|
||||
CLIENT_ROLES = {"CLIENT_ADMIN", "CLIENT_USER"}
|
||||
|
||||
|
||||
def _cookie_name_for_role(role: str) -> str:
|
||||
"""Devuelve el nombre de cookie según el rol del usuario."""
|
||||
return "client_access_token" if role in CLIENT_ROLES else "internal_access_token"
|
||||
from app.api.schemas.auth import (
|
||||
LoginRequest, LoginResponse, RefreshTokenRequest, TokenResponse,
|
||||
TwoFactorStatusResponse, TwoFactorSetupResponse,
|
||||
TwoFactorEnableRequest, TwoFactorEnableResponse, TwoFactorDisableRequest,
|
||||
ChangePasswordRequest, ForgotPasswordRequest, ResetPasswordRequest,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
logger = structlog.get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
# OAuth2 scheme
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/{settings.API_VERSION}/auth/login")
|
||||
|
||||
|
||||
# ===================================
|
||||
# PYDANTIC SCHEMAS
|
||||
# ===================================
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""Schema for login request."""
|
||||
email: EmailStr
|
||||
password: str
|
||||
tenant_slug: str
|
||||
totp_code: Optional[str] = None
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
"""Schema for login response."""
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
user: dict
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
"""Schema for refresh token request."""
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""Schema for token response."""
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
|
||||
|
||||
# ===================================
|
||||
# ENDPOINTS
|
||||
# ===================================
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
@limiter.limit("10/minute")
|
||||
async def login(
|
||||
login_data: LoginRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
@@ -87,34 +72,139 @@ async def login(
|
||||
email=login_data.email,
|
||||
tenant_slug=login_data.tenant_slug
|
||||
)
|
||||
|
||||
# Rate limiting (best-effort): by IP before any tenant/user lookup.
|
||||
if settings.RATE_LIMIT_ENABLED and not settings.TESTING:
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
ip_key = cache_key("rl", "login", "ip", client_ip)
|
||||
ip_count = await cache.incr(ip_key, 1)
|
||||
if ip_count == 1:
|
||||
await cache.expire(ip_key, settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)
|
||||
|
||||
if ip_count is not None and ip_count > settings.LOGIN_RATE_LIMIT_IP_MAX_ATTEMPTS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Too many login attempts. Try again later.",
|
||||
headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)},
|
||||
)
|
||||
|
||||
# 1. Buscar usuario en base de datos
|
||||
query = select(User).where(User.email == login_data.email)
|
||||
# 1. Validar tenant - por slug si viene, sino buscar por email
|
||||
if login_data.tenant_slug:
|
||||
tenant_result = await db.execute(
|
||||
select(Tenant).where(Tenant.slug == login_data.tenant_slug)
|
||||
)
|
||||
tenant = tenant_result.scalar_one_or_none()
|
||||
if tenant is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tenant not found",
|
||||
)
|
||||
else:
|
||||
tenant = None
|
||||
|
||||
# Rate limiting (best-effort): by (tenant,email) to slow brute force.
|
||||
ident_key = None
|
||||
if settings.RATE_LIMIT_ENABLED and not settings.TESTING and tenant:
|
||||
email_norm = login_data.email.strip().lower()
|
||||
ident_key = cache_key("rl", "login", "id", str(tenant.id), email_norm)
|
||||
ident_count = await cache.incr(ident_key, 1)
|
||||
if ident_count == 1:
|
||||
await cache.expire(ident_key, settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)
|
||||
|
||||
if ident_count is not None and ident_count > settings.LOGIN_RATE_LIMIT_ID_MAX_ATTEMPTS:
|
||||
try:
|
||||
await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=tenant.id,
|
||||
user_id=None,
|
||||
action="user.login_rate_limited",
|
||||
resource_type="user",
|
||||
resource_id=None,
|
||||
metadata={
|
||||
"email": email_norm,
|
||||
"tenant_slug": login_data.tenant_slug,
|
||||
"ip": request.client.host if request.client else None,
|
||||
"scope": "tenant_email",
|
||||
"window_seconds": settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS,
|
||||
"max_attempts": settings.LOGIN_RATE_LIMIT_ID_MAX_ATTEMPTS,
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to log rate limit audit entry", error=str(e))
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Too many login attempts. Try again later.",
|
||||
headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)},
|
||||
)
|
||||
|
||||
# 2. Buscar usuario - filtrar por tenant si se detecto, sino buscar por email
|
||||
if tenant:
|
||||
query = select(User).where(
|
||||
User.email == login_data.email,
|
||||
User.tenant_id == tenant.id,
|
||||
)
|
||||
else:
|
||||
query = select(User).where(User.email == login_data.email)
|
||||
result = await db.execute(query)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
# 2. Verificar usuario y contraseña
|
||||
# 3. Verificar usuario y contraseña
|
||||
if not user or not security.verify_password(login_data.password, user.password_hash):
|
||||
logger.warning(
|
||||
"Login failed - invalid credentials",
|
||||
email=login_data.email
|
||||
)
|
||||
|
||||
# Registrar intento fallido en auditorÃa (si el usuario existe)
|
||||
if user:
|
||||
try:
|
||||
await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=user.tenant_id,
|
||||
user_id=None, # Login fallido = sin user_id
|
||||
action="user.login_failed",
|
||||
resource_type="user",
|
||||
resource_id=user.id,
|
||||
metadata={"email": login_data.email, "reason": "invalid_password"}
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to log audit entry", error=str(e))
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Credenciales inválidas"
|
||||
detail="Invalid credentials",
|
||||
)
|
||||
|
||||
# 3. Verificar si está activo
|
||||
# 4. Verificar si está activo
|
||||
if not user.is_active:
|
||||
logger.warning(
|
||||
"Login failed - user inactive",
|
||||
email=login_data.email
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Usuario inactivo"
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User inactive",
|
||||
)
|
||||
|
||||
|
||||
# 5. Verificar 2FA si está habilitado
|
||||
if user.totp_enabled:
|
||||
if not login_data.totp_code:
|
||||
# Indicar al frontend que debe pedir el código TOTP
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Se requiere autenticación de dos factores (2FA). Ingresa tu código."
|
||||
)
|
||||
if not security.verify_totp(user.totp_secret, login_data.totp_code):
|
||||
logger.warning("Login failed - invalid 2FA code", email=login_data.email)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Código 2FA inválido o expirado"
|
||||
)
|
||||
|
||||
# Create tokens
|
||||
token_data = {
|
||||
"sub": str(user.id),
|
||||
@@ -125,6 +215,39 @@ async def login(
|
||||
|
||||
access_token = security.create_access_token(token_data)
|
||||
refresh_token = security.create_refresh_token(token_data)
|
||||
|
||||
# Persist refresh token so it can be revoked/validated later
|
||||
try:
|
||||
await TokenService.create_refresh_token(
|
||||
db=db,
|
||||
user=user,
|
||||
refresh_token=refresh_token,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
ip_address=request.client.host if request.client else None,
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
# If persistence fails, do not leak tokens
|
||||
logger.error("Failed to persist refresh token", error=str(e), user_id=str(user.id))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Service temporarily unavailable",
|
||||
)
|
||||
|
||||
# Registrar login exitoso en auditorÃa
|
||||
try:
|
||||
await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=user.tenant_id,
|
||||
user_id=user.id,
|
||||
action="user.login",
|
||||
resource_type="user",
|
||||
resource_id=user.id,
|
||||
metadata={"email": user.email, "success": True}
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to log audit entry", error=str(e))
|
||||
|
||||
logger.info(
|
||||
"Login successful",
|
||||
@@ -132,7 +255,24 @@ async def login(
|
||||
tenant_slug=login_data.tenant_slug,
|
||||
user_id=str(user.id)
|
||||
)
|
||||
|
||||
|
||||
# Best-effort: clear per-identity limiter on success.
|
||||
if ident_key:
|
||||
await cache.delete(ident_key)
|
||||
|
||||
# Cookie diferenciada por rol para aislar sesiones entre frontends
|
||||
cookie_name = _cookie_name_for_role(
|
||||
user.role.value if hasattr(user.role, "value") else user.role
|
||||
)
|
||||
response.set_cookie(
|
||||
key=cookie_name,
|
||||
value=access_token,
|
||||
httponly=True,
|
||||
secure=settings.is_production(),
|
||||
samesite="strict" if settings.is_production() else "lax",
|
||||
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||
)
|
||||
|
||||
return LoginResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
@@ -180,7 +320,20 @@ async def refresh_token(
|
||||
detail="Invalid refresh token"
|
||||
)
|
||||
|
||||
# TODO: Check if refresh token exists in database and is not revoked
|
||||
# Check token exists in database and is not revoked/expired
|
||||
db_token = await TokenService.verify_refresh_token(db=db, refresh_token=refresh_data.refresh_token)
|
||||
if db_token is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token",
|
||||
)
|
||||
|
||||
# Defensive: ensure DB token belongs to same subject
|
||||
if str(db_token.user_id) != str(payload.get("sub")):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token",
|
||||
)
|
||||
|
||||
# Create new access token
|
||||
token_data = {
|
||||
@@ -202,6 +355,7 @@ async def refresh_token(
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
response: Response,
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
@@ -225,10 +379,44 @@ async def logout(
|
||||
detail="Invalid token"
|
||||
)
|
||||
|
||||
# TODO: Revoke refresh token in database
|
||||
# Revoke all active refresh tokens for this user (logout invalidates refresh)
|
||||
try:
|
||||
import uuid
|
||||
|
||||
user_id = uuid.UUID(payload["sub"])
|
||||
await TokenService.revoke_all_user_tokens(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
revoked_by_user_id=user_id,
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to revoke refresh tokens on logout", error=str(e))
|
||||
|
||||
# Registrar logout en auditorÃa
|
||||
try:
|
||||
import uuid
|
||||
user_id = uuid.UUID(payload["sub"])
|
||||
tenant_id = uuid.UUID(payload["tenant_id"])
|
||||
|
||||
await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
action="user.logout",
|
||||
resource_type="user",
|
||||
resource_id=user_id,
|
||||
metadata={"email": payload.get("email")}
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to log audit entry", error=str(e))
|
||||
|
||||
logger.info("Logout successful", user_id=payload["sub"])
|
||||
|
||||
|
||||
# Borrar la cookie correcta según el rol del usuario
|
||||
cookie_name = _cookie_name_for_role(payload.get("role", ""))
|
||||
response.delete_cookie(key=cookie_name)
|
||||
return {"message": "Successfully logged out"}
|
||||
|
||||
|
||||
@@ -303,4 +491,352 @@ async def get_current_user(
|
||||
# DEPENDENCIES
|
||||
# ===================================
|
||||
# Dependencies are imported from app.api.deps to avoid duplication
|
||||
# Use get_current_user and get_current_active_superuser from deps.py
|
||||
# Use get_current_user and get_current_active_superuser from deps.py
|
||||
|
||||
|
||||
# ===================================
|
||||
# 2FA / TOTP ENDPOINTS
|
||||
# ===================================
|
||||
|
||||
@router.get("/2fa/status", response_model=TwoFactorStatusResponse)
|
||||
async def get_2fa_status(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Consultar si el 2FA está habilitado para el usuario actual.
|
||||
|
||||
Returns:
|
||||
Estado de 2FA del usuario autenticado.
|
||||
"""
|
||||
return TwoFactorStatusResponse(enabled=bool(current_user.totp_enabled))
|
||||
|
||||
|
||||
@router.post("/2fa/setup", response_model=TwoFactorSetupResponse)
|
||||
async def setup_2fa(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Iniciar configuración de 2FA: genera un nuevo TOTP secret y QR URI.
|
||||
|
||||
El secret se guarda en BD pero 2FA NO se activa todavÃa.
|
||||
Se necesita llamar a /2fa/enable con un código válido para activarlo.
|
||||
|
||||
Returns:
|
||||
Secret y QR URI para escanear con la app autenticadora.
|
||||
"""
|
||||
new_secret = security.generate_totp_secret()
|
||||
qr_uri = security.generate_totp_uri(new_secret, current_user.email)
|
||||
|
||||
# Guardar el secret (sin habilitar aún)
|
||||
current_user.totp_secret = new_secret
|
||||
await db.commit()
|
||||
|
||||
logger.info("2FA setup initiated", user_id=str(current_user.id))
|
||||
|
||||
return TwoFactorSetupResponse(secret=new_secret, qr_uri=qr_uri)
|
||||
|
||||
|
||||
@router.post("/2fa/enable", response_model=TwoFactorEnableResponse)
|
||||
async def enable_2fa(
|
||||
data: TwoFactorEnableRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Activar 2FA verificando que el usuario escaneó correctamente el QR.
|
||||
|
||||
Requiere que /2fa/setup haya sido llamado previamente.
|
||||
|
||||
Args:
|
||||
data: Código TOTP generado por la app autenticadora.
|
||||
|
||||
Returns:
|
||||
Confirmación y lista de códigos de respaldo.
|
||||
"""
|
||||
if not current_user.totp_secret:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Primero inicia el proceso de configuración con /2fa/setup"
|
||||
)
|
||||
|
||||
if not security.verify_totp(current_user.totp_secret, data.totp_code):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Código TOTP inválido. Verifica la hora de tu dispositivo e intenta de nuevo."
|
||||
)
|
||||
|
||||
# Activar 2FA y generar códigos de respaldo
|
||||
backup_codes = security.generate_backup_codes()
|
||||
current_user.totp_enabled = True
|
||||
current_user.backup_codes = backup_codes
|
||||
await db.commit()
|
||||
|
||||
await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=current_user.tenant_id,
|
||||
user_id=current_user.id,
|
||||
action="user.2fa_enabled",
|
||||
resource_type="user",
|
||||
resource_id=current_user.id,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info("2FA enabled", user_id=str(current_user.id))
|
||||
|
||||
return TwoFactorEnableResponse(enabled=True, backup_codes=backup_codes)
|
||||
|
||||
|
||||
@router.post("/2fa/disable")
|
||||
async def disable_2fa(
|
||||
data: TwoFactorDisableRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Deshabilitar 2FA verificando con código TOTP o código de respaldo.
|
||||
|
||||
Args:
|
||||
data: totp_code o backup_code para verificar identidad.
|
||||
|
||||
Returns:
|
||||
Mensaje de confirmación.
|
||||
"""
|
||||
if not current_user.totp_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="El 2FA no está habilitado en esta cuenta"
|
||||
)
|
||||
|
||||
# Verificar con TOTP o código de respaldo
|
||||
verified = False
|
||||
|
||||
if data.totp_code:
|
||||
verified = security.verify_totp(current_user.totp_secret, data.totp_code)
|
||||
elif data.backup_code and current_user.backup_codes:
|
||||
if data.backup_code in current_user.backup_codes:
|
||||
verified = True
|
||||
# Invalidar el código de respaldo usado
|
||||
current_user.backup_codes = [
|
||||
c for c in current_user.backup_codes if c != data.backup_code
|
||||
]
|
||||
|
||||
if not verified:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Verificación fallida. Proporciona un código TOTP o un código de respaldo válido."
|
||||
)
|
||||
|
||||
# Deshabilitar 2FA
|
||||
current_user.totp_enabled = False
|
||||
current_user.totp_secret = None
|
||||
current_user.backup_codes = None
|
||||
await db.commit()
|
||||
|
||||
await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=current_user.tenant_id,
|
||||
user_id=current_user.id,
|
||||
action="user.2fa_disabled",
|
||||
resource_type="user",
|
||||
resource_id=current_user.id,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info("2FA disabled", user_id=str(current_user.id))
|
||||
|
||||
return {"message": "Autenticación de dos factores deshabilitada correctamente"}
|
||||
|
||||
|
||||
@router.post("/change-password", status_code=status.HTTP_200_OK)
|
||||
async def change_password(
|
||||
data: ChangePasswordRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Cambiar la contraseña del usuario autenticado.
|
||||
|
||||
Verifica la contraseña actual antes de actualizar.
|
||||
Requiere autenticación activa.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
# Validar longitud mÃnima
|
||||
if len(data.new_password) < 8:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="La nueva contraseña debe tener al menos 8 caracteres"
|
||||
)
|
||||
|
||||
# Verificar que la contraseña actual sea correcta
|
||||
if not security.verify_password(data.current_password, current_user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="La contraseña actual es incorrecta"
|
||||
)
|
||||
|
||||
# No permitir que la nueva sea igual a la actual
|
||||
if security.verify_password(data.new_password, current_user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="La nueva contraseña no puede ser igual a la actual"
|
||||
)
|
||||
|
||||
current_user.password_hash = security.hash_password(data.new_password)
|
||||
current_user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=current_user.tenant_id,
|
||||
user_id=current_user.id,
|
||||
action="user.password_changed",
|
||||
resource_type="user",
|
||||
resource_id=current_user.id,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info("Password changed", user_id=str(current_user.id))
|
||||
return {"message": "Contraseña actualizada correctamente"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Recuperación de contraseña (forgot / reset)
|
||||
# ============================================================
|
||||
|
||||
_RESET_TOKEN_TTL = 1800 # 30 minutos en segundos
|
||||
_RESET_KEY_PREFIX = "pwd_reset:"
|
||||
|
||||
|
||||
@router.post("/forgot-password", status_code=status.HTTP_200_OK)
|
||||
@limiter.limit("5/minute")
|
||||
async def forgot_password(
|
||||
request: Request,
|
||||
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)
|
||||
@limiter.limit("5/minute")
|
||||
async def reset_password(
|
||||
request: Request,
|
||||
data: ResetPasswordRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Aplicar nueva contraseña usando el token recibido por email.
|
||||
|
||||
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.cache import cache, cache_key
|
||||
from app.models.category import Category
|
||||
from app.models.user import User
|
||||
from app.api import deps
|
||||
from app.api import deps
|
||||
from app.services.audit_service import AuditService
|
||||
from app.api.schemas.category import CategoryCreate, CategoryUpdate, CategoryResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ===================================
|
||||
# PYDANTIC SCHEMAS
|
||||
# ===================================
|
||||
|
||||
class CategoryCreate(BaseModel):
|
||||
"""Schema para crear categoría - NO incluye tenant_id (se asigna automáticamente)"""
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
sla_response_hours: int = 24
|
||||
sla_resolution_hours: int = 72
|
||||
auto_assign_to: Optional[uuid.UUID] = None
|
||||
|
||||
class CategoryUpdate(BaseModel):
|
||||
"""Schema para actualizar categoría"""
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
sla_response_hours: Optional[int] = None
|
||||
sla_resolution_hours: Optional[int] = None
|
||||
auto_assign_to: Optional[uuid.UUID] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
class CategoryResponse(BaseModel):
|
||||
"""Schema de respuesta - incluye todos los campos"""
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID # ✅ AÑADIDO
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
sla_response_hours: int
|
||||
sla_resolution_hours: int
|
||||
auto_assign_to: Optional[uuid.UUID] = None
|
||||
is_active: bool
|
||||
created_at: datetime # ✅ AÑADIDO
|
||||
updated_at: datetime # ✅ AÑADIDO
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ===================================
|
||||
# ENDPOINTS
|
||||
@@ -68,44 +31,87 @@ async def read_categories(
|
||||
Listar categorías del tenant del usuario actual.
|
||||
|
||||
✅ Implementa multi-tenancy: solo muestra categorías del tenant del usuario.
|
||||
✅ Optimizado con caché Redis (TTL: 10 minutos)
|
||||
"""
|
||||
# ✅ CORREGIDO: Filtrar por tenant_id
|
||||
# Intentar obtener del caché
|
||||
cache_key_str = cache_key("categories", "tenant", str(current_user.tenant_id), f"skip-{skip}", f"limit-{limit}")
|
||||
cached_categories = await cache.get(cache_key_str)
|
||||
|
||||
if cached_categories is not None:
|
||||
return [CategoryResponse(**cat) for cat in cached_categories]
|
||||
|
||||
# Si no está en caché, consultar BD
|
||||
query = select(Category).where(
|
||||
Category.tenant_id == current_user.tenant_id
|
||||
).offset(skip).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
categories = result.scalars().all()
|
||||
|
||||
# Guardar en caché (10 minutos)
|
||||
categories_dict = [
|
||||
{
|
||||
"id": str(cat.id),
|
||||
"name": cat.name,
|
||||
"description": cat.description,
|
||||
"sla_response_hours": cat.sla_response_hours,
|
||||
"sla_resolution_hours": cat.sla_resolution_hours,
|
||||
"is_active": cat.is_active,
|
||||
"tenant_id": str(cat.tenant_id),
|
||||
"created_at": cat.created_at.isoformat(),
|
||||
"updated_at": cat.updated_at.isoformat()
|
||||
}
|
||||
for cat in categories
|
||||
]
|
||||
await cache.set(cache_key_str, categories_dict, ttl=600)
|
||||
|
||||
return categories
|
||||
|
||||
|
||||
@router.post("/", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_category(
|
||||
category: CategoryCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_user)
|
||||
current_user: User = Depends(deps.get_current_user) # ✅ CORREGIDO: Type hint
|
||||
):
|
||||
"""
|
||||
Crear nueva categoría en el tenant del usuario actual.
|
||||
|
||||
**Permisos**: Solo ADMIN y SUPPORT_MANAGER pueden crear categorías.
|
||||
✅ Implementa multi-tenancy: asigna automáticamente tenant_id del usuario.
|
||||
"""
|
||||
# Verificar permisos
|
||||
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="No tienes permisos para crear categorías"
|
||||
)
|
||||
|
||||
# Asignar tenant_id del usuario actual
|
||||
# ✅ CORREGIDO: Asignar tenant_id del usuario actual
|
||||
db_category = Category(
|
||||
**category.model_dump(),
|
||||
tenant_id=current_user.tenant_id
|
||||
tenant_id=current_user.tenant_id # ✅ Multi-tenancy automático
|
||||
)
|
||||
|
||||
db.add(db_category)
|
||||
await db.commit()
|
||||
await db.refresh(db_category)
|
||||
|
||||
# Invalidar caché de categorías para este tenant
|
||||
await cache.delete_pattern(f"categories:tenant:{current_user.tenant_id}:*")
|
||||
|
||||
# Registrar creación en auditoría
|
||||
try:
|
||||
await AuditService.log(
|
||||
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
|
||||
|
||||
|
||||
@@ -146,16 +152,8 @@ async def update_category(
|
||||
"""
|
||||
Actualizar categoría del tenant.
|
||||
|
||||
**Permisos**: Solo ADMIN y SUPPORT_MANAGER pueden actualizar categorías.
|
||||
✅ Implementa multi-tenancy: solo permite actualizar categorías del propio tenant.
|
||||
"""
|
||||
# Verificar permisos
|
||||
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="No tienes permisos para actualizar categorías"
|
||||
)
|
||||
|
||||
query = select(Category).where(
|
||||
Category.id == category_id,
|
||||
Category.tenant_id == current_user.tenant_id
|
||||
@@ -169,6 +167,14 @@ async def update_category(
|
||||
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
|
||||
update_data = category_update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
@@ -176,6 +182,32 @@ async def update_category(
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(db_category)
|
||||
|
||||
# Invalidar caché de categorías para este tenant
|
||||
await cache.delete_pattern(f"categories:tenant:{current_user.tenant_id}:*")
|
||||
|
||||
# Registrar actualización en auditoría
|
||||
try:
|
||||
new_values = {
|
||||
"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
|
||||
|
||||
|
||||
@@ -188,16 +220,8 @@ async def delete_category(
|
||||
"""
|
||||
Desactivar categoría del tenant (soft delete).
|
||||
|
||||
**Permisos**: Solo ADMIN y SUPPORT_MANAGER pueden desactivar categorías.
|
||||
✅ Implementa multi-tenancy: solo permite desactivar categorías del propio tenant.
|
||||
"""
|
||||
# Verificar permisos
|
||||
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="No tienes permisos para desactivar categorías"
|
||||
)
|
||||
|
||||
query = select(Category).where(
|
||||
Category.id == category_id,
|
||||
Category.tenant_id == current_user.tenant_id
|
||||
@@ -211,7 +235,33 @@ async def delete_category(
|
||||
detail="Category not found"
|
||||
)
|
||||
|
||||
# Guardar valores para auditoría
|
||||
old_values = {
|
||||
"name": db_category.name,
|
||||
"is_active": db_category.is_active
|
||||
}
|
||||
|
||||
# Soft delete
|
||||
db_category.is_active = False
|
||||
await db.commit()
|
||||
|
||||
# Invalidar caché de categorías para este tenant
|
||||
await cache.delete_pattern(f"categories:tenant:{current_user.tenant_id}:*")
|
||||
|
||||
# Registrar eliminación en auditoría
|
||||
try:
|
||||
await AuditService.log(
|
||||
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
|
||||
@@ -50,49 +50,14 @@ async def get_current_client_profile(
|
||||
profile = result.scalar_one_or_none()
|
||||
|
||||
if not profile:
|
||||
# Si no existe, devolver un perfil vacío con solo tenant_id
|
||||
# No crear en base de datos hasta que el usuario guarde
|
||||
return ClientProfileResponse(
|
||||
id=None,
|
||||
tenant_id=current_tenant.id,
|
||||
business_name=None,
|
||||
commercial_name=None,
|
||||
client_code=None,
|
||||
client_type=None,
|
||||
rfc=None,
|
||||
tax_id=None,
|
||||
country=None,
|
||||
state=None,
|
||||
city=None,
|
||||
address=None,
|
||||
external_number=None,
|
||||
internal_number=None,
|
||||
postal_code=None,
|
||||
neighborhood=None,
|
||||
main_phone=None,
|
||||
secondary_phone=None,
|
||||
direct_phone=None,
|
||||
phone_extension=None,
|
||||
fax=None,
|
||||
business_hours=None,
|
||||
website=None,
|
||||
main_email=None,
|
||||
billing_email=None,
|
||||
advertising_medium=None,
|
||||
nationality=None,
|
||||
logo_url=None,
|
||||
company_representative=None,
|
||||
legal_representative=None,
|
||||
credit_limit=None,
|
||||
payment_terms=None,
|
||||
preferred_currency="MXN",
|
||||
send_to_billing=False,
|
||||
is_active_client=True,
|
||||
is_prospect=False,
|
||||
notes=None,
|
||||
created_at=None,
|
||||
updated_at=None
|
||||
# Si no existe, crear uno vacío con valores por defecto explícitos
|
||||
profile = ClientProfile(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=current_tenant.id
|
||||
)
|
||||
db.add(profile)
|
||||
await db.commit()
|
||||
await db.refresh(profile)
|
||||
|
||||
return profile
|
||||
|
||||
|
||||
764
backend/app/api/v1/endpoints/reports.py
Normal file
764
backend/app/api/v1/endpoints/reports.py
Normal file
@@ -0,0 +1,764 @@
|
||||
"""
|
||||
Reports Endpoints - ServiceManagerWeb
|
||||
|
||||
Módulo de reportes y estadísticas del sistema.
|
||||
Accesible por ADMIN y SUPPORT_MANAGER.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, case, text, literal_column
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import uuid
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.api.deps import get_current_user
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.ticket import Ticket, TicketStatus, TicketPriority
|
||||
from app.models.category import Category
|
||||
from app.models.system import System
|
||||
from app.models.tenant import Tenant, TenantStatus
|
||||
from app.api.schemas.reports import (
|
||||
ReportSummaryResponse,
|
||||
TicketsByStatus,
|
||||
TicketsByPriority,
|
||||
AgentReportResponse,
|
||||
AgentReportRow,
|
||||
CategoryReportResponse,
|
||||
CategoryReportRow,
|
||||
ClientReportResponse,
|
||||
ClientReportRow,
|
||||
TrendsReportResponse,
|
||||
TrendDataPoint,
|
||||
CSATReportResponse,
|
||||
CSATDistribution,
|
||||
SystemReportResponse,
|
||||
SystemReportRow,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
CLOSED_STATUSES = {TicketStatus.RESOLVED, TicketStatus.CLOSED}
|
||||
|
||||
# ===================================
|
||||
# HELPERS
|
||||
# ===================================
|
||||
|
||||
def require_reports_access(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""ADMIN, SUPPORT_MANAGER y AUDITOR pueden leer reportes."""
|
||||
allowed = [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AUDITOR]
|
||||
if current_user.role not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Solo ADMIN, SUPPORT_MANAGER y AUDITOR pueden acceder a los reportes.",
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
def require_admin(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""Solo ADMIN puede ver reportes entre tenants."""
|
||||
if current_user.role != UserRole.ADMIN:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Solo ADMIN puede ver reportes de todos los clientes.",
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
def _period_dates(days: int) -> tuple[datetime, datetime]:
|
||||
"""Devuelve (inicio, fin) del período solicitado en UTC."""
|
||||
end = datetime.now(timezone.utc)
|
||||
start = end - timedelta(days=days)
|
||||
return start, end
|
||||
|
||||
|
||||
# ===================================
|
||||
# 1. RESUMEN GENERAL
|
||||
# ===================================
|
||||
|
||||
@router.get("/summary", response_model=ReportSummaryResponse)
|
||||
async def get_report_summary(
|
||||
days: int = Query(default=30, ge=1, le=365, description="Días hacia atrás del período"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_reports_access),
|
||||
):
|
||||
"""
|
||||
Resumen ejecutivo del período seleccionado.
|
||||
|
||||
Incluye:
|
||||
- Total de tickets creados
|
||||
- Tickets abiertos vs resueltos
|
||||
- Tiempo promedio de resolución
|
||||
- Calificación promedio (CSAT)
|
||||
- Desglose por estado y prioridad
|
||||
- Comparación con el período anterior
|
||||
"""
|
||||
period_start, period_end = _period_dates(days)
|
||||
prev_start = period_start - timedelta(days=days)
|
||||
|
||||
tenant_filter = Ticket.tenant_id == current_user.tenant_id
|
||||
|
||||
# ── Conteos por estado ──
|
||||
status_rows = (await db.execute(
|
||||
select(Ticket.status, func.count(Ticket.id).label("cnt"))
|
||||
.where(and_(tenant_filter, Ticket.created_at >= period_start))
|
||||
.group_by(Ticket.status)
|
||||
)).all()
|
||||
|
||||
by_status = TicketsByStatus()
|
||||
for row in status_rows:
|
||||
s = row.status.value if hasattr(row.status, "value") else str(row.status)
|
||||
setattr(by_status, s.lower(), row.cnt)
|
||||
by_status.total = sum(
|
||||
[by_status.new, by_status.triage, by_status.in_progress,
|
||||
by_status.waiting_customer, by_status.resolved, by_status.closed, by_status.reopened]
|
||||
)
|
||||
|
||||
# ── Conteos por prioridad ──
|
||||
priority_rows = (await db.execute(
|
||||
select(Ticket.priority, func.count(Ticket.id).label("cnt"))
|
||||
.where(and_(tenant_filter, Ticket.created_at >= period_start))
|
||||
.group_by(Ticket.priority)
|
||||
)).all()
|
||||
|
||||
by_priority = TicketsByPriority()
|
||||
for row in priority_rows:
|
||||
p = row.priority.value if hasattr(row.priority, "value") else str(row.priority)
|
||||
setattr(by_priority, p.lower(), row.cnt)
|
||||
by_priority.total = sum([by_priority.low, by_priority.medium, by_priority.high, by_priority.urgent])
|
||||
|
||||
total_tickets = by_status.total
|
||||
resolved_tickets = by_status.resolved + by_status.closed
|
||||
open_tickets = total_tickets - resolved_tickets
|
||||
|
||||
# ── Promedio de tiempo de resolución (segundos → horas) ──
|
||||
res_time_row = (await db.execute(
|
||||
select(func.avg(
|
||||
func.extract("epoch", Ticket.resolved_at - Ticket.created_at)
|
||||
).label("avg_seconds"))
|
||||
.where(and_(
|
||||
tenant_filter,
|
||||
Ticket.created_at >= period_start,
|
||||
Ticket.resolved_at.isnot(None),
|
||||
))
|
||||
)).scalar_one_or_none()
|
||||
avg_resolution_hours = round(res_time_row / 3600, 2) if res_time_row else None
|
||||
|
||||
# ── Promedio de primera respuesta ──
|
||||
resp_time_row = (await db.execute(
|
||||
select(func.avg(
|
||||
func.extract("epoch", Ticket.first_response_at - Ticket.created_at)
|
||||
).label("avg_seconds"))
|
||||
.where(and_(
|
||||
tenant_filter,
|
||||
Ticket.created_at >= period_start,
|
||||
Ticket.first_response_at.isnot(None),
|
||||
))
|
||||
)).scalar_one_or_none()
|
||||
avg_first_response_hours = round(resp_time_row / 3600, 2) if resp_time_row else None
|
||||
|
||||
# ── CSAT ──
|
||||
csat_row = (await db.execute(
|
||||
select(func.avg(Ticket.rating).label("avg"), func.count(Ticket.rating).label("cnt"))
|
||||
.where(and_(tenant_filter, Ticket.created_at >= period_start, Ticket.rating.isnot(None)))
|
||||
)).one()
|
||||
avg_rating = round(float(csat_row.avg), 2) if csat_row.avg else None
|
||||
total_rated = csat_row.cnt or 0
|
||||
|
||||
# ── Comparación con período anterior ──
|
||||
prev_total = (await db.execute(
|
||||
select(func.count(Ticket.id))
|
||||
.where(and_(tenant_filter, Ticket.created_at >= prev_start, Ticket.created_at < period_start))
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
prev_resolved = (await db.execute(
|
||||
select(func.count(Ticket.id))
|
||||
.where(and_(
|
||||
tenant_filter,
|
||||
Ticket.created_at >= prev_start,
|
||||
Ticket.created_at < period_start,
|
||||
Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]),
|
||||
))
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
tickets_change_pct = None
|
||||
if prev_total > 0:
|
||||
tickets_change_pct = round(((total_tickets - prev_total) / prev_total) * 100, 1)
|
||||
|
||||
resolution_change_pct = None
|
||||
if prev_total > 0 and total_tickets > 0:
|
||||
cur_rate = resolved_tickets / total_tickets * 100
|
||||
prev_rate = prev_resolved / prev_total * 100 if prev_total > 0 else 0
|
||||
resolution_change_pct = round(cur_rate - prev_rate, 1)
|
||||
|
||||
return ReportSummaryResponse(
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
total_tickets=total_tickets,
|
||||
open_tickets=open_tickets,
|
||||
resolved_tickets=resolved_tickets,
|
||||
avg_resolution_hours=avg_resolution_hours,
|
||||
avg_first_response_hours=avg_first_response_hours,
|
||||
avg_rating=avg_rating,
|
||||
total_rated=total_rated,
|
||||
by_status=by_status,
|
||||
by_priority=by_priority,
|
||||
tickets_change_pct=tickets_change_pct,
|
||||
resolution_change_pct=resolution_change_pct,
|
||||
)
|
||||
|
||||
|
||||
# ===================================
|
||||
# 2. RENDIMIENTO POR AGENTE
|
||||
# ===================================
|
||||
|
||||
@router.get("/by-agent", response_model=AgentReportResponse)
|
||||
async def get_report_by_agent(
|
||||
days: int = Query(default=30, ge=1, le=365),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_reports_access),
|
||||
):
|
||||
"""
|
||||
Rendimiento de cada agente en el período:
|
||||
- Tickets asignados y resueltos
|
||||
- Tasa de resolución
|
||||
- Tiempo promedio de resolución
|
||||
- Calificación promedio (CSAT)
|
||||
"""
|
||||
period_start, period_end = _period_dates(days)
|
||||
tenant_filter = and_(
|
||||
Ticket.tenant_id == current_user.tenant_id,
|
||||
Ticket.created_at >= period_start,
|
||||
Ticket.assigned_to.isnot(None),
|
||||
)
|
||||
|
||||
# Obtener todos los agentes del tenant
|
||||
agents_result = await db.execute(
|
||||
select(User).where(
|
||||
and_(
|
||||
User.tenant_id == current_user.tenant_id,
|
||||
User.role.in_([UserRole.AGENT, UserRole.SUPPORT_MANAGER, UserRole.ADMIN]),
|
||||
User.is_active == True,
|
||||
)
|
||||
)
|
||||
)
|
||||
agents = agents_result.scalars().all()
|
||||
|
||||
rows: List[AgentReportRow] = []
|
||||
for agent in agents:
|
||||
agent_filter = and_(tenant_filter, Ticket.assigned_to == agent.id)
|
||||
|
||||
total_assigned = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(agent_filter)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
if total_assigned == 0:
|
||||
continue # omitir agentes sin tickets en el período
|
||||
|
||||
resolved = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(agent_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]))
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
avg_res_seconds = (await db.execute(
|
||||
select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at)))
|
||||
.where(and_(agent_filter, Ticket.resolved_at.isnot(None)))
|
||||
)).scalar_one_or_none()
|
||||
|
||||
csat = (await db.execute(
|
||||
select(func.avg(Ticket.rating), func.count(Ticket.rating))
|
||||
.where(and_(agent_filter, Ticket.rating.isnot(None)))
|
||||
)).one()
|
||||
|
||||
urgent_handled = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(agent_filter, Ticket.priority == TicketPriority.URGENT)
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
rows.append(AgentReportRow(
|
||||
agent_id=str(agent.id),
|
||||
agent_name=f"{agent.first_name} {agent.last_name}",
|
||||
agent_email=agent.email,
|
||||
total_assigned=total_assigned,
|
||||
resolved=resolved,
|
||||
open=total_assigned - resolved,
|
||||
resolution_rate=round((resolved / total_assigned * 100), 1) if total_assigned else 0,
|
||||
avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None,
|
||||
avg_rating=round(float(csat[0]), 2) if csat[0] else None,
|
||||
total_rated=csat[1] or 0,
|
||||
urgent_handled=urgent_handled,
|
||||
))
|
||||
|
||||
rows.sort(key=lambda r: r.resolved, reverse=True)
|
||||
|
||||
return AgentReportResponse(
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
agents=rows,
|
||||
total_agents=len(rows),
|
||||
)
|
||||
|
||||
|
||||
# ===================================
|
||||
# 3. TICKETS POR CATEGORÍA
|
||||
# ===================================
|
||||
|
||||
@router.get("/by-category", response_model=CategoryReportResponse)
|
||||
async def get_report_by_category(
|
||||
days: int = Query(default=30, ge=1, le=365),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_reports_access),
|
||||
):
|
||||
"""
|
||||
Tickets agrupados por categoría con tasa de cumplimiento SLA.
|
||||
"""
|
||||
period_start, _ = _period_dates(days)
|
||||
period_end = datetime.now(timezone.utc)
|
||||
tenant_filter = and_(
|
||||
Ticket.tenant_id == current_user.tenant_id,
|
||||
Ticket.created_at >= period_start,
|
||||
)
|
||||
|
||||
categories_result = await db.execute(
|
||||
select(Category).where(
|
||||
and_(Category.tenant_id == current_user.tenant_id, Category.is_active == True)
|
||||
)
|
||||
)
|
||||
categories = categories_result.scalars().all()
|
||||
|
||||
rows: List[CategoryReportRow] = []
|
||||
|
||||
for cat in categories:
|
||||
cat_filter = and_(tenant_filter, Ticket.category_id == cat.id)
|
||||
|
||||
total = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(cat_filter)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
if total == 0:
|
||||
continue
|
||||
|
||||
resolved = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(cat_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]))
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
avg_res_seconds = (await db.execute(
|
||||
select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at)))
|
||||
.where(and_(cat_filter, Ticket.resolved_at.isnot(None)))
|
||||
)).scalar_one_or_none()
|
||||
|
||||
# SLA compliance: tickets resueltos ANTES del deadline
|
||||
sla_met = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(
|
||||
cat_filter,
|
||||
Ticket.resolved_at.isnot(None),
|
||||
Ticket.sla_resolution_due.isnot(None),
|
||||
Ticket.resolved_at <= Ticket.sla_resolution_due,
|
||||
)
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
tickets_with_sla = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(cat_filter, Ticket.sla_resolution_due.isnot(None), Ticket.resolved_at.isnot(None))
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
sla_compliance_pct = round((sla_met / tickets_with_sla * 100), 1) if tickets_with_sla else 0.0
|
||||
|
||||
rows.append(CategoryReportRow(
|
||||
category_id=str(cat.id),
|
||||
category_name=cat.name,
|
||||
total_tickets=total,
|
||||
open_tickets=total - resolved,
|
||||
resolved_tickets=resolved,
|
||||
avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None,
|
||||
sla_response_hours=cat.sla_response_hours,
|
||||
sla_resolution_hours=cat.sla_resolution_hours,
|
||||
sla_compliance_pct=sla_compliance_pct,
|
||||
))
|
||||
|
||||
# Sin categoría
|
||||
uncategorized = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(tenant_filter, Ticket.category_id.is_(None))
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
rows.sort(key=lambda r: r.total_tickets, reverse=True)
|
||||
|
||||
return CategoryReportResponse(
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
categories=rows,
|
||||
uncategorized_count=uncategorized,
|
||||
)
|
||||
|
||||
|
||||
# ===================================
|
||||
# 4. TICKETS POR CLIENTE (solo ADMIN)
|
||||
# ===================================
|
||||
|
||||
@router.get("/by-client", response_model=ClientReportResponse)
|
||||
async def get_report_by_client(
|
||||
days: int = Query(default=30, ge=1, le=365),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""
|
||||
Tickets agrupados por cliente (tenant). Solo accesible por ADMIN.
|
||||
Útil para ver qué clientes generan más trabajo.
|
||||
"""
|
||||
period_start, period_end = _period_dates(days)
|
||||
|
||||
tenants_result = await db.execute(select(Tenant).where(Tenant.status == TenantStatus.ACTIVE))
|
||||
tenants = tenants_result.scalars().all()
|
||||
|
||||
rows: List[ClientReportRow] = []
|
||||
|
||||
for tenant in tenants:
|
||||
t_filter = and_(
|
||||
Ticket.tenant_id == tenant.id,
|
||||
Ticket.created_at >= period_start,
|
||||
)
|
||||
|
||||
total = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(t_filter)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
if total == 0:
|
||||
continue
|
||||
|
||||
resolved = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(t_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]))
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
urgent = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(t_filter, Ticket.priority == TicketPriority.URGENT)
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
csat_row = (await db.execute(
|
||||
select(func.avg(Ticket.rating))
|
||||
.where(and_(t_filter, Ticket.rating.isnot(None)))
|
||||
)).scalar_one_or_none()
|
||||
|
||||
avg_res_seconds = (await db.execute(
|
||||
select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at)))
|
||||
.where(and_(t_filter, Ticket.resolved_at.isnot(None)))
|
||||
)).scalar_one_or_none()
|
||||
|
||||
last_ticket = (await db.execute(
|
||||
select(func.max(Ticket.created_at)).where(t_filter)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
rows.append(ClientReportRow(
|
||||
tenant_id=str(tenant.id),
|
||||
tenant_name=tenant.name,
|
||||
total_tickets=total,
|
||||
open_tickets=total - resolved,
|
||||
resolved_tickets=resolved,
|
||||
urgent_tickets=urgent,
|
||||
avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None,
|
||||
avg_rating=round(float(csat_row), 2) if csat_row else None,
|
||||
last_ticket_at=last_ticket,
|
||||
))
|
||||
|
||||
rows.sort(key=lambda r: r.total_tickets, reverse=True)
|
||||
|
||||
return ClientReportResponse(
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
clients=rows,
|
||||
total_clients=len(rows),
|
||||
)
|
||||
|
||||
|
||||
# ===================================
|
||||
# 5. TENDENCIAS (TICKETS EN EL TIEMPO)
|
||||
# ===================================
|
||||
|
||||
@router.get("/trends", response_model=TrendsReportResponse)
|
||||
async def get_report_trends(
|
||||
days: int = Query(default=30, ge=7, le=90, description="Número de días (7-90)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_reports_access),
|
||||
):
|
||||
"""
|
||||
Evolución diaria de tickets creados y resueltos.
|
||||
Útil para detectar picos de trabajo.
|
||||
"""
|
||||
period_start, period_end = _period_dates(days)
|
||||
tenant_filter = Ticket.tenant_id == current_user.tenant_id
|
||||
|
||||
# Tickets creados por día
|
||||
# literal_column("'day'") evita que SQLAlchemy genere múltiples parámetros
|
||||
# ($1, $4, $5) para 'day', lo que confunde a PostgreSQL en el GROUP BY.
|
||||
_day_lit = literal_column("'day'")
|
||||
created_rows = (await db.execute(
|
||||
select(
|
||||
func.date_trunc(_day_lit, Ticket.created_at).label("day"),
|
||||
func.count(Ticket.id).label("cnt"),
|
||||
)
|
||||
.where(and_(tenant_filter, Ticket.created_at >= period_start))
|
||||
.group_by(func.date_trunc(_day_lit, Ticket.created_at))
|
||||
.order_by(func.date_trunc(_day_lit, Ticket.created_at))
|
||||
)).all()
|
||||
|
||||
# Tickets resueltos por día (según resolved_at)
|
||||
resolved_rows = (await db.execute(
|
||||
select(
|
||||
func.date_trunc(_day_lit, Ticket.resolved_at).label("day"),
|
||||
func.count(Ticket.id).label("cnt"),
|
||||
)
|
||||
.where(and_(
|
||||
tenant_filter,
|
||||
Ticket.resolved_at >= period_start,
|
||||
Ticket.resolved_at.isnot(None),
|
||||
))
|
||||
.group_by(func.date_trunc(_day_lit, Ticket.resolved_at))
|
||||
.order_by(func.date_trunc(_day_lit, Ticket.resolved_at))
|
||||
)).all()
|
||||
|
||||
created_map: dict[str, int] = {r.day.strftime("%Y-%m-%d"): r.cnt for r in created_rows}
|
||||
resolved_map: dict[str, int] = {r.day.strftime("%Y-%m-%d"): r.cnt for r in resolved_rows}
|
||||
|
||||
# Un punto por cada día del período
|
||||
data_points: List[TrendDataPoint] = []
|
||||
current = period_start
|
||||
while current <= period_end:
|
||||
date_str = current.strftime("%Y-%m-%d")
|
||||
c = created_map.get(date_str, 0)
|
||||
r = resolved_map.get(date_str, 0)
|
||||
data_points.append(TrendDataPoint(date=date_str, created=c, resolved=r, net_open=c - r))
|
||||
current += timedelta(days=1)
|
||||
|
||||
return TrendsReportResponse(
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
data_points=data_points,
|
||||
total_days=len(data_points),
|
||||
)
|
||||
|
||||
|
||||
# ===================================
|
||||
# 6. SATISFACCIÓN DEL CLIENTE (CSAT)
|
||||
# ===================================
|
||||
|
||||
@router.get("/csat", response_model=CSATReportResponse)
|
||||
async def get_report_csat(
|
||||
days: int = Query(default=30, ge=1, le=365),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_reports_access),
|
||||
):
|
||||
"""
|
||||
Reporte de satisfacción del cliente (calificaciones 1-5).
|
||||
Incluye distribución, promedio por categoría y por agente.
|
||||
"""
|
||||
period_start, period_end = _period_dates(days)
|
||||
tenant_filter = and_(
|
||||
Ticket.tenant_id == current_user.tenant_id,
|
||||
Ticket.created_at >= period_start,
|
||||
)
|
||||
|
||||
# Total y promedio general
|
||||
general = (await db.execute(
|
||||
select(func.avg(Ticket.rating).label("avg"), func.count(Ticket.rating).label("rated"))
|
||||
.where(and_(tenant_filter, Ticket.rating.isnot(None)))
|
||||
)).one()
|
||||
total_tickets = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(tenant_filter)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
# Distribución por estrellas
|
||||
dist_rows = (await db.execute(
|
||||
select(Ticket.rating, func.count(Ticket.id).label("cnt"))
|
||||
.where(and_(tenant_filter, Ticket.rating.isnot(None)))
|
||||
.group_by(Ticket.rating)
|
||||
)).all()
|
||||
|
||||
dist = CSATDistribution()
|
||||
for row in dist_rows:
|
||||
setattr(dist, f"rating_{row.rating}", row.cnt)
|
||||
|
||||
# Promedio por categoría
|
||||
cat_rows = (await db.execute(
|
||||
select(
|
||||
Category.name.label("cat_name"),
|
||||
func.avg(Ticket.rating).label("avg"),
|
||||
func.count(Ticket.rating).label("cnt"),
|
||||
)
|
||||
.join(Category, Ticket.category_id == Category.id, isouter=True)
|
||||
.where(and_(tenant_filter, Ticket.rating.isnot(None)))
|
||||
.group_by(Category.name)
|
||||
.order_by(func.avg(Ticket.rating).desc())
|
||||
)).all()
|
||||
|
||||
by_category = [
|
||||
{
|
||||
"category": row.cat_name or "Sin categoría",
|
||||
"avg_rating": round(float(row.avg), 2) if row.avg else None,
|
||||
"total_rated": row.cnt,
|
||||
}
|
||||
for row in cat_rows
|
||||
]
|
||||
|
||||
# Promedio por agente
|
||||
agent_rows = (await db.execute(
|
||||
select(
|
||||
User.first_name.label("fname"),
|
||||
User.last_name.label("lname"),
|
||||
func.avg(Ticket.rating).label("avg"),
|
||||
func.count(Ticket.rating).label("cnt"),
|
||||
)
|
||||
.join(User, Ticket.assigned_to == User.id, isouter=True)
|
||||
.where(and_(tenant_filter, Ticket.rating.isnot(None)))
|
||||
.group_by(User.first_name, User.last_name)
|
||||
.order_by(func.avg(Ticket.rating).desc())
|
||||
)).all()
|
||||
|
||||
by_agent = [
|
||||
{
|
||||
"agent": f"{row.fname or ''} {row.lname or ''}".strip() or "Sin asignar",
|
||||
"avg_rating": round(float(row.avg), 2) if row.avg else None,
|
||||
"total_rated": row.cnt,
|
||||
}
|
||||
for row in agent_rows
|
||||
]
|
||||
|
||||
# Últimos comentarios de calificación (rating_comment)
|
||||
comment_rows = (await db.execute(
|
||||
select(Ticket.rating, Ticket.rating_comment, Ticket.rated_at)
|
||||
.where(and_(
|
||||
tenant_filter,
|
||||
Ticket.rating.isnot(None),
|
||||
Ticket.rating_comment.isnot(None),
|
||||
Ticket.rating_comment != "",
|
||||
))
|
||||
.order_by(Ticket.rated_at.desc())
|
||||
.limit(10)
|
||||
)).all()
|
||||
|
||||
recent_comments = [
|
||||
{
|
||||
"rating": row.rating,
|
||||
"comment": row.rating_comment,
|
||||
"rated_at": row.rated_at.isoformat() if row.rated_at else None,
|
||||
}
|
||||
for row in comment_rows
|
||||
]
|
||||
|
||||
total_rated = general.rated or 0
|
||||
response_rate = round((total_rated / total_tickets * 100), 1) if total_tickets else 0.0
|
||||
|
||||
return CSATReportResponse(
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
avg_rating=round(float(general.avg), 2) if general.avg else None,
|
||||
total_rated=total_rated,
|
||||
total_tickets=total_tickets,
|
||||
response_rate=response_rate,
|
||||
distribution=dist,
|
||||
by_category=by_category,
|
||||
by_agent=by_agent,
|
||||
recent_comments=recent_comments,
|
||||
)
|
||||
|
||||
|
||||
# ===================================
|
||||
# 7. TICKETS POR SISTEMA AFECTADO
|
||||
# ===================================
|
||||
|
||||
@router.get("/by-system", response_model=SystemReportResponse)
|
||||
async def get_report_by_system(
|
||||
days: int = Query(default=30, ge=1, le=365),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_reports_access),
|
||||
):
|
||||
"""
|
||||
Tickets agrupados por sistema afectado.
|
||||
Útil para detectar qué sistemas generan más incidentes.
|
||||
"""
|
||||
period_start, period_end = _period_dates(days)
|
||||
tenant_filter = and_(
|
||||
Ticket.tenant_id == current_user.tenant_id,
|
||||
Ticket.created_at >= period_start,
|
||||
)
|
||||
|
||||
systems_result = await db.execute(
|
||||
select(System).where(
|
||||
and_(System.tenant_id == current_user.tenant_id, System.is_active == True)
|
||||
)
|
||||
)
|
||||
systems = systems_result.scalars().all()
|
||||
|
||||
rows: List[SystemReportRow] = []
|
||||
|
||||
for sys in systems:
|
||||
sys_filter = and_(tenant_filter, Ticket.affected_system_id == sys.id)
|
||||
|
||||
total = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(sys_filter)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
if total == 0:
|
||||
continue
|
||||
|
||||
resolved = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(sys_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]))
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
urgent = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(sys_filter, Ticket.priority == TicketPriority.URGENT)
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
avg_res_seconds = (await db.execute(
|
||||
select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at)))
|
||||
.where(and_(sys_filter, Ticket.resolved_at.isnot(None)))
|
||||
)).scalar_one_or_none()
|
||||
|
||||
rows.append(SystemReportRow(
|
||||
system_id=str(sys.id),
|
||||
system_name=sys.name,
|
||||
total_tickets=total,
|
||||
open_tickets=total - resolved,
|
||||
resolved_tickets=resolved,
|
||||
urgent_tickets=urgent,
|
||||
avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None,
|
||||
))
|
||||
|
||||
# Sin sistema asignado
|
||||
no_system = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(tenant_filter, Ticket.affected_system_id.is_(None))
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
rows.sort(key=lambda r: r.total_tickets, reverse=True)
|
||||
|
||||
return SystemReportResponse(
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
systems=rows,
|
||||
no_system_count=no_system,
|
||||
)
|
||||
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
@@ -9,37 +8,11 @@ import uuid
|
||||
from app.core.database import get_db
|
||||
from app.models.system import System
|
||||
from app.models.user import User
|
||||
from app.api import deps
|
||||
from app.api import deps
|
||||
from app.api.schemas.system import SystemCreate, SystemUpdate, SystemResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ===================================
|
||||
# PYDANTIC SCHEMAS
|
||||
# ===================================
|
||||
|
||||
class SystemCreate(BaseModel):
|
||||
"""Schema para crear sistema - NO incluye tenant_id (se asigna automáticamente)"""
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
|
||||
class SystemUpdate(BaseModel):
|
||||
"""Schema para actualizar sistema"""
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
class SystemResponse(BaseModel):
|
||||
"""Schema de respuesta - incluye todos los campos"""
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID # ✅ AÑADIDO
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
is_active: bool
|
||||
created_at: datetime # ✅ AÑADIDO
|
||||
updated_at: datetime # ✅ AÑADIDO
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ===================================
|
||||
# ENDPOINTS
|
||||
@@ -70,25 +43,17 @@ async def read_systems(
|
||||
async def create_system(
|
||||
system: SystemCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_user)
|
||||
current_user: User = Depends(deps.get_current_user) # ✅ CORREGIDO: Type hint
|
||||
):
|
||||
"""
|
||||
Crear nuevo sistema en el tenant del usuario actual.
|
||||
|
||||
**Permisos**: Solo ADMIN y SUPPORT_MANAGER pueden crear sistemas.
|
||||
✅ Implementa multi-tenancy: asigna automáticamente tenant_id del usuario.
|
||||
"""
|
||||
# Verificar permisos
|
||||
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="No tienes permisos para crear sistemas"
|
||||
)
|
||||
|
||||
# Asignar tenant_id del usuario actual
|
||||
# ✅ CORREGIDO: Asignar tenant_id del usuario actual
|
||||
db_system = System(
|
||||
**system.model_dump(),
|
||||
tenant_id=current_user.tenant_id
|
||||
tenant_id=current_user.tenant_id # ✅ Multi-tenancy automático
|
||||
)
|
||||
|
||||
db.add(db_system)
|
||||
@@ -134,16 +99,8 @@ async def update_system(
|
||||
"""
|
||||
Actualizar sistema del tenant.
|
||||
|
||||
**Permisos**: Solo ADMIN y SUPPORT_MANAGER pueden actualizar sistemas.
|
||||
✅ Implementa multi-tenancy: solo permite actualizar sistemas del propio tenant.
|
||||
"""
|
||||
# Verificar permisos
|
||||
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="No tienes permisos para actualizar sistemas"
|
||||
)
|
||||
|
||||
query = select(System).where(
|
||||
System.id == system_id,
|
||||
System.tenant_id == current_user.tenant_id
|
||||
@@ -176,16 +133,8 @@ async def delete_system(
|
||||
"""
|
||||
Desactivar sistema del tenant (soft delete).
|
||||
|
||||
**Permisos**: Solo ADMIN y SUPPORT_MANAGER pueden desactivar sistemas.
|
||||
✅ Implementa multi-tenancy: solo permite desactivar sistemas del propio tenant.
|
||||
"""
|
||||
# Verificar permisos
|
||||
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="No tienes permisos para desactivar sistemas"
|
||||
)
|
||||
|
||||
query = select(System).where(
|
||||
System.id == system_id,
|
||||
System.tenant_id == current_user.tenant_id
|
||||
|
||||
@@ -1,38 +1,16 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.tenant import Tenant, TenantStatus
|
||||
from app.api import deps
|
||||
from app.api import deps
|
||||
from app.api.schemas.tenant import TenantBase, TenantCreate, TenantUpdate, TenantResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class TenantBase(BaseModel):
|
||||
name: str
|
||||
slug: str
|
||||
domain: Optional[str] = None
|
||||
contact_email: Optional[EmailStr] = None
|
||||
|
||||
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])
|
||||
async def read_tenants(
|
||||
skip: int = 0,
|
||||
@@ -86,12 +64,16 @@ async def update_tenant(
|
||||
|
||||
update_data = tenant_in.model_dump(exclude_unset=True)
|
||||
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():
|
||||
setattr(tenant, field, value)
|
||||
|
||||
db.add(tenant)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(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"
|
||||
)
|
||||
@@ -1,7 +1,6 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
@@ -9,63 +8,25 @@ import uuid
|
||||
from app.core.database import get_db
|
||||
from app.core.security import security
|
||||
from app.models.user import User, UserRole
|
||||
from app.api import deps
|
||||
from app.services.audit_service import AuditService
|
||||
from app.api import deps
|
||||
from app.api.schemas.user import UserCreate, UserUpdate, UserResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ===================================
|
||||
# PYDANTIC SCHEMAS
|
||||
# ===================================
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
"""Schema para crear usuario - NO incluye tenant_id (se asigna automáticamente)"""
|
||||
email: EmailStr
|
||||
first_name: str
|
||||
last_name: str
|
||||
role: UserRole
|
||||
password: str
|
||||
language: str = "es"
|
||||
timezone: str = "UTC"
|
||||
notifications_email: bool = True
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
"""Schema para actualizar usuario"""
|
||||
email: Optional[EmailStr] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
role: Optional[UserRole] = None
|
||||
is_active: Optional[bool] = None
|
||||
password: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
timezone: Optional[str] = None
|
||||
notifications_email: Optional[bool] = None
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""Schema de respuesta - incluye todos los campos públicos"""
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
email: EmailStr
|
||||
first_name: str
|
||||
last_name: str
|
||||
avatar_url: Optional[str] = None
|
||||
role: UserRole
|
||||
is_active: bool
|
||||
email_verified: bool
|
||||
last_login: Optional[datetime] = None
|
||||
language: str
|
||||
timezone: str
|
||||
notifications_email: bool
|
||||
totp_enabled: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ===================================
|
||||
# ENDPOINTS
|
||||
# ===================================
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def read_current_user(
|
||||
current_user: User = Depends(deps.get_current_user),
|
||||
):
|
||||
"""Obtener el perfil del usuario actual."""
|
||||
return current_user
|
||||
|
||||
@router.get("/", response_model=List[UserResponse])
|
||||
async def read_users(
|
||||
skip: int = 0,
|
||||
@@ -84,8 +45,12 @@ async def read_users(
|
||||
- role: filtrar por rol
|
||||
- is_active: filtrar por estado activo
|
||||
"""
|
||||
# ✅ CORREGIDO: Filtrar por tenant_id
|
||||
query = select(User).where(User.tenant_id == current_user.tenant_id)
|
||||
# ADMIN global ve todos los tenants; el resto solo ve su propio tenant
|
||||
from app.models.user import UserRole as _UserRole
|
||||
if current_user.role != _UserRole.ADMIN:
|
||||
query = select(User).where(User.tenant_id == current_user.tenant_id)
|
||||
else:
|
||||
query = select(User)
|
||||
|
||||
# Aplicar filtros opcionales
|
||||
if role:
|
||||
@@ -147,6 +112,28 @@ async def create_user(
|
||||
db.add(db_user)
|
||||
await db.commit()
|
||||
await db.refresh(db_user)
|
||||
|
||||
# Registrar creación en auditoría
|
||||
try:
|
||||
await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=current_user.tenant_id,
|
||||
user_id=current_user.id,
|
||||
action="user.create",
|
||||
resource_type="user",
|
||||
resource_id=db_user.id,
|
||||
new_values=AuditService.sanitize_values({
|
||||
"email": db_user.email,
|
||||
"first_name": db_user.first_name,
|
||||
"last_name": db_user.last_name,
|
||||
"role": db_user.role.value
|
||||
})
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
# No fallar si falla el audit log
|
||||
pass
|
||||
|
||||
return db_user
|
||||
|
||||
|
||||
@@ -214,6 +201,15 @@ async def update_user(
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
# Guardar valores anteriores para audit
|
||||
old_values = {
|
||||
"email": db_user.email,
|
||||
"first_name": db_user.first_name,
|
||||
"last_name": db_user.last_name,
|
||||
"role": db_user.role.value,
|
||||
"is_active": db_user.is_active
|
||||
}
|
||||
|
||||
# Verificar email único si se está cambiando
|
||||
update_data = user_update.model_dump(exclude_unset=True)
|
||||
if "email" in update_data and update_data["email"] != db_user.email:
|
||||
@@ -239,6 +235,32 @@ async def update_user(
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(db_user)
|
||||
|
||||
# Registrar actualización en auditoría
|
||||
try:
|
||||
new_values = {
|
||||
"email": db_user.email,
|
||||
"first_name": db_user.first_name,
|
||||
"last_name": db_user.last_name,
|
||||
"role": db_user.role.value,
|
||||
"is_active": db_user.is_active
|
||||
}
|
||||
|
||||
await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=current_user.tenant_id,
|
||||
user_id=current_user.id,
|
||||
action="user.update",
|
||||
resource_type="user",
|
||||
resource_id=db_user.id,
|
||||
old_values=AuditService.sanitize_values(old_values),
|
||||
new_values=AuditService.sanitize_values(new_values)
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
# No fallar si falla el audit log
|
||||
pass
|
||||
|
||||
return db_user
|
||||
|
||||
|
||||
@@ -306,6 +328,28 @@ async def delete_user(
|
||||
# Soft delete
|
||||
db_user.is_active = False
|
||||
await db.commit()
|
||||
|
||||
# Registrar eliminación en auditoría
|
||||
try:
|
||||
await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=current_user.tenant_id,
|
||||
user_id=current_user.id,
|
||||
action="user.delete",
|
||||
resource_type="user",
|
||||
resource_id=db_user.id,
|
||||
old_values={
|
||||
"email": db_user.email,
|
||||
"role": db_user.role.value,
|
||||
"was_active": True
|
||||
},
|
||||
metadata={"action_type": "soft_delete"}
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
# No fallar si falla el audit log
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
||||
117
backend/app/api/v1/helpers.py
Normal file
117
backend/app/api/v1/helpers.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
Helper functions for API endpoints
|
||||
"""
|
||||
import uuid
|
||||
from typing import Any, Type
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Query
|
||||
from datetime import datetime, timedelta
|
||||
from app.models.user import User
|
||||
from app.models.ticket import Ticket
|
||||
from app.models.category import Category
|
||||
from app.services.audit_service import AuditService
|
||||
|
||||
|
||||
def validate_uuid_param(value: str, param_name: str = "ID") -> uuid.UUID:
|
||||
"""Valida y convierte string a UUID"""
|
||||
try:
|
||||
return uuid.UUID(value)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid {param_name} format"
|
||||
)
|
||||
|
||||
|
||||
def apply_client_permissions(query: Query, model: Type, current_user: User) -> Query:
|
||||
"""Aplica filtros de tenant y permisos de cliente"""
|
||||
query = query.where(model.tenant_id == current_user.tenant_id)
|
||||
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
|
||||
query = query.where(model.created_by == current_user.id)
|
||||
return query
|
||||
|
||||
|
||||
def apply_enum_filter(query: Query, model_field: Any, filter_value: str,
|
||||
enum_class: Type, filter_name: str) -> Query:
|
||||
"""Aplica filtro de enum genérico"""
|
||||
if filter_value:
|
||||
try:
|
||||
enum_val = enum_class[filter_value.upper()]
|
||||
return query.where(model_field == enum_val)
|
||||
except KeyError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid {filter_name}: {filter_value}"
|
||||
)
|
||||
return query
|
||||
|
||||
|
||||
async def safe_audit_log(db: AsyncSession, **kwargs):
|
||||
"""Registra en auditoría sin fallar la operación principal"""
|
||||
try:
|
||||
await AuditService.log(db=db, **kwargs)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
pass # Silent fail para audit logs
|
||||
|
||||
|
||||
async def generate_next_ticket_number(db: AsyncSession, tenant_id: uuid.UUID) -> str:
|
||||
"""Genera el siguiente número de ticket único para el tenant"""
|
||||
result = await db.execute(
|
||||
select(Ticket.ticket_number)
|
||||
.where(Ticket.tenant_id == tenant_id)
|
||||
.order_by(Ticket.ticket_number.desc())
|
||||
.limit(1)
|
||||
)
|
||||
last_ticket_number = result.scalar_one_or_none()
|
||||
|
||||
if last_ticket_number:
|
||||
last_number = int(last_ticket_number.split('-')[-1])
|
||||
next_number = last_number + 1
|
||||
else:
|
||||
next_number = 1
|
||||
|
||||
return f"TK-{next_number:06d}"
|
||||
|
||||
|
||||
def calculate_sla_deadlines(category: Category = None) -> tuple[datetime, datetime]:
|
||||
"""Calcula SLA response y resolution deadlines"""
|
||||
if not category:
|
||||
return None, None
|
||||
|
||||
now = datetime.utcnow()
|
||||
sla_response_due = now + timedelta(hours=category.sla_response_hours)
|
||||
sla_resolution_due = now + timedelta(hours=category.sla_resolution_hours)
|
||||
return sla_response_due, sla_resolution_due
|
||||
|
||||
|
||||
def ticket_to_dict(ticket: Ticket) -> dict:
|
||||
"""Convierte un modelo Ticket a diccionario de respuesta"""
|
||||
return {
|
||||
"id": str(ticket.id),
|
||||
"ticket_number": ticket.ticket_number,
|
||||
"subject": ticket.subject,
|
||||
"title": ticket.subject,
|
||||
"description": ticket.description,
|
||||
"status": ticket.status.value,
|
||||
"priority": ticket.priority.value,
|
||||
"category_id": str(ticket.category_id) if ticket.category_id else None,
|
||||
"category_name": ticket.category.name if ticket.category else None,
|
||||
"affected_system_id": str(ticket.affected_system_id) if ticket.affected_system_id else None,
|
||||
"system_id": str(ticket.affected_system_id) if ticket.affected_system_id else None,
|
||||
"affected_system_name": ticket.affected_system.name if ticket.affected_system else None,
|
||||
"contact_email": None,
|
||||
"contact_phone": None,
|
||||
"created_by": str(ticket.created_by),
|
||||
"assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None,
|
||||
"assigned_to_name": f"{ticket.assigned_to_user.first_name} {ticket.assigned_to_user.last_name}" if ticket.assigned_to_user else None,
|
||||
"created_at": ticket.created_at,
|
||||
"updated_at": ticket.updated_at,
|
||||
"sla_response_due": ticket.sla_response_due,
|
||||
"sla_resolution_due": ticket.sla_resolution_due,
|
||||
"first_response_at": ticket.first_response_at,
|
||||
"resolved_at": ticket.resolved_at,
|
||||
"tenant_id": str(ticket.tenant_id)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ Router principal para la API v1
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile
|
||||
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla, reports
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -59,4 +59,25 @@ api_router.include_router(
|
||||
client_profile.router,
|
||||
prefix="/client-profile",
|
||||
tags=["client-profile"]
|
||||
)
|
||||
|
||||
# Audit routes
|
||||
api_router.include_router(
|
||||
audit.router,
|
||||
prefix="/audit",
|
||||
tags=["audit"]
|
||||
)
|
||||
|
||||
# SLA routes
|
||||
api_router.include_router(
|
||||
sla.router,
|
||||
prefix="/sla",
|
||||
tags=["sla"]
|
||||
)
|
||||
|
||||
# Reports routes
|
||||
api_router.include_router(
|
||||
reports.router,
|
||||
prefix="/reports",
|
||||
tags=["reports"]
|
||||
)
|
||||
308
backend/app/core/cache.py
Normal file
308
backend/app/core/cache.py
Normal file
@@ -0,0 +1,308 @@
|
||||
"""
|
||||
Redis Caching Service - ServiceManagerWeb
|
||||
|
||||
Servicio centralizado para manejo de caché con Redis.
|
||||
"""
|
||||
|
||||
from redis import asyncio as aioredis
|
||||
from typing import Optional, Any, Union
|
||||
import json
|
||||
import structlog
|
||||
from functools import wraps
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class CacheService:
|
||||
"""
|
||||
Servicio de caché usando Redis.
|
||||
|
||||
Proporciona métodos para get/set/delete de datos con serialización JSON.
|
||||
Usa un singleton pattern para compartir la conexión Redis.
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_redis = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
async def connect(self):
|
||||
"""Conectar a Redis si aún no está conectado."""
|
||||
if self._redis is None:
|
||||
try:
|
||||
self._redis = await aioredis.from_url(
|
||||
settings.REDIS_URL,
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=5,
|
||||
socket_timeout=5
|
||||
)
|
||||
logger.info("Redis cache connected", url=settings.REDIS_URL)
|
||||
except Exception as e:
|
||||
logger.error("Failed to connect to Redis", error=str(e))
|
||||
self._redis = None
|
||||
|
||||
async def disconnect(self):
|
||||
"""Cerrar conexión Redis."""
|
||||
if self._redis:
|
||||
await self._redis.close()
|
||||
self._redis = None
|
||||
logger.info("Redis cache disconnected")
|
||||
|
||||
async def get(self, key: str) -> Optional[Any]:
|
||||
"""
|
||||
Obtener valor del cache.
|
||||
|
||||
Args:
|
||||
key: Clave del cache
|
||||
|
||||
Returns:
|
||||
Valor deserializado o None si no existe
|
||||
"""
|
||||
if self._redis is None:
|
||||
await self.connect()
|
||||
|
||||
if self._redis is None:
|
||||
logger.warning("Redis not available, skipping cache get", key=key)
|
||||
return None
|
||||
|
||||
try:
|
||||
value = await self._redis.get(key)
|
||||
if value:
|
||||
logger.debug("Cache hit", key=key)
|
||||
return json.loads(value)
|
||||
logger.debug("Cache miss", key=key)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error("Cache get error", key=key, error=str(e))
|
||||
return None
|
||||
|
||||
async def set(
|
||||
self,
|
||||
key: str,
|
||||
value: Any,
|
||||
ttl: int = 300
|
||||
) -> bool:
|
||||
"""
|
||||
Guardar valor en cache.
|
||||
|
||||
Args:
|
||||
key: Clave del cache
|
||||
value: Valor a guardar (será serializado a JSON)
|
||||
ttl: Tiempo de vida en segundos (default: 5 minutos)
|
||||
|
||||
Returns:
|
||||
True si se guardó exitosamente
|
||||
"""
|
||||
if self._redis is None:
|
||||
await self.connect()
|
||||
|
||||
if self._redis is None:
|
||||
logger.warning("Redis not available, skipping cache set", key=key)
|
||||
return False
|
||||
|
||||
try:
|
||||
serialized = json.dumps(value, default=str)
|
||||
await self._redis.setex(key, ttl, serialized)
|
||||
logger.debug("Cache set", key=key, ttl=ttl)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Cache set error", key=key, error=str(e))
|
||||
return False
|
||||
|
||||
async def delete(self, key: str) -> bool:
|
||||
"""
|
||||
Eliminar clave del cache.
|
||||
|
||||
Args:
|
||||
key: Clave a eliminar
|
||||
|
||||
Returns:
|
||||
True si se eliminó exitosamente
|
||||
"""
|
||||
if self._redis is None:
|
||||
await self.connect()
|
||||
|
||||
if self._redis is None:
|
||||
logger.warning("Redis not available, skipping cache delete", key=key)
|
||||
return False
|
||||
|
||||
try:
|
||||
await self._redis.delete(key)
|
||||
logger.debug("Cache delete", key=key)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Cache delete error", key=key, error=str(e))
|
||||
return False
|
||||
|
||||
async def delete_pattern(self, pattern: str) -> int:
|
||||
"""
|
||||
Eliminar todas las claves que coincidan con el patrón.
|
||||
|
||||
Args:
|
||||
pattern: Patrón de búsqueda (ej: "tickets:tenant:*")
|
||||
|
||||
Returns:
|
||||
Número de claves eliminadas
|
||||
"""
|
||||
if self._redis is None:
|
||||
await self.connect()
|
||||
|
||||
if self._redis is None:
|
||||
logger.warning("Redis not available, skipping pattern delete", pattern=pattern)
|
||||
return 0
|
||||
|
||||
try:
|
||||
keys = []
|
||||
async for key in self._redis.scan_iter(pattern):
|
||||
keys.append(key)
|
||||
|
||||
if keys:
|
||||
deleted = await self._redis.delete(*keys)
|
||||
logger.info("Cache pattern delete", pattern=pattern, deleted=deleted)
|
||||
return deleted
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.error("Cache pattern delete error", pattern=pattern, error=str(e))
|
||||
return 0
|
||||
|
||||
async def exists(self, key: str) -> bool:
|
||||
"""
|
||||
Verificar si una clave existe en cache.
|
||||
|
||||
Args:
|
||||
key: Clave a verificar
|
||||
|
||||
Returns:
|
||||
True si existe
|
||||
"""
|
||||
if self._redis is None:
|
||||
await self.connect()
|
||||
|
||||
if self._redis is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
return await self._redis.exists(key) > 0
|
||||
except Exception as e:
|
||||
logger.error("Cache exists error", key=key, error=str(e))
|
||||
return False
|
||||
|
||||
async def incr(self, key: str, amount: int = 1) -> Optional[int]:
|
||||
"""
|
||||
Incrementar un contador en cache.
|
||||
|
||||
Args:
|
||||
key: Clave del contador
|
||||
amount: Cantidad a incrementar
|
||||
|
||||
Returns:
|
||||
Nuevo valor del contador
|
||||
"""
|
||||
if self._redis is None:
|
||||
await self.connect()
|
||||
|
||||
if self._redis is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return await self._redis.incrby(key, amount)
|
||||
except Exception as e:
|
||||
logger.error("Cache incr error", key=key, error=str(e))
|
||||
return None
|
||||
|
||||
async def expire(self, key: str, ttl: int) -> bool:
|
||||
"""
|
||||
Establecer tiempo de expiración a una clave existente.
|
||||
|
||||
Args:
|
||||
key: Clave a expirar
|
||||
ttl: Tiempo de vida en segundos
|
||||
|
||||
Returns:
|
||||
True si se estableció exitosamente
|
||||
"""
|
||||
if self._redis is None:
|
||||
await self.connect()
|
||||
|
||||
if self._redis is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
return await self._redis.expire(key, ttl)
|
||||
except Exception as e:
|
||||
logger.error("Cache expire error", key=key, error=str(e))
|
||||
return False
|
||||
|
||||
|
||||
# Singleton instance
|
||||
cache = CacheService()
|
||||
|
||||
|
||||
def cache_key(*parts: str) -> str:
|
||||
"""
|
||||
Helper para construir claves de cache consistentes.
|
||||
|
||||
Args:
|
||||
*parts: Partes de la clave a unir
|
||||
|
||||
Returns:
|
||||
Clave formateada
|
||||
|
||||
Example:
|
||||
cache_key("tickets", "tenant", tenant_id) -> "tickets:tenant:123"
|
||||
"""
|
||||
return ":".join(str(part) for part in parts)
|
||||
|
||||
|
||||
def cached(
|
||||
key_prefix: str,
|
||||
ttl: int = 300,
|
||||
key_builder: Optional[callable] = None
|
||||
):
|
||||
"""
|
||||
Decorator para cachear resultados de funciones async.
|
||||
|
||||
Args:
|
||||
key_prefix: Prefijo para la clave de cache
|
||||
ttl: Tiempo de vida en segundos
|
||||
key_builder: Función opcional para construir la clave
|
||||
|
||||
Example:
|
||||
@cached("categories", ttl=600)
|
||||
async def get_categories(tenant_id: str):
|
||||
return await db.query(Category).all()
|
||||
"""
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
# Construir clave de cache
|
||||
if key_builder:
|
||||
key = key_builder(*args, **kwargs)
|
||||
else:
|
||||
# Default: usar nombre de función y args
|
||||
key_parts = [key_prefix, func.__name__]
|
||||
key_parts.extend(str(arg) for arg in args)
|
||||
key_parts.extend(f"{k}={v}" for k, v in sorted(kwargs.items()))
|
||||
key = cache_key(*key_parts)
|
||||
|
||||
# Intentar obtener del cache
|
||||
cached_value = await cache.get(key)
|
||||
if cached_value is not None:
|
||||
return cached_value
|
||||
|
||||
# Si no está en cache, ejecutar función
|
||||
result = await func(*args, **kwargs)
|
||||
|
||||
# Guardar en cache
|
||||
await cache.set(key, result, ttl=ttl)
|
||||
|
||||
return result
|
||||
return wrapper
|
||||
return decorator
|
||||
@@ -5,7 +5,6 @@ Configuración centralizada usando Pydantic Settings v2
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import List, Optional
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import field_validator, Field
|
||||
import os
|
||||
@@ -24,9 +23,11 @@ class Settings(BaseSettings):
|
||||
# GENERAL
|
||||
# ===================================
|
||||
ENVIRONMENT: str = Field(default="development")
|
||||
TESTING: bool = Field(default=False)
|
||||
DEBUG: bool = Field(default=False)
|
||||
SECRET_KEY: str = Field(...)
|
||||
API_VERSION: str = Field(default="v1")
|
||||
APP_VERSION: str = Field(default="1.9.0")
|
||||
|
||||
# ===================================
|
||||
# DATABASE
|
||||
@@ -58,8 +59,8 @@ class Settings(BaseSettings):
|
||||
# ===================================
|
||||
SMTP_HOST: str = Field(default="localhost")
|
||||
SMTP_PORT: int = Field(default=587)
|
||||
SMTP_USER: Optional[str] = Field(default=None)
|
||||
SMTP_PASSWORD: Optional[str] = Field(default=None)
|
||||
SMTP_USER: str | None = Field(default=None)
|
||||
SMTP_PASSWORD: str | None = Field(default=None)
|
||||
SMTP_USE_TLS: bool = Field(default=True)
|
||||
SMTP_USE_SSL: bool = Field(default=False)
|
||||
|
||||
@@ -77,7 +78,7 @@ class Settings(BaseSettings):
|
||||
UPLOAD_PATH: str = Field(default="/app/uploads")
|
||||
|
||||
@property
|
||||
def ALLOWED_FILE_EXTENSIONS(self) -> List[str]:
|
||||
def ALLOWED_FILE_EXTENSIONS(self) -> list[str]:
|
||||
"""Parse the comma-separated file extensions."""
|
||||
return [ext.strip().lower() for ext in self.ALLOWED_FILE_EXTENSIONS_STR.split(",")]
|
||||
|
||||
@@ -85,6 +86,9 @@ class Settings(BaseSettings):
|
||||
# SECURITY
|
||||
# ===================================
|
||||
RATE_LIMIT_ENABLED: bool = Field(default=True)
|
||||
LOGIN_RATE_LIMIT_WINDOW_SECONDS: int = Field(default=300)
|
||||
LOGIN_RATE_LIMIT_IP_MAX_ATTEMPTS: int = Field(default=30)
|
||||
LOGIN_RATE_LIMIT_ID_MAX_ATTEMPTS: int = Field(default=10)
|
||||
PASSWORD_MIN_LENGTH: int = Field(default=8)
|
||||
|
||||
# Argon2 settings
|
||||
@@ -97,7 +101,7 @@ class Settings(BaseSettings):
|
||||
# ===================================
|
||||
LOG_LEVEL: str = Field(default="INFO")
|
||||
LOG_FORMAT: str = Field(default="json")
|
||||
LOG_FILE: Optional[str] = Field(default=None)
|
||||
LOG_FILE: str | None = Field(default=None)
|
||||
|
||||
# ===================================
|
||||
# FRONTEND URLS
|
||||
|
||||
@@ -6,7 +6,9 @@ SQLAlchemy 2.0 async setup con PostgreSQL
|
||||
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
from sqlalchemy import String, DateTime, func
|
||||
from sqlalchemy import String, DateTime, func, text
|
||||
from sqlalchemy.types import TypeDecorator, CHAR
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
from typing import AsyncGenerator
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
@@ -19,10 +21,11 @@ settings = get_settings()
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DEBUG,
|
||||
pool_size=5,
|
||||
max_overflow=10,
|
||||
pool_size=20, # Increased for better concurrency
|
||||
max_overflow=30, # Increased for peak loads
|
||||
pool_pre_ping=True, # Verify connections before use
|
||||
pool_recycle=3600, # Recycle connections after 1 hour
|
||||
pool_timeout=30, # Wait up to 30s for connection from pool
|
||||
)
|
||||
|
||||
# Create session factory
|
||||
@@ -33,18 +36,46 @@ AsyncSessionLocal = async_sessionmaker(
|
||||
autoflush=True,
|
||||
autocommit=False
|
||||
)
|
||||
class GUID(TypeDecorator):
|
||||
"""UUID portable: UUID nativo en Postgres, CHAR(36) en otros dialectos (SQLite para tests)."""
|
||||
|
||||
impl = CHAR
|
||||
cache_ok = True
|
||||
|
||||
def load_dialect_impl(self, dialect):
|
||||
if dialect.name == "postgresql":
|
||||
return dialect.type_descriptor(PG_UUID(as_uuid=True))
|
||||
return dialect.type_descriptor(CHAR(36))
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if dialect.name == "postgresql":
|
||||
return value
|
||||
|
||||
if isinstance(value, uuid.UUID):
|
||||
return str(value)
|
||||
return str(uuid.UUID(str(value)))
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, uuid.UUID):
|
||||
return value
|
||||
return uuid.UUID(str(value))
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class para todos los modelos SQLAlchemy."""
|
||||
|
||||
|
||||
# Columnas comunes para auditoría
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now()
|
||||
onupdate=func.now(),
|
||||
)
|
||||
|
||||
|
||||
@@ -88,7 +119,7 @@ async def check_database_health() -> bool:
|
||||
"""
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
await session.execute("SELECT 1")
|
||||
await session.execute(text("SELECT 1"))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
179
backend/app/core/email.py
Normal file
179
backend/app/core/email.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
Email Utility - ServiceManagerWeb
|
||||
|
||||
Envío directo de emails desde el backend para flujos críticos
|
||||
(reseteo de contraseña, verificación) sin depender de Celery.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import smtplib
|
||||
import ssl
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from typing import Optional
|
||||
import structlog
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def _send_smtp_sync(
|
||||
to_email: str,
|
||||
subject: str,
|
||||
html_content: str,
|
||||
text_content: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Enviar email de forma síncrona vía SMTP.
|
||||
Llamar desde asyncio.to_thread para no bloquear el event loop.
|
||||
"""
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = f"{settings.DEFAULT_FROM_NAME} <{settings.DEFAULT_FROM_EMAIL}>"
|
||||
msg["To"] = to_email
|
||||
|
||||
if text_content:
|
||||
msg.attach(MIMEText(text_content, "plain", "utf-8"))
|
||||
msg.attach(MIMEText(html_content, "html", "utf-8"))
|
||||
|
||||
if settings.SMTP_USE_SSL:
|
||||
context = ssl.create_default_context()
|
||||
with smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT, context=context) as server:
|
||||
if settings.SMTP_USER and settings.SMTP_PASSWORD:
|
||||
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
server.sendmail(settings.DEFAULT_FROM_EMAIL, to_email, msg.as_string())
|
||||
else:
|
||||
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as server:
|
||||
if settings.SMTP_USE_TLS:
|
||||
server.starttls()
|
||||
if settings.SMTP_USER and settings.SMTP_PASSWORD:
|
||||
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
server.sendmail(settings.DEFAULT_FROM_EMAIL, to_email, msg.as_string())
|
||||
|
||||
|
||||
async def send_email(
|
||||
to_email: str,
|
||||
subject: str,
|
||||
html_content: str,
|
||||
text_content: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Enviar email de forma asíncrona.
|
||||
|
||||
Retorna True si el envío fue exitoso, False con log de error si falló.
|
||||
Se diseña para no propagar excepciones (fail-silent) en flujos de UI.
|
||||
"""
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
_send_smtp_sync,
|
||||
to_email,
|
||||
subject,
|
||||
html_content,
|
||||
text_content,
|
||||
)
|
||||
logger.info("Email sent", to=to_email, subject=subject)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error("Email send failed", to=to_email, subject=subject, error=str(exc))
|
||||
return False
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Plantillas HTML inline
|
||||
# ============================================================
|
||||
|
||||
def build_password_reset_email(reset_url: str, user_name: str) -> tuple[str, str]:
|
||||
"""
|
||||
Construir HTML y texto plano para email de reseteo de contraseña.
|
||||
|
||||
Returns:
|
||||
(html_content, text_content)
|
||||
"""
|
||||
html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Restablecer contraseña</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#f4f6f8;font-family:Arial,sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f6f8;padding:40px 0;">
|
||||
<tr><td align="center">
|
||||
<table width="560" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,.08);">
|
||||
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="background:#1d4ed8;padding:32px 40px;text-align:center;">
|
||||
<span style="color:#ffffff;font-size:22px;font-weight:700;letter-spacing:-.5px;">ServiceManager</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td style="padding:40px;">
|
||||
<h2 style="margin:0 0 16px;font-size:20px;color:#111827;">Restablece tu contraseña</h2>
|
||||
<p style="margin:0 0 12px;font-size:15px;color:#374151;line-height:1.6;">
|
||||
Hola <strong>{user_name}</strong>,
|
||||
</p>
|
||||
<p style="margin:0 0 24px;font-size:15px;color:#374151;line-height:1.6;">
|
||||
Recibimos una solicitud para restablecer la contraseña de tu cuenta.
|
||||
Haz clic en el botón de abajo para crear una nueva contraseña.
|
||||
Este enlace es válido por <strong>30 minutos</strong>.
|
||||
</p>
|
||||
|
||||
<table cellpadding="0" cellspacing="0" style="margin:0 auto 32px;">
|
||||
<tr>
|
||||
<td style="background:#1d4ed8;border-radius:6px;">
|
||||
<a href="{reset_url}"
|
||||
style="display:inline-block;padding:14px 32px;color:#ffffff;font-size:15px;font-weight:600;text-decoration:none;border-radius:6px;">
|
||||
Restablecer contraseña
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin:0 0 8px;font-size:13px;color:#6b7280;">
|
||||
Si no puedes hacer clic en el botón, copia y pega este enlace en tu navegador:
|
||||
</p>
|
||||
<p style="margin:0 0 24px;font-size:12px;color:#2563eb;word-break:break-all;">
|
||||
<a href="{reset_url}" style="color:#2563eb;">{reset_url}</a>
|
||||
</p>
|
||||
|
||||
<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;">
|
||||
|
||||
<p style="margin:0;font-size:13px;color:#9ca3af;line-height:1.6;">
|
||||
Si no solicitaste restablecer tu contraseña, puedes ignorar este mensaje.
|
||||
Tu contraseña no se modificará.<br>
|
||||
Por seguridad, este enlace expira en 30 minutos y solo puede usarse una vez.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="padding:20px 40px;background:#f9fafb;text-align:center;">
|
||||
<p style="margin:0;font-size:12px;color:#9ca3af;">
|
||||
© 2026 Aduanasoft — Acceso exclusivo autorizado
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
text = (
|
||||
f"Hola {user_name},\n\n"
|
||||
"Recibimos una solicitud para restablecer la contraseña de tu cuenta.\n\n"
|
||||
f"Haz clic en el siguiente enlace (válido por 30 minutos):\n{reset_url}\n\n"
|
||||
"Si no solicitaste este cambio, ignora este mensaje.\n\n"
|
||||
"— ServiceManager"
|
||||
)
|
||||
|
||||
return html, text
|
||||
@@ -16,6 +16,8 @@ settings = get_settings()
|
||||
|
||||
class FileHandler:
|
||||
"""Handler simple para archivos adjuntos"""
|
||||
|
||||
_CHUNK_SIZE_BYTES = 1024 * 1024 # 1MB
|
||||
|
||||
def __init__(self):
|
||||
self.upload_path = Path(settings.UPLOAD_PATH)
|
||||
@@ -24,8 +26,8 @@ class FileHandler:
|
||||
# Crear directorio si no existe
|
||||
self.upload_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _validate_file(self, filename: str, file_size: int) -> None:
|
||||
"""Validar archivo"""
|
||||
def _validate_extension(self, filename: str) -> str:
|
||||
"""Validar extensión del archivo y retornarla."""
|
||||
extension = Path(filename).suffix.lower().lstrip('.')
|
||||
|
||||
if extension not in self.allowed_extensions:
|
||||
@@ -33,32 +35,52 @@ class FileHandler:
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Extensión no permitida: {extension}"
|
||||
)
|
||||
|
||||
if file_size > self.max_size_bytes:
|
||||
|
||||
return extension
|
||||
|
||||
def _validate_magic_bytes(self, extension: str, first_bytes: bytes) -> None:
|
||||
"""Validación básica por firma (magic bytes) para tipos comunes."""
|
||||
|
||||
signatures = {
|
||||
# PDFs start with %PDF-
|
||||
"pdf": [b"%PDF-"],
|
||||
# PNG signature
|
||||
"png": [b"\x89PNG\r\n\x1a\n"],
|
||||
# JPEG starts with FF D8 FF
|
||||
"jpg": [b"\xff\xd8\xff"],
|
||||
"jpeg": [b"\xff\xd8\xff"],
|
||||
# Legacy MS Office (OLE Compound File)
|
||||
"doc": [b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"],
|
||||
"xls": [b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"],
|
||||
# OOXML (zip-based)
|
||||
"docx": [b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08"],
|
||||
"xlsx": [b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08"],
|
||||
}
|
||||
|
||||
# For plain text, we can't reliably validate via magic bytes.
|
||||
if extension == "txt":
|
||||
return
|
||||
|
||||
allowed = signatures.get(extension)
|
||||
if not allowed:
|
||||
return
|
||||
|
||||
if not any(first_bytes.startswith(sig) for sig in allowed):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f"Archivo muy grande. Máximo: {settings.MAX_UPLOAD_SIZE_MB}MB"
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Contenido de archivo no coincide con la extensión declarada",
|
||||
)
|
||||
|
||||
def _calculate_checksums(self, content: bytes) -> Tuple[str, str]:
|
||||
"""Calcular MD5 y SHA256"""
|
||||
return hashlib.md5(content).hexdigest(), hashlib.sha256(content).hexdigest()
|
||||
|
||||
async def save_upload(self, file: UploadFile, tenant_id: uuid.UUID, ticket_id: uuid.UUID) -> dict:
|
||||
"""Guardar archivo y retornar metadata"""
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="Filename requerido")
|
||||
|
||||
content = await file.read()
|
||||
file_size = len(content)
|
||||
|
||||
self._validate_file(file.filename, file_size)
|
||||
|
||||
md5_hash, sha256_hash = self._calculate_checksums(content)
|
||||
|
||||
|
||||
extension = self._validate_extension(file.filename)
|
||||
|
||||
# Nombre único
|
||||
extension = Path(file.filename).suffix.lower()
|
||||
safe_filename = f"{uuid.uuid4().hex}{extension}"
|
||||
original_extension = Path(file.filename).suffix.lower()
|
||||
safe_filename = f"{uuid.uuid4().hex}{original_extension}"
|
||||
|
||||
# Estructura: uploads/tenant_id/tickets/ticket_id/
|
||||
file_directory = self.upload_path / str(tenant_id) / "tickets" / str(ticket_id)
|
||||
@@ -66,10 +88,61 @@ class FileHandler:
|
||||
|
||||
file_path = file_directory / safe_filename
|
||||
relative_path = str(file_path.relative_to(self.upload_path))
|
||||
|
||||
# Guardar archivo
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
# Guardar archivo (streaming) + checksums incrementales
|
||||
md5 = hashlib.md5()
|
||||
sha256 = hashlib.sha256()
|
||||
file_size = 0
|
||||
validated_magic = False
|
||||
first_bytes: bytes = b""
|
||||
|
||||
try:
|
||||
with open(file_path, "wb") as f:
|
||||
while True:
|
||||
chunk = await file.read(self._CHUNK_SIZE_BYTES)
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
if not validated_magic:
|
||||
first_bytes = chunk[:16]
|
||||
self._validate_magic_bytes(extension, first_bytes)
|
||||
validated_magic = True
|
||||
|
||||
file_size += len(chunk)
|
||||
if file_size > self.max_size_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f"Archivo muy grande. Máximo: {settings.MAX_UPLOAD_SIZE_MB}MB",
|
||||
)
|
||||
|
||||
md5.update(chunk)
|
||||
sha256.update(chunk)
|
||||
f.write(chunk)
|
||||
|
||||
if file_size == 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Archivo vacío",
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
# Eliminar archivo parcial si existe
|
||||
try:
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
except Exception as exc:
|
||||
try:
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error guardando archivo: {exc}",
|
||||
)
|
||||
|
||||
import mimetypes
|
||||
mime_type = mimetypes.guess_type(file.filename)[0] or "application/octet-stream"
|
||||
@@ -80,8 +153,8 @@ class FileHandler:
|
||||
"file_path": relative_path,
|
||||
"file_size": file_size,
|
||||
"mime_type": mime_type,
|
||||
"md5_hash": md5_hash,
|
||||
"sha256_hash": sha256_hash
|
||||
"md5_hash": md5.hexdigest(),
|
||||
"sha256_hash": sha256.hexdigest(),
|
||||
}
|
||||
|
||||
def get_file_path(self, relative_path: str) -> Path:
|
||||
|
||||
20
backend/app/core/limiter.py
Normal file
20
backend/app/core/limiter.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
Rate Limiter - ServiceManagerWeb
|
||||
|
||||
Configura slowapi con Redis como storage backend.
|
||||
Respeta settings.RATE_LIMIT_ENABLED: si está desactivado usa memoria
|
||||
y el limiter queda en modo noop (enabled=False).
|
||||
"""
|
||||
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
limiter = Limiter(
|
||||
key_func=get_remote_address,
|
||||
storage_uri=settings.REDIS_URL if settings.RATE_LIMIT_ENABLED else "memory://",
|
||||
enabled=settings.RATE_LIMIT_ENABLED,
|
||||
)
|
||||
@@ -13,6 +13,7 @@ import pyotp
|
||||
import secrets
|
||||
import base64
|
||||
import struct
|
||||
import uuid
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
@@ -100,7 +101,8 @@ class SecurityUtils:
|
||||
"""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
to_encode.update({"exp": expire, "type": "refresh"})
|
||||
# Add a unique identifier so refresh tokens are never deterministic.
|
||||
to_encode.update({"exp": expire, "type": "refresh", "jti": str(uuid.uuid4())})
|
||||
|
||||
encoded_jwt = jwt.encode(
|
||||
to_encode,
|
||||
|
||||
@@ -9,6 +9,9 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from contextlib import asynccontextmanager
|
||||
from slowapi import _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.middleware import SlowAPIMiddleware
|
||||
import structlog
|
||||
import time
|
||||
import uuid
|
||||
@@ -23,11 +26,15 @@ from app.models.user import User
|
||||
from app.models.ticket import Ticket
|
||||
from app.models.comment import TicketComment
|
||||
from app.models.attachment import TicketAttachment
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.refresh_token import RefreshToken
|
||||
|
||||
from app.core.logging import setup_logging
|
||||
from app.api.v1.router import api_router
|
||||
from app.middleware.tenant import TenantMiddleware
|
||||
from app.middleware.correlation_id import CorrelationIDMiddleware
|
||||
from app.core.cache import cache
|
||||
from app.core.limiter import limiter
|
||||
|
||||
settings = get_settings()
|
||||
setup_logging()
|
||||
@@ -40,6 +47,10 @@ async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
logger.info("Iniciando ServiceManagerWeb Backend", version=settings.API_VERSION)
|
||||
|
||||
# Conectar a Redis cache
|
||||
await cache.connect()
|
||||
logger.info("Caché Redis conectado")
|
||||
|
||||
if settings.ENVIRONMENT == "development":
|
||||
await create_tables()
|
||||
logger.info("Tablas de base de datos verificadas")
|
||||
@@ -48,31 +59,58 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
# Shutdown
|
||||
logger.info("Cerrando ServiceManagerWeb Backend")
|
||||
await cache.disconnect()
|
||||
logger.info("Caché Redis desconectado")
|
||||
|
||||
|
||||
# Crear aplicación FastAPI
|
||||
app = FastAPI(
|
||||
title="ServiceManagerWeb API",
|
||||
description="Mesa de Ayuda B2B multi-tenant para Aduanasoft",
|
||||
version=settings.API_VERSION,
|
||||
version=settings.APP_VERSION,
|
||||
lifespan=lifespan,
|
||||
docs_url=f"/{settings.API_VERSION}/docs" if settings.ENVIRONMENT == "development" else None,
|
||||
redoc_url=f"/{settings.API_VERSION}/redoc" if settings.ENVIRONMENT == "development" else None,
|
||||
openapi_url=f"/{settings.API_VERSION}/openapi.json"
|
||||
)
|
||||
|
||||
# SlowAPI rate limiting
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
app.add_middleware(SlowAPIMiddleware)
|
||||
|
||||
# ===================================
|
||||
# MIDDLEWARE
|
||||
# ===================================
|
||||
|
||||
# CORS
|
||||
cors_origins = settings.CORS_ORIGINS.split(",") if isinstance(settings.CORS_ORIGINS, str) else settings.CORS_ORIGINS
|
||||
|
||||
if settings.is_production():
|
||||
cors_allow_methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
||||
cors_allow_headers = [
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-Tenant-ID",
|
||||
"X-Tenant-Slug",
|
||||
"X-Correlation-ID",
|
||||
]
|
||||
else:
|
||||
cors_allow_methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
||||
cors_allow_headers = [
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-Tenant-ID",
|
||||
"X-Tenant-Slug",
|
||||
"X-Correlation-ID",
|
||||
]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_methods=cors_allow_methods,
|
||||
allow_headers=cors_allow_headers,
|
||||
)
|
||||
|
||||
# Compression
|
||||
@@ -161,7 +199,8 @@ async def health_check():
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "ServiceManagerWeb API",
|
||||
"version": settings.API_VERSION,
|
||||
"version": settings.APP_VERSION,
|
||||
"api_version": settings.API_VERSION,
|
||||
"environment": settings.ENVIRONMENT
|
||||
}
|
||||
|
||||
@@ -172,7 +211,8 @@ async def root():
|
||||
"""Endpoint raíz con información básica."""
|
||||
return {
|
||||
"service": "ServiceManagerWeb API",
|
||||
"version": settings.API_VERSION,
|
||||
"version": settings.APP_VERSION,
|
||||
"api_version": settings.API_VERSION,
|
||||
"docs": f"/{settings.API_VERSION}/docs",
|
||||
"environment": settings.ENVIRONMENT
|
||||
}
|
||||
|
||||
@@ -4,69 +4,176 @@ Tenant Middleware - ServiceManagerWeb
|
||||
Middleware para manejo de multi-tenancy
|
||||
"""
|
||||
|
||||
from fastapi import Request, HTTPException, status
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import Response
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response, JSONResponse
|
||||
from sqlalchemy import select
|
||||
import structlog
|
||||
import uuid
|
||||
|
||||
from app.core.database import AsyncSessionLocal, get_db
|
||||
from app.core.config import get_settings
|
||||
from app.models.tenant import Tenant, TenantStatus
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class TenantMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware para extraer y validar información del tenant.
|
||||
|
||||
Extrae el tenant_id del header X-Tenant-ID y lo almacena
|
||||
en el estado de la request para uso posterior.
|
||||
|
||||
Extrae el tenant_id del header X-Tenant-ID o el slug del header
|
||||
X-Tenant-Slug, valida que exista en la base de datos y que esté
|
||||
activo, y almacena el objeto Tenant en request.state.tenant.
|
||||
"""
|
||||
|
||||
|
||||
# Rutas que no requieren tenant
|
||||
EXCLUDED_PATHS = {
|
||||
"/health",
|
||||
"/api/v1/health",
|
||||
"/v1/health",
|
||||
"/api/v1/health/detailed",
|
||||
"/v1/health/detailed",
|
||||
"/",
|
||||
"/api/v1/auth/login",
|
||||
"/v1/auth/login",
|
||||
"/api/v1/auth/refresh",
|
||||
"/v1/auth/refresh",
|
||||
"/api/v1/auth/logout",
|
||||
"/v1/auth/logout",
|
||||
"/api/v1/auth/me",
|
||||
"/v1/auth/me",
|
||||
"/api/v1/auth/forgot-password",
|
||||
"/v1/auth/forgot-password",
|
||||
"/api/v1/auth/reset-password",
|
||||
"/v1/auth/reset-password",
|
||||
"/docs",
|
||||
"/api/v1/docs",
|
||||
"/v1/docs",
|
||||
"/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:
|
||||
"""Process request and add tenant information."""
|
||||
|
||||
# Skip tenant validation for excluded paths
|
||||
"""Valida el tenant en cada request y lo almacena en request.state."""
|
||||
|
||||
# Inicializar state con valores por defecto
|
||||
request.state.tenant = None
|
||||
request.state.tenant_id = None
|
||||
request.state.tenant_slug = None
|
||||
|
||||
# Saltar validación en rutas excluidas
|
||||
if request.url.path in self.EXCLUDED_PATHS or request.url.path.startswith("/docs"):
|
||||
return await call_next(request)
|
||||
|
||||
# Extract tenant from header
|
||||
|
||||
# Extraer headers de tenant
|
||||
tenant_id = request.headers.get("X-Tenant-ID")
|
||||
tenant_slug = request.headers.get("X-Tenant-Slug")
|
||||
|
||||
# For now, we'll be more permissive in development
|
||||
# In production, tenant should be strictly required
|
||||
|
||||
tenant_uuid: uuid.UUID | None = None
|
||||
if tenant_id:
|
||||
try:
|
||||
tenant_uuid = uuid.UUID(tenant_id)
|
||||
except ValueError:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"detail": "Invalid X-Tenant-ID header (must be UUID)"},
|
||||
)
|
||||
|
||||
# Si no hay headers de tenant (requerido para aislamiento multi-tenant)
|
||||
if not tenant_id and not tenant_slug:
|
||||
logger.warning(
|
||||
"Request without tenant information",
|
||||
path=request.url.path,
|
||||
method=request.method
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"detail": "Tenant information required (X-Tenant-ID or X-Tenant-Slug header)"},
|
||||
)
|
||||
# For now, continue without tenant for development
|
||||
# raise HTTPException(
|
||||
# status_code=status.HTTP_400_BAD_REQUEST,
|
||||
# detail="Tenant information required (X-Tenant-ID or X-Tenant-Slug header)"
|
||||
# )
|
||||
|
||||
# Store tenant info in request state
|
||||
request.state.tenant_id = tenant_id
|
||||
request.state.tenant_slug = tenant_slug
|
||||
|
||||
# TODO: Validate tenant exists and is active
|
||||
# This would involve a database query which we'll implement later
|
||||
|
||||
logger.debug(
|
||||
"Tenant middleware processed",
|
||||
tenant_id=tenant_id,
|
||||
tenant_slug=tenant_slug,
|
||||
path=request.url.path
|
||||
)
|
||||
|
||||
|
||||
# Validar tenant contra la base de datos
|
||||
try:
|
||||
# Prefer DB session coming from dependency overrides (tests) when available.
|
||||
# Guard: in unit tests request.app may be a MagicMock, not a real FastAPI app.
|
||||
dependency_overrides = getattr(request.app, "dependency_overrides", None)
|
||||
override_get_db = None
|
||||
if isinstance(dependency_overrides, dict):
|
||||
override_get_db = dependency_overrides.get(get_db)
|
||||
|
||||
if override_get_db is not None:
|
||||
agen = override_get_db()
|
||||
session = await agen.__anext__()
|
||||
try:
|
||||
if tenant_uuid is not None:
|
||||
result = await session.execute(
|
||||
select(Tenant).where(Tenant.id == tenant_uuid)
|
||||
)
|
||||
else:
|
||||
result = await session.execute(
|
||||
select(Tenant).where(Tenant.slug == tenant_slug)
|
||||
)
|
||||
tenant = result.scalars().first()
|
||||
finally:
|
||||
await agen.aclose()
|
||||
else:
|
||||
async with AsyncSessionLocal() as session:
|
||||
if tenant_uuid is not None:
|
||||
result = await session.execute(
|
||||
select(Tenant).where(Tenant.id == tenant_uuid)
|
||||
)
|
||||
else:
|
||||
result = await session.execute(
|
||||
select(Tenant).where(Tenant.slug == tenant_slug)
|
||||
)
|
||||
tenant = result.scalars().first()
|
||||
|
||||
if tenant is None:
|
||||
logger.warning(
|
||||
"Tenant not found",
|
||||
tenant_id=tenant_id,
|
||||
tenant_slug=tenant_slug,
|
||||
path=request.url.path,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"detail": "Tenant not found"}
|
||||
)
|
||||
|
||||
if tenant.status != TenantStatus.ACTIVE:
|
||||
logger.warning(
|
||||
"Tenant is not active",
|
||||
tenant_id=str(tenant.id),
|
||||
tenant_slug=tenant.slug,
|
||||
status=tenant.status,
|
||||
path=request.url.path,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={"detail": f"Tenant is {tenant.status.value}"}
|
||||
)
|
||||
|
||||
# Almacenar tenant validado en el state
|
||||
request.state.tenant = tenant
|
||||
request.state.tenant_id = str(tenant.id)
|
||||
request.state.tenant_slug = tenant.slug
|
||||
|
||||
logger.debug(
|
||||
"Tenant validated",
|
||||
tenant_id=str(tenant.id),
|
||||
tenant_slug=tenant.slug,
|
||||
path=request.url.path,
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Error validating tenant",
|
||||
error=str(exc),
|
||||
path=request.url.path,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={"detail": "Service temporarily unavailable"}
|
||||
)
|
||||
|
||||
return await call_next(request)
|
||||
@@ -8,6 +8,8 @@ from .system import System
|
||||
from .category import Category
|
||||
from .client_profile import ClientProfile
|
||||
from .attachment import TicketAttachment
|
||||
from .audit import AuditLog
|
||||
from .refresh_token import RefreshToken
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -17,5 +19,7 @@ __all__ = [
|
||||
"System",
|
||||
"Category",
|
||||
"ClientProfile",
|
||||
"TicketAttachment"
|
||||
"TicketAttachment",
|
||||
"AuditLog",
|
||||
"RefreshToken"
|
||||
]
|
||||
@@ -3,12 +3,11 @@ Attachment Model - ServiceManagerWeb
|
||||
"""
|
||||
from sqlalchemy import String, ForeignKey, Integer, DateTime, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from app.core.database import Base
|
||||
from app.core.database import Base, GUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.ticket import Ticket
|
||||
@@ -21,24 +20,24 @@ class TicketAttachment(Base):
|
||||
__tablename__ = "ticket_attachments"
|
||||
|
||||
# Sobrescribir campos heredados de Base para que coincidan con la tabla real
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
# Esta tabla NO tiene updated_at, así que lo excluimos del mapping
|
||||
|
||||
ticket_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("tickets.id", ondelete="CASCADE"),
|
||||
nullable=False
|
||||
)
|
||||
|
||||
comment_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("ticket_comments.id", ondelete="CASCADE"),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
uploaded_by: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("users.id"),
|
||||
nullable=False
|
||||
)
|
||||
|
||||
174
backend/app/models/audit.py
Normal file
174
backend/app/models/audit.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
Audit Log Model - ServiceManagerWeb
|
||||
|
||||
Modelo para bitácora de auditoría y compliance.
|
||||
Registra todas las acciones importantes del sistema.
|
||||
"""
|
||||
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Index, JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import INET, JSONB
|
||||
from typing import Optional, Dict, Any, TYPE_CHECKING
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.core.database import Base, GUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
"""
|
||||
Bitácora de auditoría para tracking completo de acciones.
|
||||
|
||||
Registra:
|
||||
- Quién hizo la acción (user_id)
|
||||
- Qué hizo (action)
|
||||
- Sobre qué recurso (resource_type + resource_id)
|
||||
- Cuándo lo hizo (created_at)
|
||||
- Desde dónde (ip_address, user_agent)
|
||||
- Qué cambió (old_values, new_values)
|
||||
"""
|
||||
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
# Multi-tenancy: cada registro pertenece a un tenant específico
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
GUID(),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Usuario que ejecutó la acción (NULL = acción del sistema)
|
||||
user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
GUID(),
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Acción realizada en formato "recurso.verbo"
|
||||
# Ejemplos: "user.login", "ticket.create", "ticket.assign"
|
||||
action: Mapped[str] = mapped_column(
|
||||
String(100),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Tipo de recurso afectado (user, ticket, comment, category, etc.)
|
||||
resource_type: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# ID del recurso afectado
|
||||
resource_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
GUID(),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Contexto de la request: IP y navegador del usuario
|
||||
ip_address: Mapped[Optional[str]] = mapped_column(
|
||||
String(45).with_variant(INET, "postgresql"),
|
||||
nullable=True,
|
||||
)
|
||||
user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Correlation ID para rastrear todas las requests relacionadas
|
||||
# en una misma operación o sesión
|
||||
correlation_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
GUID(),
|
||||
nullable=True,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Estado del recurso antes del cambio (para auditoría de cambios)
|
||||
old_values: Mapped[Optional[Dict[str, Any]]] = mapped_column(
|
||||
JSON().with_variant(JSONB, "postgresql"),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Estado del recurso después del cambio (para auditoría de cambios)
|
||||
new_values: Mapped[Optional[Dict[str, Any]]] = mapped_column(
|
||||
JSON().with_variant(JSONB, "postgresql"),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Metadata adicional con cualquier información relevante del contexto
|
||||
# Nota: 'metadata' está reservado en SQLAlchemy, se usa 'extra_metadata'
|
||||
# como nombre del atributo Python, pero la columna en BD se llama 'metadata'
|
||||
extra_metadata: Mapped[Optional[Dict[str, Any]]] = mapped_column(
|
||||
'metadata',
|
||||
JSON().with_variant(JSONB, "postgresql"),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Timestamp de creación con timezone
|
||||
# CORRECCIÓN: default=lambda: datetime.now(timezone.utc) genera un
|
||||
# datetime aware en UTC, compatible con DateTime(timezone=True).
|
||||
# El default anterior (datetime.utcnow) generaba datetimes naive,
|
||||
# causando que los filtros de fecha fallaran silenciosamente porque
|
||||
# SQLAlchemy no podía comparar aware vs naive correctamente.
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Relaciones con otros modelos
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", foreign_keys=[tenant_id])
|
||||
user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[user_id])
|
||||
|
||||
# Índices compuestos para optimizar las queries más frecuentes
|
||||
__table_args__ = (
|
||||
# Filtrar logs por tenant y tipo de acción (uso más común)
|
||||
Index('idx_audit_logs_tenant_action', 'tenant_id', 'action'),
|
||||
# Buscar el historial de un recurso específico
|
||||
Index('idx_audit_logs_resource', 'resource_type', 'resource_id'),
|
||||
# Ver la actividad de un usuario ordenada por fecha
|
||||
Index('idx_audit_logs_user_created', 'user_id', 'created_at'),
|
||||
)
|
||||
|
||||
# Los audit logs son inmutables: nunca se actualizan, solo se crean
|
||||
# Por eso se excluye updated_at del mapper
|
||||
__mapper_args__ = {
|
||||
"exclude_properties": ["updated_at"]
|
||||
}
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<AuditLog("
|
||||
f"action='{self.action}', "
|
||||
f"resource='{self.resource_type}:{self.resource_id}'"
|
||||
f")>"
|
||||
)
|
||||
|
||||
@property
|
||||
def action_display(self) -> str:
|
||||
"""
|
||||
Formato legible de la acción para mostrar en la interfaz.
|
||||
|
||||
Convierte el formato interno "recurso.verbo" a texto descriptivo.
|
||||
Ejemplo: "ticket.create" → "creó ticket"
|
||||
"""
|
||||
parts = self.action.split('.')
|
||||
if len(parts) == 2:
|
||||
resource, verb = parts
|
||||
verb_map = {
|
||||
'create': 'creó',
|
||||
'update': 'actualizó',
|
||||
'delete': 'eliminó',
|
||||
'login': 'inició sesión',
|
||||
'logout': 'cerró sesión',
|
||||
'login_failed': 'intentó iniciar sesión',
|
||||
'assign': 'asignó',
|
||||
'close': 'cerró',
|
||||
'reopen': 'reabrió'
|
||||
}
|
||||
return f"{verb_map.get(verb, verb)} {resource}"
|
||||
return self.action
|
||||
@@ -4,11 +4,10 @@ Categorías de tickets por tenant
|
||||
"""
|
||||
from sqlalchemy import String, Text, Boolean, Integer, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
|
||||
from app.core.database import Base
|
||||
from app.core.database import Base, GUID
|
||||
|
||||
class Category(Base):
|
||||
"""Modelo de categorías de tickets (ticket_categories en BD)"""
|
||||
@@ -21,7 +20,7 @@ class Category(Base):
|
||||
|
||||
# ✅ CORREGIDO: tenant_id es obligatorio para multi-tenancy
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False # ✅ Obligatorio
|
||||
)
|
||||
@@ -31,7 +30,7 @@ class Category(Base):
|
||||
sla_response_hours: Mapped[int] = mapped_column(Integer, default=24, nullable=False)
|
||||
sla_resolution_hours: Mapped[int] = mapped_column(Integer, default=72, nullable=False)
|
||||
auto_assign_to: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("users.id"),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
@@ -7,12 +7,11 @@ Almacena información detallada de la empresa cliente
|
||||
|
||||
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Text, Numeric
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.database import Base
|
||||
from app.core.database import Base, GUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.tenant import Tenant
|
||||
@@ -25,7 +24,7 @@ class ClientProfile(Base):
|
||||
|
||||
# Relación con tenant (uno a uno)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
|
||||
@@ -5,12 +5,11 @@ Modelo para comentarios en tickets
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, String, Text, Boolean, ForeignKey, DateTime
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from app.core.database import Base
|
||||
from app.core.database import Base, GUID
|
||||
|
||||
|
||||
class TicketComment(Base):
|
||||
@@ -20,20 +19,20 @@ class TicketComment(Base):
|
||||
|
||||
# Columnas
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4
|
||||
)
|
||||
|
||||
ticket_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("tickets.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
author_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("users.id"),
|
||||
nullable=False,
|
||||
index=True
|
||||
|
||||
170
backend/app/models/refresh_token.py
Normal file
170
backend/app/models/refresh_token.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
Refresh Token Model - ServiceManagerWeb
|
||||
|
||||
Modelo para persistencia de refresh tokens con revocaci├│n y tracking.
|
||||
"""
|
||||
|
||||
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Index, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.core.database import Base, GUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class RefreshToken(Base):
|
||||
"""
|
||||
Refresh Token persistente para gesti├│n de sesiones.
|
||||
|
||||
Almacena refresh tokens con informaci├│n de dispositivo y permite
|
||||
revocaci├│n para mejorar la seguridad.
|
||||
|
||||
Características:
|
||||
- Token hasheado (no se guarda en texto plano)
|
||||
- Device fingerprinting
|
||||
- Revocaci├│n individual con tracking
|
||||
- Auto-expiraci├│n
|
||||
- Tracking de IP y uso
|
||||
"""
|
||||
|
||||
__tablename__ = "refresh_tokens"
|
||||
|
||||
# User relationship
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
GUID(),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Token JWT (almacenado directamente - firmado y verificable)
|
||||
# VARCHAR(500) para acomodar JWTs con payload extenso
|
||||
token: Mapped[str] = mapped_column(
|
||||
String(500),
|
||||
nullable=False,
|
||||
unique=True
|
||||
)
|
||||
|
||||
# Device information
|
||||
device_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(100),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
device_name: Mapped[Optional[str]] = mapped_column(
|
||||
String(200),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
user_agent: Mapped[Optional[str]] = mapped_column(
|
||||
String(500),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# IP address del cliente (varchar(45) para IPv6)
|
||||
ip_address: Mapped[Optional[str]] = mapped_column(
|
||||
String(45),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Expiraci├│n del token
|
||||
expires_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Estado de revocaci├│n
|
||||
revoked: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=False,
|
||||
nullable=False
|
||||
)
|
||||
|
||||
revoked_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
revoked_by: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
GUID(),
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Tracking de uso
|
||||
last_used_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
usage_count: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=0,
|
||||
nullable=False
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=datetime.utcnow,
|
||||
nullable=False,
|
||||
server_default="NOW()"
|
||||
)
|
||||
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow,
|
||||
nullable=False,
|
||||
server_default="NOW()"
|
||||
)
|
||||
|
||||
# Relaci├│n con usuario
|
||||
user: Mapped["User"] = relationship("User", foreign_keys=[user_id], back_populates="refresh_tokens")
|
||||
revoker: Mapped[Optional["User"]] = relationship("User", foreign_keys=[revoked_by])
|
||||
|
||||
# Índices compuestos
|
||||
__table_args__ = (
|
||||
Index('idx_refresh_tokens_user_expires', 'user_id', 'expires_at'),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<RefreshToken(user_id='{self.user_id}', revoked={self.revoked}, expires={self.expires_at})>"
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
"""
|
||||
Verificar si el token es válido.
|
||||
|
||||
Un token es válido si:
|
||||
- No está revocado
|
||||
- No ha expirado
|
||||
"""
|
||||
return not self.revoked and self.expires_at > datetime.now(timezone.utc)
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""Verificar si el token ha expirado."""
|
||||
return datetime.now(timezone.utc) >= self.expires_at
|
||||
|
||||
def revoke(self, revoked_by: Optional[uuid.UUID] = None) -> None:
|
||||
"""
|
||||
Marcar el token como revocado.
|
||||
|
||||
Args:
|
||||
revoked_by: ID del usuario que revoc├│ el token
|
||||
"""
|
||||
self.revoked = True
|
||||
self.revoked_at = datetime.now(timezone.utc)
|
||||
if revoked_by:
|
||||
self.revoked_by = revoked_by
|
||||
|
||||
def track_usage(self) -> None:
|
||||
"""Registrar uso del token."""
|
||||
self.last_used_at = datetime.now(timezone.utc)
|
||||
self.usage_count += 1
|
||||
63
backend/app/models/roles.py
Normal file
63
backend/app/models/roles.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
Definición y helpers de roles para el sistema multi-tenant.
|
||||
|
||||
Fuente única: UserRole en app.models.user.
|
||||
Este módulo expone conjuntos de roles y helpers de verificación
|
||||
para usarse en deps.py y en los endpoints.
|
||||
|
||||
Roles globales (staff interno — alcance multi-tenant):
|
||||
ADMIN → control total sobre todos los tenants
|
||||
SUPPORT_MANAGER → gestiona equipos y SLAs de todos los tenants
|
||||
AGENT → atiende tickets de cualquier tenant
|
||||
AUDITOR → auditoría de solo lectura en todos los tenants
|
||||
|
||||
Roles de cliente (alcance limitado al propio tenant):
|
||||
CLIENT_ADMIN → administra organización: usuarios, configuración, tickets
|
||||
CLIENT_USER → crea y sigue sus propios tickets
|
||||
"""
|
||||
|
||||
from app.models.user import UserRole
|
||||
|
||||
# ── Conjuntos de roles ──────────────────────────────────────────────────────
|
||||
|
||||
GLOBAL_ROLES: frozenset[UserRole] = frozenset({
|
||||
UserRole.ADMIN,
|
||||
UserRole.SUPPORT_MANAGER,
|
||||
UserRole.AGENT,
|
||||
UserRole.AUDITOR,
|
||||
})
|
||||
|
||||
CLIENT_ROLES: frozenset[UserRole] = frozenset({
|
||||
UserRole.CLIENT_ADMIN,
|
||||
UserRole.CLIENT_USER,
|
||||
})
|
||||
|
||||
# ── Permisos por rol ────────────────────────────────────────────────────────
|
||||
|
||||
ROLE_PERMISSIONS: dict[UserRole, list[str]] = {
|
||||
# Staff global
|
||||
UserRole.ADMIN: ["manage_all", "view_all", "audit_all"],
|
||||
UserRole.SUPPORT_MANAGER: ["manage_teams", "view_all_tickets", "manage_sla"],
|
||||
UserRole.AGENT: ["view_all_tickets", "update_any_ticket"],
|
||||
UserRole.AUDITOR: ["view_all", "audit_all"],
|
||||
# Clientes (acotados al tenant)
|
||||
UserRole.CLIENT_ADMIN: ["manage_tenant", "manage_tenant_users", "view_tenant_tickets"],
|
||||
UserRole.CLIENT_USER: ["create_ticket", "view_own_tickets"],
|
||||
}
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def is_global_staff(role: UserRole) -> bool:
|
||||
"""Retorna True si el rol tiene alcance global (staff interno)."""
|
||||
return role.is_global
|
||||
|
||||
|
||||
def is_client_role(role: UserRole) -> bool:
|
||||
"""Retorna True si el rol está acotado al tenant del usuario."""
|
||||
return role.is_client
|
||||
|
||||
|
||||
def has_permission(role: UserRole, permission: str) -> bool:
|
||||
"""Verifica si un rol tiene un permiso específico."""
|
||||
return permission in ROLE_PERMISSIONS.get(role, [])
|
||||
|
||||
@@ -4,11 +4,10 @@ Sistemas afectados por tenant
|
||||
"""
|
||||
from sqlalchemy import String, Text, Boolean, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
|
||||
from app.core.database import Base
|
||||
from app.core.database import Base, GUID
|
||||
|
||||
class System(Base):
|
||||
"""Modelo de sistemas afectados (affected_systems en BD)"""
|
||||
@@ -21,7 +20,7 @@ class System(Base):
|
||||
|
||||
# ✅ AÑADIDO: tenant_id obligatorio para multi-tenancy (faltaba completamente)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False
|
||||
)
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
Tenant Model - ServiceManagerWeb
|
||||
Modelo para organizaciones cliente (multi-tenancy)
|
||||
"""
|
||||
from sqlalchemy import String, Integer, Text, Boolean, ARRAY
|
||||
from sqlalchemy import String, Integer, Text, Boolean, JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, ENUM
|
||||
from sqlalchemy.dialects.postgresql import UUID, ENUM, ARRAY as PG_ARRAY
|
||||
from typing import List, Optional
|
||||
import enum
|
||||
import uuid
|
||||
@@ -40,7 +40,7 @@ class Tenant(Base):
|
||||
max_users: Mapped[int] = mapped_column(Integer, default=50)
|
||||
max_storage_mb: Mapped[int] = mapped_column(Integer, default=1024)
|
||||
allowed_file_types: Mapped[List[str]] = mapped_column(
|
||||
ARRAY(String),
|
||||
JSON().with_variant(PG_ARRAY(String), "postgresql"),
|
||||
default=["pdf", "jpg", "jpeg", "png", "doc", "docx", "xls", "xlsx", "txt"]
|
||||
)
|
||||
|
||||
|
||||
@@ -2,15 +2,20 @@
|
||||
Ticket Model - ServiceManagerWeb
|
||||
Tickets de soporte - Core del negocio
|
||||
"""
|
||||
from sqlalchemy import String, ForeignKey, Text, Integer, CheckConstraint, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, ENUM
|
||||
from sqlalchemy import String, ForeignKey, Text, Integer, CheckConstraint, UniqueConstraint, Enum as SAEnum
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship, synonym
|
||||
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
import enum
|
||||
import uuid
|
||||
|
||||
from app.core.database import Base
|
||||
from app.core.database import Base, GUID
|
||||
|
||||
|
||||
def _generate_fallback_ticket_number() -> str:
|
||||
# Matches helper format "TK-000001" and stays within VARCHAR(20)
|
||||
return f"TK-{(uuid.uuid4().int % 1_000_000):06d}"
|
||||
|
||||
class TicketStatus(str, enum.Enum):
|
||||
"""Estados posibles de un ticket"""
|
||||
@@ -35,50 +40,64 @@ class Ticket(Base):
|
||||
|
||||
# Multi-tenancy
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False
|
||||
)
|
||||
|
||||
# Campos básicos
|
||||
ticket_number: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
ticket_number: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default=_generate_fallback_ticket_number,
|
||||
)
|
||||
subject: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
# Compatibility aliases (API/UI/tests often use these names)
|
||||
title = synonym("subject")
|
||||
system_id = synonym("affected_system_id")
|
||||
|
||||
# Estado y Prioridad
|
||||
status: Mapped[TicketStatus] = mapped_column(
|
||||
ENUM(TicketStatus, name="ticket_status_enum", create_type=False),
|
||||
SAEnum(TicketStatus, name="ticket_status_enum", native_enum=False).with_variant(
|
||||
PG_ENUM(TicketStatus, name="ticket_status_enum", create_type=True),
|
||||
"postgresql",
|
||||
),
|
||||
default=TicketStatus.NEW,
|
||||
nullable=False
|
||||
)
|
||||
priority: Mapped[TicketPriority] = mapped_column(
|
||||
ENUM(TicketPriority, name="ticket_priority_enum", create_type=False),
|
||||
SAEnum(TicketPriority, name="ticket_priority_enum", native_enum=False).with_variant(
|
||||
PG_ENUM(TicketPriority, name="ticket_priority_enum", create_type=True),
|
||||
"postgresql",
|
||||
),
|
||||
default=TicketPriority.MEDIUM,
|
||||
nullable=False
|
||||
)
|
||||
|
||||
# ✅ CORREGIDO: Foreign Keys apuntan a tablas correctas
|
||||
created_by: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("users.id"),
|
||||
nullable=False
|
||||
)
|
||||
assigned_to: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("users.id"),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# ✅ CORREGIDO: Renombrado de system_id a affected_system_id
|
||||
affected_system_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("affected_systems.id"), # ✅ Tabla correcta
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# ✅ CORREGIDO: Foreign key a tabla correcta
|
||||
category_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("ticket_categories.id"), # ✅ Tabla correcta
|
||||
nullable=True
|
||||
)
|
||||
|
||||
@@ -4,15 +4,15 @@ User Model - ServiceManagerWeb
|
||||
Modelo para usuarios del sistema (internos y clientes)
|
||||
"""
|
||||
|
||||
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Text, ARRAY
|
||||
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Text, JSON, Enum as SAEnum
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, ENUM
|
||||
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM, ARRAY as PG_ARRAY
|
||||
from typing import Optional, List
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.database import Base
|
||||
from app.core.database import Base, GUID
|
||||
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
@@ -27,6 +27,24 @@ class UserRole(str, enum.Enum):
|
||||
CLIENT_ADMIN = "CLIENT_ADMIN" # Admin de organización cliente
|
||||
CLIENT_USER = "CLIENT_USER" # Usuario final cliente
|
||||
|
||||
@property
|
||||
def is_global(self) -> bool:
|
||||
"""True si el rol tiene alcance global (staff interno cross-tenant)."""
|
||||
return self in (
|
||||
UserRole.ADMIN,
|
||||
UserRole.SUPPORT_MANAGER,
|
||||
UserRole.AGENT,
|
||||
UserRole.AUDITOR,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_client(self) -> bool:
|
||||
"""True si el rol está acotado al tenant del usuario."""
|
||||
return self in (
|
||||
UserRole.CLIENT_ADMIN,
|
||||
UserRole.CLIENT_USER,
|
||||
)
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""Modelo de Usuario."""
|
||||
@@ -35,7 +53,7 @@ class User(Base):
|
||||
|
||||
# Relación con tenant
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False
|
||||
)
|
||||
@@ -48,12 +66,20 @@ class User(Base):
|
||||
|
||||
# Autenticación
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role: Mapped[UserRole] = mapped_column(ENUM(UserRole, name="user_role_enum"), nullable=False)
|
||||
role: Mapped[UserRole] = mapped_column(
|
||||
SAEnum(UserRole, name="user_role_enum", native_enum=False).with_variant(
|
||||
PG_ENUM(UserRole, name="user_role_enum", create_type=True),
|
||||
"postgresql",
|
||||
),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# 2FA (opcional para staff interno)
|
||||
totp_secret: Mapped[Optional[str]] = mapped_column(String(32))
|
||||
totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
backup_codes: Mapped[Optional[List[str]]] = mapped_column(ARRAY(String))
|
||||
backup_codes: Mapped[Optional[List[str]]] = mapped_column(
|
||||
JSON().with_variant(PG_ARRAY(String), "postgresql")
|
||||
)
|
||||
|
||||
# Estado
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
@@ -78,10 +104,11 @@ class User(Base):
|
||||
back_populates="assigned_to_user",
|
||||
foreign_keys="Ticket.assigned_to"
|
||||
)
|
||||
|
||||
# Unique constraint por tenant
|
||||
__table_args__ = (
|
||||
{"postgresql_tablespace": "users"},
|
||||
refresh_tokens: Mapped[List["RefreshToken"]] = relationship(
|
||||
"RefreshToken",
|
||||
back_populates="user",
|
||||
foreign_keys="RefreshToken.user_id",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
@@ -104,11 +131,8 @@ class User(Base):
|
||||
|
||||
@property
|
||||
def is_client(self) -> bool:
|
||||
"""Check if user is a client."""
|
||||
return self.role in [
|
||||
UserRole.CLIENT_ADMIN,
|
||||
UserRole.CLIENT_USER
|
||||
]
|
||||
"""Check if user is a client (rol acotado al propio tenant)."""
|
||||
return self.role.is_client
|
||||
|
||||
@property
|
||||
def can_manage_users(self) -> bool:
|
||||
@@ -116,7 +140,7 @@ class User(Base):
|
||||
return self.role in [
|
||||
UserRole.ADMIN,
|
||||
UserRole.SUPPORT_MANAGER,
|
||||
UserRole.CLIENT_ADMIN
|
||||
UserRole.CLIENT_ADMIN,
|
||||
]
|
||||
|
||||
@property
|
||||
@@ -125,7 +149,7 @@ class User(Base):
|
||||
return self.role in [
|
||||
UserRole.ADMIN,
|
||||
UserRole.SUPPORT_MANAGER,
|
||||
UserRole.AGENT
|
||||
UserRole.AGENT,
|
||||
]
|
||||
|
||||
@property
|
||||
|
||||
311
backend/app/services/audit_service.py
Normal file
311
backend/app/services/audit_service.py
Normal file
@@ -0,0 +1,311 @@
|
||||
"""
|
||||
Audit Service - ServiceManagerWeb
|
||||
|
||||
Funciones helper para facilitar el registro de auditoría.
|
||||
Simplifica el proceso de logging en toda la aplicaci├│n.
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict, Any
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from fastapi import Request
|
||||
import uuid
|
||||
import structlog
|
||||
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.user import User
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AuditService:
|
||||
"""
|
||||
Servicio centralizado para registro de auditoría.
|
||||
|
||||
Uso básico:
|
||||
await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=tenant.id,
|
||||
user_id=current_user.id,
|
||||
action="ticket.create",
|
||||
resource_type="ticket",
|
||||
resource_id=new_ticket.id,
|
||||
new_values={"subject": "...", "status": "NEW"}
|
||||
)
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def log(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
action: str,
|
||||
resource_type: str,
|
||||
resource_id: Optional[uuid.UUID] = None,
|
||||
user_id: Optional[uuid.UUID] = None,
|
||||
old_values: Optional[Dict[str, Any]] = None,
|
||||
new_values: Optional[Dict[str, Any]] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
request: Optional[Request] = None
|
||||
) -> AuditLog:
|
||||
"""
|
||||
Registra una acción en la bitácora de auditoría.
|
||||
|
||||
Args:
|
||||
db: Sesi├│n de base de datos
|
||||
tenant_id: ID del tenant
|
||||
action: Acci├│n realizada (formato: "recurso.verbo")
|
||||
Ejemplos: "user.login", "ticket.create", "ticket.assign"
|
||||
resource_type: Tipo de recurso ("user", "ticket", "comment", etc.)
|
||||
resource_id: ID del recurso afectado (opcional)
|
||||
user_id: ID del usuario que ejecut├│ la acci├│n (opcional = sistema)
|
||||
old_values: Valores antes del cambio (opcional)
|
||||
new_values: Valores despu├®s del cambio (opcional)
|
||||
metadata: Informaci├│n adicional (opcional)
|
||||
request: Request de FastAPI para extraer IP y user agent (opcional)
|
||||
|
||||
Returns:
|
||||
AuditLog creado
|
||||
"""
|
||||
# Extraer información del request si está disponible
|
||||
ip_address = None
|
||||
user_agent = None
|
||||
correlation_id = None
|
||||
|
||||
if request:
|
||||
# IP del cliente
|
||||
if request.client:
|
||||
ip_address = request.client.host
|
||||
|
||||
# User agent
|
||||
user_agent = request.headers.get("user-agent")
|
||||
|
||||
# Correlation ID (si existe en el request state)
|
||||
correlation_id = getattr(request.state, "correlation_id", None)
|
||||
|
||||
# Crear registro de auditoría
|
||||
audit_log = AuditLog(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
correlation_id=correlation_id,
|
||||
old_values=old_values,
|
||||
new_values=new_values,
|
||||
extra_metadata=metadata # Mapeo metadata -> extra_metadata
|
||||
)
|
||||
|
||||
db.add(audit_log)
|
||||
await db.flush() # No commit, se hará con la transacción principal
|
||||
|
||||
# Log estructurado para debugging
|
||||
logger.info(
|
||||
"Audit log created",
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=str(resource_id) if resource_id else None,
|
||||
user_id=str(user_id) if user_id else "system",
|
||||
tenant_id=str(tenant_id)
|
||||
)
|
||||
|
||||
return audit_log
|
||||
|
||||
@staticmethod
|
||||
async def log_login(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
request: Request,
|
||||
success: bool = True
|
||||
) -> AuditLog:
|
||||
"""
|
||||
Registra un intento de login.
|
||||
|
||||
Args:
|
||||
db: Sesi├│n de base de datos
|
||||
user: Usuario que intent├│ loguearse
|
||||
request: Request de FastAPI
|
||||
success: Si el login fue exitoso
|
||||
|
||||
Returns:
|
||||
AuditLog creado
|
||||
"""
|
||||
return await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=user.tenant_id,
|
||||
user_id=user.id if success else None,
|
||||
action="user.login" if success else "user.login_failed",
|
||||
resource_type="user",
|
||||
resource_id=user.id,
|
||||
metadata={
|
||||
"success": success,
|
||||
"email": user.email
|
||||
},
|
||||
request=request
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def log_logout(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
request: Request
|
||||
) -> AuditLog:
|
||||
"""
|
||||
Registra un logout.
|
||||
|
||||
Args:
|
||||
db: Sesi├│n de base de datos
|
||||
user: Usuario que cerr├│ sesi├│n
|
||||
request: Request de FastAPI
|
||||
|
||||
Returns:
|
||||
AuditLog creado
|
||||
"""
|
||||
return await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=user.tenant_id,
|
||||
user_id=user.id,
|
||||
action="user.logout",
|
||||
resource_type="user",
|
||||
resource_id=user.id,
|
||||
request=request
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def log_create(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
resource_type: str,
|
||||
resource_id: uuid.UUID,
|
||||
new_values: Dict[str, Any],
|
||||
request: Optional[Request] = None
|
||||
) -> AuditLog:
|
||||
"""
|
||||
Registra la creaci├│n de un recurso.
|
||||
|
||||
Args:
|
||||
db: Sesi├│n de base de datos
|
||||
tenant_id: ID del tenant
|
||||
user_id: ID del usuario que cre├│ el recurso
|
||||
resource_type: Tipo de recurso ("ticket", "user", etc.)
|
||||
resource_id: ID del recurso creado
|
||||
new_values: Valores del nuevo recurso
|
||||
request: Request de FastAPI (opcional)
|
||||
|
||||
Returns:
|
||||
AuditLog creado
|
||||
"""
|
||||
return await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
action=f"{resource_type}.create",
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
new_values=new_values,
|
||||
request=request
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def log_update(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
resource_type: str,
|
||||
resource_id: uuid.UUID,
|
||||
old_values: Dict[str, Any],
|
||||
new_values: Dict[str, Any],
|
||||
request: Optional[Request] = None
|
||||
) -> AuditLog:
|
||||
"""
|
||||
Registra la actualizaci├│n de un recurso.
|
||||
|
||||
Args:
|
||||
db: Sesi├│n de base de datos
|
||||
tenant_id: ID del tenant
|
||||
user_id: ID del usuario que actualiz├│
|
||||
resource_type: Tipo de recurso
|
||||
resource_id: ID del recurso
|
||||
old_values: Valores anteriores
|
||||
new_values: Valores nuevos
|
||||
request: Request de FastAPI (opcional)
|
||||
|
||||
Returns:
|
||||
AuditLog creado
|
||||
"""
|
||||
return await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
action=f"{resource_type}.update",
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
old_values=old_values,
|
||||
new_values=new_values,
|
||||
request=request
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def log_delete(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
resource_type: str,
|
||||
resource_id: uuid.UUID,
|
||||
old_values: Dict[str, Any],
|
||||
request: Optional[Request] = None
|
||||
) -> AuditLog:
|
||||
"""
|
||||
Registra la eliminaci├│n de un recurso.
|
||||
|
||||
Args:
|
||||
db: Sesi├│n de base de datos
|
||||
tenant_id: ID del tenant
|
||||
user_id: ID del usuario que elimin├│
|
||||
resource_type: Tipo de recurso
|
||||
resource_id: ID del recurso eliminado
|
||||
old_values: Valores del recurso antes de eliminar
|
||||
request: Request de FastAPI (opcional)
|
||||
|
||||
Returns:
|
||||
AuditLog creado
|
||||
"""
|
||||
return await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
action=f"{resource_type}.delete",
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
old_values=old_values,
|
||||
request=request
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def sanitize_values(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Sanitiza valores sensibles antes de guardarlos en audit log.
|
||||
|
||||
Remueve campos como passwords, tokens, etc.
|
||||
|
||||
Args:
|
||||
values: Diccionario de valores
|
||||
|
||||
Returns:
|
||||
Diccionario sanitizado
|
||||
"""
|
||||
sensitive_fields = {
|
||||
'password',
|
||||
'password_hash',
|
||||
'totp_secret',
|
||||
'backup_codes',
|
||||
'token',
|
||||
'access_token',
|
||||
'refresh_token'
|
||||
}
|
||||
|
||||
return {
|
||||
key: '***REDACTED***' if key in sensitive_fields else value
|
||||
for key, value in values.items()
|
||||
}
|
||||
170
backend/app/services/ticket_service.py
Normal file
170
backend/app/services/ticket_service.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
Ticket Service - ServiceManagerWeb
|
||||
|
||||
Lógica de negocio para creación y gestión de tickets.
|
||||
Inyectable vía Depends() en los endpoints de FastAPI.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.ticket import Ticket, TicketStatus, TicketPriority
|
||||
from app.models.category import Category
|
||||
from app.models.system import System
|
||||
from app.api.schemas.ticket import TicketCreate
|
||||
from app.api.v1.helpers import (
|
||||
generate_next_ticket_number,
|
||||
calculate_sla_deadlines,
|
||||
safe_audit_log,
|
||||
)
|
||||
|
||||
|
||||
class TicketService:
|
||||
"""Servicio de tickets: encapsula lógica de negocio fuera del router."""
|
||||
|
||||
def __init__(self, db: AsyncSession = Depends(get_db)):
|
||||
self.db = db
|
||||
|
||||
async def create_ticket(
|
||||
self,
|
||||
ticket: TicketCreate,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> dict:
|
||||
"""
|
||||
Crea un ticket con validación multi-tenant, cálculo de SLA y auto-asignación.
|
||||
|
||||
Args:
|
||||
ticket: Datos del ticket a crear.
|
||||
tenant_id: Tenant del usuario autenticado.
|
||||
user_id: ID del usuario que crea el ticket.
|
||||
|
||||
Returns:
|
||||
dict compatible con TicketResponse.
|
||||
|
||||
Raises:
|
||||
HTTPException 400: UUID inválido, categoría/sistema no encontrado o de otro tenant.
|
||||
HTTPException 500: Fallo persistente tras max_retries.
|
||||
"""
|
||||
max_retries = 3
|
||||
last_error = None
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
ticket_number = await generate_next_ticket_number(self.db, tenant_id)
|
||||
category_uuid = uuid.UUID(ticket.category_id) if ticket.category_id else None
|
||||
system_uuid = uuid.UUID(ticket.affected_system_id) if ticket.affected_system_id else None
|
||||
|
||||
category = None
|
||||
if category_uuid:
|
||||
category = await self.db.get(Category, category_uuid)
|
||||
if not category or category.tenant_id != tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"La categoría con ID {ticket.category_id} no existe.",
|
||||
)
|
||||
|
||||
if system_uuid:
|
||||
system = await self.db.get(System, system_uuid)
|
||||
if not system or system.tenant_id != tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"El sistema con ID {ticket.affected_system_id} no existe.",
|
||||
)
|
||||
|
||||
sla_response_due, sla_resolution_due = calculate_sla_deadlines(category)
|
||||
assigned_to_user = category.auto_assign_to if category and category.auto_assign_to else None
|
||||
|
||||
db_ticket = Ticket(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant_id,
|
||||
ticket_number=ticket_number,
|
||||
subject=ticket.subject,
|
||||
description=ticket.description,
|
||||
category_id=category_uuid,
|
||||
affected_system_id=system_uuid,
|
||||
priority=TicketPriority[ticket.priority.upper()],
|
||||
created_by=user_id,
|
||||
assigned_to=assigned_to_user,
|
||||
status=TicketStatus.NEW,
|
||||
sla_response_due=sla_response_due,
|
||||
sla_resolution_due=sla_resolution_due,
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
self.db.add(db_ticket)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(db_ticket)
|
||||
|
||||
await safe_audit_log(
|
||||
db=self.db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
action="ticket.create",
|
||||
resource_type="ticket",
|
||||
resource_id=db_ticket.id,
|
||||
new_values={
|
||||
"ticket_number": db_ticket.ticket_number,
|
||||
"subject": db_ticket.subject,
|
||||
"priority": db_ticket.priority.value,
|
||||
"status": db_ticket.status.value,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"id": str(db_ticket.id),
|
||||
"ticket_number": db_ticket.ticket_number,
|
||||
"subject": db_ticket.subject,
|
||||
"title": db_ticket.subject,
|
||||
"description": db_ticket.description,
|
||||
"status": db_ticket.status.value,
|
||||
"priority": db_ticket.priority.value,
|
||||
"category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
|
||||
"affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None,
|
||||
"system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None,
|
||||
"contact_email": ticket.contact_email,
|
||||
"contact_phone": ticket.contact_phone,
|
||||
"created_by": str(db_ticket.created_by),
|
||||
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
|
||||
"created_at": db_ticket.created_at,
|
||||
"updated_at": db_ticket.updated_at,
|
||||
"sla_response_due": db_ticket.sla_response_due,
|
||||
"sla_resolution_due": db_ticket.sla_resolution_due,
|
||||
"first_response_at": db_ticket.first_response_at,
|
||||
"resolved_at": db_ticket.resolved_at,
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
await self.db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid UUID format: {str(e)}",
|
||||
)
|
||||
except HTTPException:
|
||||
await self.db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
await self.db.rollback()
|
||||
last_error = e
|
||||
if "duplicate key" in str(e).lower() and "ticket_number" in str(e).lower():
|
||||
if attempt < max_retries - 1:
|
||||
continue
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Error creating ticket: {str(e)}",
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"No se pudo crear el ticket después de {max_retries} intentos: {str(last_error)}",
|
||||
)
|
||||
|
||||
|
||||
def get_ticket_service(db: AsyncSession = Depends(get_db)) -> TicketService:
|
||||
"""Factory function para inyectar TicketService vía Depends()."""
|
||||
return TicketService(db)
|
||||
270
backend/app/services/token_service.py
Normal file
270
backend/app/services/token_service.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""
|
||||
Token Service - ServiceManagerWeb
|
||||
|
||||
Servicio para gesti├│n de refresh tokens persistentes.
|
||||
"""
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
import uuid
|
||||
import structlog
|
||||
|
||||
from app.models.refresh_token import RefreshToken
|
||||
from app.models.user import User
|
||||
from app.core.config import get_settings
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class TokenService:
|
||||
"""
|
||||
Servicio para gesti├│n de refresh tokens.
|
||||
|
||||
Proporciona m├®todos para crear, validar, revocar y limpiar
|
||||
refresh tokens persistentes.
|
||||
|
||||
NOTA: Los tokens se almacenan directamente en BD (no hash)
|
||||
ya que los JWTs son firmados y verificables.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def create_refresh_token(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
refresh_token: str,
|
||||
device_id: Optional[str] = None,
|
||||
device_name: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
ip_address: Optional[str] = None
|
||||
) -> RefreshToken:
|
||||
"""
|
||||
Crear y persistir un refresh token.
|
||||
|
||||
Args:
|
||||
db: Sesi├│n de base de datos
|
||||
user: Usuario propietario del token
|
||||
refresh_token: Token JWT generado (se almacena directamente)
|
||||
device_id: ID ├║nico del dispositivo (UUID generado por cliente)
|
||||
device_name: Nombre del dispositivo (ej: "Chrome en Windows")
|
||||
user_agent: User agent completo del navegador
|
||||
ip_address: IP del cliente
|
||||
|
||||
Returns:
|
||||
RefreshToken creado
|
||||
"""
|
||||
# Calcular expiraci├│n
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(
|
||||
days=settings.REFRESH_TOKEN_EXPIRE_DAYS
|
||||
)
|
||||
|
||||
# Crear registro - almacena JWT directamente (columna UNIQUE)
|
||||
db_token = RefreshToken(
|
||||
user_id=user.id,
|
||||
token=refresh_token, # JWT almacenado directamente
|
||||
device_id=device_id,
|
||||
device_name=device_name,
|
||||
user_agent=user_agent,
|
||||
ip_address=ip_address,
|
||||
expires_at=expires_at,
|
||||
revoked=False,
|
||||
usage_count=0
|
||||
)
|
||||
|
||||
db.add(db_token)
|
||||
await db.flush()
|
||||
|
||||
logger.info(
|
||||
"Refresh token created",
|
||||
user_id=str(user.id),
|
||||
token_id=str(db_token.id),
|
||||
device_name=device_name,
|
||||
expires_at=expires_at.isoformat()
|
||||
)
|
||||
|
||||
return db_token
|
||||
|
||||
@staticmethod
|
||||
async def verify_refresh_token(
|
||||
db: AsyncSession,
|
||||
refresh_token: str
|
||||
) -> Optional[RefreshToken]:
|
||||
"""
|
||||
Verificar que el refresh token exista y sea válido.
|
||||
|
||||
Busca el JWT directamente en la BD y verifica su estado.
|
||||
|
||||
Args:
|
||||
db: Sesi├│n de base de datos
|
||||
refresh_token: Token JWT a verificar
|
||||
|
||||
Returns:
|
||||
RefreshToken si es válido, None si no existe o está revocado/expirado
|
||||
"""
|
||||
# Buscar token directamente en BD (sin hash)
|
||||
query = select(RefreshToken).where(
|
||||
RefreshToken.token == refresh_token
|
||||
)
|
||||
result = await db.execute(query)
|
||||
db_token = result.scalar_one_or_none()
|
||||
|
||||
if not db_token:
|
||||
logger.warning("Refresh token not found in database")
|
||||
return None
|
||||
|
||||
# Verificar si es válido (usa property is_valid del modelo)
|
||||
if not db_token.is_valid:
|
||||
logger.warning(
|
||||
"Invalid refresh token",
|
||||
token_id=str(db_token.id),
|
||||
revoked=db_token.revoked,
|
||||
expired=db_token.is_expired
|
||||
)
|
||||
return None
|
||||
|
||||
# Actualizar estadísticas de uso
|
||||
db_token.track_usage()
|
||||
await db.flush()
|
||||
|
||||
logger.info(
|
||||
"Refresh token verified and usage tracked",
|
||||
token_id=str(db_token.id),
|
||||
usage_count=db_token.usage_count
|
||||
)
|
||||
return db_token
|
||||
|
||||
@staticmethod
|
||||
async def revoke_token(
|
||||
db: AsyncSession,
|
||||
refresh_token: str,
|
||||
revoked_by_user_id: Optional[uuid.UUID] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Revocar un refresh token específico.
|
||||
|
||||
Args:
|
||||
db: Sesi├│n de base de datos
|
||||
refresh_token: Token JWT a revocar
|
||||
revoked_by_user_id: ID del usuario que revoca (para auditoría)
|
||||
|
||||
Returns:
|
||||
True si se revoc├│, False si no se encontr├│
|
||||
"""
|
||||
# Buscar token directamente (sin hash)
|
||||
query = select(RefreshToken).where(
|
||||
RefreshToken.token == refresh_token
|
||||
)
|
||||
result = await db.execute(query)
|
||||
db_token = result.scalar_one_or_none()
|
||||
|
||||
if not db_token:
|
||||
logger.warning("Refresh token not found for revocation")
|
||||
return False
|
||||
|
||||
# Revocar usando m├®todo del modelo
|
||||
db_token.revoke(revoked_by=revoked_by_user_id)
|
||||
await db.flush()
|
||||
|
||||
logger.info(
|
||||
"Refresh token revoked",
|
||||
token_id=str(db_token.id),
|
||||
revoked_by=str(revoked_by_user_id) if revoked_by_user_id else None
|
||||
)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def revoke_all_user_tokens(
|
||||
db: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
revoked_by_user_id: Optional[uuid.UUID] = None
|
||||
) -> int:
|
||||
"""
|
||||
Revocar todos los tokens activos de un usuario.
|
||||
|
||||
Útil para logout en todos los dispositivos.
|
||||
|
||||
Args:
|
||||
db: Sesi├│n de base de datos
|
||||
user_id: ID del usuario
|
||||
revoked_by_user_id: ID del usuario que ejecuta la revocación (para auditoría)
|
||||
|
||||
Returns:
|
||||
N├║mero de tokens revocados
|
||||
"""
|
||||
# Buscar todos los tokens activos del usuario
|
||||
query = select(RefreshToken).where(
|
||||
RefreshToken.user_id == user_id,
|
||||
RefreshToken.revoked == False
|
||||
)
|
||||
result = await db.execute(query)
|
||||
tokens = result.scalars().all()
|
||||
|
||||
count = 0
|
||||
for token in tokens:
|
||||
token.revoke(revoked_by=revoked_by_user_id)
|
||||
count += 1
|
||||
|
||||
await db.flush()
|
||||
|
||||
logger.info(
|
||||
"All user tokens revoked",
|
||||
user_id=str(user_id),
|
||||
count=count,
|
||||
revoked_by=str(revoked_by_user_id) if revoked_by_user_id else None
|
||||
)
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def cleanup_expired_tokens(
|
||||
db: AsyncSession
|
||||
) -> int:
|
||||
"""
|
||||
Eliminar tokens expirados de la base de datos.
|
||||
|
||||
Tarea de mantenimiento para limpiar tokens antiguos.
|
||||
|
||||
Args:
|
||||
db: Sesi├│n de base de datos
|
||||
|
||||
Returns:
|
||||
N├║mero de tokens eliminados
|
||||
"""
|
||||
# Eliminar tokens expirados hace más de 7 días
|
||||
cutoff_date = datetime.now(timezone.utc) - timedelta(days=7)
|
||||
|
||||
query = delete(RefreshToken).where(
|
||||
RefreshToken.expires_at < cutoff_date
|
||||
)
|
||||
result = await db.execute(query)
|
||||
await db.flush()
|
||||
|
||||
deleted_count = result.rowcount
|
||||
|
||||
logger.info("Expired tokens cleaned up", count=deleted_count)
|
||||
return deleted_count
|
||||
|
||||
@staticmethod
|
||||
async def get_user_tokens(
|
||||
db: AsyncSession,
|
||||
user_id: uuid.UUID
|
||||
) -> list[RefreshToken]:
|
||||
"""
|
||||
Obtener todos los tokens activos de un usuario.
|
||||
|
||||
Args:
|
||||
db: Sesi├│n de base de datos
|
||||
user_id: ID del usuario
|
||||
|
||||
Returns:
|
||||
Lista de RefreshTokens activos
|
||||
"""
|
||||
query = select(RefreshToken).where(
|
||||
RefreshToken.user_id == user_id,
|
||||
RefreshToken.revoked == False,
|
||||
RefreshToken.expires_at > datetime.utcnow()
|
||||
).order_by(RefreshToken.created_at.desc())
|
||||
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
0
backend/app/tests/__init__.py
Normal file
0
backend/app/tests/__init__.py
Normal file
373
backend/app/tests/conftest.py
Normal file
373
backend/app/tests/conftest.py
Normal file
@@ -0,0 +1,373 @@
|
||||
"""
|
||||
Integration Test Fixtures - ServiceManagerWeb (Docker / PostgreSQL)
|
||||
|
||||
backend/app/tests/conftest.py
|
||||
|
||||
Usa la BD Docker existente (servicemanager).
|
||||
Los fixtures leen datos reales ya seedeados — no crean ni eliminan nada.
|
||||
Los tests que inserten datos propios quedan aislados por rollback.
|
||||
|
||||
Tenant de referencia : aduanasoft
|
||||
Usuarios de referencia:
|
||||
admin@aduanasoft.com → ADMIN
|
||||
manager@aduanasoft.com → SUPPORT_MANAGER
|
||||
agente@aduanasoft.com → AGENT
|
||||
auditor1@test.com → AUDITOR (tenant aduanasoft)
|
||||
admin-cliente@empresa-demo → CLIENT_ADMIN
|
||||
test_user@aduanasoft.com → CLIENT_USER
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
import pytest
|
||||
from typing import AsyncGenerator, Generator
|
||||
|
||||
# ============================================================
|
||||
# ENV VARS — antes de importar la app
|
||||
# ============================================================
|
||||
os.environ.setdefault("ENVIRONMENT", "testing")
|
||||
os.environ.setdefault("TESTING", "true")
|
||||
os.environ.setdefault("DEBUG", "false")
|
||||
os.environ.setdefault("SECRET_KEY", "integration-secret-key-32chars!!!!")
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "integration-jwt-secret-32chars!!!!")
|
||||
os.environ.setdefault(
|
||||
"DATABASE_URL",
|
||||
"postgresql+asyncpg://servicemanager:servicemanager123@localhost:5432/servicemanager",
|
||||
)
|
||||
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/14")
|
||||
os.environ.setdefault("CELERY_BROKER_URL", "redis://localhost:6379/14")
|
||||
os.environ.setdefault("CELERY_RESULT_BACKEND", "redis://localhost:6379/14")
|
||||
os.environ.setdefault("CORS_ORIGINS", "http://localhost:3000")
|
||||
os.environ.setdefault("ALLOWED_FILE_EXTENSIONS", "pdf,jpg,jpeg,png,doc,docx,txt")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# EVENT LOOP (session-scoped)
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop() -> Generator:
|
||||
"""Event loop compartido para toda la sesión de tests."""
|
||||
policy = asyncio.get_event_loop_policy()
|
||||
loop = policy.new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# ENGINE (session-scoped — reutiliza el pool toda la sesión)
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def engine():
|
||||
"""
|
||||
Conecta al PostgreSQL Docker existente (servicemanager).
|
||||
NO crea ni destruye el schema — la BD ya está lista.
|
||||
"""
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
import app.models # noqa: F401 — registra todos los modelos
|
||||
|
||||
_engine = create_async_engine(os.environ["DATABASE_URL"], echo=False)
|
||||
yield _engine
|
||||
await _engine.dispose()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# DB (function-scoped — rollback para datos creados en el test)
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture
|
||||
async def db(engine) -> AsyncGenerator:
|
||||
"""
|
||||
Sesión con transacción por test.
|
||||
Los datos seedeados son visibles (ya están committed).
|
||||
Cualquier INSERT hecho en el test se revierte al finalizar.
|
||||
"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with factory() as session:
|
||||
await session.begin()
|
||||
yield session
|
||||
await session.rollback()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# TENANT (function-scoped — lee el registro existente)
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture
|
||||
async def tenant_a(db):
|
||||
"""Tenant 'aduanasoft' ya existente en la BD."""
|
||||
from sqlalchemy import select
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
result = await db.execute(select(Tenant).where(Tenant.slug == "aduanasoft"))
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# USUARIOS (function-scoped — leen registros existentes)
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_user(db, tenant_a):
|
||||
"""ADMIN: admin@aduanasoft.com (tenant aduanasoft)."""
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.email == "admin@aduanasoft.com")
|
||||
.where(User.tenant_id == tenant_a.id)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def manager_user(db, tenant_a):
|
||||
"""SUPPORT_MANAGER: manager@aduanasoft.com (tenant aduanasoft)."""
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.email == "manager@aduanasoft.com")
|
||||
.where(User.tenant_id == tenant_a.id)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def agent_user(db, tenant_a):
|
||||
"""AGENT: agente@aduanasoft.com (tenant aduanasoft)."""
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.email == "agente@aduanasoft.com")
|
||||
.where(User.tenant_id == tenant_a.id)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def user_tenant_a(db, tenant_a):
|
||||
"""CLIENT_USER: test_user@aduanasoft.com (tenant aduanasoft)."""
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.email == "test_user@aduanasoft.com")
|
||||
.where(User.tenant_id == tenant_a.id)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# HTTP CLIENT (function-scoped)
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture
|
||||
async def client(db) -> AsyncGenerator:
|
||||
"""
|
||||
httpx.AsyncClient contra la app FastAPI en memoria (sin red).
|
||||
get_db queda sobreescrito para inyectar la sesión de test.
|
||||
Los cambios del test se revierten al terminar (rollback en db).
|
||||
"""
|
||||
import httpx
|
||||
from httpx import ASGITransport
|
||||
from app.main import app
|
||||
from app.core.database import get_db
|
||||
|
||||
async def _override_get_db():
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_db] = _override_get_db
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
) as ac:
|
||||
yield ac
|
||||
|
||||
app.dependency_overrides.pop(get_db, None)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# FIXTURES DE AISLAMIENTO MULTI-TENANT
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture
|
||||
def make_token():
|
||||
"""Factory de JWT tokens para autenticar clientes HTTP en tests."""
|
||||
from app.core.security import security
|
||||
|
||||
def _make(user):
|
||||
return security.create_access_token(data={"sub": str(user.id)})
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def app_with_db(db):
|
||||
"""
|
||||
Override de get_db compartido para todos los HTTP clients de un mismo test.
|
||||
Garantiza que todos los clients usen la misma sesión (y el mismo rollback).
|
||||
"""
|
||||
from app.main import app as _app
|
||||
from app.core.database import get_db
|
||||
|
||||
async def _override():
|
||||
yield db
|
||||
|
||||
_app.dependency_overrides[get_db] = _override
|
||||
yield _app
|
||||
_app.dependency_overrides.pop(get_db, None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def tenant_b(db):
|
||||
"""Tenant 'empresa-test' creado en la transacción del test (se revierte al final)."""
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
t = Tenant(name="Empresa Test", slug="empresa-test")
|
||||
db.add(t)
|
||||
await db.flush()
|
||||
return t
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def user_b(db, tenant_b):
|
||||
"""CLIENT_ADMIN en tenant_b — puede gestionar recursos de su tenant."""
|
||||
from app.models.user import User, UserRole
|
||||
from app.core.security import security
|
||||
|
||||
u = User(
|
||||
tenant_id=tenant_b.id,
|
||||
email="admin@empresa-test.com",
|
||||
first_name="Admin",
|
||||
last_name="Test",
|
||||
password_hash=security.hash_password("Test1234!"),
|
||||
role=UserRole.CLIENT_ADMIN,
|
||||
is_active=True,
|
||||
email_verified=True,
|
||||
)
|
||||
db.add(u)
|
||||
await db.flush()
|
||||
return u
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client_tenant_a(app_with_db, manager_user, make_token):
|
||||
"""HTTP client autenticado como SUPPORT_MANAGER de tenant_a (aduanasoft).
|
||||
Usa manager_user en lugar de admin_user para mantener el aislamiento de
|
||||
tenant en GET /users/ (el ADMIN global bypasa el filtro de tenant).
|
||||
"""
|
||||
import httpx
|
||||
from httpx import ASGITransport
|
||||
|
||||
token = make_token(manager_user)
|
||||
async with httpx.AsyncClient(
|
||||
transport=ASGITransport(app=app_with_db),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
) as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client_tenant_b(app_with_db, user_b, make_token):
|
||||
"""HTTP client autenticado como CLIENT_ADMIN de tenant_b (empresa-test)."""
|
||||
import httpx
|
||||
from httpx import ASGITransport
|
||||
|
||||
token = make_token(user_b)
|
||||
async with httpx.AsyncClient(
|
||||
transport=ASGITransport(app=app_with_db),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
) as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client_admin(app_with_db, admin_user, make_token):
|
||||
"""HTTP client autenticado como ADMIN global."""
|
||||
import httpx
|
||||
from httpx import ASGITransport
|
||||
|
||||
token = make_token(admin_user)
|
||||
async with httpx.AsyncClient(
|
||||
transport=ASGITransport(app=app_with_db),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
) as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_ticket_tenant_a(client_tenant_a):
|
||||
"""Factory: crea un ticket en tenant_a vía HTTP y retorna el JSON de respuesta."""
|
||||
async def _create(subject="Ticket Tenant A", priority="MEDIUM"):
|
||||
resp = await client_tenant_a.post("/v1/tickets/", json={
|
||||
"subject": subject,
|
||||
"description": "Test de aislamiento tenant A",
|
||||
"priority": priority,
|
||||
})
|
||||
assert resp.status_code in (200, 201), f"Error creando ticket A: {resp.text}"
|
||||
return resp.json()
|
||||
|
||||
return _create
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_ticket_tenant_b(client_tenant_b):
|
||||
"""Factory: crea un ticket en tenant_b vía HTTP y retorna el JSON de respuesta."""
|
||||
async def _create(subject="Ticket Tenant B", priority="MEDIUM"):
|
||||
resp = await client_tenant_b.post("/v1/tickets/", json={
|
||||
"subject": subject,
|
||||
"description": "Test de aislamiento tenant B",
|
||||
"priority": priority,
|
||||
})
|
||||
assert resp.status_code in (200, 201), f"Error creando ticket B: {resp.text}"
|
||||
return resp.json()
|
||||
|
||||
return _create
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_user_tenant_a(client_tenant_a):
|
||||
"""Factory: crea un usuario en tenant_a vía HTTP y retorna el JSON de respuesta."""
|
||||
async def _create(email="nuevo_user_a@test.com"):
|
||||
resp = await client_tenant_a.post("/v1/users/", json={
|
||||
"email": email,
|
||||
"first_name": "Usuario",
|
||||
"last_name": "TenantA",
|
||||
"password": "Test1234!",
|
||||
"role": "CLIENT_USER",
|
||||
})
|
||||
assert resp.status_code in (200, 201), f"Error creando user A: {resp.text}"
|
||||
return resp.json()
|
||||
|
||||
return _create
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_user_tenant_b(client_tenant_b):
|
||||
"""Factory: crea un usuario en tenant_b vía HTTP y retorna el JSON de respuesta."""
|
||||
async def _create(email="nuevo_user_b@test.com"):
|
||||
resp = await client_tenant_b.post("/v1/users/", json={
|
||||
"email": email,
|
||||
"first_name": "Usuario",
|
||||
"last_name": "TenantB",
|
||||
"password": "Test1234!",
|
||||
"role": "CLIENT_USER",
|
||||
})
|
||||
assert resp.status_code in (200, 201), f"Error creando user B: {resp.text}"
|
||||
return resp.json()
|
||||
|
||||
return _create
|
||||
137
backend/app/tests/test_smoke.py
Normal file
137
backend/app/tests/test_smoke.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Smoke Tests - ServiceManagerWeb
|
||||
|
||||
Verifican que el stack completo funciona:
|
||||
- Conexión a BD Docker
|
||||
- Fixtures de tenant y usuarios
|
||||
- Login vía HTTP (httpx + FastAPI en memoria)
|
||||
- Endpoint protegido con token
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ============================================================
|
||||
# BD + FIXTURES
|
||||
# ============================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_db_connected(db):
|
||||
"""La sesión de BD está activa y responde."""
|
||||
from sqlalchemy import text
|
||||
result = await db.execute(text("SELECT 1"))
|
||||
assert result.scalar() == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tenant_a_existe(tenant_a):
|
||||
"""El tenant 'aduanasoft' existe y tiene datos válidos."""
|
||||
assert tenant_a.slug == "aduanasoft"
|
||||
assert tenant_a.name is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_user_existe(admin_user):
|
||||
"""El usuario ADMIN existe y pertenece al tenant correcto."""
|
||||
from app.models.user import UserRole
|
||||
assert admin_user.email == "admin@aduanasoft.com"
|
||||
assert admin_user.role == UserRole.ADMIN
|
||||
assert admin_user.is_active is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_user_existe(manager_user):
|
||||
"""El usuario SUPPORT_MANAGER existe."""
|
||||
from app.models.user import UserRole
|
||||
assert manager_user.email == "manager@aduanasoft.com"
|
||||
assert manager_user.role == UserRole.SUPPORT_MANAGER
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_user_existe(agent_user):
|
||||
"""El usuario AGENT existe."""
|
||||
from app.models.user import UserRole
|
||||
assert agent_user.email == "agente@aduanasoft.com"
|
||||
assert agent_user.role == UserRole.AGENT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_user_existe(user_tenant_a):
|
||||
"""El CLIENT_USER existe."""
|
||||
from app.models.user import UserRole
|
||||
assert user_tenant_a.email == "test_user@aduanasoft.com"
|
||||
assert user_tenant_a.role == UserRole.CLIENT_USER
|
||||
|
||||
|
||||
# ============================================================
|
||||
# HTTP — LOGIN
|
||||
# ============================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_admin_ok(client):
|
||||
"""Login con credenciales de admin devuelve access_token."""
|
||||
response = await client.post(
|
||||
"/v1/auth/login",
|
||||
json={
|
||||
"email": "admin@aduanasoft.com",
|
||||
"password": "admin123",
|
||||
"tenant_slug": "aduanasoft",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_credenciales_invalidas(client):
|
||||
"""Login con contraseña incorrecta devuelve 401."""
|
||||
response = await client.post(
|
||||
"/v1/auth/login",
|
||||
json={
|
||||
"email": "admin@aduanasoft.com",
|
||||
"password": "wrongpassword",
|
||||
"tenant_slug": "aduanasoft",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_endpoint_sin_token_devuelve_401(client):
|
||||
"""Acceder a un endpoint protegido sin token devuelve 401."""
|
||||
response = await client.get(
|
||||
"/v1/users/me",
|
||||
headers={"X-Tenant-Slug": "aduanasoft"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_y_me(client):
|
||||
"""Login exitoso → /users/me devuelve el usuario correcto."""
|
||||
# Login
|
||||
login = await client.post(
|
||||
"/v1/auth/login",
|
||||
json={
|
||||
"email": "admin@aduanasoft.com",
|
||||
"password": "admin123",
|
||||
"tenant_slug": "aduanasoft",
|
||||
},
|
||||
)
|
||||
assert login.status_code == 200
|
||||
token = login.json()["access_token"]
|
||||
|
||||
# Endpoint protegido
|
||||
me = await client.get(
|
||||
"/v1/users/me",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-Tenant-Slug": "aduanasoft",
|
||||
},
|
||||
)
|
||||
assert me.status_code == 200
|
||||
data = me.json()
|
||||
assert data["email"] == "admin@aduanasoft.com"
|
||||
assert data["role"] == "ADMIN"
|
||||
123
backend/app/tests/test_tenant_isolation.py
Normal file
123
backend/app/tests/test_tenant_isolation.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
Pruebas de aislamiento multi-tenant para tickets y usuarios.
|
||||
Usa solo los fixtures definidos en conftest.py.
|
||||
|
||||
Roles en juego:
|
||||
client_tenant_a → SUPPORT_MANAGER (aduanasoft) — restringido a su tenant
|
||||
client_tenant_b → CLIENT_ADMIN (empresa-test) — restringido a su tenant
|
||||
client_admin → ADMIN global (aduanasoft) — acceso a todos los tenants
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tenant_a_cannot_see_tenant_b_tickets(
|
||||
client_tenant_a, create_ticket_tenant_b
|
||||
):
|
||||
"""
|
||||
El usuario del tenant B crea un ticket.
|
||||
El usuario del tenant A (SUPPORT_MANAGER) lista sus tickets.
|
||||
El ticket de tenant B NO debe aparecer en la respuesta.
|
||||
"""
|
||||
# El usuario del tenant B crea un ticket
|
||||
ticket_b = await create_ticket_tenant_b()
|
||||
ticket_b_id = ticket_b["id"]
|
||||
|
||||
# El usuario del tenant A lista sus tickets
|
||||
response = await client_tenant_a.get("/v1/tickets/")
|
||||
assert response.status_code == 200
|
||||
|
||||
ids_visibles = {t["id"] for t in response.json()}
|
||||
|
||||
# El ticket de tenant B no debe ser visible para tenant A
|
||||
assert ticket_b_id not in ids_visibles, (
|
||||
f"Fallo de aislamiento: ticket de tenant B ({ticket_b_id}) "
|
||||
f"visible para usuario de tenant A"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tenant_b_cannot_edit_tenant_a_ticket(
|
||||
create_ticket_tenant_a, client_tenant_b
|
||||
):
|
||||
"""
|
||||
El usuario del tenant A crea un ticket.
|
||||
El usuario del tenant B intenta editar ese ticket vía PATCH.
|
||||
Debe recibir 403 (prohibido) o 404 (no encontrado).
|
||||
"""
|
||||
# El usuario del tenant A crea un ticket
|
||||
ticket_a = await create_ticket_tenant_a()
|
||||
ticket_a_id = ticket_a["id"]
|
||||
|
||||
# El usuario del tenant B intenta editar el ticket de tenant A
|
||||
response = await client_tenant_b.patch(
|
||||
f"/v1/tickets/{ticket_a_id}",
|
||||
json={"status": "CLOSED"},
|
||||
)
|
||||
|
||||
# Debe recibir 403 o 404 — nunca 200
|
||||
assert response.status_code in (403, 404), (
|
||||
f"Fallo de aislamiento: tenant B pudo editar ticket de tenant A "
|
||||
f"(HTTP {response.status_code})"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tenant_a_cannot_see_tenant_b_users(
|
||||
client_tenant_a, create_user_tenant_b
|
||||
):
|
||||
"""
|
||||
El usuario del tenant B crea un usuario nuevo.
|
||||
El usuario del tenant A (SUPPORT_MANAGER) lista los usuarios.
|
||||
El usuario de tenant B NO debe aparecer en la respuesta.
|
||||
"""
|
||||
# El usuario del tenant B crea un usuario
|
||||
user_b = await create_user_tenant_b()
|
||||
user_b_id = user_b["id"]
|
||||
|
||||
# El usuario del tenant A lista los usuarios de su tenant
|
||||
response = await client_tenant_a.get("/v1/users/")
|
||||
assert response.status_code == 200
|
||||
|
||||
ids_visibles = {u["id"] for u in response.json()}
|
||||
|
||||
# El usuario de tenant B no debe ser visible para tenant A
|
||||
assert user_b_id not in ids_visibles, (
|
||||
f"Fallo de aislamiento: usuario de tenant B ({user_b_id}) "
|
||||
f"visible para usuario de tenant A"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_sees_all_tenant_data(
|
||||
client_admin,
|
||||
create_ticket_tenant_a,
|
||||
create_ticket_tenant_b,
|
||||
create_user_tenant_a,
|
||||
create_user_tenant_b,
|
||||
):
|
||||
"""
|
||||
El ADMIN global debe poder ver tickets y usuarios de TODOS los tenants.
|
||||
- Tickets: vía /v1/tickets/admin/all (endpoint multi-tenant).
|
||||
- Usuarios: vía /v1/users/ (ADMIN bypasa el filtro de tenant).
|
||||
"""
|
||||
# Crear datos en ambos tenants
|
||||
ticket_a = await create_ticket_tenant_a()
|
||||
ticket_b = await create_ticket_tenant_b()
|
||||
user_a = await create_user_tenant_a()
|
||||
user_b = await create_user_tenant_b()
|
||||
|
||||
# El admin lista todos los tickets (endpoint multi-tenant)
|
||||
resp_tickets = await client_admin.get("/v1/tickets/admin/all")
|
||||
assert resp_tickets.status_code == 200
|
||||
ids_tickets = {t["id"] for t in resp_tickets.json()}
|
||||
assert ticket_a["id"] in ids_tickets, "El ADMIN no ve el ticket de tenant A"
|
||||
assert ticket_b["id"] in ids_tickets, "El ADMIN no ve el ticket de tenant B"
|
||||
|
||||
# El admin lista todos los usuarios (ADMIN bypasa filtro de tenant)
|
||||
resp_users = await client_admin.get("/v1/users/")
|
||||
assert resp_users.status_code == 200
|
||||
ids_users = {u["id"] for u in resp_users.json()}
|
||||
assert user_a["id"] in ids_users, "El ADMIN no ve el usuario de tenant A"
|
||||
assert user_b["id"] in ids_users, "El ADMIN no ve el usuario de tenant B"
|
||||
|
||||
137
backend/migrations/versions/a1b2c3d4e5f6_add_audit_logs_table.py
Normal file
137
backend/migrations/versions/a1b2c3d4e5f6_add_audit_logs_table.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""add_audit_logs_table
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: 13362e8c493a
|
||||
Create Date: 2026-02-12 10:00:00.000000
|
||||
|
||||
Registra el modelo AuditLog en Alembic.
|
||||
La tabla audit_logs ya existe en schema.sql, esta migración solo
|
||||
la registra en el control de versiones de Alembic.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'a1b2c3d4e5f6'
|
||||
down_revision = '13362e8c493a'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""
|
||||
Verificar que audit_logs existe y registrarla en Alembic.
|
||||
|
||||
La tabla fue creada por schema.sql, esta migración solo verifica
|
||||
que exista y esté disponible para usar.
|
||||
"""
|
||||
from sqlalchemy import inspect
|
||||
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
if 'audit_logs' in tables:
|
||||
print("OK Tabla audit_logs encontrada (creada por schema.sql)")
|
||||
print("OK Modelo AuditLog registrado en Alembic")
|
||||
|
||||
# Verificar que tenga los índices necesarios
|
||||
existing_indexes = [idx['name'] for idx in inspector.get_indexes('audit_logs')]
|
||||
|
||||
required_indexes = [
|
||||
'idx_audit_logs_tenant_id',
|
||||
'idx_audit_logs_user_id',
|
||||
'idx_audit_logs_action',
|
||||
'idx_audit_logs_correlation_id',
|
||||
'idx_audit_logs_created_at',
|
||||
]
|
||||
|
||||
missing_indexes = [idx for idx in required_indexes if idx not in existing_indexes]
|
||||
|
||||
if missing_indexes:
|
||||
print(f"WARN Indices faltantes: {', '.join(missing_indexes)}")
|
||||
print(" (Esto es normal si usaste schema.sql completo)")
|
||||
else:
|
||||
print("OK Todos los índices necesarios están presentes")
|
||||
|
||||
else:
|
||||
print("ERROR La tabla audit_logs NO existe")
|
||||
print(" Ejecuta: docker-compose exec -T postgres psql -U postgres -d servicemanager < db/schema.sql")
|
||||
raise Exception(
|
||||
"La tabla audit_logs no existe. "
|
||||
"Por favor ejecuta el schema.sql completo primero."
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""
|
||||
No eliminar la tabla - fue creada por schema.sql.
|
||||
|
||||
Solo des-registrar de Alembic.
|
||||
"""
|
||||
print("INFO Tabla audit_logs NO será eliminada (creada por schema.sql)")
|
||||
print("OK Modelo AuditLog des-registrado de Alembic")
|
||||
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""
|
||||
Verificar que audit_logs existe y registrarla en Alembic.
|
||||
|
||||
La tabla fue creada por schema.sql, esta migraci├│n solo verifica
|
||||
que exista y está disponible para usar.
|
||||
"""
|
||||
from sqlalchemy import inspect
|
||||
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
if 'audit_logs' in tables:
|
||||
print(" Tabla audit_logs encontrada (creada por schema.sql)")
|
||||
print(" Modelo AuditLog registrado en Alembic")
|
||||
|
||||
# Verificar que tenga los índices necesarios
|
||||
existing_indexes = [idx['name'] for idx in inspector.get_indexes('audit_logs')]
|
||||
missing_indexes = []
|
||||
|
||||
required_indexes = [
|
||||
'idx_audit_logs_tenant_id',
|
||||
'idx_audit_logs_user_id',
|
||||
'idx_audit_logs_action',
|
||||
'idx_audit_logs_correlation_id',
|
||||
'idx_audit_logs_created_at'
|
||||
]
|
||||
|
||||
for idx in required_indexes:
|
||||
if idx not in existing_indexes:
|
||||
missing_indexes.append(idx)
|
||||
|
||||
if missing_indexes:
|
||||
print(f"ÔÜá´©Å ├ìndices faltantes: {', '.join(missing_indexes)}")
|
||||
print(" (Esto es normal si usaste schema.sql completo)")
|
||||
else:
|
||||
print("Ô£à Todos los ├¡ndices necesarios est├ín presentes")
|
||||
|
||||
else:
|
||||
print("ÔÜá´©Å La tabla audit_logs NO existe")
|
||||
print(" Ejecuta: docker-compose exec -T postgres psql -U postgres -d servicemanager < db/schema.sql")
|
||||
print(" O crea la tabla manualmente desde schema.sql")
|
||||
|
||||
# No crear la tabla aquí - debe venir de schema.sql para mantener consistencia
|
||||
raise Exception(
|
||||
"La tabla audit_logs no existe. "
|
||||
"Por favor ejecuta el schema.sql completo primero."
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""
|
||||
No eliminar la tabla - fue creada por schema.sql.
|
||||
|
||||
Solo des-registrar de Alembic.
|
||||
"""
|
||||
print("Ôä╣´©Å Tabla audit_logs NO ser├í eliminada (creada por schema.sql)")
|
||||
print(" Modelo AuditLog des-registrado de Alembic")
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""add_ticket_indexes
|
||||
|
||||
Revision ID: b7c8d9e0f1a2
|
||||
Revises: fix_client_timestamps
|
||||
Create Date: 2026-03-03 00:00:00.000000
|
||||
|
||||
Agrega índices a la tabla tickets para optimizar queries frecuentes:
|
||||
- idx_tickets_status → filtros por estado
|
||||
- idx_tickets_priority → filtros por prioridad
|
||||
- idx_tickets_assigned_to → tickets por agente asignado
|
||||
- idx_tickets_tenant_status → compuesto multi-tenant (tenant_id, status)
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'b7c8d9e0f1a2'
|
||||
down_revision = 'fix_client_timestamps'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets (status)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_tickets_priority ON tickets (priority)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_tickets_assigned_to "
|
||||
"ON tickets (assigned_to) WHERE assigned_to IS NOT NULL"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_tickets_tenant_status "
|
||||
"ON tickets (tenant_id, status)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS idx_tickets_tenant_status")
|
||||
op.execute("DROP INDEX IF EXISTS idx_tickets_assigned_to")
|
||||
op.execute("DROP INDEX IF EXISTS idx_tickets_priority")
|
||||
op.execute("DROP INDEX IF EXISTS idx_tickets_status")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Add CLIENT_MANAGER, CLIENT_AGENT, CLIENT_AUDITOR to user_role_enum
|
||||
|
||||
Revision ID: c1d2e3f4a5b6
|
||||
Revises: b7c8d9e0f1a2
|
||||
Create Date: 2026-03-03 10:00:00.000000
|
||||
|
||||
Agrega tres nuevos roles de cliente al enum PostgreSQL:
|
||||
- CLIENT_MANAGER → gestiona tickets y usuarios del tenant
|
||||
- CLIENT_AGENT → atiende tickets del tenant
|
||||
- CLIENT_AUDITOR → auditoría de solo lectura del tenant
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'c1d2e3f4a5b6'
|
||||
down_revision = 'b7c8d9e0f1a2'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# PostgreSQL permite agregar valores a un enum con ADD VALUE.
|
||||
# IF NOT EXISTS evita error si la migración se aplica dos veces.
|
||||
op.execute("ALTER TYPE user_role_enum ADD VALUE IF NOT EXISTS 'CLIENT_MANAGER'")
|
||||
op.execute("ALTER TYPE user_role_enum ADD VALUE IF NOT EXISTS 'CLIENT_AGENT'")
|
||||
op.execute("ALTER TYPE user_role_enum ADD VALUE IF NOT EXISTS 'CLIENT_AUDITOR'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# PostgreSQL no permite eliminar valores de un enum con ALTER TYPE DROP VALUE.
|
||||
# Para revertir habría que recrear el tipo completo desde cero, lo que requiere
|
||||
# actualizar todas las columnas que lo usan. Se documenta como no reversible
|
||||
# automáticamente — usar con precaución.
|
||||
pass
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Remove CLIENT_MANAGER, CLIENT_AGENT, CLIENT_AUDITOR from user_role_enum
|
||||
|
||||
Revision ID: d2e3f4a5b6c7
|
||||
Revises: c1d2e3f4a5b6
|
||||
Create Date: 2026-03-03 14:00:00.000000
|
||||
|
||||
Consolida 9 roles → 6 roles migrando datos primero y luego recreando
|
||||
el tipo enum de PostgreSQL (única forma de eliminar valores en PG).
|
||||
|
||||
Mapeo de datos:
|
||||
CLIENT_MANAGER → CLIENT_ADMIN (conserva nivel de gestión)
|
||||
CLIENT_AGENT → CLIENT_USER (acceso básico de cliente)
|
||||
CLIENT_AUDITOR → CLIENT_USER (acceso básico de cliente)
|
||||
|
||||
ADVERTENCIA DOWNGRADE: La migración inversa restaura los valores del
|
||||
enum pero NO puede recuperar la distinción original entre CLIENT_AGENT
|
||||
y CLIENT_AUDITOR (ambos quedaron como CLIENT_USER). El downgrade es
|
||||
seguro a nivel de integridad de datos, pero irreversible en semántica.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'd2e3f4a5b6c7'
|
||||
down_revision = 'c1d2e3f4a5b6'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ── Paso 1: Migrar datos ANTES de modificar el tipo ────────────────────
|
||||
# CLIENT_MANAGER → CLIENT_ADMIN (conserva acceso de gestión)
|
||||
op.execute("UPDATE users SET role = 'CLIENT_ADMIN' WHERE role = 'CLIENT_MANAGER'")
|
||||
# CLIENT_AGENT → CLIENT_USER (acceso básico de cliente)
|
||||
op.execute("UPDATE users SET role = 'CLIENT_USER' WHERE role = 'CLIENT_AGENT'")
|
||||
# CLIENT_AUDITOR → CLIENT_USER (acceso básico de cliente)
|
||||
op.execute("UPDATE users SET role = 'CLIENT_USER' WHERE role = 'CLIENT_AUDITOR'")
|
||||
|
||||
# ── Paso 2: Soltar la restricción de tipo para poder recrear el enum ───
|
||||
# PostgreSQL no permite DROP VALUE en un enum; hay que recrear el tipo.
|
||||
op.execute("ALTER TABLE users ALTER COLUMN role TYPE TEXT")
|
||||
|
||||
# ── Paso 3: Eliminar tipo actual y recrearlo solo con los 6 roles ──────
|
||||
op.execute("DROP TYPE user_role_enum")
|
||||
op.execute("""
|
||||
CREATE TYPE user_role_enum AS ENUM (
|
||||
'ADMIN',
|
||||
'SUPPORT_MANAGER',
|
||||
'AGENT',
|
||||
'AUDITOR',
|
||||
'CLIENT_ADMIN',
|
||||
'CLIENT_USER'
|
||||
)
|
||||
""")
|
||||
|
||||
# ── Paso 4: Restaurar columna al tipo enum ──────────────────────────────
|
||||
op.execute(
|
||||
"ALTER TABLE users ALTER COLUMN role TYPE user_role_enum "
|
||||
"USING role::user_role_enum"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ── Paso 1: Soltar la restricción de tipo para recrear el enum ─────────
|
||||
op.execute("ALTER TABLE users ALTER COLUMN role TYPE TEXT")
|
||||
|
||||
# ── Paso 2: Recrear enum con los 9 valores originales ──────────────────
|
||||
op.execute("DROP TYPE user_role_enum")
|
||||
op.execute("""
|
||||
CREATE TYPE user_role_enum AS ENUM (
|
||||
'ADMIN',
|
||||
'SUPPORT_MANAGER',
|
||||
'AGENT',
|
||||
'AUDITOR',
|
||||
'CLIENT_ADMIN',
|
||||
'CLIENT_MANAGER',
|
||||
'CLIENT_AGENT',
|
||||
'CLIENT_AUDITOR',
|
||||
'CLIENT_USER'
|
||||
)
|
||||
""")
|
||||
|
||||
# ── Paso 3: Restaurar columna al tipo enum ──────────────────────────────
|
||||
op.execute(
|
||||
"ALTER TABLE users ALTER COLUMN role TYPE user_role_enum "
|
||||
"USING role::user_role_enum"
|
||||
)
|
||||
|
||||
# ── Nota sobre pérdida de datos ─────────────────────────────────────────
|
||||
# Los usuarios que eran CLIENT_MANAGER ahora son CLIENT_ADMIN.
|
||||
# Los usuarios que eran CLIENT_AGENT o CLIENT_AUDITOR ahora son CLIENT_USER.
|
||||
# No es posible restaurar la distinción original automáticamente.
|
||||
@@ -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
|
||||
)
|
||||
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "servicemanager-backend"
|
||||
version = "1.5.1"
|
||||
version = "1.6.0"
|
||||
description = "ServiceManagerWeb Backend - Mesa de Ayuda B2B"
|
||||
authors = [
|
||||
{name = "Aduanasoft", email = "dev@aduanasoft.com"}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[tool:pytest]
|
||||
testpaths = tests
|
||||
[pytest]
|
||||
testpaths = tests tests/unit tests/integration
|
||||
python_files = test_*.py
|
||||
python_functions = test_*
|
||||
python_classes = Test*
|
||||
@@ -17,6 +17,8 @@ markers =
|
||||
unit: marks tests as unit tests
|
||||
auth: marks tests related to authentication
|
||||
db: marks tests that require database
|
||||
env =
|
||||
TESTING=true
|
||||
filterwarnings =
|
||||
ignore::DeprecationWarning
|
||||
ignore::PendingDeprecationWarning
|
||||
@@ -31,6 +31,7 @@ pyotp==2.9.0 # TOTP/2FA support
|
||||
# ===================================
|
||||
celery==5.3.4
|
||||
redis==5.0.1
|
||||
slowapi==0.1.9 # Rate limiting middleware
|
||||
|
||||
# ===================================
|
||||
# EMAIL
|
||||
|
||||
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())
|
||||
67
backend/scripts/create_test_user.py
Normal file
67
backend/scripts/create_test_user.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Script para crear/actualizar usuario de prueba con contraseña conocida
|
||||
"""
|
||||
import asyncio
|
||||
from sqlalchemy import select, update
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.core.security import security
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.tenant import Tenant
|
||||
import uuid
|
||||
|
||||
async def create_test_user():
|
||||
async with AsyncSessionLocal() as db:
|
||||
# Buscar tenant
|
||||
tenant_query = select(Tenant).where(Tenant.slug.like('%aduanasoft%')).limit(1)
|
||||
result = await db.execute(tenant_query)
|
||||
tenant = result.scalar_one_or_none()
|
||||
|
||||
if not tenant:
|
||||
print("❌ No se encontró tenant")
|
||||
return
|
||||
|
||||
print(f"✅ Tenant encontrado: {tenant.name} ({tenant.slug})")
|
||||
|
||||
# Buscar o crear usuario admin
|
||||
user_query = select(User).where(
|
||||
User.email == "admin@aduanasoft.com",
|
||||
User.tenant_id == tenant.id
|
||||
)
|
||||
result = await db.execute(user_query)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
# Hash de la contraseña "admin123"
|
||||
password_hash = security.hash_password("admin123")
|
||||
|
||||
if user:
|
||||
# Actualizar contraseña
|
||||
user.password_hash = password_hash
|
||||
user.is_active = True
|
||||
user.email_verified = True
|
||||
await db.commit()
|
||||
print(f"✅ Usuario actualizado: {user.email}")
|
||||
else:
|
||||
# Crear usuario nuevo
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant.id,
|
||||
email="admin@aduanasoft.com",
|
||||
first_name="Admin",
|
||||
last_name="Sistema",
|
||||
password_hash=password_hash,
|
||||
role=UserRole.ADMIN,
|
||||
is_active=True,
|
||||
email_verified=True
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
print(f"✅ Usuario creado: {user.email}")
|
||||
|
||||
print(f"\n📋 Credenciales de prueba:")
|
||||
print(f" Email: admin@aduanasoft.com")
|
||||
print(f" Password: admin123")
|
||||
print(f" Tenant: {tenant.slug}")
|
||||
print(f" Role: ADMIN")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(create_test_user())
|
||||
240
backend/scripts/generate_security_test_data.py
Normal file
240
backend/scripts/generate_security_test_data.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
Script para generar datos de prueba de seguridad en logs de auditoría.
|
||||
Esto permite probar la funcionalidad de análisis de seguridad con diferentes tipos de amenazas.
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import uuid
|
||||
import random
|
||||
|
||||
# Agregar el directorio raíz al path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.user import User
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
|
||||
async def generate_test_data():
|
||||
"""Genera logs de auditoría de prueba para análisis de seguridad."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
# Obtener tenant y usuarios de prueba
|
||||
tenant_result = await db.execute(select(Tenant).limit(1))
|
||||
tenant = tenant_result.scalar_one_or_none()
|
||||
|
||||
if not tenant:
|
||||
print("❌ No se encontró ningún tenant. Ejecuta las migraciones primero.")
|
||||
return
|
||||
|
||||
user_result = await db.execute(select(User).where(User.tenant_id == tenant.id).limit(1))
|
||||
user = user_result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
print("❌ No se encontró ningún usuario. Crea un usuario primero.")
|
||||
return
|
||||
|
||||
print(f"✅ Usando tenant: {tenant.name}")
|
||||
print(f"✅ Usando usuario: {user.email}")
|
||||
print()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# IPs de prueba
|
||||
suspicious_ips = [
|
||||
"192.168.1.100",
|
||||
"10.0.0.50",
|
||||
"172.16.0.10",
|
||||
"203.0.113.42",
|
||||
"198.51.100.88"
|
||||
]
|
||||
|
||||
logs_created = 0
|
||||
|
||||
# ============================================
|
||||
# 1. GENERAR INTENTOS FALLIDOS DE LOGIN (Fuerza Bruta)
|
||||
# ============================================
|
||||
print("🔐 Generando intentos fallidos de login...")
|
||||
|
||||
# Generar 25 intentos fallidos (esto hará que sea HIGH severity)
|
||||
for i in range(25):
|
||||
time_offset = timedelta(hours=random.randint(0, 23), minutes=random.randint(0, 59))
|
||||
log = AuditLog(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant.id,
|
||||
user_id=user.id,
|
||||
action="user.login_failed",
|
||||
resource_type="auth",
|
||||
resource_id=None,
|
||||
ip_address=random.choice(suspicious_ips),
|
||||
user_agent="Mozilla/5.0 (Test Browser)",
|
||||
metadata={"reason": "invalid_credentials", "username": f"test_user_{i}"},
|
||||
created_at=now - time_offset
|
||||
)
|
||||
db.add(log)
|
||||
logs_created += 1
|
||||
|
||||
print(f" ✓ Creados {25} intentos fallidos de login (HIGH severity)")
|
||||
|
||||
# ============================================
|
||||
# 2. GENERAR ELIMINACIONES MASIVAS (CRITICAL)
|
||||
# ============================================
|
||||
print("🗑️ Generando eliminaciones masivas...")
|
||||
|
||||
resources = ["ticket", "comment", "attachment", "category", "user"]
|
||||
|
||||
# Generar 55 eliminaciones (esto hará que sea CRITICAL severity)
|
||||
for i in range(55):
|
||||
time_offset = timedelta(hours=random.randint(0, 23), minutes=random.randint(0, 59))
|
||||
resource = random.choice(resources)
|
||||
log = AuditLog(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant.id,
|
||||
user_id=user.id,
|
||||
action=f"{resource}.delete",
|
||||
resource_type=resource,
|
||||
resource_id=uuid.uuid4(),
|
||||
ip_address=random.choice(suspicious_ips),
|
||||
user_agent="Mozilla/5.0 (Test Browser)",
|
||||
metadata={"deleted_by": user.email},
|
||||
created_at=now - time_offset
|
||||
)
|
||||
db.add(log)
|
||||
logs_created += 1
|
||||
|
||||
print(f" ✓ Creadas {55} eliminaciones masivas (CRITICAL severity)")
|
||||
|
||||
# ============================================
|
||||
# 3. GENERAR CAMBIOS DE PRIVILEGIOS (HIGH)
|
||||
# ============================================
|
||||
print("👤 Generando cambios de privilegios...")
|
||||
|
||||
roles = ["AGENT", "CLIENT_USER", "AUDITOR", "SUPPORT_MANAGER", "ADMIN"]
|
||||
|
||||
# Generar 5 cambios de rol (esto hará que sea HIGH severity)
|
||||
for i in range(5):
|
||||
time_offset = timedelta(hours=random.randint(0, 23), minutes=random.randint(0, 59))
|
||||
old_role = random.choice(roles)
|
||||
new_role = random.choice([r for r in roles if r != old_role])
|
||||
|
||||
log = AuditLog(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant.id,
|
||||
user_id=user.id,
|
||||
action="user.update",
|
||||
resource_type="user",
|
||||
resource_id=uuid.uuid4(),
|
||||
ip_address=random.choice(suspicious_ips),
|
||||
user_agent="Mozilla/5.0 (Test Browser)",
|
||||
old_values={"role": old_role},
|
||||
new_values={"role": new_role},
|
||||
metadata={"changed_by": user.email},
|
||||
created_at=now - time_offset
|
||||
)
|
||||
db.add(log)
|
||||
logs_created += 1
|
||||
|
||||
print(f" ✓ Creados {5} cambios de privilegios (HIGH severity)")
|
||||
|
||||
# ============================================
|
||||
# 4. GENERAR LOGS NORMALES (para dar contexto)
|
||||
# ============================================
|
||||
print("📋 Generando logs de actividad normal...")
|
||||
|
||||
normal_actions = [
|
||||
"ticket.create",
|
||||
"ticket.update",
|
||||
"comment.create",
|
||||
"user.login",
|
||||
"ticket.view",
|
||||
]
|
||||
|
||||
for i in range(20):
|
||||
time_offset = timedelta(hours=random.randint(0, 23), minutes=random.randint(0, 59))
|
||||
action = random.choice(normal_actions)
|
||||
|
||||
log = AuditLog(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant.id,
|
||||
user_id=user.id,
|
||||
action=action,
|
||||
resource_type=action.split('.')[0],
|
||||
resource_id=uuid.uuid4(),
|
||||
ip_address=random.choice(suspicious_ips),
|
||||
user_agent="Mozilla/5.0 (Test Browser)",
|
||||
metadata={"action": "normal_activity"},
|
||||
created_at=now - time_offset
|
||||
)
|
||||
db.add(log)
|
||||
logs_created += 1
|
||||
|
||||
print(f" ✓ Creados {20} logs de actividad normal")
|
||||
|
||||
# Guardar todo
|
||||
await db.commit()
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(f"✅ GENERACIÓN COMPLETADA")
|
||||
print(f" Total de logs creados: {logs_created}")
|
||||
print()
|
||||
print("📊 Amenazas esperadas en el análisis:")
|
||||
print(" 🔴 1 amenaza CRÍTICA: 55 eliminaciones masivas")
|
||||
print(" 🟠 1 amenaza HIGH: 25 intentos fallidos de login")
|
||||
print(" 🟠 1 amenaza HIGH: 5 cambios de privilegios")
|
||||
print()
|
||||
print("🌐 Accede a la página de seguridad para ver el análisis")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
async def cleanup_test_data():
|
||||
"""Elimina los logs de auditoría de prueba."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
tenant_result = await db.execute(select(Tenant).limit(1))
|
||||
tenant = tenant_result.scalar_one_or_none()
|
||||
|
||||
if not tenant:
|
||||
print("❌ No se encontró ningún tenant.")
|
||||
return
|
||||
|
||||
# Eliminar logs de las últimas 24 horas
|
||||
now = datetime.now(timezone.utc)
|
||||
cutoff = now - timedelta(hours=24)
|
||||
|
||||
result = await db.execute(
|
||||
select(AuditLog).where(
|
||||
AuditLog.tenant_id == tenant.id,
|
||||
AuditLog.created_at >= cutoff
|
||||
)
|
||||
)
|
||||
logs = result.scalars().all()
|
||||
|
||||
if not logs:
|
||||
print("ℹ️ No hay logs de prueba para eliminar.")
|
||||
return
|
||||
|
||||
for log in logs:
|
||||
await db.delete(log)
|
||||
|
||||
await db.commit()
|
||||
|
||||
print(f"✅ Eliminados {len(logs)} logs de prueba de las últimas 24 horas")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "cleanup":
|
||||
print("🧹 Limpiando datos de prueba...")
|
||||
asyncio.run(cleanup_test_data())
|
||||
else:
|
||||
print("🚀 Generando datos de prueba para análisis de seguridad...")
|
||||
print()
|
||||
asyncio.run(generate_test_data())
|
||||
print()
|
||||
print("💡 Para limpiar estos datos de prueba, ejecuta:")
|
||||
print(" python scripts/generate_security_test_data.py cleanup")
|
||||
399
backend/scripts/generate_sla_test_data.py
Normal file
399
backend/scripts/generate_sla_test_data.py
Normal file
@@ -0,0 +1,399 @@
|
||||
"""
|
||||
Script para generar datos de prueba de SLA Management.
|
||||
Crea tickets con diferentes estados de SLA para probar el dashboard.
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import uuid
|
||||
import random
|
||||
|
||||
# Agregar el directorio raíz al path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.models.ticket import Ticket, TicketStatus, TicketPriority
|
||||
from app.models.category import Category
|
||||
from app.models.user import User
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.system import System
|
||||
|
||||
|
||||
async def generate_sla_test_data():
|
||||
"""Genera tickets de prueba con diferentes estados de SLA."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
# Obtener tenant y usuarios
|
||||
tenant_result = await db.execute(select(Tenant).limit(1))
|
||||
tenant = tenant_result.scalar_one_or_none()
|
||||
|
||||
if not tenant:
|
||||
print("❌ No se encontró ningún tenant. Ejecuta las migraciones primero.")
|
||||
return
|
||||
|
||||
# Obtener usuarios
|
||||
users_result = await db.execute(
|
||||
select(User).where(User.tenant_id == tenant.id).limit(5)
|
||||
)
|
||||
users = list(users_result.scalars().all())
|
||||
|
||||
if not users:
|
||||
print("❌ No se encontraron usuarios. Crea usuarios primero.")
|
||||
return
|
||||
|
||||
creator = users[0]
|
||||
agents = users if len(users) > 1 else [creator]
|
||||
|
||||
# Obtener o crear categorías
|
||||
categories_result = await db.execute(
|
||||
select(Category).where(Category.tenant_id == tenant.id)
|
||||
)
|
||||
categories = list(categories_result.scalars().all())
|
||||
|
||||
if not categories:
|
||||
print("📁 Creando categorías de prueba...")
|
||||
category_data = [
|
||||
{"name": "Soporte Técnico", "sla_response_hours": 2, "sla_resolution_hours": 24, "color": "#3B82F6"},
|
||||
{"name": "Facturación", "sla_response_hours": 4, "sla_resolution_hours": 48, "color": "#10B981"},
|
||||
{"name": "Incidente Crítico", "sla_response_hours": 1, "sla_resolution_hours": 8, "color": "#EF4444"},
|
||||
{"name": "Consulta General", "sla_response_hours": 8, "sla_resolution_hours": 72, "color": "#6B7280"},
|
||||
]
|
||||
|
||||
for cat_data in category_data:
|
||||
category = Category(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant.id,
|
||||
name=cat_data["name"],
|
||||
description=f"Categoría de {cat_data['name']}",
|
||||
color=cat_data["color"],
|
||||
sla_response_hours=cat_data["sla_response_hours"],
|
||||
sla_resolution_hours=cat_data["sla_resolution_hours"],
|
||||
is_active=True
|
||||
)
|
||||
db.add(category)
|
||||
categories.append(category)
|
||||
|
||||
await db.commit()
|
||||
print(f" ✓ Creadas {len(categories)} categorías")
|
||||
|
||||
# Obtener o crear sistemas afectados
|
||||
systems_result = await db.execute(
|
||||
select(System).where(System.tenant_id == tenant.id)
|
||||
)
|
||||
systems = list(systems_result.scalars().all())
|
||||
|
||||
if not systems:
|
||||
print("🖥️ Creando sistemas de prueba...")
|
||||
system_names = ["Portal Web", "API REST", "Base de Datos", "Sistema de Pagos"]
|
||||
for sys_name in system_names:
|
||||
system = System(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant.id,
|
||||
name=sys_name,
|
||||
description=f"Sistema {sys_name}",
|
||||
is_active=True
|
||||
)
|
||||
db.add(system)
|
||||
systems.append(system)
|
||||
|
||||
await db.commit()
|
||||
print(f" ✓ Creados {len(systems)} sistemas")
|
||||
|
||||
print(f"✅ Usando tenant: {tenant.name}")
|
||||
print(f"✅ Usuarios disponibles: {len(users)}")
|
||||
print(f"✅ Categorías disponibles: {len(categories)}")
|
||||
print()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
tickets_created = 0
|
||||
|
||||
# Función auxiliar para crear ticket
|
||||
def create_ticket(
|
||||
subject: str,
|
||||
description: str,
|
||||
priority: TicketPriority,
|
||||
status: TicketStatus,
|
||||
category: Category,
|
||||
created_hours_ago: int,
|
||||
first_response_hours_after: int = None,
|
||||
resolved_hours_after: int = None,
|
||||
assigned: bool = True
|
||||
):
|
||||
nonlocal tickets_created
|
||||
|
||||
ticket_id = uuid.uuid4()
|
||||
created_at = now - timedelta(hours=created_hours_ago)
|
||||
|
||||
# Calcular SLA deadlines basados en la categoría (sin timezone para la BD)
|
||||
sla_response_due = (created_at + timedelta(hours=category.sla_response_hours)).replace(tzinfo=None)
|
||||
sla_resolution_due = (created_at + timedelta(hours=category.sla_resolution_hours)).replace(tzinfo=None)
|
||||
|
||||
# Primera respuesta (si aplica)
|
||||
first_response_at = None
|
||||
if first_response_hours_after is not None:
|
||||
first_response_at = (created_at + timedelta(hours=first_response_hours_after)).replace(tzinfo=None)
|
||||
|
||||
# Resolución (si aplica)
|
||||
resolved_at = None
|
||||
if resolved_hours_after is not None:
|
||||
resolved_at = (created_at + timedelta(hours=resolved_hours_after)).replace(tzinfo=None)
|
||||
|
||||
ticket = Ticket(
|
||||
id=ticket_id,
|
||||
tenant_id=tenant.id,
|
||||
ticket_number=f"TKT-{1000 + tickets_created}",
|
||||
subject=subject,
|
||||
description=description,
|
||||
status=status,
|
||||
priority=priority,
|
||||
created_by=creator.id,
|
||||
assigned_to=random.choice(agents).id if assigned else None,
|
||||
category_id=category.id,
|
||||
affected_system_id=random.choice(systems).id if systems else None,
|
||||
sla_response_due=sla_response_due,
|
||||
sla_resolution_due=sla_resolution_due,
|
||||
first_response_at=first_response_at,
|
||||
resolved_at=resolved_at,
|
||||
created_at=created_at,
|
||||
updated_at=resolved_at or first_response_at or created_at
|
||||
)
|
||||
|
||||
db.add(ticket)
|
||||
tickets_created += 1
|
||||
return ticket
|
||||
|
||||
# ============================================
|
||||
# 1. TICKETS CUMPLIENDO SLA RESPONSE (Verde)
|
||||
# ============================================
|
||||
print("✅ Generando tickets CUMPLIENDO Response SLA...")
|
||||
|
||||
for i in range(15):
|
||||
category = random.choice(categories)
|
||||
priority = random.choice([TicketPriority.LOW, TicketPriority.MEDIUM, TicketPriority.HIGH])
|
||||
|
||||
# Creado hace X horas, respondido ANTES del deadline
|
||||
created_hours_ago = random.randint(24, 120)
|
||||
response_time = random.uniform(0.5, category.sla_response_hours * 0.7) # 70% del SLA
|
||||
|
||||
status = random.choice([TicketStatus.IN_PROGRESS, TicketStatus.WAITING_CUSTOMER])
|
||||
|
||||
create_ticket(
|
||||
subject=f"Ticket con respuesta a tiempo #{i+1}",
|
||||
description=f"Este ticket fue respondido dentro del SLA de {category.name}",
|
||||
priority=priority,
|
||||
status=status,
|
||||
category=category,
|
||||
created_hours_ago=created_hours_ago,
|
||||
first_response_hours_after=response_time,
|
||||
assigned=True
|
||||
)
|
||||
|
||||
print(f" ✓ Creados 15 tickets cumpliendo Response SLA")
|
||||
|
||||
# ============================================
|
||||
# 2. TICKETS VIOLANDO SLA RESPONSE (Rojo)
|
||||
# ============================================
|
||||
print("🔴 Generando tickets VIOLANDO Response SLA...")
|
||||
|
||||
for i in range(8):
|
||||
category = random.choice(categories)
|
||||
priority = random.choice([TicketPriority.HIGH, TicketPriority.URGENT])
|
||||
|
||||
# Creado hace más tiempo que el SLA, SIN respuesta
|
||||
created_hours_ago = category.sla_response_hours + random.randint(1, 10)
|
||||
|
||||
create_ticket(
|
||||
subject=f"Ticket SIN respuesta - VIOLACIÓN #{i+1}",
|
||||
description=f"Este ticket lleva {created_hours_ago}h sin respuesta (SLA: {category.sla_response_hours}h)",
|
||||
priority=priority,
|
||||
status=random.choice([TicketStatus.NEW, TicketStatus.TRIAGE]),
|
||||
category=category,
|
||||
created_hours_ago=created_hours_ago,
|
||||
first_response_hours_after=None, # Sin respuesta!
|
||||
assigned=random.choice([True, False])
|
||||
)
|
||||
|
||||
print(f" ✓ Creados 8 tickets VIOLANDO Response SLA")
|
||||
|
||||
# ============================================
|
||||
# 3. TICKETS EN RIESGO Response (Amarillo)
|
||||
# ============================================
|
||||
print("⚠️ Generando tickets EN RIESGO Response SLA...")
|
||||
|
||||
for i in range(10):
|
||||
category = random.choice(categories)
|
||||
priority = random.choice([TicketPriority.MEDIUM, TicketPriority.HIGH, TicketPriority.URGENT])
|
||||
|
||||
# Creado hace tiempo, cerca del deadline (80-95% consumido)
|
||||
sla_hours = category.sla_response_hours
|
||||
time_consumed = random.uniform(0.8, 0.95) * sla_hours
|
||||
created_hours_ago = time_consumed
|
||||
|
||||
create_ticket(
|
||||
subject=f"Ticket cerca de vencer respuesta #{i+1}",
|
||||
description=f"Este ticket está al {int(time_consumed/sla_hours*100)}% del SLA de respuesta",
|
||||
priority=priority,
|
||||
status=random.choice([TicketStatus.TRIAGE, TicketStatus.NEW]),
|
||||
category=category,
|
||||
created_hours_ago=created_hours_ago,
|
||||
first_response_hours_after=None, # Aún sin respuesta
|
||||
assigned=True
|
||||
)
|
||||
|
||||
print(f" ✓ Creados 10 tickets EN RIESGO Response SLA")
|
||||
|
||||
# ============================================
|
||||
# 4. TICKETS CUMPLIENDO SLA RESOLUTION
|
||||
# ============================================
|
||||
print("✅ Generando tickets CUMPLIENDO Resolution SLA...")
|
||||
|
||||
for i in range(20):
|
||||
category = random.choice(categories)
|
||||
priority = random.choice([TicketPriority.LOW, TicketPriority.MEDIUM, TicketPriority.HIGH])
|
||||
|
||||
# Creado, respondido y resuelto dentro del SLA
|
||||
created_hours_ago = random.randint(72, 240)
|
||||
response_time = random.uniform(1, category.sla_response_hours * 0.5)
|
||||
resolution_time = random.uniform(
|
||||
response_time + 1,
|
||||
category.sla_resolution_hours * 0.8
|
||||
)
|
||||
|
||||
create_ticket(
|
||||
subject=f"Ticket resuelto a tiempo #{i+1}",
|
||||
description=f"Este ticket fue resuelto dentro del SLA de {category.name}",
|
||||
priority=priority,
|
||||
status=random.choice([TicketStatus.RESOLVED, TicketStatus.CLOSED]),
|
||||
category=category,
|
||||
created_hours_ago=created_hours_ago,
|
||||
first_response_hours_after=response_time,
|
||||
resolved_hours_after=resolution_time,
|
||||
assigned=True
|
||||
)
|
||||
|
||||
print(f" ✓ Creados 20 tickets cumpliendo Resolution SLA")
|
||||
|
||||
# ============================================
|
||||
# 5. TICKETS VIOLANDO SLA RESOLUTION
|
||||
# ============================================
|
||||
print("🔴 Generando tickets VIOLANDO Resolution SLA...")
|
||||
|
||||
for i in range(6):
|
||||
category = random.choice(categories)
|
||||
priority = random.choice([TicketPriority.HIGH, TicketPriority.URGENT])
|
||||
|
||||
# Creado hace más del SLA de resolución, con respuesta pero sin resolver
|
||||
created_hours_ago = category.sla_resolution_hours + random.randint(5, 48)
|
||||
response_time = random.uniform(1, category.sla_response_hours * 0.5)
|
||||
|
||||
create_ticket(
|
||||
subject=f"Ticket sin resolver - VIOLACIÓN #{i+1}",
|
||||
description=f"Ticket lleva {created_hours_ago}h sin resolver (SLA: {category.sla_resolution_hours}h)",
|
||||
priority=priority,
|
||||
status=random.choice([TicketStatus.IN_PROGRESS, TicketStatus.WAITING_CUSTOMER]),
|
||||
category=category,
|
||||
created_hours_ago=created_hours_ago,
|
||||
first_response_hours_after=response_time,
|
||||
resolved_hours_after=None, # Sin resolver!
|
||||
assigned=True
|
||||
)
|
||||
|
||||
print(f" ✓ Creados 6 tickets VIOLANDO Resolution SLA")
|
||||
|
||||
# ============================================
|
||||
# 6. TICKETS EN RIESGO Resolution
|
||||
# ============================================
|
||||
print("⚠️ Generando tickets EN RIESGO Resolution SLA...")
|
||||
|
||||
for i in range(12):
|
||||
category = random.choice(categories)
|
||||
priority = random.choice([TicketPriority.MEDIUM, TicketPriority.HIGH])
|
||||
|
||||
# Con respuesta, cerca del deadline de resolución
|
||||
sla_hours = category.sla_resolution_hours
|
||||
time_consumed = random.uniform(0.75, 0.95) * sla_hours
|
||||
created_hours_ago = time_consumed
|
||||
response_time = random.uniform(0.5, category.sla_response_hours * 0.5)
|
||||
|
||||
create_ticket(
|
||||
subject=f"Ticket cerca de vencer resolución #{i+1}",
|
||||
description=f"Este ticket está al {int(time_consumed/sla_hours*100)}% del SLA de resolución",
|
||||
priority=priority,
|
||||
status=TicketStatus.IN_PROGRESS,
|
||||
category=category,
|
||||
created_hours_ago=created_hours_ago,
|
||||
first_response_hours_after=response_time,
|
||||
resolved_hours_after=None,
|
||||
assigned=True
|
||||
)
|
||||
|
||||
print(f" ✓ Creados 12 tickets EN RIESGO Resolution SLA")
|
||||
|
||||
# Guardar todos los tickets
|
||||
await db.commit()
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print("✅ GENERACIÓN DE DATOS SLA COMPLETADA")
|
||||
print(f" Total de tickets creados: {tickets_created}")
|
||||
print()
|
||||
print("📊 Distribución esperada:")
|
||||
print(" ✅ Response cumplidos: 15 tickets")
|
||||
print(" 🔴 Response violados: 8 tickets")
|
||||
print(" ⚠️ Response en riesgo: 10 tickets")
|
||||
print(" ✅ Resolution cumplidos: 20 tickets")
|
||||
print(" 🔴 Resolution violados: 6 tickets")
|
||||
print(" ⚠️ Resolution en riesgo: 12 tickets")
|
||||
print()
|
||||
print("🌐 Ve los resultados en:")
|
||||
print(" Dashboard SLA: http://localhost:3001/sla")
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
async def cleanup_sla_test_data():
|
||||
"""Elimina tickets de prueba."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
tenant_result = await db.execute(select(Tenant).limit(1))
|
||||
tenant = tenant_result.scalar_one_or_none()
|
||||
|
||||
if not tenant:
|
||||
print("❌ No se encontró ningún tenant.")
|
||||
return
|
||||
|
||||
# Eliminar tickets que empiezan con TKT-
|
||||
result = await db.execute(
|
||||
select(Ticket).where(
|
||||
Ticket.tenant_id == tenant.id,
|
||||
Ticket.ticket_number.like('TKT-%')
|
||||
)
|
||||
)
|
||||
tickets = result.scalars().all()
|
||||
|
||||
if not tickets:
|
||||
print("ℹ️ No hay tickets de prueba para eliminar.")
|
||||
return
|
||||
|
||||
for ticket in tickets:
|
||||
await db.delete(ticket)
|
||||
|
||||
await db.commit()
|
||||
|
||||
print(f"✅ Eliminados {len(tickets)} tickets de prueba")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "cleanup":
|
||||
print("🧹 Limpiando datos de prueba de SLA...")
|
||||
print()
|
||||
asyncio.run(cleanup_sla_test_data())
|
||||
else:
|
||||
print("🚀 Generando datos de prueba para SLA Management...")
|
||||
print()
|
||||
asyncio.run(generate_sla_test_data())
|
||||
print()
|
||||
print("💡 Para limpiar estos datos de prueba, ejecuta:")
|
||||
print(" python scripts/generate_sla_test_data.py cleanup")
|
||||
49
backend/scripts/reset_passwords.py
Normal file
49
backend/scripts/reset_passwords.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
Script para resetear contraseñas de todos los usuarios a valores conocidos.
|
||||
Ejecutar con: python -m scripts.reset_passwords (desde /app en el contenedor)
|
||||
"""
|
||||
import asyncio
|
||||
from sqlalchemy import select, update
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.core.security import security
|
||||
from app.models.user import User
|
||||
|
||||
# Mapa email -> nueva contraseña
|
||||
PASSWORD_MAP = {
|
||||
"admin@aduanasoft.com": "admin123",
|
||||
"admin@test.com": "admin123",
|
||||
"manager@aduanasoft.com": "manager123",
|
||||
"agente@aduanasoft.com": "agente123",
|
||||
"auditor1@test.com": "auditor123",
|
||||
"admin-cliente@empresa-demo.com": "clienteadmin123",
|
||||
"cliente@empresa-demo.com": "cliente123",
|
||||
"test_user@aduanasoft.com": "test123",
|
||||
}
|
||||
|
||||
async def reset_all_passwords():
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(select(User))
|
||||
users = result.scalars().all()
|
||||
|
||||
updated = 0
|
||||
skipped = 0
|
||||
for user in users:
|
||||
if user.email in PASSWORD_MAP:
|
||||
plain = PASSWORD_MAP[user.email]
|
||||
user.password_hash = security.hash_password(plain)
|
||||
user.email_verified = True
|
||||
user.is_active = True
|
||||
updated += 1
|
||||
print(f" ✅ {user.email} → {plain}")
|
||||
else:
|
||||
skipped += 1
|
||||
print(f" ⚠️ {user.email} (sin contraseña definida, se omite)")
|
||||
|
||||
await db.commit()
|
||||
print(f"\nResumen: {updated} actualizados, {skipped} omitidos")
|
||||
print("\n📋 Credenciales listas:")
|
||||
for email, pwd in PASSWORD_MAP.items():
|
||||
print(f" {email} / {pwd}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(reset_all_passwords())
|
||||
@@ -1,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
|
||||
|
||||
Configuración básica para testing con pytest
|
||||
Configuración global para todos los tests (unit + integration).
|
||||
Carga variables de entorno de prueba antes de cualquier import de la app,
|
||||
y provee fixtures compartidos sin dependencia de Docker/PostgreSQL.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
import asyncio
|
||||
from typing import AsyncGenerator, Generator
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
import uuid
|
||||
|
||||
# ============================================================
|
||||
# CARGAR VARIABLES DE ENTORNO DE TEST ANTES DE IMPORTAR LA APP
|
||||
# Esto evita que pydantic-settings falle por SECRET_KEY faltante
|
||||
# ============================================================
|
||||
os.environ.setdefault("ENVIRONMENT", "testing")
|
||||
os.environ.setdefault("DEBUG", "true")
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-unit-tests-only-32chars!")
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "test-jwt-secret-key-for-unit-tests-only!")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///./test_unit.db")
|
||||
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/15")
|
||||
os.environ.setdefault("CELERY_BROKER_URL", "redis://localhost:6379/15")
|
||||
os.environ.setdefault("CELERY_RESULT_BACKEND", "redis://localhost:6379/15")
|
||||
os.environ.setdefault("CORS_ORIGINS", "http://localhost:3000")
|
||||
os.environ.setdefault("ALLOWED_FILE_EXTENSIONS", "pdf,jpg,jpeg,png,doc,docx,txt")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_user_data():
|
||||
"""Sample user data for testing."""
|
||||
# ============================================================
|
||||
# IN-MEMORY SQLite DB PARA UNIT TESTS (sin Docker)
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop() -> Generator:
|
||||
"""Event loop compartido para toda la sesión de tests."""
|
||||
policy = asyncio.get_event_loop_policy()
|
||||
loop = policy.new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def sqlite_engine():
|
||||
"""
|
||||
Engine SQLite en memoria para unit tests.
|
||||
No requiere Docker ni PostgreSQL.
|
||||
"""
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from app.core.database import Base
|
||||
# Importar todos los modelos para registrarlos en Base.metadata
|
||||
import app.models # noqa: F401
|
||||
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
echo=False,
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
yield engine
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_session(sqlite_engine) -> AsyncGenerator:
|
||||
"""
|
||||
Sesión de BD SQLite en memoria para cada test.
|
||||
Hace rollback al finalizar para mantener tests aislados.
|
||||
"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
async_session = async_sessionmaker(
|
||||
sqlite_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
async with async_session() as session:
|
||||
async with session.begin():
|
||||
yield session
|
||||
await session.rollback()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# FIXTURES DE DATOS COMUNES
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture
|
||||
def test_user_data() -> dict:
|
||||
"""Datos de usuario válidos para pruebas."""
|
||||
return {
|
||||
"email": "test@example.com",
|
||||
"first_name": "Test",
|
||||
"last_name": "User",
|
||||
"password": "TestPassword123!"
|
||||
"last_name": "User",
|
||||
"password": "TestPassword123!",
|
||||
"role": "AGENT",
|
||||
"language": "es",
|
||||
"timezone": "UTC",
|
||||
"notifications_email": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_tenant_data():
|
||||
"""Sample tenant data for testing."""
|
||||
@pytest.fixture
|
||||
def test_tenant_data() -> dict:
|
||||
"""Datos de tenant válidos para pruebas."""
|
||||
return {
|
||||
"name": "Test Tenant",
|
||||
"slug": "test-tenant",
|
||||
"description": "Test tenant for testing"
|
||||
}
|
||||
"name": "Test Company",
|
||||
"slug": "test-company",
|
||||
"contact_email": "admin@testcompany.com",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_ticket_data() -> dict:
|
||||
"""Datos de ticket válidos para pruebas."""
|
||||
return {
|
||||
"subject": "Test ticket subject",
|
||||
"description": "Detailed description of the test ticket",
|
||||
"priority": "MEDIUM",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db_session():
|
||||
"""Sesión de BD completamente mockeada (sin SQLite, sin red)."""
|
||||
session = AsyncMock()
|
||||
session.execute = AsyncMock()
|
||||
session.add = MagicMock()
|
||||
session.commit = AsyncMock()
|
||||
session.refresh = AsyncMock()
|
||||
session.rollback = AsyncMock()
|
||||
return session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_request():
|
||||
"""Request HTTP mockeado para tests de middleware y endpoints."""
|
||||
request = MagicMock()
|
||||
request.url.path = "/v1/tickets/"
|
||||
request.method = "GET"
|
||||
request.headers = {}
|
||||
request.state = MagicMock()
|
||||
return request
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_tenant_id() -> str:
|
||||
"""UUID de tenant fijo para pruebas."""
|
||||
return "12345678-1234-5678-1234-567812345678"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_user_id() -> str:
|
||||
"""UUID de usuario fijo para pruebas."""
|
||||
return "87654321-4321-8765-4321-876543218765"
|
||||
|
||||
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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user