Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd3d7e816f | |||
| 63925fe305 | |||
| c146a6c3c3 | |||
| ba779bde55 | |||
| ba94152074 | |||
| bd21207aae |
178
README.legacy.md
Normal file
178
README.legacy.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# ServiceManagerWeb - Mesa de Ayuda B2B
|
||||
|
||||
Sistema multi-tenant de Mesa de Ayuda/Soporte Técnico empresarial para Aduanasoft.
|
||||
|
||||
## Arquitectura
|
||||
|
||||
- **Frontend**: SvelteKit + TypeScript (portal clientes + panel interno)
|
||||
- **Backend**: Python FastAPI + Pydantic v2
|
||||
- **Workers**: Celery + Redis (notificaciones, SLAs, jobs)
|
||||
- **BD**: PostgreSQL + Alembic migrations
|
||||
- **Auth**: JWT + Refresh tokens + 2FA opcional (TOTP)
|
||||
- **Infra**: Docker Compose local, preparado para producción
|
||||
|
||||
## Estructura del Monorepo
|
||||
|
||||
```
|
||||
ServiceManagerWeb/
|
||||
├── backend/ # FastAPI app
|
||||
├── frontend-client/ # SvelteKit app para clientes
|
||||
├── frontend-internal/ # SvelteKit app para staff interno
|
||||
├── workers/ # Celery tasks
|
||||
├── db/ # Migrations y esquemas
|
||||
├── docker/ # Dockerfiles específicos
|
||||
├── docs/ # Documentación adicional
|
||||
├── scripts/ # Scripts de desarrollo/despliegue
|
||||
├── docker-compose.yml # Orquestación completa
|
||||
└── .env.example # Variables de entorno
|
||||
```
|
||||
|
||||
## Stack Tecnológico
|
||||
|
||||
### Backend (Python)
|
||||
- FastAPI (async)
|
||||
- Pydantic v2
|
||||
- SQLAlchemy 2.0 (async)
|
||||
- Alembic (migrations)
|
||||
- Argon2 (hashing passwords)
|
||||
- PyJWT
|
||||
- Celery + Redis
|
||||
|
||||
### Frontend (JavaScript/TypeScript)
|
||||
- SvelteKit
|
||||
- TypeScript
|
||||
- TailwindCSS
|
||||
- shadcn/ui o similar
|
||||
- Zod (validación)
|
||||
|
||||
### Infraestructura
|
||||
- PostgreSQL 15+
|
||||
- Redis 7+
|
||||
- Docker & Docker Compose
|
||||
- Nginx (reverse proxy)
|
||||
|
||||
## Dominios del Sistema
|
||||
|
||||
1. **Auth**: Usuarios, roles, permisos, 2FA
|
||||
2. **Tenants**: Multi-tenancy, organizaciones
|
||||
3. **Tickets**: Gestión de tickets, estados, SLAs
|
||||
4. **Notifications**: Email, plantillas, logs
|
||||
5. **Audit**: Bitácora de acciones
|
||||
|
||||
## Roles de Usuario
|
||||
|
||||
### Internos (Staff)
|
||||
- `ADMIN`: Control total del sistema
|
||||
- `SUPPORT_MANAGER`: Gestión de equipos y SLAs
|
||||
- `AGENT`: Atención de tickets
|
||||
- `AUDITOR`: Solo lectura para auditoría
|
||||
|
||||
### Clientes
|
||||
- `CLIENT_ADMIN`: Gestión de organización cliente
|
||||
- `CLIENT_USER`: Creación y seguimiento de tickets
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Clonar y configurar
|
||||
git clone <repo>
|
||||
cd ServiceManagerWeb
|
||||
cp .env.example .env
|
||||
|
||||
# Levantar servicios
|
||||
docker-compose up -d
|
||||
|
||||
# Verificar estado
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
## URLs por Defecto
|
||||
|
||||
- Frontend Clientes: http://localhost:3000
|
||||
- Frontend Interno: http://localhost:3001
|
||||
- API Backend: http://localhost:8000
|
||||
- API Docs: http://localhost:8000/docs
|
||||
- Adminer (DB): http://localhost:8080
|
||||
|
||||
## Scripts de Desarrollo
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend
|
||||
python -m uvicorn app.main:app --reload --port 8000
|
||||
|
||||
# Frontend Cliente
|
||||
cd frontend-client
|
||||
npm run dev -- --port 3000
|
||||
|
||||
# Frontend Interno
|
||||
cd frontend-internal
|
||||
npm run dev -- --port 3001
|
||||
|
||||
# Workers
|
||||
cd workers
|
||||
celery -A app.worker worker --loglevel=info
|
||||
celery -A app.worker beat --loglevel=info
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Backend tests
|
||||
cd backend
|
||||
pytest
|
||||
|
||||
# Frontend tests
|
||||
cd frontend-client
|
||||
npm test
|
||||
cd ../frontend-internal
|
||||
npm test
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Error 500 en Login / Proxy Error
|
||||
|
||||
**Síntoma**: Error 500 al intentar hacer login, o error de proxy de Vite "connect ECONNREFUSED".
|
||||
|
||||
**Causa**: Configuración incorrecta de la comunicación entre servicios de Docker.
|
||||
|
||||
**Solución**:
|
||||
1. En desarrollo con Docker, los servicios usan nombres de servicio (no `localhost`)
|
||||
2. Verificar `vite.config.js`: el proxy debe apuntar a `http://backend:8000`
|
||||
3. Verificar `docker-compose.yml`: `PUBLIC_API_URL` debe ser `http://backend:8000`
|
||||
4. Después de cambios, reiniciar contenedor: `docker-compose restart frontend-internal`
|
||||
|
||||
**Nota**: Para desarrollo local sin Docker, cambiar el proxy a `http://localhost:8000`.
|
||||
|
||||
### Tenant Slug Incorrecto
|
||||
|
||||
**Síntoma**: Error de autenticación incluso con credenciales correctas.
|
||||
|
||||
**Causa**: El `tenant_slug` en el login no coincide con los tenants en la BD.
|
||||
|
||||
**Solución**:
|
||||
1. Verificar tenants existentes: `docker exec servicemanager-backend python check_tenants.py`
|
||||
2. Actualizar el tenant_slug en el código de login
|
||||
3. Tenants por defecto: `aduanasoft-demo`, `test-tenant`
|
||||
|
||||
### Credenciales de Prueba
|
||||
|
||||
```
|
||||
Email: admin@aduanasoft.com
|
||||
Password: admin123
|
||||
Tenant: aduanasoft-demo
|
||||
Role: ADMIN
|
||||
```
|
||||
|
||||
## Contribución
|
||||
|
||||
1. Fork del proyecto
|
||||
2. Crear feature branch (`git checkout -b feature/nueva-funcionalidad`)
|
||||
3. Commit cambios (`git commit -am 'Agregar nueva funcionalidad'`)
|
||||
4. Push a branch (`git push origin feature/nueva-funcionalidad`)
|
||||
5. Crear Pull Request
|
||||
|
||||
## Licencia
|
||||
|
||||
Propietario - Aduanasoft © 2026
|
||||
837
README.md
837
README.md
@@ -1,178 +1,755 @@
|
||||
# ServiceManagerWeb - Mesa de Ayuda B2B
|
||||
# ServiceManagerWeb — Mesa de Ayuda B2B
|
||||
|
||||
Sistema multi-tenant de Mesa de Ayuda/Soporte Técnico empresarial para Aduanasoft.
|
||||
> **Versión actual:** v1.15.1 — Módulo de reportes implementado
|
||||
>
|
||||
> Sistema multi-tenant de Mesa de Ayuda / Soporte Técnico empresarial desarrollado para Aduanasoft.
|
||||
> Arquitectura Modular Monolith con Clean Architecture, preparado para escalar a microservicios.
|
||||
|
||||
## Arquitectura
|
||||
---
|
||||
|
||||
- **Frontend**: SvelteKit + TypeScript (portal clientes + panel interno)
|
||||
- **Backend**: Python FastAPI + Pydantic v2
|
||||
- **Workers**: Celery + Redis (notificaciones, SLAs, jobs)
|
||||
- **BD**: PostgreSQL + Alembic migrations
|
||||
- **Auth**: JWT + Refresh tokens + 2FA opcional (TOTP)
|
||||
- **Infra**: Docker Compose local, preparado para producción
|
||||
## Tabla de Contenidos
|
||||
|
||||
## Estructura del Monorepo
|
||||
1. [Requisitos previos](#requisitos-previos)
|
||||
2. [Inicio rápido con Docker (recomendado)](#inicio-rápido-con-docker-recomendado)
|
||||
3. [Configuración de variables de entorno](#configuración-de-variables-de-entorno)
|
||||
4. [Cargar datos de prueba](#cargar-datos-de-prueba)
|
||||
5. [URLs y puertos por defecto](#urls-y-puertos-por-defecto)
|
||||
6. [Credenciales de prueba](#credenciales-de-prueba)
|
||||
7. [Desarrollo local sin Docker](#desarrollo-local-sin-docker)
|
||||
8. [Arquitectura del proyecto](#arquitectura-del-proyecto)
|
||||
9. [Roles y permisos](#roles-y-permisos)
|
||||
10. [Comandos útiles](#comandos-útiles)
|
||||
11. [Pruebas (testing)](#pruebas-testing)
|
||||
12. [Solución de problemas](#solución-de-problemas)
|
||||
13. [Contribución](#contribución)
|
||||
14. [Historial de versiones](#historial-de-versiones)
|
||||
|
||||
---
|
||||
|
||||
## Requisitos previos
|
||||
|
||||
Antes de clonar el proyecto, asegúrate de tener instalado:
|
||||
|
||||
| Herramienta | Versión mínima | Descarga |
|
||||
|-------------|---------------|---------|
|
||||
| **Git** | 2.x | https://git-scm.com/downloads |
|
||||
| **Docker Desktop** | 24.x | https://www.docker.com/products/docker-desktop |
|
||||
| **Docker Compose** | v2.x (incluido en Docker Desktop) | — |
|
||||
|
||||
> **Nota para desarrolladores que quieran editar código localmente (sin Docker):**
|
||||
> también necesitarás Python 3.11+ y Node.js 18+. Ver sección
|
||||
> [Desarrollo local sin Docker](#desarrollo-local-sin-docker).
|
||||
|
||||
### Verificar que Docker esté corriendo
|
||||
|
||||
```bash
|
||||
docker --version # Debe mostrar Docker version 24.x o superior
|
||||
docker compose version # Debe mostrar Docker Compose version v2.x
|
||||
```
|
||||
|
||||
Si `docker compose version` falla, prueba `docker-compose --version` (versión standalone).
|
||||
|
||||
---
|
||||
|
||||
## Inicio rápido con Docker (recomendado)
|
||||
|
||||
Este es el método más simple y funciona igual en **Windows, Linux y macOS**.
|
||||
Solo necesitas Docker Desktop instalado y corriendo.
|
||||
|
||||
### Paso 1 — Clonar el repositorio
|
||||
|
||||
```bash
|
||||
git clone https://git.aduanasoft.com/ADUANASOFT/service_manager.git
|
||||
cd service_manager
|
||||
```
|
||||
|
||||
### Paso 2 — Crear el archivo de variables de entorno
|
||||
|
||||
**Linux / macOS:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
Copy-Item .env.example .env
|
||||
```
|
||||
|
||||
**Windows (CMD):**
|
||||
```cmd
|
||||
copy .env.example .env
|
||||
```
|
||||
|
||||
> **Importante:** El archivo `.env` nunca se sube a git (está en `.gitignore`).
|
||||
> Para desarrollo local los valores del `.env.example` funcionan sin cambios.
|
||||
> En producción **debes** generar claves secretas únicas (ver sección de variables de entorno).
|
||||
|
||||
### Paso 3 — Levantar todos los servicios
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Este comando descarga las imágenes, construye los contenedores e inicia todo el stack.
|
||||
La primera vez tarda entre 3 y 8 minutos dependiendo de la conexión a internet.
|
||||
|
||||
> **Alternativa con herramientas de desarrollo** (Adminer, MailHog, Redis Commander):
|
||||
> ```bash
|
||||
> docker compose --profile dev up -d
|
||||
> ```
|
||||
|
||||
### Paso 4 — Verificar que todo esté funcionando
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Deberías ver todos los servicios con estado `Up` o `healthy`:
|
||||
|
||||
```
|
||||
NAME STATUS
|
||||
servicemanager-db Up (healthy)
|
||||
servicemanager-redis Up (healthy)
|
||||
servicemanager-backend Up (healthy)
|
||||
servicemanager-worker Up
|
||||
servicemanager-beat Up
|
||||
servicemanager-client-frontend Up
|
||||
servicemanager-internal-... Up
|
||||
servicemanager-nginx Up
|
||||
```
|
||||
|
||||
Si algún servicio muestra `Exit` o `Restarting`, revisa la sección
|
||||
[Solución de problemas](#solución-de-problemas).
|
||||
|
||||
### Paso 5 — Cargar datos de ejemplo (opcional pero recomendado)
|
||||
|
||||
```bash
|
||||
docker exec servicemanager-backend python /scripts/seed_data.py
|
||||
```
|
||||
|
||||
Esto crea el tenant de demostración, categorías, usuarios y tickets de prueba.
|
||||
|
||||
### ¡Listo! Abre el navegador
|
||||
|
||||
| Aplicación | URL |
|
||||
|------------|-----|
|
||||
| Portal de clientes | http://localhost:3000 |
|
||||
| Panel interno (staff) | http://localhost:3001 |
|
||||
| API REST | http://localhost:8000 |
|
||||
| Documentación API (Swagger) | http://localhost:8000/docs |
|
||||
| Documentación API (ReDoc) | http://localhost:8000/redoc |
|
||||
| Health check | http://localhost:8000/health |
|
||||
|
||||
> **Con perfil dev** activo también tendrás:
|
||||
> - Adminer (gestor visual de PostgreSQL): http://localhost:8080
|
||||
> - MailHog (pruebas de email): http://localhost:8025
|
||||
> - Redis Commander (inspector de Redis): http://localhost:8081
|
||||
|
||||
---
|
||||
|
||||
## Configuración de variables de entorno
|
||||
|
||||
El archivo `.env` controla todo el comportamiento de la aplicación.
|
||||
Copia `.env.example` como `.env` y revisa los valores siguientes:
|
||||
|
||||
### Variables críticas
|
||||
|
||||
| Variable | Descripción | Valor por defecto (dev) |
|
||||
|----------|-------------|-------------------------|
|
||||
| `SECRET_KEY` | Clave secreta general de Flask/FastAPI | _(cambiar en producción)_ |
|
||||
| `JWT_SECRET_KEY` | Clave para firmar tokens JWT | _(cambiar en producción)_ |
|
||||
| `DATABASE_URL` | Cadena de conexión a PostgreSQL | `postgresql+asyncpg://servicemanager:...@postgres:5432/servicemanager` |
|
||||
| `REDIS_URL` | URL de conexión a Redis | `redis://redis:6379/0` |
|
||||
| `ENVIRONMENT` | Entorno actual | `development` |
|
||||
| `DEBUG` | Modo debug (muestra errores detallados) | `true` |
|
||||
|
||||
### Generar claves seguras para producción
|
||||
|
||||
**Linux / macOS:**
|
||||
```bash
|
||||
openssl rand -base64 32 # Genera SECRET_KEY
|
||||
openssl rand -base64 32 # Genera JWT_SECRET_KEY
|
||||
```
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
[Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Maximum 256 }))
|
||||
```
|
||||
|
||||
> **Advertencia:** Nunca uses las claves del `.env.example` en producción.
|
||||
> Cambiar las claves en producción invalida todas las sesiones activas.
|
||||
|
||||
### Desarrollo local vs Docker
|
||||
|
||||
En `.env.example` las URLs apuntan a nombres de servicio Docker (`postgres`, `redis`, `backend`).
|
||||
Si ejecutas el backend directamente en tu máquina (sin Docker), cambia:
|
||||
|
||||
```dotenv
|
||||
# Para desarrollo local sin Docker:
|
||||
DATABASE_URL=postgresql+asyncpg://servicemanager:servicemanager123@localhost:5432/servicemanager
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
CELERY_BROKER_URL=redis://localhost:6379/0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cargar datos de prueba
|
||||
|
||||
El script `seed_data.py` crea datos iniciales en la base de datos.
|
||||
|
||||
**Con Docker (recomendado):**
|
||||
```bash
|
||||
docker exec servicemanager-backend python /scripts/seed_data.py
|
||||
```
|
||||
|
||||
**Sin Docker:**
|
||||
```bash
|
||||
cd backend
|
||||
python ../scripts/seed_data.py
|
||||
```
|
||||
|
||||
El script crea:
|
||||
- Tenant de demostración: `aduanasoft-demo`
|
||||
- Categorías de tickets (Soporte Técnico, Facturación, Incidentes Críticos, etc.)
|
||||
- Sistemas registrados
|
||||
- Usuarios de prueba con distintos roles
|
||||
|
||||
---
|
||||
|
||||
## URLs y puertos por defecto
|
||||
|
||||
| Servicio | Puerto | Descripción |
|
||||
|----------|--------|-------------|
|
||||
| Frontend Clientes | **3000** | Portal para usuarios clientes |
|
||||
| Frontend Interno | **3001** | Panel para staff (agentes, admins) |
|
||||
| Backend API | **8000** | FastAPI — endpoints REST |
|
||||
| PostgreSQL | **5432** | Base de datos (no exponer en producción) |
|
||||
| Redis | **6379** | Cache y broker Celery (no exponer en producción) |
|
||||
| Nginx | **80** | Reverse proxy |
|
||||
| Adminer *(perfil dev)* | **8080** | GUI para PostgreSQL |
|
||||
| MailHog *(perfil dev)* | **8025** | Capturador de emails en desarrollo |
|
||||
| Redis Commander *(perfil dev)* | **8081** | GUI para Redis |
|
||||
|
||||
### ¿Conflicto de puertos?
|
||||
|
||||
Si algún puerto ya está en uso en tu máquina, edita `docker-compose.yml` y cambia
|
||||
el número **izquierdo** del mapeo `host:container`. Por ejemplo, para backend en el 8080:
|
||||
|
||||
```yaml
|
||||
ports:
|
||||
- "8080:8000" # ahora accesible en localhost:8080
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Credenciales de prueba
|
||||
|
||||
Después de ejecutar el seed, puedes iniciar sesión con:
|
||||
|
||||
| Campo | Valor |
|
||||
|-------|-------|
|
||||
| Email | `admin@aduanasoft.com` |
|
||||
| Contraseña | `admin123` |
|
||||
| Tenant | `aduanasoft-demo` |
|
||||
| Rol | `ADMIN` |
|
||||
|
||||
> Otros usuarios creados por el seed tienen el mismo sufijo de contraseña (`123`).
|
||||
> Revisa `scripts/seed_data.py` para ver la lista completa.
|
||||
|
||||
---
|
||||
|
||||
## Desarrollo local sin Docker
|
||||
|
||||
Útil cuando necesitas depurar el código con breakpoints o acelerar el ciclo de desarrollo.
|
||||
Requiere que **PostgreSQL y Redis sí corran en Docker** (o instalación nativa).
|
||||
|
||||
### Requisitos adicionales
|
||||
|
||||
| Herramienta | Versión | Descarga |
|
||||
|------------|---------|---------|
|
||||
| Python | 3.11 o 3.12 | https://www.python.org/downloads/ |
|
||||
| Node.js (con npm) | 18 LTS | https://nodejs.org/ |
|
||||
| pip | incluido con Python | — |
|
||||
|
||||
### Iniciar solo la base de datos y Redis
|
||||
|
||||
```bash
|
||||
docker compose up -d postgres redis
|
||||
```
|
||||
|
||||
### Backend (FastAPI)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Crear entorno virtual (solo la primera vez)
|
||||
python -m venv ../.venv
|
||||
|
||||
# Activar entorno virtual
|
||||
# Linux / macOS:
|
||||
source ../.venv/bin/activate
|
||||
# Windows (PowerShell):
|
||||
..\.venv\Scripts\Activate.ps1
|
||||
# Windows (CMD):
|
||||
..\.venv\Scripts\activate.bat
|
||||
|
||||
# Instalar dependencias (solo la primera vez o cuando cambie requirements.txt)
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Ejecutar migraciones de base de datos
|
||||
alembic upgrade head
|
||||
|
||||
# Iniciar servidor de desarrollo
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
> Si `uvicorn` no se encuentra, asegúrate de que el entorno virtual está activado
|
||||
> (`(.venv)` debe aparecer en tu terminal).
|
||||
|
||||
### Frontend Clientes
|
||||
|
||||
```bash
|
||||
cd frontend-client
|
||||
|
||||
# Instalar dependencias (solo la primera vez)
|
||||
npm install
|
||||
|
||||
# Iniciar servidor de desarrollo en puerto 3000
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Frontend Interno (staff)
|
||||
|
||||
```bash
|
||||
cd frontend-internal
|
||||
|
||||
# Instalar dependencias (solo la primera vez)
|
||||
npm install
|
||||
|
||||
# Iniciar servidor de desarrollo en puerto 3001
|
||||
npm run dev
|
||||
```
|
||||
|
||||
> Los dos frontends tienen puertos distintos (3000 y 3001) para que no haya conflicto
|
||||
> cuando corren al mismo tiempo.
|
||||
|
||||
### Workers Celery (opcional en desarrollo)
|
||||
|
||||
Necesario solo si desarrollas funcionalidades de notificaciones o SLAs automáticos.
|
||||
|
||||
```bash
|
||||
cd workers
|
||||
|
||||
# Activar el mismo entorno virtual del backend:
|
||||
# Linux / macOS:
|
||||
source ../.venv/bin/activate
|
||||
# Windows:
|
||||
..\.venv\Scripts\Activate.ps1
|
||||
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Worker principal
|
||||
celery -A app.celery worker --loglevel=info
|
||||
|
||||
# Scheduler de tareas periódicas (en otra terminal)
|
||||
celery -A app.celery beat --loglevel=info --schedule=/tmp/celerybeat-schedule
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Arquitectura del proyecto
|
||||
|
||||
```
|
||||
ServiceManagerWeb/
|
||||
├── backend/ # FastAPI app
|
||||
├── frontend-client/ # SvelteKit app para clientes
|
||||
├── frontend-internal/ # SvelteKit app para staff interno
|
||||
├── workers/ # Celery tasks
|
||||
├── db/ # Migrations y esquemas
|
||||
├── docker/ # Dockerfiles específicos
|
||||
├── docs/ # Documentación adicional
|
||||
├── scripts/ # Scripts de desarrollo/despliegue
|
||||
├── docker-compose.yml # Orquestación completa
|
||||
└── .env.example # Variables de entorno
|
||||
├── backend/ # Aplicación FastAPI (Python 3.11)
|
||||
│ ├── app/
|
||||
│ │ ├── main.py # Punto de entrada, lifespan, middlewares
|
||||
│ │ ├── api/v1/
|
||||
│ │ │ ├── router.py # Registro de todos los routers
|
||||
│ │ │ └── endpoints/ # Endpoints REST por dominio
|
||||
│ │ ├── core/ # Config, seguridad, base de datos, caché
|
||||
│ │ ├── models/ # Modelos SQLAlchemy (ORM)
|
||||
│ │ ├── services/ # Lógica de negocio
|
||||
│ │ └── middleware/ # Tenant context, Correlation ID
|
||||
│ ├── migrations/ # Migraciones Alembic
|
||||
│ ├── tests/ # Pruebas backend
|
||||
│ └── requirements.txt # Dependencias Python
|
||||
│
|
||||
├── frontend-client/ # Portal de clientes (SvelteKit + TypeScript)
|
||||
│ └── src/routes/ # Páginas: login, tickets, perfil
|
||||
│
|
||||
├── frontend-internal/ # Panel de staff (SvelteKit + TypeScript)
|
||||
│ └── src/routes/ # Páginas: dashboard, tickets, reportes, auditoría
|
||||
│
|
||||
├── workers/ # Tareas asíncronas Celery
|
||||
│ └── app/tasks/ # email_tasks.py, sla_tasks.py, etc.
|
||||
│
|
||||
├── docker/ # Dockerfiles y configuración Nginx
|
||||
├── db/ # schema.sql inicial
|
||||
├── docs/ # Documentación técnica adicional
|
||||
├── scripts/ # seed_data.py, setup-dev.sh, etc.
|
||||
├── docker-compose.yml # Orquestación completa
|
||||
└── .env.example # Plantilla de variables de entorno
|
||||
```
|
||||
|
||||
## Stack Tecnológico
|
||||
### Stack tecnológico
|
||||
|
||||
### Backend (Python)
|
||||
- FastAPI (async)
|
||||
- Pydantic v2
|
||||
- SQLAlchemy 2.0 (async)
|
||||
- Alembic (migrations)
|
||||
- Argon2 (hashing passwords)
|
||||
- PyJWT
|
||||
- Celery + Redis
|
||||
**Backend:** Python 3.11 · FastAPI · Pydantic v2 · SQLAlchemy 2.0 (async) · Alembic · Argon2 · PyJWT · Celery · Redis
|
||||
|
||||
### Frontend (JavaScript/TypeScript)
|
||||
- SvelteKit
|
||||
- TypeScript
|
||||
- TailwindCSS
|
||||
- shadcn/ui o similar
|
||||
- Zod (validación)
|
||||
**Frontend:** Node.js 18 · SvelteKit · TypeScript · TailwindCSS · Zod
|
||||
|
||||
### Infraestructura
|
||||
- PostgreSQL 15+
|
||||
- Redis 7+
|
||||
- Docker & Docker Compose
|
||||
- Nginx (reverse proxy)
|
||||
**Infraestructura:** PostgreSQL 15 · Redis 7 · Docker Compose · Nginx
|
||||
|
||||
## Dominios del Sistema
|
||||
---
|
||||
|
||||
1. **Auth**: Usuarios, roles, permisos, 2FA
|
||||
2. **Tenants**: Multi-tenancy, organizaciones
|
||||
3. **Tickets**: Gestión de tickets, estados, SLAs
|
||||
4. **Notifications**: Email, plantillas, logs
|
||||
5. **Audit**: Bitácora de acciones
|
||||
## Roles y permisos
|
||||
|
||||
## Roles de Usuario
|
||||
|
||||
### Internos (Staff)
|
||||
- `ADMIN`: Control total del sistema
|
||||
- `SUPPORT_MANAGER`: Gestión de equipos y SLAs
|
||||
- `AGENT`: Atención de tickets
|
||||
- `AUDITOR`: Solo lectura para auditoría
|
||||
### Personal interno (staff)
|
||||
| Rol | Descripción |
|
||||
|-----|-------------|
|
||||
| `ADMIN` | Control total del sistema |
|
||||
| `SUPPORT_MANAGER` | Gestión de equipos y configuración de SLAs |
|
||||
| `AGENT` | Atención y resolución de tickets |
|
||||
| `AUDITOR` | Solo lectura para revisiones y cumplimiento |
|
||||
|
||||
### Clientes
|
||||
- `CLIENT_ADMIN`: Gestión de organización cliente
|
||||
- `CLIENT_USER`: Creación y seguimiento de tickets
|
||||
| Rol | Descripción |
|
||||
|-----|-------------|
|
||||
| `CLIENT_ADMIN` | Gestión de su organización cliente |
|
||||
| `CLIENT_USER` | Creación y seguimiento de sus propios tickets |
|
||||
|
||||
## Quick Start
|
||||
---
|
||||
|
||||
## Comandos útiles
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```bash
|
||||
# Clonar y configurar
|
||||
git clone <repo>
|
||||
cd ServiceManagerWeb
|
||||
cp .env.example .env
|
||||
# Levantar todos los servicios (segundo plano)
|
||||
docker compose up -d
|
||||
|
||||
# Levantar servicios
|
||||
docker-compose up -d
|
||||
# Levantar con herramientas de desarrollo
|
||||
docker compose --profile dev up -d
|
||||
|
||||
# Verificar estado
|
||||
docker-compose ps
|
||||
# Ver logs en tiempo real de todos los servicios
|
||||
docker compose logs -f
|
||||
|
||||
# Ver logs de un servicio específico
|
||||
docker compose logs -f backend
|
||||
docker compose logs -f frontend-internal
|
||||
|
||||
# Detener todos los servicios (mantiene los datos)
|
||||
docker compose down
|
||||
|
||||
# Detener Y borrar todos los volúmenes (¡borra la base de datos!)
|
||||
docker compose down -v
|
||||
|
||||
# Reconstruir imagen de un servicio (después de cambiar Dockerfile o requirements)
|
||||
docker compose build backend
|
||||
docker compose up -d backend
|
||||
|
||||
# Reiniciar un servicio
|
||||
docker compose restart backend
|
||||
```
|
||||
|
||||
## URLs por Defecto
|
||||
|
||||
- Frontend Clientes: http://localhost:3000
|
||||
- Frontend Interno: http://localhost:3001
|
||||
- API Backend: http://localhost:8000
|
||||
- API Docs: http://localhost:8000/docs
|
||||
- Adminer (DB): http://localhost:8080
|
||||
|
||||
## Scripts de Desarrollo
|
||||
### Base de datos (Alembic)
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
# Aplicar todas las migraciones pendientes
|
||||
cd backend
|
||||
python -m uvicorn app.main:app --reload --port 8000
|
||||
alembic upgrade head
|
||||
|
||||
# Frontend Cliente
|
||||
cd frontend-client
|
||||
npm run dev -- --port 3000
|
||||
# Ver estado de migraciones
|
||||
alembic current
|
||||
|
||||
# Frontend Interno
|
||||
cd frontend-internal
|
||||
npm run dev -- --port 3001
|
||||
# Revertir última migración
|
||||
alembic downgrade -1
|
||||
|
||||
# Workers
|
||||
cd workers
|
||||
celery -A app.worker worker --loglevel=info
|
||||
celery -A app.worker beat --loglevel=info
|
||||
# Crear nueva migración (después de modificar models/)
|
||||
alembic revision --autogenerate -m "nombre descriptivo del cambio"
|
||||
|
||||
# Con Docker:
|
||||
docker exec servicemanager-backend alembic upgrade head
|
||||
```
|
||||
|
||||
## Testing
|
||||
### Calidad de código
|
||||
|
||||
```bash
|
||||
# Backend tests
|
||||
cd backend
|
||||
|
||||
# Linter y auto-fix
|
||||
ruff check . --fix
|
||||
|
||||
# Formateador
|
||||
black .
|
||||
|
||||
# Verificación de tipos
|
||||
mypy .
|
||||
|
||||
# Todo de una vez
|
||||
ruff check . --fix && black . && mypy .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pruebas (testing)
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Ejecutar todas las pruebas
|
||||
pytest
|
||||
|
||||
# Frontend tests
|
||||
cd frontend-client
|
||||
npm test
|
||||
cd ../frontend-internal
|
||||
npm test
|
||||
# Con cobertura detallada
|
||||
pytest --cov=app --cov-report=html
|
||||
|
||||
# Abrir reporte de cobertura (Linux/macOS)
|
||||
open htmlcov/index.html
|
||||
# Windows
|
||||
start htmlcov/index.html
|
||||
|
||||
# Prueba específica
|
||||
pytest tests/test_auth.py -v
|
||||
|
||||
# Con Docker
|
||||
docker exec servicemanager-backend pytest -v --cov=app
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Error 500 en Login / Proxy Error
|
||||
|
||||
**Síntoma**: Error 500 al intentar hacer login, o error de proxy de Vite "connect ECONNREFUSED".
|
||||
|
||||
**Causa**: Configuración incorrecta de la comunicación entre servicios de Docker.
|
||||
|
||||
**Solución**:
|
||||
1. En desarrollo con Docker, los servicios usan nombres de servicio (no `localhost`)
|
||||
2. Verificar `vite.config.js`: el proxy debe apuntar a `http://backend:8000`
|
||||
3. Verificar `docker-compose.yml`: `PUBLIC_API_URL` debe ser `http://backend:8000`
|
||||
4. Después de cambios, reiniciar contenedor: `docker-compose restart frontend-internal`
|
||||
|
||||
**Nota**: Para desarrollo local sin Docker, cambiar el proxy a `http://localhost:8000`.
|
||||
|
||||
### Tenant Slug Incorrecto
|
||||
|
||||
**Síntoma**: Error de autenticación incluso con credenciales correctas.
|
||||
|
||||
**Causa**: El `tenant_slug` en el login no coincide con los tenants en la BD.
|
||||
|
||||
**Solución**:
|
||||
1. Verificar tenants existentes: `docker exec servicemanager-backend python check_tenants.py`
|
||||
2. Actualizar el tenant_slug en el código de login
|
||||
3. Tenants por defecto: `aduanasoft-demo`, `test-tenant`
|
||||
|
||||
### Credenciales de Prueba
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend-internal # o frontend-client
|
||||
npm test # Ejecutar una vez
|
||||
npm run test:watch # Modo observador
|
||||
```
|
||||
Email: admin@aduanasoft.com
|
||||
Password: admin123
|
||||
Tenant: aduanasoft-demo
|
||||
Role: ADMIN
|
||||
|
||||
---
|
||||
|
||||
## Solución de problemas
|
||||
|
||||
### El backend no inicia — error en `DATABASE_URL`
|
||||
|
||||
**Síntoma:** El contenedor `servicemanager-backend` reinicia continuamente.
|
||||
|
||||
**Causa frecuente:** El archivo `.env` no existe o tiene `DATABASE_URL` apuntando a `localhost`
|
||||
en lugar del nombre del servicio Docker `postgres`.
|
||||
|
||||
**Solución:**
|
||||
```bash
|
||||
# Verificar que .env existe
|
||||
ls .env # Linux/macOS
|
||||
dir .env # Windows
|
||||
|
||||
# Si no existe, crearlo
|
||||
cp .env.example .env # Linux/macOS
|
||||
Copy-Item .env.example .env # Windows PowerShell
|
||||
|
||||
# Verificar el valor correcto en .env:
|
||||
# DATABASE_URL=postgresql+asyncpg://servicemanager:servicemanager123@postgres:5432/servicemanager
|
||||
# ^^^^^^^
|
||||
# Nombre de servicio Docker, NO localhost
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Error 500 en login / "connect ECONNREFUSED"
|
||||
|
||||
**Síntoma:** El frontend muestra error 500 al hacer login, o la consola del navegador
|
||||
muestra `ECONNREFUSED 127.0.0.1:8000`.
|
||||
|
||||
**Causa:** El proxy de Vite no encuentra el backend.
|
||||
|
||||
**Solución en Docker:** El proxy ya está configurado para usar `PUBLIC_API_URL`.
|
||||
Verifica en `docker-compose.yml` que `frontend-internal` y `frontend-client` tienen:
|
||||
```yaml
|
||||
environment:
|
||||
- PUBLIC_API_URL=http://backend:8000
|
||||
```
|
||||
Después reinicia:
|
||||
```bash
|
||||
docker compose restart frontend-internal frontend-client
|
||||
```
|
||||
|
||||
**Solución en desarrollo local:** Asegúrate de que el backend está corriendo:
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
# Debe responder: {"status": "ok", ...}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### El frontend-internal y frontend-client usan el mismo puerto localmente
|
||||
|
||||
**Síntoma:** Al correr ambos frontends sin Docker, uno de los dos falla
|
||||
con `Port 3000 is already in use`.
|
||||
|
||||
**Solución:**
|
||||
- `frontend-client` → usa el puerto **3000** (por defecto con `npm run dev`)
|
||||
- `frontend-internal` → usa el puerto **3001** (configurado en `vite.config.js`)
|
||||
|
||||
Nunca hay conflicto si los iniciaste con `npm run dev` en cada carpeta por separado.
|
||||
Si aún hay conflicto, mata el proceso en ese puerto:
|
||||
|
||||
```bash
|
||||
# Linux / macOS
|
||||
lsof -ti:3000 | xargs kill -9
|
||||
|
||||
# Windows (PowerShell)
|
||||
Get-Process -Id (Get-NetTCPConnection -LocalPort 3000).OwningProcess | Stop-Process -Force
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### El tenant slug es incorrecto al hacer login
|
||||
|
||||
**Síntoma:** Login falla con "credenciales inválidas" aunque el email y contraseña son correctos.
|
||||
|
||||
**Causa:** El campo `tenant_slug` no corresponde a ningún tenant en la base de datos.
|
||||
|
||||
**Solución:**
|
||||
```bash
|
||||
# Ver los tenants disponibles
|
||||
docker exec servicemanager-backend python -c "
|
||||
import asyncio
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy import text
|
||||
import os
|
||||
async def main():
|
||||
engine = create_async_engine(os.environ['DATABASE_URL'])
|
||||
async with AsyncSession(engine) as s:
|
||||
result = await s.execute(text('SELECT slug, name FROM tenants'))
|
||||
for row in result:
|
||||
print(row)
|
||||
asyncio.run(main())
|
||||
"
|
||||
```
|
||||
Tenant por defecto (después del seed): **`aduanasoft-demo`**
|
||||
|
||||
---
|
||||
|
||||
### Puerto ocupado — cambiar puertos de los servicios
|
||||
|
||||
Edita `docker-compose.yml` y modifica **solo el número izquierdo** del mapeo de puertos:
|
||||
|
||||
```yaml
|
||||
# Ejemplo: mover el backend al puerto 9000
|
||||
backend:
|
||||
ports:
|
||||
- "9000:8000" # accesible en localhost:9000
|
||||
|
||||
# Ejemplo: mover el frontend al puerto 4000
|
||||
frontend-client:
|
||||
ports:
|
||||
- "4000:3000" # accesible en localhost:4000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Migraciones fallidas — `alembic upgrade head` da error
|
||||
|
||||
```bash
|
||||
# Verificar el estado actual
|
||||
docker exec servicemanager-backend alembic current
|
||||
|
||||
# Si hay conflicto, hacer downgrade hasta la base y volver a subir
|
||||
docker exec servicemanager-backend alembic downgrade base
|
||||
docker exec servicemanager-backend alembic upgrade head
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Módulo Python no encontrado (`ModuleNotFoundError`)
|
||||
|
||||
**Con Docker:** El módulo no está en `requirements.txt` o la imagen no fue reconstruida.
|
||||
```bash
|
||||
# Reconstruir la imagen del backend
|
||||
docker compose build backend
|
||||
docker compose up -d backend
|
||||
```
|
||||
|
||||
**Local:** El entorno virtual no está activado.
|
||||
```bash
|
||||
# Verificar que el venv está activo (debe aparecer (.venv) en el prompt)
|
||||
which python # Linux/macOS — debe apuntar a .venv/
|
||||
# Windows:
|
||||
where python # debe apuntar a .venv\Scripts\python.exe
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `npm: command not found` o versión de Node incorrecta
|
||||
|
||||
```bash
|
||||
node --version # Debe ser v18.x o superior
|
||||
npm --version # Debe ser 9.x o superior
|
||||
```
|
||||
|
||||
Si Node no está instalado, descárgalo desde https://nodejs.org/ (elige "LTS").
|
||||
|
||||
En macOS con Homebrew:
|
||||
```bash
|
||||
brew install node@18
|
||||
```
|
||||
|
||||
En Linux (Ubuntu/Debian):
|
||||
```bash
|
||||
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `docker-compose` no se reconoce como comando
|
||||
|
||||
En versiones modernas de Docker Desktop, el comando es `docker compose` (con espacio, sin guion).
|
||||
Si tienes instalación separada de Docker Compose v1, usa `docker-compose` (con guion).
|
||||
|
||||
---
|
||||
|
||||
### Logs de los contenedores
|
||||
|
||||
```bash
|
||||
# Ver qué está fallando
|
||||
docker compose logs backend --tail=50
|
||||
docker compose logs frontend-internal --tail=50
|
||||
docker compose logs postgres --tail=20
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contribución
|
||||
|
||||
1. Fork del proyecto
|
||||
2. Crear feature branch (`git checkout -b feature/nueva-funcionalidad`)
|
||||
3. Commit cambios (`git commit -am 'Agregar nueva funcionalidad'`)
|
||||
4. Push a branch (`git push origin feature/nueva-funcionalidad`)
|
||||
5. Crear Pull Request
|
||||
1. Haz fork del proyecto
|
||||
2. Crea una rama de funcionalidad: `git checkout -b feature/nombre-funcionalidad`
|
||||
3. Realiza tus cambios siguiendo las convenciones del proyecto
|
||||
4. Ejecuta las pruebas: `pytest` y el linter: `ruff check .`
|
||||
5. Haz commit con un mensaje descriptivo: `git commit -m "feat: agregar exportación a CSV"`
|
||||
6. Sube tu rama: `git push origin feature/nombre-funcionalidad`
|
||||
7. Abre un Pull Request hacia `main`
|
||||
|
||||
### Convenciones de nombres
|
||||
|
||||
- **Modelos**: `PascalCase` → `User`, `Ticket`, `TenantOrganization`
|
||||
- **Endpoints (URL)**: `kebab-case` → `/api/v1/user-management/`
|
||||
- **Componentes Svelte**: `PascalCase.svelte` → `TicketCard.svelte`
|
||||
- **Stores**: `camelCase` → `ticketStore.ts`
|
||||
|
||||
---
|
||||
|
||||
## Historial de versiones
|
||||
|
||||
| Versión | Descripción |
|
||||
|---------|-------------|
|
||||
| **v1.15.1** | Módulo de reportes implementado |
|
||||
| v1.14.x | Mejoras al módulo de auditoría |
|
||||
| v1.13.x | Sistema de SLAs automático |
|
||||
| v1.12.x | Notificaciones por email |
|
||||
| v1.0.0 | MVP inicial — tickets, tenants, autenticación |
|
||||
|
||||
---
|
||||
|
||||
## Licencia
|
||||
|
||||
Propietario - Aduanasoft © 2026
|
||||
Propietario — Aduanasoft © 2026. Todos los derechos reservados.
|
||||
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)
|
||||
@@ -1,8 +1,34 @@
|
||||
"""Helper functions for audit endpoints"""
|
||||
"""
|
||||
Audit Helpers - ServiceManagerWeb
|
||||
===================================
|
||||
Funciones auxiliares reutilizables para los endpoints de auditoría.
|
||||
|
||||
Este archivo contiene:
|
||||
- audit_log_to_dict: Convierte un modelo AuditLog a diccionario
|
||||
- apply_tenant_filter: Aplica filtro de tenant según permisos
|
||||
- get_count_stat: Cuenta registros con filtros opcionales (CORREGIDO)
|
||||
- get_top_items: Obtiene los items más frecuentes
|
||||
- detect_mass_deletions: Detecta eliminaciones masivas sospechosas
|
||||
- detect_brute_force: Detecta ataques de fuerza bruta
|
||||
- detect_privilege_escalation: Detecta escaladas de privilegios
|
||||
|
||||
CORRECCIÓN APLICADA en get_count_stat:
|
||||
La columna created_at en PostgreSQL es 'timestamp with time zone' (TIMESTAMPTZ),
|
||||
lo que significa que almacena y devuelve fechas CON información de timezone (+00).
|
||||
|
||||
El bug era que se comparaba un datetime naive (sin timezone) contra una columna
|
||||
TIMESTAMPTZ. PostgreSQL no puede comparar ambos tipos directamente, por lo que
|
||||
el filtro se ignoraba silenciosamente y los tres contadores devolvían el mismo
|
||||
valor (el total histórico completo sin ningún filtro de fecha).
|
||||
|
||||
La solución es garantizar que TODAS las fechas que se usen en queries tengan
|
||||
timezone info (aware datetime en UTC) usando _ensure_aware_utc().
|
||||
"""
|
||||
|
||||
from sqlalchemy import select, func, and_, or_, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Optional, Dict, List
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
import uuid
|
||||
|
||||
from app.models.audit import AuditLog
|
||||
@@ -10,8 +36,18 @@ from app.models.user import User, UserRole
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CONVERSIÓN DE MODELOS
|
||||
# =============================================================================
|
||||
|
||||
def audit_log_to_dict(log: AuditLog) -> dict:
|
||||
"""Convierte AuditLog a diccionario de respuesta"""
|
||||
"""
|
||||
Convierte un objeto AuditLog de SQLAlchemy a un diccionario plano
|
||||
compatible con los schemas de respuesta de Pydantic.
|
||||
|
||||
Incluye los datos del usuario relacionado si están cargados
|
||||
(requiere que la query use selectinload(AuditLog.user)).
|
||||
"""
|
||||
log_dict = {
|
||||
"id": log.id,
|
||||
"tenant_id": log.tenant_id,
|
||||
@@ -19,203 +55,463 @@ def audit_log_to_dict(log: AuditLog) -> dict:
|
||||
"action": log.action,
|
||||
"resource_type": log.resource_type,
|
||||
"resource_id": log.resource_id,
|
||||
# ip_address puede ser un objeto especial de PostgreSQL, convertir a string
|
||||
"ip_address": str(log.ip_address) if log.ip_address else None,
|
||||
"user_agent": log.user_agent,
|
||||
"correlation_id": log.correlation_id,
|
||||
"old_values": log.old_values,
|
||||
"new_values": log.new_values,
|
||||
# extra_metadata evita conflicto con la palabra reservada 'metadata'
|
||||
"metadata": log.extra_metadata,
|
||||
"created_at": log.created_at,
|
||||
"action_display": log.action_display,
|
||||
# Campos del usuario (se llenan abajo si la relación está cargada)
|
||||
"user_email": None,
|
||||
"user_name": None
|
||||
"user_name": None,
|
||||
"user_role": None,
|
||||
}
|
||||
|
||||
|
||||
# Solo agregar datos del usuario si la relación fue cargada en la query
|
||||
if log.user:
|
||||
log_dict["user_email"] = log.user.email
|
||||
log_dict["user_name"] = log.user.full_name
|
||||
log_dict["user_role"] = log.user.role.value if hasattr(log.user.role, 'value') else str(log.user.role)
|
||||
|
||||
# El rol puede ser un Enum de Python o un string, manejar ambos casos
|
||||
log_dict["user_role"] = (
|
||||
log.user.role.value
|
||||
if hasattr(log.user.role, 'value')
|
||||
else str(log.user.role)
|
||||
)
|
||||
|
||||
return log_dict
|
||||
|
||||
|
||||
def apply_tenant_filter(query, current_user: User, current_tenant: Tenant, all_tenants: bool = False, specific_tenant_id: Optional[uuid.UUID] = None):
|
||||
"""Aplica filtro de tenant según permisos del usuario"""
|
||||
# =============================================================================
|
||||
# FILTRO DE MULTI-TENANCY
|
||||
# =============================================================================
|
||||
|
||||
def apply_tenant_filter(
|
||||
query,
|
||||
current_user: User,
|
||||
current_tenant: Tenant,
|
||||
all_tenants: bool = False,
|
||||
specific_tenant_id: Optional[uuid.UUID] = None
|
||||
):
|
||||
"""
|
||||
Aplica el filtro de tenant a una query de SQLAlchemy según los
|
||||
permisos del usuario actual.
|
||||
|
||||
Reglas:
|
||||
- ADMIN y SUPPORT_MANAGER pueden ver todos los tenants si
|
||||
all_tenants=True, o filtrar por un tenant específico.
|
||||
- Cualquier otro rol solo puede ver los datos de su propio tenant.
|
||||
"""
|
||||
can_see_all_tenants = current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]
|
||||
|
||||
|
||||
if all_tenants and can_see_all_tenants:
|
||||
return query # No filtrar por tenant
|
||||
# Usuario privilegiado pidiendo ver todos los tenants → sin filtro
|
||||
return query
|
||||
elif specific_tenant_id and can_see_all_tenants:
|
||||
# Usuario privilegiado pidiendo un tenant específico
|
||||
return query.where(AuditLog.tenant_id == specific_tenant_id)
|
||||
else:
|
||||
# Cualquier otro caso → solo ver el propio tenant
|
||||
return query.where(AuditLog.tenant_id == current_tenant.id)
|
||||
|
||||
|
||||
async def get_count_stat(db: AsyncSession, tenant_id: Optional[uuid.UUID] = None,
|
||||
date_from: Optional[datetime] = None, action_filter=None) -> int:
|
||||
"""Obtiene estadística de conteo con filtros opcionales"""
|
||||
# =============================================================================
|
||||
# UTILIDAD DE FECHAS
|
||||
# =============================================================================
|
||||
|
||||
def _ensure_aware_utc(dt: datetime) -> datetime:
|
||||
"""
|
||||
Garantiza que un datetime tenga información de timezone en UTC.
|
||||
|
||||
PROBLEMA QUE RESUELVE:
|
||||
La columna created_at en PostgreSQL es 'timestamp with time zone'
|
||||
(TIMESTAMPTZ). Cuando se compara con un datetime naive (sin timezone),
|
||||
PostgreSQL no puede hacer la comparación correctamente y el filtro
|
||||
de fecha se ignora silenciosamente, devolviendo todos los registros
|
||||
sin importar la fecha.
|
||||
|
||||
SOLUCIÓN:
|
||||
Siempre convertir las fechas a aware UTC antes de usarlas en queries.
|
||||
|
||||
Casos que maneja:
|
||||
- datetime naive (sin tzinfo): agrega UTC como timezone
|
||||
- datetime aware (con tzinfo): convierte a UTC si es otra zona horaria
|
||||
|
||||
Ejemplos:
|
||||
datetime(2026, 2, 24, 15, 0, 0) → datetime(2026, 2, 24, 15, 0, 0, tzinfo=UTC)
|
||||
datetime(2026, 2, 24, 9, 0, 0, tzinfo=CST) → datetime(2026, 2, 24, 15, 0, 0, tzinfo=UTC)
|
||||
"""
|
||||
if dt.tzinfo is None:
|
||||
# Datetime naive → asumir que ya es UTC y agregarle timezone info
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
# Datetime aware → convertir a UTC (por si viene en otra zona horaria)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CONTADORES DE ESTADÍSTICAS
|
||||
# =============================================================================
|
||||
|
||||
async def get_count_stat(
|
||||
db: AsyncSession,
|
||||
tenant_id: Optional[uuid.UUID] = None,
|
||||
date_from: Optional[datetime] = None,
|
||||
action_filter=None
|
||||
) -> int:
|
||||
"""
|
||||
Cuenta registros de AuditLog con filtros opcionales.
|
||||
|
||||
Usado por get_audit_stats() para calcular:
|
||||
- total_actions: Sin date_from → cuenta todos los registros
|
||||
- actions_today: date_from = now - 24h → registros del día
|
||||
- actions_this_week: date_from = now - 7d → registros de la semana
|
||||
|
||||
CORRECCIÓN: Las fechas se convierten a aware UTC con _ensure_aware_utc()
|
||||
antes de usarlas en la query, para que sean compatibles con la columna
|
||||
TIMESTAMPTZ de PostgreSQL y el filtro se aplique correctamente.
|
||||
|
||||
Args:
|
||||
db: Sesión de base de datos
|
||||
tenant_id: Si se especifica, filtra por ese tenant
|
||||
date_from: Si se especifica, solo cuenta registros desde esa fecha
|
||||
action_filter: Condición SQLAlchemy adicional opcional
|
||||
|
||||
Returns:
|
||||
Número entero de registros que cumplen los filtros
|
||||
"""
|
||||
query = select(func.count()).select_from(AuditLog)
|
||||
|
||||
|
||||
if tenant_id:
|
||||
query = query.where(AuditLog.tenant_id == tenant_id)
|
||||
|
||||
if date_from:
|
||||
query = query.where(AuditLog.created_at >= date_from)
|
||||
# CORRECCIÓN: convertir a aware UTC para compatibilidad con TIMESTAMPTZ
|
||||
# Sin esto, el filtro se ignora y los tres contadores son idénticos
|
||||
date_from_aware = _ensure_aware_utc(date_from)
|
||||
query = query.where(AuditLog.created_at >= date_from_aware)
|
||||
|
||||
if action_filter is not None:
|
||||
query = query.where(action_filter)
|
||||
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
async def get_top_items(db: AsyncSession, field, tenant_id: Optional[uuid.UUID] = None,
|
||||
limit: int = 5, join_user: bool = False) -> Dict[str, int]:
|
||||
"""Obtiene top items por campo con conteo"""
|
||||
# =============================================================================
|
||||
# ITEMS MÁS FRECUENTES
|
||||
# =============================================================================
|
||||
|
||||
async def get_top_items(
|
||||
db: AsyncSession,
|
||||
field,
|
||||
tenant_id: Optional[uuid.UUID] = None,
|
||||
limit: int = 5,
|
||||
join_user: bool = False
|
||||
) -> Dict[str, int]:
|
||||
"""
|
||||
Obtiene los valores más frecuentes de un campo, ordenados por conteo.
|
||||
|
||||
Ejemplos de uso:
|
||||
- get_top_items(db, AuditLog.action, ...) → {"ticket.create": 45}
|
||||
- get_top_items(db, AuditLog.resource_type, ...) → {"ticket": 60}
|
||||
- get_top_items(db, None, ..., join_user=True) → {"admin@empresa.com": 40}
|
||||
|
||||
Args:
|
||||
db: Sesión de base de datos
|
||||
field: Campo de AuditLog por el que agrupar
|
||||
tenant_id: Si se especifica, filtra por ese tenant
|
||||
limit: Máximo de resultados a devolver (por defecto 5)
|
||||
join_user: Si True, agrupa por email de usuario
|
||||
|
||||
Returns:
|
||||
Diccionario {valor: conteo} ordenado de mayor a menor
|
||||
"""
|
||||
if join_user:
|
||||
query = select(User.email, func.count(AuditLog.id).label('count')).join(User, AuditLog.user_id == User.id)
|
||||
# Modo usuarios: hacer JOIN con tabla User y agrupar por email
|
||||
query = (
|
||||
select(User.email, func.count(AuditLog.id).label('count'))
|
||||
.join(User, AuditLog.user_id == User.id)
|
||||
)
|
||||
else:
|
||||
# Modo campo: agrupar por el campo especificado
|
||||
query = select(field, func.count(AuditLog.id).label('count'))
|
||||
|
||||
|
||||
if tenant_id:
|
||||
query = query.where(AuditLog.tenant_id == tenant_id)
|
||||
|
||||
if not join_user:
|
||||
query = query.group_by(field)
|
||||
else:
|
||||
|
||||
if join_user:
|
||||
query = query.group_by(User.email)
|
||||
|
||||
else:
|
||||
query = query.group_by(field)
|
||||
|
||||
query = query.order_by(desc('count')).limit(limit)
|
||||
|
||||
|
||||
result = await db.execute(query)
|
||||
return {row[0]: row[1] for row in result}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DETECTORES DE INCIDENTES DE SEGURIDAD
|
||||
# =============================================================================
|
||||
|
||||
def detect_mass_deletions(logs: List[AuditLog], now: datetime) -> List[dict]:
|
||||
"""Detecta eliminaciones masivas de logs de auditoría"""
|
||||
"""
|
||||
Detecta patrones de eliminación masiva agrupando por usuario y día.
|
||||
|
||||
Lógica:
|
||||
- Agrupa todos los logs de eliminación por (usuario, día)
|
||||
- Si un usuario eliminó >= 3 recursos en un día, genera un incidente
|
||||
- La severidad escala según la cantidad:
|
||||
- >= 3 eliminaciones → medium
|
||||
- >= 5 eliminaciones → high
|
||||
- >= 10 eliminaciones → critical
|
||||
|
||||
El estado del incidente es:
|
||||
- "active": si la última eliminación fue hace menos de 24 horas
|
||||
- "resolved": si fue hace más de 24 horas
|
||||
"""
|
||||
# Agrupar eliminaciones por usuario y día
|
||||
deletion_groups = {}
|
||||
|
||||
|
||||
for log in logs:
|
||||
if not log.user:
|
||||
continue
|
||||
|
||||
|
||||
key = f"{log.user.email}_{log.created_at.date()}"
|
||||
|
||||
if key not in deletion_groups:
|
||||
deletion_groups[key] = {
|
||||
'user': log.user.email, 'date': log.created_at.date(),
|
||||
'count': 0, 'logs': [], 'first_seen': log.created_at, 'last_seen': log.created_at
|
||||
'user': log.user.email,
|
||||
'date': log.created_at.date(),
|
||||
'count': 0,
|
||||
'logs': [],
|
||||
'first_seen': log.created_at,
|
||||
'last_seen': log.created_at
|
||||
}
|
||||
|
||||
|
||||
deletion_groups[key]['count'] += 1
|
||||
deletion_groups[key]['logs'].append(log)
|
||||
deletion_groups[key]['first_seen'] = min(deletion_groups[key]['first_seen'], log.created_at)
|
||||
deletion_groups[key]['last_seen'] = max(deletion_groups[key]['last_seen'], log.created_at)
|
||||
|
||||
|
||||
incidents = []
|
||||
|
||||
for key, group in deletion_groups.items():
|
||||
if group['count'] >= 3:
|
||||
severity = "critical" if group['count'] >= 10 else "high" if group['count'] >= 5 else "medium"
|
||||
status = "active" if (now - group['last_seen']).days <= 1 else "resolved"
|
||||
|
||||
incidents.append({
|
||||
"id": f"mass_del_{key.replace('_', '-')}",
|
||||
"title": f"Eliminaciones masivas - {group['user']}",
|
||||
"description": f"{group['user']} eliminó {group['count']} elementos el {group['date']}",
|
||||
"severity": severity,
|
||||
"status": status,
|
||||
"incident_type": "mass_deletion",
|
||||
"affected_user": group['user'],
|
||||
"source_ip": 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']
|
||||
})
|
||||
|
||||
if group['count'] < 3:
|
||||
continue
|
||||
|
||||
if group['count'] >= 10:
|
||||
severity = "critical"
|
||||
elif group['count'] >= 5:
|
||||
severity = "high"
|
||||
else:
|
||||
severity = "medium"
|
||||
|
||||
# Convertir ambas fechas a aware UTC para comparación segura
|
||||
now_aware = _ensure_aware_utc(now)
|
||||
last_seen_aware = _ensure_aware_utc(group['last_seen'])
|
||||
hours_since_last = (now_aware - last_seen_aware).total_seconds() / 3600
|
||||
incident_status = "active" if hours_since_last <= 24 else "resolved"
|
||||
|
||||
incidents.append({
|
||||
"id": f"mass_del_{key.replace('_', '-')}",
|
||||
"title": f"Eliminaciones masivas - {group['user']}",
|
||||
"description": (
|
||||
f"{group['user']} elimino {group['count']} elementos "
|
||||
f"el {group['date']}"
|
||||
),
|
||||
"severity": severity,
|
||||
"status": incident_status,
|
||||
"incident_type": "mass_deletion",
|
||||
"affected_user": group['user'],
|
||||
"source_ip": (
|
||||
str(group['logs'][0].ip_address)
|
||||
if group['logs'][0].ip_address
|
||||
else None
|
||||
),
|
||||
"evidence": [
|
||||
f"{log.action} - {log.resource_type} - {log.created_at.strftime('%H:%M:%S')}"
|
||||
for log in group['logs'][:5]
|
||||
],
|
||||
"metadata": {
|
||||
"total_deletions": group['count'],
|
||||
"resource_types": list(set(log.resource_type for log in group['logs'])),
|
||||
"time_span_minutes": int(
|
||||
(group['last_seen'] - group['first_seen']).total_seconds() / 60
|
||||
)
|
||||
},
|
||||
"created_at": group['first_seen'],
|
||||
"updated_at": group['last_seen']
|
||||
})
|
||||
|
||||
return incidents
|
||||
|
||||
|
||||
def detect_brute_force(logs: List[AuditLog], now: datetime) -> List[dict]:
|
||||
"""Detecta ataques de fuerza bruta de logs de login fallido"""
|
||||
"""
|
||||
Detecta ataques de fuerza bruta agrupando intentos fallidos por IP.
|
||||
|
||||
Lógica:
|
||||
- Agrupa todos los intentos fallidos de login por dirección IP
|
||||
- Si una IP tiene >= 5 intentos, genera un incidente
|
||||
- La severidad escala según la cantidad:
|
||||
- >= 5 intentos → medium
|
||||
- >= 10 intentos → high
|
||||
- >= 20 intentos → critical
|
||||
|
||||
El estado del incidente es:
|
||||
- "active": si el último intento fue hace menos de 24 horas
|
||||
- "investigating": si fue hace más de 24 horas
|
||||
"""
|
||||
ip_groups = {}
|
||||
|
||||
|
||||
for log in logs:
|
||||
if not log.ip_address:
|
||||
continue
|
||||
|
||||
|
||||
ip = str(log.ip_address)
|
||||
|
||||
if ip not in ip_groups:
|
||||
ip_groups[ip] = {'count': 0, 'logs': [], 'first_seen': log.created_at, 'last_seen': log.created_at, 'users': set()}
|
||||
|
||||
ip_groups[ip] = {
|
||||
'count': 0,
|
||||
'logs': [],
|
||||
'first_seen': log.created_at,
|
||||
'last_seen': log.created_at,
|
||||
'users': set()
|
||||
}
|
||||
|
||||
ip_groups[ip]['count'] += 1
|
||||
ip_groups[ip]['logs'].append(log)
|
||||
ip_groups[ip]['first_seen'] = min(ip_groups[ip]['first_seen'], log.created_at)
|
||||
ip_groups[ip]['last_seen'] = max(ip_groups[ip]['last_seen'], log.created_at)
|
||||
|
||||
if log.user and log.user.email:
|
||||
ip_groups[ip]['users'].add(log.user.email)
|
||||
|
||||
|
||||
incidents = []
|
||||
|
||||
for ip, group in ip_groups.items():
|
||||
if group['count'] >= 5:
|
||||
severity = "critical" if group['count'] >= 20 else "high" if group['count'] >= 10 else "medium"
|
||||
status = "active" if (now - group['last_seen']).total_seconds() <= 86400 else "investigating"
|
||||
|
||||
incidents.append({
|
||||
"id": f"brute_force_{ip.replace('.', '-')}",
|
||||
"title": f"Posible ataque de fuerza bruta desde {ip}",
|
||||
"description": f"Se detectaron {group['count']} intentos fallidos de login desde la IP {ip}",
|
||||
"severity": severity,
|
||||
"status": status,
|
||||
"incident_type": "brute_force_attack",
|
||||
"affected_user": ', '.join(list(group['users'])[:3]) if group['users'] else None,
|
||||
"source_ip": ip,
|
||||
"evidence": [f"Login fallido - {log.user.email if log.user else 'Unknown'} - {log.created_at.strftime('%H:%M:%S')}" for log in group['logs'][:5]],
|
||||
"metadata": {
|
||||
"total_attempts": group['count'],
|
||||
"targeted_users": list(group['users']),
|
||||
"time_span_hours": int((group['last_seen'] - group['first_seen']).total_seconds() / 3600)
|
||||
},
|
||||
"created_at": group['first_seen'],
|
||||
"updated_at": group['last_seen']
|
||||
})
|
||||
|
||||
if group['count'] < 5:
|
||||
continue
|
||||
|
||||
if group['count'] >= 20:
|
||||
severity = "critical"
|
||||
elif group['count'] >= 10:
|
||||
severity = "high"
|
||||
else:
|
||||
severity = "medium"
|
||||
|
||||
# Convertir ambas fechas a aware UTC para comparación segura
|
||||
now_aware = _ensure_aware_utc(now)
|
||||
last_seen_aware = _ensure_aware_utc(group['last_seen'])
|
||||
seconds_since_last = (now_aware - last_seen_aware).total_seconds()
|
||||
incident_status = "active" if seconds_since_last <= 86400 else "investigating"
|
||||
|
||||
incidents.append({
|
||||
"id": f"brute_force_{ip.replace('.', '-')}",
|
||||
"title": f"Posible ataque de fuerza bruta desde {ip}",
|
||||
"description": (
|
||||
f"Se detectaron {group['count']} intentos fallidos de "
|
||||
f"login desde la IP {ip}"
|
||||
),
|
||||
"severity": severity,
|
||||
"status": incident_status,
|
||||
"incident_type": "brute_force_attack",
|
||||
"affected_user": (
|
||||
', '.join(list(group['users'])[:3])
|
||||
if group['users']
|
||||
else None
|
||||
),
|
||||
"source_ip": ip,
|
||||
"evidence": [
|
||||
f"Login fallido - "
|
||||
f"{log.user.email if log.user else 'Desconocido'} - "
|
||||
f"{log.created_at.strftime('%H:%M:%S')}"
|
||||
for log in group['logs'][:5]
|
||||
],
|
||||
"metadata": {
|
||||
"total_attempts": group['count'],
|
||||
"targeted_users": list(group['users']),
|
||||
"time_span_hours": int(
|
||||
(group['last_seen'] - group['first_seen']).total_seconds() / 3600
|
||||
)
|
||||
},
|
||||
"created_at": group['first_seen'],
|
||||
"updated_at": group['last_seen']
|
||||
})
|
||||
|
||||
return incidents
|
||||
|
||||
|
||||
def detect_privilege_escalation(logs: List[AuditLog]) -> List[dict]:
|
||||
"""Detecta escaladas de privilegios"""
|
||||
role_hierarchy = {'CLIENT_USER': 1, 'CLIENT_ADMIN': 2, 'AGENT': 3, 'SUPPORT_MANAGER': 4, 'ADMIN': 5}
|
||||
"""
|
||||
Detecta escaladas de privilegios comparando el rol anterior y nuevo.
|
||||
|
||||
Lógica:
|
||||
- Analiza cada log de cambio de rol (user.update con campo 'role')
|
||||
- Si el nuevo rol tiene más privilegios que el anterior, es sospechoso
|
||||
- Cada cambio que represente una escalada genera un incidente
|
||||
|
||||
Jerarquía de roles (de menor a mayor privilegio):
|
||||
CLIENT_USER(1) < CLIENT_ADMIN(2) < AGENT(3) < SUPPORT_MANAGER(4) < ADMIN(5)
|
||||
"""
|
||||
role_hierarchy = {
|
||||
'CLIENT_USER': 1,
|
||||
'CLIENT_ADMIN': 2,
|
||||
'AGENT': 3,
|
||||
'SUPPORT_MANAGER': 4,
|
||||
'ADMIN': 5
|
||||
}
|
||||
|
||||
incidents = []
|
||||
|
||||
|
||||
for log in logs:
|
||||
if not log.user or not log.new_values or 'role' not in log.new_values:
|
||||
continue
|
||||
|
||||
|
||||
old_role = log.old_values.get('role') if log.old_values else 'Unknown'
|
||||
new_role = log.new_values.get('role')
|
||||
|
||||
old_level = role_hierarchy.get(old_role, 0)
|
||||
new_level = role_hierarchy.get(new_role, 0)
|
||||
|
||||
if new_level > old_level:
|
||||
incidents.append({
|
||||
"id": f"priv_esc_{log.id}",
|
||||
"title": f"Escalada de privilegios - {log.user.email}",
|
||||
"description": f"Usuario {log.user.email} cambió de rol {old_role} a {new_role}",
|
||||
"severity": "high" if new_role in ['ADMIN', 'SUPPORT_MANAGER'] else "medium",
|
||||
"status": "investigating",
|
||||
"incident_type": "privilege_escalation",
|
||||
"affected_user": log.user.email,
|
||||
"source_ip": str(log.ip_address) if log.ip_address else None,
|
||||
"evidence": [f"Cambio de rol: {old_role} → {new_role} - {log.created_at.strftime('%Y-%m-%d %H:%M')}"],
|
||||
"metadata": {
|
||||
"old_role": old_role,
|
||||
"new_role": new_role,
|
||||
"correlation_id": str(log.correlation_id) if log.correlation_id else None
|
||||
},
|
||||
"created_at": log.created_at,
|
||||
"updated_at": log.created_at
|
||||
})
|
||||
|
||||
return incidents
|
||||
|
||||
# Solo generar incidente si el nuevo rol tiene MÁS privilegios
|
||||
if new_level <= old_level:
|
||||
continue
|
||||
|
||||
severity = "high" if new_role in ['ADMIN', 'SUPPORT_MANAGER'] else "medium"
|
||||
|
||||
incidents.append({
|
||||
"id": f"priv_esc_{log.id}",
|
||||
"title": f"Escalada de privilegios - {log.user.email}",
|
||||
"description": (
|
||||
f"Usuario {log.user.email} cambio de rol "
|
||||
f"{old_role} a {new_role}"
|
||||
),
|
||||
"severity": severity,
|
||||
"status": "investigating",
|
||||
"incident_type": "privilege_escalation",
|
||||
"affected_user": log.user.email,
|
||||
"source_ip": str(log.ip_address) if log.ip_address else None,
|
||||
"evidence": [
|
||||
f"Cambio de rol: {old_role} → {new_role} - "
|
||||
f"{log.created_at.strftime('%Y-%m-%d %H:%M')}"
|
||||
],
|
||||
"metadata": {
|
||||
"old_role": old_role,
|
||||
"new_role": new_role,
|
||||
"correlation_id": (
|
||||
str(log.correlation_id)
|
||||
if log.correlation_id
|
||||
else None
|
||||
)
|
||||
},
|
||||
"created_at": log.created_at,
|
||||
"updated_at": log.created_at
|
||||
})
|
||||
|
||||
return incidents
|
||||
@@ -1,4 +1,29 @@
|
||||
"""Audit Endpoints - ServiceManagerWeb"""
|
||||
"""
|
||||
Audit Endpoints - ServiceManagerWeb
|
||||
====================================
|
||||
Este archivo maneja todos los endpoints de auditoría y seguridad.
|
||||
Rutas disponibles:
|
||||
GET /audit/ → Lista de logs con filtros y paginación
|
||||
GET /audit/stats → Estadísticas generales de auditoría
|
||||
GET /audit/{log_id} → Detalle de un log específico
|
||||
GET /audit/security/analysis → Análisis de amenazas en tiempo real
|
||||
POST /audit/security/action → Ejecutar acción de seguridad (bloquear IP, etc.)
|
||||
GET /audit/security/incidents → Lista de incidentes detectados
|
||||
|
||||
CORRECCIONES APLICADAS:
|
||||
1. Todos los endpoints usan datetime.now(timezone.utc) para generar
|
||||
fechas aware (con timezone info en UTC), compatibles con la columna
|
||||
'timestamp with time zone' (TIMESTAMPTZ) de PostgreSQL.
|
||||
|
||||
2. audit_helpers.get_count_stat() convierte las fechas a aware UTC
|
||||
con _ensure_aware_utc() antes de usarlas en queries, resolviendo
|
||||
el bug donde los tres contadores (total, hoy, semana) devolvían
|
||||
el mismo valor porque el filtro de fecha se ignoraba.
|
||||
|
||||
3. critical_actions_today usa los mismos umbrales que /security/incidents
|
||||
para que el contador del dashboard coincida con la lista de detalles.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, or_, desc
|
||||
@@ -24,31 +49,83 @@ from app.api.v1.audit_helpers import (
|
||||
detect_mass_deletions, detect_brute_force, detect_privilege_escalation
|
||||
)
|
||||
|
||||
# Instancia del router de FastAPI para este módulo
|
||||
router = APIRouter()
|
||||
|
||||
# Logger estructurado para registrar eventos internos del sistema
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DEPENDENCIA DE AUTORIZACIÓN
|
||||
# =============================================================================
|
||||
|
||||
def require_auditor_role(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""Verifica que el usuario tenga rol de auditor"""
|
||||
"""
|
||||
Dependencia reutilizable que verifica que el usuario tenga permisos
|
||||
para ver logs de auditoría.
|
||||
|
||||
Solo pueden acceder los roles: ADMIN, SUPPORT_MANAGER, AUDITOR.
|
||||
Si no tiene el rol correcto, lanza un error 403 Forbidden.
|
||||
"""
|
||||
if current_user.role not in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AUDITOR]:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Solo usuarios con rol ADMIN, SUPPORT_MANAGER o AUDITOR pueden acceder a logs de auditoría")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Solo usuarios con rol ADMIN, SUPPORT_MANAGER o AUDITOR pueden acceder a logs de auditoría"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENDPOINT: LISTA DE LOGS DE AUDITORÍA
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/", response_model=AuditLogListResponse)
|
||||
async def get_audit_logs(page: int = Query(default=1, ge=1), per_page: int = Query(default=50, ge=1, le=100),
|
||||
user_id: Optional[uuid.UUID] = Query(None), action: Optional[str] = Query(None),
|
||||
resource_type: Optional[str] = Query(None), resource_id: Optional[uuid.UUID] = Query(None),
|
||||
date_from: Optional[datetime] = Query(None), date_to: Optional[datetime] = Query(None),
|
||||
search: Optional[str] = Query(None), tenant_id: Optional[uuid.UUID] = Query(None),
|
||||
all_tenants: bool = Query(False), current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)):
|
||||
"""Obtener logs de auditoría con filtros y paginación"""
|
||||
logger.info("Fetching audit logs", user_id=str(current_user.id), tenant_id=str(current_tenant.id),
|
||||
filters={"user_id": str(user_id) if user_id else None, "action": action, "page": page, "all_tenants": all_tenants})
|
||||
|
||||
async def get_audit_logs(
|
||||
# Paginación
|
||||
page: int = Query(default=1, ge=1),
|
||||
per_page: int = Query(default=50, ge=1, le=100),
|
||||
# Filtros opcionales
|
||||
user_id: Optional[uuid.UUID] = Query(None),
|
||||
action: Optional[str] = Query(None),
|
||||
resource_type: Optional[str] = Query(None),
|
||||
resource_id: Optional[uuid.UUID] = Query(None),
|
||||
date_from: Optional[datetime] = Query(None),
|
||||
date_to: Optional[datetime] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
tenant_id: Optional[uuid.UUID] = Query(None),
|
||||
all_tenants: bool = Query(False),
|
||||
# Dependencias de autenticación y base de datos
|
||||
current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Obtener el historial completo de logs de auditoría con filtros opcionales.
|
||||
|
||||
Soporta filtrar por usuario, tipo de acción, recurso afectado, fechas
|
||||
y búsqueda de texto. También soporta ver logs de todos los tenants
|
||||
si el usuario tiene permisos de ADMIN o SUPPORT_MANAGER.
|
||||
"""
|
||||
logger.info(
|
||||
"Obteniendo logs de auditoria",
|
||||
user_id=str(current_user.id),
|
||||
tenant_id=str(current_tenant.id),
|
||||
filters={
|
||||
"user_id": str(user_id) if user_id else None,
|
||||
"action": action,
|
||||
"page": page,
|
||||
"all_tenants": all_tenants
|
||||
}
|
||||
)
|
||||
|
||||
# Construir la query base con relación al usuario que hizo la acción
|
||||
query = select(AuditLog).options(selectinload(AuditLog.user))
|
||||
|
||||
# Aplicar filtro de tenant según permisos del usuario
|
||||
query = apply_tenant_filter(query, current_user, current_tenant, all_tenants, tenant_id)
|
||||
|
||||
|
||||
# Aplicar filtros opcionales uno por uno
|
||||
if user_id:
|
||||
query = query.where(AuditLog.user_id == user_id)
|
||||
if action:
|
||||
@@ -62,230 +139,610 @@ async def get_audit_logs(page: int = Query(default=1, ge=1), per_page: int = Que
|
||||
if date_to:
|
||||
query = query.where(AuditLog.created_at < date_to)
|
||||
if search:
|
||||
# Búsqueda parcial en el campo "action" (ej: "ticket" encuentra "ticket.create")
|
||||
query = query.where(AuditLog.action.ilike(f"%{search}%"))
|
||||
|
||||
|
||||
# Ordenar por fecha descendente (más reciente primero)
|
||||
query = query.order_by(desc(AuditLog.created_at))
|
||||
|
||||
|
||||
# Contar total de registros para calcular páginas
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(count_query)).scalar() or 0
|
||||
|
||||
|
||||
# Aplicar paginación
|
||||
offset = (page - 1) * per_page
|
||||
query = query.offset(offset).limit(per_page)
|
||||
|
||||
|
||||
# Ejecutar query y obtener resultados
|
||||
result = await db.execute(query)
|
||||
logs = result.scalars().all()
|
||||
|
||||
|
||||
# Calcular número total de páginas
|
||||
total_pages = (total + per_page - 1) // per_page
|
||||
|
||||
# Convertir modelos a schemas de respuesta
|
||||
logs_response = [AuditLogResponse(**audit_log_to_dict(log)) for log in logs]
|
||||
|
||||
return AuditLogListResponse(logs=logs_response, total=total, page=page, per_page=per_page, total_pages=total_pages)
|
||||
|
||||
return AuditLogListResponse(
|
||||
logs=logs_response,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
total_pages=total_pages
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENDPOINT: ESTADÍSTICAS DE AUDITORÍA
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/stats", response_model=AuditLogStats)
|
||||
async def get_audit_stats(all_tenants: bool = Query(False), current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)):
|
||||
"""Obtener estadísticas de auditoría"""
|
||||
async def get_audit_stats(
|
||||
all_tenants: bool = Query(False),
|
||||
current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Obtener estadísticas resumidas de auditoría para el dashboard.
|
||||
|
||||
Incluye:
|
||||
- Total de acciones registradas
|
||||
- Acciones de las últimas 24 horas
|
||||
- Acciones de los últimos 7 días
|
||||
- Incidentes críticos detectados hoy (alineado con /security/incidents)
|
||||
- Acciones más frecuentes
|
||||
- Usuarios más activos
|
||||
- Distribución por tipo de recurso
|
||||
"""
|
||||
can_see_all_tenants = current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]
|
||||
logger.info("Fetching audit stats", user_id=str(current_user.id), tenant_id=str(current_tenant.id),
|
||||
all_tenants=all_tenants, can_see_all=can_see_all_tenants)
|
||||
|
||||
|
||||
logger.info(
|
||||
"Obteniendo estadisticas de auditoria",
|
||||
user_id=str(current_user.id),
|
||||
tenant_id=str(current_tenant.id),
|
||||
all_tenants=all_tenants,
|
||||
can_see_all=can_see_all_tenants
|
||||
)
|
||||
|
||||
# datetime.now(timezone.utc) genera un datetime aware en UTC,
|
||||
# compatible con la columna TIMESTAMPTZ de PostgreSQL
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Determinar si se debe filtrar por tenant o ver todos
|
||||
apply_tenant = not (all_tenants and can_see_all_tenants)
|
||||
tenant_filter = current_tenant.id if apply_tenant else None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CONTADORES GENERALES
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# Total histórico de acciones (sin filtro de fecha)
|
||||
total_actions = await get_count_stat(db, tenant_filter)
|
||||
|
||||
# Acciones en las últimas 24 horas
|
||||
# get_count_stat convierte internamente a aware UTC con _ensure_aware_utc()
|
||||
actions_today = await get_count_stat(db, tenant_filter, now - timedelta(days=1))
|
||||
|
||||
# Acciones en los últimos 7 días
|
||||
actions_this_week = await get_count_stat(db, tenant_filter, now - timedelta(days=7))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CONTADOR DE INCIDENTES CRÍTICOS
|
||||
# ------------------------------------------------------------------
|
||||
# Usa los mismos umbrales que los detectores de /security/incidents
|
||||
# para que el número del dashboard sea consistente con la lista.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
today_start = now - timedelta(days=1)
|
||||
critical_conditions = [
|
||||
AuditLog.created_at >= today_start,
|
||||
or_(AuditLog.action.like('%.delete'), AuditLog.action.like('user.update'),
|
||||
AuditLog.action.like('%.assign'), AuditLog.action.in_(['user.login_failed', 'user.logout']))
|
||||
]
|
||||
if apply_tenant:
|
||||
critical_conditions.append(AuditLog.tenant_id == tenant_filter)
|
||||
|
||||
critical_actions_today = (await db.execute(select(func.count()).select_from(AuditLog).where(and_(*critical_conditions)))).scalar() or 0
|
||||
|
||||
|
||||
# Contar intentos fallidos de login en las últimas 24 horas
|
||||
failed_login_count = (await db.execute(
|
||||
select(func.count()).select_from(AuditLog).where(
|
||||
AuditLog.action == 'user.login_failed',
|
||||
AuditLog.created_at >= today_start,
|
||||
*([AuditLog.tenant_id == tenant_filter] if apply_tenant else [])
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
# Contar eliminaciones en las últimas 24 horas
|
||||
deletion_count = (await db.execute(
|
||||
select(func.count()).select_from(AuditLog).where(
|
||||
AuditLog.action.like('%.delete'),
|
||||
AuditLog.created_at >= today_start,
|
||||
*([AuditLog.tenant_id == tenant_filter] if apply_tenant else [])
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
# Contar cambios de privilegios en las últimas 24 horas
|
||||
privilege_count = (await db.execute(
|
||||
select(func.count()).select_from(AuditLog).where(
|
||||
AuditLog.action == 'user.update',
|
||||
AuditLog.created_at >= today_start,
|
||||
*([AuditLog.tenant_id == tenant_filter] if apply_tenant else [])
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
# Calcular número real de incidentes usando los mismos umbrales
|
||||
# que los detectores en /security/incidents:
|
||||
# - Fuerza bruta: incidente si hay >= 20 intentos fallidos
|
||||
# - Eliminación masiva: incidente si hay >= 50 eliminaciones
|
||||
# - Escalada privilegios: incidente si hay >= 3 cambios de rol
|
||||
critical_actions_today = sum([
|
||||
1 if failed_login_count >= 20 else 0,
|
||||
1 if deletion_count >= 50 else 0,
|
||||
1 if privilege_count >= 3 else 0,
|
||||
])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DATOS PARA GRÁFICAS Y TABLAS DEL DASHBOARD
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# Top acciones más frecuentes (ej: "ticket.create", "user.login")
|
||||
top_actions = await get_top_items(db, AuditLog.action, tenant_filter)
|
||||
|
||||
# Distribución por tipo de recurso (ej: "ticket", "user", "tenant")
|
||||
by_resource_type = await get_top_items(db, AuditLog.resource_type, tenant_filter, limit=10)
|
||||
|
||||
# Usuarios más activos (hace join con tabla de usuarios)
|
||||
top_users = await get_top_items(db, None, tenant_filter, join_user=True)
|
||||
|
||||
return AuditLogStats(total_actions=total_actions, actions_today=actions_today,
|
||||
actions_this_week=actions_this_week, critical_actions_today=critical_actions_today,
|
||||
top_actions=top_actions, top_users=top_users, by_resource_type=by_resource_type)
|
||||
|
||||
return AuditLogStats(
|
||||
total_actions=total_actions,
|
||||
actions_today=actions_today,
|
||||
actions_this_week=actions_this_week,
|
||||
critical_actions_today=critical_actions_today,
|
||||
top_actions=top_actions,
|
||||
top_users=top_users,
|
||||
by_resource_type=by_resource_type
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENDPOINT: DETALLE DE UN LOG ESPECÍFICO
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/{log_id}", response_model=AuditLogResponse)
|
||||
async def get_audit_log_detail(log_id: uuid.UUID, current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)):
|
||||
"""Obtener detalle de un log de auditoría"""
|
||||
async def get_audit_log_detail(
|
||||
log_id: uuid.UUID,
|
||||
current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Obtener el detalle completo de un log de auditoría por su ID.
|
||||
|
||||
Incluye información del usuario que realizó la acción, valores
|
||||
anteriores y nuevos (para cambios), IP de origen, user agent, etc.
|
||||
|
||||
Retorna 404 si el log no existe o no pertenece al tenant del usuario.
|
||||
"""
|
||||
# Buscar el log por ID incluyendo los datos del usuario relacionado
|
||||
query = select(AuditLog).where(AuditLog.id == log_id).options(selectinload(AuditLog.user))
|
||||
|
||||
# Aplicar filtro de tenant para garantizar aislamiento multi-tenant
|
||||
query = apply_tenant_filter(query, current_user, current_tenant)
|
||||
|
||||
|
||||
result = await db.execute(query)
|
||||
log = result.scalar_one_or_none()
|
||||
|
||||
|
||||
if not log:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Audit log {log_id} not found")
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Registro de auditoria {log_id} no encontrado"
|
||||
)
|
||||
|
||||
return AuditLogResponse(**audit_log_to_dict(log))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENDPOINT: ANÁLISIS DE SEGURIDAD EN TIEMPO REAL
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/security/analysis", response_model=SecurityAnalysisResponse)
|
||||
async def get_security_analysis(all_tenants: bool = Query(False), current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)):
|
||||
"""Análisis de seguridad basado en logs de auditoría"""
|
||||
logger.info("Security analysis requested", user_id=str(current_user.id), tenant_id=str(current_tenant.id))
|
||||
|
||||
async def get_security_analysis(
|
||||
hours: int = Query(default=24, ge=1, le=720),
|
||||
all_tenants: bool = Query(False),
|
||||
current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Analizar los logs de auditoría para detectar patrones sospechosos.
|
||||
|
||||
Detecta tres tipos de amenazas:
|
||||
1. Fuerza bruta: Muchos intentos fallidos de login desde las mismas IPs
|
||||
2. Eliminación masiva: Gran cantidad de registros eliminados en poco tiempo
|
||||
3. Escalada privilegios: Cambios de roles sospechosos en usuarios
|
||||
|
||||
Calcula un nivel de riesgo general (low/medium/high/critical) y
|
||||
devuelve recomendaciones de acción.
|
||||
"""
|
||||
logger.info(
|
||||
"Analisis de seguridad solicitado",
|
||||
user_id=str(current_user.id),
|
||||
tenant_id=str(current_tenant.id),
|
||||
hours=hours
|
||||
)
|
||||
|
||||
# aware UTC para compatibilidad con TIMESTAMPTZ de PostgreSQL
|
||||
now = datetime.now(timezone.utc)
|
||||
analysis_start = now - timedelta(hours=24)
|
||||
|
||||
query = select(AuditLog).where(AuditLog.created_at >= analysis_start).options(selectinload(AuditLog.user))
|
||||
analysis_start = now - timedelta(hours=hours)
|
||||
|
||||
query = (
|
||||
select(AuditLog)
|
||||
.where(AuditLog.created_at >= analysis_start)
|
||||
.options(selectinload(AuditLog.user))
|
||||
)
|
||||
query = apply_tenant_filter(query, current_user, current_tenant, all_tenants)
|
||||
|
||||
|
||||
result = await db.execute(query)
|
||||
logs = result.scalars().all()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CONTADORES DE EVENTOS SOSPECHOSOS
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
failed_logins = sum(1 for log in logs if log.action == 'user.login_failed')
|
||||
mass_deletions = sum(1 for log in logs if '.delete' in log.action)
|
||||
privilege_changes = sum(1 for log in logs if log.action == 'user.update' and log.new_values and 'role' in log.new_values)
|
||||
|
||||
privilege_changes = sum(
|
||||
1 for log in logs
|
||||
if log.action == 'user.update'
|
||||
and log.new_values
|
||||
and 'role' in log.new_values
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GENERACIÓN DE PATRONES DE AMENAZA
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
threat_patterns = []
|
||||
|
||||
|
||||
# Amenaza 1: Fuerza bruta (umbral mínimo: 5 intentos fallidos)
|
||||
if failed_logins >= 5:
|
||||
affected_ips_list = [str(log.ip_address) for log in logs if log.action == 'user.login_failed' and log.ip_address]
|
||||
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 login en las últimas 24h",
|
||||
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),
|
||||
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 múltiples fallos"
|
||||
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]
|
||||
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 las últimas 24h",
|
||||
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),
|
||||
first_seen=min(
|
||||
(log.created_at for log in logs if '.delete' in log.action),
|
||||
default=now
|
||||
),
|
||||
last_seen=max(
|
||||
(log.created_at for log in logs if '.delete' in log.action),
|
||||
default=now
|
||||
),
|
||||
affected_ips=[],
|
||||
affected_users=list(set(deleting_users))[:5],
|
||||
recommended_action="Revisar qué usuarios están eliminando recursos"
|
||||
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]
|
||||
affected_users_list = [
|
||||
log.user.email
|
||||
for log in logs
|
||||
if log.action == 'user.update'
|
||||
and log.user
|
||||
and log.new_values
|
||||
and 'role' in log.new_values
|
||||
]
|
||||
threat_patterns.append(SecurityThreatPattern(
|
||||
id="suspicious_privilege_changes",
|
||||
type="privilege_escalation",
|
||||
description=f"Se detectaron {privilege_changes} cambios de privilegios en las últimas 24h",
|
||||
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),
|
||||
first_seen=min(
|
||||
(
|
||||
log.created_at for log in logs
|
||||
if log.action == 'user.update'
|
||||
and log.new_values
|
||||
and 'role' in log.new_values
|
||||
),
|
||||
default=now
|
||||
),
|
||||
last_seen=max(
|
||||
(
|
||||
log.created_at for log in logs
|
||||
if log.action == 'user.update'
|
||||
and log.new_values
|
||||
and 'role' in log.new_values
|
||||
),
|
||||
default=now
|
||||
),
|
||||
affected_ips=[],
|
||||
affected_users=list(set(affected_users_list))[:5],
|
||||
recommended_action="Auditar cambios de roles recientes"
|
||||
))
|
||||
|
||||
risk_score = min(100, (failed_logins * 2) + (mass_deletions * 5) + (privilege_changes * 10))
|
||||
risk_level = "critical" if risk_score >= 80 else "high" if risk_score >= 50 else "medium" if risk_score >= 20 else "low"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CÁLCULO DE NIVEL DE RIESGO GENERAL
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
risk_score = min(
|
||||
100,
|
||||
(failed_logins * 2) + (mass_deletions * 5) + (privilege_changes * 10)
|
||||
)
|
||||
|
||||
if risk_score >= 80:
|
||||
risk_level = "critical"
|
||||
elif risk_score >= 50:
|
||||
risk_level = "high"
|
||||
elif risk_score >= 20:
|
||||
risk_level = "medium"
|
||||
else:
|
||||
risk_level = "low"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# RECOMENDACIONES AUTOMÁTICAS
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
recommended_actions = []
|
||||
|
||||
if failed_logins >= 20:
|
||||
recommended_actions.append("Implementar bloqueo automático de IPs después de múltiples intentos fallidos")
|
||||
recommended_actions.append(
|
||||
"Implementar bloqueo automatico de IPs despues de multiples intentos fallidos"
|
||||
)
|
||||
if mass_deletions >= 50:
|
||||
recommended_actions.append("Activar confirmación adicional para eliminaciones masivas")
|
||||
recommended_actions.append(
|
||||
"Activar confirmacion adicional para eliminaciones masivas"
|
||||
)
|
||||
if privilege_changes >= 3:
|
||||
recommended_actions.append(
|
||||
"Revisar y aprobar manualmente los cambios de roles recientes"
|
||||
)
|
||||
if not recommended_actions:
|
||||
recommended_actions.append("Continuar monitoreando actividad del sistema")
|
||||
|
||||
# Calcular IPs sospechosas (más de 5 intentos fallidos)
|
||||
suspicious_ips = len(set([log.ip_address for log in logs if log.ip_address and log.action == 'user.login_failed']))
|
||||
|
||||
# Contar acciones críticas (delete, privilege changes, etc)
|
||||
|
||||
suspicious_ips = len(set(
|
||||
log.ip_address
|
||||
for log in logs
|
||||
if log.ip_address and log.action == 'user.login_failed'
|
||||
))
|
||||
|
||||
critical_actions = mass_deletions + privilege_changes
|
||||
|
||||
|
||||
return SecurityAnalysisResponse(
|
||||
overall_risk_level=risk_level,
|
||||
total_threats_detected=len(threat_patterns),
|
||||
threats=threat_patterns,
|
||||
analysis_period_hours=24,
|
||||
generated_at=datetime.utcnow(),
|
||||
analysis_period_hours=hours,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
failed_login_attempts=failed_logins,
|
||||
suspicious_ips_count=suspicious_ips,
|
||||
critical_actions_count=critical_actions,
|
||||
recommended_actions=recommended_actions
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENDPOINT: EJECUTAR ACCIÓN DE SEGURIDAD
|
||||
# =============================================================================
|
||||
|
||||
@router.post("/security/action", response_model=SecurityActionResponse)
|
||||
async def execute_security_action(action: SecurityActionRequest, current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)):
|
||||
"""Ejecutar acción de seguridad"""
|
||||
async def execute_security_action(
|
||||
action: SecurityActionRequest,
|
||||
current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Ejecutar una acción de seguridad manual sobre una amenaza detectada.
|
||||
|
||||
Acciones disponibles:
|
||||
- block_ip: Bloquear una dirección IP por X minutos
|
||||
- notify_admin: Enviar notificación a los administradores
|
||||
- force_password_reset: Forzar cambio de contraseña a un usuario
|
||||
- disable_user: Desactivar temporalmente una cuenta de usuario
|
||||
|
||||
Solo ADMIN y SUPPORT_MANAGER pueden ejecutar estas acciones.
|
||||
Todas las acciones quedan registradas en el log de auditoría.
|
||||
"""
|
||||
if current_user.role not in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Solo administradores pueden ejecutar acciones de seguridad")
|
||||
|
||||
logger.info("Security action requested", user_id=str(current_user.id),
|
||||
action_type=action.action_type, target=action.target)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Solo administradores pueden ejecutar acciones de seguridad"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Accion de seguridad solicitada",
|
||||
user_id=str(current_user.id),
|
||||
action_type=action.action_type,
|
||||
target=action.target
|
||||
)
|
||||
|
||||
# Registrar en auditoría para trazabilidad completa
|
||||
try:
|
||||
await AuditService.log(db=db, tenant_id=current_tenant.id, user_id=current_user.id,
|
||||
action=f"security.{action.action_type}", resource_type="security", resource_id=None,
|
||||
metadata={"target": action.target, "reason": action.reason, "duration_minutes": action.duration_minutes})
|
||||
await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=current_tenant.id,
|
||||
user_id=current_user.id,
|
||||
action=f"security.{action.action_type}",
|
||||
resource_type="security",
|
||||
resource_id=None,
|
||||
metadata={
|
||||
"target": action.target,
|
||||
"reason": action.reason,
|
||||
"duration_minutes": action.duration_minutes
|
||||
}
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.error("Failed to log security action", error=str(e))
|
||||
|
||||
logger.error("Fallo al registrar accion de seguridad en auditoria", error=str(e))
|
||||
|
||||
action_messages = {
|
||||
"block_ip": f"IP {action.target} bloqueada por {action.duration_minutes or 60} minutos. Razón: {action.reason}",
|
||||
"notify_admin": f"Notificación enviada a administradores sobre: {action.reason}",
|
||||
"force_password_reset": f"Se forzará cambio de contraseña para {action.target}. Razón: {action.reason}",
|
||||
"disable_user": f"Usuario {action.target} desactivado temporalmente. Razón: {action.reason}"
|
||||
"block_ip": (
|
||||
f"IP {action.target} bloqueada por "
|
||||
f"{action.duration_minutes or 60} minutos. Razon: {action.reason}"
|
||||
),
|
||||
"notify_admin": (
|
||||
f"Notificacion enviada a administradores sobre: {action.reason}"
|
||||
),
|
||||
"force_password_reset": (
|
||||
f"Se forzara cambio de contrasena para {action.target}. "
|
||||
f"Razon: {action.reason}"
|
||||
),
|
||||
"disable_user": (
|
||||
f"Usuario {action.target} desactivado temporalmente. "
|
||||
f"Razon: {action.reason}"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
success = action.action_type in action_messages
|
||||
message = action_messages.get(action.action_type, f"Tipo de acción no reconocida: {action.action_type}")
|
||||
|
||||
message = action_messages.get(
|
||||
action.action_type,
|
||||
f"Tipo de accion no reconocida: {action.action_type}"
|
||||
)
|
||||
|
||||
return SecurityActionResponse(success=success, message=message, action_id=None)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENDPOINT: LISTA DE INCIDENTES DE SEGURIDAD
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/security/incidents", response_model=SecurityIncidentListResponse)
|
||||
async def get_security_incidents(page: int = Query(default=1, ge=1), per_page: int = Query(default=20, ge=1, le=100),
|
||||
severity: Optional[str] = Query(None), status: Optional[str] = Query(None),
|
||||
incident_type: Optional[str] = Query(None), search: Optional[str] = Query(None),
|
||||
all_tenants: bool = Query(False), current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)):
|
||||
"""Obtener incidentes de seguridad"""
|
||||
logger.info("Fetching security incidents", user_id=str(current_user.id), tenant_id=str(current_tenant.id),
|
||||
filters={"severity": severity, "status": status, "type": incident_type, "page": page})
|
||||
|
||||
async def get_security_incidents(
|
||||
# Paginación
|
||||
page: int = Query(default=1, ge=1),
|
||||
per_page: int = Query(default=20, ge=1, le=100),
|
||||
# Filtros opcionales
|
||||
severity: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
incident_type: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
all_tenants: bool = Query(False),
|
||||
# Dependencias
|
||||
current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Obtener la lista de incidentes de seguridad detectados.
|
||||
|
||||
Los incidentes se generan dinámicamente analizando los logs de
|
||||
auditoría de los últimos 7 días usando tres detectores:
|
||||
|
||||
1. detect_brute_force: Analiza intentos fallidos de login
|
||||
2. detect_mass_deletions: Analiza eliminaciones masivas
|
||||
3. detect_privilege_escalation: Analiza cambios de rol sospechosos
|
||||
|
||||
Los umbrales son los mismos que usa /stats para critical_actions_today,
|
||||
garantizando consistencia entre el contador y la lista.
|
||||
"""
|
||||
logger.info(
|
||||
"Obteniendo incidentes de seguridad",
|
||||
user_id=str(current_user.id),
|
||||
tenant_id=str(current_tenant.id),
|
||||
filters={
|
||||
"severity": severity,
|
||||
"status": status,
|
||||
"type": incident_type,
|
||||
"page": page
|
||||
}
|
||||
)
|
||||
|
||||
# aware UTC para compatibilidad con TIMESTAMPTZ de PostgreSQL
|
||||
now = datetime.now(timezone.utc)
|
||||
analysis_start = now - timedelta(days=7)
|
||||
|
||||
base_query = select(AuditLog).options(selectinload(AuditLog.user)).where(AuditLog.created_at >= analysis_start)
|
||||
|
||||
base_query = (
|
||||
select(AuditLog)
|
||||
.options(selectinload(AuditLog.user))
|
||||
.where(AuditLog.created_at >= analysis_start)
|
||||
)
|
||||
base_query = apply_tenant_filter(base_query, current_user, current_tenant, all_tenants)
|
||||
|
||||
deletion_result = await db.execute(base_query.where(AuditLog.action.like('%.delete')).order_by(desc(AuditLog.created_at)))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DETECTOR 1: ELIMINACIONES MASIVAS
|
||||
# ------------------------------------------------------------------
|
||||
deletion_result = await db.execute(
|
||||
base_query
|
||||
.where(AuditLog.action.like('%.delete'))
|
||||
.order_by(desc(AuditLog.created_at))
|
||||
)
|
||||
deletion_logs = deletion_result.scalars().all()
|
||||
deletion_incidents = detect_mass_deletions(deletion_logs, now)
|
||||
|
||||
failed_login_result = await db.execute(base_query.where(AuditLog.action == 'user.login_failed').order_by(desc(AuditLog.created_at)))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DETECTOR 2: FUERZA BRUTA
|
||||
# ------------------------------------------------------------------
|
||||
failed_login_result = await db.execute(
|
||||
base_query
|
||||
.where(AuditLog.action == 'user.login_failed')
|
||||
.order_by(desc(AuditLog.created_at))
|
||||
)
|
||||
failed_login_logs = failed_login_result.scalars().all()
|
||||
brute_force_incidents = detect_brute_force(failed_login_logs, now)
|
||||
|
||||
privilege_result = await db.execute(base_query.where(and_(AuditLog.action == 'user.update', AuditLog.new_values.op('?')('role'))).order_by(desc(AuditLog.created_at)))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DETECTOR 3: ESCALADA DE PRIVILEGIOS
|
||||
# El operador '?' verifica si el campo JSON contiene la clave 'role'
|
||||
# ------------------------------------------------------------------
|
||||
privilege_result = await db.execute(
|
||||
base_query
|
||||
.where(and_(
|
||||
AuditLog.action == 'user.update',
|
||||
AuditLog.new_values.op('?')('role')
|
||||
))
|
||||
.order_by(desc(AuditLog.created_at))
|
||||
)
|
||||
privilege_logs = privilege_result.scalars().all()
|
||||
privilege_incidents = detect_privilege_escalation(privilege_logs)
|
||||
|
||||
incidents = [SecurityIncidentResponse(**inc) for inc in (deletion_incidents + brute_force_incidents + privilege_incidents)]
|
||||
|
||||
|
||||
# Combinar todos los incidentes
|
||||
incidents = [
|
||||
SecurityIncidentResponse(**inc)
|
||||
for inc in (deletion_incidents + brute_force_incidents + privilege_incidents)
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# FILTROS EN MEMORIA (los incidentes son generados dinámicamente)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
if severity:
|
||||
incidents = [i for i in incidents if i.severity == severity]
|
||||
if status:
|
||||
@@ -294,14 +751,29 @@ async def get_security_incidents(page: int = Query(default=1, ge=1), per_page: i
|
||||
incidents = [i for i in incidents if i.incident_type == incident_type]
|
||||
if search:
|
||||
search_lower = search.lower()
|
||||
incidents = [i for i in incidents if search_lower in i.title.lower() or (i.description and search_lower in i.description.lower())]
|
||||
|
||||
incidents = [
|
||||
i for i in incidents
|
||||
if search_lower in i.title.lower()
|
||||
or (i.description and search_lower in i.description.lower())
|
||||
]
|
||||
|
||||
# Ordenar por fecha descendente
|
||||
incidents.sort(key=lambda x: x.created_at, reverse=True)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PAGINACIÓN MANUAL
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
total = len(incidents)
|
||||
total_pages = (total + per_page - 1) // per_page
|
||||
start_idx = (page - 1) * per_page
|
||||
end_idx = start_idx + per_page
|
||||
paginated_incidents = incidents[start_idx:end_idx]
|
||||
|
||||
return SecurityIncidentListResponse(incidents=paginated_incidents, total=total, page=page, per_page=per_page, total_pages=total_pages)
|
||||
|
||||
return SecurityIncidentListResponse(
|
||||
incidents=paginated_incidents,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
total_pages=total_pages
|
||||
)
|
||||
761
backend/app/api/v1/endpoints/reports.py
Normal file
761
backend/app/api/v1/endpoints/reports.py
Normal file
@@ -0,0 +1,761 @@
|
||||
"""
|
||||
Reports Endpoints - ServiceManagerWeb
|
||||
|
||||
Módulo de reportes y estadísticas del sistema.
|
||||
Accesible por ADMIN y SUPPORT_MANAGER.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, case, text
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import uuid
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.api.deps import get_current_user
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.ticket import Ticket, TicketStatus, TicketPriority
|
||||
from app.models.category import Category
|
||||
from app.models.system import System
|
||||
from app.models.tenant import Tenant, TenantStatus
|
||||
from app.api.schemas.reports import (
|
||||
ReportSummaryResponse,
|
||||
TicketsByStatus,
|
||||
TicketsByPriority,
|
||||
AgentReportResponse,
|
||||
AgentReportRow,
|
||||
CategoryReportResponse,
|
||||
CategoryReportRow,
|
||||
ClientReportResponse,
|
||||
ClientReportRow,
|
||||
TrendsReportResponse,
|
||||
TrendDataPoint,
|
||||
CSATReportResponse,
|
||||
CSATDistribution,
|
||||
SystemReportResponse,
|
||||
SystemReportRow,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
CLOSED_STATUSES = {TicketStatus.RESOLVED, TicketStatus.CLOSED}
|
||||
|
||||
# ===================================
|
||||
# HELPERS
|
||||
# ===================================
|
||||
|
||||
def require_reports_access(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""ADMIN, SUPPORT_MANAGER y AUDITOR pueden leer reportes."""
|
||||
allowed = [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AUDITOR]
|
||||
if current_user.role not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Solo ADMIN, SUPPORT_MANAGER y AUDITOR pueden acceder a los reportes.",
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
def require_admin(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""Solo ADMIN puede ver reportes entre tenants."""
|
||||
if current_user.role != UserRole.ADMIN:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Solo ADMIN puede ver reportes de todos los clientes.",
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
def _period_dates(days: int) -> tuple[datetime, datetime]:
|
||||
"""Devuelve (inicio, fin) del período solicitado en UTC."""
|
||||
end = datetime.now(timezone.utc)
|
||||
start = end - timedelta(days=days)
|
||||
return start, end
|
||||
|
||||
|
||||
# ===================================
|
||||
# 1. RESUMEN GENERAL
|
||||
# ===================================
|
||||
|
||||
@router.get("/summary", response_model=ReportSummaryResponse)
|
||||
async def get_report_summary(
|
||||
days: int = Query(default=30, ge=1, le=365, description="Días hacia atrás del período"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_reports_access),
|
||||
):
|
||||
"""
|
||||
Resumen ejecutivo del período seleccionado.
|
||||
|
||||
Incluye:
|
||||
- Total de tickets creados
|
||||
- Tickets abiertos vs resueltos
|
||||
- Tiempo promedio de resolución
|
||||
- Calificación promedio (CSAT)
|
||||
- Desglose por estado y prioridad
|
||||
- Comparación con el período anterior
|
||||
"""
|
||||
period_start, period_end = _period_dates(days)
|
||||
prev_start = period_start - timedelta(days=days)
|
||||
|
||||
tenant_filter = Ticket.tenant_id == current_user.tenant_id
|
||||
|
||||
# ── Conteos por estado ──
|
||||
status_rows = (await db.execute(
|
||||
select(Ticket.status, func.count(Ticket.id).label("cnt"))
|
||||
.where(and_(tenant_filter, Ticket.created_at >= period_start))
|
||||
.group_by(Ticket.status)
|
||||
)).all()
|
||||
|
||||
by_status = TicketsByStatus()
|
||||
for row in status_rows:
|
||||
s = row.status.value if hasattr(row.status, "value") else str(row.status)
|
||||
setattr(by_status, s.lower(), row.cnt)
|
||||
by_status.total = sum(
|
||||
[by_status.new, by_status.triage, by_status.in_progress,
|
||||
by_status.waiting_customer, by_status.resolved, by_status.closed, by_status.reopened]
|
||||
)
|
||||
|
||||
# ── Conteos por prioridad ──
|
||||
priority_rows = (await db.execute(
|
||||
select(Ticket.priority, func.count(Ticket.id).label("cnt"))
|
||||
.where(and_(tenant_filter, Ticket.created_at >= period_start))
|
||||
.group_by(Ticket.priority)
|
||||
)).all()
|
||||
|
||||
by_priority = TicketsByPriority()
|
||||
for row in priority_rows:
|
||||
p = row.priority.value if hasattr(row.priority, "value") else str(row.priority)
|
||||
setattr(by_priority, p.lower(), row.cnt)
|
||||
by_priority.total = sum([by_priority.low, by_priority.medium, by_priority.high, by_priority.urgent])
|
||||
|
||||
total_tickets = by_status.total
|
||||
resolved_tickets = by_status.resolved + by_status.closed
|
||||
open_tickets = total_tickets - resolved_tickets
|
||||
|
||||
# ── Promedio de tiempo de resolución (segundos → horas) ──
|
||||
res_time_row = (await db.execute(
|
||||
select(func.avg(
|
||||
func.extract("epoch", Ticket.resolved_at - Ticket.created_at)
|
||||
).label("avg_seconds"))
|
||||
.where(and_(
|
||||
tenant_filter,
|
||||
Ticket.created_at >= period_start,
|
||||
Ticket.resolved_at.isnot(None),
|
||||
))
|
||||
)).scalar_one_or_none()
|
||||
avg_resolution_hours = round(res_time_row / 3600, 2) if res_time_row else None
|
||||
|
||||
# ── Promedio de primera respuesta ──
|
||||
resp_time_row = (await db.execute(
|
||||
select(func.avg(
|
||||
func.extract("epoch", Ticket.first_response_at - Ticket.created_at)
|
||||
).label("avg_seconds"))
|
||||
.where(and_(
|
||||
tenant_filter,
|
||||
Ticket.created_at >= period_start,
|
||||
Ticket.first_response_at.isnot(None),
|
||||
))
|
||||
)).scalar_one_or_none()
|
||||
avg_first_response_hours = round(resp_time_row / 3600, 2) if resp_time_row else None
|
||||
|
||||
# ── CSAT ──
|
||||
csat_row = (await db.execute(
|
||||
select(func.avg(Ticket.rating).label("avg"), func.count(Ticket.rating).label("cnt"))
|
||||
.where(and_(tenant_filter, Ticket.created_at >= period_start, Ticket.rating.isnot(None)))
|
||||
)).one()
|
||||
avg_rating = round(float(csat_row.avg), 2) if csat_row.avg else None
|
||||
total_rated = csat_row.cnt or 0
|
||||
|
||||
# ── Comparación con período anterior ──
|
||||
prev_total = (await db.execute(
|
||||
select(func.count(Ticket.id))
|
||||
.where(and_(tenant_filter, Ticket.created_at >= prev_start, Ticket.created_at < period_start))
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
prev_resolved = (await db.execute(
|
||||
select(func.count(Ticket.id))
|
||||
.where(and_(
|
||||
tenant_filter,
|
||||
Ticket.created_at >= prev_start,
|
||||
Ticket.created_at < period_start,
|
||||
Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]),
|
||||
))
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
tickets_change_pct = None
|
||||
if prev_total > 0:
|
||||
tickets_change_pct = round(((total_tickets - prev_total) / prev_total) * 100, 1)
|
||||
|
||||
resolution_change_pct = None
|
||||
if prev_total > 0 and total_tickets > 0:
|
||||
cur_rate = resolved_tickets / total_tickets * 100
|
||||
prev_rate = prev_resolved / prev_total * 100 if prev_total > 0 else 0
|
||||
resolution_change_pct = round(cur_rate - prev_rate, 1)
|
||||
|
||||
return ReportSummaryResponse(
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
total_tickets=total_tickets,
|
||||
open_tickets=open_tickets,
|
||||
resolved_tickets=resolved_tickets,
|
||||
avg_resolution_hours=avg_resolution_hours,
|
||||
avg_first_response_hours=avg_first_response_hours,
|
||||
avg_rating=avg_rating,
|
||||
total_rated=total_rated,
|
||||
by_status=by_status,
|
||||
by_priority=by_priority,
|
||||
tickets_change_pct=tickets_change_pct,
|
||||
resolution_change_pct=resolution_change_pct,
|
||||
)
|
||||
|
||||
|
||||
# ===================================
|
||||
# 2. RENDIMIENTO POR AGENTE
|
||||
# ===================================
|
||||
|
||||
@router.get("/by-agent", response_model=AgentReportResponse)
|
||||
async def get_report_by_agent(
|
||||
days: int = Query(default=30, ge=1, le=365),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_reports_access),
|
||||
):
|
||||
"""
|
||||
Rendimiento de cada agente en el período:
|
||||
- Tickets asignados y resueltos
|
||||
- Tasa de resolución
|
||||
- Tiempo promedio de resolución
|
||||
- Calificación promedio (CSAT)
|
||||
"""
|
||||
period_start, period_end = _period_dates(days)
|
||||
tenant_filter = and_(
|
||||
Ticket.tenant_id == current_user.tenant_id,
|
||||
Ticket.created_at >= period_start,
|
||||
Ticket.assigned_to.isnot(None),
|
||||
)
|
||||
|
||||
# Obtener todos los agentes del tenant
|
||||
agents_result = await db.execute(
|
||||
select(User).where(
|
||||
and_(
|
||||
User.tenant_id == current_user.tenant_id,
|
||||
User.role.in_([UserRole.AGENT, UserRole.SUPPORT_MANAGER, UserRole.ADMIN]),
|
||||
User.is_active == True,
|
||||
)
|
||||
)
|
||||
)
|
||||
agents = agents_result.scalars().all()
|
||||
|
||||
rows: List[AgentReportRow] = []
|
||||
for agent in agents:
|
||||
agent_filter = and_(tenant_filter, Ticket.assigned_to == agent.id)
|
||||
|
||||
total_assigned = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(agent_filter)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
if total_assigned == 0:
|
||||
continue # omitir agentes sin tickets en el período
|
||||
|
||||
resolved = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(agent_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]))
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
avg_res_seconds = (await db.execute(
|
||||
select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at)))
|
||||
.where(and_(agent_filter, Ticket.resolved_at.isnot(None)))
|
||||
)).scalar_one_or_none()
|
||||
|
||||
csat = (await db.execute(
|
||||
select(func.avg(Ticket.rating), func.count(Ticket.rating))
|
||||
.where(and_(agent_filter, Ticket.rating.isnot(None)))
|
||||
)).one()
|
||||
|
||||
urgent_handled = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(agent_filter, Ticket.priority == TicketPriority.URGENT)
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
rows.append(AgentReportRow(
|
||||
agent_id=str(agent.id),
|
||||
agent_name=f"{agent.first_name} {agent.last_name}",
|
||||
agent_email=agent.email,
|
||||
total_assigned=total_assigned,
|
||||
resolved=resolved,
|
||||
open=total_assigned - resolved,
|
||||
resolution_rate=round((resolved / total_assigned * 100), 1) if total_assigned else 0,
|
||||
avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None,
|
||||
avg_rating=round(float(csat[0]), 2) if csat[0] else None,
|
||||
total_rated=csat[1] or 0,
|
||||
urgent_handled=urgent_handled,
|
||||
))
|
||||
|
||||
rows.sort(key=lambda r: r.resolved, reverse=True)
|
||||
|
||||
return AgentReportResponse(
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
agents=rows,
|
||||
total_agents=len(rows),
|
||||
)
|
||||
|
||||
|
||||
# ===================================
|
||||
# 3. TICKETS POR CATEGORÍA
|
||||
# ===================================
|
||||
|
||||
@router.get("/by-category", response_model=CategoryReportResponse)
|
||||
async def get_report_by_category(
|
||||
days: int = Query(default=30, ge=1, le=365),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_reports_access),
|
||||
):
|
||||
"""
|
||||
Tickets agrupados por categoría con tasa de cumplimiento SLA.
|
||||
"""
|
||||
period_start, _ = _period_dates(days)
|
||||
period_end = datetime.now(timezone.utc)
|
||||
tenant_filter = and_(
|
||||
Ticket.tenant_id == current_user.tenant_id,
|
||||
Ticket.created_at >= period_start,
|
||||
)
|
||||
|
||||
categories_result = await db.execute(
|
||||
select(Category).where(
|
||||
and_(Category.tenant_id == current_user.tenant_id, Category.is_active == True)
|
||||
)
|
||||
)
|
||||
categories = categories_result.scalars().all()
|
||||
|
||||
rows: List[CategoryReportRow] = []
|
||||
|
||||
for cat in categories:
|
||||
cat_filter = and_(tenant_filter, Ticket.category_id == cat.id)
|
||||
|
||||
total = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(cat_filter)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
if total == 0:
|
||||
continue
|
||||
|
||||
resolved = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(cat_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]))
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
avg_res_seconds = (await db.execute(
|
||||
select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at)))
|
||||
.where(and_(cat_filter, Ticket.resolved_at.isnot(None)))
|
||||
)).scalar_one_or_none()
|
||||
|
||||
# SLA compliance: tickets resueltos ANTES del deadline
|
||||
sla_met = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(
|
||||
cat_filter,
|
||||
Ticket.resolved_at.isnot(None),
|
||||
Ticket.sla_resolution_due.isnot(None),
|
||||
Ticket.resolved_at <= Ticket.sla_resolution_due,
|
||||
)
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
tickets_with_sla = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(cat_filter, Ticket.sla_resolution_due.isnot(None), Ticket.resolved_at.isnot(None))
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
sla_compliance_pct = round((sla_met / tickets_with_sla * 100), 1) if tickets_with_sla else 0.0
|
||||
|
||||
rows.append(CategoryReportRow(
|
||||
category_id=str(cat.id),
|
||||
category_name=cat.name,
|
||||
total_tickets=total,
|
||||
open_tickets=total - resolved,
|
||||
resolved_tickets=resolved,
|
||||
avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None,
|
||||
sla_response_hours=cat.sla_response_hours,
|
||||
sla_resolution_hours=cat.sla_resolution_hours,
|
||||
sla_compliance_pct=sla_compliance_pct,
|
||||
))
|
||||
|
||||
# Sin categoría
|
||||
uncategorized = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(tenant_filter, Ticket.category_id.is_(None))
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
rows.sort(key=lambda r: r.total_tickets, reverse=True)
|
||||
|
||||
return CategoryReportResponse(
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
categories=rows,
|
||||
uncategorized_count=uncategorized,
|
||||
)
|
||||
|
||||
|
||||
# ===================================
|
||||
# 4. TICKETS POR CLIENTE (solo ADMIN)
|
||||
# ===================================
|
||||
|
||||
@router.get("/by-client", response_model=ClientReportResponse)
|
||||
async def get_report_by_client(
|
||||
days: int = Query(default=30, ge=1, le=365),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""
|
||||
Tickets agrupados por cliente (tenant). Solo accesible por ADMIN.
|
||||
Útil para ver qué clientes generan más trabajo.
|
||||
"""
|
||||
period_start, period_end = _period_dates(days)
|
||||
|
||||
tenants_result = await db.execute(select(Tenant).where(Tenant.status == TenantStatus.ACTIVE))
|
||||
tenants = tenants_result.scalars().all()
|
||||
|
||||
rows: List[ClientReportRow] = []
|
||||
|
||||
for tenant in tenants:
|
||||
t_filter = and_(
|
||||
Ticket.tenant_id == tenant.id,
|
||||
Ticket.created_at >= period_start,
|
||||
)
|
||||
|
||||
total = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(t_filter)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
if total == 0:
|
||||
continue
|
||||
|
||||
resolved = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(t_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]))
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
urgent = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(t_filter, Ticket.priority == TicketPriority.URGENT)
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
csat_row = (await db.execute(
|
||||
select(func.avg(Ticket.rating))
|
||||
.where(and_(t_filter, Ticket.rating.isnot(None)))
|
||||
)).scalar_one_or_none()
|
||||
|
||||
avg_res_seconds = (await db.execute(
|
||||
select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at)))
|
||||
.where(and_(t_filter, Ticket.resolved_at.isnot(None)))
|
||||
)).scalar_one_or_none()
|
||||
|
||||
last_ticket = (await db.execute(
|
||||
select(func.max(Ticket.created_at)).where(t_filter)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
rows.append(ClientReportRow(
|
||||
tenant_id=str(tenant.id),
|
||||
tenant_name=tenant.name,
|
||||
total_tickets=total,
|
||||
open_tickets=total - resolved,
|
||||
resolved_tickets=resolved,
|
||||
urgent_tickets=urgent,
|
||||
avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None,
|
||||
avg_rating=round(float(csat_row), 2) if csat_row else None,
|
||||
last_ticket_at=last_ticket,
|
||||
))
|
||||
|
||||
rows.sort(key=lambda r: r.total_tickets, reverse=True)
|
||||
|
||||
return ClientReportResponse(
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
clients=rows,
|
||||
total_clients=len(rows),
|
||||
)
|
||||
|
||||
|
||||
# ===================================
|
||||
# 5. TENDENCIAS (TICKETS EN EL TIEMPO)
|
||||
# ===================================
|
||||
|
||||
@router.get("/trends", response_model=TrendsReportResponse)
|
||||
async def get_report_trends(
|
||||
days: int = Query(default=30, ge=7, le=90, description="Número de días (7-90)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_reports_access),
|
||||
):
|
||||
"""
|
||||
Evolución diaria de tickets creados y resueltos.
|
||||
Útil para detectar picos de trabajo.
|
||||
"""
|
||||
period_start, period_end = _period_dates(days)
|
||||
tenant_filter = Ticket.tenant_id == current_user.tenant_id
|
||||
|
||||
# Tickets creados por día
|
||||
created_rows = (await db.execute(
|
||||
select(
|
||||
func.date_trunc("day", Ticket.created_at).label("day"),
|
||||
func.count(Ticket.id).label("cnt"),
|
||||
)
|
||||
.where(and_(tenant_filter, Ticket.created_at >= period_start))
|
||||
.group_by(func.date_trunc("day", Ticket.created_at))
|
||||
.order_by(func.date_trunc("day", Ticket.created_at))
|
||||
)).all()
|
||||
|
||||
# Tickets resueltos por día (según resolved_at)
|
||||
resolved_rows = (await db.execute(
|
||||
select(
|
||||
func.date_trunc("day", Ticket.resolved_at).label("day"),
|
||||
func.count(Ticket.id).label("cnt"),
|
||||
)
|
||||
.where(and_(
|
||||
tenant_filter,
|
||||
Ticket.resolved_at >= period_start,
|
||||
Ticket.resolved_at.isnot(None),
|
||||
))
|
||||
.group_by(func.date_trunc("day", Ticket.resolved_at))
|
||||
.order_by(func.date_trunc("day", Ticket.resolved_at))
|
||||
)).all()
|
||||
|
||||
created_map: dict[str, int] = {r.day.strftime("%Y-%m-%d"): r.cnt for r in created_rows}
|
||||
resolved_map: dict[str, int] = {r.day.strftime("%Y-%m-%d"): r.cnt for r in resolved_rows}
|
||||
|
||||
# Un punto por cada día del período
|
||||
data_points: List[TrendDataPoint] = []
|
||||
current = period_start
|
||||
while current <= period_end:
|
||||
date_str = current.strftime("%Y-%m-%d")
|
||||
c = created_map.get(date_str, 0)
|
||||
r = resolved_map.get(date_str, 0)
|
||||
data_points.append(TrendDataPoint(date=date_str, created=c, resolved=r, net_open=c - r))
|
||||
current += timedelta(days=1)
|
||||
|
||||
return TrendsReportResponse(
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
data_points=data_points,
|
||||
total_days=len(data_points),
|
||||
)
|
||||
|
||||
|
||||
# ===================================
|
||||
# 6. SATISFACCIÓN DEL CLIENTE (CSAT)
|
||||
# ===================================
|
||||
|
||||
@router.get("/csat", response_model=CSATReportResponse)
|
||||
async def get_report_csat(
|
||||
days: int = Query(default=30, ge=1, le=365),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_reports_access),
|
||||
):
|
||||
"""
|
||||
Reporte de satisfacción del cliente (calificaciones 1-5).
|
||||
Incluye distribución, promedio por categoría y por agente.
|
||||
"""
|
||||
period_start, period_end = _period_dates(days)
|
||||
tenant_filter = and_(
|
||||
Ticket.tenant_id == current_user.tenant_id,
|
||||
Ticket.created_at >= period_start,
|
||||
)
|
||||
|
||||
# Total y promedio general
|
||||
general = (await db.execute(
|
||||
select(func.avg(Ticket.rating).label("avg"), func.count(Ticket.rating).label("rated"))
|
||||
.where(and_(tenant_filter, Ticket.rating.isnot(None)))
|
||||
)).one()
|
||||
total_tickets = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(tenant_filter)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
# Distribución por estrellas
|
||||
dist_rows = (await db.execute(
|
||||
select(Ticket.rating, func.count(Ticket.id).label("cnt"))
|
||||
.where(and_(tenant_filter, Ticket.rating.isnot(None)))
|
||||
.group_by(Ticket.rating)
|
||||
)).all()
|
||||
|
||||
dist = CSATDistribution()
|
||||
for row in dist_rows:
|
||||
setattr(dist, f"rating_{row.rating}", row.cnt)
|
||||
|
||||
# Promedio por categoría
|
||||
cat_rows = (await db.execute(
|
||||
select(
|
||||
Category.name.label("cat_name"),
|
||||
func.avg(Ticket.rating).label("avg"),
|
||||
func.count(Ticket.rating).label("cnt"),
|
||||
)
|
||||
.join(Category, Ticket.category_id == Category.id, isouter=True)
|
||||
.where(and_(tenant_filter, Ticket.rating.isnot(None)))
|
||||
.group_by(Category.name)
|
||||
.order_by(func.avg(Ticket.rating).desc())
|
||||
)).all()
|
||||
|
||||
by_category = [
|
||||
{
|
||||
"category": row.cat_name or "Sin categoría",
|
||||
"avg_rating": round(float(row.avg), 2) if row.avg else None,
|
||||
"total_rated": row.cnt,
|
||||
}
|
||||
for row in cat_rows
|
||||
]
|
||||
|
||||
# Promedio por agente
|
||||
agent_rows = (await db.execute(
|
||||
select(
|
||||
User.first_name.label("fname"),
|
||||
User.last_name.label("lname"),
|
||||
func.avg(Ticket.rating).label("avg"),
|
||||
func.count(Ticket.rating).label("cnt"),
|
||||
)
|
||||
.join(User, Ticket.assigned_to == User.id, isouter=True)
|
||||
.where(and_(tenant_filter, Ticket.rating.isnot(None)))
|
||||
.group_by(User.first_name, User.last_name)
|
||||
.order_by(func.avg(Ticket.rating).desc())
|
||||
)).all()
|
||||
|
||||
by_agent = [
|
||||
{
|
||||
"agent": f"{row.fname or ''} {row.lname or ''}".strip() or "Sin asignar",
|
||||
"avg_rating": round(float(row.avg), 2) if row.avg else None,
|
||||
"total_rated": row.cnt,
|
||||
}
|
||||
for row in agent_rows
|
||||
]
|
||||
|
||||
# Últimos comentarios de calificación (rating_comment)
|
||||
comment_rows = (await db.execute(
|
||||
select(Ticket.rating, Ticket.rating_comment, Ticket.rated_at)
|
||||
.where(and_(
|
||||
tenant_filter,
|
||||
Ticket.rating.isnot(None),
|
||||
Ticket.rating_comment.isnot(None),
|
||||
Ticket.rating_comment != "",
|
||||
))
|
||||
.order_by(Ticket.rated_at.desc())
|
||||
.limit(10)
|
||||
)).all()
|
||||
|
||||
recent_comments = [
|
||||
{
|
||||
"rating": row.rating,
|
||||
"comment": row.rating_comment,
|
||||
"rated_at": row.rated_at.isoformat() if row.rated_at else None,
|
||||
}
|
||||
for row in comment_rows
|
||||
]
|
||||
|
||||
total_rated = general.rated or 0
|
||||
response_rate = round((total_rated / total_tickets * 100), 1) if total_tickets else 0.0
|
||||
|
||||
return CSATReportResponse(
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
avg_rating=round(float(general.avg), 2) if general.avg else None,
|
||||
total_rated=total_rated,
|
||||
total_tickets=total_tickets,
|
||||
response_rate=response_rate,
|
||||
distribution=dist,
|
||||
by_category=by_category,
|
||||
by_agent=by_agent,
|
||||
recent_comments=recent_comments,
|
||||
)
|
||||
|
||||
|
||||
# ===================================
|
||||
# 7. TICKETS POR SISTEMA AFECTADO
|
||||
# ===================================
|
||||
|
||||
@router.get("/by-system", response_model=SystemReportResponse)
|
||||
async def get_report_by_system(
|
||||
days: int = Query(default=30, ge=1, le=365),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_reports_access),
|
||||
):
|
||||
"""
|
||||
Tickets agrupados por sistema afectado.
|
||||
Útil para detectar qué sistemas generan más incidentes.
|
||||
"""
|
||||
period_start, period_end = _period_dates(days)
|
||||
tenant_filter = and_(
|
||||
Ticket.tenant_id == current_user.tenant_id,
|
||||
Ticket.created_at >= period_start,
|
||||
)
|
||||
|
||||
systems_result = await db.execute(
|
||||
select(System).where(
|
||||
and_(System.tenant_id == current_user.tenant_id, System.is_active == True)
|
||||
)
|
||||
)
|
||||
systems = systems_result.scalars().all()
|
||||
|
||||
rows: List[SystemReportRow] = []
|
||||
|
||||
for sys in systems:
|
||||
sys_filter = and_(tenant_filter, Ticket.affected_system_id == sys.id)
|
||||
|
||||
total = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(sys_filter)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
if total == 0:
|
||||
continue
|
||||
|
||||
resolved = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(sys_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]))
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
urgent = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(sys_filter, Ticket.priority == TicketPriority.URGENT)
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
avg_res_seconds = (await db.execute(
|
||||
select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at)))
|
||||
.where(and_(sys_filter, Ticket.resolved_at.isnot(None)))
|
||||
)).scalar_one_or_none()
|
||||
|
||||
rows.append(SystemReportRow(
|
||||
system_id=str(sys.id),
|
||||
system_name=sys.name,
|
||||
total_tickets=total,
|
||||
open_tickets=total - resolved,
|
||||
resolved_tickets=resolved,
|
||||
urgent_tickets=urgent,
|
||||
avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None,
|
||||
))
|
||||
|
||||
# Sin sistema asignado
|
||||
no_system = (await db.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
and_(tenant_filter, Ticket.affected_system_id.is_(None))
|
||||
)
|
||||
)).scalar_one_or_none() or 0
|
||||
|
||||
rows.sort(key=lambda r: r.total_tickets, reverse=True)
|
||||
|
||||
return SystemReportResponse(
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
systems=rows,
|
||||
no_system_count=no_system,
|
||||
)
|
||||
@@ -6,7 +6,7 @@ Router principal para la API v1
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla
|
||||
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla, reports
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -73,4 +73,11 @@ api_router.include_router(
|
||||
sla.router,
|
||||
prefix="/sla",
|
||||
tags=["sla"]
|
||||
)
|
||||
|
||||
# Reports routes
|
||||
api_router.include_router(
|
||||
reports.router,
|
||||
prefix="/reports",
|
||||
tags=["reports"]
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Audit Log Model - ServiceManagerWeb
|
||||
|
||||
Modelo para bitácora de auditoría y compliance.
|
||||
Modelo para bitácora de auditoría y compliance.
|
||||
Registra todas las acciones importantes del sistema.
|
||||
"""
|
||||
|
||||
@@ -10,7 +10,7 @@ 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
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.core.database import Base, GUID
|
||||
|
||||
@@ -21,131 +21,154 @@ if TYPE_CHECKING:
|
||||
|
||||
class AuditLog(Base):
|
||||
"""
|
||||
Bitácora de auditoría para tracking completo de acciones.
|
||||
|
||||
Bitácora de auditoría para tracking completo de acciones.
|
||||
|
||||
Registra:
|
||||
- Qui├®n hizo la acci├│n (user_id)
|
||||
- Qu├® hizo (action)
|
||||
- Sobre qu├® recurso (resource_type + resource_id)
|
||||
- Cuándo lo hizo (created_at)
|
||||
- Desde d├│nde (ip_address, user_agent)
|
||||
- Qu├® cambi├│ (old_values, new_values)
|
||||
- Quién hizo la acción (user_id)
|
||||
- Qué hizo (action)
|
||||
- Sobre qué recurso (resource_type + resource_id)
|
||||
- Cuándo lo hizo (created_at)
|
||||
- Desde dónde (ip_address, user_agent)
|
||||
- Qué cambió (old_values, new_values)
|
||||
"""
|
||||
|
||||
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
# Multi-tenancy
|
||||
|
||||
# Multi-tenancy: cada registro pertenece a un tenant específico
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
GUID(),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Usuario que ejecut├│ la acci├│n (NULL = acci├│n del sistema)
|
||||
|
||||
# Usuario que ejecutó la acción (NULL = acción del sistema)
|
||||
user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
GUID(),
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Acci├│n realizada (ej: "user.login", "ticket.create", "ticket.assign")
|
||||
|
||||
# Acción realizada en formato "recurso.verbo"
|
||||
# Ejemplos: "user.login", "ticket.create", "ticket.assign"
|
||||
action: Mapped[str] = mapped_column(
|
||||
String(100),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
|
||||
# Tipo de recurso afectado (user, ticket, comment, category, etc.)
|
||||
resource_type: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
|
||||
# ID del recurso afectado
|
||||
resource_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
GUID(),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Contexto de la request
|
||||
|
||||
# Contexto de la request: IP y navegador del usuario
|
||||
ip_address: Mapped[Optional[str]] = mapped_column(
|
||||
String(45).with_variant(INET, "postgresql"),
|
||||
nullable=True,
|
||||
)
|
||||
user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Correlation ID para rastrear requests relacionadas
|
||||
|
||||
# Correlation ID para rastrear todas las requests relacionadas
|
||||
# en una misma operación o sesión
|
||||
correlation_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
GUID(),
|
||||
nullable=True,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Valores antes del cambio (JSON)
|
||||
|
||||
# Estado del recurso antes del cambio (para auditoría de cambios)
|
||||
old_values: Mapped[Optional[Dict[str, Any]]] = mapped_column(
|
||||
JSON().with_variant(JSONB, "postgresql"),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Valores despu├®s del cambio (JSON)
|
||||
|
||||
# Estado del recurso después del cambio (para auditoría de cambios)
|
||||
new_values: Mapped[Optional[Dict[str, Any]]] = mapped_column(
|
||||
JSON().with_variant(JSONB, "postgresql"),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Metadata adicional (cualquier info relevante)
|
||||
# Nota: 'metadata' está reservado en SQLAlchemy, usamos 'extra_metadata'
|
||||
|
||||
# Metadata adicional con cualquier información relevante del contexto
|
||||
# Nota: 'metadata' está reservado en SQLAlchemy, se usa 'extra_metadata'
|
||||
# como nombre del atributo Python, pero la columna en BD se llama 'metadata'
|
||||
extra_metadata: Mapped[Optional[Dict[str, Any]]] = mapped_column(
|
||||
'metadata', # Nombre real de la columna en BD
|
||||
'metadata',
|
||||
JSON().with_variant(JSONB, "postgresql"),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Timestamp
|
||||
|
||||
# Timestamp de creación con timezone
|
||||
# CORRECCIÓN: default=lambda: datetime.now(timezone.utc) genera un
|
||||
# datetime aware en UTC, compatible con DateTime(timezone=True).
|
||||
# El default anterior (datetime.utcnow) generaba datetimes naive,
|
||||
# causando que los filtros de fecha fallaran silenciosamente porque
|
||||
# SQLAlchemy no podía comparar aware vs naive correctamente.
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=datetime.utcnow,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Relaciones
|
||||
|
||||
# Relaciones con otros modelos
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", foreign_keys=[tenant_id])
|
||||
user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[user_id])
|
||||
|
||||
# Índices compuestos para queries comunes
|
||||
|
||||
# Índices compuestos para optimizar las queries más frecuentes
|
||||
__table_args__ = (
|
||||
# Filtrar logs por tenant y tipo de acción (uso más común)
|
||||
Index('idx_audit_logs_tenant_action', 'tenant_id', 'action'),
|
||||
# Buscar el historial de un recurso específico
|
||||
Index('idx_audit_logs_resource', 'resource_type', 'resource_id'),
|
||||
# Ver la actividad de un usuario ordenada por fecha
|
||||
Index('idx_audit_logs_user_created', 'user_id', 'created_at'),
|
||||
)
|
||||
|
||||
# Configuraci├│n del mapper: excluir updated_at porque audit logs son inmutables
|
||||
|
||||
# Los audit logs son inmutables: nunca se actualizan, solo se crean
|
||||
# Por eso se excluye updated_at del mapper
|
||||
__mapper_args__ = {
|
||||
"exclude_properties": ["updated_at"]
|
||||
}
|
||||
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<AuditLog(action='{self.action}', resource='{self.resource_type}:{self.resource_id}')>"
|
||||
|
||||
return (
|
||||
f"<AuditLog("
|
||||
f"action='{self.action}', "
|
||||
f"resource='{self.resource_type}:{self.resource_id}'"
|
||||
f")>"
|
||||
)
|
||||
|
||||
@property
|
||||
def action_display(self) -> str:
|
||||
"""Formato amigable de la acci├│n."""
|
||||
"""
|
||||
Formato legible de la acción para mostrar en la interfaz.
|
||||
|
||||
Convierte el formato interno "recurso.verbo" a texto descriptivo.
|
||||
Ejemplo: "ticket.create" → "creó ticket"
|
||||
"""
|
||||
parts = self.action.split('.')
|
||||
if len(parts) == 2:
|
||||
resource, verb = parts
|
||||
verb_map = {
|
||||
'create': 'cre├│',
|
||||
'update': 'actualiz├│',
|
||||
'delete': 'elimin├│',
|
||||
'login': 'inici├│ sesi├│n',
|
||||
'logout': 'cerr├│ sesi├│n',
|
||||
'assign': 'asign├│',
|
||||
'close': 'cerr├│',
|
||||
'reopen': 'reabri├│'
|
||||
'create': 'creó',
|
||||
'update': 'actualizó',
|
||||
'delete': 'eliminó',
|
||||
'login': 'inició sesión',
|
||||
'logout': 'cerró sesión',
|
||||
'login_failed': 'intentó iniciar sesión',
|
||||
'assign': 'asignó',
|
||||
'close': 'cerró',
|
||||
'reopen': 'reabrió'
|
||||
}
|
||||
return f"{verb_map.get(verb, verb)} {resource}"
|
||||
return self.action
|
||||
return self.action
|
||||
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())
|
||||
@@ -415,18 +415,19 @@ INSERT INTO tenants (name, slug, contact_email) VALUES
|
||||
('Aduanasoft Demo', 'aduanasoft-demo', 'demo@aduanasoft.com');
|
||||
|
||||
-- Usuario admin por defecto (password: admin123)
|
||||
-- Hash generado con Argon2: $argon2id$v=19$m=65536,t=3,p=4$...
|
||||
-- Hash Argon2id generado con m=65536,t=3,p=4
|
||||
INSERT INTO users (tenant_id, email, first_name, last_name, password_hash, role, is_active, email_verified)
|
||||
SELECT
|
||||
id,
|
||||
'admin@aduanasoft.com',
|
||||
'Admin',
|
||||
'Sistema',
|
||||
'$argon2id$v=19$m=65536,t=3,p=4$example_hash_here',
|
||||
'$argon2id$v=19$m=65536,t=3,p=4$wpjz/t+bM4bQmtM6B6A0pg$ELwnGUL4S1Y6tywp0LS6cre0bvWEoVuJ845spZ9Z9IQ',
|
||||
'ADMIN',
|
||||
true,
|
||||
true
|
||||
FROM tenants WHERE slug = 'aduanasoft-demo';
|
||||
FROM tenants WHERE slug = 'aduanasoft-demo'
|
||||
ON CONFLICT (tenant_id, email) DO NOTHING;
|
||||
|
||||
-- Categorías por defecto
|
||||
INSERT INTO ticket_categories (tenant_id, name, description, sla_response_hours, sla_resolution_hours)
|
||||
|
||||
@@ -164,6 +164,8 @@ services:
|
||||
- NODE_ENV=${ENVIRONMENT:-development}
|
||||
- PUBLIC_API_URL=http://backend:8000
|
||||
- PUBLIC_APP_NAME=ServiceManager Cliente
|
||||
- PORT=3000
|
||||
- HMR_CLIENT_PORT=3000
|
||||
volumes:
|
||||
- ./frontend-client:/app
|
||||
- /app/node_modules
|
||||
@@ -189,6 +191,8 @@ services:
|
||||
- NODE_ENV=${ENVIRONMENT:-development}
|
||||
- PUBLIC_API_URL=http://backend:8000
|
||||
- PUBLIC_APP_NAME=ServiceManager Admin
|
||||
- PORT=3000
|
||||
- HMR_CLIENT_PORT=3001
|
||||
volumes:
|
||||
- ./frontend-internal:/app
|
||||
- /app/node_modules
|
||||
|
||||
@@ -4,14 +4,26 @@
|
||||
import Toast from '$lib/components/Toast.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { browser } from '$app/environment';
|
||||
import '../app.css';
|
||||
|
||||
let mounted = false;
|
||||
|
||||
onMount(() => {
|
||||
auth.init();
|
||||
mounted = true;
|
||||
});
|
||||
|
||||
$: showHeader = !$page.url.pathname.startsWith('/login') && !$page.url.pathname.startsWith('/register');
|
||||
// Guard reactivo global: redirige a /login si no está autenticado en rutas protegidas
|
||||
const publicRoutes = ['/login', '/register', '/forgot-password', '/reset-password'];
|
||||
$: if (browser && mounted && !$auth.isAuthenticated &&
|
||||
!publicRoutes.some(r => $page.url.pathname.startsWith(r))) {
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
$: showHeader = !publicRoutes.some(r => $page.url.pathname.startsWith(r));
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-gray-50 font-sans">
|
||||
|
||||
@@ -216,12 +216,13 @@
|
||||
>Recordar en este equipo</label
|
||||
>
|
||||
</div>
|
||||
<a
|
||||
href="/forgot-password"
|
||||
class="text-sm font-medium text-blue-600 hover:text-blue-500"
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm font-medium text-blue-600 hover:text-blue-500 bg-transparent border-none p-0 cursor-pointer"
|
||||
on:click={() => goto('/forgot-password')}
|
||||
>
|
||||
Olvide mi clave
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -10,6 +10,16 @@ export default defineConfig({
|
||||
usePolling: true,
|
||||
interval: 500
|
||||
},
|
||||
// HMR: el browser llega al contenedor en el mismo puerto 3000
|
||||
hmr: {
|
||||
host: 'localhost',
|
||||
clientPort: parseInt(process.env.HMR_CLIENT_PORT || '3000')
|
||||
},
|
||||
// Permitir que Vite sirva archivos del filesystem del contenedor
|
||||
fs: {
|
||||
allow: ['/app', '.'],
|
||||
strict: false
|
||||
},
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.PUBLIC_API_URL || 'http://localhost:8000',
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 3000 --host 0.0.0.0",
|
||||
"dev": "vite dev --host 0.0.0.0",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 3000 --host 0.0.0.0",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
);
|
||||
}
|
||||
|
||||
if (role === 'ADMIN') {
|
||||
if (role === 'ADMIN' || role === 'SUPPORT_MANAGER') {
|
||||
baseNavigation.push(
|
||||
{
|
||||
name: 'SLA Management',
|
||||
@@ -57,7 +57,12 @@
|
||||
name: 'Reportes',
|
||||
href: '/reports',
|
||||
icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z'
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (role === 'ADMIN') {
|
||||
baseNavigation.push(
|
||||
{
|
||||
name: 'Auditoría',
|
||||
href: '/audit',
|
||||
@@ -67,6 +72,11 @@
|
||||
name: 'Seguridad',
|
||||
href: '/audit/security',
|
||||
icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z'
|
||||
},
|
||||
{
|
||||
name: 'Reporte Endpoints',
|
||||
href: '/test-report',
|
||||
icon: 'M9 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v18m0 0h10a2 2 0 002-2V9M9 21H5a2 2 0 01-2-2V9m0 0h18'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
161
frontend-internal/src/lib/stores/dashboardConfig.ts
Normal file
161
frontend-internal/src/lib/stores/dashboardConfig.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
/** All available dashboard modules */
|
||||
export interface DashboardModule {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
href: string;
|
||||
color: string;
|
||||
/** Minimum role required to see this module */
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
export const ALL_MODULES: DashboardModule[] = [
|
||||
{
|
||||
id: 'tenants',
|
||||
title: 'Clientes',
|
||||
description: 'Gestión de organizaciones y tenants',
|
||||
icon: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4',
|
||||
href: '/tenants',
|
||||
color: 'bg-blue-600',
|
||||
roles: ['ADMIN', 'SUPPORT_MANAGER']
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
title: 'Usuarios',
|
||||
description: 'Administración de usuarios y roles',
|
||||
icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z',
|
||||
href: '/users',
|
||||
color: 'bg-green-600',
|
||||
roles: ['ADMIN', 'SUPPORT_MANAGER']
|
||||
},
|
||||
{
|
||||
id: 'tickets',
|
||||
title: 'Tickets',
|
||||
description: 'Gestión y seguimiento de tickets de soporte',
|
||||
icon: 'M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2',
|
||||
href: '/tickets',
|
||||
color: 'bg-indigo-600',
|
||||
roles: ['ADMIN', 'SUPPORT_MANAGER', 'AGENT']
|
||||
},
|
||||
{
|
||||
id: 'systems',
|
||||
title: 'Sistemas',
|
||||
description: 'Catálogo de sistemas soportados',
|
||||
icon: 'M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01',
|
||||
href: '/systems',
|
||||
color: 'bg-gray-700',
|
||||
roles: ['ADMIN', 'SUPPORT_MANAGER']
|
||||
},
|
||||
{
|
||||
id: 'categories',
|
||||
title: 'Categorías',
|
||||
description: 'Clasificación de tickets por área',
|
||||
icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10',
|
||||
href: '/categories',
|
||||
color: 'bg-orange-600',
|
||||
roles: ['ADMIN', 'SUPPORT_MANAGER']
|
||||
},
|
||||
{
|
||||
id: 'sla',
|
||||
title: 'SLA Management',
|
||||
description: 'Monitoreo de tiempos de respuesta y SLAs',
|
||||
icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
href: '/sla',
|
||||
color: 'bg-teal-600',
|
||||
roles: ['ADMIN', 'SUPPORT_MANAGER']
|
||||
},
|
||||
{
|
||||
id: 'reports',
|
||||
title: 'Reportes',
|
||||
description: 'Informes estadísticos y análisis de rendimiento',
|
||||
icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z',
|
||||
href: '/reports',
|
||||
color: 'bg-purple-600',
|
||||
roles: ['ADMIN', 'SUPPORT_MANAGER']
|
||||
},
|
||||
{
|
||||
id: 'audit',
|
||||
title: 'Auditoría',
|
||||
description: 'Bitácora de acciones y trazabilidad del sistema',
|
||||
icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
|
||||
href: '/audit',
|
||||
color: 'bg-red-700',
|
||||
roles: ['ADMIN', 'AUDITOR']
|
||||
},
|
||||
{
|
||||
id: 'security',
|
||||
title: 'Seguridad',
|
||||
description: 'Análisis de amenazas y eventos de seguridad',
|
||||
icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z',
|
||||
href: '/audit/security',
|
||||
color: 'bg-yellow-600',
|
||||
roles: ['ADMIN']
|
||||
},
|
||||
{
|
||||
id: 'endpoints',
|
||||
title: 'Reporte de Endpoints',
|
||||
description: 'Estado y diagnóstico de todos los endpoints API',
|
||||
icon: 'M9 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v18m0 0h10a2 2 0 002-2V9M9 21H5a2 2 0 01-2-2V9m0 0h18',
|
||||
href: '/test-report',
|
||||
color: 'bg-cyan-600',
|
||||
roles: ['ADMIN']
|
||||
}
|
||||
];
|
||||
|
||||
const STORAGE_KEY = 'dashboard_module_visibility';
|
||||
|
||||
function getInitialVisibility(): Record<string, boolean> {
|
||||
if (typeof window === 'undefined') {
|
||||
return Object.fromEntries(ALL_MODULES.map(m => [m.id, true]));
|
||||
}
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) return JSON.parse(stored);
|
||||
} catch { /* ignore */ }
|
||||
return Object.fromEntries(ALL_MODULES.map(m => [m.id, true]));
|
||||
}
|
||||
|
||||
function createDashboardConfig() {
|
||||
const { subscribe, set, update } = writable<Record<string, boolean>>(getInitialVisibility());
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
toggle(id: string) {
|
||||
update(state => {
|
||||
const next = { ...state, [id]: !state[id] };
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
setVisible(id: string, visible: boolean) {
|
||||
update(state => {
|
||||
const next = { ...state, [id]: visible };
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
showAll() {
|
||||
const all = Object.fromEntries(ALL_MODULES.map(m => [m.id, true]));
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
|
||||
}
|
||||
set(all);
|
||||
},
|
||||
reset() {
|
||||
const defaults = Object.fromEntries(ALL_MODULES.map(m => [m.id, true]));
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(defaults));
|
||||
}
|
||||
set(defaults);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const dashboardConfig = createDashboardConfig();
|
||||
@@ -5,14 +5,24 @@
|
||||
import { toast } from '$lib/stores/toast.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { browser } from '$app/environment';
|
||||
import '../app.css';
|
||||
|
||||
let sidebarOpen = false;
|
||||
let mounted = false;
|
||||
|
||||
onMount(() => {
|
||||
auth.init();
|
||||
mounted = true;
|
||||
});
|
||||
|
||||
// Guard reactivo global: redirige a /login si no está autenticado
|
||||
$: if (browser && mounted && !$auth.isAuthenticated && $page.url.pathname !== '/login') {
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarOpen = !sidebarOpen;
|
||||
}
|
||||
|
||||
@@ -2,44 +2,27 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
|
||||
import { dashboardConfig, ALL_MODULES, type DashboardModule } from '$lib/stores/dashboardConfig.js';
|
||||
|
||||
let showSettings = false;
|
||||
|
||||
onMount(() => {
|
||||
if (!$auth.isAuthenticated) {
|
||||
goto('/login');
|
||||
}
|
||||
});
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: 'Clientes',
|
||||
description: 'Gestión de organizaciones y tenants',
|
||||
icon: 'users',
|
||||
href: '/tenants',
|
||||
color: 'bg-blue-600'
|
||||
},
|
||||
{
|
||||
title: 'Usuarios',
|
||||
description: 'Administración de usuarios y roles',
|
||||
icon: 'user-plus',
|
||||
href: '/users',
|
||||
color: 'bg-green-600'
|
||||
},
|
||||
{
|
||||
title: 'Sistemas',
|
||||
description: 'Catálogo de sistemas soportados',
|
||||
icon: 'server',
|
||||
href: '/systems',
|
||||
color: 'bg-gray-700'
|
||||
},
|
||||
{
|
||||
title: 'Categorías',
|
||||
description: 'Clasificación de tickets',
|
||||
icon: 'tag',
|
||||
href: '/categories',
|
||||
color: 'bg-orange-600'
|
||||
}
|
||||
];
|
||||
const role = $auth.user?.role ?? '';
|
||||
|
||||
/** Only modules the current role can access */
|
||||
$: accessibleModules = ALL_MODULES.filter(m => m.roles.includes(role) || role === 'ADMIN');
|
||||
|
||||
/** Modules that are visible (enabled by user + accessible by role) */
|
||||
$: visibleModules = accessibleModules.filter(m => $dashboardConfig[m.id] !== false);
|
||||
|
||||
function toggleSettings() {
|
||||
showSettings = !showSettings;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -47,40 +30,106 @@
|
||||
</svelte:head>
|
||||
|
||||
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
|
||||
<!-- Header -->
|
||||
<div class="md:flex md:items-center md:justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:text-3xl sm:truncate">
|
||||
Panel de Administración
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Bienvenido al sistema de gestión interna.
|
||||
Bienvenido al sistema de gestión interna.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4 flex md:mt-0 md:ml-4 gap-2">
|
||||
<button
|
||||
on:click={toggleSettings}
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Configurar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{#each cards as card}
|
||||
<a href={card.href} class="bg-white overflow-hidden shadow rounded-lg hover:shadow-md transition-shadow duration-200 cursor-pointer group">
|
||||
<div class="p-5">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">
|
||||
{card.title}
|
||||
</dt>
|
||||
<dd>
|
||||
<div class="text-xs text-gray-900 font-light mt-1">
|
||||
{card.description}
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
<!-- Settings Panel -->
|
||||
{#if showSettings}
|
||||
<div class="mt-6 bg-white border border-gray-200 rounded-lg shadow-sm p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-base font-semibold text-gray-900">Módulos visibles en el dashboard</h3>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
on:click={() => dashboardConfig.showAll()}
|
||||
class="text-xs text-blue-600 hover:text-blue-800 underline"
|
||||
>
|
||||
Mostrar todos
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-5 py-3">
|
||||
<div class="text-sm">
|
||||
<span class="font-medium text-blue-700 hover:text-blue-900">
|
||||
Ver detalles
|
||||
</div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{#each accessibleModules as mod}
|
||||
<label class="flex items-center gap-2 p-3 border rounded-lg cursor-pointer hover:bg-gray-50 {$dashboardConfig[mod.id] !== false ? 'border-blue-300 bg-blue-50' : 'border-gray-200'}">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={$dashboardConfig[mod.id] !== false}
|
||||
on:change={() => dashboardConfig.toggle(mod.id)}
|
||||
class="rounded text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="w-2 h-2 rounded-full {mod.color} flex-shrink-0"></span>
|
||||
<span class="text-sm font-medium text-gray-800 truncate">{mod.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-gray-400">Las preferencias se guardan automáticamente en este navegador.</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Module Cards -->
|
||||
{#if visibleModules.length === 0}
|
||||
<div class="mt-10 text-center py-16 bg-white rounded-lg border-2 border-dashed border-gray-200">
|
||||
<svg class="mx-auto h-10 w-10 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
<p class="mt-3 text-sm text-gray-500">No hay módulos visibles.</p>
|
||||
<button on:click={() => dashboardConfig.showAll()} class="mt-3 text-sm text-blue-600 hover:underline">
|
||||
Restaurar todos los módulos
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-8 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{#each visibleModules as mod}
|
||||
<a
|
||||
href={mod.href}
|
||||
class="bg-white overflow-hidden shadow rounded-lg hover:shadow-md transition-all duration-200 cursor-pointer group flex flex-col"
|
||||
>
|
||||
<div class="p-5 flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="w-9 h-9 rounded-lg {mod.color} flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={mod.icon} />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-800 group-hover:text-blue-700 transition-colors">
|
||||
{mod.title}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 leading-relaxed">{mod.description}</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-5 py-2.5 border-t border-gray-100">
|
||||
<span class="text-xs font-medium text-blue-600 group-hover:text-blue-800 transition-colors">
|
||||
Abrir módulo →
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
661
frontend-internal/src/routes/reports/+page.svelte
Normal file
661
frontend-internal/src/routes/reports/+page.svelte
Normal file
@@ -0,0 +1,661 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/utils/api';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
import { auth } from '$lib/stores/auth';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
let isLoading = false;
|
||||
let days = 30;
|
||||
let activeTab = 'summary';
|
||||
|
||||
const user = get(auth).user;
|
||||
const isAdmin = user?.role === 'ADMIN';
|
||||
|
||||
let summary: any = null;
|
||||
let agentReport: any = null;
|
||||
let catReport: any = null;
|
||||
let sysReport: any = null;
|
||||
let clientReport: any = null;
|
||||
let trendsReport: any = null;
|
||||
let csatReport: any = null;
|
||||
|
||||
const tabs = [
|
||||
{ id: 'summary', label: 'Resumen', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' },
|
||||
{ id: 'agents', label: 'Por Agente', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197' },
|
||||
{ id: 'categories', label: 'Por Categoría', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' },
|
||||
{ id: 'systems', label: 'Por Sistema', icon: 'M9 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v18m0 0h10a2 2 0 002-2V9M9 21H5a2 2 0 01-2-2V9m0 0h18' },
|
||||
{ id: 'clients', label: 'Por Cliente', icon: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4', adminOnly: true },
|
||||
{ id: 'trends', label: 'Tendencias', icon: 'M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z' },
|
||||
{ id: 'csat', label: 'Satisfacción', icon: 'M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z' },
|
||||
].filter(t => !t.adminOnly || isAdmin);
|
||||
|
||||
async function loadTab(tab: string) {
|
||||
isLoading = true;
|
||||
try {
|
||||
switch (tab) {
|
||||
case 'summary': summary = await api.get(`/reports/summary?days=${days}`); break;
|
||||
case 'agents': agentReport = await api.get(`/reports/by-agent?days=${days}`); break;
|
||||
case 'categories': catReport = await api.get(`/reports/by-category?days=${days}`); break;
|
||||
case 'systems': sysReport = await api.get(`/reports/by-system?days=${days}`); break;
|
||||
case 'clients': if (isAdmin) clientReport = await api.get(`/reports/by-client?days=${days}`); break;
|
||||
case 'trends': trendsReport = await api.get(`/reports/trends?days=${Math.min(days, 90)}`); break;
|
||||
case 'csat': csatReport = await api.get(`/reports/csat?days=${days}`); break;
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error('Error cargando reporte: ' + (e.message ?? 'Error desconocido'));
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function switchTab(tab: string) { activeTab = tab; await loadTab(tab); }
|
||||
async function reloadAll() { await loadTab(activeTab); }
|
||||
onMount(() => loadTab('summary'));
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
||||
NEW: { label: 'Nuevo', color: 'blue' },
|
||||
TRIAGE: { label: 'Triaje', color: 'purple' },
|
||||
IN_PROGRESS: { label: 'En progreso', color: 'indigo' },
|
||||
WAITING_CUSTOMER: { label: 'Esp. cliente', color: 'yellow' },
|
||||
RESOLVED: { label: 'Resuelto', color: 'green' },
|
||||
CLOSED: { label: 'Cerrado', color: 'gray' },
|
||||
REOPENED: { label: 'Reabierto', color: 'red' },
|
||||
};
|
||||
const PRIORITY_MAP: Record<string, { label: string; color: string }> = {
|
||||
LOW: { label: 'Baja', color: 'gray' },
|
||||
MEDIUM: { label: 'Media', color: 'blue' },
|
||||
HIGH: { label: 'Alta', color: 'orange' },
|
||||
URGENT: { label: 'Urgente', color: 'red' },
|
||||
};
|
||||
|
||||
function statusBadge(s: string) { const c = STATUS_MAP[s]?.color ?? 'gray'; return `bg-${c}-100 text-${c}-800`; }
|
||||
function statusLabel(s: string) { return STATUS_MAP[s]?.label ?? s.replace(/_/g, ' '); }
|
||||
function priorityBadge(p: string) { const c = PRIORITY_MAP[p]?.color ?? 'gray'; return `bg-${c}-100 text-${c}-800`; }
|
||||
function priorityLabel(p: string) { return PRIORITY_MAP[p]?.label ?? p; }
|
||||
|
||||
function fmtHours(h: number | null): string {
|
||||
if (h == null) return '—';
|
||||
if (h < 1) return `${Math.round(h * 60)} min`;
|
||||
if (h < 24) return `${h.toFixed(1)} h`;
|
||||
return `${(h / 24).toFixed(1)} días`;
|
||||
}
|
||||
|
||||
function fmtDate(d: string): string {
|
||||
return new Date(d).toLocaleDateString('es-MX', { day: '2-digit', month: 'short' });
|
||||
}
|
||||
|
||||
function stars(r: number | null): string {
|
||||
if (!r) return '—';
|
||||
const n = Math.round(r);
|
||||
return '★'.repeat(n) + '☆'.repeat(5 - n);
|
||||
}
|
||||
|
||||
function changePct(val: number | null, type: 'tickets' | 'resolution'): { cls: string; txt: string } {
|
||||
if (val == null) return { cls: '', txt: '' };
|
||||
const arrow = val > 0 ? '↑' : '↓';
|
||||
const cls = type === 'tickets'
|
||||
? (val > 0 ? 'text-red-600' : 'text-green-600')
|
||||
: (val > 0 ? 'text-green-600' : 'text-red-600');
|
||||
return { cls, txt: `${arrow} ${Math.abs(val)}%` };
|
||||
}
|
||||
|
||||
function slaBarColor(pct: number): string {
|
||||
if (pct >= 90) return 'bg-green-500';
|
||||
if (pct >= 70) return 'bg-yellow-400';
|
||||
return 'bg-red-500';
|
||||
}
|
||||
|
||||
function maxTrend(pts: any[]): number {
|
||||
if (!pts?.length) return 1;
|
||||
return Math.max(...pts.map((p: any) => Math.max(p.created, p.resolved, 1)));
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- PAGINA -->
|
||||
<div class="p-6 space-y-6">
|
||||
|
||||
<!-- ENCABEZADO -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Reportes</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">Estadísticas y métricas del sistema de soporte</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label for="period-select" class="text-sm font-medium text-gray-600">Período:</label>
|
||||
<select
|
||||
id="period-select"
|
||||
class="border rounded-lg px-3 py-2 text-sm bg-white shadow-sm focus:ring-2 focus:ring-blue-500"
|
||||
bind:value={days}
|
||||
on:change={reloadAll}
|
||||
>
|
||||
<option value={7}>Últimos 7 días</option>
|
||||
<option value={30}>Últimos 30 días</option>
|
||||
<option value={60}>Últimos 60 días</option>
|
||||
<option value={90}>Últimos 90 días</option>
|
||||
<option value={180}>Últimos 6 meses</option>
|
||||
<option value={365}>Último año</option>
|
||||
</select>
|
||||
<button
|
||||
class="px-3 py-2 bg-blue-700 text-white text-sm rounded-lg hover:bg-blue-800 transition disabled:opacity-50"
|
||||
on:click={reloadAll}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? '...' : '↺ Actualizar'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TABS -->
|
||||
<div class="border-b border-gray-200">
|
||||
<nav class="flex gap-1 overflow-x-auto">
|
||||
{#each tabs as tab}
|
||||
<button
|
||||
class="flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 whitespace-nowrap transition
|
||||
{activeTab === tab.id ? 'border-blue-700 text-blue-700' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}"
|
||||
on:click={() => switchTab(tab.id)}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={tab.icon} />
|
||||
</svg>
|
||||
{tab.label}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- SPINNER -->
|
||||
{#if isLoading}
|
||||
<div class="flex justify-center items-center py-16 text-gray-400 text-sm gap-2">
|
||||
<svg class="animate-spin h-5 w-5 text-blue-700" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/>
|
||||
</svg>
|
||||
Cargando reporte...
|
||||
</div>
|
||||
|
||||
<!-- RESUMEN -->
|
||||
{:else if activeTab === 'summary' && summary}
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="bg-white rounded-xl p-5 shadow-sm border border-gray-200">
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Total tickets</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-2">{summary.total_tickets}</p>
|
||||
{#if summary.tickets_change_pct != null}
|
||||
{@const cp = changePct(summary.tickets_change_pct, 'tickets')}
|
||||
<p class="text-sm mt-1 {cp.cls}">{cp.txt} vs período anterior</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-5 shadow-sm border-l-4 border-orange-400 border border-gray-100">
|
||||
<p class="text-xs font-medium text-orange-500 uppercase tracking-wide">Abiertos</p>
|
||||
<p class="text-3xl font-bold text-orange-500 mt-2">{summary.open_tickets}</p>
|
||||
<p class="text-sm text-gray-400 mt-1">
|
||||
{summary.total_tickets > 0 ? Math.round(summary.open_tickets / summary.total_tickets * 100) : 0}% del total
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-5 shadow-sm border-l-4 border-green-500 border border-gray-100">
|
||||
<p class="text-xs font-medium text-green-600 uppercase tracking-wide">Resueltos</p>
|
||||
<p class="text-3xl font-bold text-green-600 mt-2">{summary.resolved_tickets}</p>
|
||||
{#if summary.resolution_change_pct != null}
|
||||
{@const cp = changePct(summary.resolution_change_pct, 'resolution')}
|
||||
<p class="text-sm mt-1 {cp.cls}">{cp.txt} tasa vs anterior</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-5 shadow-sm border-l-4 border-red-500 border border-gray-100">
|
||||
<p class="text-xs font-medium text-red-500 uppercase tracking-wide">Urgentes</p>
|
||||
<p class="text-3xl font-bold text-red-600 mt-2">{summary.by_priority.urgent}</p>
|
||||
<p class="text-sm text-gray-400 mt-1">Tiempo prom: {fmtHours(summary.avg_resolution_hours)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-4 gap-4">
|
||||
<div class="bg-white rounded-xl p-5 shadow-sm border border-gray-200">
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Tiempo prom. resolución</p>
|
||||
<p class="text-3xl font-bold text-blue-700 mt-2">{fmtHours(summary.avg_resolution_hours)}</p>
|
||||
<p class="text-sm text-gray-400 mt-1">Primera resp: {fmtHours(summary.avg_first_response_hours)}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-5 shadow-sm border border-gray-200">
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">Satisfacción (CSAT)</p>
|
||||
{#if summary.avg_rating}
|
||||
<p class="text-4xl font-bold text-yellow-500">{summary.avg_rating.toFixed(1)} <span class="text-2xl">★</span></p>
|
||||
<p class="text-sm text-gray-400 mt-1">{summary.total_rated} calificaciones</p>
|
||||
{:else}
|
||||
<p class="text-gray-400 text-sm mt-2">Sin calificaciones</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-5 shadow-sm border border-gray-200">
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">Por estado</p>
|
||||
<div class="space-y-2">
|
||||
{#each Object.entries(summary.by_status).filter(([k]) => k !== 'total') as [s, count]}
|
||||
{#if count > 0}
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs px-2 py-0.5 rounded-full font-medium {statusBadge(s.toUpperCase())}">
|
||||
{statusLabel(s.toUpperCase())}
|
||||
</span>
|
||||
<span class="text-sm font-semibold text-gray-700">{count}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-5 shadow-sm border border-gray-200">
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">Por prioridad</p>
|
||||
<div class="space-y-2">
|
||||
{#each [['URGENT', summary.by_priority.urgent], ['HIGH', summary.by_priority.high], ['MEDIUM', summary.by_priority.medium], ['LOW', summary.by_priority.low]] as [p, cnt]}
|
||||
{#if cnt > 0}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs px-2 py-0.5 rounded-full font-medium {priorityBadge(String(p))} w-20 text-center">
|
||||
{priorityLabel(String(p))}
|
||||
</span>
|
||||
<div class="flex-1 bg-gray-100 rounded-full h-2">
|
||||
<div class="h-2 rounded-full bg-blue-600"
|
||||
style="width:{summary.by_priority.total > 0 ? Math.round(Number(cnt) / summary.by_priority.total * 100) : 0}%">
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm font-semibold w-6 text-right">{cnt}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- POR AGENTE -->
|
||||
{:else if activeTab === 'agents' && agentReport}
|
||||
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
<div class="px-6 py-4 border-b bg-gray-50">
|
||||
<h2 class="font-semibold text-gray-700">Rendimiento por agente — {agentReport.total_agents} agentes</h2>
|
||||
</div>
|
||||
{#if agentReport.agents.length === 0}
|
||||
<p class="p-8 text-center text-gray-400">No hay datos de agentes en este período.</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-medium text-gray-600">Agente</th>
|
||||
<th class="px-4 py-3 text-center font-medium text-gray-600">Asignados</th>
|
||||
<th class="px-4 py-3 text-center font-medium text-gray-600">Resueltos</th>
|
||||
<th class="px-4 py-3 text-center font-medium text-gray-600">Abiertos</th>
|
||||
<th class="px-4 py-3 text-center font-medium text-gray-600">Resolución %</th>
|
||||
<th class="px-4 py-3 text-center font-medium text-gray-600">Tiempo prom.</th>
|
||||
<th class="px-4 py-3 text-center font-medium text-gray-600">CSAT</th>
|
||||
<th class="px-4 py-3 text-center font-medium text-gray-600">Urgentes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{#each agentReport.agents as a}
|
||||
<tr class="hover:bg-gray-50 transition">
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-medium text-gray-900">{a.agent_name}</div>
|
||||
<div class="text-xs text-gray-400">{a.agent_email}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center font-semibold">{a.total_assigned}</td>
|
||||
<td class="px-4 py-3 text-center text-green-600 font-semibold">{a.resolved}</td>
|
||||
<td class="px-4 py-3 text-center text-orange-500 font-semibold">{a.open}</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<div class="flex items-center gap-2 justify-center">
|
||||
<div class="w-16 bg-gray-200 rounded-full h-2">
|
||||
<div class="h-2 rounded-full {a.resolution_rate >= 80 ? 'bg-green-500' : a.resolution_rate >= 50 ? 'bg-yellow-400' : 'bg-red-500'}"
|
||||
style="width:{a.resolution_rate}%"></div>
|
||||
</div>
|
||||
<span class="text-xs font-medium">{a.resolution_rate}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center text-gray-600">{fmtHours(a.avg_resolution_hours)}</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
{#if a.avg_rating}
|
||||
<span class="text-yellow-500 font-semibold">{a.avg_rating.toFixed(1)} ★</span>
|
||||
<div class="text-xs text-gray-400">{a.total_rated} cal.</div>
|
||||
{:else}
|
||||
<span class="text-gray-300">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
{#if a.urgent_handled > 0}
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-800 font-semibold">{a.urgent_handled}</span>
|
||||
{:else}
|
||||
<span class="text-gray-300">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- POR CATEGORIA -->
|
||||
{:else if activeTab === 'categories' && catReport}
|
||||
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
<div class="px-6 py-4 border-b bg-gray-50 flex justify-between items-center">
|
||||
<h2 class="font-semibold text-gray-700">Tickets por categoría</h2>
|
||||
{#if catReport.uncategorized_count > 0}
|
||||
<span class="text-xs bg-gray-100 text-gray-500 px-2 py-1 rounded-full">
|
||||
+ {catReport.uncategorized_count} sin categoría
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if catReport.categories.length === 0}
|
||||
<p class="p-8 text-center text-gray-400">No hay datos de categorías en este período.</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-medium text-gray-600">Categoría</th>
|
||||
<th class="px-4 py-3 text-center font-medium text-gray-600">Total</th>
|
||||
<th class="px-4 py-3 text-center font-medium text-gray-600">Abiertos</th>
|
||||
<th class="px-4 py-3 text-center font-medium text-gray-600">Resueltos</th>
|
||||
<th class="px-4 py-3 text-center font-medium text-gray-600">Tiempo prom.</th>
|
||||
<th class="px-4 py-3 text-center font-medium text-gray-600">SLA resp/resol</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-gray-600">Cumplimiento SLA</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{#each catReport.categories as cat}
|
||||
<tr class="hover:bg-gray-50 transition">
|
||||
<td class="px-4 py-3 font-medium text-gray-900">{cat.category_name}</td>
|
||||
<td class="px-4 py-3 text-center font-semibold">{cat.total_tickets}</td>
|
||||
<td class="px-4 py-3 text-center text-orange-500">{cat.open_tickets}</td>
|
||||
<td class="px-4 py-3 text-center text-green-600">{cat.resolved_tickets}</td>
|
||||
<td class="px-4 py-3 text-center text-gray-600">{fmtHours(cat.avg_resolution_hours)}</td>
|
||||
<td class="px-4 py-3 text-center text-gray-500 text-xs">{cat.sla_response_hours}h / {cat.sla_resolution_hours}h</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 bg-gray-200 rounded-full h-2.5">
|
||||
<div class="h-2.5 rounded-full {slaBarColor(cat.sla_compliance_pct)}" style="width:{cat.sla_compliance_pct}%"></div>
|
||||
</div>
|
||||
<span class="text-xs font-medium w-10 text-right">{cat.sla_compliance_pct}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- POR SISTEMA -->
|
||||
{:else if activeTab === 'systems' && sysReport}
|
||||
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
<div class="px-6 py-4 border-b bg-gray-50 flex justify-between items-center">
|
||||
<h2 class="font-semibold text-gray-700">Tickets por sistema afectado</h2>
|
||||
{#if sysReport.no_system_count > 0}
|
||||
<span class="text-xs bg-gray-100 text-gray-500 px-2 py-1 rounded-full">
|
||||
+ {sysReport.no_system_count} sin sistema
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if sysReport.systems.length === 0}
|
||||
<p class="p-8 text-center text-gray-400">No hay tickets con sistema asignado en este período.</p>
|
||||
{:else}
|
||||
{@const maxSys = Math.max(...sysReport.systems.map(s => s.total_tickets), 1)}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-medium text-gray-600">Sistema</th>
|
||||
<th class="px-4 py-3 text-center font-medium text-gray-600">Total</th>
|
||||
<th class="px-4 py-3 text-center font-medium text-gray-600">Abiertos</th>
|
||||
<th class="px-4 py-3 text-center font-medium text-gray-600">Resueltos</th>
|
||||
<th class="px-4 py-3 text-center font-medium text-gray-600">Urgentes</th>
|
||||
<th class="px-4 py-3 text-center font-medium text-gray-600">Tiempo prom.</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-gray-600">Carga de trabajo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{#each sysReport.systems as sys}
|
||||
<tr class="hover:bg-gray-50 transition">
|
||||
<td class="px-4 py-3 font-medium text-gray-900">{sys.system_name}</td>
|
||||
<td class="px-4 py-3 text-center font-semibold">{sys.total_tickets}</td>
|
||||
<td class="px-4 py-3 text-center text-orange-500">{sys.open_tickets}</td>
|
||||
<td class="px-4 py-3 text-center text-green-600">{sys.resolved_tickets}</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
{#if sys.urgent_tickets > 0}
|
||||
<span class="px-2 py-0.5 rounded-full bg-red-100 text-red-800 text-xs font-semibold">{sys.urgent_tickets}</span>
|
||||
{:else}
|
||||
<span class="text-gray-300">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center text-gray-600">{fmtHours(sys.avg_resolution_hours)}</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 bg-gray-100 rounded-full h-2.5">
|
||||
<div class="h-2.5 rounded-full bg-blue-600" style="width:{Math.round(sys.total_tickets / maxSys * 100)}%"></div>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400 w-8 text-right">{Math.round(sys.total_tickets / maxSys * 100)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- POR CLIENTE (solo ADMIN) -->
|
||||
{:else if activeTab === 'clients' && isAdmin && clientReport}
|
||||
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
<div class="px-6 py-4 border-b bg-gray-50">
|
||||
<h2 class="font-semibold text-gray-700">Tickets por cliente — {clientReport.total_clients} clientes activos</h2>
|
||||
</div>
|
||||
{#if clientReport.clients.length === 0}
|
||||
<p class="p-8 text-center text-gray-400">No hay datos de clientes en este período.</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-medium text-gray-600">Cliente</th>
|
||||
<th class="px-4 py-3 text-center font-medium text-gray-600">Total</th>
|
||||
<th class="px-4 py-3 text-center font-medium text-gray-600">Abiertos</th>
|
||||
<th class="px-4 py-3 text-center font-medium text-gray-600">Resueltos</th>
|
||||
<th class="px-4 py-3 text-center font-medium text-gray-600">Urgentes</th>
|
||||
<th class="px-4 py-3 text-center font-medium text-gray-600">Tiempo prom.</th>
|
||||
<th class="px-4 py-3 text-center font-medium text-gray-600">CSAT</th>
|
||||
<th class="px-4 py-3 text-center font-medium text-gray-600">Último ticket</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{#each clientReport.clients as c}
|
||||
<tr class="hover:bg-gray-50 transition">
|
||||
<td class="px-4 py-3 font-medium text-gray-900">{c.tenant_name}</td>
|
||||
<td class="px-4 py-3 text-center font-semibold">{c.total_tickets}</td>
|
||||
<td class="px-4 py-3 text-center text-orange-500">{c.open_tickets}</td>
|
||||
<td class="px-4 py-3 text-center text-green-600">{c.resolved_tickets}</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
{#if c.urgent_tickets > 0}
|
||||
<span class="px-2 py-0.5 rounded-full bg-red-100 text-red-800 text-xs font-semibold">{c.urgent_tickets}</span>
|
||||
{:else}
|
||||
<span class="text-gray-300">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center text-gray-600">{fmtHours(c.avg_resolution_hours)}</td>
|
||||
<td class="px-4 py-3 text-center text-yellow-500">
|
||||
{c.avg_rating ? c.avg_rating.toFixed(1) + ' ★' : '—'}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center text-gray-400 text-xs">
|
||||
{c.last_ticket_at ? new Date(c.last_ticket_at).toLocaleDateString('es-MX') : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- TENDENCIAS -->
|
||||
{:else if activeTab === 'trends' && trendsReport}
|
||||
<div class="bg-white rounded-xl shadow-sm border p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="font-semibold text-gray-700">Tickets diarios — últimos {trendsReport.total_days} días</h2>
|
||||
<div class="flex gap-4 text-xs text-gray-500">
|
||||
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded bg-blue-400 inline-block"></span> Creados</span>
|
||||
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded bg-green-500 inline-block"></span> Resueltos</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if trendsReport.data_points.length > 0}
|
||||
{@const maxVal = maxTrend(trendsReport.data_points)}
|
||||
<div class="overflow-x-auto">
|
||||
<div class="relative" style="height:160px; min-width:max-content">
|
||||
<div class="flex items-end gap-1 h-full border-b border-gray-200">
|
||||
{#each trendsReport.data_points as pt}
|
||||
<div class="w-3 bg-blue-400 rounded-t opacity-80 shrink-0"
|
||||
style="height:{maxVal > 0 ? Math.round(pt.created / maxVal * 100) : 0}%"
|
||||
title="Creados {pt.date}: {pt.created}"></div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 flex items-end gap-1 h-full pointer-events-none">
|
||||
{#each trendsReport.data_points as pt}
|
||||
<div class="w-3 bg-green-500 rounded-t opacity-60 shrink-0"
|
||||
style="height:{maxVal > 0 ? Math.round(pt.resolved / maxVal * 100) : 0}%"
|
||||
title="Resueltos {pt.date}: {pt.resolved}"></div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-1 mt-2" style="min-width:max-content">
|
||||
{#each trendsReport.data_points as pt, i}
|
||||
<div class="w-3 shrink-0 text-center">
|
||||
{#if i % 7 === 0}
|
||||
<span class="text-gray-400 block" style="font-size:0.55rem;writing-mode:vertical-rl">{fmtDate(pt.date)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 max-h-48 overflow-y-auto rounded border border-gray-100">
|
||||
<table class="w-full text-xs">
|
||||
<thead class="sticky top-0 bg-gray-50">
|
||||
<tr class="border-b">
|
||||
<th class="px-3 py-2 text-left text-gray-500 font-medium">Fecha</th>
|
||||
<th class="px-3 py-2 text-center text-blue-500 font-medium">Creados</th>
|
||||
<th class="px-3 py-2 text-center text-green-600 font-medium">Resueltos</th>
|
||||
<th class="px-3 py-2 text-center text-gray-500 font-medium">Balance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each [...trendsReport.data_points].reverse() as pt}
|
||||
{#if pt.created > 0 || pt.resolved > 0}
|
||||
<tr class="border-b hover:bg-gray-50">
|
||||
<td class="px-3 py-1.5 text-gray-600">{pt.date}</td>
|
||||
<td class="px-3 py-1.5 text-center text-blue-600 font-semibold">{pt.created}</td>
|
||||
<td class="px-3 py-1.5 text-center text-green-600 font-semibold">{pt.resolved}</td>
|
||||
<td class="px-3 py-1.5 text-center font-semibold {pt.net_open > 0 ? 'text-red-500' : pt.net_open < 0 ? 'text-green-500' : 'text-gray-400'}">
|
||||
{pt.net_open > 0 ? '+' : ''}{pt.net_open}
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-center text-gray-400 py-8">No hay datos en este período.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- CSAT -->
|
||||
{:else if activeTab === 'csat' && csatReport}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 class="font-semibold text-gray-700 mb-4">Resumen CSAT</h3>
|
||||
{#if csatReport.avg_rating}
|
||||
<div class="text-center">
|
||||
<p class="text-5xl font-bold text-yellow-500">{csatReport.avg_rating.toFixed(1)}</p>
|
||||
<p class="text-3xl mt-1 text-yellow-400">{stars(csatReport.avg_rating)}</p>
|
||||
<p class="text-sm text-gray-500 mt-2">{csatReport.total_rated} de {csatReport.total_tickets} tickets calificados</p>
|
||||
<p class="text-sm font-medium text-blue-600 mt-1">{csatReport.response_rate}% tasa de respuesta</p>
|
||||
</div>
|
||||
<div class="mt-6 space-y-2">
|
||||
{#each [5, 4, 3, 2, 1] as star}
|
||||
{@const count = csatReport.distribution[`rating_${star}`] ?? 0}
|
||||
{@const pct = csatReport.total_rated > 0 ? Math.round(count / csatReport.total_rated * 100) : 0}
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-yellow-400 w-6 text-right shrink-0">{star}★</span>
|
||||
<div class="flex-1 bg-gray-100 rounded-full h-3">
|
||||
<div class="h-3 rounded-full bg-yellow-400" style="width:{pct}%"></div>
|
||||
</div>
|
||||
<span class="text-gray-500 w-8 text-right text-xs shrink-0">{count}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-gray-400 text-center py-4">Sin calificaciones en este período</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 class="font-semibold text-gray-700 mb-4">CSAT por categoría</h3>
|
||||
{#if csatReport.by_category.length}
|
||||
<div class="space-y-3">
|
||||
{#each csatReport.by_category as item}
|
||||
<div>
|
||||
<div class="flex justify-between items-center text-sm mb-1">
|
||||
<span class="text-gray-700 truncate">{item.category}</span>
|
||||
<span class="text-yellow-500 font-medium ml-2 shrink-0">{item.avg_rating ? item.avg_rating.toFixed(1) + ' ★' : '—'}</span>
|
||||
</div>
|
||||
<div class="bg-gray-100 rounded-full h-2">
|
||||
<div class="h-2 rounded-full bg-yellow-400" style="width:{item.avg_rating ? item.avg_rating / 5 * 100 : 0}%"></div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-0.5">{item.total_rated} calificaciones</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-gray-400 text-sm">Sin datos</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 class="font-semibold text-gray-700 mb-4">CSAT por agente</h3>
|
||||
{#if csatReport.by_agent.length}
|
||||
<div class="space-y-3">
|
||||
{#each csatReport.by_agent as item}
|
||||
<div>
|
||||
<div class="flex justify-between items-center text-sm mb-1">
|
||||
<span class="text-gray-700 truncate">{item.agent}</span>
|
||||
<span class="text-yellow-500 font-medium ml-2 shrink-0">{item.avg_rating ? item.avg_rating.toFixed(1) + ' ★' : '—'}</span>
|
||||
</div>
|
||||
<div class="bg-gray-100 rounded-full h-2">
|
||||
<div class="h-2 rounded-full bg-yellow-400" style="width:{item.avg_rating ? item.avg_rating / 5 * 100 : 0}%"></div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-0.5">{item.total_rated} calificaciones</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-gray-400 text-sm">Sin datos</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if csatReport.recent_comments?.length > 0}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 class="font-semibold text-gray-700 mb-4">Comentarios recientes</h3>
|
||||
<div class="space-y-3">
|
||||
{#each csatReport.recent_comments as c}
|
||||
<div class="flex gap-3 items-start pb-3 border-b border-gray-100 last:border-0">
|
||||
<span class="text-yellow-400 font-bold text-lg shrink-0 leading-none">
|
||||
{'★'.repeat(c.rating)}{'☆'.repeat(5 - c.rating)}
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-sm text-gray-700">{c.comment}</p>
|
||||
<p class="text-xs text-gray-400 mt-0.5">
|
||||
{c.rated_at ? new Date(c.rated_at).toLocaleDateString('es-MX', { day: '2-digit', month: 'short', year: 'numeric' }) : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ESTADO VACIO -->
|
||||
{:else if !isLoading}
|
||||
<div class="flex justify-center py-16">
|
||||
<p class="text-gray-400">Selecciona un período o cambia de pestaña para ver el reporte.</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
492
frontend-internal/src/routes/test-report/+page.svelte
Normal file
492
frontend-internal/src/routes/test-report/+page.svelte
Normal file
@@ -0,0 +1,492 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
onMount(() => {
|
||||
if (!$auth.isAuthenticated) goto('/login');
|
||||
});
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
interface EndpointDef {
|
||||
id: string;
|
||||
label: string;
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||
path: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface EndpointResult {
|
||||
status: number | null;
|
||||
ok: boolean | null;
|
||||
ms: number | null;
|
||||
error: string | null;
|
||||
preview: string | null;
|
||||
tested: boolean;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
interface PageDef {
|
||||
label: string;
|
||||
href: string;
|
||||
description: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
// ─── Backend Endpoints ───────────────────────────────────────────────────────
|
||||
const GROUPS: { name: string; color: string; endpoints: EndpointDef[] }[] = [
|
||||
{
|
||||
name: 'Auth',
|
||||
color: 'bg-purple-100 text-purple-800',
|
||||
endpoints: [
|
||||
{ id: 'auth-me', label: 'Perfil actual', method: 'GET', path: '/auth/me', description: 'Información del usuario autenticado' },
|
||||
{ id: 'auth-refresh', label: 'Refrescar token', method: 'POST', path: '/auth/refresh', description: 'Renovar access token (POST)' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Health',
|
||||
color: 'bg-green-100 text-green-800',
|
||||
endpoints: [
|
||||
{ id: 'health', label: 'Health Check', method: 'GET', path: '/health', description: 'Estado general del sistema' },
|
||||
{ id: 'health-details', label: 'Health Detallado', method: 'GET', path: '/health/detailed', description: 'Estado con detalle de dependencias' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Tenants',
|
||||
color: 'bg-blue-100 text-blue-800',
|
||||
endpoints: [
|
||||
{ id: 'tenants-list', label: 'Listar Tenants', method: 'GET', path: '/tenants/', description: 'Todos los tenants registrados' },
|
||||
{ id: 'tenant-stats', label: 'Stats Tenant', method: 'GET', path: '/tenants/stats', description: 'Estadísticas globales de tenants' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Users',
|
||||
color: 'bg-indigo-100 text-indigo-800',
|
||||
endpoints: [
|
||||
{ id: 'users-list', label: 'Listar Usuarios', method: 'GET', path: '/users/', description: 'Todos los usuarios del sistema' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Tickets',
|
||||
color: 'bg-orange-100 text-orange-800',
|
||||
endpoints: [
|
||||
{ id: 'tickets-list', label: 'Listar Tickets', method: 'GET', path: '/tickets/', description: 'Tickets con paginación' },
|
||||
{ id: 'tickets-stats', label: 'Stats Tickets', method: 'GET', path: '/tickets/stats', description: 'Estadísticas de tickets' },
|
||||
{ id: 'tickets-comments',label: 'Comentarios recientes', method: 'GET', path: '/tickets/comments/recent',description: 'Últimos comentarios' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Categories',
|
||||
color: 'bg-amber-100 text-amber-800',
|
||||
endpoints: [
|
||||
{ id: 'cats-list', label: 'Listar Categorías', method: 'GET', path: '/categories/', description: 'Categorías de tickets' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Systems',
|
||||
color: 'bg-gray-100 text-gray-800',
|
||||
endpoints: [
|
||||
{ id: 'sys-list', label: 'Listar Sistemas', method: 'GET', path: '/systems/', description: 'Sistemas soportados' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'SLA',
|
||||
color: 'bg-teal-100 text-teal-800',
|
||||
endpoints: [
|
||||
{ id: 'sla-dashboard', label: 'Dashboard SLA', method: 'GET', path: '/sla/dashboard', description: 'Panel SLA principal' },
|
||||
{ id: 'sla-compliance', label: 'SLA Compliance', method: 'GET', path: '/sla/compliance', description: 'Métricas de cumplimiento SLA' },
|
||||
{ id: 'sla-at-risk', label: 'Tickets en Riesgo', method: 'GET', path: '/sla/at-risk', description: 'Tickets próximos a violar SLA' },
|
||||
{ id: 'sla-violations', label: 'Violaciones SLA', method: 'GET', path: '/sla/violations', description: 'Tickets que violaron SLA' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Reports',
|
||||
color: 'bg-pink-100 text-pink-800',
|
||||
endpoints: [
|
||||
{ id: 'rep-summary', label: 'Resumen General', method: 'GET', path: '/reports/summary', description: 'Resumen ejecutivo de reportes' },
|
||||
{ id: 'rep-agents', label: 'Por Agente', method: 'GET', path: '/reports/agents', description: 'Rendimiento por agente' },
|
||||
{ id: 'rep-categories', label: 'Por Categoría', method: 'GET', path: '/reports/categories', description: 'Distribución por categoría' },
|
||||
{ id: 'rep-trends', label: 'Tendencias', method: 'GET', path: '/reports/trends', description: 'Tendencias temporales' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Audit',
|
||||
color: 'bg-red-100 text-red-800',
|
||||
endpoints: [
|
||||
{ id: 'audit-logs', label: 'Logs de Auditoría', method: 'GET', path: '/audit/logs', description: 'Bitácora de acciones' },
|
||||
{ id: 'audit-stats', label: 'Stats Auditoría', method: 'GET', path: '/audit/stats', description: 'Estadísticas de auditoría' },
|
||||
{ id: 'audit-security', label: 'Análisis Seguridad', method: 'GET', path: '/audit/security/analysis',description: 'Análisis de amenazas de seguridad' },
|
||||
{ id: 'audit-users', label: 'Actividad Usuarios', method: 'GET', path: '/audit/users', description: 'Actividad por usuario' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Client Profile',
|
||||
color: 'bg-cyan-100 text-cyan-800',
|
||||
endpoints: [
|
||||
{ id: 'client-profile', label: 'Perfil Cliente', method: 'GET', path: '/client/profile', description: 'Perfil organización cliente' },
|
||||
{ id: 'client-tickets', label: 'Tickets Cliente', method: 'GET', path: '/client/tickets', description: 'Tickets del cliente' },
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Frontend Pages ──────────────────────────────────────────────────────────
|
||||
const FRONTEND_PAGES: PageDef[] = [
|
||||
{ label: 'Dashboard', href: '/', description: 'Panel principal de administración', roles: ['todos'] },
|
||||
{ label: 'Tickets', href: '/tickets', description: 'Gestión y listado de tickets', roles: ['ADMIN', 'SUPPORT_MANAGER', 'AGENT'] },
|
||||
{ label: 'Clientes (Tenants)', href: '/tenants', description: 'Administración de organizaciones cliente', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
|
||||
{ label: 'Usuarios', href: '/users', description: 'Gestión de usuarios internos', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
|
||||
{ label: 'Categorías', href: '/categories', description: 'Categorías y SLA por área', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
|
||||
{ label: 'Sistemas', href: '/systems', description: 'Catálogo de sistemas soportados', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
|
||||
{ label: 'SLA Dashboard', href: '/sla', description: 'Monitoreo de SLAs y cumplimiento', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
|
||||
{ label: 'SLA En Riesgo', href: '/sla/at-risk', description: 'Tickets próximos a violar SLA', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
|
||||
{ label: 'SLA Violaciones', href: '/sla/violations', description: 'Historial de violaciones SLA', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
|
||||
{ label: 'Reportes', href: '/reports', description: 'Reportes estadísticos e informes', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
|
||||
{ label: 'Auditoría', href: '/audit', description: 'Bitácora de acciones del sistema', roles: ['ADMIN', 'AUDITOR'] },
|
||||
{ label: 'Seguridad', href: '/audit/security', description: 'Análisis de amenazas y eventos de seguridad',roles: ['ADMIN'] },
|
||||
{ label: 'Perfil', href: '/profile', description: 'Perfil y configuración de seguridad', roles: ['todos'] },
|
||||
{ label: 'Rate Limits', href: '/rate-limits', description: 'Estado de rate limiting por IP', roles: ['ADMIN'] },
|
||||
{ label: 'Reporte Endpoints', href: '/test-report', description: 'Esta misma página', roles: ['ADMIN'] },
|
||||
];
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────────────
|
||||
let results: Record<string, EndpointResult> = {};
|
||||
let isTesting = false;
|
||||
let testingId: string | null = null;
|
||||
let totalOk = 0;
|
||||
let totalFail = 0;
|
||||
let activeTab: 'endpoints' | 'pages' = 'endpoints';
|
||||
|
||||
// Init results
|
||||
for (const group of GROUPS) {
|
||||
for (const ep of group.endpoints) {
|
||||
results[ep.id] = { status: null, ok: null, ms: null, error: null, preview: null, tested: false, loading: false };
|
||||
}
|
||||
}
|
||||
|
||||
function getToken(): string | null {
|
||||
return (typeof window !== 'undefined')
|
||||
? localStorage.getItem('internal_auth_token')
|
||||
: null;
|
||||
}
|
||||
|
||||
function getTenantId(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const stored = localStorage.getItem('internal_auth_user');
|
||||
if (stored) return JSON.parse(stored)?.tenant_id ?? null;
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
async function testEndpoint(ep: EndpointDef) {
|
||||
results[ep.id] = { ...results[ep.id], loading: true, tested: false };
|
||||
results = results; // trigger reactivity
|
||||
|
||||
const token = getToken();
|
||||
const tenantId = getTenantId();
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
if (tenantId) headers['X-Tenant-ID'] = tenantId;
|
||||
|
||||
const url = `/api/v1${ep.path}`;
|
||||
const t0 = performance.now();
|
||||
|
||||
try {
|
||||
let fetchOptions: RequestInit = { method: ep.method, headers };
|
||||
// For non-GET we don't send a body to avoid validation errors
|
||||
const res = await fetch(url, fetchOptions);
|
||||
const ms = Math.round(performance.now() - t0);
|
||||
let preview: string | null = null;
|
||||
try {
|
||||
const text = await res.text();
|
||||
const obj = JSON.parse(text);
|
||||
preview = JSON.stringify(obj, null, 2).slice(0, 500);
|
||||
if (JSON.stringify(obj, null, 2).length > 500) preview += '\n...';
|
||||
} catch { /* ignore */ }
|
||||
|
||||
results[ep.id] = { status: res.status, ok: res.ok, ms, error: null, preview, tested: true, loading: false };
|
||||
} catch (e: any) {
|
||||
const ms = Math.round(performance.now() - t0);
|
||||
results[ep.id] = { status: null, ok: false, ms, error: e.message ?? 'Network error', preview: null, tested: true, loading: false };
|
||||
}
|
||||
|
||||
results = results;
|
||||
recalcCounters();
|
||||
}
|
||||
|
||||
async function testAll() {
|
||||
isTesting = true;
|
||||
totalOk = 0;
|
||||
totalFail = 0;
|
||||
for (const group of GROUPS) {
|
||||
for (const ep of group.endpoints) {
|
||||
testingId = ep.id;
|
||||
await testEndpoint(ep);
|
||||
}
|
||||
}
|
||||
testingId = null;
|
||||
isTesting = false;
|
||||
}
|
||||
|
||||
function recalcCounters() {
|
||||
totalOk = Object.values(results).filter(r => r.tested && r.ok).length;
|
||||
totalFail = Object.values(results).filter(r => r.tested && !r.ok).length;
|
||||
}
|
||||
|
||||
function statusBadge(r: EndpointResult): { text: string; cls: string } {
|
||||
if (r.loading) return { text: 'Probando...', cls: 'bg-gray-100 text-gray-600 animate-pulse' };
|
||||
if (!r.tested) return { text: 'Sin probar', cls: 'bg-gray-100 text-gray-400' };
|
||||
if (r.ok) return { text: `${r.status} OK`, cls: 'bg-green-100 text-green-700' };
|
||||
return { text: r.status ? `${r.status} Error` : 'Fallo red', cls: 'bg-red-100 text-red-700' };
|
||||
}
|
||||
|
||||
function methodBadge(method: string): string {
|
||||
const map: Record<string, string> = {
|
||||
GET: 'bg-blue-100 text-blue-700',
|
||||
POST: 'bg-green-100 text-green-700',
|
||||
PUT: 'bg-yellow-100 text-yellow-700',
|
||||
DELETE: 'bg-red-100 text-red-700',
|
||||
PATCH: 'bg-purple-100 text-purple-700',
|
||||
};
|
||||
return map[method] ?? 'bg-gray-100 text-gray-700';
|
||||
}
|
||||
|
||||
let expandedIds = new Set<string>();
|
||||
function toggleExpand(id: string) {
|
||||
if (expandedIds.has(id)) expandedIds.delete(id);
|
||||
else expandedIds.add(id);
|
||||
expandedIds = new Set(expandedIds);
|
||||
}
|
||||
|
||||
const testedCount = () => Object.values(results).filter(r => r.tested).length;
|
||||
const totalEndpoints = GROUPS.reduce((acc, g) => acc + g.endpoints.length, 0);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Reporte de Endpoints - ServiceManager</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
|
||||
<!-- Header -->
|
||||
<div class="md:flex md:items-center md:justify-between mb-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-900">Reporte de Endpoints & Páginas</h2>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Diagnóstico de conectividad de todos los endpoints del backend y páginas del frontend.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2 md:mt-0">
|
||||
<button
|
||||
on:click={testAll}
|
||||
disabled={isTesting}
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
||||
>
|
||||
{#if isTesting}
|
||||
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Probando endpoints...
|
||||
{:else}
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
Probar Todo
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Bar -->
|
||||
{#if testedCount() > 0}
|
||||
<div class="mb-6 grid grid-cols-3 gap-4">
|
||||
<div class="bg-white rounded-lg border p-4 text-center">
|
||||
<div class="text-2xl font-bold text-gray-800">{testedCount()}/{totalEndpoints}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">Endpoints probados</div>
|
||||
</div>
|
||||
<div class="bg-green-50 rounded-lg border border-green-200 p-4 text-center">
|
||||
<div class="text-2xl font-bold text-green-700">{totalOk}</div>
|
||||
<div class="text-xs text-green-600 mt-1">OK / Exitosos</div>
|
||||
</div>
|
||||
<div class="bg-red-50 rounded-lg border border-red-200 p-4 text-center">
|
||||
<div class="text-2xl font-bold text-red-700">{totalFail}</div>
|
||||
<div class="text-xs text-red-600 mt-1">Errores / Fallidos</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-gray-200 mb-6">
|
||||
<button
|
||||
on:click={() => activeTab = 'endpoints'}
|
||||
class="px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors {activeTab === 'endpoints' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}"
|
||||
>
|
||||
Endpoints Backend ({totalEndpoints})
|
||||
</button>
|
||||
<button
|
||||
on:click={() => activeTab = 'pages'}
|
||||
class="px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors {activeTab === 'pages' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}"
|
||||
>
|
||||
Páginas Frontend ({FRONTEND_PAGES.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ─── Endpoints Tab ─────────────────────────────────────────────────────── -->
|
||||
{#if activeTab === 'endpoints'}
|
||||
<div class="space-y-6">
|
||||
{#each GROUPS as group}
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
<!-- Group header -->
|
||||
<div class="flex items-center justify-between px-5 py-3 bg-gray-50 border-b border-gray-200">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider px-2 py-0.5 rounded-full {group.color}">
|
||||
{group.name}
|
||||
</span>
|
||||
<span class="text-xs text-gray-400">{group.endpoints.length} endpoint{group.endpoints.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div class="flex gap-1 items-center">
|
||||
{#each group.endpoints as ep}
|
||||
{#if results[ep.id].tested}
|
||||
<span class="w-2 h-2 rounded-full {results[ep.id].ok ? 'bg-green-400' : 'bg-red-400'}" title={ep.label}></span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Endpoints list -->
|
||||
<div class="divide-y divide-gray-100">
|
||||
{#each group.endpoints as ep}
|
||||
{@const r = results[ep.id]}
|
||||
{@const badge = statusBadge(r)}
|
||||
<div class="px-5 py-3">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<!-- Method badge -->
|
||||
<span class="text-xs font-bold px-2 py-0.5 rounded font-mono {methodBadge(ep.method)}">
|
||||
{ep.method}
|
||||
</span>
|
||||
<!-- Path -->
|
||||
<code class="text-xs text-gray-700 bg-gray-50 px-2 py-0.5 rounded border border-gray-200 font-mono flex-shrink-0">
|
||||
/api/v1{ep.path}
|
||||
</code>
|
||||
<!-- Label -->
|
||||
<span class="text-sm text-gray-700 flex-1 min-w-0 truncate">{ep.label}</span>
|
||||
|
||||
<!-- Status + timing -->
|
||||
<div class="flex items-center gap-2 ml-auto flex-shrink-0">
|
||||
{#if r.ms !== null && r.tested}
|
||||
<span class="text-xs text-gray-400">{r.ms}ms</span>
|
||||
{/if}
|
||||
<span class="text-xs font-medium px-2 py-0.5 rounded-full {badge.cls}">{badge.text}</span>
|
||||
<!-- Test individual -->
|
||||
<button
|
||||
on:click={() => testEndpoint(ep)}
|
||||
disabled={r.loading || isTesting}
|
||||
class="ml-1 text-xs px-2 py-1 rounded border border-gray-200 hover:border-blue-300 hover:text-blue-600 text-gray-500 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{r.loading ? '...' : 'Probar'}
|
||||
</button>
|
||||
<!-- Expand preview -->
|
||||
{#if r.tested && r.preview}
|
||||
<button
|
||||
on:click={() => toggleExpand(ep.id)}
|
||||
class="text-xs px-2 py-1 rounded border border-gray-200 hover:border-blue-300 hover:text-blue-600 text-gray-500 transition-colors"
|
||||
>
|
||||
{expandedIds.has(ep.id) ? 'Ocultar' : 'Ver respuesta'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="mt-0.5 text-xs text-gray-400 ml-0.5">{ep.description}</p>
|
||||
|
||||
<!-- Error message -->
|
||||
{#if r.tested && r.error}
|
||||
<div class="mt-2 text-xs text-red-600 bg-red-50 rounded px-2 py-1.5 font-mono">{r.error}</div>
|
||||
{/if}
|
||||
|
||||
<!-- Response preview -->
|
||||
{#if expandedIds.has(ep.id) && r.preview}
|
||||
<pre class="mt-2 text-xs text-gray-700 bg-gray-50 border border-gray-200 rounded p-3 overflow-x-auto whitespace-pre-wrap break-words max-h-48">{r.preview}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ─── Frontend Pages Tab ─────────────────────────────────────────────────── -->
|
||||
{#if activeTab === 'pages'}
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Página</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ruta</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Descripción</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Roles</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Acción</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-100">
|
||||
{#each FRONTEND_PAGES as pg}
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-6 py-3">
|
||||
<span class="text-sm font-medium text-gray-900">{pg.label}</span>
|
||||
</td>
|
||||
<td class="px-6 py-3">
|
||||
<code class="text-xs bg-gray-100 text-gray-700 px-2 py-0.5 rounded font-mono">{pg.href}</code>
|
||||
</td>
|
||||
<td class="px-6 py-3">
|
||||
<span class="text-xs text-gray-500">{pg.description}</span>
|
||||
</td>
|
||||
<td class="px-6 py-3">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each pg.roles as role}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded-full bg-blue-50 text-blue-600 font-medium">{role}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-3">
|
||||
<a
|
||||
href={pg.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs text-blue-600 hover:text-blue-800 underline font-medium"
|
||||
>
|
||||
Abrir ↗
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="px-6 py-4 bg-gray-50 border-t border-gray-200">
|
||||
<p class="text-xs text-gray-500">
|
||||
<strong>Nota:</strong> Las páginas se abren en una nueva pestaña para verificar su renderizado.
|
||||
Asegúrate de estar autenticado antes de acceder a rutas protegidas.
|
||||
</p>
|
||||
<div class="mt-3 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{#each FRONTEND_PAGES as pg}
|
||||
<a
|
||||
href={pg.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-200 hover:border-blue-300 hover:bg-blue-50 text-xs text-gray-700 hover:text-blue-700 transition-all group"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5 text-gray-400 group-hover:text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
<span class="truncate font-medium">{pg.label}</span>
|
||||
<code class="ml-auto text-gray-400 text-xs flex-shrink-0">{pg.href}</code>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -4,12 +4,23 @@ import { defineConfig } from 'vite';
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
port: 3000,
|
||||
// Puerto: dentro del contenedor siempre 3000; Docker mapea 3001:3000 al host
|
||||
port: parseInt(process.env.PORT || '3001'),
|
||||
host: '0.0.0.0',
|
||||
watch: {
|
||||
usePolling: true,
|
||||
interval: 500
|
||||
},
|
||||
// HMR: el browser llega al contenedor a través del puerto 3001 del host
|
||||
hmr: {
|
||||
host: 'localhost',
|
||||
clientPort: parseInt(process.env.HMR_CLIENT_PORT || '3001')
|
||||
},
|
||||
// Permitir que Vite sirva archivos del filesystem del contenedor
|
||||
fs: {
|
||||
allow: ['/app', '.'],
|
||||
strict: false
|
||||
},
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.PUBLIC_API_URL || 'http://localhost:8000',
|
||||
@@ -19,10 +30,10 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
preview: {
|
||||
port: 3000,
|
||||
port: parseInt(process.env.PORT || '3001'),
|
||||
host: '0.0.0.0'
|
||||
},
|
||||
build: {
|
||||
target: 'esnext'
|
||||
}
|
||||
build: {
|
||||
target: 'esnext'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -216,15 +216,18 @@ async def main():
|
||||
if user_data["email"] in existing_emails:
|
||||
print(f" ⏭ Ya existe: {user_data['email']}")
|
||||
continue
|
||||
pwd = user_data.pop("password")
|
||||
# Usar copia para no mutar el dict original (permite re-ejecutar el script)
|
||||
ud = user_data.copy()
|
||||
pwd = ud.pop("password")
|
||||
hashed_pwd = security.hash_password(pwd)
|
||||
user = User(
|
||||
tenant_id=tenant_id,
|
||||
password_hash=hashed_pwd,
|
||||
**user_data,
|
||||
email_verified=True, # Marcar como verificado para permitir login
|
||||
**ud,
|
||||
)
|
||||
session.add(user)
|
||||
print(f" ✓ {user_data['email']} [{user_data['role'].value}] pwd={pwd}")
|
||||
print(f" ✓ {ud['email']} [{ud['role'].value}] pwd={pwd}")
|
||||
created_users += 1
|
||||
|
||||
await session.commit()
|
||||
|
||||
Reference in New Issue
Block a user