Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b46f48655 | |||
| f10b15d91b | |||
| 49dfb3ef24 | |||
| b187aa1b46 | |||
| cd3d7e816f | |||
| 63925fe305 | |||
| c146a6c3c3 | |||
| ba779bde55 | |||
| ba94152074 | |||
| bd21207aae | |||
| 1ccc39732b | |||
| ceea67eb2b | |||
| 517297e89a | |||
|
|
16d795e8bd | ||
| f80a57a697 | |||
| e6440395ea | |||
| cc1e964c3a | |||
| 75726d915f | |||
| 42a5bb54cc | |||
| ae0bfc9d62 | |||
| 0bc4caf65d | |||
| be762585d2 | |||
| 32cc8b6ccd | |||
| caeac3e96c | |||
| 6af80f1960 | |||
| 57944b364c | |||
| 3a061a005c | |||
| d9b783107f | |||
| 5a292daa0b | |||
| 87e094b668 | |||
| 2cb8b58808 | |||
| 9f973464b9 | |||
| 90f9c9c6e6 | |||
| 51a8515b40 | |||
| ff9a8998b2 | |||
| 1543397212 | |||
| 2033a35a2b | |||
| 96cd09476c | |||
| 96084b89c0 | |||
| 771b6eba30 | |||
| 0f94d1cc67 | |||
| a8e7af87dc | |||
| e766e5b747 | |||
| 471c263158 | |||
| 5cff309422 | |||
| 94e9d91586 | |||
| c9dd024c7e | |||
| a13a8cb0e8 | |||
| 659fc2f446 | |||
| 91ff49cdec | |||
| ac0cb6f132 | |||
| d4ff32dac7 | |||
| ea682d8cd9 | |||
| 896c99d586 | |||
| 2125504831 | |||
| 0d7cdf51ca | |||
|
|
6215fc40a7 |
187
.github/copilot-context.md
vendored
Normal file
187
.github/copilot-context.md
vendored
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
# Configuración Avanzada de GitHub Copilot para ServiceManagerWeb
|
||||||
|
|
||||||
|
## Variables de Contexto Importantes
|
||||||
|
|
||||||
|
### Configuración del Sistema
|
||||||
|
```env
|
||||||
|
# Variables críticas a considerar
|
||||||
|
DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/servicemanager
|
||||||
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
JWT_SECRET_KEY=your-secret-key
|
||||||
|
TENANT_ISOLATION=strict
|
||||||
|
CORS_ORIGINS=["http://localhost:3000", "http://localhost:3001"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modelos de Datos Clave
|
||||||
|
|
||||||
|
#### User Model Completo
|
||||||
|
```python
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"))
|
||||||
|
email: Mapped[str] = mapped_column(unique=True, index=True)
|
||||||
|
role: Mapped[UserRole] = mapped_column(default=UserRole.CLIENT_USER)
|
||||||
|
is_active: Mapped[bool] = mapped_column(default=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Ticket Workflow States
|
||||||
|
```python
|
||||||
|
class TicketStatus(str, Enum):
|
||||||
|
OPEN = "open"
|
||||||
|
IN_PROGRESS = "in_progress"
|
||||||
|
PENDING_CLIENT = "pending_client"
|
||||||
|
RESOLVED = "resolved"
|
||||||
|
CLOSED = "closed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reglas de Implementación Específicas
|
||||||
|
|
||||||
|
### 1. Multi-Tenancy Estricto
|
||||||
|
- NUNCA hacer queries sin filtrar por `tenant_id`
|
||||||
|
- Middleware de tenant debe estar en toda request
|
||||||
|
- Validar permisos a nivel de tenant antes de operaciones
|
||||||
|
|
||||||
|
### 2. Audit Trail Obligatorio
|
||||||
|
```python
|
||||||
|
async def log_audit_event(
|
||||||
|
action: str,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: int,
|
||||||
|
user_id: int,
|
||||||
|
tenant_id: int,
|
||||||
|
details: dict = None
|
||||||
|
):
|
||||||
|
# Implementar en todas las operaciones CRUD críticas
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Error Handling Consistente
|
||||||
|
```python
|
||||||
|
# Backend
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Insufficient permissions for tenant resource"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
import { toast } from '$lib/stores/toast';
|
||||||
|
toast.error("Error al procesar la solicitud");
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Performance Patterns
|
||||||
|
```python
|
||||||
|
# Queries con paginación siempre
|
||||||
|
async def get_tickets_paginated(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: int,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 20
|
||||||
|
) -> Tuple[List[Ticket], int]:
|
||||||
|
# Select con join optimizado + count total
|
||||||
|
```
|
||||||
|
|
||||||
|
## Componentes Frontend Reutilizables
|
||||||
|
|
||||||
|
### Layout Structure
|
||||||
|
```
|
||||||
|
+layout.svelte (global)
|
||||||
|
├── Header.svelte (navigation)
|
||||||
|
├── Sidebar.svelte (menu)
|
||||||
|
└── Toast.svelte (notifications)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Form Patterns
|
||||||
|
```typescript
|
||||||
|
// Validation con Zod
|
||||||
|
const createTicketSchema = z.object({
|
||||||
|
title: z.string().min(5).max(200),
|
||||||
|
description: z.string().min(10),
|
||||||
|
priority: z.nativeEnum(TicketPriority),
|
||||||
|
category_id: z.number().positive()
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Debugging y Logging
|
||||||
|
|
||||||
|
### Backend Logging
|
||||||
|
```python
|
||||||
|
import structlog
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
# En cada endpoint
|
||||||
|
logger.info(
|
||||||
|
"ticket_created",
|
||||||
|
ticket_id=ticket.id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
tenant_id=current_user.tenant_id,
|
||||||
|
correlation_id=request.correlation_id
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Error Boundary
|
||||||
|
```svelte
|
||||||
|
<!-- En +layout.svelte -->
|
||||||
|
{#if $page.error}
|
||||||
|
<ErrorComponent error={$page.error} />
|
||||||
|
{/if}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comandos de Desarrollo Específicos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend development
|
||||||
|
cd backend && uvicorn app.main:app --reload --port 8000
|
||||||
|
|
||||||
|
# Frontend internal (admin panel)
|
||||||
|
cd frontend-internal && npm run dev -- --port 3001
|
||||||
|
|
||||||
|
# Frontend client (customer portal)
|
||||||
|
cd frontend-client && npm run dev -- --port 3000
|
||||||
|
|
||||||
|
# Workers
|
||||||
|
cd workers && celery -A app.celery worker --loglevel=info
|
||||||
|
|
||||||
|
# Full stack con Docker
|
||||||
|
docker-compose -f docker-compose.dev.yml up
|
||||||
|
|
||||||
|
# Database operations
|
||||||
|
docker-compose exec backend alembic revision --autogenerate -m "Description"
|
||||||
|
docker-compose exec backend alembic upgrade head
|
||||||
|
|
||||||
|
# Testing complete
|
||||||
|
docker-compose exec backend pytest -v --cov=app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Review Checklist
|
||||||
|
|
||||||
|
- [ ] ✅ Multi-tenant isolation verificado
|
||||||
|
- [ ] 🔒 Autenticación/autorización implementada
|
||||||
|
- [ ] 📊 Audit logging en operaciones críticas
|
||||||
|
- [ ] 🚀 Performance considerado (índices, paginación)
|
||||||
|
- [ ] 🧪 Tests unitarios/integración agregados
|
||||||
|
- [ ] 📝 OpenAPI documentation actualizada
|
||||||
|
- [ ] 🎨 UI/UX consistente con design system
|
||||||
|
- [ ] 🐛 Error handling comprehensivo
|
||||||
|
- [ ] 📱 Responsive design verificado
|
||||||
|
- [ ] 🔍 Type safety con TypeScript/mypy
|
||||||
|
|
||||||
|
## Herramientas de Calidad
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Python quality
|
||||||
|
ruff check . --fix
|
||||||
|
black .
|
||||||
|
mypy .
|
||||||
|
bandit -r app/
|
||||||
|
safety check
|
||||||
|
|
||||||
|
# JavaScript/TypeScript quality
|
||||||
|
npm run lint
|
||||||
|
npm run type-check
|
||||||
|
npm run format
|
||||||
|
```
|
||||||
|
|
||||||
|
Esta configuración te ayudará a mantener la calidad enterprise del sistema ServiceManagerWeb.
|
||||||
94
.github/copilot-instructions.md
vendored
94
.github/copilot-instructions.md
vendored
@@ -98,4 +98,98 @@ black .
|
|||||||
mypy .
|
mypy .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Configuración de IA Especializada
|
||||||
|
|
||||||
|
### Prioridades de Asistencia
|
||||||
|
1. **Seguridad primero**: Siempre implementar autenticación/autorización en nuevos endpoints
|
||||||
|
2. **Multi-tenancy**: Verificar aislamiento de datos entre tenants en toda nueva funcionalidad
|
||||||
|
3. **Performance**: Considerar impacto en bases de datos grandes (índices, paginación, caching)
|
||||||
|
4. **Auditabilidad**: Registrar acciones sensibles en el sistema de audit
|
||||||
|
5. **Escalabilidad**: Código preparado para crecimiento empresarial
|
||||||
|
|
||||||
|
### Patrones Preferidos
|
||||||
|
|
||||||
|
#### Backend (FastAPI)
|
||||||
|
```python
|
||||||
|
# Estructura de endpoint típica
|
||||||
|
@router.post("/", response_model=schemas.TicketResponse)
|
||||||
|
async def create_ticket(
|
||||||
|
ticket: schemas.TicketCreate,
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
# 1. Validar permisos multi-tenant
|
||||||
|
# 2. Procesar lógica de negocio
|
||||||
|
# 3. Audit log
|
||||||
|
# 4. Return response
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Frontend (SvelteKit)
|
||||||
|
```typescript
|
||||||
|
// Store pattern con Zod validation
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
const TicketSchema = z.object({
|
||||||
|
title: z.string().min(5),
|
||||||
|
priority: z.enum(['LOW', 'MEDIUM', 'HIGH', 'URGENT'])
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Contexto de Archivos Clave
|
||||||
|
|
||||||
|
#### Backend Core
|
||||||
|
- `app/core/security.py`: JWT, permissions, rate limiting
|
||||||
|
- `app/middleware/tenant.py`: Multi-tenant context
|
||||||
|
- `app/models/`: SQLAlchemy models con relationships
|
||||||
|
- `app/api/v1/endpoints/`: Endpoints REST por dominio
|
||||||
|
|
||||||
|
#### Frontend Routing
|
||||||
|
- `frontend-internal/`: Panel administrativo interno
|
||||||
|
- `frontend-client/`: Portal de clientes
|
||||||
|
- Ambos usan SvelteKit con layout compartido
|
||||||
|
|
||||||
|
#### Workers/Tasks
|
||||||
|
- `workers/app/tasks/`: Tareas Celery asíncronas
|
||||||
|
- `email_tasks.py`: Notificaciones y plantillas
|
||||||
|
- `sla_tasks.py`: Monitoreo de SLAs automático
|
||||||
|
|
||||||
|
### Troubleshooting Común
|
||||||
|
|
||||||
|
#### Database Issues
|
||||||
|
```bash
|
||||||
|
# Reset migrations
|
||||||
|
docker-compose exec backend alembic downgrade base
|
||||||
|
docker-compose exec backend alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Multi-tenant Debug
|
||||||
|
- Verificar `tenant_context` middleware
|
||||||
|
- Headers: `X-Tenant-ID` en requests
|
||||||
|
- Queries siempre filtrar por tenant_id
|
||||||
|
|
||||||
|
#### Frontend Build Errors
|
||||||
|
```bash
|
||||||
|
cd frontend-internal && npm run build
|
||||||
|
cd frontend-client && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Convenciones de Desarrollo
|
||||||
|
|
||||||
|
#### Naming
|
||||||
|
- **Models**: PascalCase (User, Ticket, TenantOrganization)
|
||||||
|
- **Endpoints**: kebab-case (/api/v1/user-management/)
|
||||||
|
- **Components**: PascalCase.svelte (TicketCard.svelte)
|
||||||
|
- **Stores**: camelCase (ticketStore.ts)
|
||||||
|
|
||||||
|
#### Error Handling
|
||||||
|
- Backend: HTTPException con status codes apropiados
|
||||||
|
- Frontend: Toast notifications para UX
|
||||||
|
- Logs: Structured logging con correlation IDs
|
||||||
|
|
||||||
|
#### Testing Strategy
|
||||||
|
- Unit: Lógica de negocio y validaciones
|
||||||
|
- Integration: Endpoints completos con DB
|
||||||
|
- E2E: Flujos críticos multi-tenant
|
||||||
|
|
||||||
Cuando trabajes en este proyecto, siempre considera la naturaleza multi-tenant y empresarial del sistema.
|
Cuando trabajes en este proyecto, siempre considera la naturaleza multi-tenant y empresarial del sistema.
|
||||||
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
|
||||||
809
README.md
809
README.md
@@ -1,142 +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)
|
## Tabla de Contenidos
|
||||||
- **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
|
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/
|
ServiceManagerWeb/
|
||||||
├── backend/ # FastAPI app
|
├── backend/ # Aplicación FastAPI (Python 3.11)
|
||||||
├── frontend-client/ # SvelteKit app para clientes
|
│ ├── app/
|
||||||
├── frontend-internal/ # SvelteKit app para staff interno
|
│ │ ├── main.py # Punto de entrada, lifespan, middlewares
|
||||||
├── workers/ # Celery tasks
|
│ │ ├── api/v1/
|
||||||
├── db/ # Migrations y esquemas
|
│ │ │ ├── router.py # Registro de todos los routers
|
||||||
├── docker/ # Dockerfiles específicos
|
│ │ │ └── endpoints/ # Endpoints REST por dominio
|
||||||
├── docs/ # Documentación adicional
|
│ │ ├── core/ # Config, seguridad, base de datos, caché
|
||||||
├── scripts/ # Scripts de desarrollo/despliegue
|
│ │ ├── models/ # Modelos SQLAlchemy (ORM)
|
||||||
├── docker-compose.yml # Orquestación completa
|
│ │ ├── services/ # Lógica de negocio
|
||||||
└── .env.example # Variables de entorno
|
│ │ └── 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)
|
**Backend:** Python 3.11 · FastAPI · Pydantic v2 · SQLAlchemy 2.0 (async) · Alembic · Argon2 · PyJWT · Celery · Redis
|
||||||
- FastAPI (async)
|
|
||||||
- Pydantic v2
|
|
||||||
- SQLAlchemy 2.0 (async)
|
|
||||||
- Alembic (migrations)
|
|
||||||
- Argon2 (hashing passwords)
|
|
||||||
- PyJWT
|
|
||||||
- Celery + Redis
|
|
||||||
|
|
||||||
### Frontend (JavaScript/TypeScript)
|
**Frontend:** Node.js 18 · SvelteKit · TypeScript · TailwindCSS · Zod
|
||||||
- SvelteKit
|
|
||||||
- TypeScript
|
|
||||||
- TailwindCSS
|
|
||||||
- shadcn/ui o similar
|
|
||||||
- Zod (validación)
|
|
||||||
|
|
||||||
### Infraestructura
|
**Infraestructura:** PostgreSQL 15 · Redis 7 · Docker Compose · Nginx
|
||||||
- PostgreSQL 15+
|
|
||||||
- Redis 7+
|
|
||||||
- Docker & Docker Compose
|
|
||||||
- Nginx (reverse proxy)
|
|
||||||
|
|
||||||
## Dominios del Sistema
|
---
|
||||||
|
|
||||||
1. **Auth**: Usuarios, roles, permisos, 2FA
|
## Roles y permisos
|
||||||
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
|
### Personal interno (staff)
|
||||||
|
| Rol | Descripción |
|
||||||
### Internos (Staff)
|
|-----|-------------|
|
||||||
- `ADMIN`: Control total del sistema
|
| `ADMIN` | Control total del sistema |
|
||||||
- `SUPPORT_MANAGER`: Gestión de equipos y SLAs
|
| `SUPPORT_MANAGER` | Gestión de equipos y configuración de SLAs |
|
||||||
- `AGENT`: Atención de tickets
|
| `AGENT` | Atención y resolución de tickets |
|
||||||
- `AUDITOR`: Solo lectura para auditoría
|
| `AUDITOR` | Solo lectura para revisiones y cumplimiento |
|
||||||
|
|
||||||
### Clientes
|
### Clientes
|
||||||
- `CLIENT_ADMIN`: Gestión de organización cliente
|
| Rol | Descripción |
|
||||||
- `CLIENT_USER`: Creación y seguimiento de tickets
|
|-----|-------------|
|
||||||
|
| `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
|
```bash
|
||||||
# Clonar y configurar
|
# Levantar todos los servicios (segundo plano)
|
||||||
git clone <repo>
|
docker compose up -d
|
||||||
cd ServiceManagerWeb
|
|
||||||
cp .env.example .env
|
|
||||||
|
|
||||||
# Levantar servicios
|
# Levantar con herramientas de desarrollo
|
||||||
docker-compose up -d
|
docker compose --profile dev up -d
|
||||||
|
|
||||||
# Verificar estado
|
# Ver logs en tiempo real de todos los servicios
|
||||||
docker-compose ps
|
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
|
### Base de datos (Alembic)
|
||||||
|
|
||||||
- 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
|
```bash
|
||||||
# Backend
|
# Aplicar todas las migraciones pendientes
|
||||||
cd backend
|
cd backend
|
||||||
python -m uvicorn app.main:app --reload --port 8000
|
alembic upgrade head
|
||||||
|
|
||||||
# Frontend Cliente
|
# Ver estado de migraciones
|
||||||
cd frontend-client
|
alembic current
|
||||||
npm run dev -- --port 3000
|
|
||||||
|
|
||||||
# Frontend Interno
|
# Revertir última migración
|
||||||
cd frontend-internal
|
alembic downgrade -1
|
||||||
npm run dev -- --port 3001
|
|
||||||
|
|
||||||
# Workers
|
# Crear nueva migración (después de modificar models/)
|
||||||
cd workers
|
alembic revision --autogenerate -m "nombre descriptivo del cambio"
|
||||||
celery -A app.worker worker --loglevel=info
|
|
||||||
celery -A app.worker beat --loglevel=info
|
# Con Docker:
|
||||||
|
docker exec servicemanager-backend alembic upgrade head
|
||||||
```
|
```
|
||||||
|
|
||||||
## Testing
|
### Calidad de código
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend tests
|
|
||||||
cd backend
|
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
|
pytest
|
||||||
|
|
||||||
# Frontend tests
|
# Con cobertura detallada
|
||||||
cd frontend-client
|
pytest --cov=app --cov-report=html
|
||||||
npm test
|
|
||||||
cd ../frontend-internal
|
# Abrir reporte de cobertura (Linux/macOS)
|
||||||
npm test
|
open htmlcov/index.html
|
||||||
|
# Windows
|
||||||
|
start htmlcov/index.html
|
||||||
|
|
||||||
|
# Prueba específica
|
||||||
|
pytest tests/test_auth.py -v
|
||||||
|
|
||||||
|
# Con Docker
|
||||||
|
docker exec servicemanager-backend pytest -v --cov=app
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend-internal # o frontend-client
|
||||||
|
npm test # Ejecutar una vez
|
||||||
|
npm run test:watch # Modo observador
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Solución de problemas
|
||||||
|
|
||||||
|
### El backend no inicia — error en `DATABASE_URL`
|
||||||
|
|
||||||
|
**Síntoma:** El contenedor `servicemanager-backend` reinicia continuamente.
|
||||||
|
|
||||||
|
**Causa frecuente:** El archivo `.env` no existe o tiene `DATABASE_URL` apuntando a `localhost`
|
||||||
|
en lugar del nombre del servicio Docker `postgres`.
|
||||||
|
|
||||||
|
**Solución:**
|
||||||
|
```bash
|
||||||
|
# Verificar que .env existe
|
||||||
|
ls .env # Linux/macOS
|
||||||
|
dir .env # Windows
|
||||||
|
|
||||||
|
# Si no existe, crearlo
|
||||||
|
cp .env.example .env # Linux/macOS
|
||||||
|
Copy-Item .env.example .env # Windows PowerShell
|
||||||
|
|
||||||
|
# Verificar el valor correcto en .env:
|
||||||
|
# DATABASE_URL=postgresql+asyncpg://servicemanager:servicemanager123@postgres:5432/servicemanager
|
||||||
|
# ^^^^^^^
|
||||||
|
# Nombre de servicio Docker, NO localhost
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Error 500 en login / "connect ECONNREFUSED"
|
||||||
|
|
||||||
|
**Síntoma:** El frontend muestra error 500 al hacer login, o la consola del navegador
|
||||||
|
muestra `ECONNREFUSED 127.0.0.1:8000`.
|
||||||
|
|
||||||
|
**Causa:** El proxy de Vite no encuentra el backend.
|
||||||
|
|
||||||
|
**Solución en Docker:** El proxy ya está configurado para usar `PUBLIC_API_URL`.
|
||||||
|
Verifica en `docker-compose.yml` que `frontend-internal` y `frontend-client` tienen:
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
- PUBLIC_API_URL=http://backend:8000
|
||||||
|
```
|
||||||
|
Después reinicia:
|
||||||
|
```bash
|
||||||
|
docker compose restart frontend-internal frontend-client
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solución en desarrollo local:** Asegúrate de que el backend está corriendo:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/health
|
||||||
|
# Debe responder: {"status": "ok", ...}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### El frontend-internal y frontend-client usan el mismo puerto localmente
|
||||||
|
|
||||||
|
**Síntoma:** Al correr ambos frontends sin Docker, uno de los dos falla
|
||||||
|
con `Port 3000 is already in use`.
|
||||||
|
|
||||||
|
**Solución:**
|
||||||
|
- `frontend-client` → usa el puerto **3000** (por defecto con `npm run dev`)
|
||||||
|
- `frontend-internal` → usa el puerto **3001** (configurado en `vite.config.js`)
|
||||||
|
|
||||||
|
Nunca hay conflicto si los iniciaste con `npm run dev` en cada carpeta por separado.
|
||||||
|
Si aún hay conflicto, mata el proceso en ese puerto:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux / macOS
|
||||||
|
lsof -ti:3000 | xargs kill -9
|
||||||
|
|
||||||
|
# Windows (PowerShell)
|
||||||
|
Get-Process -Id (Get-NetTCPConnection -LocalPort 3000).OwningProcess | Stop-Process -Force
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### El tenant slug es incorrecto al hacer login
|
||||||
|
|
||||||
|
**Síntoma:** Login falla con "credenciales inválidas" aunque el email y contraseña son correctos.
|
||||||
|
|
||||||
|
**Causa:** El campo `tenant_slug` no corresponde a ningún tenant en la base de datos.
|
||||||
|
|
||||||
|
**Solución:**
|
||||||
|
```bash
|
||||||
|
# Ver los tenants disponibles
|
||||||
|
docker exec servicemanager-backend python -c "
|
||||||
|
import asyncio
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||||
|
from sqlalchemy import text
|
||||||
|
import os
|
||||||
|
async def main():
|
||||||
|
engine = create_async_engine(os.environ['DATABASE_URL'])
|
||||||
|
async with AsyncSession(engine) as s:
|
||||||
|
result = await s.execute(text('SELECT slug, name FROM tenants'))
|
||||||
|
for row in result:
|
||||||
|
print(row)
|
||||||
|
asyncio.run(main())
|
||||||
|
"
|
||||||
|
```
|
||||||
|
Tenant por defecto (después del seed): **`aduanasoft-demo`**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Puerto ocupado — cambiar puertos de los servicios
|
||||||
|
|
||||||
|
Edita `docker-compose.yml` y modifica **solo el número izquierdo** del mapeo de puertos:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Ejemplo: mover el backend al puerto 9000
|
||||||
|
backend:
|
||||||
|
ports:
|
||||||
|
- "9000:8000" # accesible en localhost:9000
|
||||||
|
|
||||||
|
# Ejemplo: mover el frontend al puerto 4000
|
||||||
|
frontend-client:
|
||||||
|
ports:
|
||||||
|
- "4000:3000" # accesible en localhost:4000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Migraciones fallidas — `alembic upgrade head` da error
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verificar el estado actual
|
||||||
|
docker exec servicemanager-backend alembic current
|
||||||
|
|
||||||
|
# Si hay conflicto, hacer downgrade hasta la base y volver a subir
|
||||||
|
docker exec servicemanager-backend alembic downgrade base
|
||||||
|
docker exec servicemanager-backend alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Módulo Python no encontrado (`ModuleNotFoundError`)
|
||||||
|
|
||||||
|
**Con Docker:** El módulo no está en `requirements.txt` o la imagen no fue reconstruida.
|
||||||
|
```bash
|
||||||
|
# Reconstruir la imagen del backend
|
||||||
|
docker compose build backend
|
||||||
|
docker compose up -d backend
|
||||||
|
```
|
||||||
|
|
||||||
|
**Local:** El entorno virtual no está activado.
|
||||||
|
```bash
|
||||||
|
# Verificar que el venv está activo (debe aparecer (.venv) en el prompt)
|
||||||
|
which python # Linux/macOS — debe apuntar a .venv/
|
||||||
|
# Windows:
|
||||||
|
where python # debe apuntar a .venv\Scripts\python.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `npm: command not found` o versión de Node incorrecta
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --version # Debe ser v18.x o superior
|
||||||
|
npm --version # Debe ser 9.x o superior
|
||||||
|
```
|
||||||
|
|
||||||
|
Si Node no está instalado, descárgalo desde https://nodejs.org/ (elige "LTS").
|
||||||
|
|
||||||
|
En macOS con Homebrew:
|
||||||
|
```bash
|
||||||
|
brew install node@18
|
||||||
|
```
|
||||||
|
|
||||||
|
En Linux (Ubuntu/Debian):
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
|
||||||
|
sudo apt-get install -y nodejs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `docker-compose` no se reconoce como comando
|
||||||
|
|
||||||
|
En versiones modernas de Docker Desktop, el comando es `docker compose` (con espacio, sin guion).
|
||||||
|
Si tienes instalación separada de Docker Compose v1, usa `docker-compose` (con guion).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Logs de los contenedores
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ver qué está fallando
|
||||||
|
docker compose logs backend --tail=50
|
||||||
|
docker compose logs frontend-internal --tail=50
|
||||||
|
docker compose logs postgres --tail=20
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Contribución
|
## Contribución
|
||||||
|
|
||||||
1. Fork del proyecto
|
1. Haz fork del proyecto
|
||||||
2. Crear feature branch (`git checkout -b feature/nueva-funcionalidad`)
|
2. Crea una rama de funcionalidad: `git checkout -b feature/nombre-funcionalidad`
|
||||||
3. Commit cambios (`git commit -am 'Agregar nueva funcionalidad'`)
|
3. Realiza tus cambios siguiendo las convenciones del proyecto
|
||||||
4. Push a branch (`git push origin feature/nueva-funcionalidad`)
|
4. Ejecuta las pruebas: `pytest` y el linter: `ruff check .`
|
||||||
5. Crear Pull Request
|
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
|
## Licencia
|
||||||
|
|
||||||
Propietario - Aduanasoft © 2026
|
Propietario — Aduanasoft © 2026. Todos los derechos reservados.
|
||||||
BIN
backend/.coverage
Normal file
BIN
backend/.coverage
Normal file
Binary file not shown.
@@ -56,6 +56,22 @@ backend/
|
|||||||
- [x] TOTP 2FA implementation
|
- [x] TOTP 2FA implementation
|
||||||
- [x] Validation con Pydantic v2
|
- [x] Validation con Pydantic v2
|
||||||
|
|
||||||
|
### Rate limiting (login)
|
||||||
|
|
||||||
|
El endpoint `/{API_VERSION}/auth/login` incluye rate limiting (best-effort) usando Redis:
|
||||||
|
|
||||||
|
- Por IP: limita intentos totales por ventana
|
||||||
|
- Por identidad: limita por `(tenant_id, email)` por ventana
|
||||||
|
|
||||||
|
Responde `429 Too Many Requests` con header `Retry-After`.
|
||||||
|
|
||||||
|
Variables de entorno (ver `app/core/config.py`):
|
||||||
|
|
||||||
|
- `RATE_LIMIT_ENABLED` (default: `true`)
|
||||||
|
- `LOGIN_RATE_LIMIT_WINDOW_SECONDS` (default: `300`)
|
||||||
|
- `LOGIN_RATE_LIMIT_IP_MAX_ATTEMPTS` (default: `30`)
|
||||||
|
- `LOGIN_RATE_LIMIT_ID_MAX_ATTEMPTS` (default: `10`)
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -157,8 +173,8 @@ Ver `.env.example` para todas las variables disponibles.
|
|||||||
- [x] CORS restrictivo
|
- [x] CORS restrictivo
|
||||||
- [x] Input validation con Pydantic
|
- [x] Input validation con Pydantic
|
||||||
- [x] SQL injection protection (SQLAlchemy)
|
- [x] SQL injection protection (SQLAlchemy)
|
||||||
- [x] Rate limiting (TODO: implementar)
|
- [x] Rate limiting (login)
|
||||||
- [x] File upload validation (TODO: implementar)
|
- [x] File upload validation (extensión + firma básica + tamaño + streaming)
|
||||||
- [x] XSS protection (headers en nginx)
|
- [x] XSS protection (headers en nginx)
|
||||||
|
|
||||||
## Próximos pasos
|
## Próximos pasos
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
from sqlalchemy import text
|
|
||||||
from app.core.database import engine
|
|
||||||
|
|
||||||
async def add_columns():
|
|
||||||
print("Starting schema update...")
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
try:
|
|
||||||
await conn.execute(text("ALTER TABLE tickets ADD COLUMN system_id UUID REFERENCES systems(id)"))
|
|
||||||
print("Added system_id column")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error adding system_id (might exist): {e}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
await conn.execute(text("ALTER TABLE tickets ADD COLUMN category_id UUID REFERENCES categories(id)"))
|
|
||||||
print("Added category_id column")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error adding category_id (might exist): {e}")
|
|
||||||
print("Schema update finished.")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(add_columns())
|
|
||||||
84
backend/alembic.ini
Normal file
84
backend/alembic.ini
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# A generic, single database configuration for ServiceManagerWeb
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# path to migration scripts
|
||||||
|
script_location = migrations
|
||||||
|
|
||||||
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||||
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
|
# file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# sys.path path, will be prepended to sys.path if present.
|
||||||
|
# defaults to the current working directory.
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
# timezone to use when rendering the date within the migration file
|
||||||
|
# as well as the filename.
|
||||||
|
# If specified, requires the python-dateutil library that can be
|
||||||
|
# installed by adding `alembic[tz]` to the pip requirements
|
||||||
|
# string value is passed to dateutil.tz.gettz()
|
||||||
|
# leave blank for localtime
|
||||||
|
# timezone =
|
||||||
|
|
||||||
|
# max length of characters to apply to the
|
||||||
|
# "slug" field
|
||||||
|
# truncate_slug_length = 40
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
# set to 'true' to allow .pyc and .pyo files without
|
||||||
|
# a source .py file to be detected as revisions in the
|
||||||
|
# versions/ directory
|
||||||
|
# sourceless = false
|
||||||
|
|
||||||
|
# version path separator; As mentioned above, this is the character used to split
|
||||||
|
# version_locations. The default within new alembic.ini files is "os", which uses
|
||||||
|
# os.pathsep. If this key is omitted entirely, it falls back to the legacy
|
||||||
|
# behavior of splitting on spaces and/or commas.
|
||||||
|
# Valid values for version_path_separator are:
|
||||||
|
#
|
||||||
|
# version_path_separator = :
|
||||||
|
# version_path_separator = ;
|
||||||
|
# version_path_separator = space
|
||||||
|
version_path_separator = os
|
||||||
|
|
||||||
|
# the output encoding used when revision files
|
||||||
|
# are written from script.py.mako
|
||||||
|
# output_encoding = utf-8
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
from typing import Optional
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from starlette.requests import Request
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
from jose import jwt, JWTError
|
from jose import jwt, JWTError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -9,15 +11,56 @@ from app.core.database import get_db
|
|||||||
from app.core.security import security
|
from app.core.security import security
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.models.user import User, UserRole
|
from app.models.user import User, UserRole
|
||||||
|
from app.models.tenant import Tenant
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
# Define OAuth2 scheme here or import from auth if needed.
|
# Esquema OAuth2 centralizado — auth.py importa desde aquí
|
||||||
# Defining here creates a separate instance which is fine as they share config.
|
# Soporta: 1) Authorization: Bearer header (Swagger/API clients)
|
||||||
# Ideally auth.py should import from here, but modifying auth.py is risky now.
|
# 2) Cookie access_token HttpOnly (apps web)
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/{settings.API_VERSION}/auth/login")
|
_bearer_scheme = OAuth2PasswordBearer(
|
||||||
|
tokenUrl=f"/{settings.API_VERSION}/auth/login",
|
||||||
|
auto_error=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def oauth2_scheme(
|
||||||
|
request: Request,
|
||||||
|
bearer_token: Optional[str] = Depends(_bearer_scheme),
|
||||||
|
) -> str:
|
||||||
|
"""Extrae JWT desde header Authorization (prioridad) o cookie del frontend correcto.
|
||||||
|
|
||||||
|
Usa el header X-App para seleccionar la cookie:
|
||||||
|
- X-App: internal → solo 'internal_access_token'
|
||||||
|
- X-App: client → solo 'client_access_token'
|
||||||
|
- sin header → prueba ambas (compatibilidad con Swagger/CLI)
|
||||||
|
"""
|
||||||
|
if bearer_token:
|
||||||
|
return bearer_token
|
||||||
|
|
||||||
|
app_hint = request.headers.get("X-App", "").lower()
|
||||||
|
if app_hint == "internal":
|
||||||
|
token = request.cookies.get("internal_access_token")
|
||||||
|
elif app_hint == "client":
|
||||||
|
token = request.cookies.get("client_access_token")
|
||||||
|
else:
|
||||||
|
# Fallback para Swagger, tests y clientes sin header
|
||||||
|
token = (
|
||||||
|
request.cookies.get("internal_access_token")
|
||||||
|
or request.cookies.get("client_access_token")
|
||||||
|
)
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Not authenticated",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(
|
async def get_current_user(
|
||||||
|
request: Request,
|
||||||
token: str = Depends(oauth2_scheme),
|
token: str = Depends(oauth2_scheme),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
) -> User:
|
) -> User:
|
||||||
@@ -44,6 +87,16 @@ async def get_current_user(
|
|||||||
|
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
raise HTTPException(status_code=400, detail="Inactive user")
|
raise HTTPException(status_code=400, detail="Inactive user")
|
||||||
|
|
||||||
|
# Enforce that tenant header (if present) matches the authenticated user's tenant.
|
||||||
|
# Roles globales (is_global) pueden operar en cualquier tenant → omitir chequeo.
|
||||||
|
# Roles de cliente (is_client) deben coincidir con su propio tenant.
|
||||||
|
request_tenant_id = getattr(getattr(request, "state", None), "tenant_id", None)
|
||||||
|
if request_tenant_id and user.role.is_client and str(user.tenant_id) != str(request_tenant_id):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Tenant header does not match authenticated user",
|
||||||
|
)
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@@ -55,3 +108,20 @@ async def get_current_active_superuser(
|
|||||||
status_code=403, detail="The user doesn't have enough privileges"
|
status_code=403, detail="The user doesn't have enough privileges"
|
||||||
)
|
)
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_tenant(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
) -> Tenant:
|
||||||
|
"""Obtener el tenant del usuario actual."""
|
||||||
|
result = await db.execute(select(Tenant).where(Tenant.id == current_user.tenant_id))
|
||||||
|
tenant = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not tenant:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Tenant not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
return tenant
|
||||||
|
|||||||
65
backend/app/api/schemas/__init__.py
Normal file
65
backend/app/api/schemas/__init__.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""Schemas package initialization."""
|
||||||
|
|
||||||
|
from .auth import (
|
||||||
|
LoginRequest, LoginResponse, RefreshTokenRequest, TokenResponse,
|
||||||
|
TwoFactorStatusResponse, TwoFactorSetupResponse,
|
||||||
|
TwoFactorEnableRequest, TwoFactorEnableResponse, TwoFactorDisableRequest,
|
||||||
|
ChangePasswordRequest, ForgotPasswordRequest, ResetPasswordRequest,
|
||||||
|
)
|
||||||
|
from .tenant import TenantBase, TenantCreate, TenantUpdate, TenantResponse
|
||||||
|
from .user import UserCreate, UserUpdate, UserResponse
|
||||||
|
from .category import CategoryCreate, CategoryUpdate, CategoryResponse
|
||||||
|
from .system import SystemCreate, SystemUpdate, SystemResponse
|
||||||
|
from .ticket import (
|
||||||
|
TicketCreate,
|
||||||
|
TicketUpdate,
|
||||||
|
TicketResponse,
|
||||||
|
TicketCloseRequest,
|
||||||
|
CommentCreate,
|
||||||
|
CommentResponse,
|
||||||
|
)
|
||||||
|
from .client_profile import (
|
||||||
|
ClientProfileCreate,
|
||||||
|
ClientProfileUpdate,
|
||||||
|
ClientProfileResponse,
|
||||||
|
ClientProfileSummary,
|
||||||
|
)
|
||||||
|
from .audit import * # noqa: F401,F403
|
||||||
|
from .sla import * # noqa: F401,F403
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Auth
|
||||||
|
"LoginRequest",
|
||||||
|
"LoginResponse",
|
||||||
|
"RefreshTokenRequest",
|
||||||
|
"TokenResponse",
|
||||||
|
# Tenant
|
||||||
|
"TenantBase",
|
||||||
|
"TenantCreate",
|
||||||
|
"TenantUpdate",
|
||||||
|
"TenantResponse",
|
||||||
|
# User
|
||||||
|
"UserCreate",
|
||||||
|
"UserUpdate",
|
||||||
|
"UserResponse",
|
||||||
|
# Category
|
||||||
|
"CategoryCreate",
|
||||||
|
"CategoryUpdate",
|
||||||
|
"CategoryResponse",
|
||||||
|
# System
|
||||||
|
"SystemCreate",
|
||||||
|
"SystemUpdate",
|
||||||
|
"SystemResponse",
|
||||||
|
# Ticket
|
||||||
|
"TicketCreate",
|
||||||
|
"TicketUpdate",
|
||||||
|
"TicketResponse",
|
||||||
|
"TicketCloseRequest",
|
||||||
|
"CommentCreate",
|
||||||
|
"CommentResponse",
|
||||||
|
# Client Profile
|
||||||
|
"ClientProfileCreate",
|
||||||
|
"ClientProfileUpdate",
|
||||||
|
"ClientProfileResponse",
|
||||||
|
"ClientProfileSummary",
|
||||||
|
]
|
||||||
25
backend/app/api/schemas/attachment.py
Normal file
25
backend/app/api/schemas/attachment.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""
|
||||||
|
Attachment Schemas - ServiceManagerWeb
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class AttachmentResponse(BaseModel):
|
||||||
|
"""Schema para respuesta de attachment"""
|
||||||
|
id: uuid.UUID
|
||||||
|
ticket_id: uuid.UUID
|
||||||
|
comment_id: Optional[uuid.UUID] = None
|
||||||
|
uploaded_by: uuid.UUID
|
||||||
|
filename: str
|
||||||
|
original_filename: str
|
||||||
|
mime_type: str
|
||||||
|
file_size: int
|
||||||
|
file_path: str
|
||||||
|
uploaded_by_name: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
download_url: Optional[str] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
192
backend/app/api/schemas/audit.py
Normal file
192
backend/app/api/schemas/audit.py
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
"""
|
||||||
|
Audit Schemas - ServiceManagerWeb
|
||||||
|
|
||||||
|
Schemas Pydantic para endpoints de auditoría
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, UUID4
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLogBase(BaseModel):
|
||||||
|
"""Schema base para audit logs."""
|
||||||
|
action: str = Field(..., description="Acci├│n realizada (ej: ticket.create)")
|
||||||
|
resource_type: str = Field(..., description="Tipo de recurso (ticket, user, etc.)")
|
||||||
|
resource_id: Optional[UUID4] = Field(None, description="ID del recurso afectado")
|
||||||
|
extra_metadata: Optional[Dict[str, Any]] = Field(None, description="Metadata adicional", alias="metadata")
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLogResponse(AuditLogBase):
|
||||||
|
"""
|
||||||
|
Schema de respuesta para audit logs.
|
||||||
|
|
||||||
|
Incluye toda la informaci├│n del log con datos del usuario.
|
||||||
|
"""
|
||||||
|
id: UUID4
|
||||||
|
tenant_id: UUID4
|
||||||
|
user_id: Optional[UUID4]
|
||||||
|
|
||||||
|
# Informaci├│n del usuario (si existe)
|
||||||
|
user_email: Optional[str] = None
|
||||||
|
user_name: Optional[str] = None
|
||||||
|
user_role: Optional[str] = None
|
||||||
|
|
||||||
|
# Contexto de la acci├│n
|
||||||
|
ip_address: Optional[str]
|
||||||
|
user_agent: Optional[str]
|
||||||
|
correlation_id: Optional[UUID4]
|
||||||
|
|
||||||
|
# Cambios realizados
|
||||||
|
old_values: Optional[Dict[str, Any]]
|
||||||
|
new_values: Optional[Dict[str, Any]]
|
||||||
|
|
||||||
|
# Timestamp
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
# Display friendly
|
||||||
|
action_display: str = Field(description="Acci├│n en formato amigable")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# SECURITY ANALYSIS SCHEMAS
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
class SecurityThreatPattern(BaseModel):
|
||||||
|
"""Patrón de amenaza detectado."""
|
||||||
|
id: str = Field(description="ID único de la amenaza (pattern_id)")
|
||||||
|
type: str = Field(description="Tipo de amenaza (brute_force, privilege_escalation, etc.)")
|
||||||
|
severity: str = Field(description="Severidad: low, medium, high, critical")
|
||||||
|
description: str = Field(description="Descripción de la amenaza")
|
||||||
|
occurrences: int = Field(description="Número de ocurrencias")
|
||||||
|
affected_ips: list[str] = Field(default=[], description="IPs involucradas")
|
||||||
|
affected_users: list[str] = Field(default=[], description="Usuarios afectados")
|
||||||
|
first_seen: datetime = Field(description="Primera ocurrencia")
|
||||||
|
last_seen: datetime = Field(description="Última ocurrencia")
|
||||||
|
recommended_action: str = Field(default="", description="Acción recomendada")
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityAnalysisResponse(BaseModel):
|
||||||
|
"""Análisis completo de seguridad."""
|
||||||
|
overall_risk_level: str = Field(description="Nivel de riesgo general: safe, low, medium, high, critical")
|
||||||
|
total_threats_detected: int = Field(description="Total de amenazas detectadas")
|
||||||
|
threats: list[SecurityThreatPattern] = Field(description="Lista de amenazas detectadas")
|
||||||
|
analysis_period_hours: int = Field(description="Período de análisis en horas")
|
||||||
|
generated_at: datetime = Field(description="Timestamp del análisis")
|
||||||
|
|
||||||
|
# Estadísticas de seguridad
|
||||||
|
failed_login_attempts: int = Field(description="Intentos fallidos de login")
|
||||||
|
suspicious_ips_count: int = Field(description="IPs sospechosas detectadas")
|
||||||
|
critical_actions_count: int = Field(description="Acciones críticas realizadas")
|
||||||
|
|
||||||
|
# Opciones de acción
|
||||||
|
recommended_actions: list[str] = Field(default=[], description="Acciones recomendadas")
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityActionRequest(BaseModel):
|
||||||
|
"""Solicitud de acción de seguridad."""
|
||||||
|
action_type: str = Field(description="Tipo de acción: block_ip, notify_admin, reset_password, etc.")
|
||||||
|
target: str = Field(description="Objetivo de la acción (IP, email, etc.)")
|
||||||
|
reason: str = Field(description="Razón de la acción")
|
||||||
|
duration_minutes: Optional[int] = Field(None, description="Duración del bloqueo en minutos")
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityActionResponse(BaseModel):
|
||||||
|
"""Respuesta de acción de seguridad."""
|
||||||
|
success: bool = Field(description="Si la acción fue exitosa")
|
||||||
|
message: str = Field(description="Mensaje descriptivo")
|
||||||
|
action_id: Optional[UUID4] = Field(None, description="ID de la acción registrada")
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityIncidentResponse(BaseModel):
|
||||||
|
"""Respuesta para incidentes de seguridad."""
|
||||||
|
id: str = Field(description="ID único del incidente")
|
||||||
|
title: str = Field(description="Título del incidente")
|
||||||
|
description: Optional[str] = Field(None, description="Descripción detallada")
|
||||||
|
severity: str = Field(description="Severidad: low, medium, high, critical")
|
||||||
|
status: str = Field(description="Estado: active, investigating, resolved")
|
||||||
|
incident_type: str = Field(description="Tipo de incidente")
|
||||||
|
affected_user: Optional[str] = Field(None, description="Usuario afectado")
|
||||||
|
source_ip: Optional[str] = Field(None, description="IP origen del incidente")
|
||||||
|
evidence: list[str] = Field(default=[], description="Evidencia del incidente")
|
||||||
|
metadata: Optional[Dict[str, Any]] = Field(None, description="Metadata adicional")
|
||||||
|
created_at: datetime = Field(description="Fecha de creación")
|
||||||
|
updated_at: Optional[datetime] = Field(None, description="Última actualización")
|
||||||
|
resolved_at: Optional[datetime] = Field(None, description="Fecha de resolución")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityIncidentListResponse(BaseModel):
|
||||||
|
"""Respuesta paginada de incidentes de seguridad."""
|
||||||
|
incidents: list[SecurityIncidentResponse]
|
||||||
|
total: int = Field(description="Total de incidentes")
|
||||||
|
page: int = Field(description="Página actual")
|
||||||
|
per_page: int = Field(description="Incidentes por página")
|
||||||
|
total_pages: int = Field(description="Total de páginas")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLogFilters(BaseModel):
|
||||||
|
"""
|
||||||
|
Filtros para consulta de audit logs.
|
||||||
|
|
||||||
|
Permite filtrar por m├║ltiples criterios.
|
||||||
|
"""
|
||||||
|
# Paginaci├│n
|
||||||
|
page: int = Field(default=1, ge=1, description="Número de página")
|
||||||
|
per_page: int = Field(default=50, ge=1, le=100, description="Elementos por página")
|
||||||
|
|
||||||
|
# Filtros
|
||||||
|
user_id: Optional[UUID4] = Field(None, description="Filtrar por usuario")
|
||||||
|
action: Optional[str] = Field(None, description="Filtrar por acción específica")
|
||||||
|
resource_type: Optional[str] = Field(None, description="Filtrar por tipo de recurso")
|
||||||
|
resource_id: Optional[UUID4] = Field(None, description="Filtrar por ID de recurso")
|
||||||
|
|
||||||
|
# Rango de fechas
|
||||||
|
date_from: Optional[datetime] = Field(None, description="Fecha inicio (ISO 8601)")
|
||||||
|
date_to: Optional[datetime] = Field(None, description="Fecha fin (ISO 8601)")
|
||||||
|
|
||||||
|
# B├║squeda
|
||||||
|
search: Optional[str] = Field(None, description="B├║squeda en acciones o recursos")
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLogStats(BaseModel):
|
||||||
|
"""
|
||||||
|
Estadísticas de auditoría.
|
||||||
|
|
||||||
|
Resumen de actividad del sistema.
|
||||||
|
"""
|
||||||
|
total_actions: int = Field(description="Total de acciones registradas")
|
||||||
|
actions_today: int = Field(description="Acciones en las ├║ltimas 24 horas")
|
||||||
|
actions_this_week: int = Field(description="Acciones en los últimos 7 días")
|
||||||
|
critical_actions_today: int = Field(description="Acciones críticas hoy (delete, cambios sensibles)")
|
||||||
|
|
||||||
|
# Top acciones
|
||||||
|
top_actions: Dict[str, int] = Field(description="Acciones más frecuentes")
|
||||||
|
|
||||||
|
# Top usuarios
|
||||||
|
top_users: Dict[str, int] = Field(description="Usuarios más activos")
|
||||||
|
|
||||||
|
# Actividad por tipo de recurso
|
||||||
|
by_resource_type: Dict[str, int] = Field(description="Acciones por tipo de recurso")
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLogListResponse(BaseModel):
|
||||||
|
"""
|
||||||
|
Respuesta paginada de audit logs.
|
||||||
|
"""
|
||||||
|
logs: list[AuditLogResponse]
|
||||||
|
total: int = Field(description="Total de registros")
|
||||||
|
page: int = Field(description="Página actual")
|
||||||
|
per_page: int = Field(description="Registros por página")
|
||||||
|
total_pages: int = Field(description="Total de páginas")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
96
backend/app/api/schemas/auth.py
Normal file
96
backend/app/api/schemas/auth.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"""
|
||||||
|
Auth Schemas - ServiceManagerWeb
|
||||||
|
|
||||||
|
Pydantic schemas para autenticación y autorización.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
"""Schema para solicitud de login."""
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
tenant_slug: str
|
||||||
|
totp_code: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class LoginResponse(BaseModel):
|
||||||
|
"""Schema de respuesta al login exitoso."""
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
expires_in: int
|
||||||
|
user: dict
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshTokenRequest(BaseModel):
|
||||||
|
"""Schema para renovar access token usando refresh token."""
|
||||||
|
refresh_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class TokenResponse(BaseModel):
|
||||||
|
"""Schema de respuesta al renovar token."""
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
expires_in: int
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 2FA / TOTP Schemas
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class TwoFactorStatusResponse(BaseModel):
|
||||||
|
"""Estado actual de 2FA del usuario autenticado."""
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorSetupResponse(BaseModel):
|
||||||
|
"""QR URI y clave manual devueltos al iniciar el setup de 2FA."""
|
||||||
|
secret: str
|
||||||
|
qr_uri: str
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorEnableRequest(BaseModel):
|
||||||
|
"""Código TOTP para confirmar y activar 2FA."""
|
||||||
|
totp_code: str
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorEnableResponse(BaseModel):
|
||||||
|
"""Resultado al habilitar 2FA: incluye los códigos de respaldo."""
|
||||||
|
enabled: bool
|
||||||
|
backup_codes: List[str]
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorDisableRequest(BaseModel):
|
||||||
|
"""Deshabilitar 2FA verificando con TOTP o código de respaldo."""
|
||||||
|
totp_code: Optional[str] = None
|
||||||
|
backup_code: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Cambio de contraseña
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class ChangePasswordRequest(BaseModel):
|
||||||
|
"""Schema para cambio de contraseña del usuario autenticado."""
|
||||||
|
current_password: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
model_config = {"json_schema_extra": {"example": {"current_password": "old_pass", "new_password": "new_secure_pass"}}}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Recuperación de contraseña
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class ForgotPasswordRequest(BaseModel):
|
||||||
|
"""Solicitar enlace de reseteo de contraseña por email."""
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class ResetPasswordRequest(BaseModel):
|
||||||
|
"""Aplicar nueva contraseña usando token de reseteo."""
|
||||||
|
token: str
|
||||||
|
new_password: str
|
||||||
48
backend/app/api/schemas/category.py
Normal file
48
backend/app/api/schemas/category.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""
|
||||||
|
Category Schemas - ServiceManagerWeb
|
||||||
|
|
||||||
|
Pydantic schemas para gestión de categorías de tickets.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryCreate(BaseModel):
|
||||||
|
"""Schema para crear categoría. No incluye tenant_id (se asigna automáticamente)."""
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
color: Optional[str] = None
|
||||||
|
sla_response_hours: int = 24
|
||||||
|
sla_resolution_hours: int = 72
|
||||||
|
auto_assign_to: Optional[uuid.UUID] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryUpdate(BaseModel):
|
||||||
|
"""Schema para actualizar categoría."""
|
||||||
|
name: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
color: Optional[str] = None
|
||||||
|
sla_response_hours: Optional[int] = None
|
||||||
|
sla_resolution_hours: Optional[int] = None
|
||||||
|
auto_assign_to: Optional[uuid.UUID] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryResponse(BaseModel):
|
||||||
|
"""Schema de respuesta con todos los campos públicos de la categoría."""
|
||||||
|
id: uuid.UUID
|
||||||
|
tenant_id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
color: Optional[str] = None
|
||||||
|
sla_response_hours: int
|
||||||
|
sla_resolution_hours: int
|
||||||
|
auto_assign_to: Optional[uuid.UUID] = None
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
202
backend/app/api/schemas/client_profile.py
Normal file
202
backend/app/api/schemas/client_profile.py
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
"""
|
||||||
|
Client Profile Schemas - ServiceManagerWeb
|
||||||
|
Esquemas de validación para el perfil empresarial de clientes
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, validator, EmailStr
|
||||||
|
from typing import Optional
|
||||||
|
from decimal import Decimal
|
||||||
|
from datetime import datetime
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class ClientProfileBase(BaseModel):
|
||||||
|
"""Schema base para ClientProfile."""
|
||||||
|
|
||||||
|
# === INFORMACIÓN GENERAL ===
|
||||||
|
business_name: Optional[str] = Field(None, max_length=255, description="Razón social")
|
||||||
|
commercial_name: Optional[str] = Field(None, max_length=255, description="Nombre comercial")
|
||||||
|
client_code: Optional[str] = Field(None, max_length=50, description="Clave de cliente")
|
||||||
|
client_type: Optional[str] = Field(None, max_length=50, description="Tipo de cliente")
|
||||||
|
rfc: Optional[str] = Field(None, max_length=13, description="RFC (México)")
|
||||||
|
tax_id: Optional[str] = Field(None, max_length=50, description="ID fiscal general")
|
||||||
|
|
||||||
|
# === UBICACIÓN ===
|
||||||
|
country: Optional[str] = Field(None, max_length=100, description="País")
|
||||||
|
state: Optional[str] = Field(None, max_length=100, description="Estado/Provincia")
|
||||||
|
city: Optional[str] = Field(None, max_length=100, description="Ciudad")
|
||||||
|
address: Optional[str] = Field(None, description="Dirección completa")
|
||||||
|
external_number: Optional[str] = Field(None, max_length=20, description="Número exterior")
|
||||||
|
internal_number: Optional[str] = Field(None, max_length=20, description="Número interior")
|
||||||
|
postal_code: Optional[str] = Field(None, max_length=10, description="Código postal")
|
||||||
|
neighborhood: Optional[str] = Field(None, max_length=100, description="Colonia")
|
||||||
|
|
||||||
|
# === CONTACTO ===
|
||||||
|
main_phone: Optional[str] = Field(None, max_length=20, description="Teléfono principal")
|
||||||
|
secondary_phone: Optional[str] = Field(None, max_length=20, description="Teléfono secundario")
|
||||||
|
direct_phone: Optional[str] = Field(None, max_length=20, description="Teléfono directo")
|
||||||
|
phone_extension: Optional[str] = Field(None, max_length=10, description="Extensión")
|
||||||
|
fax: Optional[str] = Field(None, max_length=20, description="Fax")
|
||||||
|
|
||||||
|
# === INFORMACIÓN ADICIONAL ===
|
||||||
|
business_hours: Optional[str] = Field(None, max_length=255, description="Horario de atención")
|
||||||
|
website: Optional[str] = Field(None, max_length=255, description="Página web")
|
||||||
|
main_email: Optional[EmailStr] = Field(None, description="Email principal")
|
||||||
|
billing_email: Optional[EmailStr] = Field(None, description="Email de facturación")
|
||||||
|
|
||||||
|
# === MARKETING ===
|
||||||
|
advertising_medium: Optional[str] = Field(None, max_length=255, description="Medio de publicidad")
|
||||||
|
nationality: Optional[str] = Field(None, max_length=100, description="Nacionalidad")
|
||||||
|
|
||||||
|
# === CONFIGURACIÓN EMPRESARIAL ===
|
||||||
|
logo_url: Optional[str] = Field(None, max_length=500, description="URL del logo")
|
||||||
|
company_representative: Optional[str] = Field(None, max_length=255, description="Representante de empresa")
|
||||||
|
legal_representative: Optional[str] = Field(None, max_length=255, description="Representante legal")
|
||||||
|
|
||||||
|
# === FINANZAS/FACTURACIÓN ===
|
||||||
|
credit_limit: Optional[Decimal] = Field(None, description="Límite de crédito")
|
||||||
|
payment_terms: Optional[str] = Field(None, max_length=100, description="Términos de pago")
|
||||||
|
preferred_currency: str = Field("MXN", max_length=3, description="Moneda preferida")
|
||||||
|
|
||||||
|
# === METADATOS ===
|
||||||
|
send_to_billing: bool = Field(False, description="Enviar a facturación")
|
||||||
|
is_active_client: bool = Field(True, description="Cliente activo")
|
||||||
|
is_prospect: bool = Field(False, description="Es prospecto")
|
||||||
|
notes: Optional[str] = Field(None, description="Notas adicionales")
|
||||||
|
|
||||||
|
@validator('rfc')
|
||||||
|
def validate_rfc(cls, v):
|
||||||
|
"""Validar formato de RFC mexicano."""
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
|
||||||
|
v = v.strip().upper()
|
||||||
|
if len(v) < 10 or len(v) > 13:
|
||||||
|
raise ValueError('RFC debe tener entre 10 y 13 caracteres')
|
||||||
|
|
||||||
|
# Validación básica de formato RFC
|
||||||
|
import re
|
||||||
|
rfc_pattern = r'^[A-ZÑ&]{3,4}[0-9]{6}[A-Z0-9]{3}$'
|
||||||
|
if not re.match(rfc_pattern, v):
|
||||||
|
raise ValueError('Formato de RFC inválido')
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator('postal_code')
|
||||||
|
def validate_postal_code(cls, v):
|
||||||
|
"""Validar código postal."""
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
|
||||||
|
v = v.strip()
|
||||||
|
if not v.isdigit() or len(v) != 5:
|
||||||
|
raise ValueError('Código postal debe tener 5 dígitos')
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator('website')
|
||||||
|
def validate_website(cls, v):
|
||||||
|
"""Validar formato de sitio web."""
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
|
||||||
|
v = v.strip()
|
||||||
|
if not v.startswith(('http://', 'https://')):
|
||||||
|
v = f"https://{v}"
|
||||||
|
|
||||||
|
import re
|
||||||
|
url_pattern = r'^https?://.+\..+'
|
||||||
|
if not re.match(url_pattern, v):
|
||||||
|
raise ValueError('Formato de sitio web inválido')
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator('preferred_currency')
|
||||||
|
def validate_currency(cls, v):
|
||||||
|
"""Validar código de moneda."""
|
||||||
|
valid_currencies = ['MXN', 'USD', 'EUR', 'GBP', 'CAD']
|
||||||
|
if v not in valid_currencies:
|
||||||
|
raise ValueError(f'Moneda debe ser una de: {", ".join(valid_currencies)}')
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ClientProfileCreate(ClientProfileBase):
|
||||||
|
"""Schema para crear un perfil de cliente."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ClientProfileUpdate(ClientProfileBase):
|
||||||
|
"""Schema para actualizar un perfil de cliente."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ClientProfileResponse(ClientProfileBase):
|
||||||
|
"""Schema de respuesta para ClientProfile."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
tenant_id: uuid.UUID
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def full_address(self) -> str:
|
||||||
|
"""Dirección completa formateada."""
|
||||||
|
address_parts = []
|
||||||
|
|
||||||
|
if self.address:
|
||||||
|
address_parts.append(self.address)
|
||||||
|
|
||||||
|
if self.external_number:
|
||||||
|
if self.internal_number:
|
||||||
|
address_parts.append(f"#{self.external_number}-{self.internal_number}")
|
||||||
|
else:
|
||||||
|
address_parts.append(f"#{self.external_number}")
|
||||||
|
|
||||||
|
if self.neighborhood:
|
||||||
|
address_parts.append(f"Col. {self.neighborhood}")
|
||||||
|
|
||||||
|
if self.city and self.state:
|
||||||
|
address_parts.append(f"{self.city}, {self.state}")
|
||||||
|
|
||||||
|
if self.postal_code:
|
||||||
|
address_parts.append(f"C.P. {self.postal_code}")
|
||||||
|
|
||||||
|
if self.country:
|
||||||
|
address_parts.append(self.country)
|
||||||
|
|
||||||
|
return ", ".join(address_parts)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_name(self) -> str:
|
||||||
|
"""Nombre para mostrar."""
|
||||||
|
return self.commercial_name or self.business_name or "Sin nombre"
|
||||||
|
|
||||||
|
|
||||||
|
class ClientProfileSummary(BaseModel):
|
||||||
|
"""Schema resumido para listados."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
tenant_id: uuid.UUID
|
||||||
|
business_name: Optional[str]
|
||||||
|
commercial_name: Optional[str]
|
||||||
|
rfc: Optional[str]
|
||||||
|
client_code: Optional[str]
|
||||||
|
city: Optional[str]
|
||||||
|
state: Optional[str]
|
||||||
|
main_phone: Optional[str]
|
||||||
|
main_email: Optional[str]
|
||||||
|
is_active_client: bool
|
||||||
|
is_prospect: bool
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_name(self) -> str:
|
||||||
|
"""Nombre para mostrar."""
|
||||||
|
return self.commercial_name or self.business_name or "Sin nombre"
|
||||||
227
backend/app/api/schemas/reports.py
Normal file
227
backend/app/api/schemas/reports.py
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
"""
|
||||||
|
Reports Schemas - ServiceManagerWeb
|
||||||
|
|
||||||
|
Schemas de respuesta para el módulo de reportes y estadísticas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# RESUMEN GENERAL
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
class TicketsByStatus(BaseModel):
|
||||||
|
"""Conteo de tickets agrupado por estado"""
|
||||||
|
new: int = 0
|
||||||
|
triage: int = 0
|
||||||
|
in_progress: int = 0
|
||||||
|
waiting_customer: int = 0
|
||||||
|
resolved: int = 0
|
||||||
|
closed: int = 0
|
||||||
|
reopened: int = 0
|
||||||
|
total: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class TicketsByPriority(BaseModel):
|
||||||
|
"""Conteo de tickets agrupado por prioridad"""
|
||||||
|
low: int = 0
|
||||||
|
medium: int = 0
|
||||||
|
high: int = 0
|
||||||
|
urgent: int = 0
|
||||||
|
total: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ReportSummaryResponse(BaseModel):
|
||||||
|
"""Resumen ejecutivo del período seleccionado"""
|
||||||
|
period_start: datetime
|
||||||
|
period_end: datetime
|
||||||
|
generated_at: datetime
|
||||||
|
|
||||||
|
# Totales del período
|
||||||
|
total_tickets: int
|
||||||
|
open_tickets: int # Tickets sin resolver
|
||||||
|
resolved_tickets: int # Tickets resueltos o cerrados
|
||||||
|
avg_resolution_hours: Optional[float] # Promedio de horas para resolver
|
||||||
|
avg_first_response_hours: Optional[float] # Promedio de horas para primera respuesta
|
||||||
|
|
||||||
|
# Satisfacción del cliente
|
||||||
|
avg_rating: Optional[float] # Promedio de calificación (1-5)
|
||||||
|
total_rated: int # Cuántos tickets tienen calificación
|
||||||
|
|
||||||
|
# Desglose por estado y prioridad
|
||||||
|
by_status: TicketsByStatus
|
||||||
|
by_priority: TicketsByPriority
|
||||||
|
|
||||||
|
# Comparación vs período anterior
|
||||||
|
tickets_change_pct: Optional[float] # % cambio vs período anterior
|
||||||
|
resolution_change_pct: Optional[float] # % cambio en tasa de resolución
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# RENDIMIENTO POR AGENTE
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
class AgentReportRow(BaseModel):
|
||||||
|
"""Estadísticas de un agente específico"""
|
||||||
|
agent_id: str
|
||||||
|
agent_name: str
|
||||||
|
agent_email: str
|
||||||
|
total_assigned: int # Total asignados en el período
|
||||||
|
resolved: int # Cuántos resolvió
|
||||||
|
open: int # Cuántos siguen abiertos
|
||||||
|
resolution_rate: float # Porcentaje de resolución (0-100)
|
||||||
|
avg_resolution_hours: Optional[float] # Promedio de horas para resolver
|
||||||
|
avg_rating: Optional[float] # Calificación promedio (1-5)
|
||||||
|
total_rated: int # Cuántos tickets calificaron al agente
|
||||||
|
urgent_handled: int # Urgentes atendidos
|
||||||
|
|
||||||
|
|
||||||
|
class AgentReportResponse(BaseModel):
|
||||||
|
"""Reporte de rendimiento por agente"""
|
||||||
|
period_start: datetime
|
||||||
|
period_end: datetime
|
||||||
|
generated_at: datetime
|
||||||
|
agents: List[AgentReportRow]
|
||||||
|
total_agents: int
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# TICKETS POR CATEGORÍA
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
class CategoryReportRow(BaseModel):
|
||||||
|
"""Estadísticas de una categoría"""
|
||||||
|
category_id: str
|
||||||
|
category_name: str
|
||||||
|
total_tickets: int
|
||||||
|
open_tickets: int
|
||||||
|
resolved_tickets: int
|
||||||
|
avg_resolution_hours: Optional[float]
|
||||||
|
sla_response_hours: int # SLA configurado para respuesta
|
||||||
|
sla_resolution_hours: int # SLA configurado para resolución
|
||||||
|
sla_compliance_pct: float # % de tickets que cumplieron SLA de resolución
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryReportResponse(BaseModel):
|
||||||
|
"""Reporte de tickets agrupado por categoría"""
|
||||||
|
period_start: datetime
|
||||||
|
period_end: datetime
|
||||||
|
generated_at: datetime
|
||||||
|
categories: List[CategoryReportRow]
|
||||||
|
uncategorized_count: int
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# TICKETS POR CLIENTE (TENANT)
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
class ClientReportRow(BaseModel):
|
||||||
|
"""Estadísticas de un cliente (tenant)"""
|
||||||
|
tenant_id: str
|
||||||
|
tenant_name: str
|
||||||
|
total_tickets: int
|
||||||
|
open_tickets: int
|
||||||
|
resolved_tickets: int
|
||||||
|
urgent_tickets: int
|
||||||
|
avg_resolution_hours: Optional[float]
|
||||||
|
avg_rating: Optional[float]
|
||||||
|
last_ticket_at: Optional[datetime]
|
||||||
|
|
||||||
|
|
||||||
|
class ClientReportResponse(BaseModel):
|
||||||
|
"""Reporte de tickets agrupado por cliente — solo ADMIN"""
|
||||||
|
period_start: datetime
|
||||||
|
period_end: datetime
|
||||||
|
generated_at: datetime
|
||||||
|
clients: List[ClientReportRow]
|
||||||
|
total_clients: int
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# TENDENCIAS (TICKETS EN EL TIEMPO)
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
class TrendDataPoint(BaseModel):
|
||||||
|
"""Un punto de datos en la línea de tendencia"""
|
||||||
|
date: str # Formato YYYY-MM-DD
|
||||||
|
created: int # Tickets creados ese día
|
||||||
|
resolved: int # Tickets resueltos ese día
|
||||||
|
net_open: int # Diferencia: creados - resueltos
|
||||||
|
|
||||||
|
|
||||||
|
class TrendsReportResponse(BaseModel):
|
||||||
|
"""Evolución de tickets día a día"""
|
||||||
|
period_start: datetime
|
||||||
|
period_end: datetime
|
||||||
|
generated_at: datetime
|
||||||
|
data_points: List[TrendDataPoint]
|
||||||
|
total_days: int
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# SATISFACCIÓN DEL CLIENTE (CSAT)
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
class CSATDistribution(BaseModel):
|
||||||
|
"""Distribución de calificaciones 1-5"""
|
||||||
|
rating_1: int = 0
|
||||||
|
rating_2: int = 0
|
||||||
|
rating_3: int = 0
|
||||||
|
rating_4: int = 0
|
||||||
|
rating_5: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class CSATReportResponse(BaseModel):
|
||||||
|
"""Reporte de satisfacción del cliente"""
|
||||||
|
period_start: datetime
|
||||||
|
period_end: datetime
|
||||||
|
generated_at: datetime
|
||||||
|
avg_rating: Optional[float]
|
||||||
|
total_rated: int
|
||||||
|
total_tickets: int
|
||||||
|
response_rate: float # % de tickets que recibieron calificación
|
||||||
|
distribution: CSATDistribution
|
||||||
|
by_category: List[Dict[str, Any]] # Promedio por categoría
|
||||||
|
by_agent: List[Dict[str, Any]] # Promedio por agente
|
||||||
|
recent_comments: List[Dict[str, Any]] = [] # Últimos comentarios de calificación
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# TICKETS POR SISTEMA AFECTADO
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
class SystemReportRow(BaseModel):
|
||||||
|
"""Estadísticas de un sistema afectado"""
|
||||||
|
system_id: str
|
||||||
|
system_name: str
|
||||||
|
total_tickets: int
|
||||||
|
open_tickets: int
|
||||||
|
resolved_tickets: int
|
||||||
|
urgent_tickets: int
|
||||||
|
avg_resolution_hours: Optional[float]
|
||||||
|
|
||||||
|
|
||||||
|
class SystemReportResponse(BaseModel):
|
||||||
|
"""Reporte de tickets agrupado por sistema afectado"""
|
||||||
|
period_start: datetime
|
||||||
|
period_end: datetime
|
||||||
|
generated_at: datetime
|
||||||
|
systems: List[SystemReportRow]
|
||||||
|
no_system_count: int # Tickets sin sistema asignado
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
253
backend/app/api/schemas/sla.py
Normal file
253
backend/app/api/schemas/sla.py
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
"""
|
||||||
|
SLA Schemas - ServiceManagerWeb
|
||||||
|
|
||||||
|
Schemas para el sistema de gestión de SLAs
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class SLATypeEnum(str, Enum):
|
||||||
|
"""Tipos de SLA"""
|
||||||
|
RESPONSE = "response"
|
||||||
|
RESOLUTION = "resolution"
|
||||||
|
|
||||||
|
|
||||||
|
class SLAStatusEnum(str, Enum):
|
||||||
|
"""Estados de cumplimiento SLA"""
|
||||||
|
MET = "met" # Cumplido
|
||||||
|
VIOLATED = "violated" # Violado
|
||||||
|
AT_RISK = "at_risk" # En riesgo (80%+ del tiempo)
|
||||||
|
PENDING = "pending" # Pendiente (ticket aún abierto)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# DASHBOARD SCHEMAS
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
class SLAComplianceMetrics(BaseModel):
|
||||||
|
"""Métricas de cumplimiento SLA"""
|
||||||
|
target_hours: int
|
||||||
|
met_count: int
|
||||||
|
violated_count: int
|
||||||
|
at_risk_count: int
|
||||||
|
total_count: int
|
||||||
|
compliance_percentage: float
|
||||||
|
avg_time_hours: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SLADashboardResponse(BaseModel):
|
||||||
|
"""Response del dashboard principal de SLA"""
|
||||||
|
tenant_id: uuid.UUID
|
||||||
|
period_start: datetime
|
||||||
|
period_end: datetime
|
||||||
|
generated_at: datetime
|
||||||
|
|
||||||
|
# Métricas generales
|
||||||
|
response_sla: SLAComplianceMetrics
|
||||||
|
resolution_sla: SLAComplianceMetrics
|
||||||
|
|
||||||
|
# Contadores rápidos
|
||||||
|
active_violations: int
|
||||||
|
at_risk_tickets: int
|
||||||
|
total_tickets_period: int
|
||||||
|
|
||||||
|
# Breakdown por categoría (top 5)
|
||||||
|
by_category: List[Dict[str, Any]]
|
||||||
|
|
||||||
|
# Breakdown por prioridad
|
||||||
|
by_priority: Dict[str, Dict[str, float]]
|
||||||
|
|
||||||
|
# Tendencias (comparación con período anterior)
|
||||||
|
trends: Dict[str, str]
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# VIOLATIONS SCHEMAS
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
class TicketBasicInfo(BaseModel):
|
||||||
|
"""Información básica del ticket"""
|
||||||
|
id: uuid.UUID
|
||||||
|
ticket_number: str
|
||||||
|
subject: str
|
||||||
|
priority: str
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserBasicInfo(BaseModel):
|
||||||
|
"""Información básica del usuario"""
|
||||||
|
id: uuid.UUID
|
||||||
|
first_name: str
|
||||||
|
last_name: str
|
||||||
|
email: str
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryBasicInfo(BaseModel):
|
||||||
|
"""Información básica de categoría"""
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
sla_response_hours: int
|
||||||
|
sla_resolution_hours: int
|
||||||
|
|
||||||
|
|
||||||
|
class SLAViolationResponse(BaseModel):
|
||||||
|
"""Detalle de una violación SLA"""
|
||||||
|
ticket: TicketBasicInfo
|
||||||
|
category: Optional[CategoryBasicInfo] = None
|
||||||
|
created_by: UserBasicInfo
|
||||||
|
assigned_to: Optional[UserBasicInfo] = None
|
||||||
|
|
||||||
|
sla_type: SLATypeEnum
|
||||||
|
sla_due_at: datetime
|
||||||
|
violated_at: datetime
|
||||||
|
hours_overdue: float
|
||||||
|
|
||||||
|
# Contexto adicional
|
||||||
|
first_response_at: Optional[datetime] = None
|
||||||
|
resolved_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SLAViolationsListResponse(BaseModel):
|
||||||
|
"""Lista paginada de violaciones"""
|
||||||
|
violations: List[SLAViolationResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
per_page: int
|
||||||
|
total_pages: int
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# TICKETS AT RISK
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
class SLATicketAtRisk(BaseModel):
|
||||||
|
"""Ticket que está en riesgo de violar SLA"""
|
||||||
|
ticket: TicketBasicInfo
|
||||||
|
category: Optional[CategoryBasicInfo] = None
|
||||||
|
assigned_to: Optional[UserBasicInfo] = None
|
||||||
|
|
||||||
|
sla_type: SLATypeEnum
|
||||||
|
sla_due_at: datetime
|
||||||
|
time_remaining_hours: float
|
||||||
|
risk_percentage: float # 0-100, qué % del tiempo ha pasado
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SLAAtRiskListResponse(BaseModel):
|
||||||
|
"""Lista de tickets en riesgo"""
|
||||||
|
tickets: List[SLATicketAtRisk]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# METRICS & REPORTS SCHEMAS
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
class SLAMetricsByCategory(BaseModel):
|
||||||
|
"""Métricas SLA por categoría"""
|
||||||
|
category_id: uuid.UUID
|
||||||
|
category_name: str
|
||||||
|
response_sla_compliance: float
|
||||||
|
resolution_sla_compliance: float
|
||||||
|
total_tickets: int
|
||||||
|
response_violations: int
|
||||||
|
resolution_violations: int
|
||||||
|
avg_response_time_hours: Optional[float]
|
||||||
|
avg_resolution_time_hours: Optional[float]
|
||||||
|
|
||||||
|
|
||||||
|
class SLAMetricsByAgent(BaseModel):
|
||||||
|
"""Métricas SLA por agente"""
|
||||||
|
agent_id: uuid.UUID
|
||||||
|
agent_name: str
|
||||||
|
tickets_assigned: int
|
||||||
|
response_sla_met: int
|
||||||
|
resolution_sla_met: int
|
||||||
|
response_compliance: float
|
||||||
|
resolution_compliance: float
|
||||||
|
avg_response_time_hours: Optional[float]
|
||||||
|
avg_resolution_time_hours: Optional[float]
|
||||||
|
|
||||||
|
|
||||||
|
class SLAMetricsByPriority(BaseModel):
|
||||||
|
"""Métricas SLA por prioridad"""
|
||||||
|
priority: str
|
||||||
|
total_tickets: int
|
||||||
|
response_sla_compliance: float
|
||||||
|
resolution_sla_compliance: float
|
||||||
|
avg_response_time_hours: Optional[float]
|
||||||
|
avg_resolution_time_hours: Optional[float]
|
||||||
|
|
||||||
|
|
||||||
|
class SLADetailedMetricsResponse(BaseModel):
|
||||||
|
"""Response de métricas detalladas"""
|
||||||
|
tenant_id: uuid.UUID
|
||||||
|
date_from: datetime
|
||||||
|
date_to: datetime
|
||||||
|
group_by: str # 'category', 'agent', 'priority'
|
||||||
|
|
||||||
|
by_category: Optional[List[SLAMetricsByCategory]] = None
|
||||||
|
by_agent: Optional[List[SLAMetricsByAgent]] = None
|
||||||
|
by_priority: Optional[List[SLAMetricsByPriority]] = None
|
||||||
|
|
||||||
|
generated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# HISTORICAL TRENDS
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
class SLADailyTrend(BaseModel):
|
||||||
|
"""Tendencia diaria de SLA"""
|
||||||
|
date: str # YYYY-MM-DD
|
||||||
|
response_compliance: float
|
||||||
|
resolution_compliance: float
|
||||||
|
total_tickets: int
|
||||||
|
violations: int
|
||||||
|
|
||||||
|
|
||||||
|
class SLATrendsResponse(BaseModel):
|
||||||
|
"""Response de tendencias históricas"""
|
||||||
|
tenant_id: uuid.UUID
|
||||||
|
days: int
|
||||||
|
daily_trends: List[SLADailyTrend]
|
||||||
|
|
||||||
|
# Promedios del período
|
||||||
|
avg_response_compliance: float
|
||||||
|
avg_resolution_compliance: float
|
||||||
|
total_tickets: int
|
||||||
|
total_violations: int
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# CONFIGURATION
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
class SLAConfigByCategoryResponse(BaseModel):
|
||||||
|
"""Configuración SLA por categoría"""
|
||||||
|
category_id: uuid.UUID
|
||||||
|
category_name: str
|
||||||
|
sla_response_hours: int
|
||||||
|
sla_resolution_hours: int
|
||||||
|
warning_threshold_percentage: int # % del tiempo para alertar
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class SLAConfigListResponse(BaseModel):
|
||||||
|
"""Lista de configuraciones SLA"""
|
||||||
|
tenant_id: uuid.UUID
|
||||||
|
categories: List[SLAConfigByCategoryResponse]
|
||||||
36
backend/app/api/schemas/system.py
Normal file
36
backend/app/api/schemas/system.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""
|
||||||
|
System Schemas - ServiceManagerWeb
|
||||||
|
|
||||||
|
Pydantic schemas para gestión de sistemas afectados en tickets.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class SystemCreate(BaseModel):
|
||||||
|
"""Schema para crear sistema. No incluye tenant_id (se asigna automáticamente)."""
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SystemUpdate(BaseModel):
|
||||||
|
"""Schema para actualizar sistema."""
|
||||||
|
name: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SystemResponse(BaseModel):
|
||||||
|
"""Schema de respuesta con todos los campos públicos del sistema."""
|
||||||
|
id: uuid.UUID
|
||||||
|
tenant_id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
43
backend/app/api/schemas/tenant.py
Normal file
43
backend/app/api/schemas/tenant.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""
|
||||||
|
Tenant Schemas - ServiceManagerWeb
|
||||||
|
|
||||||
|
Pydantic schemas para gestión de tenants (organizaciones cliente).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, EmailStr
|
||||||
|
from typing import Optional
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.models.tenant import TenantStatus
|
||||||
|
|
||||||
|
|
||||||
|
class TenantBase(BaseModel):
|
||||||
|
"""Campos base compartidos entre Create y Response."""
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
domain: Optional[str] = None
|
||||||
|
contact_email: Optional[EmailStr] = None
|
||||||
|
contact_phone: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TenantCreate(TenantBase):
|
||||||
|
"""Schema para crear un nuevo tenant."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TenantUpdate(BaseModel):
|
||||||
|
"""Schema para actualizar un tenant existente."""
|
||||||
|
name: Optional[str] = None
|
||||||
|
slug: Optional[str] = None
|
||||||
|
domain: Optional[str] = None
|
||||||
|
contact_email: Optional[EmailStr] = None
|
||||||
|
contact_phone: Optional[str] = None
|
||||||
|
status: Optional[TenantStatus] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TenantResponse(TenantBase):
|
||||||
|
"""Schema de respuesta con todos los campos públicos del tenant."""
|
||||||
|
id: uuid.UUID
|
||||||
|
status: TenantStatus
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
97
backend/app/api/schemas/ticket.py
Normal file
97
backend/app/api/schemas/ticket.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"""
|
||||||
|
Ticket Schemas - ServiceManagerWeb
|
||||||
|
|
||||||
|
Pydantic schemas para gestión de tickets y comentarios.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, model_validator
|
||||||
|
from typing import Optional, Literal
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class TicketCreate(BaseModel):
|
||||||
|
"""Schema para crear un ticket."""
|
||||||
|
subject: str
|
||||||
|
description: str
|
||||||
|
category_id: Optional[str] = None
|
||||||
|
affected_system_id: Optional[str] = None
|
||||||
|
priority: Literal["LOW", "MEDIUM", "HIGH", "URGENT"] = "MEDIUM"
|
||||||
|
contact_email: Optional[str] = None
|
||||||
|
contact_phone: Optional[str] = None
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _accept_legacy_fields(cls, data):
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return data
|
||||||
|
|
||||||
|
if "subject" not in data and "title" in data:
|
||||||
|
data["subject"] = data["title"]
|
||||||
|
|
||||||
|
if "affected_system_id" not in data and "system_id" in data:
|
||||||
|
data["affected_system_id"] = data["system_id"]
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class TicketUpdate(BaseModel):
|
||||||
|
"""Schema para actualizar un ticket."""
|
||||||
|
subject: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
priority: Optional[str] = None
|
||||||
|
assigned_to: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TicketResponse(BaseModel):
|
||||||
|
"""Schema de respuesta con todos los campos públicos del ticket."""
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
ticket_number: str
|
||||||
|
subject: str
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
status: str
|
||||||
|
priority: str
|
||||||
|
category_id: Optional[str] = None
|
||||||
|
category_name: Optional[str] = None
|
||||||
|
affected_system_id: Optional[str] = None
|
||||||
|
system_id: Optional[str] = None
|
||||||
|
affected_system_name: Optional[str] = None
|
||||||
|
contact_email: Optional[str] = None
|
||||||
|
contact_phone: Optional[str] = None
|
||||||
|
created_by: str
|
||||||
|
assigned_to: Optional[str] = None
|
||||||
|
assigned_to_name: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
sla_response_due: Optional[datetime] = None
|
||||||
|
sla_resolution_due: Optional[datetime] = None
|
||||||
|
first_response_at: Optional[datetime] = None
|
||||||
|
resolved_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TicketCloseRequest(BaseModel):
|
||||||
|
"""Schema para cerrar un ticket con resolución opcional."""
|
||||||
|
resolution: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CommentCreate(BaseModel):
|
||||||
|
"""Schema para crear un comentario en un ticket."""
|
||||||
|
content: str
|
||||||
|
is_internal: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class CommentResponse(BaseModel):
|
||||||
|
"""Schema de respuesta de comentario."""
|
||||||
|
id: str
|
||||||
|
ticket_id: str
|
||||||
|
author_id: str
|
||||||
|
author_name: str
|
||||||
|
content: str
|
||||||
|
is_internal: bool
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
59
backend/app/api/schemas/user.py
Normal file
59
backend/app/api/schemas/user.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""
|
||||||
|
User Schemas - ServiceManagerWeb
|
||||||
|
|
||||||
|
Pydantic schemas para gestión de usuarios.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, EmailStr
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.models.user import UserRole
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
"""Schema para crear usuario. No incluye tenant_id (se asigna automáticamente)."""
|
||||||
|
email: EmailStr
|
||||||
|
first_name: str
|
||||||
|
last_name: str
|
||||||
|
role: UserRole
|
||||||
|
password: str
|
||||||
|
language: str = "es"
|
||||||
|
timezone: str = "UTC"
|
||||||
|
notifications_email: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
"""Schema para actualizar usuario."""
|
||||||
|
email: Optional[EmailStr] = None
|
||||||
|
first_name: Optional[str] = None
|
||||||
|
last_name: Optional[str] = None
|
||||||
|
role: Optional[UserRole] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
password: Optional[str] = None
|
||||||
|
language: Optional[str] = None
|
||||||
|
timezone: Optional[str] = None
|
||||||
|
notifications_email: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(BaseModel):
|
||||||
|
"""Schema de respuesta con todos los campos públicos del usuario."""
|
||||||
|
id: uuid.UUID
|
||||||
|
tenant_id: uuid.UUID
|
||||||
|
email: EmailStr
|
||||||
|
first_name: str
|
||||||
|
last_name: str
|
||||||
|
avatar_url: Optional[str] = None
|
||||||
|
role: UserRole
|
||||||
|
is_active: bool
|
||||||
|
email_verified: bool
|
||||||
|
last_login: Optional[datetime] = None
|
||||||
|
language: str
|
||||||
|
timezone: str
|
||||||
|
notifications_email: bool
|
||||||
|
totp_enabled: bool
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
517
backend/app/api/v1/audit_helpers.py
Normal file
517
backend/app/api/v1/audit_helpers.py
Normal file
@@ -0,0 +1,517 @@
|
|||||||
|
"""
|
||||||
|
Audit Helpers - ServiceManagerWeb
|
||||||
|
===================================
|
||||||
|
Funciones auxiliares reutilizables para los endpoints de auditoría.
|
||||||
|
|
||||||
|
Este archivo contiene:
|
||||||
|
- audit_log_to_dict: Convierte un modelo AuditLog a diccionario
|
||||||
|
- apply_tenant_filter: Aplica filtro de tenant según permisos
|
||||||
|
- get_count_stat: Cuenta registros con filtros opcionales (CORREGIDO)
|
||||||
|
- get_top_items: Obtiene los items más frecuentes
|
||||||
|
- detect_mass_deletions: Detecta eliminaciones masivas sospechosas
|
||||||
|
- detect_brute_force: Detecta ataques de fuerza bruta
|
||||||
|
- detect_privilege_escalation: Detecta escaladas de privilegios
|
||||||
|
|
||||||
|
CORRECCIÓN APLICADA en get_count_stat:
|
||||||
|
La columna created_at en PostgreSQL es 'timestamp with time zone' (TIMESTAMPTZ),
|
||||||
|
lo que significa que almacena y devuelve fechas CON información de timezone (+00).
|
||||||
|
|
||||||
|
El bug era que se comparaba un datetime naive (sin timezone) contra una columna
|
||||||
|
TIMESTAMPTZ. PostgreSQL no puede comparar ambos tipos directamente, por lo que
|
||||||
|
el filtro se ignoraba silenciosamente y los tres contadores devolvían el mismo
|
||||||
|
valor (el total histórico completo sin ningún filtro de fecha).
|
||||||
|
|
||||||
|
La solución es garantizar que TODAS las fechas que se usen en queries tengan
|
||||||
|
timezone info (aware datetime en UTC) usando _ensure_aware_utc().
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import select, func, and_, or_, desc
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from typing import Optional, Dict, List
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.models.audit import AuditLog
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.models.tenant import Tenant
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CONVERSIÓN DE MODELOS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def audit_log_to_dict(log: AuditLog) -> dict:
|
||||||
|
"""
|
||||||
|
Convierte un objeto AuditLog de SQLAlchemy a un diccionario plano
|
||||||
|
compatible con los schemas de respuesta de Pydantic.
|
||||||
|
|
||||||
|
Incluye los datos del usuario relacionado si están cargados
|
||||||
|
(requiere que la query use selectinload(AuditLog.user)).
|
||||||
|
"""
|
||||||
|
log_dict = {
|
||||||
|
"id": log.id,
|
||||||
|
"tenant_id": log.tenant_id,
|
||||||
|
"user_id": log.user_id,
|
||||||
|
"action": log.action,
|
||||||
|
"resource_type": log.resource_type,
|
||||||
|
"resource_id": log.resource_id,
|
||||||
|
# ip_address puede ser un objeto especial de PostgreSQL, convertir a string
|
||||||
|
"ip_address": str(log.ip_address) if log.ip_address else None,
|
||||||
|
"user_agent": log.user_agent,
|
||||||
|
"correlation_id": log.correlation_id,
|
||||||
|
"old_values": log.old_values,
|
||||||
|
"new_values": log.new_values,
|
||||||
|
# extra_metadata evita conflicto con la palabra reservada 'metadata'
|
||||||
|
"metadata": log.extra_metadata,
|
||||||
|
"created_at": log.created_at,
|
||||||
|
"action_display": log.action_display,
|
||||||
|
# Campos del usuario (se llenan abajo si la relación está cargada)
|
||||||
|
"user_email": None,
|
||||||
|
"user_name": None,
|
||||||
|
"user_role": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Solo agregar datos del usuario si la relación fue cargada en la query
|
||||||
|
if log.user:
|
||||||
|
log_dict["user_email"] = log.user.email
|
||||||
|
log_dict["user_name"] = log.user.full_name
|
||||||
|
# El rol puede ser un Enum de Python o un string, manejar ambos casos
|
||||||
|
log_dict["user_role"] = (
|
||||||
|
log.user.role.value
|
||||||
|
if hasattr(log.user.role, 'value')
|
||||||
|
else str(log.user.role)
|
||||||
|
)
|
||||||
|
|
||||||
|
return log_dict
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# FILTRO DE MULTI-TENANCY
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def apply_tenant_filter(
|
||||||
|
query,
|
||||||
|
current_user: User,
|
||||||
|
current_tenant: Tenant,
|
||||||
|
all_tenants: bool = False,
|
||||||
|
specific_tenant_id: Optional[uuid.UUID] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Aplica el filtro de tenant a una query de SQLAlchemy según los
|
||||||
|
permisos del usuario actual.
|
||||||
|
|
||||||
|
Reglas:
|
||||||
|
- ADMIN y SUPPORT_MANAGER pueden ver todos los tenants si
|
||||||
|
all_tenants=True, o filtrar por un tenant específico.
|
||||||
|
- Cualquier otro rol solo puede ver los datos de su propio tenant.
|
||||||
|
"""
|
||||||
|
can_see_all_tenants = current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]
|
||||||
|
|
||||||
|
if all_tenants and can_see_all_tenants:
|
||||||
|
# Usuario privilegiado pidiendo ver todos los tenants → sin filtro
|
||||||
|
return query
|
||||||
|
elif specific_tenant_id and can_see_all_tenants:
|
||||||
|
# Usuario privilegiado pidiendo un tenant específico
|
||||||
|
return query.where(AuditLog.tenant_id == specific_tenant_id)
|
||||||
|
else:
|
||||||
|
# Cualquier otro caso → solo ver el propio tenant
|
||||||
|
return query.where(AuditLog.tenant_id == current_tenant.id)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# UTILIDAD DE FECHAS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def _ensure_aware_utc(dt: datetime) -> datetime:
|
||||||
|
"""
|
||||||
|
Garantiza que un datetime tenga información de timezone en UTC.
|
||||||
|
|
||||||
|
PROBLEMA QUE RESUELVE:
|
||||||
|
La columna created_at en PostgreSQL es 'timestamp with time zone'
|
||||||
|
(TIMESTAMPTZ). Cuando se compara con un datetime naive (sin timezone),
|
||||||
|
PostgreSQL no puede hacer la comparación correctamente y el filtro
|
||||||
|
de fecha se ignora silenciosamente, devolviendo todos los registros
|
||||||
|
sin importar la fecha.
|
||||||
|
|
||||||
|
SOLUCIÓN:
|
||||||
|
Siempre convertir las fechas a aware UTC antes de usarlas en queries.
|
||||||
|
|
||||||
|
Casos que maneja:
|
||||||
|
- datetime naive (sin tzinfo): agrega UTC como timezone
|
||||||
|
- datetime aware (con tzinfo): convierte a UTC si es otra zona horaria
|
||||||
|
|
||||||
|
Ejemplos:
|
||||||
|
datetime(2026, 2, 24, 15, 0, 0) → datetime(2026, 2, 24, 15, 0, 0, tzinfo=UTC)
|
||||||
|
datetime(2026, 2, 24, 9, 0, 0, tzinfo=CST) → datetime(2026, 2, 24, 15, 0, 0, tzinfo=UTC)
|
||||||
|
"""
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
# Datetime naive → asumir que ya es UTC y agregarle timezone info
|
||||||
|
return dt.replace(tzinfo=timezone.utc)
|
||||||
|
else:
|
||||||
|
# Datetime aware → convertir a UTC (por si viene en otra zona horaria)
|
||||||
|
return dt.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CONTADORES DE ESTADÍSTICAS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
async def get_count_stat(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: Optional[uuid.UUID] = None,
|
||||||
|
date_from: Optional[datetime] = None,
|
||||||
|
action_filter=None
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Cuenta registros de AuditLog con filtros opcionales.
|
||||||
|
|
||||||
|
Usado por get_audit_stats() para calcular:
|
||||||
|
- total_actions: Sin date_from → cuenta todos los registros
|
||||||
|
- actions_today: date_from = now - 24h → registros del día
|
||||||
|
- actions_this_week: date_from = now - 7d → registros de la semana
|
||||||
|
|
||||||
|
CORRECCIÓN: Las fechas se convierten a aware UTC con _ensure_aware_utc()
|
||||||
|
antes de usarlas en la query, para que sean compatibles con la columna
|
||||||
|
TIMESTAMPTZ de PostgreSQL y el filtro se aplique correctamente.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Sesión de base de datos
|
||||||
|
tenant_id: Si se especifica, filtra por ese tenant
|
||||||
|
date_from: Si se especifica, solo cuenta registros desde esa fecha
|
||||||
|
action_filter: Condición SQLAlchemy adicional opcional
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Número entero de registros que cumplen los filtros
|
||||||
|
"""
|
||||||
|
query = select(func.count()).select_from(AuditLog)
|
||||||
|
|
||||||
|
if tenant_id:
|
||||||
|
query = query.where(AuditLog.tenant_id == tenant_id)
|
||||||
|
|
||||||
|
if date_from:
|
||||||
|
# CORRECCIÓN: convertir a aware UTC para compatibilidad con TIMESTAMPTZ
|
||||||
|
# Sin esto, el filtro se ignora y los tres contadores son idénticos
|
||||||
|
date_from_aware = _ensure_aware_utc(date_from)
|
||||||
|
query = query.where(AuditLog.created_at >= date_from_aware)
|
||||||
|
|
||||||
|
if action_filter is not None:
|
||||||
|
query = query.where(action_filter)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
return result.scalar() or 0
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# ITEMS MÁS FRECUENTES
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
async def get_top_items(
|
||||||
|
db: AsyncSession,
|
||||||
|
field,
|
||||||
|
tenant_id: Optional[uuid.UUID] = None,
|
||||||
|
limit: int = 5,
|
||||||
|
join_user: bool = False
|
||||||
|
) -> Dict[str, int]:
|
||||||
|
"""
|
||||||
|
Obtiene los valores más frecuentes de un campo, ordenados por conteo.
|
||||||
|
|
||||||
|
Ejemplos de uso:
|
||||||
|
- get_top_items(db, AuditLog.action, ...) → {"ticket.create": 45}
|
||||||
|
- get_top_items(db, AuditLog.resource_type, ...) → {"ticket": 60}
|
||||||
|
- get_top_items(db, None, ..., join_user=True) → {"admin@empresa.com": 40}
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Sesión de base de datos
|
||||||
|
field: Campo de AuditLog por el que agrupar
|
||||||
|
tenant_id: Si se especifica, filtra por ese tenant
|
||||||
|
limit: Máximo de resultados a devolver (por defecto 5)
|
||||||
|
join_user: Si True, agrupa por email de usuario
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Diccionario {valor: conteo} ordenado de mayor a menor
|
||||||
|
"""
|
||||||
|
if join_user:
|
||||||
|
# Modo usuarios: hacer JOIN con tabla User y agrupar por email
|
||||||
|
query = (
|
||||||
|
select(User.email, func.count(AuditLog.id).label('count'))
|
||||||
|
.join(User, AuditLog.user_id == User.id)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Modo campo: agrupar por el campo especificado
|
||||||
|
query = select(field, func.count(AuditLog.id).label('count'))
|
||||||
|
|
||||||
|
if tenant_id:
|
||||||
|
query = query.where(AuditLog.tenant_id == tenant_id)
|
||||||
|
|
||||||
|
if join_user:
|
||||||
|
query = query.group_by(User.email)
|
||||||
|
else:
|
||||||
|
query = query.group_by(field)
|
||||||
|
|
||||||
|
query = query.order_by(desc('count')).limit(limit)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
return {row[0]: row[1] for row in result}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DETECTORES DE INCIDENTES DE SEGURIDAD
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def detect_mass_deletions(logs: List[AuditLog], now: datetime) -> List[dict]:
|
||||||
|
"""
|
||||||
|
Detecta patrones de eliminación masiva agrupando por usuario y día.
|
||||||
|
|
||||||
|
Lógica:
|
||||||
|
- Agrupa todos los logs de eliminación por (usuario, día)
|
||||||
|
- Si un usuario eliminó >= 3 recursos en un día, genera un incidente
|
||||||
|
- La severidad escala según la cantidad:
|
||||||
|
- >= 3 eliminaciones → medium
|
||||||
|
- >= 5 eliminaciones → high
|
||||||
|
- >= 10 eliminaciones → critical
|
||||||
|
|
||||||
|
El estado del incidente es:
|
||||||
|
- "active": si la última eliminación fue hace menos de 24 horas
|
||||||
|
- "resolved": si fue hace más de 24 horas
|
||||||
|
"""
|
||||||
|
# Agrupar eliminaciones por usuario y día
|
||||||
|
deletion_groups = {}
|
||||||
|
|
||||||
|
for log in logs:
|
||||||
|
if not log.user:
|
||||||
|
continue
|
||||||
|
|
||||||
|
key = f"{log.user.email}_{log.created_at.date()}"
|
||||||
|
|
||||||
|
if key not in deletion_groups:
|
||||||
|
deletion_groups[key] = {
|
||||||
|
'user': log.user.email,
|
||||||
|
'date': log.created_at.date(),
|
||||||
|
'count': 0,
|
||||||
|
'logs': [],
|
||||||
|
'first_seen': log.created_at,
|
||||||
|
'last_seen': log.created_at
|
||||||
|
}
|
||||||
|
|
||||||
|
deletion_groups[key]['count'] += 1
|
||||||
|
deletion_groups[key]['logs'].append(log)
|
||||||
|
deletion_groups[key]['first_seen'] = min(deletion_groups[key]['first_seen'], log.created_at)
|
||||||
|
deletion_groups[key]['last_seen'] = max(deletion_groups[key]['last_seen'], log.created_at)
|
||||||
|
|
||||||
|
incidents = []
|
||||||
|
|
||||||
|
for key, group in deletion_groups.items():
|
||||||
|
if group['count'] < 3:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if group['count'] >= 10:
|
||||||
|
severity = "critical"
|
||||||
|
elif group['count'] >= 5:
|
||||||
|
severity = "high"
|
||||||
|
else:
|
||||||
|
severity = "medium"
|
||||||
|
|
||||||
|
# Convertir ambas fechas a aware UTC para comparación segura
|
||||||
|
now_aware = _ensure_aware_utc(now)
|
||||||
|
last_seen_aware = _ensure_aware_utc(group['last_seen'])
|
||||||
|
hours_since_last = (now_aware - last_seen_aware).total_seconds() / 3600
|
||||||
|
incident_status = "active" if hours_since_last <= 24 else "resolved"
|
||||||
|
|
||||||
|
incidents.append({
|
||||||
|
"id": f"mass_del_{key.replace('_', '-')}",
|
||||||
|
"title": f"Eliminaciones masivas - {group['user']}",
|
||||||
|
"description": (
|
||||||
|
f"{group['user']} elimino {group['count']} elementos "
|
||||||
|
f"el {group['date']}"
|
||||||
|
),
|
||||||
|
"severity": severity,
|
||||||
|
"status": incident_status,
|
||||||
|
"incident_type": "mass_deletion",
|
||||||
|
"affected_user": group['user'],
|
||||||
|
"source_ip": (
|
||||||
|
str(group['logs'][0].ip_address)
|
||||||
|
if group['logs'][0].ip_address
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"evidence": [
|
||||||
|
f"{log.action} - {log.resource_type} - {log.created_at.strftime('%H:%M:%S')}"
|
||||||
|
for log in group['logs'][:5]
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"total_deletions": group['count'],
|
||||||
|
"resource_types": list(set(log.resource_type for log in group['logs'])),
|
||||||
|
"time_span_minutes": int(
|
||||||
|
(group['last_seen'] - group['first_seen']).total_seconds() / 60
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"created_at": group['first_seen'],
|
||||||
|
"updated_at": group['last_seen']
|
||||||
|
})
|
||||||
|
|
||||||
|
return incidents
|
||||||
|
|
||||||
|
|
||||||
|
def detect_brute_force(logs: List[AuditLog], now: datetime) -> List[dict]:
|
||||||
|
"""
|
||||||
|
Detecta ataques de fuerza bruta agrupando intentos fallidos por IP.
|
||||||
|
|
||||||
|
Lógica:
|
||||||
|
- Agrupa todos los intentos fallidos de login por dirección IP
|
||||||
|
- Si una IP tiene >= 5 intentos, genera un incidente
|
||||||
|
- La severidad escala según la cantidad:
|
||||||
|
- >= 5 intentos → medium
|
||||||
|
- >= 10 intentos → high
|
||||||
|
- >= 20 intentos → critical
|
||||||
|
|
||||||
|
El estado del incidente es:
|
||||||
|
- "active": si el último intento fue hace menos de 24 horas
|
||||||
|
- "investigating": si fue hace más de 24 horas
|
||||||
|
"""
|
||||||
|
ip_groups = {}
|
||||||
|
|
||||||
|
for log in logs:
|
||||||
|
if not log.ip_address:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ip = str(log.ip_address)
|
||||||
|
|
||||||
|
if ip not in ip_groups:
|
||||||
|
ip_groups[ip] = {
|
||||||
|
'count': 0,
|
||||||
|
'logs': [],
|
||||||
|
'first_seen': log.created_at,
|
||||||
|
'last_seen': log.created_at,
|
||||||
|
'users': set()
|
||||||
|
}
|
||||||
|
|
||||||
|
ip_groups[ip]['count'] += 1
|
||||||
|
ip_groups[ip]['logs'].append(log)
|
||||||
|
ip_groups[ip]['first_seen'] = min(ip_groups[ip]['first_seen'], log.created_at)
|
||||||
|
ip_groups[ip]['last_seen'] = max(ip_groups[ip]['last_seen'], log.created_at)
|
||||||
|
|
||||||
|
if log.user and log.user.email:
|
||||||
|
ip_groups[ip]['users'].add(log.user.email)
|
||||||
|
|
||||||
|
incidents = []
|
||||||
|
|
||||||
|
for ip, group in ip_groups.items():
|
||||||
|
if group['count'] < 5:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if group['count'] >= 20:
|
||||||
|
severity = "critical"
|
||||||
|
elif group['count'] >= 10:
|
||||||
|
severity = "high"
|
||||||
|
else:
|
||||||
|
severity = "medium"
|
||||||
|
|
||||||
|
# Convertir ambas fechas a aware UTC para comparación segura
|
||||||
|
now_aware = _ensure_aware_utc(now)
|
||||||
|
last_seen_aware = _ensure_aware_utc(group['last_seen'])
|
||||||
|
seconds_since_last = (now_aware - last_seen_aware).total_seconds()
|
||||||
|
incident_status = "active" if seconds_since_last <= 86400 else "investigating"
|
||||||
|
|
||||||
|
incidents.append({
|
||||||
|
"id": f"brute_force_{ip.replace('.', '-')}",
|
||||||
|
"title": f"Posible ataque de fuerza bruta desde {ip}",
|
||||||
|
"description": (
|
||||||
|
f"Se detectaron {group['count']} intentos fallidos de "
|
||||||
|
f"login desde la IP {ip}"
|
||||||
|
),
|
||||||
|
"severity": severity,
|
||||||
|
"status": incident_status,
|
||||||
|
"incident_type": "brute_force_attack",
|
||||||
|
"affected_user": (
|
||||||
|
', '.join(list(group['users'])[:3])
|
||||||
|
if group['users']
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"source_ip": ip,
|
||||||
|
"evidence": [
|
||||||
|
f"Login fallido - "
|
||||||
|
f"{log.user.email if log.user else 'Desconocido'} - "
|
||||||
|
f"{log.created_at.strftime('%H:%M:%S')}"
|
||||||
|
for log in group['logs'][:5]
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"total_attempts": group['count'],
|
||||||
|
"targeted_users": list(group['users']),
|
||||||
|
"time_span_hours": int(
|
||||||
|
(group['last_seen'] - group['first_seen']).total_seconds() / 3600
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"created_at": group['first_seen'],
|
||||||
|
"updated_at": group['last_seen']
|
||||||
|
})
|
||||||
|
|
||||||
|
return incidents
|
||||||
|
|
||||||
|
|
||||||
|
def detect_privilege_escalation(logs: List[AuditLog]) -> List[dict]:
|
||||||
|
"""
|
||||||
|
Detecta escaladas de privilegios comparando el rol anterior y nuevo.
|
||||||
|
|
||||||
|
Lógica:
|
||||||
|
- Analiza cada log de cambio de rol (user.update con campo 'role')
|
||||||
|
- Si el nuevo rol tiene más privilegios que el anterior, es sospechoso
|
||||||
|
- Cada cambio que represente una escalada genera un incidente
|
||||||
|
|
||||||
|
Jerarquía de roles (de menor a mayor privilegio):
|
||||||
|
CLIENT_USER(1) < CLIENT_ADMIN(2) < AGENT(3) < SUPPORT_MANAGER(4) < ADMIN(5)
|
||||||
|
"""
|
||||||
|
role_hierarchy = {
|
||||||
|
'CLIENT_USER': 1,
|
||||||
|
'CLIENT_ADMIN': 2,
|
||||||
|
'AGENT': 3,
|
||||||
|
'SUPPORT_MANAGER': 4,
|
||||||
|
'ADMIN': 5
|
||||||
|
}
|
||||||
|
|
||||||
|
incidents = []
|
||||||
|
|
||||||
|
for log in logs:
|
||||||
|
if not log.user or not log.new_values or 'role' not in log.new_values:
|
||||||
|
continue
|
||||||
|
|
||||||
|
old_role = log.old_values.get('role') if log.old_values else 'Unknown'
|
||||||
|
new_role = log.new_values.get('role')
|
||||||
|
|
||||||
|
old_level = role_hierarchy.get(old_role, 0)
|
||||||
|
new_level = role_hierarchy.get(new_role, 0)
|
||||||
|
|
||||||
|
# Solo generar incidente si el nuevo rol tiene MÁS privilegios
|
||||||
|
if new_level <= old_level:
|
||||||
|
continue
|
||||||
|
|
||||||
|
severity = "high" if new_role in ['ADMIN', 'SUPPORT_MANAGER'] else "medium"
|
||||||
|
|
||||||
|
incidents.append({
|
||||||
|
"id": f"priv_esc_{log.id}",
|
||||||
|
"title": f"Escalada de privilegios - {log.user.email}",
|
||||||
|
"description": (
|
||||||
|
f"Usuario {log.user.email} cambio de rol "
|
||||||
|
f"{old_role} a {new_role}"
|
||||||
|
),
|
||||||
|
"severity": severity,
|
||||||
|
"status": "investigating",
|
||||||
|
"incident_type": "privilege_escalation",
|
||||||
|
"affected_user": log.user.email,
|
||||||
|
"source_ip": str(log.ip_address) if log.ip_address else None,
|
||||||
|
"evidence": [
|
||||||
|
f"Cambio de rol: {old_role} → {new_role} - "
|
||||||
|
f"{log.created_at.strftime('%Y-%m-%d %H:%M')}"
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"old_role": old_role,
|
||||||
|
"new_role": new_role,
|
||||||
|
"correlation_id": (
|
||||||
|
str(log.correlation_id)
|
||||||
|
if log.correlation_id
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"created_at": log.created_at,
|
||||||
|
"updated_at": log.created_at
|
||||||
|
})
|
||||||
|
|
||||||
|
return incidents
|
||||||
779
backend/app/api/v1/endpoints/audit.py
Normal file
779
backend/app/api/v1/endpoints/audit.py
Normal file
@@ -0,0 +1,779 @@
|
|||||||
|
"""
|
||||||
|
Audit Endpoints - ServiceManagerWeb
|
||||||
|
====================================
|
||||||
|
Este archivo maneja todos los endpoints de auditoría y seguridad.
|
||||||
|
Rutas disponibles:
|
||||||
|
GET /audit/ → Lista de logs con filtros y paginación
|
||||||
|
GET /audit/stats → Estadísticas generales de auditoría
|
||||||
|
GET /audit/{log_id} → Detalle de un log específico
|
||||||
|
GET /audit/security/analysis → Análisis de amenazas en tiempo real
|
||||||
|
POST /audit/security/action → Ejecutar acción de seguridad (bloquear IP, etc.)
|
||||||
|
GET /audit/security/incidents → Lista de incidentes detectados
|
||||||
|
|
||||||
|
CORRECCIONES APLICADAS:
|
||||||
|
1. Todos los endpoints usan datetime.now(timezone.utc) para generar
|
||||||
|
fechas aware (con timezone info en UTC), compatibles con la columna
|
||||||
|
'timestamp with time zone' (TIMESTAMPTZ) de PostgreSQL.
|
||||||
|
|
||||||
|
2. audit_helpers.get_count_stat() convierte las fechas a aware UTC
|
||||||
|
con _ensure_aware_utc() antes de usarlas en queries, resolviendo
|
||||||
|
el bug donde los tres contadores (total, hoy, semana) devolvían
|
||||||
|
el mismo valor porque el filtro de fecha se ignoraba.
|
||||||
|
|
||||||
|
3. critical_actions_today usa los mismos umbrales que /security/incidents
|
||||||
|
para que el contador del dashboard coincida con la lista de detalles.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func, and_, or_, desc
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import uuid
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.api.deps import get_current_user, get_current_tenant
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.models.tenant import Tenant
|
||||||
|
from app.models.audit import AuditLog
|
||||||
|
from app.services.audit_service import AuditService
|
||||||
|
from app.api.schemas.audit import (
|
||||||
|
AuditLogResponse, AuditLogListResponse, AuditLogFilters, AuditLogStats,
|
||||||
|
SecurityAnalysisResponse, SecurityThreatPattern, SecurityActionRequest,
|
||||||
|
SecurityActionResponse, SecurityIncidentResponse, SecurityIncidentListResponse
|
||||||
|
)
|
||||||
|
from app.api.v1.audit_helpers import (
|
||||||
|
audit_log_to_dict, apply_tenant_filter, get_count_stat, get_top_items,
|
||||||
|
detect_mass_deletions, detect_brute_force, detect_privilege_escalation
|
||||||
|
)
|
||||||
|
|
||||||
|
# Instancia del router de FastAPI para este módulo
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Logger estructurado para registrar eventos internos del sistema
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DEPENDENCIA DE AUTORIZACIÓN
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def require_auditor_role(current_user: User = Depends(get_current_user)) -> User:
|
||||||
|
"""
|
||||||
|
Dependencia reutilizable que verifica que el usuario tenga permisos
|
||||||
|
para ver logs de auditoría.
|
||||||
|
|
||||||
|
Solo pueden acceder los roles: ADMIN, SUPPORT_MANAGER, AUDITOR.
|
||||||
|
Si no tiene el rol correcto, lanza un error 403 Forbidden.
|
||||||
|
"""
|
||||||
|
if current_user.role not in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AUDITOR]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Solo usuarios con rol ADMIN, SUPPORT_MANAGER o AUDITOR pueden acceder a logs de auditoría"
|
||||||
|
)
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# ENDPOINT: LISTA DE LOGS DE AUDITORÍA
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@router.get("/", response_model=AuditLogListResponse)
|
||||||
|
async def get_audit_logs(
|
||||||
|
# Paginación
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
per_page: int = Query(default=50, ge=1, le=100),
|
||||||
|
# Filtros opcionales
|
||||||
|
user_id: Optional[uuid.UUID] = Query(None),
|
||||||
|
action: Optional[str] = Query(None),
|
||||||
|
resource_type: Optional[str] = Query(None),
|
||||||
|
resource_id: Optional[uuid.UUID] = Query(None),
|
||||||
|
date_from: Optional[datetime] = Query(None),
|
||||||
|
date_to: Optional[datetime] = Query(None),
|
||||||
|
search: Optional[str] = Query(None),
|
||||||
|
tenant_id: Optional[uuid.UUID] = Query(None),
|
||||||
|
all_tenants: bool = Query(False),
|
||||||
|
# Dependencias de autenticación y base de datos
|
||||||
|
current_user: User = Depends(require_auditor_role),
|
||||||
|
current_tenant: Tenant = Depends(get_current_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Obtener el historial completo de logs de auditoría con filtros opcionales.
|
||||||
|
|
||||||
|
Soporta filtrar por usuario, tipo de acción, recurso afectado, fechas
|
||||||
|
y búsqueda de texto. También soporta ver logs de todos los tenants
|
||||||
|
si el usuario tiene permisos de ADMIN o SUPPORT_MANAGER.
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
"Obteniendo logs de auditoria",
|
||||||
|
user_id=str(current_user.id),
|
||||||
|
tenant_id=str(current_tenant.id),
|
||||||
|
filters={
|
||||||
|
"user_id": str(user_id) if user_id else None,
|
||||||
|
"action": action,
|
||||||
|
"page": page,
|
||||||
|
"all_tenants": all_tenants
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Construir la query base con relación al usuario que hizo la acción
|
||||||
|
query = select(AuditLog).options(selectinload(AuditLog.user))
|
||||||
|
|
||||||
|
# Aplicar filtro de tenant según permisos del usuario
|
||||||
|
query = apply_tenant_filter(query, current_user, current_tenant, all_tenants, tenant_id)
|
||||||
|
|
||||||
|
# Aplicar filtros opcionales uno por uno
|
||||||
|
if user_id:
|
||||||
|
query = query.where(AuditLog.user_id == user_id)
|
||||||
|
if action:
|
||||||
|
query = query.where(AuditLog.action == action)
|
||||||
|
if resource_type:
|
||||||
|
query = query.where(AuditLog.resource_type == resource_type)
|
||||||
|
if resource_id:
|
||||||
|
query = query.where(AuditLog.resource_id == resource_id)
|
||||||
|
if date_from:
|
||||||
|
query = query.where(AuditLog.created_at >= date_from)
|
||||||
|
if date_to:
|
||||||
|
query = query.where(AuditLog.created_at < date_to)
|
||||||
|
if search:
|
||||||
|
# Búsqueda parcial en el campo "action" (ej: "ticket" encuentra "ticket.create")
|
||||||
|
query = query.where(AuditLog.action.ilike(f"%{search}%"))
|
||||||
|
|
||||||
|
# Ordenar por fecha descendente (más reciente primero)
|
||||||
|
query = query.order_by(desc(AuditLog.created_at))
|
||||||
|
|
||||||
|
# Contar total de registros para calcular páginas
|
||||||
|
count_query = select(func.count()).select_from(query.subquery())
|
||||||
|
total = (await db.execute(count_query)).scalar() or 0
|
||||||
|
|
||||||
|
# Aplicar paginación
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
query = query.offset(offset).limit(per_page)
|
||||||
|
|
||||||
|
# Ejecutar query y obtener resultados
|
||||||
|
result = await db.execute(query)
|
||||||
|
logs = result.scalars().all()
|
||||||
|
|
||||||
|
# Calcular número total de páginas
|
||||||
|
total_pages = (total + per_page - 1) // per_page
|
||||||
|
|
||||||
|
# Convertir modelos a schemas de respuesta
|
||||||
|
logs_response = [AuditLogResponse(**audit_log_to_dict(log)) for log in logs]
|
||||||
|
|
||||||
|
return AuditLogListResponse(
|
||||||
|
logs=logs_response,
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
per_page=per_page,
|
||||||
|
total_pages=total_pages
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# ENDPOINT: ESTADÍSTICAS DE AUDITORÍA
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@router.get("/stats", response_model=AuditLogStats)
|
||||||
|
async def get_audit_stats(
|
||||||
|
all_tenants: bool = Query(False),
|
||||||
|
current_user: User = Depends(require_auditor_role),
|
||||||
|
current_tenant: Tenant = Depends(get_current_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Obtener estadísticas resumidas de auditoría para el dashboard.
|
||||||
|
|
||||||
|
Incluye:
|
||||||
|
- Total de acciones registradas
|
||||||
|
- Acciones de las últimas 24 horas
|
||||||
|
- Acciones de los últimos 7 días
|
||||||
|
- Incidentes críticos detectados hoy (alineado con /security/incidents)
|
||||||
|
- Acciones más frecuentes
|
||||||
|
- Usuarios más activos
|
||||||
|
- Distribución por tipo de recurso
|
||||||
|
"""
|
||||||
|
can_see_all_tenants = current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Obteniendo estadisticas de auditoria",
|
||||||
|
user_id=str(current_user.id),
|
||||||
|
tenant_id=str(current_tenant.id),
|
||||||
|
all_tenants=all_tenants,
|
||||||
|
can_see_all=can_see_all_tenants
|
||||||
|
)
|
||||||
|
|
||||||
|
# datetime.now(timezone.utc) genera un datetime aware en UTC,
|
||||||
|
# compatible con la columna TIMESTAMPTZ de PostgreSQL
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Determinar si se debe filtrar por tenant o ver todos
|
||||||
|
apply_tenant = not (all_tenants and can_see_all_tenants)
|
||||||
|
tenant_filter = current_tenant.id if apply_tenant else None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# CONTADORES GENERALES
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Total histórico de acciones (sin filtro de fecha)
|
||||||
|
total_actions = await get_count_stat(db, tenant_filter)
|
||||||
|
|
||||||
|
# Acciones en las últimas 24 horas
|
||||||
|
# get_count_stat convierte internamente a aware UTC con _ensure_aware_utc()
|
||||||
|
actions_today = await get_count_stat(db, tenant_filter, now - timedelta(days=1))
|
||||||
|
|
||||||
|
# Acciones en los últimos 7 días
|
||||||
|
actions_this_week = await get_count_stat(db, tenant_filter, now - timedelta(days=7))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# CONTADOR DE INCIDENTES CRÍTICOS
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Usa los mismos umbrales que los detectores de /security/incidents
|
||||||
|
# para que el número del dashboard sea consistente con la lista.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
today_start = now - timedelta(days=1)
|
||||||
|
|
||||||
|
# Contar intentos fallidos de login en las últimas 24 horas
|
||||||
|
failed_login_count = (await db.execute(
|
||||||
|
select(func.count()).select_from(AuditLog).where(
|
||||||
|
AuditLog.action == 'user.login_failed',
|
||||||
|
AuditLog.created_at >= today_start,
|
||||||
|
*([AuditLog.tenant_id == tenant_filter] if apply_tenant else [])
|
||||||
|
)
|
||||||
|
)).scalar() or 0
|
||||||
|
|
||||||
|
# Contar eliminaciones en las últimas 24 horas
|
||||||
|
deletion_count = (await db.execute(
|
||||||
|
select(func.count()).select_from(AuditLog).where(
|
||||||
|
AuditLog.action.like('%.delete'),
|
||||||
|
AuditLog.created_at >= today_start,
|
||||||
|
*([AuditLog.tenant_id == tenant_filter] if apply_tenant else [])
|
||||||
|
)
|
||||||
|
)).scalar() or 0
|
||||||
|
|
||||||
|
# Contar cambios de privilegios en las últimas 24 horas
|
||||||
|
privilege_count = (await db.execute(
|
||||||
|
select(func.count()).select_from(AuditLog).where(
|
||||||
|
AuditLog.action == 'user.update',
|
||||||
|
AuditLog.created_at >= today_start,
|
||||||
|
*([AuditLog.tenant_id == tenant_filter] if apply_tenant else [])
|
||||||
|
)
|
||||||
|
)).scalar() or 0
|
||||||
|
|
||||||
|
# Calcular número real de incidentes usando los mismos umbrales
|
||||||
|
# que los detectores en /security/incidents:
|
||||||
|
# - Fuerza bruta: incidente si hay >= 20 intentos fallidos
|
||||||
|
# - Eliminación masiva: incidente si hay >= 50 eliminaciones
|
||||||
|
# - Escalada privilegios: incidente si hay >= 3 cambios de rol
|
||||||
|
critical_actions_today = sum([
|
||||||
|
1 if failed_login_count >= 20 else 0,
|
||||||
|
1 if deletion_count >= 50 else 0,
|
||||||
|
1 if privilege_count >= 3 else 0,
|
||||||
|
])
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# DATOS PARA GRÁFICAS Y TABLAS DEL DASHBOARD
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Top acciones más frecuentes (ej: "ticket.create", "user.login")
|
||||||
|
top_actions = await get_top_items(db, AuditLog.action, tenant_filter)
|
||||||
|
|
||||||
|
# Distribución por tipo de recurso (ej: "ticket", "user", "tenant")
|
||||||
|
by_resource_type = await get_top_items(db, AuditLog.resource_type, tenant_filter, limit=10)
|
||||||
|
|
||||||
|
# Usuarios más activos (hace join con tabla de usuarios)
|
||||||
|
top_users = await get_top_items(db, None, tenant_filter, join_user=True)
|
||||||
|
|
||||||
|
return AuditLogStats(
|
||||||
|
total_actions=total_actions,
|
||||||
|
actions_today=actions_today,
|
||||||
|
actions_this_week=actions_this_week,
|
||||||
|
critical_actions_today=critical_actions_today,
|
||||||
|
top_actions=top_actions,
|
||||||
|
top_users=top_users,
|
||||||
|
by_resource_type=by_resource_type
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# ENDPOINT: DETALLE DE UN LOG ESPECÍFICO
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@router.get("/{log_id}", response_model=AuditLogResponse)
|
||||||
|
async def get_audit_log_detail(
|
||||||
|
log_id: uuid.UUID,
|
||||||
|
current_user: User = Depends(require_auditor_role),
|
||||||
|
current_tenant: Tenant = Depends(get_current_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Obtener el detalle completo de un log de auditoría por su ID.
|
||||||
|
|
||||||
|
Incluye información del usuario que realizó la acción, valores
|
||||||
|
anteriores y nuevos (para cambios), IP de origen, user agent, etc.
|
||||||
|
|
||||||
|
Retorna 404 si el log no existe o no pertenece al tenant del usuario.
|
||||||
|
"""
|
||||||
|
# Buscar el log por ID incluyendo los datos del usuario relacionado
|
||||||
|
query = select(AuditLog).where(AuditLog.id == log_id).options(selectinload(AuditLog.user))
|
||||||
|
|
||||||
|
# Aplicar filtro de tenant para garantizar aislamiento multi-tenant
|
||||||
|
query = apply_tenant_filter(query, current_user, current_tenant)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
log = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not log:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Registro de auditoria {log_id} no encontrado"
|
||||||
|
)
|
||||||
|
|
||||||
|
return AuditLogResponse(**audit_log_to_dict(log))
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# ENDPOINT: ANÁLISIS DE SEGURIDAD EN TIEMPO REAL
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@router.get("/security/analysis", response_model=SecurityAnalysisResponse)
|
||||||
|
async def get_security_analysis(
|
||||||
|
hours: int = Query(default=24, ge=1, le=720),
|
||||||
|
all_tenants: bool = Query(False),
|
||||||
|
current_user: User = Depends(require_auditor_role),
|
||||||
|
current_tenant: Tenant = Depends(get_current_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Analizar los logs de auditoría para detectar patrones sospechosos.
|
||||||
|
|
||||||
|
Detecta tres tipos de amenazas:
|
||||||
|
1. Fuerza bruta: Muchos intentos fallidos de login desde las mismas IPs
|
||||||
|
2. Eliminación masiva: Gran cantidad de registros eliminados en poco tiempo
|
||||||
|
3. Escalada privilegios: Cambios de roles sospechosos en usuarios
|
||||||
|
|
||||||
|
Calcula un nivel de riesgo general (low/medium/high/critical) y
|
||||||
|
devuelve recomendaciones de acción.
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
"Analisis de seguridad solicitado",
|
||||||
|
user_id=str(current_user.id),
|
||||||
|
tenant_id=str(current_tenant.id),
|
||||||
|
hours=hours
|
||||||
|
)
|
||||||
|
|
||||||
|
# aware UTC para compatibilidad con TIMESTAMPTZ de PostgreSQL
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
analysis_start = now - timedelta(hours=hours)
|
||||||
|
|
||||||
|
query = (
|
||||||
|
select(AuditLog)
|
||||||
|
.where(AuditLog.created_at >= analysis_start)
|
||||||
|
.options(selectinload(AuditLog.user))
|
||||||
|
)
|
||||||
|
query = apply_tenant_filter(query, current_user, current_tenant, all_tenants)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
logs = result.scalars().all()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# CONTADORES DE EVENTOS SOSPECHOSOS
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
failed_logins = sum(1 for log in logs if log.action == 'user.login_failed')
|
||||||
|
mass_deletions = sum(1 for log in logs if '.delete' in log.action)
|
||||||
|
privilege_changes = sum(
|
||||||
|
1 for log in logs
|
||||||
|
if log.action == 'user.update'
|
||||||
|
and log.new_values
|
||||||
|
and 'role' in log.new_values
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GENERACIÓN DE PATRONES DE AMENAZA
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
threat_patterns = []
|
||||||
|
|
||||||
|
# Amenaza 1: Fuerza bruta (umbral mínimo: 5 intentos fallidos)
|
||||||
|
if failed_logins >= 5:
|
||||||
|
affected_ips_list = [
|
||||||
|
str(log.ip_address)
|
||||||
|
for log in logs
|
||||||
|
if log.action == 'user.login_failed' and log.ip_address
|
||||||
|
]
|
||||||
|
threat_patterns.append(SecurityThreatPattern(
|
||||||
|
id="brute_force_attempt",
|
||||||
|
type="brute_force",
|
||||||
|
description=(
|
||||||
|
f"Se detectaron {failed_logins} intentos fallidos de "
|
||||||
|
f"login en las ultimas {hours}h"
|
||||||
|
),
|
||||||
|
severity="high" if failed_logins >= 20 else "medium",
|
||||||
|
occurrences=failed_logins,
|
||||||
|
first_seen=min(
|
||||||
|
(log.created_at for log in logs if log.action == 'user.login_failed'),
|
||||||
|
default=now
|
||||||
|
),
|
||||||
|
last_seen=max(
|
||||||
|
(log.created_at for log in logs if log.action == 'user.login_failed'),
|
||||||
|
default=now
|
||||||
|
),
|
||||||
|
affected_ips=list(set(affected_ips_list))[:5],
|
||||||
|
affected_users=[],
|
||||||
|
recommended_action="Considerar bloquear IPs con multiples fallos"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Amenaza 2: Eliminación masiva (umbral mínimo: 10 eliminaciones)
|
||||||
|
if mass_deletions >= 10:
|
||||||
|
deleting_users = [
|
||||||
|
log.user.email
|
||||||
|
for log in logs
|
||||||
|
if '.delete' in log.action and log.user
|
||||||
|
]
|
||||||
|
threat_patterns.append(SecurityThreatPattern(
|
||||||
|
id="mass_deletion",
|
||||||
|
type="mass_deletion",
|
||||||
|
description=(
|
||||||
|
f"Se detectaron {mass_deletions} eliminaciones en "
|
||||||
|
f"las ultimas {hours}h"
|
||||||
|
),
|
||||||
|
severity="critical" if mass_deletions >= 50 else "high",
|
||||||
|
occurrences=mass_deletions,
|
||||||
|
first_seen=min(
|
||||||
|
(log.created_at for log in logs if '.delete' in log.action),
|
||||||
|
default=now
|
||||||
|
),
|
||||||
|
last_seen=max(
|
||||||
|
(log.created_at for log in logs if '.delete' in log.action),
|
||||||
|
default=now
|
||||||
|
),
|
||||||
|
affected_ips=[],
|
||||||
|
affected_users=list(set(deleting_users))[:5],
|
||||||
|
recommended_action="Revisar que usuarios estan eliminando recursos masivamente"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Amenaza 3: Escalada de privilegios (umbral mínimo: 3 cambios de rol)
|
||||||
|
if privilege_changes >= 3:
|
||||||
|
affected_users_list = [
|
||||||
|
log.user.email
|
||||||
|
for log in logs
|
||||||
|
if log.action == 'user.update'
|
||||||
|
and log.user
|
||||||
|
and log.new_values
|
||||||
|
and 'role' in log.new_values
|
||||||
|
]
|
||||||
|
threat_patterns.append(SecurityThreatPattern(
|
||||||
|
id="suspicious_privilege_changes",
|
||||||
|
type="privilege_escalation",
|
||||||
|
description=(
|
||||||
|
f"Se detectaron {privilege_changes} cambios de "
|
||||||
|
f"privilegios en las ultimas {hours}h"
|
||||||
|
),
|
||||||
|
severity="high",
|
||||||
|
occurrences=privilege_changes,
|
||||||
|
first_seen=min(
|
||||||
|
(
|
||||||
|
log.created_at for log in logs
|
||||||
|
if log.action == 'user.update'
|
||||||
|
and log.new_values
|
||||||
|
and 'role' in log.new_values
|
||||||
|
),
|
||||||
|
default=now
|
||||||
|
),
|
||||||
|
last_seen=max(
|
||||||
|
(
|
||||||
|
log.created_at for log in logs
|
||||||
|
if log.action == 'user.update'
|
||||||
|
and log.new_values
|
||||||
|
and 'role' in log.new_values
|
||||||
|
),
|
||||||
|
default=now
|
||||||
|
),
|
||||||
|
affected_ips=[],
|
||||||
|
affected_users=list(set(affected_users_list))[:5],
|
||||||
|
recommended_action="Auditar cambios de roles recientes"
|
||||||
|
))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# CÁLCULO DE NIVEL DE RIESGO GENERAL
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
risk_score = min(
|
||||||
|
100,
|
||||||
|
(failed_logins * 2) + (mass_deletions * 5) + (privilege_changes * 10)
|
||||||
|
)
|
||||||
|
|
||||||
|
if risk_score >= 80:
|
||||||
|
risk_level = "critical"
|
||||||
|
elif risk_score >= 50:
|
||||||
|
risk_level = "high"
|
||||||
|
elif risk_score >= 20:
|
||||||
|
risk_level = "medium"
|
||||||
|
else:
|
||||||
|
risk_level = "low"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# RECOMENDACIONES AUTOMÁTICAS
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
recommended_actions = []
|
||||||
|
|
||||||
|
if failed_logins >= 20:
|
||||||
|
recommended_actions.append(
|
||||||
|
"Implementar bloqueo automatico de IPs despues de multiples intentos fallidos"
|
||||||
|
)
|
||||||
|
if mass_deletions >= 50:
|
||||||
|
recommended_actions.append(
|
||||||
|
"Activar confirmacion adicional para eliminaciones masivas"
|
||||||
|
)
|
||||||
|
if privilege_changes >= 3:
|
||||||
|
recommended_actions.append(
|
||||||
|
"Revisar y aprobar manualmente los cambios de roles recientes"
|
||||||
|
)
|
||||||
|
if not recommended_actions:
|
||||||
|
recommended_actions.append("Continuar monitoreando actividad del sistema")
|
||||||
|
|
||||||
|
suspicious_ips = len(set(
|
||||||
|
log.ip_address
|
||||||
|
for log in logs
|
||||||
|
if log.ip_address and log.action == 'user.login_failed'
|
||||||
|
))
|
||||||
|
|
||||||
|
critical_actions = mass_deletions + privilege_changes
|
||||||
|
|
||||||
|
return SecurityAnalysisResponse(
|
||||||
|
overall_risk_level=risk_level,
|
||||||
|
total_threats_detected=len(threat_patterns),
|
||||||
|
threats=threat_patterns,
|
||||||
|
analysis_period_hours=hours,
|
||||||
|
generated_at=datetime.now(timezone.utc),
|
||||||
|
failed_login_attempts=failed_logins,
|
||||||
|
suspicious_ips_count=suspicious_ips,
|
||||||
|
critical_actions_count=critical_actions,
|
||||||
|
recommended_actions=recommended_actions
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# ENDPOINT: EJECUTAR ACCIÓN DE SEGURIDAD
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@router.post("/security/action", response_model=SecurityActionResponse)
|
||||||
|
async def execute_security_action(
|
||||||
|
action: SecurityActionRequest,
|
||||||
|
current_user: User = Depends(require_auditor_role),
|
||||||
|
current_tenant: Tenant = Depends(get_current_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Ejecutar una acción de seguridad manual sobre una amenaza detectada.
|
||||||
|
|
||||||
|
Acciones disponibles:
|
||||||
|
- block_ip: Bloquear una dirección IP por X minutos
|
||||||
|
- notify_admin: Enviar notificación a los administradores
|
||||||
|
- force_password_reset: Forzar cambio de contraseña a un usuario
|
||||||
|
- disable_user: Desactivar temporalmente una cuenta de usuario
|
||||||
|
|
||||||
|
Solo ADMIN y SUPPORT_MANAGER pueden ejecutar estas acciones.
|
||||||
|
Todas las acciones quedan registradas en el log de auditoría.
|
||||||
|
"""
|
||||||
|
if current_user.role not in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Solo administradores pueden ejecutar acciones de seguridad"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Accion de seguridad solicitada",
|
||||||
|
user_id=str(current_user.id),
|
||||||
|
action_type=action.action_type,
|
||||||
|
target=action.target
|
||||||
|
)
|
||||||
|
|
||||||
|
# Registrar en auditoría para trazabilidad completa
|
||||||
|
try:
|
||||||
|
await AuditService.log(
|
||||||
|
db=db,
|
||||||
|
tenant_id=current_tenant.id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action=f"security.{action.action_type}",
|
||||||
|
resource_type="security",
|
||||||
|
resource_id=None,
|
||||||
|
metadata={
|
||||||
|
"target": action.target,
|
||||||
|
"reason": action.reason,
|
||||||
|
"duration_minutes": action.duration_minutes
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Fallo al registrar accion de seguridad en auditoria", error=str(e))
|
||||||
|
|
||||||
|
action_messages = {
|
||||||
|
"block_ip": (
|
||||||
|
f"IP {action.target} bloqueada por "
|
||||||
|
f"{action.duration_minutes or 60} minutos. Razon: {action.reason}"
|
||||||
|
),
|
||||||
|
"notify_admin": (
|
||||||
|
f"Notificacion enviada a administradores sobre: {action.reason}"
|
||||||
|
),
|
||||||
|
"force_password_reset": (
|
||||||
|
f"Se forzara cambio de contrasena para {action.target}. "
|
||||||
|
f"Razon: {action.reason}"
|
||||||
|
),
|
||||||
|
"disable_user": (
|
||||||
|
f"Usuario {action.target} desactivado temporalmente. "
|
||||||
|
f"Razon: {action.reason}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
success = action.action_type in action_messages
|
||||||
|
message = action_messages.get(
|
||||||
|
action.action_type,
|
||||||
|
f"Tipo de accion no reconocida: {action.action_type}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return SecurityActionResponse(success=success, message=message, action_id=None)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# ENDPOINT: LISTA DE INCIDENTES DE SEGURIDAD
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@router.get("/security/incidents", response_model=SecurityIncidentListResponse)
|
||||||
|
async def get_security_incidents(
|
||||||
|
# Paginación
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
per_page: int = Query(default=20, ge=1, le=100),
|
||||||
|
# Filtros opcionales
|
||||||
|
severity: Optional[str] = Query(None),
|
||||||
|
status: Optional[str] = Query(None),
|
||||||
|
incident_type: Optional[str] = Query(None),
|
||||||
|
search: Optional[str] = Query(None),
|
||||||
|
all_tenants: bool = Query(False),
|
||||||
|
# Dependencias
|
||||||
|
current_user: User = Depends(require_auditor_role),
|
||||||
|
current_tenant: Tenant = Depends(get_current_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Obtener la lista de incidentes de seguridad detectados.
|
||||||
|
|
||||||
|
Los incidentes se generan dinámicamente analizando los logs de
|
||||||
|
auditoría de los últimos 7 días usando tres detectores:
|
||||||
|
|
||||||
|
1. detect_brute_force: Analiza intentos fallidos de login
|
||||||
|
2. detect_mass_deletions: Analiza eliminaciones masivas
|
||||||
|
3. detect_privilege_escalation: Analiza cambios de rol sospechosos
|
||||||
|
|
||||||
|
Los umbrales son los mismos que usa /stats para critical_actions_today,
|
||||||
|
garantizando consistencia entre el contador y la lista.
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
"Obteniendo incidentes de seguridad",
|
||||||
|
user_id=str(current_user.id),
|
||||||
|
tenant_id=str(current_tenant.id),
|
||||||
|
filters={
|
||||||
|
"severity": severity,
|
||||||
|
"status": status,
|
||||||
|
"type": incident_type,
|
||||||
|
"page": page
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# aware UTC para compatibilidad con TIMESTAMPTZ de PostgreSQL
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
analysis_start = now - timedelta(days=7)
|
||||||
|
|
||||||
|
base_query = (
|
||||||
|
select(AuditLog)
|
||||||
|
.options(selectinload(AuditLog.user))
|
||||||
|
.where(AuditLog.created_at >= analysis_start)
|
||||||
|
)
|
||||||
|
base_query = apply_tenant_filter(base_query, current_user, current_tenant, all_tenants)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# DETECTOR 1: ELIMINACIONES MASIVAS
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
deletion_result = await db.execute(
|
||||||
|
base_query
|
||||||
|
.where(AuditLog.action.like('%.delete'))
|
||||||
|
.order_by(desc(AuditLog.created_at))
|
||||||
|
)
|
||||||
|
deletion_logs = deletion_result.scalars().all()
|
||||||
|
deletion_incidents = detect_mass_deletions(deletion_logs, now)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# DETECTOR 2: FUERZA BRUTA
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
failed_login_result = await db.execute(
|
||||||
|
base_query
|
||||||
|
.where(AuditLog.action == 'user.login_failed')
|
||||||
|
.order_by(desc(AuditLog.created_at))
|
||||||
|
)
|
||||||
|
failed_login_logs = failed_login_result.scalars().all()
|
||||||
|
brute_force_incidents = detect_brute_force(failed_login_logs, now)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# DETECTOR 3: ESCALADA DE PRIVILEGIOS
|
||||||
|
# El operador '?' verifica si el campo JSON contiene la clave 'role'
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
privilege_result = await db.execute(
|
||||||
|
base_query
|
||||||
|
.where(and_(
|
||||||
|
AuditLog.action == 'user.update',
|
||||||
|
AuditLog.new_values.op('?')('role')
|
||||||
|
))
|
||||||
|
.order_by(desc(AuditLog.created_at))
|
||||||
|
)
|
||||||
|
privilege_logs = privilege_result.scalars().all()
|
||||||
|
privilege_incidents = detect_privilege_escalation(privilege_logs)
|
||||||
|
|
||||||
|
# Combinar todos los incidentes
|
||||||
|
incidents = [
|
||||||
|
SecurityIncidentResponse(**inc)
|
||||||
|
for inc in (deletion_incidents + brute_force_incidents + privilege_incidents)
|
||||||
|
]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# FILTROS EN MEMORIA (los incidentes son generados dinámicamente)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
if severity:
|
||||||
|
incidents = [i for i in incidents if i.severity == severity]
|
||||||
|
if status:
|
||||||
|
incidents = [i for i in incidents if i.status == status]
|
||||||
|
if incident_type:
|
||||||
|
incidents = [i for i in incidents if i.incident_type == incident_type]
|
||||||
|
if search:
|
||||||
|
search_lower = search.lower()
|
||||||
|
incidents = [
|
||||||
|
i for i in incidents
|
||||||
|
if search_lower in i.title.lower()
|
||||||
|
or (i.description and search_lower in i.description.lower())
|
||||||
|
]
|
||||||
|
|
||||||
|
# Ordenar por fecha descendente
|
||||||
|
incidents.sort(key=lambda x: x.created_at, reverse=True)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# PAGINACIÓN MANUAL
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
total = len(incidents)
|
||||||
|
total_pages = (total + per_page - 1) // per_page
|
||||||
|
start_idx = (page - 1) * per_page
|
||||||
|
end_idx = start_idx + per_page
|
||||||
|
paginated_incidents = incidents[start_idx:end_idx]
|
||||||
|
|
||||||
|
return SecurityIncidentListResponse(
|
||||||
|
incidents=paginated_incidents,
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
per_page=per_page,
|
||||||
|
total_pages=total_pages
|
||||||
|
)
|
||||||
@@ -4,11 +4,11 @@ Authentication Endpoints - ServiceManagerWeb
|
|||||||
Endpoints para autenticación y autorización
|
Endpoints para autenticación y autorización
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, status, Depends
|
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response
|
||||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from pydantic import BaseModel, EmailStr
|
from sqlalchemy.orm import selectinload
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
@@ -17,55 +17,41 @@ from app.core.security import security
|
|||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.tenant import Tenant
|
from app.models.tenant import Tenant
|
||||||
|
from app.services.audit_service import AuditService
|
||||||
|
from app.services.token_service import TokenService
|
||||||
|
from app.api.deps import oauth2_scheme, get_current_user
|
||||||
|
from app.core.cache import cache, cache_key
|
||||||
|
from app.core.limiter import limiter
|
||||||
|
|
||||||
|
# Nombres de cookie por tipo de usuario
|
||||||
|
CLIENT_ROLES = {"CLIENT_ADMIN", "CLIENT_USER"}
|
||||||
|
|
||||||
|
|
||||||
|
def _cookie_name_for_role(role: str) -> str:
|
||||||
|
"""Devuelve el nombre de cookie según el rol del usuario."""
|
||||||
|
return "client_access_token" if role in CLIENT_ROLES else "internal_access_token"
|
||||||
|
from app.api.schemas.auth import (
|
||||||
|
LoginRequest, LoginResponse, RefreshTokenRequest, TokenResponse,
|
||||||
|
TwoFactorStatusResponse, TwoFactorSetupResponse,
|
||||||
|
TwoFactorEnableRequest, TwoFactorEnableResponse, TwoFactorDisableRequest,
|
||||||
|
ChangePasswordRequest, ForgotPasswordRequest, ResetPasswordRequest,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
# OAuth2 scheme
|
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/{settings.API_VERSION}/auth/login")
|
|
||||||
|
|
||||||
|
|
||||||
# ===================================
|
|
||||||
# PYDANTIC SCHEMAS
|
|
||||||
# ===================================
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
|
||||||
"""Schema for login request."""
|
|
||||||
email: EmailStr
|
|
||||||
password: str
|
|
||||||
tenant_slug: str
|
|
||||||
totp_code: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class LoginResponse(BaseModel):
|
|
||||||
"""Schema for login response."""
|
|
||||||
access_token: str
|
|
||||||
refresh_token: str
|
|
||||||
token_type: str = "bearer"
|
|
||||||
expires_in: int
|
|
||||||
user: dict
|
|
||||||
|
|
||||||
|
|
||||||
class RefreshTokenRequest(BaseModel):
|
|
||||||
"""Schema for refresh token request."""
|
|
||||||
refresh_token: str
|
|
||||||
|
|
||||||
|
|
||||||
class TokenResponse(BaseModel):
|
|
||||||
"""Schema for token response."""
|
|
||||||
access_token: str
|
|
||||||
token_type: str = "bearer"
|
|
||||||
expires_in: int
|
|
||||||
|
|
||||||
|
|
||||||
# ===================================
|
# ===================================
|
||||||
# ENDPOINTS
|
# ENDPOINTS
|
||||||
# ===================================
|
# ===================================
|
||||||
|
|
||||||
@router.post("/login", response_model=LoginResponse)
|
@router.post("/login", response_model=LoginResponse)
|
||||||
|
@limiter.limit("10/minute")
|
||||||
async def login(
|
async def login(
|
||||||
login_data: LoginRequest,
|
login_data: LoginRequest,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -86,34 +72,138 @@ async def login(
|
|||||||
email=login_data.email,
|
email=login_data.email,
|
||||||
tenant_slug=login_data.tenant_slug
|
tenant_slug=login_data.tenant_slug
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Rate limiting (best-effort): by IP before any tenant/user lookup.
|
||||||
|
if settings.RATE_LIMIT_ENABLED and not settings.TESTING:
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
ip_key = cache_key("rl", "login", "ip", client_ip)
|
||||||
|
ip_count = await cache.incr(ip_key, 1)
|
||||||
|
if ip_count == 1:
|
||||||
|
await cache.expire(ip_key, settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)
|
||||||
|
|
||||||
|
if ip_count is not None and ip_count > settings.LOGIN_RATE_LIMIT_IP_MAX_ATTEMPTS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="Too many login attempts. Try again later.",
|
||||||
|
headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)},
|
||||||
|
)
|
||||||
|
|
||||||
# 1. Buscar usuario en base de datos
|
# 1. Validar tenant
|
||||||
query = select(User).where(User.email == login_data.email)
|
tenant_result = await db.execute(
|
||||||
|
select(Tenant).where(Tenant.slug == login_data.tenant_slug)
|
||||||
|
)
|
||||||
|
tenant = tenant_result.scalar_one_or_none()
|
||||||
|
if tenant is None:
|
||||||
|
logger.warning(
|
||||||
|
"Login failed - tenant not found",
|
||||||
|
email=login_data.email,
|
||||||
|
tenant_slug=login_data.tenant_slug,
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Tenant not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Rate limiting (best-effort): by (tenant,email) to slow brute force.
|
||||||
|
ident_key = None
|
||||||
|
if settings.RATE_LIMIT_ENABLED and not settings.TESTING:
|
||||||
|
email_norm = login_data.email.strip().lower()
|
||||||
|
ident_key = cache_key("rl", "login", "id", str(tenant.id), email_norm)
|
||||||
|
ident_count = await cache.incr(ident_key, 1)
|
||||||
|
if ident_count == 1:
|
||||||
|
await cache.expire(ident_key, settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)
|
||||||
|
|
||||||
|
if ident_count is not None and ident_count > settings.LOGIN_RATE_LIMIT_ID_MAX_ATTEMPTS:
|
||||||
|
try:
|
||||||
|
await AuditService.log(
|
||||||
|
db=db,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
user_id=None,
|
||||||
|
action="user.login_rate_limited",
|
||||||
|
resource_type="user",
|
||||||
|
resource_id=None,
|
||||||
|
metadata={
|
||||||
|
"email": email_norm,
|
||||||
|
"tenant_slug": login_data.tenant_slug,
|
||||||
|
"ip": request.client.host if request.client else None,
|
||||||
|
"scope": "tenant_email",
|
||||||
|
"window_seconds": settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS,
|
||||||
|
"max_attempts": settings.LOGIN_RATE_LIMIT_ID_MAX_ATTEMPTS,
|
||||||
|
},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to log rate limit audit entry", error=str(e))
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="Too many login attempts. Try again later.",
|
||||||
|
headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Buscar usuario en base de datos (aislado por tenant)
|
||||||
|
query = select(User).where(
|
||||||
|
User.email == login_data.email,
|
||||||
|
User.tenant_id == tenant.id,
|
||||||
|
)
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
# 2. Verificar usuario y contraseña
|
# 3. Verificar usuario y contraseña
|
||||||
if not user or not security.verify_password(login_data.password, user.password_hash):
|
if not user or not security.verify_password(login_data.password, user.password_hash):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Login failed - invalid credentials",
|
"Login failed - invalid credentials",
|
||||||
email=login_data.email
|
email=login_data.email
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Registrar intento fallido en auditoría (si el usuario existe)
|
||||||
|
if user:
|
||||||
|
try:
|
||||||
|
await AuditService.log(
|
||||||
|
db=db,
|
||||||
|
tenant_id=user.tenant_id,
|
||||||
|
user_id=None, # Login fallido = sin user_id
|
||||||
|
action="user.login_failed",
|
||||||
|
resource_type="user",
|
||||||
|
resource_id=user.id,
|
||||||
|
metadata={"email": login_data.email, "reason": "invalid_password"}
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to log audit entry", error=str(e))
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Credenciales inválidas"
|
detail="Invalid credentials",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3. Verificar si está activo
|
# 4. Verificar si está activo
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Login failed - user inactive",
|
"Login failed - user inactive",
|
||||||
email=login_data.email
|
email=login_data.email
|
||||||
)
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Usuario inactivo"
|
detail="User inactive",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 5. Verificar 2FA si está habilitado
|
||||||
|
if user.totp_enabled:
|
||||||
|
if not login_data.totp_code:
|
||||||
|
# Indicar al frontend que debe pedir el código TOTP
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Se requiere autenticación de dos factores (2FA). Ingresa tu código."
|
||||||
|
)
|
||||||
|
if not security.verify_totp(user.totp_secret, login_data.totp_code):
|
||||||
|
logger.warning("Login failed - invalid 2FA code", email=login_data.email)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Código 2FA inválido o expirado"
|
||||||
|
)
|
||||||
|
|
||||||
# Create tokens
|
# Create tokens
|
||||||
token_data = {
|
token_data = {
|
||||||
"sub": str(user.id),
|
"sub": str(user.id),
|
||||||
@@ -124,6 +214,39 @@ async def login(
|
|||||||
|
|
||||||
access_token = security.create_access_token(token_data)
|
access_token = security.create_access_token(token_data)
|
||||||
refresh_token = security.create_refresh_token(token_data)
|
refresh_token = security.create_refresh_token(token_data)
|
||||||
|
|
||||||
|
# Persist refresh token so it can be revoked/validated later
|
||||||
|
try:
|
||||||
|
await TokenService.create_refresh_token(
|
||||||
|
db=db,
|
||||||
|
user=user,
|
||||||
|
refresh_token=refresh_token,
|
||||||
|
user_agent=request.headers.get("user-agent"),
|
||||||
|
ip_address=request.client.host if request.client else None,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
# If persistence fails, do not leak tokens
|
||||||
|
logger.error("Failed to persist refresh token", error=str(e), user_id=str(user.id))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Service temporarily unavailable",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Registrar login exitoso en auditoría
|
||||||
|
try:
|
||||||
|
await AuditService.log(
|
||||||
|
db=db,
|
||||||
|
tenant_id=user.tenant_id,
|
||||||
|
user_id=user.id,
|
||||||
|
action="user.login",
|
||||||
|
resource_type="user",
|
||||||
|
resource_id=user.id,
|
||||||
|
metadata={"email": user.email, "success": True}
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to log audit entry", error=str(e))
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Login successful",
|
"Login successful",
|
||||||
@@ -131,7 +254,24 @@ async def login(
|
|||||||
tenant_slug=login_data.tenant_slug,
|
tenant_slug=login_data.tenant_slug,
|
||||||
user_id=str(user.id)
|
user_id=str(user.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Best-effort: clear per-identity limiter on success.
|
||||||
|
if ident_key:
|
||||||
|
await cache.delete(ident_key)
|
||||||
|
|
||||||
|
# Cookie diferenciada por rol para aislar sesiones entre frontends
|
||||||
|
cookie_name = _cookie_name_for_role(
|
||||||
|
user.role.value if hasattr(user.role, "value") else user.role
|
||||||
|
)
|
||||||
|
response.set_cookie(
|
||||||
|
key=cookie_name,
|
||||||
|
value=access_token,
|
||||||
|
httponly=True,
|
||||||
|
secure=settings.is_production(),
|
||||||
|
samesite="strict" if settings.is_production() else "lax",
|
||||||
|
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||||
|
)
|
||||||
|
|
||||||
return LoginResponse(
|
return LoginResponse(
|
||||||
access_token=access_token,
|
access_token=access_token,
|
||||||
refresh_token=refresh_token,
|
refresh_token=refresh_token,
|
||||||
@@ -179,7 +319,20 @@ async def refresh_token(
|
|||||||
detail="Invalid refresh token"
|
detail="Invalid refresh token"
|
||||||
)
|
)
|
||||||
|
|
||||||
# TODO: Check if refresh token exists in database and is not revoked
|
# Check token exists in database and is not revoked/expired
|
||||||
|
db_token = await TokenService.verify_refresh_token(db=db, refresh_token=refresh_data.refresh_token)
|
||||||
|
if db_token is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid refresh token",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Defensive: ensure DB token belongs to same subject
|
||||||
|
if str(db_token.user_id) != str(payload.get("sub")):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid refresh token",
|
||||||
|
)
|
||||||
|
|
||||||
# Create new access token
|
# Create new access token
|
||||||
token_data = {
|
token_data = {
|
||||||
@@ -201,6 +354,7 @@ async def refresh_token(
|
|||||||
|
|
||||||
@router.post("/logout")
|
@router.post("/logout")
|
||||||
async def logout(
|
async def logout(
|
||||||
|
response: Response,
|
||||||
token: str = Depends(oauth2_scheme),
|
token: str = Depends(oauth2_scheme),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
@@ -224,10 +378,44 @@ async def logout(
|
|||||||
detail="Invalid token"
|
detail="Invalid token"
|
||||||
)
|
)
|
||||||
|
|
||||||
# TODO: Revoke refresh token in database
|
# Revoke all active refresh tokens for this user (logout invalidates refresh)
|
||||||
|
try:
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
user_id = uuid.UUID(payload["sub"])
|
||||||
|
await TokenService.revoke_all_user_tokens(
|
||||||
|
db=db,
|
||||||
|
user_id=user_id,
|
||||||
|
revoked_by_user_id=user_id,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to revoke refresh tokens on logout", error=str(e))
|
||||||
|
|
||||||
|
# Registrar logout en auditoría
|
||||||
|
try:
|
||||||
|
import uuid
|
||||||
|
user_id = uuid.UUID(payload["sub"])
|
||||||
|
tenant_id = uuid.UUID(payload["tenant_id"])
|
||||||
|
|
||||||
|
await AuditService.log(
|
||||||
|
db=db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
action="user.logout",
|
||||||
|
resource_type="user",
|
||||||
|
resource_id=user_id,
|
||||||
|
metadata={"email": payload.get("email")}
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to log audit entry", error=str(e))
|
||||||
|
|
||||||
logger.info("Logout successful", user_id=payload["sub"])
|
logger.info("Logout successful", user_id=payload["sub"])
|
||||||
|
|
||||||
|
# Borrar la cookie correcta según el rol del usuario
|
||||||
|
cookie_name = _cookie_name_for_role(payload.get("role", ""))
|
||||||
|
response.delete_cookie(key=cookie_name)
|
||||||
return {"message": "Successfully logged out"}
|
return {"message": "Successfully logged out"}
|
||||||
|
|
||||||
|
|
||||||
@@ -257,41 +445,397 @@ async def get_current_user(
|
|||||||
detail="Invalid token"
|
detail="Invalid token"
|
||||||
)
|
)
|
||||||
|
|
||||||
# TODO: Fetch actual user from database
|
user_id = payload.get("sub")
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid token payload"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch actual user from database
|
||||||
|
query = select(User).where(User.id == user_id).options(
|
||||||
|
selectinload(User.tenant)
|
||||||
|
)
|
||||||
|
result = await db.execute(query)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="User not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="User account is disabled"
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": payload["sub"],
|
"id": str(user.id),
|
||||||
"email": payload["email"],
|
"email": user.email,
|
||||||
"role": payload["role"],
|
"first_name": user.first_name,
|
||||||
"tenant_id": payload["tenant_id"]
|
"last_name": user.last_name,
|
||||||
|
"role": user.role.value if hasattr(user.role, 'value') else user.role,
|
||||||
|
"tenant_id": str(user.tenant_id),
|
||||||
|
"tenant_name": user.tenant.name if user.tenant else None,
|
||||||
|
"is_active": user.is_active,
|
||||||
|
"is_two_factor_enabled": user.totp_secret is not None,
|
||||||
|
"last_login": user.last_login.isoformat() if user.last_login else None,
|
||||||
|
"created_at": user.created_at.isoformat()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ===================================
|
# ===================================
|
||||||
# DEPENDENCIES
|
# DEPENDENCIES
|
||||||
# ===================================
|
# ===================================
|
||||||
|
# Dependencies are imported from app.api.deps to avoid duplication
|
||||||
|
# Use get_current_user and get_current_active_superuser from deps.py
|
||||||
|
|
||||||
async def get_current_active_user(token: str = Depends(oauth2_scheme)):
|
|
||||||
|
# ===================================
|
||||||
|
# 2FA / TOTP ENDPOINTS
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
@router.get("/2fa/status", response_model=TwoFactorStatusResponse)
|
||||||
|
async def get_2fa_status(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Dependency to get current active user from token.
|
Consultar si el 2FA está habilitado para el usuario actual.
|
||||||
|
|
||||||
Args:
|
|
||||||
token: Access token
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Current user data
|
Estado de 2FA del usuario autenticado.
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: If token is invalid or user is inactive
|
|
||||||
"""
|
"""
|
||||||
payload = security.verify_token(token)
|
return TwoFactorStatusResponse(enabled=bool(current_user.totp_enabled))
|
||||||
if not payload:
|
|
||||||
|
|
||||||
|
@router.post("/2fa/setup", response_model=TwoFactorSetupResponse)
|
||||||
|
async def setup_2fa(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Iniciar configuración de 2FA: genera un nuevo TOTP secret y QR URI.
|
||||||
|
|
||||||
|
El secret se guarda en BD pero 2FA NO se activa todavía.
|
||||||
|
Se necesita llamar a /2fa/enable con un código válido para activarlo.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Secret y QR URI para escanear con la app autenticadora.
|
||||||
|
"""
|
||||||
|
new_secret = security.generate_totp_secret()
|
||||||
|
qr_uri = security.generate_totp_uri(new_secret, current_user.email)
|
||||||
|
|
||||||
|
# Guardar el secret (sin habilitar aún)
|
||||||
|
current_user.totp_secret = new_secret
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
logger.info("2FA setup initiated", user_id=str(current_user.id))
|
||||||
|
|
||||||
|
return TwoFactorSetupResponse(secret=new_secret, qr_uri=qr_uri)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/2fa/enable", response_model=TwoFactorEnableResponse)
|
||||||
|
async def enable_2fa(
|
||||||
|
data: TwoFactorEnableRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Activar 2FA verificando que el usuario escaneó correctamente el QR.
|
||||||
|
|
||||||
|
Requiere que /2fa/setup haya sido llamado previamente.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Código TOTP generado por la app autenticadora.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Confirmación y lista de códigos de respaldo.
|
||||||
|
"""
|
||||||
|
if not current_user.totp_secret:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Invalid token",
|
detail="Primero inicia el proceso de configuración con /2fa/setup"
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# TODO: Verify user exists and is active
|
if not security.verify_totp(current_user.totp_secret, data.totp_code):
|
||||||
|
raise HTTPException(
|
||||||
return payload
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Código TOTP inválido. Verifica la hora de tu dispositivo e intenta de nuevo."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Activar 2FA y generar códigos de respaldo
|
||||||
|
backup_codes = security.generate_backup_codes()
|
||||||
|
current_user.totp_enabled = True
|
||||||
|
current_user.backup_codes = backup_codes
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
await AuditService.log(
|
||||||
|
db=db,
|
||||||
|
tenant_id=current_user.tenant_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="user.2fa_enabled",
|
||||||
|
resource_type="user",
|
||||||
|
resource_id=current_user.id,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
logger.info("2FA enabled", user_id=str(current_user.id))
|
||||||
|
|
||||||
|
return TwoFactorEnableResponse(enabled=True, backup_codes=backup_codes)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/2fa/disable")
|
||||||
|
async def disable_2fa(
|
||||||
|
data: TwoFactorDisableRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Deshabilitar 2FA verificando con código TOTP o código de respaldo.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: totp_code o backup_code para verificar identidad.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Mensaje de confirmación.
|
||||||
|
"""
|
||||||
|
if not current_user.totp_enabled:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="El 2FA no está habilitado en esta cuenta"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verificar con TOTP o código de respaldo
|
||||||
|
verified = False
|
||||||
|
|
||||||
|
if data.totp_code:
|
||||||
|
verified = security.verify_totp(current_user.totp_secret, data.totp_code)
|
||||||
|
elif data.backup_code and current_user.backup_codes:
|
||||||
|
if data.backup_code in current_user.backup_codes:
|
||||||
|
verified = True
|
||||||
|
# Invalidar el código de respaldo usado
|
||||||
|
current_user.backup_codes = [
|
||||||
|
c for c in current_user.backup_codes if c != data.backup_code
|
||||||
|
]
|
||||||
|
|
||||||
|
if not verified:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Verificación fallida. Proporciona un código TOTP o un código de respaldo válido."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Deshabilitar 2FA
|
||||||
|
current_user.totp_enabled = False
|
||||||
|
current_user.totp_secret = None
|
||||||
|
current_user.backup_codes = None
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
await AuditService.log(
|
||||||
|
db=db,
|
||||||
|
tenant_id=current_user.tenant_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="user.2fa_disabled",
|
||||||
|
resource_type="user",
|
||||||
|
resource_id=current_user.id,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
logger.info("2FA disabled", user_id=str(current_user.id))
|
||||||
|
|
||||||
|
return {"message": "Autenticación de dos factores deshabilitada correctamente"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/change-password", status_code=status.HTTP_200_OK)
|
||||||
|
async def change_password(
|
||||||
|
data: ChangePasswordRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Cambiar la contraseña del usuario autenticado.
|
||||||
|
|
||||||
|
Verifica la contraseña actual antes de actualizar.
|
||||||
|
Requiere autenticación activa.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Validar longitud mínima
|
||||||
|
if len(data.new_password) < 8:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="La nueva contraseña debe tener al menos 8 caracteres"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verificar que la contraseña actual sea correcta
|
||||||
|
if not security.verify_password(data.current_password, current_user.password_hash):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="La contraseña actual es incorrecta"
|
||||||
|
)
|
||||||
|
|
||||||
|
# No permitir que la nueva sea igual a la actual
|
||||||
|
if security.verify_password(data.new_password, current_user.password_hash):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="La nueva contraseña no puede ser igual a la actual"
|
||||||
|
)
|
||||||
|
|
||||||
|
current_user.password_hash = security.hash_password(data.new_password)
|
||||||
|
current_user.updated_at = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
await AuditService.log(
|
||||||
|
db=db,
|
||||||
|
tenant_id=current_user.tenant_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="user.password_changed",
|
||||||
|
resource_type="user",
|
||||||
|
resource_id=current_user.id,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
logger.info("Password changed", user_id=str(current_user.id))
|
||||||
|
return {"message": "Contraseña actualizada correctamente"}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Recuperación de contraseña (forgot / reset)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
_RESET_TOKEN_TTL = 1800 # 30 minutos en segundos
|
||||||
|
_RESET_KEY_PREFIX = "pwd_reset:"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/forgot-password", status_code=status.HTTP_200_OK)
|
||||||
|
@limiter.limit("5/minute")
|
||||||
|
async def forgot_password(
|
||||||
|
request: Request,
|
||||||
|
data: ForgotPasswordRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Solicitar reseteo de contraseña.
|
||||||
|
|
||||||
|
Siempre retorna 200 aunque el email no exista, para no revelar
|
||||||
|
si una dirección está registrada en el sistema.
|
||||||
|
"""
|
||||||
|
import secrets
|
||||||
|
from redis.asyncio import from_url as redis_from_url
|
||||||
|
from app.core.email import send_email, build_password_reset_email
|
||||||
|
|
||||||
|
# Buscar usuario activo con ese email
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(
|
||||||
|
User.email == data.email,
|
||||||
|
User.is_active == True, # noqa: E712
|
||||||
|
).limit(1)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
# Respuesta idéntica — no revelar existencia
|
||||||
|
logger.info("Forgot password: email not found", email=data.email)
|
||||||
|
return {"message": "Si el correo está registrado recibirás un enlace en breve."}
|
||||||
|
|
||||||
|
# Generar token seguro
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
redis_key = f"{_RESET_KEY_PREFIX}{token}"
|
||||||
|
|
||||||
|
# Guardar en Redis con TTL de 30 min
|
||||||
|
redis = redis_from_url(settings.REDIS_URL, decode_responses=True)
|
||||||
|
try:
|
||||||
|
await redis.setex(redis_key, _RESET_TOKEN_TTL, str(user.id))
|
||||||
|
finally:
|
||||||
|
await redis.aclose()
|
||||||
|
|
||||||
|
# Construir URL y enviar email
|
||||||
|
reset_url = f"{settings.CLIENT_FRONTEND_URL}/reset-password?token={token}"
|
||||||
|
user_name = f"{user.first_name} {user.last_name}".strip() or user.email
|
||||||
|
html, text = build_password_reset_email(reset_url, user_name)
|
||||||
|
|
||||||
|
await send_email(
|
||||||
|
to_email=user.email,
|
||||||
|
subject="Restablece tu contraseña — ServiceManager",
|
||||||
|
html_content=html,
|
||||||
|
text_content=text,
|
||||||
|
)
|
||||||
|
|
||||||
|
await AuditService.log(
|
||||||
|
db=db,
|
||||||
|
tenant_id=user.tenant_id,
|
||||||
|
user_id=user.id,
|
||||||
|
action="user.password_reset_requested",
|
||||||
|
resource_type="user",
|
||||||
|
resource_id=user.id,
|
||||||
|
new_values={"email": user.email},
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
logger.info("Password reset email sent", user_id=str(user.id))
|
||||||
|
return {"message": "Si el correo está registrado recibirás un enlace en breve."}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reset-password", status_code=status.HTTP_200_OK)
|
||||||
|
@limiter.limit("5/minute")
|
||||||
|
async def reset_password(
|
||||||
|
request: Request,
|
||||||
|
data: ResetPasswordRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Aplicar nueva contraseña usando el token recibido por email.
|
||||||
|
|
||||||
|
El token es de un solo uso: se elimina de Redis al usarse.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from redis.asyncio import from_url as redis_from_url
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
if len(data.new_password) < 8:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="La contraseña debe tener al menos 8 caracteres"
|
||||||
|
)
|
||||||
|
|
||||||
|
redis_key = f"{_RESET_KEY_PREFIX}{data.token}"
|
||||||
|
redis = redis_from_url(settings.REDIS_URL, decode_responses=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_id_str = await redis.get(redis_key)
|
||||||
|
if not user_id_str:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="El enlace de reseteo es inválido o ya expiró. Solicita uno nuevo."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Eliminar token inmediatamente (un solo uso)
|
||||||
|
await redis.delete(redis_key)
|
||||||
|
finally:
|
||||||
|
await redis.aclose()
|
||||||
|
|
||||||
|
# Buscar y actualizar usuario
|
||||||
|
user = await db.get(User, uuid.UUID(user_id_str))
|
||||||
|
if not user or not user.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Usuario no encontrado o inactivo"
|
||||||
|
)
|
||||||
|
|
||||||
|
user.password_hash = security.hash_password(data.new_password)
|
||||||
|
user.updated_at = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
await AuditService.log(
|
||||||
|
db=db,
|
||||||
|
tenant_id=user.tenant_id,
|
||||||
|
user_id=user.id,
|
||||||
|
action="user.password_reset_completed",
|
||||||
|
resource_type="user",
|
||||||
|
resource_id=user.id,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
logger.info("Password reset completed", user_id=str(user.id))
|
||||||
|
return {"message": "Contraseña actualizada correctamente. Ya puedes iniciar sesión."}
|
||||||
@@ -1,55 +1,267 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from pydantic import BaseModel, ConfigDict
|
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
|
from app.core.cache import cache, cache_key
|
||||||
from app.models.category import Category
|
from app.models.category import Category
|
||||||
from app.api import deps
|
from app.models.user import User
|
||||||
|
from app.api import deps
|
||||||
|
from app.services.audit_service import AuditService
|
||||||
|
from app.api.schemas.category import CategoryCreate, CategoryUpdate, CategoryResponse
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
class CategoryBase(BaseModel):
|
|
||||||
name: str
|
|
||||||
description: Optional[str] = None
|
|
||||||
is_active: bool = True
|
|
||||||
tenant_id: Optional[uuid.UUID] = None
|
|
||||||
|
|
||||||
class CategoryCreate(CategoryBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class CategoryUpdate(CategoryBase):
|
# ===================================
|
||||||
name: Optional[str] = None
|
# ENDPOINTS
|
||||||
description: Optional[str] = None
|
# ===================================
|
||||||
is_active: Optional[bool] = None
|
|
||||||
tenant_id: Optional[uuid.UUID] = None
|
|
||||||
|
|
||||||
class CategoryResponse(CategoryBase):
|
|
||||||
id: uuid.UUID
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
@router.get("/", response_model=List[CategoryResponse])
|
@router.get("/", response_model=List[CategoryResponse])
|
||||||
async def read_categories(
|
async def read_categories(
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user = Depends(deps.get_current_active_superuser)
|
current_user: User = Depends(deps.get_current_user) # ✅ CORREGIDO: Type hint + no solo superuser
|
||||||
):
|
):
|
||||||
query = select(Category).offset(skip).limit(limit)
|
"""
|
||||||
|
Listar categorías del tenant del usuario actual.
|
||||||
|
|
||||||
|
✅ Implementa multi-tenancy: solo muestra categorías del tenant del usuario.
|
||||||
|
✅ Optimizado con caché Redis (TTL: 10 minutos)
|
||||||
|
"""
|
||||||
|
# Intentar obtener del caché
|
||||||
|
cache_key_str = cache_key("categories", "tenant", str(current_user.tenant_id), f"skip-{skip}", f"limit-{limit}")
|
||||||
|
cached_categories = await cache.get(cache_key_str)
|
||||||
|
|
||||||
|
if cached_categories is not None:
|
||||||
|
return [CategoryResponse(**cat) for cat in cached_categories]
|
||||||
|
|
||||||
|
# Si no está en caché, consultar BD
|
||||||
|
query = select(Category).where(
|
||||||
|
Category.tenant_id == current_user.tenant_id
|
||||||
|
).offset(skip).limit(limit)
|
||||||
|
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
return result.scalars().all()
|
categories = result.scalars().all()
|
||||||
|
|
||||||
|
# Guardar en caché (10 minutos)
|
||||||
|
categories_dict = [
|
||||||
|
{
|
||||||
|
"id": str(cat.id),
|
||||||
|
"name": cat.name,
|
||||||
|
"description": cat.description,
|
||||||
|
"sla_response_hours": cat.sla_response_hours,
|
||||||
|
"sla_resolution_hours": cat.sla_resolution_hours,
|
||||||
|
"is_active": cat.is_active,
|
||||||
|
"tenant_id": str(cat.tenant_id),
|
||||||
|
"created_at": cat.created_at.isoformat(),
|
||||||
|
"updated_at": cat.updated_at.isoformat()
|
||||||
|
}
|
||||||
|
for cat in categories
|
||||||
|
]
|
||||||
|
await cache.set(cache_key_str, categories_dict, ttl=600)
|
||||||
|
|
||||||
|
return categories
|
||||||
|
|
||||||
@router.post("/", response_model=CategoryResponse)
|
|
||||||
|
@router.post("/", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_category(
|
async def create_category(
|
||||||
category: CategoryCreate,
|
category: CategoryCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user = Depends(deps.get_current_active_superuser)
|
current_user: User = Depends(deps.get_current_user) # ✅ CORREGIDO: Type hint
|
||||||
):
|
):
|
||||||
db_category = Category(**category.model_dump())
|
"""
|
||||||
|
Crear nueva categoría en el tenant del usuario actual.
|
||||||
|
|
||||||
|
✅ Implementa multi-tenancy: asigna automáticamente tenant_id del usuario.
|
||||||
|
"""
|
||||||
|
# ✅ CORREGIDO: Asignar tenant_id del usuario actual
|
||||||
|
db_category = Category(
|
||||||
|
**category.model_dump(),
|
||||||
|
tenant_id=current_user.tenant_id # ✅ Multi-tenancy automático
|
||||||
|
)
|
||||||
|
|
||||||
db.add(db_category)
|
db.add(db_category)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(db_category)
|
await db.refresh(db_category)
|
||||||
|
|
||||||
|
# Invalidar caché de categorías para este tenant
|
||||||
|
await cache.delete_pattern(f"categories:tenant:{current_user.tenant_id}:*")
|
||||||
|
|
||||||
|
# Registrar creación en auditoría
|
||||||
|
try:
|
||||||
|
await AuditService.log(
|
||||||
|
db=db,
|
||||||
|
tenant_id=current_user.tenant_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="category.create",
|
||||||
|
resource_type="category",
|
||||||
|
resource_id=db_category.id,
|
||||||
|
new_values={
|
||||||
|
"name": db_category.name,
|
||||||
|
"sla_response_hours": db_category.sla_response_hours,
|
||||||
|
"sla_resolution_hours": db_category.sla_resolution_hours,
|
||||||
|
"is_active": db_category.is_active
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass # No fallar si falla el audit log
|
||||||
|
|
||||||
return db_category
|
return db_category
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{category_id}", response_model=CategoryResponse)
|
||||||
|
async def read_category(
|
||||||
|
category_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(deps.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Obtener una categoría específica del tenant.
|
||||||
|
|
||||||
|
✅ Implementa multi-tenancy: solo permite acceso a categorías del propio tenant.
|
||||||
|
"""
|
||||||
|
query = select(Category).where(
|
||||||
|
Category.id == category_id,
|
||||||
|
Category.tenant_id == current_user.tenant_id # ✅ Seguridad multi-tenant
|
||||||
|
)
|
||||||
|
result = await db.execute(query)
|
||||||
|
category = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not category:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Category not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
return category
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{category_id}", response_model=CategoryResponse)
|
||||||
|
async def update_category(
|
||||||
|
category_id: uuid.UUID,
|
||||||
|
category_update: CategoryUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(deps.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Actualizar categoría del tenant.
|
||||||
|
|
||||||
|
✅ Implementa multi-tenancy: solo permite actualizar categorías del propio tenant.
|
||||||
|
"""
|
||||||
|
query = select(Category).where(
|
||||||
|
Category.id == category_id,
|
||||||
|
Category.tenant_id == current_user.tenant_id
|
||||||
|
)
|
||||||
|
result = await db.execute(query)
|
||||||
|
db_category = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not db_category:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Category not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Guardar valores anteriores para auditoría
|
||||||
|
old_values = {
|
||||||
|
"name": db_category.name,
|
||||||
|
"sla_response_hours": db_category.sla_response_hours,
|
||||||
|
"sla_resolution_hours": db_category.sla_resolution_hours,
|
||||||
|
"is_active": db_category.is_active
|
||||||
|
}
|
||||||
|
|
||||||
|
# Actualizar campos
|
||||||
|
update_data = category_update.model_dump(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(db_category, field, value)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(db_category)
|
||||||
|
|
||||||
|
# Invalidar caché de categorías para este tenant
|
||||||
|
await cache.delete_pattern(f"categories:tenant:{current_user.tenant_id}:*")
|
||||||
|
|
||||||
|
# Registrar actualización en auditoría
|
||||||
|
try:
|
||||||
|
new_values = {
|
||||||
|
"name": db_category.name,
|
||||||
|
"sla_response_hours": db_category.sla_response_hours,
|
||||||
|
"sla_resolution_hours": db_category.sla_resolution_hours,
|
||||||
|
"is_active": db_category.is_active
|
||||||
|
}
|
||||||
|
await AuditService.log(
|
||||||
|
db=db,
|
||||||
|
tenant_id=current_user.tenant_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="category.update",
|
||||||
|
resource_type="category",
|
||||||
|
resource_id=db_category.id,
|
||||||
|
old_values=old_values,
|
||||||
|
new_values=new_values
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass # No fallar si falla el audit log
|
||||||
|
|
||||||
|
return db_category
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_category(
|
||||||
|
category_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(deps.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Desactivar categoría del tenant (soft delete).
|
||||||
|
|
||||||
|
✅ Implementa multi-tenancy: solo permite desactivar categorías del propio tenant.
|
||||||
|
"""
|
||||||
|
query = select(Category).where(
|
||||||
|
Category.id == category_id,
|
||||||
|
Category.tenant_id == current_user.tenant_id
|
||||||
|
)
|
||||||
|
result = await db.execute(query)
|
||||||
|
db_category = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not db_category:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Category not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Guardar valores para auditoría
|
||||||
|
old_values = {
|
||||||
|
"name": db_category.name,
|
||||||
|
"is_active": db_category.is_active
|
||||||
|
}
|
||||||
|
|
||||||
|
# Soft delete
|
||||||
|
db_category.is_active = False
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Invalidar caché de categorías para este tenant
|
||||||
|
await cache.delete_pattern(f"categories:tenant:{current_user.tenant_id}:*")
|
||||||
|
|
||||||
|
# Registrar eliminación en auditoría
|
||||||
|
try:
|
||||||
|
await AuditService.log(
|
||||||
|
db=db,
|
||||||
|
tenant_id=current_user.tenant_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="category.delete",
|
||||||
|
resource_type="category",
|
||||||
|
resource_id=db_category.id,
|
||||||
|
old_values=old_values,
|
||||||
|
new_values={"is_active": False}
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass # No fallar si falla el audit log
|
||||||
|
|
||||||
|
return None
|
||||||
302
backend/app/api/v1/endpoints/client_profile.py
Normal file
302
backend/app/api/v1/endpoints/client_profile.py
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
"""
|
||||||
|
Client Profile Endpoints - ServiceManagerWeb
|
||||||
|
Endpoints para gestión del perfil empresarial de clientes
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, or_
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.api.deps import get_current_user, get_current_tenant
|
||||||
|
from app.api.schemas.client_profile import (
|
||||||
|
ClientProfileCreate,
|
||||||
|
ClientProfileUpdate,
|
||||||
|
ClientProfileResponse,
|
||||||
|
ClientProfileSummary
|
||||||
|
)
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.tenant import Tenant
|
||||||
|
from app.models.client_profile import ClientProfile
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=ClientProfileResponse)
|
||||||
|
async def get_current_client_profile(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
current_tenant: Tenant = Depends(get_current_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Obtener el perfil empresarial del tenant actual.
|
||||||
|
|
||||||
|
**Permisos**: CLIENT_ADMIN, CLIENT_USER
|
||||||
|
"""
|
||||||
|
# Solo clientes pueden acceder
|
||||||
|
if current_user.role not in ['CLIENT_ADMIN', 'CLIENT_USER']:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Solo los clientes pueden acceder al perfil empresarial"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Buscar perfil existente
|
||||||
|
result = await db.execute(
|
||||||
|
select(ClientProfile).where(ClientProfile.tenant_id == current_tenant.id)
|
||||||
|
)
|
||||||
|
profile = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not profile:
|
||||||
|
# Si no existe, crear uno vacío con valores por defecto explícitos
|
||||||
|
profile = ClientProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
tenant_id=current_tenant.id
|
||||||
|
)
|
||||||
|
db.add(profile)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(profile)
|
||||||
|
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=ClientProfileResponse)
|
||||||
|
async def create_or_update_client_profile(
|
||||||
|
profile_data: ClientProfileCreate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
current_tenant: Tenant = Depends(get_current_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Crear o actualizar el perfil empresarial del tenant actual.
|
||||||
|
|
||||||
|
**Permisos**: CLIENT_ADMIN
|
||||||
|
"""
|
||||||
|
# Solo CLIENT_ADMIN puede modificar el perfil
|
||||||
|
if current_user.role != 'CLIENT_ADMIN':
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Solo los administradores de cliente pueden modificar el perfil empresarial"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Buscar perfil existente
|
||||||
|
result = await db.execute(
|
||||||
|
select(ClientProfile).where(ClientProfile.tenant_id == current_tenant.id)
|
||||||
|
)
|
||||||
|
existing_profile = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing_profile:
|
||||||
|
# Actualizar perfil existente
|
||||||
|
update_data = profile_data.dict(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(existing_profile, field, value)
|
||||||
|
|
||||||
|
profile = existing_profile
|
||||||
|
else:
|
||||||
|
# Crear nuevo perfil
|
||||||
|
profile = ClientProfile(
|
||||||
|
tenant_id=current_tenant.id,
|
||||||
|
**profile_data.dict()
|
||||||
|
)
|
||||||
|
db.add(profile)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(profile)
|
||||||
|
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/", response_model=ClientProfileResponse)
|
||||||
|
async def update_client_profile(
|
||||||
|
profile_data: ClientProfileUpdate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
current_tenant: Tenant = Depends(get_current_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Actualizar parcialmente el perfil empresarial del tenant actual.
|
||||||
|
|
||||||
|
**Permisos**: CLIENT_ADMIN
|
||||||
|
"""
|
||||||
|
# Solo CLIENT_ADMIN puede modificar el perfil
|
||||||
|
if current_user.role != 'CLIENT_ADMIN':
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Solo los administradores de cliente pueden modificar el perfil empresarial"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Buscar perfil existente
|
||||||
|
result = await db.execute(
|
||||||
|
select(ClientProfile).where(ClientProfile.tenant_id == current_tenant.id)
|
||||||
|
)
|
||||||
|
profile = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not profile:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Perfil empresarial no encontrado"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Actualizar solo campos proporcionados
|
||||||
|
update_data = profile_data.dict(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(profile, field, value)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(profile)
|
||||||
|
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/")
|
||||||
|
async def delete_client_profile(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
current_tenant: Tenant = Depends(get_current_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Eliminar el perfil empresarial del tenant actual.
|
||||||
|
|
||||||
|
**Permisos**: CLIENT_ADMIN
|
||||||
|
"""
|
||||||
|
# Solo CLIENT_ADMIN puede eliminar el perfil
|
||||||
|
if current_user.role != 'CLIENT_ADMIN':
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Solo los administradores de cliente pueden eliminar el perfil empresarial"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Buscar perfil existente
|
||||||
|
result = await db.execute(
|
||||||
|
select(ClientProfile).where(ClientProfile.tenant_id == current_tenant.id)
|
||||||
|
)
|
||||||
|
profile = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not profile:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Perfil empresarial no encontrado"
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.delete(profile)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {"message": "Perfil empresarial eliminado exitosamente"}
|
||||||
|
|
||||||
|
|
||||||
|
# === ENDPOINTS ADMINISTRATIVOS (Solo para ADMIN y SUPPORT_MANAGER) ===
|
||||||
|
|
||||||
|
@router.get("/admin/list", response_model=list[ClientProfileSummary])
|
||||||
|
async def list_all_client_profiles(
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
search: Optional[str] = None,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Listar todos los perfiles empresariales (solo para administradores).
|
||||||
|
|
||||||
|
**Permisos**: ADMIN, SUPPORT_MANAGER
|
||||||
|
"""
|
||||||
|
# Solo personal interno puede ver todos los perfiles
|
||||||
|
if current_user.role not in ['ADMIN', 'SUPPORT_MANAGER']:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Acceso denegado"
|
||||||
|
)
|
||||||
|
|
||||||
|
query = select(ClientProfile)
|
||||||
|
|
||||||
|
# Filtro de búsqueda
|
||||||
|
if search:
|
||||||
|
search_filter = or_(
|
||||||
|
ClientProfile.business_name.ilike(f"%{search}%"),
|
||||||
|
ClientProfile.commercial_name.ilike(f"%{search}%"),
|
||||||
|
ClientProfile.rfc.ilike(f"%{search}%"),
|
||||||
|
ClientProfile.client_code.ilike(f"%{search}%")
|
||||||
|
)
|
||||||
|
query = query.where(search_filter)
|
||||||
|
|
||||||
|
# Paginación
|
||||||
|
query = query.offset(skip).limit(limit)
|
||||||
|
query = query.order_by(ClientProfile.created_at.desc())
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
profiles = result.scalars().all()
|
||||||
|
|
||||||
|
return profiles
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/{tenant_id}", response_model=ClientProfileResponse)
|
||||||
|
async def get_client_profile_by_tenant(
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Obtener perfil empresarial de un tenant específico (solo para administradores).
|
||||||
|
|
||||||
|
**Permisos**: ADMIN, SUPPORT_MANAGER
|
||||||
|
"""
|
||||||
|
# Solo personal interno puede ver perfiles de otros tenants
|
||||||
|
if current_user.role not in ['ADMIN', 'SUPPORT_MANAGER']:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Acceso denegado"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(ClientProfile).where(ClientProfile.tenant_id == tenant_id)
|
||||||
|
)
|
||||||
|
profile = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not profile:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Perfil empresarial no encontrado"
|
||||||
|
)
|
||||||
|
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/admin/{tenant_id}", response_model=ClientProfileResponse)
|
||||||
|
async def update_client_profile_by_admin(
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
profile_data: ClientProfileUpdate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Actualizar perfil empresarial de un tenant específico (solo para administradores).
|
||||||
|
|
||||||
|
**Permisos**: ADMIN, SUPPORT_MANAGER
|
||||||
|
"""
|
||||||
|
# Solo personal interno puede modificar perfiles de otros tenants
|
||||||
|
if current_user.role not in ['ADMIN', 'SUPPORT_MANAGER']:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Acceso denegado"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(ClientProfile).where(ClientProfile.tenant_id == tenant_id)
|
||||||
|
)
|
||||||
|
profile = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not profile:
|
||||||
|
# Crear perfil si no existe
|
||||||
|
profile = ClientProfile(tenant_id=tenant_id, **profile_data.dict(exclude_unset=True))
|
||||||
|
db.add(profile)
|
||||||
|
else:
|
||||||
|
# Actualizar perfil existente
|
||||||
|
update_data = profile_data.dict(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(profile, field, value)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(profile)
|
||||||
|
|
||||||
|
return profile
|
||||||
764
backend/app/api/v1/endpoints/reports.py
Normal file
764
backend/app/api/v1/endpoints/reports.py
Normal file
@@ -0,0 +1,764 @@
|
|||||||
|
"""
|
||||||
|
Reports Endpoints - ServiceManagerWeb
|
||||||
|
|
||||||
|
Módulo de reportes y estadísticas del sistema.
|
||||||
|
Accesible por ADMIN y SUPPORT_MANAGER.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func, and_, case, text, literal_column
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.models.ticket import Ticket, TicketStatus, TicketPriority
|
||||||
|
from app.models.category import Category
|
||||||
|
from app.models.system import System
|
||||||
|
from app.models.tenant import Tenant, TenantStatus
|
||||||
|
from app.api.schemas.reports import (
|
||||||
|
ReportSummaryResponse,
|
||||||
|
TicketsByStatus,
|
||||||
|
TicketsByPriority,
|
||||||
|
AgentReportResponse,
|
||||||
|
AgentReportRow,
|
||||||
|
CategoryReportResponse,
|
||||||
|
CategoryReportRow,
|
||||||
|
ClientReportResponse,
|
||||||
|
ClientReportRow,
|
||||||
|
TrendsReportResponse,
|
||||||
|
TrendDataPoint,
|
||||||
|
CSATReportResponse,
|
||||||
|
CSATDistribution,
|
||||||
|
SystemReportResponse,
|
||||||
|
SystemReportRow,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
CLOSED_STATUSES = {TicketStatus.RESOLVED, TicketStatus.CLOSED}
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# HELPERS
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
def require_reports_access(current_user: User = Depends(get_current_user)) -> User:
|
||||||
|
"""ADMIN, SUPPORT_MANAGER y AUDITOR pueden leer reportes."""
|
||||||
|
allowed = [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AUDITOR]
|
||||||
|
if current_user.role not in allowed:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Solo ADMIN, SUPPORT_MANAGER y AUDITOR pueden acceder a los reportes.",
|
||||||
|
)
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(current_user: User = Depends(get_current_user)) -> User:
|
||||||
|
"""Solo ADMIN puede ver reportes entre tenants."""
|
||||||
|
if current_user.role != UserRole.ADMIN:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Solo ADMIN puede ver reportes de todos los clientes.",
|
||||||
|
)
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
def _period_dates(days: int) -> tuple[datetime, datetime]:
|
||||||
|
"""Devuelve (inicio, fin) del período solicitado en UTC."""
|
||||||
|
end = datetime.now(timezone.utc)
|
||||||
|
start = end - timedelta(days=days)
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# 1. RESUMEN GENERAL
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
@router.get("/summary", response_model=ReportSummaryResponse)
|
||||||
|
async def get_report_summary(
|
||||||
|
days: int = Query(default=30, ge=1, le=365, description="Días hacia atrás del período"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_reports_access),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Resumen ejecutivo del período seleccionado.
|
||||||
|
|
||||||
|
Incluye:
|
||||||
|
- Total de tickets creados
|
||||||
|
- Tickets abiertos vs resueltos
|
||||||
|
- Tiempo promedio de resolución
|
||||||
|
- Calificación promedio (CSAT)
|
||||||
|
- Desglose por estado y prioridad
|
||||||
|
- Comparación con el período anterior
|
||||||
|
"""
|
||||||
|
period_start, period_end = _period_dates(days)
|
||||||
|
prev_start = period_start - timedelta(days=days)
|
||||||
|
|
||||||
|
tenant_filter = Ticket.tenant_id == current_user.tenant_id
|
||||||
|
|
||||||
|
# ── Conteos por estado ──
|
||||||
|
status_rows = (await db.execute(
|
||||||
|
select(Ticket.status, func.count(Ticket.id).label("cnt"))
|
||||||
|
.where(and_(tenant_filter, Ticket.created_at >= period_start))
|
||||||
|
.group_by(Ticket.status)
|
||||||
|
)).all()
|
||||||
|
|
||||||
|
by_status = TicketsByStatus()
|
||||||
|
for row in status_rows:
|
||||||
|
s = row.status.value if hasattr(row.status, "value") else str(row.status)
|
||||||
|
setattr(by_status, s.lower(), row.cnt)
|
||||||
|
by_status.total = sum(
|
||||||
|
[by_status.new, by_status.triage, by_status.in_progress,
|
||||||
|
by_status.waiting_customer, by_status.resolved, by_status.closed, by_status.reopened]
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Conteos por prioridad ──
|
||||||
|
priority_rows = (await db.execute(
|
||||||
|
select(Ticket.priority, func.count(Ticket.id).label("cnt"))
|
||||||
|
.where(and_(tenant_filter, Ticket.created_at >= period_start))
|
||||||
|
.group_by(Ticket.priority)
|
||||||
|
)).all()
|
||||||
|
|
||||||
|
by_priority = TicketsByPriority()
|
||||||
|
for row in priority_rows:
|
||||||
|
p = row.priority.value if hasattr(row.priority, "value") else str(row.priority)
|
||||||
|
setattr(by_priority, p.lower(), row.cnt)
|
||||||
|
by_priority.total = sum([by_priority.low, by_priority.medium, by_priority.high, by_priority.urgent])
|
||||||
|
|
||||||
|
total_tickets = by_status.total
|
||||||
|
resolved_tickets = by_status.resolved + by_status.closed
|
||||||
|
open_tickets = total_tickets - resolved_tickets
|
||||||
|
|
||||||
|
# ── Promedio de tiempo de resolución (segundos → horas) ──
|
||||||
|
res_time_row = (await db.execute(
|
||||||
|
select(func.avg(
|
||||||
|
func.extract("epoch", Ticket.resolved_at - Ticket.created_at)
|
||||||
|
).label("avg_seconds"))
|
||||||
|
.where(and_(
|
||||||
|
tenant_filter,
|
||||||
|
Ticket.created_at >= period_start,
|
||||||
|
Ticket.resolved_at.isnot(None),
|
||||||
|
))
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
avg_resolution_hours = round(res_time_row / 3600, 2) if res_time_row else None
|
||||||
|
|
||||||
|
# ── Promedio de primera respuesta ──
|
||||||
|
resp_time_row = (await db.execute(
|
||||||
|
select(func.avg(
|
||||||
|
func.extract("epoch", Ticket.first_response_at - Ticket.created_at)
|
||||||
|
).label("avg_seconds"))
|
||||||
|
.where(and_(
|
||||||
|
tenant_filter,
|
||||||
|
Ticket.created_at >= period_start,
|
||||||
|
Ticket.first_response_at.isnot(None),
|
||||||
|
))
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
avg_first_response_hours = round(resp_time_row / 3600, 2) if resp_time_row else None
|
||||||
|
|
||||||
|
# ── CSAT ──
|
||||||
|
csat_row = (await db.execute(
|
||||||
|
select(func.avg(Ticket.rating).label("avg"), func.count(Ticket.rating).label("cnt"))
|
||||||
|
.where(and_(tenant_filter, Ticket.created_at >= period_start, Ticket.rating.isnot(None)))
|
||||||
|
)).one()
|
||||||
|
avg_rating = round(float(csat_row.avg), 2) if csat_row.avg else None
|
||||||
|
total_rated = csat_row.cnt or 0
|
||||||
|
|
||||||
|
# ── Comparación con período anterior ──
|
||||||
|
prev_total = (await db.execute(
|
||||||
|
select(func.count(Ticket.id))
|
||||||
|
.where(and_(tenant_filter, Ticket.created_at >= prev_start, Ticket.created_at < period_start))
|
||||||
|
)).scalar_one_or_none() or 0
|
||||||
|
|
||||||
|
prev_resolved = (await db.execute(
|
||||||
|
select(func.count(Ticket.id))
|
||||||
|
.where(and_(
|
||||||
|
tenant_filter,
|
||||||
|
Ticket.created_at >= prev_start,
|
||||||
|
Ticket.created_at < period_start,
|
||||||
|
Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]),
|
||||||
|
))
|
||||||
|
)).scalar_one_or_none() or 0
|
||||||
|
|
||||||
|
tickets_change_pct = None
|
||||||
|
if prev_total > 0:
|
||||||
|
tickets_change_pct = round(((total_tickets - prev_total) / prev_total) * 100, 1)
|
||||||
|
|
||||||
|
resolution_change_pct = None
|
||||||
|
if prev_total > 0 and total_tickets > 0:
|
||||||
|
cur_rate = resolved_tickets / total_tickets * 100
|
||||||
|
prev_rate = prev_resolved / prev_total * 100 if prev_total > 0 else 0
|
||||||
|
resolution_change_pct = round(cur_rate - prev_rate, 1)
|
||||||
|
|
||||||
|
return ReportSummaryResponse(
|
||||||
|
period_start=period_start,
|
||||||
|
period_end=period_end,
|
||||||
|
generated_at=datetime.now(timezone.utc),
|
||||||
|
total_tickets=total_tickets,
|
||||||
|
open_tickets=open_tickets,
|
||||||
|
resolved_tickets=resolved_tickets,
|
||||||
|
avg_resolution_hours=avg_resolution_hours,
|
||||||
|
avg_first_response_hours=avg_first_response_hours,
|
||||||
|
avg_rating=avg_rating,
|
||||||
|
total_rated=total_rated,
|
||||||
|
by_status=by_status,
|
||||||
|
by_priority=by_priority,
|
||||||
|
tickets_change_pct=tickets_change_pct,
|
||||||
|
resolution_change_pct=resolution_change_pct,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# 2. RENDIMIENTO POR AGENTE
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
@router.get("/by-agent", response_model=AgentReportResponse)
|
||||||
|
async def get_report_by_agent(
|
||||||
|
days: int = Query(default=30, ge=1, le=365),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_reports_access),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Rendimiento de cada agente en el período:
|
||||||
|
- Tickets asignados y resueltos
|
||||||
|
- Tasa de resolución
|
||||||
|
- Tiempo promedio de resolución
|
||||||
|
- Calificación promedio (CSAT)
|
||||||
|
"""
|
||||||
|
period_start, period_end = _period_dates(days)
|
||||||
|
tenant_filter = and_(
|
||||||
|
Ticket.tenant_id == current_user.tenant_id,
|
||||||
|
Ticket.created_at >= period_start,
|
||||||
|
Ticket.assigned_to.isnot(None),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Obtener todos los agentes del tenant
|
||||||
|
agents_result = await db.execute(
|
||||||
|
select(User).where(
|
||||||
|
and_(
|
||||||
|
User.tenant_id == current_user.tenant_id,
|
||||||
|
User.role.in_([UserRole.AGENT, UserRole.SUPPORT_MANAGER, UserRole.ADMIN]),
|
||||||
|
User.is_active == True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
agents = agents_result.scalars().all()
|
||||||
|
|
||||||
|
rows: List[AgentReportRow] = []
|
||||||
|
for agent in agents:
|
||||||
|
agent_filter = and_(tenant_filter, Ticket.assigned_to == agent.id)
|
||||||
|
|
||||||
|
total_assigned = (await db.execute(
|
||||||
|
select(func.count(Ticket.id)).where(agent_filter)
|
||||||
|
)).scalar_one_or_none() or 0
|
||||||
|
|
||||||
|
if total_assigned == 0:
|
||||||
|
continue # omitir agentes sin tickets en el período
|
||||||
|
|
||||||
|
resolved = (await db.execute(
|
||||||
|
select(func.count(Ticket.id)).where(
|
||||||
|
and_(agent_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]))
|
||||||
|
)
|
||||||
|
)).scalar_one_or_none() or 0
|
||||||
|
|
||||||
|
avg_res_seconds = (await db.execute(
|
||||||
|
select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at)))
|
||||||
|
.where(and_(agent_filter, Ticket.resolved_at.isnot(None)))
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
|
||||||
|
csat = (await db.execute(
|
||||||
|
select(func.avg(Ticket.rating), func.count(Ticket.rating))
|
||||||
|
.where(and_(agent_filter, Ticket.rating.isnot(None)))
|
||||||
|
)).one()
|
||||||
|
|
||||||
|
urgent_handled = (await db.execute(
|
||||||
|
select(func.count(Ticket.id)).where(
|
||||||
|
and_(agent_filter, Ticket.priority == TicketPriority.URGENT)
|
||||||
|
)
|
||||||
|
)).scalar_one_or_none() or 0
|
||||||
|
|
||||||
|
rows.append(AgentReportRow(
|
||||||
|
agent_id=str(agent.id),
|
||||||
|
agent_name=f"{agent.first_name} {agent.last_name}",
|
||||||
|
agent_email=agent.email,
|
||||||
|
total_assigned=total_assigned,
|
||||||
|
resolved=resolved,
|
||||||
|
open=total_assigned - resolved,
|
||||||
|
resolution_rate=round((resolved / total_assigned * 100), 1) if total_assigned else 0,
|
||||||
|
avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None,
|
||||||
|
avg_rating=round(float(csat[0]), 2) if csat[0] else None,
|
||||||
|
total_rated=csat[1] or 0,
|
||||||
|
urgent_handled=urgent_handled,
|
||||||
|
))
|
||||||
|
|
||||||
|
rows.sort(key=lambda r: r.resolved, reverse=True)
|
||||||
|
|
||||||
|
return AgentReportResponse(
|
||||||
|
period_start=period_start,
|
||||||
|
period_end=period_end,
|
||||||
|
generated_at=datetime.now(timezone.utc),
|
||||||
|
agents=rows,
|
||||||
|
total_agents=len(rows),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# 3. TICKETS POR CATEGORÍA
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
@router.get("/by-category", response_model=CategoryReportResponse)
|
||||||
|
async def get_report_by_category(
|
||||||
|
days: int = Query(default=30, ge=1, le=365),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_reports_access),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Tickets agrupados por categoría con tasa de cumplimiento SLA.
|
||||||
|
"""
|
||||||
|
period_start, _ = _period_dates(days)
|
||||||
|
period_end = datetime.now(timezone.utc)
|
||||||
|
tenant_filter = and_(
|
||||||
|
Ticket.tenant_id == current_user.tenant_id,
|
||||||
|
Ticket.created_at >= period_start,
|
||||||
|
)
|
||||||
|
|
||||||
|
categories_result = await db.execute(
|
||||||
|
select(Category).where(
|
||||||
|
and_(Category.tenant_id == current_user.tenant_id, Category.is_active == True)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
categories = categories_result.scalars().all()
|
||||||
|
|
||||||
|
rows: List[CategoryReportRow] = []
|
||||||
|
|
||||||
|
for cat in categories:
|
||||||
|
cat_filter = and_(tenant_filter, Ticket.category_id == cat.id)
|
||||||
|
|
||||||
|
total = (await db.execute(
|
||||||
|
select(func.count(Ticket.id)).where(cat_filter)
|
||||||
|
)).scalar_one_or_none() or 0
|
||||||
|
|
||||||
|
if total == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
resolved = (await db.execute(
|
||||||
|
select(func.count(Ticket.id)).where(
|
||||||
|
and_(cat_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]))
|
||||||
|
)
|
||||||
|
)).scalar_one_or_none() or 0
|
||||||
|
|
||||||
|
avg_res_seconds = (await db.execute(
|
||||||
|
select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at)))
|
||||||
|
.where(and_(cat_filter, Ticket.resolved_at.isnot(None)))
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
|
||||||
|
# SLA compliance: tickets resueltos ANTES del deadline
|
||||||
|
sla_met = (await db.execute(
|
||||||
|
select(func.count(Ticket.id)).where(
|
||||||
|
and_(
|
||||||
|
cat_filter,
|
||||||
|
Ticket.resolved_at.isnot(None),
|
||||||
|
Ticket.sla_resolution_due.isnot(None),
|
||||||
|
Ticket.resolved_at <= Ticket.sla_resolution_due,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)).scalar_one_or_none() or 0
|
||||||
|
|
||||||
|
tickets_with_sla = (await db.execute(
|
||||||
|
select(func.count(Ticket.id)).where(
|
||||||
|
and_(cat_filter, Ticket.sla_resolution_due.isnot(None), Ticket.resolved_at.isnot(None))
|
||||||
|
)
|
||||||
|
)).scalar_one_or_none() or 0
|
||||||
|
|
||||||
|
sla_compliance_pct = round((sla_met / tickets_with_sla * 100), 1) if tickets_with_sla else 0.0
|
||||||
|
|
||||||
|
rows.append(CategoryReportRow(
|
||||||
|
category_id=str(cat.id),
|
||||||
|
category_name=cat.name,
|
||||||
|
total_tickets=total,
|
||||||
|
open_tickets=total - resolved,
|
||||||
|
resolved_tickets=resolved,
|
||||||
|
avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None,
|
||||||
|
sla_response_hours=cat.sla_response_hours,
|
||||||
|
sla_resolution_hours=cat.sla_resolution_hours,
|
||||||
|
sla_compliance_pct=sla_compliance_pct,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Sin categoría
|
||||||
|
uncategorized = (await db.execute(
|
||||||
|
select(func.count(Ticket.id)).where(
|
||||||
|
and_(tenant_filter, Ticket.category_id.is_(None))
|
||||||
|
)
|
||||||
|
)).scalar_one_or_none() or 0
|
||||||
|
|
||||||
|
rows.sort(key=lambda r: r.total_tickets, reverse=True)
|
||||||
|
|
||||||
|
return CategoryReportResponse(
|
||||||
|
period_start=period_start,
|
||||||
|
period_end=period_end,
|
||||||
|
generated_at=datetime.now(timezone.utc),
|
||||||
|
categories=rows,
|
||||||
|
uncategorized_count=uncategorized,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# 4. TICKETS POR CLIENTE (solo ADMIN)
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
@router.get("/by-client", response_model=ClientReportResponse)
|
||||||
|
async def get_report_by_client(
|
||||||
|
days: int = Query(default=30, ge=1, le=365),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Tickets agrupados por cliente (tenant). Solo accesible por ADMIN.
|
||||||
|
Útil para ver qué clientes generan más trabajo.
|
||||||
|
"""
|
||||||
|
period_start, period_end = _period_dates(days)
|
||||||
|
|
||||||
|
tenants_result = await db.execute(select(Tenant).where(Tenant.status == TenantStatus.ACTIVE))
|
||||||
|
tenants = tenants_result.scalars().all()
|
||||||
|
|
||||||
|
rows: List[ClientReportRow] = []
|
||||||
|
|
||||||
|
for tenant in tenants:
|
||||||
|
t_filter = and_(
|
||||||
|
Ticket.tenant_id == tenant.id,
|
||||||
|
Ticket.created_at >= period_start,
|
||||||
|
)
|
||||||
|
|
||||||
|
total = (await db.execute(
|
||||||
|
select(func.count(Ticket.id)).where(t_filter)
|
||||||
|
)).scalar_one_or_none() or 0
|
||||||
|
|
||||||
|
if total == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
resolved = (await db.execute(
|
||||||
|
select(func.count(Ticket.id)).where(
|
||||||
|
and_(t_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]))
|
||||||
|
)
|
||||||
|
)).scalar_one_or_none() or 0
|
||||||
|
|
||||||
|
urgent = (await db.execute(
|
||||||
|
select(func.count(Ticket.id)).where(
|
||||||
|
and_(t_filter, Ticket.priority == TicketPriority.URGENT)
|
||||||
|
)
|
||||||
|
)).scalar_one_or_none() or 0
|
||||||
|
|
||||||
|
csat_row = (await db.execute(
|
||||||
|
select(func.avg(Ticket.rating))
|
||||||
|
.where(and_(t_filter, Ticket.rating.isnot(None)))
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
|
||||||
|
avg_res_seconds = (await db.execute(
|
||||||
|
select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at)))
|
||||||
|
.where(and_(t_filter, Ticket.resolved_at.isnot(None)))
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
|
||||||
|
last_ticket = (await db.execute(
|
||||||
|
select(func.max(Ticket.created_at)).where(t_filter)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
|
||||||
|
rows.append(ClientReportRow(
|
||||||
|
tenant_id=str(tenant.id),
|
||||||
|
tenant_name=tenant.name,
|
||||||
|
total_tickets=total,
|
||||||
|
open_tickets=total - resolved,
|
||||||
|
resolved_tickets=resolved,
|
||||||
|
urgent_tickets=urgent,
|
||||||
|
avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None,
|
||||||
|
avg_rating=round(float(csat_row), 2) if csat_row else None,
|
||||||
|
last_ticket_at=last_ticket,
|
||||||
|
))
|
||||||
|
|
||||||
|
rows.sort(key=lambda r: r.total_tickets, reverse=True)
|
||||||
|
|
||||||
|
return ClientReportResponse(
|
||||||
|
period_start=period_start,
|
||||||
|
period_end=period_end,
|
||||||
|
generated_at=datetime.now(timezone.utc),
|
||||||
|
clients=rows,
|
||||||
|
total_clients=len(rows),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# 5. TENDENCIAS (TICKETS EN EL TIEMPO)
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
@router.get("/trends", response_model=TrendsReportResponse)
|
||||||
|
async def get_report_trends(
|
||||||
|
days: int = Query(default=30, ge=7, le=90, description="Número de días (7-90)"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_reports_access),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Evolución diaria de tickets creados y resueltos.
|
||||||
|
Útil para detectar picos de trabajo.
|
||||||
|
"""
|
||||||
|
period_start, period_end = _period_dates(days)
|
||||||
|
tenant_filter = Ticket.tenant_id == current_user.tenant_id
|
||||||
|
|
||||||
|
# Tickets creados por día
|
||||||
|
# literal_column("'day'") evita que SQLAlchemy genere múltiples parámetros
|
||||||
|
# ($1, $4, $5) para 'day', lo que confunde a PostgreSQL en el GROUP BY.
|
||||||
|
_day_lit = literal_column("'day'")
|
||||||
|
created_rows = (await db.execute(
|
||||||
|
select(
|
||||||
|
func.date_trunc(_day_lit, Ticket.created_at).label("day"),
|
||||||
|
func.count(Ticket.id).label("cnt"),
|
||||||
|
)
|
||||||
|
.where(and_(tenant_filter, Ticket.created_at >= period_start))
|
||||||
|
.group_by(func.date_trunc(_day_lit, Ticket.created_at))
|
||||||
|
.order_by(func.date_trunc(_day_lit, Ticket.created_at))
|
||||||
|
)).all()
|
||||||
|
|
||||||
|
# Tickets resueltos por día (según resolved_at)
|
||||||
|
resolved_rows = (await db.execute(
|
||||||
|
select(
|
||||||
|
func.date_trunc(_day_lit, Ticket.resolved_at).label("day"),
|
||||||
|
func.count(Ticket.id).label("cnt"),
|
||||||
|
)
|
||||||
|
.where(and_(
|
||||||
|
tenant_filter,
|
||||||
|
Ticket.resolved_at >= period_start,
|
||||||
|
Ticket.resolved_at.isnot(None),
|
||||||
|
))
|
||||||
|
.group_by(func.date_trunc(_day_lit, Ticket.resolved_at))
|
||||||
|
.order_by(func.date_trunc(_day_lit, Ticket.resolved_at))
|
||||||
|
)).all()
|
||||||
|
|
||||||
|
created_map: dict[str, int] = {r.day.strftime("%Y-%m-%d"): r.cnt for r in created_rows}
|
||||||
|
resolved_map: dict[str, int] = {r.day.strftime("%Y-%m-%d"): r.cnt for r in resolved_rows}
|
||||||
|
|
||||||
|
# Un punto por cada día del período
|
||||||
|
data_points: List[TrendDataPoint] = []
|
||||||
|
current = period_start
|
||||||
|
while current <= period_end:
|
||||||
|
date_str = current.strftime("%Y-%m-%d")
|
||||||
|
c = created_map.get(date_str, 0)
|
||||||
|
r = resolved_map.get(date_str, 0)
|
||||||
|
data_points.append(TrendDataPoint(date=date_str, created=c, resolved=r, net_open=c - r))
|
||||||
|
current += timedelta(days=1)
|
||||||
|
|
||||||
|
return TrendsReportResponse(
|
||||||
|
period_start=period_start,
|
||||||
|
period_end=period_end,
|
||||||
|
generated_at=datetime.now(timezone.utc),
|
||||||
|
data_points=data_points,
|
||||||
|
total_days=len(data_points),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# 6. SATISFACCIÓN DEL CLIENTE (CSAT)
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
@router.get("/csat", response_model=CSATReportResponse)
|
||||||
|
async def get_report_csat(
|
||||||
|
days: int = Query(default=30, ge=1, le=365),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_reports_access),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Reporte de satisfacción del cliente (calificaciones 1-5).
|
||||||
|
Incluye distribución, promedio por categoría y por agente.
|
||||||
|
"""
|
||||||
|
period_start, period_end = _period_dates(days)
|
||||||
|
tenant_filter = and_(
|
||||||
|
Ticket.tenant_id == current_user.tenant_id,
|
||||||
|
Ticket.created_at >= period_start,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Total y promedio general
|
||||||
|
general = (await db.execute(
|
||||||
|
select(func.avg(Ticket.rating).label("avg"), func.count(Ticket.rating).label("rated"))
|
||||||
|
.where(and_(tenant_filter, Ticket.rating.isnot(None)))
|
||||||
|
)).one()
|
||||||
|
total_tickets = (await db.execute(
|
||||||
|
select(func.count(Ticket.id)).where(tenant_filter)
|
||||||
|
)).scalar_one_or_none() or 0
|
||||||
|
|
||||||
|
# Distribución por estrellas
|
||||||
|
dist_rows = (await db.execute(
|
||||||
|
select(Ticket.rating, func.count(Ticket.id).label("cnt"))
|
||||||
|
.where(and_(tenant_filter, Ticket.rating.isnot(None)))
|
||||||
|
.group_by(Ticket.rating)
|
||||||
|
)).all()
|
||||||
|
|
||||||
|
dist = CSATDistribution()
|
||||||
|
for row in dist_rows:
|
||||||
|
setattr(dist, f"rating_{row.rating}", row.cnt)
|
||||||
|
|
||||||
|
# Promedio por categoría
|
||||||
|
cat_rows = (await db.execute(
|
||||||
|
select(
|
||||||
|
Category.name.label("cat_name"),
|
||||||
|
func.avg(Ticket.rating).label("avg"),
|
||||||
|
func.count(Ticket.rating).label("cnt"),
|
||||||
|
)
|
||||||
|
.join(Category, Ticket.category_id == Category.id, isouter=True)
|
||||||
|
.where(and_(tenant_filter, Ticket.rating.isnot(None)))
|
||||||
|
.group_by(Category.name)
|
||||||
|
.order_by(func.avg(Ticket.rating).desc())
|
||||||
|
)).all()
|
||||||
|
|
||||||
|
by_category = [
|
||||||
|
{
|
||||||
|
"category": row.cat_name or "Sin categoría",
|
||||||
|
"avg_rating": round(float(row.avg), 2) if row.avg else None,
|
||||||
|
"total_rated": row.cnt,
|
||||||
|
}
|
||||||
|
for row in cat_rows
|
||||||
|
]
|
||||||
|
|
||||||
|
# Promedio por agente
|
||||||
|
agent_rows = (await db.execute(
|
||||||
|
select(
|
||||||
|
User.first_name.label("fname"),
|
||||||
|
User.last_name.label("lname"),
|
||||||
|
func.avg(Ticket.rating).label("avg"),
|
||||||
|
func.count(Ticket.rating).label("cnt"),
|
||||||
|
)
|
||||||
|
.join(User, Ticket.assigned_to == User.id, isouter=True)
|
||||||
|
.where(and_(tenant_filter, Ticket.rating.isnot(None)))
|
||||||
|
.group_by(User.first_name, User.last_name)
|
||||||
|
.order_by(func.avg(Ticket.rating).desc())
|
||||||
|
)).all()
|
||||||
|
|
||||||
|
by_agent = [
|
||||||
|
{
|
||||||
|
"agent": f"{row.fname or ''} {row.lname or ''}".strip() or "Sin asignar",
|
||||||
|
"avg_rating": round(float(row.avg), 2) if row.avg else None,
|
||||||
|
"total_rated": row.cnt,
|
||||||
|
}
|
||||||
|
for row in agent_rows
|
||||||
|
]
|
||||||
|
|
||||||
|
# Últimos comentarios de calificación (rating_comment)
|
||||||
|
comment_rows = (await db.execute(
|
||||||
|
select(Ticket.rating, Ticket.rating_comment, Ticket.rated_at)
|
||||||
|
.where(and_(
|
||||||
|
tenant_filter,
|
||||||
|
Ticket.rating.isnot(None),
|
||||||
|
Ticket.rating_comment.isnot(None),
|
||||||
|
Ticket.rating_comment != "",
|
||||||
|
))
|
||||||
|
.order_by(Ticket.rated_at.desc())
|
||||||
|
.limit(10)
|
||||||
|
)).all()
|
||||||
|
|
||||||
|
recent_comments = [
|
||||||
|
{
|
||||||
|
"rating": row.rating,
|
||||||
|
"comment": row.rating_comment,
|
||||||
|
"rated_at": row.rated_at.isoformat() if row.rated_at else None,
|
||||||
|
}
|
||||||
|
for row in comment_rows
|
||||||
|
]
|
||||||
|
|
||||||
|
total_rated = general.rated or 0
|
||||||
|
response_rate = round((total_rated / total_tickets * 100), 1) if total_tickets else 0.0
|
||||||
|
|
||||||
|
return CSATReportResponse(
|
||||||
|
period_start=period_start,
|
||||||
|
period_end=period_end,
|
||||||
|
generated_at=datetime.now(timezone.utc),
|
||||||
|
avg_rating=round(float(general.avg), 2) if general.avg else None,
|
||||||
|
total_rated=total_rated,
|
||||||
|
total_tickets=total_tickets,
|
||||||
|
response_rate=response_rate,
|
||||||
|
distribution=dist,
|
||||||
|
by_category=by_category,
|
||||||
|
by_agent=by_agent,
|
||||||
|
recent_comments=recent_comments,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# 7. TICKETS POR SISTEMA AFECTADO
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
@router.get("/by-system", response_model=SystemReportResponse)
|
||||||
|
async def get_report_by_system(
|
||||||
|
days: int = Query(default=30, ge=1, le=365),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_reports_access),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Tickets agrupados por sistema afectado.
|
||||||
|
Útil para detectar qué sistemas generan más incidentes.
|
||||||
|
"""
|
||||||
|
period_start, period_end = _period_dates(days)
|
||||||
|
tenant_filter = and_(
|
||||||
|
Ticket.tenant_id == current_user.tenant_id,
|
||||||
|
Ticket.created_at >= period_start,
|
||||||
|
)
|
||||||
|
|
||||||
|
systems_result = await db.execute(
|
||||||
|
select(System).where(
|
||||||
|
and_(System.tenant_id == current_user.tenant_id, System.is_active == True)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
systems = systems_result.scalars().all()
|
||||||
|
|
||||||
|
rows: List[SystemReportRow] = []
|
||||||
|
|
||||||
|
for sys in systems:
|
||||||
|
sys_filter = and_(tenant_filter, Ticket.affected_system_id == sys.id)
|
||||||
|
|
||||||
|
total = (await db.execute(
|
||||||
|
select(func.count(Ticket.id)).where(sys_filter)
|
||||||
|
)).scalar_one_or_none() or 0
|
||||||
|
|
||||||
|
if total == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
resolved = (await db.execute(
|
||||||
|
select(func.count(Ticket.id)).where(
|
||||||
|
and_(sys_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]))
|
||||||
|
)
|
||||||
|
)).scalar_one_or_none() or 0
|
||||||
|
|
||||||
|
urgent = (await db.execute(
|
||||||
|
select(func.count(Ticket.id)).where(
|
||||||
|
and_(sys_filter, Ticket.priority == TicketPriority.URGENT)
|
||||||
|
)
|
||||||
|
)).scalar_one_or_none() or 0
|
||||||
|
|
||||||
|
avg_res_seconds = (await db.execute(
|
||||||
|
select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at)))
|
||||||
|
.where(and_(sys_filter, Ticket.resolved_at.isnot(None)))
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
|
||||||
|
rows.append(SystemReportRow(
|
||||||
|
system_id=str(sys.id),
|
||||||
|
system_name=sys.name,
|
||||||
|
total_tickets=total,
|
||||||
|
open_tickets=total - resolved,
|
||||||
|
resolved_tickets=resolved,
|
||||||
|
urgent_tickets=urgent,
|
||||||
|
avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Sin sistema asignado
|
||||||
|
no_system = (await db.execute(
|
||||||
|
select(func.count(Ticket.id)).where(
|
||||||
|
and_(tenant_filter, Ticket.affected_system_id.is_(None))
|
||||||
|
)
|
||||||
|
)).scalar_one_or_none() or 0
|
||||||
|
|
||||||
|
rows.sort(key=lambda r: r.total_tickets, reverse=True)
|
||||||
|
|
||||||
|
return SystemReportResponse(
|
||||||
|
period_start=period_start,
|
||||||
|
period_end=period_end,
|
||||||
|
generated_at=datetime.now(timezone.utc),
|
||||||
|
systems=rows,
|
||||||
|
no_system_count=no_system,
|
||||||
|
)
|
||||||
664
backend/app/api/v1/endpoints/sla.py
Normal file
664
backend/app/api/v1/endpoints/sla.py
Normal file
@@ -0,0 +1,664 @@
|
|||||||
|
"""
|
||||||
|
SLA Endpoints - ServiceManagerWeb
|
||||||
|
|
||||||
|
Endpoints para gestión y monitoreo de SLAs
|
||||||
|
Solo accesible por roles staff internos
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func, and_, or_, desc, case, cast
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import uuid
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.api.deps import get_current_user, get_current_tenant
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.models.tenant import Tenant
|
||||||
|
from app.models.ticket import Ticket, TicketStatus, TicketPriority
|
||||||
|
from app.models.category import Category
|
||||||
|
from app.api.schemas.sla import (
|
||||||
|
SLADashboardResponse,
|
||||||
|
SLAComplianceMetrics,
|
||||||
|
SLAViolationResponse,
|
||||||
|
SLAViolationsListResponse,
|
||||||
|
SLAAtRiskListResponse,
|
||||||
|
SLATicketAtRisk,
|
||||||
|
SLADetailedMetricsResponse,
|
||||||
|
SLAMetricsByCategory,
|
||||||
|
SLAMetricsByAgent,
|
||||||
|
SLAMetricsByPriority,
|
||||||
|
SLATrendsResponse,
|
||||||
|
SLADailyTrend,
|
||||||
|
SLAConfigListResponse,
|
||||||
|
SLAConfigByCategoryResponse,
|
||||||
|
SLATypeEnum,
|
||||||
|
TicketBasicInfo,
|
||||||
|
UserBasicInfo,
|
||||||
|
CategoryBasicInfo
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def require_staff_role(current_user: User = Depends(get_current_user)) -> User:
|
||||||
|
"""Requiere roles de staff interno (ADMIN, SUPPORT_MANAGER, AGENT)"""
|
||||||
|
allowed_roles = [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AGENT, UserRole.AUDITOR]
|
||||||
|
if current_user.role not in allowed_roles:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Solo staff interno puede acceder a métricas de SLA"
|
||||||
|
)
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
def require_manager_role(current_user: User = Depends(get_current_user)) -> User:
|
||||||
|
"""Requiere roles de gestión (ADMIN, SUPPORT_MANAGER)"""
|
||||||
|
if current_user.role not in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Solo managers pueden acceder a esta funcionalidad"
|
||||||
|
)
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# DASHBOARD PRINCIPAL
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
@router.get("/dashboard", response_model=SLADashboardResponse)
|
||||||
|
async def get_sla_dashboard(
|
||||||
|
days: int = Query(default=30, ge=1, le=365, description="Días hacia atrás para el período"),
|
||||||
|
current_user: User = Depends(require_staff_role),
|
||||||
|
current_tenant: Tenant = Depends(get_current_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Dashboard principal de métricas SLA.
|
||||||
|
|
||||||
|
**Permisos**: ADMIN, SUPPORT_MANAGER, AGENT, AUDITOR
|
||||||
|
|
||||||
|
Retorna métricas agregadas de cumplimiento SLA para el período especificado.
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
"SLA dashboard requested",
|
||||||
|
user_id=str(current_user.id),
|
||||||
|
tenant_id=str(current_tenant.id),
|
||||||
|
days=days
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
period_start = now - timedelta(days=days)
|
||||||
|
|
||||||
|
# Usar func.now() para comparaciones en SQL (evita timezone issues)
|
||||||
|
db_now = func.now()
|
||||||
|
|
||||||
|
# Query base para tickets del período
|
||||||
|
base_query = select(Ticket).where(
|
||||||
|
and_(
|
||||||
|
Ticket.tenant_id == current_tenant.id,
|
||||||
|
Ticket.created_at >= period_start
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calcular métricas de Response SLA
|
||||||
|
# Para "at risk": ticket pendiente que ha consumido >80% del tiempo disponible
|
||||||
|
# Calculamos: (now - created_at) > 0.8 * (sla_response_due - created_at)
|
||||||
|
response_query = select(
|
||||||
|
func.count().label('total'),
|
||||||
|
func.sum(case((Ticket.first_response_at <= Ticket.sla_response_due, 1), else_=0)).label('met'),
|
||||||
|
func.sum(case((and_(Ticket.first_response_at > Ticket.sla_response_due, Ticket.first_response_at != None), 1), else_=0)).label('violated'),
|
||||||
|
func.sum(case((and_(Ticket.first_response_at == None, Ticket.sla_response_due != None, db_now > Ticket.sla_response_due), 1), else_=0)).label('violated_pending'),
|
||||||
|
func.sum(case((
|
||||||
|
and_(
|
||||||
|
Ticket.first_response_at == None,
|
||||||
|
Ticket.sla_response_due != None,
|
||||||
|
db_now < Ticket.sla_response_due,
|
||||||
|
func.extract('epoch', db_now - Ticket.created_at) > (func.extract('epoch', Ticket.sla_response_due - Ticket.created_at) * 0.8)
|
||||||
|
), 1), else_=0)
|
||||||
|
).label('at_risk'),
|
||||||
|
func.avg(
|
||||||
|
func.extract('epoch', Ticket.first_response_at - Ticket.created_at) / 3600
|
||||||
|
).label('avg_hours')
|
||||||
|
).where(
|
||||||
|
and_(
|
||||||
|
Ticket.tenant_id == current_tenant.id,
|
||||||
|
Ticket.created_at >= period_start,
|
||||||
|
Ticket.sla_response_due != None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
response_result = await db.execute(response_query)
|
||||||
|
response_row = response_result.one()
|
||||||
|
|
||||||
|
response_total = response_row.total or 0
|
||||||
|
response_met = (response_row.met or 0)
|
||||||
|
response_violated = (response_row.violated or 0) + (response_row.violated_pending or 0)
|
||||||
|
response_at_risk = response_row.at_risk or 0
|
||||||
|
response_avg = float(response_row.avg_hours) if response_row.avg_hours else 0.0
|
||||||
|
response_compliance = (response_met / response_total * 100) if response_total > 0 else 0.0
|
||||||
|
|
||||||
|
# Calcular métricas de Resolution SLA
|
||||||
|
resolution_query = select(
|
||||||
|
func.count().label('total'),
|
||||||
|
func.sum(case((Ticket.resolved_at <= Ticket.sla_resolution_due, 1), else_=0)).label('met'),
|
||||||
|
func.sum(case((and_(Ticket.resolved_at > Ticket.sla_resolution_due, Ticket.resolved_at != None), 1), else_=0)).label('violated'),
|
||||||
|
func.sum(case((and_(Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED]), Ticket.sla_resolution_due != None, db_now > Ticket.sla_resolution_due), 1), else_=0)).label('violated_pending'),
|
||||||
|
func.sum(case((
|
||||||
|
and_(
|
||||||
|
Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED]),
|
||||||
|
Ticket.sla_resolution_due != None,
|
||||||
|
db_now < Ticket.sla_resolution_due,
|
||||||
|
func.extract('epoch', db_now - Ticket.created_at) > (func.extract('epoch', Ticket.sla_resolution_due - Ticket.created_at) * 0.8)
|
||||||
|
), 1), else_=0)
|
||||||
|
).label('at_risk'),
|
||||||
|
func.avg(
|
||||||
|
func.extract('epoch', Ticket.resolved_at - Ticket.created_at) / 3600
|
||||||
|
).label('avg_hours')
|
||||||
|
).where(
|
||||||
|
and_(
|
||||||
|
Ticket.tenant_id == current_tenant.id,
|
||||||
|
Ticket.created_at >= period_start,
|
||||||
|
Ticket.sla_resolution_due != None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
resolution_result = await db.execute(resolution_query)
|
||||||
|
resolution_row = resolution_result.one()
|
||||||
|
|
||||||
|
resolution_total = resolution_row.total or 0
|
||||||
|
resolution_met = (resolution_row.met or 0)
|
||||||
|
resolution_violated = (resolution_row.violated or 0) + (resolution_row.violated_pending or 0)
|
||||||
|
resolution_at_risk = resolution_row.at_risk or 0
|
||||||
|
resolution_avg = float(resolution_row.avg_hours) if resolution_row.avg_hours else 0.0
|
||||||
|
resolution_compliance = (resolution_met / resolution_total * 100) if resolution_total > 0 else 0.0
|
||||||
|
|
||||||
|
# Métricas por categoría (top 5)
|
||||||
|
category_query = select(
|
||||||
|
Category.id,
|
||||||
|
Category.name,
|
||||||
|
func.count(Ticket.id).label('ticket_count'),
|
||||||
|
func.avg(case((Ticket.first_response_at <= Ticket.sla_response_due, 100.0), else_=0.0)).label('response_compliance'),
|
||||||
|
func.avg(case((Ticket.resolved_at <= Ticket.sla_resolution_due, 100.0), else_=0.0)).label('resolution_compliance')
|
||||||
|
).select_from(Ticket).join(
|
||||||
|
Category, Ticket.category_id == Category.id, isouter=True
|
||||||
|
).where(
|
||||||
|
and_(
|
||||||
|
Ticket.tenant_id == current_tenant.id,
|
||||||
|
Ticket.created_at >= period_start
|
||||||
|
)
|
||||||
|
).group_by(Category.id, Category.name).order_by(desc('ticket_count')).limit(5)
|
||||||
|
|
||||||
|
category_result = await db.execute(category_query)
|
||||||
|
by_category = [
|
||||||
|
{
|
||||||
|
"category_id": str(row.id) if row.id else None,
|
||||||
|
"category_name": row.name or "Sin categoría",
|
||||||
|
"ticket_count": row.ticket_count,
|
||||||
|
"response_compliance": float(row.response_compliance or 0.0),
|
||||||
|
"resolution_compliance": float(row.resolution_compliance or 0.0)
|
||||||
|
}
|
||||||
|
for row in category_result.all()
|
||||||
|
]
|
||||||
|
|
||||||
|
# Métricas por prioridad
|
||||||
|
by_priority = {}
|
||||||
|
for priority in TicketPriority:
|
||||||
|
priority_query = select(
|
||||||
|
func.avg(case((Ticket.first_response_at <= Ticket.sla_response_due, 100.0), else_=0.0)).label('response'),
|
||||||
|
func.avg(case((Ticket.resolved_at <= Ticket.sla_resolution_due, 100.0), else_=0.0)).label('resolution')
|
||||||
|
).where(
|
||||||
|
and_(
|
||||||
|
Ticket.tenant_id == current_tenant.id,
|
||||||
|
Ticket.created_at >= period_start,
|
||||||
|
Ticket.priority == priority
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
priority_result = await db.execute(priority_query)
|
||||||
|
priority_row = priority_result.one()
|
||||||
|
|
||||||
|
by_priority[priority.value] = {
|
||||||
|
"response_compliance": float(priority_row.response or 0.0),
|
||||||
|
"resolution_compliance": float(priority_row.resolution or 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Calcular tendencias (comparación con período anterior)
|
||||||
|
prev_period_start = period_start - timedelta(days=days)
|
||||||
|
prev_response_query = select(
|
||||||
|
func.count().label('total'),
|
||||||
|
func.sum(case((Ticket.first_response_at <= Ticket.sla_response_due, 1), else_=0)).label('met')
|
||||||
|
).where(
|
||||||
|
and_(
|
||||||
|
Ticket.tenant_id == current_tenant.id,
|
||||||
|
Ticket.created_at >= prev_period_start,
|
||||||
|
Ticket.created_at < period_start,
|
||||||
|
Ticket.sla_response_due != None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
prev_response_result = await db.execute(prev_response_query)
|
||||||
|
prev_response_row = prev_response_result.one()
|
||||||
|
prev_response_compliance = ((prev_response_row.met or 0) / (prev_response_row.total or 1) * 100) if (prev_response_row.total or 0) > 0 else 0.0
|
||||||
|
|
||||||
|
response_trend = response_compliance - prev_response_compliance
|
||||||
|
response_trend_str = f"+{response_trend:.1f}%" if response_trend >= 0 else f"{response_trend:.1f}%"
|
||||||
|
|
||||||
|
prev_resolution_query = select(
|
||||||
|
func.count().label('total'),
|
||||||
|
func.sum(case((Ticket.resolved_at <= Ticket.sla_resolution_due, 1), else_=0)).label('met')
|
||||||
|
).where(
|
||||||
|
and_(
|
||||||
|
Ticket.tenant_id == current_tenant.id,
|
||||||
|
Ticket.created_at >= prev_period_start,
|
||||||
|
Ticket.created_at < period_start,
|
||||||
|
Ticket.sla_resolution_due != None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
prev_resolution_result = await db.execute(prev_resolution_query)
|
||||||
|
prev_resolution_row = prev_resolution_result.one()
|
||||||
|
prev_resolution_compliance = ((prev_resolution_row.met or 0) / (prev_resolution_row.total or 1) * 100) if (prev_resolution_row.total or 0) > 0 else 0.0
|
||||||
|
|
||||||
|
resolution_trend = resolution_compliance - prev_resolution_compliance
|
||||||
|
resolution_trend_str = f"+{resolution_trend:.1f}%" if resolution_trend >= 0 else f"{resolution_trend:.1f}%"
|
||||||
|
|
||||||
|
# Contar violaciones activas
|
||||||
|
active_violations_query = select(func.count()).select_from(Ticket).where(
|
||||||
|
and_(
|
||||||
|
Ticket.tenant_id == current_tenant.id,
|
||||||
|
Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED]),
|
||||||
|
or_(
|
||||||
|
and_(Ticket.first_response_at == None, Ticket.sla_response_due != None, db_now > Ticket.sla_response_due),
|
||||||
|
and_(Ticket.resolved_at == None, Ticket.sla_resolution_due != None, db_now > Ticket.sla_resolution_due)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
active_violations_result = await db.execute(active_violations_query)
|
||||||
|
active_violations = active_violations_result.scalar() or 0
|
||||||
|
|
||||||
|
# Contar total de tickets del período
|
||||||
|
total_tickets_query = select(func.count()).select_from(Ticket).where(
|
||||||
|
and_(
|
||||||
|
Ticket.tenant_id == current_tenant.id,
|
||||||
|
Ticket.created_at >= period_start
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
total_tickets_result = await db.execute(total_tickets_query)
|
||||||
|
total_tickets_period = total_tickets_result.scalar() or 0
|
||||||
|
|
||||||
|
return SLADashboardResponse(
|
||||||
|
tenant_id=current_tenant.id,
|
||||||
|
period_start=period_start,
|
||||||
|
period_end=now,
|
||||||
|
generated_at=now,
|
||||||
|
response_sla=SLAComplianceMetrics(
|
||||||
|
target_hours=2, # Promedio, podría calcularse
|
||||||
|
met_count=response_met,
|
||||||
|
violated_count=response_violated,
|
||||||
|
at_risk_count=response_at_risk,
|
||||||
|
total_count=response_total,
|
||||||
|
compliance_percentage=response_compliance,
|
||||||
|
avg_time_hours=response_avg
|
||||||
|
),
|
||||||
|
resolution_sla=SLAComplianceMetrics(
|
||||||
|
target_hours=24, # Promedio, podría calcularse
|
||||||
|
met_count=resolution_met,
|
||||||
|
violated_count=resolution_violated,
|
||||||
|
at_risk_count=resolution_at_risk,
|
||||||
|
total_count=resolution_total,
|
||||||
|
compliance_percentage=resolution_compliance,
|
||||||
|
avg_time_hours=resolution_avg
|
||||||
|
),
|
||||||
|
active_violations=active_violations,
|
||||||
|
at_risk_tickets=response_at_risk + resolution_at_risk,
|
||||||
|
total_tickets_period=total_tickets_period,
|
||||||
|
by_category=by_category,
|
||||||
|
by_priority=by_priority,
|
||||||
|
trends={
|
||||||
|
"response_sla": response_trend_str,
|
||||||
|
"resolution_sla": resolution_trend_str
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# VIOLACIONES
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
@router.get("/violations", response_model=SLAViolationsListResponse)
|
||||||
|
async def get_sla_violations(
|
||||||
|
skip: int = Query(default=0, ge=0),
|
||||||
|
limit: int = Query(default=50, ge=1, le=100),
|
||||||
|
sla_type: Optional[str] = Query(default=None, regex="^(response|resolution)$"),
|
||||||
|
category_id: Optional[uuid.UUID] = None,
|
||||||
|
priority: Optional[str] = None,
|
||||||
|
current_user: User = Depends(require_staff_role),
|
||||||
|
current_tenant: Tenant = Depends(get_current_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Listar violaciones SLA activas.
|
||||||
|
|
||||||
|
**Permisos**: ADMIN, SUPPORT_MANAGER, AGENT (solo sus tickets), AUDITOR
|
||||||
|
|
||||||
|
Retorna tickets que han violado sus SLAs de respuesta o resolución.
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
"SLA violations requested",
|
||||||
|
user_id=str(current_user.id),
|
||||||
|
tenant_id=str(current_tenant.id),
|
||||||
|
sla_type=sla_type
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
db_now = func.now()
|
||||||
|
|
||||||
|
# Base query con carga de relaciones
|
||||||
|
query = select(Ticket).options(
|
||||||
|
selectinload(Ticket.created_by_user),
|
||||||
|
selectinload(Ticket.assigned_to_user),
|
||||||
|
selectinload(Ticket.category)
|
||||||
|
).where(
|
||||||
|
and_(
|
||||||
|
Ticket.tenant_id == current_tenant.id,
|
||||||
|
Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filtrar por tipo de SLA
|
||||||
|
if sla_type == "response":
|
||||||
|
query = query.where(
|
||||||
|
and_(
|
||||||
|
Ticket.first_response_at == None,
|
||||||
|
Ticket.sla_response_due != None,
|
||||||
|
db_now > Ticket.sla_response_due
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif sla_type == "resolution":
|
||||||
|
query = query.where(
|
||||||
|
and_(
|
||||||
|
Ticket.sla_resolution_due != None,
|
||||||
|
db_now > Ticket.sla_resolution_due
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Ambos tipos
|
||||||
|
query = query.where(
|
||||||
|
or_(
|
||||||
|
and_(Ticket.first_response_at == None, Ticket.sla_response_due != None, db_now > Ticket.sla_response_due),
|
||||||
|
and_(Ticket.sla_resolution_due != None, db_now > Ticket.sla_resolution_due)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filtros adicionales
|
||||||
|
if category_id:
|
||||||
|
query = query.where(Ticket.category_id == category_id)
|
||||||
|
|
||||||
|
if priority:
|
||||||
|
try:
|
||||||
|
priority_enum = TicketPriority(priority.upper())
|
||||||
|
query = query.where(Ticket.priority == priority_enum)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# AGENTS solo ven sus tickets
|
||||||
|
if current_user.role == UserRole.AGENT:
|
||||||
|
query = query.where(Ticket.assigned_to == current_user.id)
|
||||||
|
|
||||||
|
# Contar total
|
||||||
|
count_query = select(func.count()).select_from(query.subquery())
|
||||||
|
total_result = await db.execute(count_query)
|
||||||
|
total = total_result.scalar() or 0
|
||||||
|
|
||||||
|
# Obtener todos los tickets sin paginación primero (los ordenaremos por tiempo vencido después)
|
||||||
|
result = await db.execute(query)
|
||||||
|
tickets = result.scalars().all()
|
||||||
|
|
||||||
|
# Formatear response
|
||||||
|
violations = []
|
||||||
|
for ticket in tickets:
|
||||||
|
# Todos los campos son timezone-naive (TIMESTAMP WITHOUT TIME ZONE)
|
||||||
|
sla_response_due = ticket.sla_response_due
|
||||||
|
sla_resolution_due = ticket.sla_resolution_due
|
||||||
|
|
||||||
|
# Determinar tipo de violación
|
||||||
|
response_violated = ticket.first_response_at is None and sla_response_due and now > sla_response_due
|
||||||
|
resolution_violated = sla_resolution_due and now > sla_resolution_due
|
||||||
|
|
||||||
|
# Priorizar resolution si ambos están violados
|
||||||
|
if resolution_violated:
|
||||||
|
violation_type = SLATypeEnum.RESOLUTION
|
||||||
|
due_at = sla_resolution_due
|
||||||
|
else:
|
||||||
|
violation_type = SLATypeEnum.RESPONSE
|
||||||
|
due_at = sla_response_due
|
||||||
|
|
||||||
|
hours_overdue = (now - due_at).total_seconds() / 3600 if due_at else 0
|
||||||
|
|
||||||
|
# Las relaciones ya están cargadas por selectinload
|
||||||
|
violations.append(SLAViolationResponse(
|
||||||
|
ticket=TicketBasicInfo(
|
||||||
|
id=ticket.id,
|
||||||
|
ticket_number=ticket.ticket_number,
|
||||||
|
subject=ticket.subject,
|
||||||
|
priority=ticket.priority.value,
|
||||||
|
status=ticket.status.value
|
||||||
|
),
|
||||||
|
category=CategoryBasicInfo(
|
||||||
|
id=ticket.category.id,
|
||||||
|
name=ticket.category.name,
|
||||||
|
sla_response_hours=ticket.category.sla_response_hours,
|
||||||
|
sla_resolution_hours=ticket.category.sla_resolution_hours
|
||||||
|
) if ticket.category else None,
|
||||||
|
created_by=UserBasicInfo(
|
||||||
|
id=ticket.created_by_user.id,
|
||||||
|
first_name=ticket.created_by_user.first_name,
|
||||||
|
last_name=ticket.created_by_user.last_name,
|
||||||
|
email=ticket.created_by_user.email
|
||||||
|
),
|
||||||
|
assigned_to=UserBasicInfo(
|
||||||
|
id=ticket.assigned_to_user.id,
|
||||||
|
first_name=ticket.assigned_to_user.first_name,
|
||||||
|
last_name=ticket.assigned_to_user.last_name,
|
||||||
|
email=ticket.assigned_to_user.email
|
||||||
|
) if ticket.assigned_to_user else None,
|
||||||
|
sla_type=violation_type,
|
||||||
|
sla_due_at=due_at,
|
||||||
|
violated_at=due_at, # Se violó en el momento del due
|
||||||
|
hours_overdue=hours_overdue,
|
||||||
|
first_response_at=ticket.first_response_at,
|
||||||
|
resolved_at=ticket.resolved_at
|
||||||
|
))
|
||||||
|
|
||||||
|
# Ordenar por tiempo vencido (de mayor a menor)
|
||||||
|
violations.sort(key=lambda v: v.hours_overdue, reverse=True)
|
||||||
|
|
||||||
|
# Aplicar paginación en Python
|
||||||
|
paginated_violations = violations[skip:skip + limit]
|
||||||
|
|
||||||
|
total_pages = (total + limit - 1) // limit
|
||||||
|
|
||||||
|
return SLAViolationsListResponse(
|
||||||
|
violations=paginated_violations,
|
||||||
|
total=total,
|
||||||
|
page=(skip // limit) + 1,
|
||||||
|
per_page=limit,
|
||||||
|
total_pages=total_pages
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# TICKETS EN RIESGO
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
@router.get("/at-risk", response_model=SLAAtRiskListResponse)
|
||||||
|
async def get_tickets_at_risk(
|
||||||
|
threshold: int = Query(default=80, ge=50, le=95, description="% de tiempo consumido para considerar en riesgo"),
|
||||||
|
current_user: User = Depends(require_staff_role),
|
||||||
|
current_tenant: Tenant = Depends(get_current_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Listar tickets que están en riesgo de violar SLA.
|
||||||
|
|
||||||
|
**Permisos**: ADMIN, SUPPORT_MANAGER, AGENT (solo sus tickets), AUDITOR
|
||||||
|
|
||||||
|
Retorna tickets que están cerca de vencer su SLA (por defecto, 80% del tiempo consumido).
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
"SLA at-risk tickets requested",
|
||||||
|
user_id=str(current_user.id),
|
||||||
|
tenant_id=str(current_tenant.id),
|
||||||
|
threshold=threshold
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
db_now = func.now()
|
||||||
|
threshold_decimal = threshold / 100.0
|
||||||
|
|
||||||
|
# Query para tickets en riesgo con relaciones precargadas
|
||||||
|
# Un ticket está en riesgo si: (now - created_at) / (due_at - created_at) >= threshold
|
||||||
|
query = select(Ticket).options(
|
||||||
|
selectinload(Ticket.assigned_to_user),
|
||||||
|
selectinload(Ticket.category)
|
||||||
|
).where(
|
||||||
|
and_(
|
||||||
|
Ticket.tenant_id == current_tenant.id,
|
||||||
|
Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED]),
|
||||||
|
or_(
|
||||||
|
# Response SLA en riesgo
|
||||||
|
and_(
|
||||||
|
Ticket.first_response_at == None,
|
||||||
|
Ticket.sla_response_due != None,
|
||||||
|
db_now < Ticket.sla_response_due,
|
||||||
|
# Calcular si está en zona de riesgo
|
||||||
|
func.extract('epoch', db_now - Ticket.created_at) >= (func.extract('epoch', Ticket.sla_response_due - Ticket.created_at) * threshold_decimal)
|
||||||
|
),
|
||||||
|
# Resolution SLA en riesgo
|
||||||
|
and_(
|
||||||
|
Ticket.sla_resolution_due != None,
|
||||||
|
db_now < Ticket.sla_resolution_due,
|
||||||
|
func.extract('epoch', db_now - Ticket.created_at) >= (func.extract('epoch', Ticket.sla_resolution_due - Ticket.created_at) * threshold_decimal)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).order_by(desc(Ticket.sla_response_due if Ticket.sla_response_due else Ticket.sla_resolution_due))
|
||||||
|
|
||||||
|
# AGENTS solo ven sus tickets
|
||||||
|
if current_user.role == UserRole.AGENT:
|
||||||
|
query = query.where(Ticket.assigned_to == current_user.id)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
tickets = result.scalars().all()
|
||||||
|
|
||||||
|
# Formatear response
|
||||||
|
at_risk_tickets = []
|
||||||
|
for ticket in tickets:
|
||||||
|
# Determinar cuál SLA está en riesgo
|
||||||
|
response_at_risk = (
|
||||||
|
ticket.first_response_at is None and
|
||||||
|
ticket.sla_response_due and
|
||||||
|
now < ticket.sla_response_due
|
||||||
|
)
|
||||||
|
|
||||||
|
resolution_at_risk = (
|
||||||
|
ticket.sla_resolution_due and
|
||||||
|
now < ticket.sla_resolution_due
|
||||||
|
)
|
||||||
|
|
||||||
|
# Priorizar response si ambos están en riesgo
|
||||||
|
if response_at_risk:
|
||||||
|
sla_type = SLATypeEnum.RESPONSE
|
||||||
|
due_at = ticket.sla_response_due
|
||||||
|
elif resolution_at_risk:
|
||||||
|
sla_type = SLATypeEnum.RESOLUTION
|
||||||
|
due_at = ticket.sla_resolution_due
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Normalizar created_at a timezone-naive para evitar errores de comparación
|
||||||
|
created_at = ticket.created_at.replace(tzinfo=None) if ticket.created_at.tzinfo else ticket.created_at
|
||||||
|
|
||||||
|
time_remaining = (due_at - now).total_seconds() / 3600
|
||||||
|
total_time = (due_at - created_at).total_seconds() / 3600
|
||||||
|
elapsed_time = total_time - time_remaining
|
||||||
|
risk_percentage = (elapsed_time / total_time * 100) if total_time > 0 else 0
|
||||||
|
|
||||||
|
# Las relaciones ya están cargadas por selectinload
|
||||||
|
at_risk_tickets.append(SLATicketAtRisk(
|
||||||
|
ticket=TicketBasicInfo(
|
||||||
|
id=ticket.id,
|
||||||
|
ticket_number=ticket.ticket_number,
|
||||||
|
subject=ticket.subject,
|
||||||
|
priority=ticket.priority.value,
|
||||||
|
status=ticket.status.value
|
||||||
|
),
|
||||||
|
category=CategoryBasicInfo(
|
||||||
|
id=ticket.category.id,
|
||||||
|
name=ticket.category.name,
|
||||||
|
sla_response_hours=ticket.category.sla_response_hours,
|
||||||
|
sla_resolution_hours=ticket.category.sla_resolution_hours
|
||||||
|
) if ticket.category else None,
|
||||||
|
assigned_to=UserBasicInfo(
|
||||||
|
id=ticket.assigned_to_user.id,
|
||||||
|
first_name=ticket.assigned_to_user.first_name,
|
||||||
|
last_name=ticket.assigned_to_user.last_name,
|
||||||
|
email=ticket.assigned_to_user.email
|
||||||
|
) if ticket.assigned_to_user else None,
|
||||||
|
sla_type=sla_type,
|
||||||
|
sla_due_at=due_at,
|
||||||
|
time_remaining_hours=time_remaining,
|
||||||
|
risk_percentage=risk_percentage
|
||||||
|
))
|
||||||
|
|
||||||
|
return SLAAtRiskListResponse(
|
||||||
|
tickets=at_risk_tickets,
|
||||||
|
total=len(at_risk_tickets)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# CONFIGURACIÓN
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
@router.get("/config", response_model=SLAConfigListResponse)
|
||||||
|
async def get_sla_config(
|
||||||
|
current_user: User = Depends(require_manager_role),
|
||||||
|
current_tenant: Tenant = Depends(get_current_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Obtener configuración de SLAs por categoría.
|
||||||
|
|
||||||
|
**Permisos**: ADMIN, SUPPORT_MANAGER
|
||||||
|
|
||||||
|
Retorna la configuración de tiempos SLA para todas las categorías del tenant.
|
||||||
|
"""
|
||||||
|
query = select(Category).where(
|
||||||
|
Category.tenant_id == current_tenant.id
|
||||||
|
).order_by(Category.name)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
categories = result.scalars().all()
|
||||||
|
|
||||||
|
return SLAConfigListResponse(
|
||||||
|
tenant_id=current_tenant.id,
|
||||||
|
categories=[
|
||||||
|
SLAConfigByCategoryResponse(
|
||||||
|
category_id=cat.id,
|
||||||
|
category_name=cat.name,
|
||||||
|
sla_response_hours=cat.sla_response_hours,
|
||||||
|
sla_resolution_hours=cat.sla_resolution_hours,
|
||||||
|
warning_threshold_percentage=80, # Por ahora hardcoded
|
||||||
|
is_active=cat.is_active
|
||||||
|
)
|
||||||
|
for cat in categories
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -1,53 +1,154 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from pydantic import BaseModel, ConfigDict
|
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.models.system import System
|
from app.models.system import System
|
||||||
from app.api import deps
|
from app.models.user import User
|
||||||
|
from app.api import deps
|
||||||
|
from app.api.schemas.system import SystemCreate, SystemUpdate, SystemResponse
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
class SystemBase(BaseModel):
|
|
||||||
name: str
|
|
||||||
description: Optional[str] = None
|
|
||||||
is_active: bool = True
|
|
||||||
|
|
||||||
class SystemCreate(SystemBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class SystemUpdate(SystemBase):
|
# ===================================
|
||||||
name: Optional[str] = None
|
# ENDPOINTS
|
||||||
description: Optional[str] = None
|
# ===================================
|
||||||
is_active: Optional[bool] = None
|
|
||||||
|
|
||||||
class SystemResponse(SystemBase):
|
|
||||||
id: uuid.UUID
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
@router.get("/", response_model=List[SystemResponse])
|
@router.get("/", response_model=List[SystemResponse])
|
||||||
async def read_systems(
|
async def read_systems(
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user = Depends(deps.get_current_active_superuser)
|
current_user: User = Depends(deps.get_current_user) # ✅ CORREGIDO: Type hint + no solo superuser
|
||||||
):
|
):
|
||||||
query = select(System).offset(skip).limit(limit)
|
"""
|
||||||
|
Listar sistemas del tenant del usuario actual.
|
||||||
|
|
||||||
|
✅ Implementa multi-tenancy: solo muestra sistemas del tenant del usuario.
|
||||||
|
"""
|
||||||
|
# ✅ CORREGIDO: Filtrar por tenant_id
|
||||||
|
query = select(System).where(
|
||||||
|
System.tenant_id == current_user.tenant_id
|
||||||
|
).offset(skip).limit(limit)
|
||||||
|
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
@router.post("/", response_model=SystemResponse)
|
|
||||||
|
@router.post("/", response_model=SystemResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_system(
|
async def create_system(
|
||||||
system: SystemCreate,
|
system: SystemCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user = Depends(deps.get_current_active_superuser)
|
current_user: User = Depends(deps.get_current_user) # ✅ CORREGIDO: Type hint
|
||||||
):
|
):
|
||||||
db_system = System(**system.model_dump())
|
"""
|
||||||
|
Crear nuevo sistema en el tenant del usuario actual.
|
||||||
|
|
||||||
|
✅ Implementa multi-tenancy: asigna automáticamente tenant_id del usuario.
|
||||||
|
"""
|
||||||
|
# ✅ CORREGIDO: Asignar tenant_id del usuario actual
|
||||||
|
db_system = System(
|
||||||
|
**system.model_dump(),
|
||||||
|
tenant_id=current_user.tenant_id # ✅ Multi-tenancy automático
|
||||||
|
)
|
||||||
|
|
||||||
db.add(db_system)
|
db.add(db_system)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(db_system)
|
await db.refresh(db_system)
|
||||||
return db_system
|
return db_system
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{system_id}", response_model=SystemResponse)
|
||||||
|
async def read_system(
|
||||||
|
system_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(deps.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Obtener un sistema específico del tenant.
|
||||||
|
|
||||||
|
✅ Implementa multi-tenancy: solo permite acceso a sistemas del propio tenant.
|
||||||
|
"""
|
||||||
|
query = select(System).where(
|
||||||
|
System.id == system_id,
|
||||||
|
System.tenant_id == current_user.tenant_id # ✅ Seguridad multi-tenant
|
||||||
|
)
|
||||||
|
result = await db.execute(query)
|
||||||
|
system = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not system:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="System not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
return system
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{system_id}", response_model=SystemResponse)
|
||||||
|
async def update_system(
|
||||||
|
system_id: uuid.UUID,
|
||||||
|
system_update: SystemUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(deps.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Actualizar sistema del tenant.
|
||||||
|
|
||||||
|
✅ Implementa multi-tenancy: solo permite actualizar sistemas del propio tenant.
|
||||||
|
"""
|
||||||
|
query = select(System).where(
|
||||||
|
System.id == system_id,
|
||||||
|
System.tenant_id == current_user.tenant_id
|
||||||
|
)
|
||||||
|
result = await db.execute(query)
|
||||||
|
db_system = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not db_system:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="System not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Actualizar campos
|
||||||
|
update_data = system_update.model_dump(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(db_system, field, value)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(db_system)
|
||||||
|
return db_system
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{system_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_system(
|
||||||
|
system_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(deps.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Desactivar sistema del tenant (soft delete).
|
||||||
|
|
||||||
|
✅ Implementa multi-tenancy: solo permite desactivar sistemas del propio tenant.
|
||||||
|
"""
|
||||||
|
query = select(System).where(
|
||||||
|
System.id == system_id,
|
||||||
|
System.tenant_id == current_user.tenant_id
|
||||||
|
)
|
||||||
|
result = await db.execute(query)
|
||||||
|
db_system = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not db_system:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="System not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Soft delete
|
||||||
|
db_system.is_active = False
|
||||||
|
await db.commit()
|
||||||
|
return None
|
||||||
@@ -1,38 +1,16 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from pydantic import BaseModel, ConfigDict, EmailStr
|
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.models.tenant import Tenant, TenantStatus
|
from app.models.tenant import Tenant, TenantStatus
|
||||||
from app.api import deps
|
from app.api import deps
|
||||||
|
from app.api.schemas.tenant import TenantBase, TenantCreate, TenantUpdate, TenantResponse
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
class TenantBase(BaseModel):
|
|
||||||
name: str
|
|
||||||
slug: str
|
|
||||||
domain: Optional[str] = None
|
|
||||||
contact_email: Optional[EmailStr] = None
|
|
||||||
|
|
||||||
class TenantCreate(TenantBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class TenantUpdate(BaseModel):
|
|
||||||
name: Optional[str] = None
|
|
||||||
slug: Optional[str] = None
|
|
||||||
domain: Optional[str] = None
|
|
||||||
contact_email: Optional[EmailStr] = None
|
|
||||||
status: Optional[TenantStatus] = None
|
|
||||||
|
|
||||||
class TenantResponse(TenantBase):
|
|
||||||
id: uuid.UUID
|
|
||||||
status: TenantStatus
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
@router.get("/", response_model=List[TenantResponse])
|
@router.get("/", response_model=List[TenantResponse])
|
||||||
async def read_tenants(
|
async def read_tenants(
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
@@ -85,10 +63,32 @@ async def update_tenant(
|
|||||||
raise HTTPException(status_code=404, detail="Tenant not found")
|
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||||
|
|
||||||
update_data = tenant_in.model_dump(exclude_unset=True)
|
update_data = tenant_in.model_dump(exclude_unset=True)
|
||||||
|
if "status" in update_data:
|
||||||
|
# Convertir string a enum TenantStatus
|
||||||
|
status_value = update_data.pop("status")
|
||||||
|
if isinstance(status_value, str):
|
||||||
|
tenant.status = TenantStatus(status_value)
|
||||||
|
else:
|
||||||
|
tenant.status = status_value
|
||||||
|
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(tenant, field, value)
|
setattr(tenant, field, value)
|
||||||
|
|
||||||
db.add(tenant)
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(tenant)
|
await db.refresh(tenant)
|
||||||
return tenant
|
return tenant
|
||||||
|
|
||||||
|
@router.delete("/{tenant_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_tenant(
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user = Depends(deps.get_current_active_superuser)
|
||||||
|
):
|
||||||
|
"""Eliminar un cliente (tenant) por ID."""
|
||||||
|
tenant = await db.get(Tenant, tenant_id)
|
||||||
|
if not tenant:
|
||||||
|
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||||
|
|
||||||
|
await db.delete(tenant)
|
||||||
|
await db.commit()
|
||||||
|
return {"message": "Tenant deleted successfully"}
|
||||||
|
|||||||
413
backend/app/api/v1/endpoints/tickets.py
Normal file
413
backend/app/api/v1/endpoints/tickets.py
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
"""Tickets endpoints - ServiceManagerWeb"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.api.deps import get_current_user, get_current_tenant
|
||||||
|
from app.models.ticket import Ticket, TicketStatus, TicketPriority
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.models.tenant import Tenant
|
||||||
|
from app.models.category import Category
|
||||||
|
from app.models.system import System
|
||||||
|
from app.models.comment import TicketComment
|
||||||
|
from app.models.attachment import TicketAttachment
|
||||||
|
from app.api.schemas.attachment import AttachmentResponse
|
||||||
|
from app.api.schemas.ticket import (
|
||||||
|
TicketCreate, TicketUpdate, TicketResponse,
|
||||||
|
TicketCloseRequest, CommentCreate, CommentResponse
|
||||||
|
)
|
||||||
|
from app.core.file_handler import file_handler
|
||||||
|
from app.api.v1.helpers import (
|
||||||
|
validate_uuid_param, apply_client_permissions, apply_enum_filter,
|
||||||
|
safe_audit_log, generate_next_ticket_number, calculate_sla_deadlines, ticket_to_dict
|
||||||
|
)
|
||||||
|
from app.services.audit_service import AuditService
|
||||||
|
from app.services.ticket_service import TicketService, get_ticket_service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.post("/", response_model=TicketResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_ticket(
|
||||||
|
ticket: TicketCreate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
ticket_service: TicketService = Depends(get_ticket_service),
|
||||||
|
):
|
||||||
|
"""Crear un nuevo ticket"""
|
||||||
|
return await ticket_service.create_ticket(ticket, current_user.tenant_id, current_user.id)
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[TicketResponse])
|
||||||
|
async def get_tickets(skip: int = 0, limit: int = 100, status: Optional[str] = None, priority: Optional[str] = None,
|
||||||
|
db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
|
"""Obtener tickets con filtros opcionales"""
|
||||||
|
query = select(Ticket).where(Ticket.tenant_id == current_user.tenant_id)
|
||||||
|
# Solo CLIENT_USER ve únicamente sus propios tickets.
|
||||||
|
# CLIENT_ADMIN ve todos los del tenant.
|
||||||
|
if current_user.role == UserRole.CLIENT_USER:
|
||||||
|
query = query.where(Ticket.created_by == current_user.id)
|
||||||
|
|
||||||
|
query = apply_enum_filter(query, Ticket.status, status, TicketStatus, "status")
|
||||||
|
query = apply_enum_filter(query, Ticket.priority, priority, TicketPriority, "priority")
|
||||||
|
query = query.options(
|
||||||
|
selectinload(Ticket.category),
|
||||||
|
selectinload(Ticket.affected_system),
|
||||||
|
selectinload(Ticket.assigned_to_user)
|
||||||
|
)
|
||||||
|
query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
tickets = result.scalars().all()
|
||||||
|
|
||||||
|
return [ticket_to_dict(t) for t in tickets]
|
||||||
|
|
||||||
|
@router.get("/admin/all", response_model=List[dict])
|
||||||
|
async def get_all_tickets_admin(skip: int = 0, limit: int = 100, status_filter: Optional[str] = None,
|
||||||
|
priority_filter: Optional[str] = None, tenant_id_filter: Optional[str] = None, category_filter: Optional[str] = None,
|
||||||
|
assigned_to_filter: Optional[str] = None, search: Optional[str] = None, date_from: Optional[str] = None,
|
||||||
|
date_to: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
|
"""Obtener todos los tickets del tenant del administrador (ADMIN/SUPPORT_MANAGER)."""
|
||||||
|
if current_user.role not in (UserRole.ADMIN, UserRole.SUPPORT_MANAGER):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No tienes permisos para acceder a esta función")
|
||||||
|
|
||||||
|
query = select(Ticket, Tenant, User).join(Tenant, Ticket.tenant_id == Tenant.id).join(User, Ticket.created_by == User.id)
|
||||||
|
|
||||||
|
# SUPPORT_MANAGER solo ve su propio tenant.
|
||||||
|
# ADMIN ve todos los tenants (es el administrador de la plataforma).
|
||||||
|
if current_user.role == UserRole.SUPPORT_MANAGER:
|
||||||
|
query = query.where(Ticket.tenant_id == current_user.tenant_id)
|
||||||
|
|
||||||
|
query = apply_enum_filter(query, Ticket.status, status_filter, TicketStatus, "status")
|
||||||
|
query = apply_enum_filter(query, Ticket.priority, priority_filter, TicketPriority, "priority")
|
||||||
|
if tenant_id_filter:
|
||||||
|
query = query.where(Ticket.tenant_id == validate_uuid_param(tenant_id_filter, "tenant ID"))
|
||||||
|
if category_filter:
|
||||||
|
query = query.where(Ticket.category_id == validate_uuid_param(category_filter, "category ID"))
|
||||||
|
if assigned_to_filter:
|
||||||
|
query = query.where(Ticket.assigned_to == validate_uuid_param(assigned_to_filter, "assigned user ID"))
|
||||||
|
if search:
|
||||||
|
search_pattern = f"%{search}%"
|
||||||
|
query = query.where((Ticket.subject.ilike(search_pattern)) | (Ticket.description.ilike(search_pattern)))
|
||||||
|
if date_from:
|
||||||
|
try:
|
||||||
|
date_from_parsed = datetime.fromisoformat(date_from)
|
||||||
|
query = query.where(Ticket.created_at >= date_from_parsed)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid date_from format. Use YYYY-MM-DD")
|
||||||
|
if date_to:
|
||||||
|
try:
|
||||||
|
date_to_parsed = datetime.fromisoformat(date_to) + timedelta(days=1)
|
||||||
|
query = query.where(Ticket.created_at < date_to_parsed)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid date_to format. Use YYYY-MM-DD")
|
||||||
|
|
||||||
|
query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit)
|
||||||
|
result = await db.execute(query)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
# Cargar categorías en un solo query para evitar N+1
|
||||||
|
category_ids = list({ticket.category_id for ticket, _, _ in rows if ticket.category_id})
|
||||||
|
categories_map = {}
|
||||||
|
if category_ids:
|
||||||
|
from app.models.category import Category as CategoryModel
|
||||||
|
cat_result = await db.execute(select(CategoryModel).where(CategoryModel.id.in_(category_ids)))
|
||||||
|
categories_map = {c.id: c.name for c in cat_result.scalars().all()}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{"id": str(ticket.id), "ticket_number": ticket.ticket_number, "subject": ticket.subject,
|
||||||
|
"description": ticket.description, "status": ticket.status.value, "priority": ticket.priority.value,
|
||||||
|
"tenant_id": str(ticket.tenant_id), "tenant_name": tenant.name, "tenant_slug": tenant.slug,
|
||||||
|
"category_id": str(ticket.category_id) if ticket.category_id else None,
|
||||||
|
"category_name": categories_map.get(ticket.category_id) if ticket.category_id else None,
|
||||||
|
"created_by": str(ticket.created_by), "creator_name": f"{creator.first_name} {creator.last_name}",
|
||||||
|
"creator_email": creator.email, "assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None,
|
||||||
|
"created_at": ticket.created_at, "updated_at": ticket.updated_at, "sla_response_due": ticket.sla_response_due,
|
||||||
|
"sla_resolution_due": ticket.sla_resolution_due, "first_response_at": ticket.first_response_at,
|
||||||
|
"resolved_at": ticket.resolved_at}
|
||||||
|
for ticket, tenant, creator in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
@router.get("/{ticket_id}", response_model=TicketResponse)
|
||||||
|
async def get_ticket(ticket_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
|
"""Obtener un ticket por ID"""
|
||||||
|
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||||
|
query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id)
|
||||||
|
if current_user.role.is_client:
|
||||||
|
query = query.where(Ticket.created_by == current_user.id)
|
||||||
|
|
||||||
|
query = query.options(selectinload(Ticket.category), selectinload(Ticket.affected_system), selectinload(Ticket.assigned_to_user))
|
||||||
|
result = await db.execute(query)
|
||||||
|
db_ticket = result.scalars().first()
|
||||||
|
|
||||||
|
if not db_ticket:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
|
||||||
|
|
||||||
|
return ticket_to_dict(db_ticket)
|
||||||
|
|
||||||
|
@router.patch("/{ticket_id}", response_model=TicketResponse)
|
||||||
|
async def update_ticket(ticket_id: str, ticket: TicketUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
|
"""Actualizar un ticket"""
|
||||||
|
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||||
|
query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id)
|
||||||
|
if current_user.role.is_client:
|
||||||
|
query = query.where(Ticket.created_by == current_user.id)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
db_ticket = result.scalars().first()
|
||||||
|
if not db_ticket:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
|
||||||
|
|
||||||
|
old_values = {"status": db_ticket.status.value, "priority": db_ticket.priority.value, "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None}
|
||||||
|
|
||||||
|
update_data = ticket.dict(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
if field == "status" and value:
|
||||||
|
setattr(db_ticket, field, TicketStatus[value.upper()])
|
||||||
|
elif field == "priority" and value:
|
||||||
|
setattr(db_ticket, field, TicketPriority[value.upper()])
|
||||||
|
elif field in ["category_id", "affected_system_id", "assigned_to"] and value:
|
||||||
|
setattr(db_ticket, field, uuid.UUID(value))
|
||||||
|
elif value is not None:
|
||||||
|
setattr(db_ticket, field, value)
|
||||||
|
|
||||||
|
db_ticket.updated_at = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(db_ticket, ["category", "affected_system", "assigned_to_user"])
|
||||||
|
|
||||||
|
new_values = {"status": db_ticket.status.value, "priority": db_ticket.priority.value, "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None}
|
||||||
|
await safe_audit_log(db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
|
||||||
|
action="ticket.update", resource_type="ticket", resource_id=db_ticket.id,
|
||||||
|
old_values=old_values, new_values=new_values)
|
||||||
|
|
||||||
|
return ticket_to_dict(db_ticket)
|
||||||
|
|
||||||
|
@router.patch("/{ticket_id}/close", response_model=TicketResponse)
|
||||||
|
async def close_ticket(ticket_id: str, close_request: TicketCloseRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
|
"""Cerrar un ticket"""
|
||||||
|
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||||
|
query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id)
|
||||||
|
result = await db.execute(query)
|
||||||
|
db_ticket = result.scalars().first()
|
||||||
|
|
||||||
|
if not db_ticket:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
|
||||||
|
|
||||||
|
if db_ticket.status in [TicketStatus.CLOSED, TicketStatus.RESOLVED]:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ticket ya está cerrado o resuelto")
|
||||||
|
|
||||||
|
old_status = db_ticket.status.value
|
||||||
|
db_ticket.status = TicketStatus.CLOSED
|
||||||
|
db_ticket.resolved_at = datetime.utcnow()
|
||||||
|
db_ticket.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
if close_request.resolution_notes:
|
||||||
|
comment = TicketComment(
|
||||||
|
id=uuid.uuid4(), ticket_id=ticket_uuid, author_id=current_user.id,
|
||||||
|
content=f"Ticket cerrado: {close_request.resolution_notes}",
|
||||||
|
is_internal=False, created_at=datetime.utcnow(), updated_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(comment)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(db_ticket, ["category", "affected_system", "assigned_to_user"])
|
||||||
|
|
||||||
|
await safe_audit_log(db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
|
||||||
|
action="ticket.close", resource_type="ticket", resource_id=db_ticket.id,
|
||||||
|
old_values={"status": old_status}, new_values={"status": db_ticket.status.value, "resolution_notes": close_request.resolution_notes})
|
||||||
|
|
||||||
|
return ticket_to_dict(db_ticket)
|
||||||
|
|
||||||
|
@router.get("/{ticket_id}/comments", response_model=List[CommentResponse])
|
||||||
|
async def get_ticket_comments(ticket_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
|
"""Obtener comentarios de un ticket"""
|
||||||
|
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||||
|
query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id)
|
||||||
|
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
|
||||||
|
query = query.where(Ticket.created_by == current_user.id)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
ticket_obj = result.scalars().first()
|
||||||
|
if not ticket_obj:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
|
||||||
|
|
||||||
|
comments_query = select(TicketComment).where(TicketComment.ticket_id == ticket_uuid).options(selectinload(TicketComment.author)).order_by(TicketComment.created_at.desc())
|
||||||
|
result = await db.execute(comments_query)
|
||||||
|
comments = result.scalars().all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{"id": str(c.id), "ticket_id": str(c.ticket_id), "author_id": str(c.author_id),
|
||||||
|
"author_name": f"{c.author.first_name} {c.author.last_name}" if c.author else "Unknown",
|
||||||
|
"content": c.content, "is_internal": c.is_internal, "created_at": c.created_at, "updated_at": c.updated_at}
|
||||||
|
for c in comments
|
||||||
|
]
|
||||||
|
|
||||||
|
@router.post("/{ticket_id}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_comment(ticket_id: str, comment: CommentCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
|
"""Crear un comentario en un ticket"""
|
||||||
|
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||||
|
query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id)
|
||||||
|
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
|
||||||
|
query = query.where(Ticket.created_by == current_user.id)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
ticket_obj = result.scalars().first()
|
||||||
|
if not ticket_obj:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
|
||||||
|
|
||||||
|
new_comment = TicketComment(
|
||||||
|
id=uuid.uuid4(), ticket_id=ticket_uuid, author_id=current_user.id,
|
||||||
|
content=comment.content, is_internal=comment.is_internal,
|
||||||
|
created_at=datetime.utcnow(), updated_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(new_comment)
|
||||||
|
|
||||||
|
staff_roles = ["ADMIN", "SUPPORT_MANAGER", "AGENT"]
|
||||||
|
if current_user.role in staff_roles and not comment.is_internal and ticket_obj.first_response_at is None:
|
||||||
|
ticket_obj.first_response_at = datetime.utcnow()
|
||||||
|
|
||||||
|
ticket_obj.updated_at = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(new_comment)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(new_comment.id), "ticket_id": str(new_comment.ticket_id), "author_id": str(new_comment.author_id),
|
||||||
|
"author_name": f"{current_user.first_name} {current_user.last_name}",
|
||||||
|
"content": new_comment.content, "is_internal": new_comment.is_internal,
|
||||||
|
"created_at": new_comment.created_at, "updated_at": new_comment.updated_at
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.delete("/{ticket_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_ticket(ticket_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
|
"""Eliminar un ticket (solo admin/manager)"""
|
||||||
|
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||||
|
query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id)
|
||||||
|
result = await db.execute(query)
|
||||||
|
db_ticket = result.scalars().first()
|
||||||
|
|
||||||
|
if not db_ticket:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
|
||||||
|
|
||||||
|
old_values = {"ticket_number": db_ticket.ticket_number, "subject": db_ticket.subject,
|
||||||
|
"status": db_ticket.status.value, "priority": db_ticket.priority.value}
|
||||||
|
|
||||||
|
await db.delete(db_ticket)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
await safe_audit_log(db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
|
||||||
|
action="ticket.delete", resource_type="ticket", resource_id=ticket_uuid, old_values=old_values)
|
||||||
|
|
||||||
|
return {"message": "Ticket deleted successfully"}
|
||||||
|
|
||||||
|
@router.get("/{ticket_id}/attachments", response_model=List[AttachmentResponse])
|
||||||
|
async def get_ticket_attachments(ticket_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), current_tenant: Tenant = Depends(get_current_tenant)):
|
||||||
|
"""Obtener adjuntos de un ticket"""
|
||||||
|
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||||
|
result = await db.execute(select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_tenant.id))
|
||||||
|
ticket = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not ticket:
|
||||||
|
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
||||||
|
|
||||||
|
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"] and ticket.created_by != current_user.id:
|
||||||
|
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
||||||
|
|
||||||
|
result = await db.execute(select(TicketAttachment).where(TicketAttachment.ticket_id == ticket_uuid).options(selectinload(TicketAttachment.uploaded_by_user)).order_by(TicketAttachment.created_at.desc()))
|
||||||
|
attachments = result.scalars().all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
AttachmentResponse(
|
||||||
|
id=att.id, ticket_id=att.ticket_id, comment_id=att.comment_id, uploaded_by=att.uploaded_by,
|
||||||
|
filename=att.filename, original_filename=att.original_filename, mime_type=att.mime_type,
|
||||||
|
file_size=att.file_size, file_path=att.file_path,
|
||||||
|
uploaded_by_name=f"{att.uploaded_by_user.first_name} {att.uploaded_by_user.last_name}" if att.uploaded_by_user else "Unknown",
|
||||||
|
created_at=att.created_at, download_url=f"/api/v1/tickets/{ticket_id}/attachments/{att.id}/download"
|
||||||
|
) for att in attachments
|
||||||
|
]
|
||||||
|
|
||||||
|
@router.post("/{ticket_id}/attachments", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def upload_attachment(ticket_id: str, file: UploadFile = File(...), db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user), current_tenant: Tenant = Depends(get_current_tenant)):
|
||||||
|
"""Subir un archivo adjunto a un ticket"""
|
||||||
|
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||||
|
result = await db.execute(select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_tenant.id))
|
||||||
|
ticket = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not ticket:
|
||||||
|
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
||||||
|
|
||||||
|
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"] and ticket.created_by != current_user.id:
|
||||||
|
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
||||||
|
|
||||||
|
file_metadata = await file_handler.save_upload(file, current_tenant.id, ticket_uuid)
|
||||||
|
|
||||||
|
attachment = TicketAttachment(
|
||||||
|
id=uuid.uuid4(), ticket_id=ticket_uuid, uploaded_by=current_user.id, filename=file_metadata["filename"],
|
||||||
|
original_filename=file_metadata["original_filename"], mime_type=file_metadata["mime_type"],
|
||||||
|
file_size=file_metadata["file_size"], file_path=file_metadata["file_path"],
|
||||||
|
md5_hash=file_metadata["md5_hash"], sha256_hash=file_metadata["sha256_hash"], created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(attachment)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(attachment, ["uploaded_by_user"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True, "message": "Archivo subido exitosamente",
|
||||||
|
"data": AttachmentResponse(
|
||||||
|
id=attachment.id, ticket_id=attachment.ticket_id, comment_id=attachment.comment_id,
|
||||||
|
uploaded_by=attachment.uploaded_by, filename=attachment.filename, original_filename=attachment.original_filename,
|
||||||
|
mime_type=attachment.mime_type, file_size=attachment.file_size, file_path=attachment.file_path,
|
||||||
|
uploaded_by_name=f"{attachment.uploaded_by_user.first_name} {attachment.uploaded_by_user.last_name}",
|
||||||
|
created_at=attachment.created_at, download_url=f"/api/v1/tickets/{ticket_id}/attachments/{attachment.id}/download"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/{ticket_id}/attachments/{attachment_id}/download")
|
||||||
|
async def download_attachment(ticket_id: str, attachment_id: str, db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user), current_tenant: Tenant = Depends(get_current_tenant)):
|
||||||
|
"""Descargar un archivo adjunto"""
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
logger.info(f"Download request - ticket_id: {ticket_id}, attachment_id: {attachment_id}")
|
||||||
|
|
||||||
|
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||||
|
attachment_uuid = validate_uuid_param(attachment_id, "attachment ID")
|
||||||
|
|
||||||
|
result = await db.execute(select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_tenant.id))
|
||||||
|
ticket = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not ticket:
|
||||||
|
logger.error(f"Ticket not found - ticket_id: {ticket_id}")
|
||||||
|
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
||||||
|
|
||||||
|
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"] and ticket.created_by != current_user.id:
|
||||||
|
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
||||||
|
|
||||||
|
result = await db.execute(select(TicketAttachment).where(TicketAttachment.id == attachment_uuid, TicketAttachment.ticket_id == ticket_uuid))
|
||||||
|
attachment = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not attachment:
|
||||||
|
logger.error(f"Attachment not found - attachment_id: {attachment_id}")
|
||||||
|
raise HTTPException(status_code=404, detail="Adjunto no encontrado")
|
||||||
|
|
||||||
|
logger.info(f"Attachment found - file_path: {attachment.file_path}, original_filename: {attachment.original_filename}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
file_path = file_handler.get_file_path(attachment.file_path)
|
||||||
|
logger.info(f"Absolute file path: {file_path}")
|
||||||
|
|
||||||
|
if not file_path.exists():
|
||||||
|
logger.error(f"File does not exist at path: {file_path}")
|
||||||
|
raise HTTPException(status_code=404, detail="Archivo no encontrado en el sistema")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting file path: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.info(f"Returning file: {attachment.original_filename}")
|
||||||
|
return FileResponse(path=file_path, filename=attachment.original_filename, media_type=attachment.mime_type)
|
||||||
@@ -1,68 +1,391 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from pydantic import BaseModel, ConfigDict, EmailStr
|
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import security
|
from app.core.security import security
|
||||||
from app.models.user import User, UserRole
|
from app.models.user import User, UserRole
|
||||||
from app.api import deps
|
from app.services.audit_service import AuditService
|
||||||
|
from app.api import deps
|
||||||
|
from app.api.schemas.user import UserCreate, UserUpdate, UserResponse
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
class UserBase(BaseModel):
|
|
||||||
email: EmailStr
|
|
||||||
first_name: str
|
|
||||||
last_name: str
|
|
||||||
role: UserRole
|
|
||||||
is_active: bool = True
|
|
||||||
tenant_id: Optional[uuid.UUID] = None
|
|
||||||
|
|
||||||
class UserCreate(UserBase):
|
# ===================================
|
||||||
password: str
|
# ENDPOINTS
|
||||||
|
# ===================================
|
||||||
|
|
||||||
class UserUpdate(BaseModel):
|
|
||||||
email: Optional[EmailStr] = None
|
|
||||||
first_name: Optional[str] = None
|
|
||||||
last_name: Optional[str] = None
|
|
||||||
role: Optional[UserRole] = None
|
|
||||||
is_active: Optional[bool] = None
|
|
||||||
password: Optional[str] = None # Optional password update
|
|
||||||
|
|
||||||
class UserResponse(UserBase):
|
@router.get("/me", response_model=UserResponse)
|
||||||
id: uuid.UUID
|
async def read_current_user(
|
||||||
|
current_user: User = Depends(deps.get_current_user),
|
||||||
model_config = ConfigDict(from_attributes=True)
|
):
|
||||||
|
"""Obtener el perfil del usuario actual."""
|
||||||
|
return current_user
|
||||||
|
|
||||||
@router.get("/", response_model=List[UserResponse])
|
@router.get("/", response_model=List[UserResponse])
|
||||||
async def read_users(
|
async def read_users(
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
|
role: Optional[UserRole] = None,
|
||||||
|
is_active: Optional[bool] = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user = Depends(deps.get_current_active_superuser)
|
current_user: User = Depends(deps.get_current_user)
|
||||||
):
|
):
|
||||||
query = select(User).offset(skip).limit(limit)
|
"""
|
||||||
|
Listar usuarios del tenant del usuario actual.
|
||||||
|
|
||||||
|
✅ Implementa multi-tenancy: solo muestra usuarios del tenant del usuario.
|
||||||
|
|
||||||
|
Filtros opcionales:
|
||||||
|
- role: filtrar por rol
|
||||||
|
- is_active: filtrar por estado activo
|
||||||
|
"""
|
||||||
|
# ADMIN global ve todos los tenants; el resto solo ve su propio tenant
|
||||||
|
from app.models.user import UserRole as _UserRole
|
||||||
|
if current_user.role != _UserRole.ADMIN:
|
||||||
|
query = select(User).where(User.tenant_id == current_user.tenant_id)
|
||||||
|
else:
|
||||||
|
query = select(User)
|
||||||
|
|
||||||
|
# Aplicar filtros opcionales
|
||||||
|
if role:
|
||||||
|
query = query.where(User.role == role)
|
||||||
|
if is_active is not None:
|
||||||
|
query = query.where(User.is_active == is_active)
|
||||||
|
|
||||||
|
query = query.offset(skip).limit(limit).order_by(User.created_at.desc())
|
||||||
|
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
@router.post("/", response_model=UserResponse)
|
|
||||||
|
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_user(
|
async def create_user(
|
||||||
user: UserCreate,
|
user: UserCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user = Depends(deps.get_current_active_superuser)
|
current_user: User = Depends(deps.get_current_user)
|
||||||
):
|
):
|
||||||
query = select(User).where(User.email == user.email)
|
"""
|
||||||
|
Crear nuevo usuario en el tenant del usuario actual.
|
||||||
|
|
||||||
|
✅ Implementa multi-tenancy: asigna automáticamente tenant_id del usuario.
|
||||||
|
|
||||||
|
Restricciones:
|
||||||
|
- Solo ADMIN, SUPPORT_MANAGER y CLIENT_ADMIN pueden crear usuarios
|
||||||
|
- El email debe ser único dentro del tenant
|
||||||
|
"""
|
||||||
|
# Verificar permisos
|
||||||
|
if not current_user.can_manage_users:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="You don't have permission to create users"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verificar si el email ya existe en el tenant
|
||||||
|
query = select(User).where(
|
||||||
|
User.email == user.email,
|
||||||
|
User.tenant_id == current_user.tenant_id
|
||||||
|
)
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
if result.scalar_one_or_none():
|
if result.scalar_one_or_none():
|
||||||
raise HTTPException(status_code=400, detail="Email already registered")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
user_data = user.model_dump(exclude={"password"})
|
detail="Email already registered in this tenant"
|
||||||
password_hash = security.get_password_hash(user.password)
|
)
|
||||||
|
|
||||||
|
# Preparar datos del usuario
|
||||||
|
user_data = user.model_dump(exclude={"password"})
|
||||||
|
password_hash = security.hash_password(user.password)
|
||||||
|
|
||||||
|
# ✅ CORREGIDO: Asignar tenant_id del usuario actual
|
||||||
|
db_user = User(
|
||||||
|
**user_data,
|
||||||
|
password_hash=password_hash,
|
||||||
|
tenant_id=current_user.tenant_id # ✅ Multi-tenancy automático
|
||||||
|
)
|
||||||
|
|
||||||
db_user = User(**user_data, password_hash=password_hash)
|
|
||||||
db.add(db_user)
|
db.add(db_user)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(db_user)
|
await db.refresh(db_user)
|
||||||
|
|
||||||
|
# Registrar creación en auditoría
|
||||||
|
try:
|
||||||
|
await AuditService.log(
|
||||||
|
db=db,
|
||||||
|
tenant_id=current_user.tenant_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="user.create",
|
||||||
|
resource_type="user",
|
||||||
|
resource_id=db_user.id,
|
||||||
|
new_values=AuditService.sanitize_values({
|
||||||
|
"email": db_user.email,
|
||||||
|
"first_name": db_user.first_name,
|
||||||
|
"last_name": db_user.last_name,
|
||||||
|
"role": db_user.role.value
|
||||||
|
})
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
# No fallar si falla el audit log
|
||||||
|
pass
|
||||||
|
|
||||||
|
return db_user
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{user_id}", response_model=UserResponse)
|
||||||
|
async def read_user(
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(deps.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Obtener un usuario específico del tenant.
|
||||||
|
|
||||||
|
✅ Implementa multi-tenancy: solo permite acceso a usuarios del propio tenant.
|
||||||
|
"""
|
||||||
|
query = select(User).where(
|
||||||
|
User.id == user_id,
|
||||||
|
User.tenant_id == current_user.tenant_id # ✅ Seguridad multi-tenant
|
||||||
|
)
|
||||||
|
result = await db.execute(query)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="User not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{user_id}", response_model=UserResponse)
|
||||||
|
async def update_user(
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
user_update: UserUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(deps.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Actualizar usuario del tenant.
|
||||||
|
|
||||||
|
✅ Implementa multi-tenancy: solo permite actualizar usuarios del propio tenant.
|
||||||
|
|
||||||
|
Restricciones:
|
||||||
|
- Solo ADMIN, SUPPORT_MANAGER y CLIENT_ADMIN pueden actualizar usuarios
|
||||||
|
- No se puede cambiar el tenant_id
|
||||||
|
"""
|
||||||
|
# Verificar permisos
|
||||||
|
if not current_user.can_manage_users:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="You don't have permission to update users"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Buscar usuario
|
||||||
|
query = select(User).where(
|
||||||
|
User.id == user_id,
|
||||||
|
User.tenant_id == current_user.tenant_id
|
||||||
|
)
|
||||||
|
result = await db.execute(query)
|
||||||
|
db_user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not db_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="User not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Guardar valores anteriores para audit
|
||||||
|
old_values = {
|
||||||
|
"email": db_user.email,
|
||||||
|
"first_name": db_user.first_name,
|
||||||
|
"last_name": db_user.last_name,
|
||||||
|
"role": db_user.role.value,
|
||||||
|
"is_active": db_user.is_active
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verificar email único si se está cambiando
|
||||||
|
update_data = user_update.model_dump(exclude_unset=True)
|
||||||
|
if "email" in update_data and update_data["email"] != db_user.email:
|
||||||
|
email_query = select(User).where(
|
||||||
|
User.email == update_data["email"],
|
||||||
|
User.tenant_id == current_user.tenant_id,
|
||||||
|
User.id != user_id
|
||||||
|
)
|
||||||
|
email_result = await db.execute(email_query)
|
||||||
|
if email_result.scalar_one_or_none():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Email already in use by another user"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Actualizar campos
|
||||||
|
for field, value in update_data.items():
|
||||||
|
if field == "password":
|
||||||
|
# Hash the new password
|
||||||
|
db_user.password_hash = security.hash_password(value)
|
||||||
|
else:
|
||||||
|
setattr(db_user, field, value)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(db_user)
|
||||||
|
|
||||||
|
# Registrar actualización en auditoría
|
||||||
|
try:
|
||||||
|
new_values = {
|
||||||
|
"email": db_user.email,
|
||||||
|
"first_name": db_user.first_name,
|
||||||
|
"last_name": db_user.last_name,
|
||||||
|
"role": db_user.role.value,
|
||||||
|
"is_active": db_user.is_active
|
||||||
|
}
|
||||||
|
|
||||||
|
await AuditService.log(
|
||||||
|
db=db,
|
||||||
|
tenant_id=current_user.tenant_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="user.update",
|
||||||
|
resource_type="user",
|
||||||
|
resource_id=db_user.id,
|
||||||
|
old_values=AuditService.sanitize_values(old_values),
|
||||||
|
new_values=AuditService.sanitize_values(new_values)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
# No fallar si falla el audit log
|
||||||
|
pass
|
||||||
|
|
||||||
|
return db_user
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_user(
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(deps.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Desactivar usuario del tenant (soft delete).
|
||||||
|
|
||||||
|
✅ Implementa multi-tenancy: solo permite desactivar usuarios del propio tenant.
|
||||||
|
|
||||||
|
Restricciones:
|
||||||
|
- Solo ADMIN puede eliminar usuarios
|
||||||
|
- No se puede eliminar a sí mismo
|
||||||
|
- No se puede eliminar el último ADMIN del tenant
|
||||||
|
"""
|
||||||
|
# Verificar permisos - solo ADMIN puede eliminar
|
||||||
|
if current_user.role != UserRole.ADMIN:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Only admins can delete users"
|
||||||
|
)
|
||||||
|
|
||||||
|
# No se puede eliminar a sí mismo
|
||||||
|
if user_id == current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="You cannot delete yourself"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Buscar usuario
|
||||||
|
query = select(User).where(
|
||||||
|
User.id == user_id,
|
||||||
|
User.tenant_id == current_user.tenant_id
|
||||||
|
)
|
||||||
|
result = await db.execute(query)
|
||||||
|
db_user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not db_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="User not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verificar que no sea el último admin del tenant
|
||||||
|
if db_user.role == UserRole.ADMIN:
|
||||||
|
admin_query = select(User).where(
|
||||||
|
User.tenant_id == current_user.tenant_id,
|
||||||
|
User.role == UserRole.ADMIN,
|
||||||
|
User.is_active == True,
|
||||||
|
User.id != user_id
|
||||||
|
)
|
||||||
|
admin_result = await db.execute(admin_query)
|
||||||
|
active_admins = admin_result.scalars().all()
|
||||||
|
|
||||||
|
if len(active_admins) == 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Cannot delete the last active admin of the tenant"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Soft delete
|
||||||
|
db_user.is_active = False
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Registrar eliminación en auditoría
|
||||||
|
try:
|
||||||
|
await AuditService.log(
|
||||||
|
db=db,
|
||||||
|
tenant_id=current_user.tenant_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="user.delete",
|
||||||
|
resource_type="user",
|
||||||
|
resource_id=db_user.id,
|
||||||
|
old_values={
|
||||||
|
"email": db_user.email,
|
||||||
|
"role": db_user.role.value,
|
||||||
|
"was_active": True
|
||||||
|
},
|
||||||
|
metadata={"action_type": "soft_delete"}
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
# No fallar si falla el audit log
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{user_id}/activate", response_model=UserResponse)
|
||||||
|
async def activate_user(
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(deps.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Reactivar usuario desactivado.
|
||||||
|
|
||||||
|
✅ Implementa multi-tenancy: solo permite reactivar usuarios del propio tenant.
|
||||||
|
"""
|
||||||
|
# Verificar permisos
|
||||||
|
if not current_user.can_manage_users:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="You don't have permission to activate users"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Buscar usuario
|
||||||
|
query = select(User).where(
|
||||||
|
User.id == user_id,
|
||||||
|
User.tenant_id == current_user.tenant_id
|
||||||
|
)
|
||||||
|
result = await db.execute(query)
|
||||||
|
db_user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not db_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="User not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
db_user.is_active = True
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(db_user)
|
||||||
return db_user
|
return db_user
|
||||||
|
|||||||
117
backend/app/api/v1/helpers.py
Normal file
117
backend/app/api/v1/helpers.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"""
|
||||||
|
Helper functions for API endpoints
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from typing import Any, Type
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import Query
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.ticket import Ticket
|
||||||
|
from app.models.category import Category
|
||||||
|
from app.services.audit_service import AuditService
|
||||||
|
|
||||||
|
|
||||||
|
def validate_uuid_param(value: str, param_name: str = "ID") -> uuid.UUID:
|
||||||
|
"""Valida y convierte string a UUID"""
|
||||||
|
try:
|
||||||
|
return uuid.UUID(value)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid {param_name} format"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_client_permissions(query: Query, model: Type, current_user: User) -> Query:
|
||||||
|
"""Aplica filtros de tenant y permisos de cliente"""
|
||||||
|
query = query.where(model.tenant_id == current_user.tenant_id)
|
||||||
|
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
|
||||||
|
query = query.where(model.created_by == current_user.id)
|
||||||
|
return query
|
||||||
|
|
||||||
|
|
||||||
|
def apply_enum_filter(query: Query, model_field: Any, filter_value: str,
|
||||||
|
enum_class: Type, filter_name: str) -> Query:
|
||||||
|
"""Aplica filtro de enum genérico"""
|
||||||
|
if filter_value:
|
||||||
|
try:
|
||||||
|
enum_val = enum_class[filter_value.upper()]
|
||||||
|
return query.where(model_field == enum_val)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid {filter_name}: {filter_value}"
|
||||||
|
)
|
||||||
|
return query
|
||||||
|
|
||||||
|
|
||||||
|
async def safe_audit_log(db: AsyncSession, **kwargs):
|
||||||
|
"""Registra en auditoría sin fallar la operación principal"""
|
||||||
|
try:
|
||||||
|
await AuditService.log(db=db, **kwargs)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass # Silent fail para audit logs
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_next_ticket_number(db: AsyncSession, tenant_id: uuid.UUID) -> str:
|
||||||
|
"""Genera el siguiente número de ticket único para el tenant"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Ticket.ticket_number)
|
||||||
|
.where(Ticket.tenant_id == tenant_id)
|
||||||
|
.order_by(Ticket.ticket_number.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
last_ticket_number = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if last_ticket_number:
|
||||||
|
last_number = int(last_ticket_number.split('-')[-1])
|
||||||
|
next_number = last_number + 1
|
||||||
|
else:
|
||||||
|
next_number = 1
|
||||||
|
|
||||||
|
return f"TK-{next_number:06d}"
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_sla_deadlines(category: Category = None) -> tuple[datetime, datetime]:
|
||||||
|
"""Calcula SLA response y resolution deadlines"""
|
||||||
|
if not category:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
sla_response_due = now + timedelta(hours=category.sla_response_hours)
|
||||||
|
sla_resolution_due = now + timedelta(hours=category.sla_resolution_hours)
|
||||||
|
return sla_response_due, sla_resolution_due
|
||||||
|
|
||||||
|
|
||||||
|
def ticket_to_dict(ticket: Ticket) -> dict:
|
||||||
|
"""Convierte un modelo Ticket a diccionario de respuesta"""
|
||||||
|
return {
|
||||||
|
"id": str(ticket.id),
|
||||||
|
"ticket_number": ticket.ticket_number,
|
||||||
|
"subject": ticket.subject,
|
||||||
|
"title": ticket.subject,
|
||||||
|
"description": ticket.description,
|
||||||
|
"status": ticket.status.value,
|
||||||
|
"priority": ticket.priority.value,
|
||||||
|
"category_id": str(ticket.category_id) if ticket.category_id else None,
|
||||||
|
"category_name": ticket.category.name if ticket.category else None,
|
||||||
|
"affected_system_id": str(ticket.affected_system_id) if ticket.affected_system_id else None,
|
||||||
|
"system_id": str(ticket.affected_system_id) if ticket.affected_system_id else None,
|
||||||
|
"affected_system_name": ticket.affected_system.name if ticket.affected_system else None,
|
||||||
|
"contact_email": None,
|
||||||
|
"contact_phone": None,
|
||||||
|
"created_by": str(ticket.created_by),
|
||||||
|
"assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None,
|
||||||
|
"assigned_to_name": f"{ticket.assigned_to_user.first_name} {ticket.assigned_to_user.last_name}" if ticket.assigned_to_user else None,
|
||||||
|
"created_at": ticket.created_at,
|
||||||
|
"updated_at": ticket.updated_at,
|
||||||
|
"sla_response_due": ticket.sla_response_due,
|
||||||
|
"sla_resolution_due": ticket.sla_resolution_due,
|
||||||
|
"first_response_at": ticket.first_response_at,
|
||||||
|
"resolved_at": ticket.resolved_at,
|
||||||
|
"tenant_id": str(ticket.tenant_id)
|
||||||
|
}
|
||||||
@@ -5,7 +5,8 @@ Router principal para la API v1
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories
|
|
||||||
|
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla, reports
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
@@ -45,3 +46,38 @@ api_router.include_router(
|
|||||||
prefix="/categories",
|
prefix="/categories",
|
||||||
tags=["categories"]
|
tags=["categories"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Tickets routes
|
||||||
|
api_router.include_router(
|
||||||
|
tickets.router,
|
||||||
|
prefix="/tickets",
|
||||||
|
tags=["tickets"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Client Profile routes
|
||||||
|
api_router.include_router(
|
||||||
|
client_profile.router,
|
||||||
|
prefix="/client-profile",
|
||||||
|
tags=["client-profile"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Audit routes
|
||||||
|
api_router.include_router(
|
||||||
|
audit.router,
|
||||||
|
prefix="/audit",
|
||||||
|
tags=["audit"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# SLA routes
|
||||||
|
api_router.include_router(
|
||||||
|
sla.router,
|
||||||
|
prefix="/sla",
|
||||||
|
tags=["sla"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reports routes
|
||||||
|
api_router.include_router(
|
||||||
|
reports.router,
|
||||||
|
prefix="/reports",
|
||||||
|
tags=["reports"]
|
||||||
|
)
|
||||||
308
backend/app/core/cache.py
Normal file
308
backend/app/core/cache.py
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
"""
|
||||||
|
Redis Caching Service - ServiceManagerWeb
|
||||||
|
|
||||||
|
Servicio centralizado para manejo de caché con Redis.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from redis import asyncio as aioredis
|
||||||
|
from typing import Optional, Any, Union
|
||||||
|
import json
|
||||||
|
import structlog
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class CacheService:
|
||||||
|
"""
|
||||||
|
Servicio de caché usando Redis.
|
||||||
|
|
||||||
|
Proporciona métodos para get/set/delete de datos con serialización JSON.
|
||||||
|
Usa un singleton pattern para compartir la conexión Redis.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_instance = None
|
||||||
|
_redis = None
|
||||||
|
|
||||||
|
def __new__(cls):
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super().__new__(cls)
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
"""Conectar a Redis si aún no está conectado."""
|
||||||
|
if self._redis is None:
|
||||||
|
try:
|
||||||
|
self._redis = await aioredis.from_url(
|
||||||
|
settings.REDIS_URL,
|
||||||
|
encoding="utf-8",
|
||||||
|
decode_responses=True,
|
||||||
|
socket_connect_timeout=5,
|
||||||
|
socket_timeout=5
|
||||||
|
)
|
||||||
|
logger.info("Redis cache connected", url=settings.REDIS_URL)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to connect to Redis", error=str(e))
|
||||||
|
self._redis = None
|
||||||
|
|
||||||
|
async def disconnect(self):
|
||||||
|
"""Cerrar conexión Redis."""
|
||||||
|
if self._redis:
|
||||||
|
await self._redis.close()
|
||||||
|
self._redis = None
|
||||||
|
logger.info("Redis cache disconnected")
|
||||||
|
|
||||||
|
async def get(self, key: str) -> Optional[Any]:
|
||||||
|
"""
|
||||||
|
Obtener valor del cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Clave del cache
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Valor deserializado o None si no existe
|
||||||
|
"""
|
||||||
|
if self._redis is None:
|
||||||
|
await self.connect()
|
||||||
|
|
||||||
|
if self._redis is None:
|
||||||
|
logger.warning("Redis not available, skipping cache get", key=key)
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
value = await self._redis.get(key)
|
||||||
|
if value:
|
||||||
|
logger.debug("Cache hit", key=key)
|
||||||
|
return json.loads(value)
|
||||||
|
logger.debug("Cache miss", key=key)
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Cache get error", key=key, error=str(e))
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def set(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
value: Any,
|
||||||
|
ttl: int = 300
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Guardar valor en cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Clave del cache
|
||||||
|
value: Valor a guardar (será serializado a JSON)
|
||||||
|
ttl: Tiempo de vida en segundos (default: 5 minutos)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True si se guardó exitosamente
|
||||||
|
"""
|
||||||
|
if self._redis is None:
|
||||||
|
await self.connect()
|
||||||
|
|
||||||
|
if self._redis is None:
|
||||||
|
logger.warning("Redis not available, skipping cache set", key=key)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
serialized = json.dumps(value, default=str)
|
||||||
|
await self._redis.setex(key, ttl, serialized)
|
||||||
|
logger.debug("Cache set", key=key, ttl=ttl)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Cache set error", key=key, error=str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def delete(self, key: str) -> bool:
|
||||||
|
"""
|
||||||
|
Eliminar clave del cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Clave a eliminar
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True si se eliminó exitosamente
|
||||||
|
"""
|
||||||
|
if self._redis is None:
|
||||||
|
await self.connect()
|
||||||
|
|
||||||
|
if self._redis is None:
|
||||||
|
logger.warning("Redis not available, skipping cache delete", key=key)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._redis.delete(key)
|
||||||
|
logger.debug("Cache delete", key=key)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Cache delete error", key=key, error=str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def delete_pattern(self, pattern: str) -> int:
|
||||||
|
"""
|
||||||
|
Eliminar todas las claves que coincidan con el patrón.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pattern: Patrón de búsqueda (ej: "tickets:tenant:*")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Número de claves eliminadas
|
||||||
|
"""
|
||||||
|
if self._redis is None:
|
||||||
|
await self.connect()
|
||||||
|
|
||||||
|
if self._redis is None:
|
||||||
|
logger.warning("Redis not available, skipping pattern delete", pattern=pattern)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
keys = []
|
||||||
|
async for key in self._redis.scan_iter(pattern):
|
||||||
|
keys.append(key)
|
||||||
|
|
||||||
|
if keys:
|
||||||
|
deleted = await self._redis.delete(*keys)
|
||||||
|
logger.info("Cache pattern delete", pattern=pattern, deleted=deleted)
|
||||||
|
return deleted
|
||||||
|
return 0
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Cache pattern delete error", pattern=pattern, error=str(e))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def exists(self, key: str) -> bool:
|
||||||
|
"""
|
||||||
|
Verificar si una clave existe en cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Clave a verificar
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True si existe
|
||||||
|
"""
|
||||||
|
if self._redis is None:
|
||||||
|
await self.connect()
|
||||||
|
|
||||||
|
if self._redis is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await self._redis.exists(key) > 0
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Cache exists error", key=key, error=str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def incr(self, key: str, amount: int = 1) -> Optional[int]:
|
||||||
|
"""
|
||||||
|
Incrementar un contador en cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Clave del contador
|
||||||
|
amount: Cantidad a incrementar
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Nuevo valor del contador
|
||||||
|
"""
|
||||||
|
if self._redis is None:
|
||||||
|
await self.connect()
|
||||||
|
|
||||||
|
if self._redis is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await self._redis.incrby(key, amount)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Cache incr error", key=key, error=str(e))
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def expire(self, key: str, ttl: int) -> bool:
|
||||||
|
"""
|
||||||
|
Establecer tiempo de expiración a una clave existente.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Clave a expirar
|
||||||
|
ttl: Tiempo de vida en segundos
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True si se estableció exitosamente
|
||||||
|
"""
|
||||||
|
if self._redis is None:
|
||||||
|
await self.connect()
|
||||||
|
|
||||||
|
if self._redis is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await self._redis.expire(key, ttl)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Cache expire error", key=key, error=str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
cache = CacheService()
|
||||||
|
|
||||||
|
|
||||||
|
def cache_key(*parts: str) -> str:
|
||||||
|
"""
|
||||||
|
Helper para construir claves de cache consistentes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
*parts: Partes de la clave a unir
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Clave formateada
|
||||||
|
|
||||||
|
Example:
|
||||||
|
cache_key("tickets", "tenant", tenant_id) -> "tickets:tenant:123"
|
||||||
|
"""
|
||||||
|
return ":".join(str(part) for part in parts)
|
||||||
|
|
||||||
|
|
||||||
|
def cached(
|
||||||
|
key_prefix: str,
|
||||||
|
ttl: int = 300,
|
||||||
|
key_builder: Optional[callable] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Decorator para cachear resultados de funciones async.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key_prefix: Prefijo para la clave de cache
|
||||||
|
ttl: Tiempo de vida en segundos
|
||||||
|
key_builder: Función opcional para construir la clave
|
||||||
|
|
||||||
|
Example:
|
||||||
|
@cached("categories", ttl=600)
|
||||||
|
async def get_categories(tenant_id: str):
|
||||||
|
return await db.query(Category).all()
|
||||||
|
"""
|
||||||
|
def decorator(func):
|
||||||
|
@wraps(func)
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
# Construir clave de cache
|
||||||
|
if key_builder:
|
||||||
|
key = key_builder(*args, **kwargs)
|
||||||
|
else:
|
||||||
|
# Default: usar nombre de función y args
|
||||||
|
key_parts = [key_prefix, func.__name__]
|
||||||
|
key_parts.extend(str(arg) for arg in args)
|
||||||
|
key_parts.extend(f"{k}={v}" for k, v in sorted(kwargs.items()))
|
||||||
|
key = cache_key(*key_parts)
|
||||||
|
|
||||||
|
# Intentar obtener del cache
|
||||||
|
cached_value = await cache.get(key)
|
||||||
|
if cached_value is not None:
|
||||||
|
return cached_value
|
||||||
|
|
||||||
|
# Si no está en cache, ejecutar función
|
||||||
|
result = await func(*args, **kwargs)
|
||||||
|
|
||||||
|
# Guardar en cache
|
||||||
|
await cache.set(key, result, ttl=ttl)
|
||||||
|
|
||||||
|
return result
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
@@ -5,7 +5,6 @@ Configuración centralizada usando Pydantic Settings v2
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import List, Optional
|
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
from pydantic import field_validator, Field
|
from pydantic import field_validator, Field
|
||||||
import os
|
import os
|
||||||
@@ -23,101 +22,103 @@ class Settings(BaseSettings):
|
|||||||
# ===================================
|
# ===================================
|
||||||
# GENERAL
|
# GENERAL
|
||||||
# ===================================
|
# ===================================
|
||||||
ENVIRONMENT: str = Field(default="development", env="ENVIRONMENT")
|
ENVIRONMENT: str = Field(default="development")
|
||||||
DEBUG: bool = Field(default=False, env="DEBUG")
|
TESTING: bool = Field(default=False)
|
||||||
SECRET_KEY: str = Field(..., env="SECRET_KEY")
|
DEBUG: bool = Field(default=False)
|
||||||
API_VERSION: str = Field(default="v1", env="API_VERSION")
|
SECRET_KEY: str = Field(...)
|
||||||
|
API_VERSION: str = Field(default="v1")
|
||||||
|
APP_VERSION: str = Field(default="1.9.0")
|
||||||
|
|
||||||
# ===================================
|
# ===================================
|
||||||
# DATABASE
|
# DATABASE
|
||||||
# ===================================
|
# ===================================
|
||||||
DATABASE_URL: str = Field(..., env="DATABASE_URL")
|
DATABASE_URL: str = Field(...)
|
||||||
|
|
||||||
# ===================================
|
# ===================================
|
||||||
# REDIS
|
# REDIS
|
||||||
# ===================================
|
# ===================================
|
||||||
REDIS_URL: str = Field(..., env="REDIS_URL")
|
REDIS_URL: str = Field(...)
|
||||||
|
|
||||||
# ===================================
|
# ===================================
|
||||||
# JWT AUTHENTICATION
|
# JWT AUTHENTICATION
|
||||||
# ===================================
|
# ===================================
|
||||||
JWT_SECRET_KEY: str = Field(..., env="JWT_SECRET_KEY")
|
JWT_SECRET_KEY: str = Field(...)
|
||||||
JWT_ALGORITHM: str = Field(default="HS256", env="JWT_ALGORITHM")
|
JWT_ALGORITHM: str = Field(default="HS256")
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=60, env="ACCESS_TOKEN_EXPIRE_MINUTES")
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=60)
|
||||||
REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7, env="REFRESH_TOKEN_EXPIRE_DAYS")
|
REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7)
|
||||||
|
|
||||||
# ===================================
|
# ===================================
|
||||||
# CORS
|
# CORS
|
||||||
# ===================================
|
# ===================================
|
||||||
CORS_ORIGINS: str = Field(
|
CORS_ORIGINS: str = Field(
|
||||||
default="http://localhost:3000,http://localhost:3001",
|
default="http://localhost:3000,http://localhost:3001"
|
||||||
env="CORS_ORIGINS"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# ===================================
|
# ===================================
|
||||||
# EMAIL
|
# EMAIL
|
||||||
# ===================================
|
# ===================================
|
||||||
SMTP_HOST: str = Field(default="localhost", env="SMTP_HOST")
|
SMTP_HOST: str = Field(default="localhost")
|
||||||
SMTP_PORT: int = Field(default=587, env="SMTP_PORT")
|
SMTP_PORT: int = Field(default=587)
|
||||||
SMTP_USER: Optional[str] = Field(default=None, env="SMTP_USER")
|
SMTP_USER: str | None = Field(default=None)
|
||||||
SMTP_PASSWORD: Optional[str] = Field(default=None, env="SMTP_PASSWORD")
|
SMTP_PASSWORD: str | None = Field(default=None)
|
||||||
SMTP_USE_TLS: bool = Field(default=True, env="SMTP_USE_TLS")
|
SMTP_USE_TLS: bool = Field(default=True)
|
||||||
SMTP_USE_SSL: bool = Field(default=False, env="SMTP_USE_SSL")
|
SMTP_USE_SSL: bool = Field(default=False)
|
||||||
|
|
||||||
DEFAULT_FROM_EMAIL: str = Field(default="noreply@servicemanager.local", env="DEFAULT_FROM_EMAIL")
|
DEFAULT_FROM_EMAIL: str = Field(default="noreply@servicemanager.local")
|
||||||
DEFAULT_FROM_NAME: str = Field(default="ServiceManager", env="DEFAULT_FROM_NAME")
|
DEFAULT_FROM_NAME: str = Field(default="ServiceManager")
|
||||||
|
|
||||||
# ===================================
|
# ===================================
|
||||||
# FILE UPLOADS
|
# FILE UPLOADS
|
||||||
# ===================================
|
# ===================================
|
||||||
MAX_UPLOAD_SIZE_MB: int = Field(default=10, env="MAX_UPLOAD_SIZE_MB")
|
MAX_UPLOAD_SIZE_MB: int = Field(default=10)
|
||||||
ALLOWED_FILE_EXTENSIONS: List[str] = Field(
|
ALLOWED_FILE_EXTENSIONS_STR: str = Field(
|
||||||
default=["pdf", "jpg", "jpeg", "png", "doc", "docx", "xls", "xlsx", "txt"],
|
default="pdf,jpg,jpeg,png,doc,docx,xls,xlsx,txt",
|
||||||
env="ALLOWED_FILE_EXTENSIONS"
|
alias="ALLOWED_FILE_EXTENSIONS"
|
||||||
)
|
)
|
||||||
UPLOAD_PATH: str = Field(default="/app/uploads", env="UPLOAD_PATH")
|
UPLOAD_PATH: str = Field(default="/app/uploads")
|
||||||
|
|
||||||
@field_validator("ALLOWED_FILE_EXTENSIONS", mode='before')
|
@property
|
||||||
@classmethod
|
def ALLOWED_FILE_EXTENSIONS(self) -> list[str]:
|
||||||
def validate_file_extensions(cls, v):
|
"""Parse the comma-separated file extensions."""
|
||||||
if isinstance(v, str):
|
return [ext.strip().lower() for ext in self.ALLOWED_FILE_EXTENSIONS_STR.split(",")]
|
||||||
return [ext.strip().lower() for ext in v.split(",")]
|
|
||||||
return [ext.lower() for ext in v]
|
|
||||||
|
|
||||||
# ===================================
|
# ===================================
|
||||||
# SECURITY
|
# SECURITY
|
||||||
# ===================================
|
# ===================================
|
||||||
RATE_LIMIT_ENABLED: bool = Field(default=True, env="RATE_LIMIT_ENABLED")
|
RATE_LIMIT_ENABLED: bool = Field(default=True)
|
||||||
PASSWORD_MIN_LENGTH: int = Field(default=8, env="PASSWORD_MIN_LENGTH")
|
LOGIN_RATE_LIMIT_WINDOW_SECONDS: int = Field(default=300)
|
||||||
|
LOGIN_RATE_LIMIT_IP_MAX_ATTEMPTS: int = Field(default=30)
|
||||||
|
LOGIN_RATE_LIMIT_ID_MAX_ATTEMPTS: int = Field(default=10)
|
||||||
|
PASSWORD_MIN_LENGTH: int = Field(default=8)
|
||||||
|
|
||||||
# Argon2 settings
|
# Argon2 settings
|
||||||
ARGON2_TIME_COST: int = Field(default=3, env="ARGON2_TIME_COST")
|
ARGON2_TIME_COST: int = Field(default=3)
|
||||||
ARGON2_MEMORY_COST: int = Field(default=65536, env="ARGON2_MEMORY_COST")
|
ARGON2_MEMORY_COST: int = Field(default=65536)
|
||||||
ARGON2_PARALLELISM: int = Field(default=4, env="ARGON2_PARALLELISM")
|
ARGON2_PARALLELISM: int = Field(default=4)
|
||||||
|
|
||||||
# ===================================
|
# ===================================
|
||||||
# LOGGING
|
# LOGGING
|
||||||
# ===================================
|
# ===================================
|
||||||
LOG_LEVEL: str = Field(default="INFO", env="LOG_LEVEL")
|
LOG_LEVEL: str = Field(default="INFO")
|
||||||
LOG_FORMAT: str = Field(default="json", env="LOG_FORMAT")
|
LOG_FORMAT: str = Field(default="json")
|
||||||
LOG_FILE: Optional[str] = Field(default=None, env="LOG_FILE")
|
LOG_FILE: str | None = Field(default=None)
|
||||||
|
|
||||||
# ===================================
|
# ===================================
|
||||||
# FRONTEND URLS
|
# FRONTEND URLS
|
||||||
# ===================================
|
# ===================================
|
||||||
CLIENT_FRONTEND_URL: str = Field(default="http://localhost:3000", env="CLIENT_FRONTEND_URL")
|
CLIENT_FRONTEND_URL: str = Field(default="http://localhost:3000")
|
||||||
INTERNAL_FRONTEND_URL: str = Field(default="http://localhost:3001", env="INTERNAL_FRONTEND_URL")
|
INTERNAL_FRONTEND_URL: str = Field(default="http://localhost:3001")
|
||||||
|
|
||||||
# ===================================
|
# ===================================
|
||||||
# HEALTH CHECKS
|
# HEALTH CHECKS
|
||||||
# ===================================
|
# ===================================
|
||||||
HEALTH_CHECK_TIMEOUT: int = Field(default=30, env="HEALTH_CHECK_TIMEOUT")
|
HEALTH_CHECK_TIMEOUT: int = Field(default=30)
|
||||||
|
|
||||||
# ===================================
|
# ===================================
|
||||||
# CELERY
|
# CELERY
|
||||||
# ===================================
|
# ===================================
|
||||||
CELERY_BROKER_URL: str = Field(..., env="CELERY_BROKER_URL")
|
CELERY_BROKER_URL: str = Field(...)
|
||||||
CELERY_RESULT_BACKEND: str = Field(..., env="CELERY_RESULT_BACKEND")
|
CELERY_RESULT_BACKEND: str = Field(...)
|
||||||
|
|
||||||
def is_production(self) -> bool:
|
def is_production(self) -> bool:
|
||||||
"""Check if environment is production."""
|
"""Check if environment is production."""
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ SQLAlchemy 2.0 async setup con PostgreSQL
|
|||||||
|
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
from sqlalchemy import String, DateTime, func
|
from sqlalchemy import String, DateTime, func, text
|
||||||
|
from sqlalchemy.types import TypeDecorator, CHAR
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -19,10 +21,11 @@ settings = get_settings()
|
|||||||
engine = create_async_engine(
|
engine = create_async_engine(
|
||||||
settings.DATABASE_URL,
|
settings.DATABASE_URL,
|
||||||
echo=settings.DEBUG,
|
echo=settings.DEBUG,
|
||||||
pool_size=5,
|
pool_size=20, # Increased for better concurrency
|
||||||
max_overflow=10,
|
max_overflow=30, # Increased for peak loads
|
||||||
pool_pre_ping=True, # Verify connections before use
|
pool_pre_ping=True, # Verify connections before use
|
||||||
pool_recycle=3600, # Recycle connections after 1 hour
|
pool_recycle=3600, # Recycle connections after 1 hour
|
||||||
|
pool_timeout=30, # Wait up to 30s for connection from pool
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create session factory
|
# Create session factory
|
||||||
@@ -33,18 +36,46 @@ AsyncSessionLocal = async_sessionmaker(
|
|||||||
autoflush=True,
|
autoflush=True,
|
||||||
autocommit=False
|
autocommit=False
|
||||||
)
|
)
|
||||||
|
class GUID(TypeDecorator):
|
||||||
|
"""UUID portable: UUID nativo en Postgres, CHAR(36) en otros dialectos (SQLite para tests)."""
|
||||||
|
|
||||||
|
impl = CHAR
|
||||||
|
cache_ok = True
|
||||||
|
|
||||||
|
def load_dialect_impl(self, dialect):
|
||||||
|
if dialect.name == "postgresql":
|
||||||
|
return dialect.type_descriptor(PG_UUID(as_uuid=True))
|
||||||
|
return dialect.type_descriptor(CHAR(36))
|
||||||
|
|
||||||
|
def process_bind_param(self, value, dialect):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if dialect.name == "postgresql":
|
||||||
|
return value
|
||||||
|
|
||||||
|
if isinstance(value, uuid.UUID):
|
||||||
|
return str(value)
|
||||||
|
return str(uuid.UUID(str(value)))
|
||||||
|
|
||||||
|
def process_result_value(self, value, dialect):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, uuid.UUID):
|
||||||
|
return value
|
||||||
|
return uuid.UUID(str(value))
|
||||||
|
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
class Base(DeclarativeBase):
|
||||||
"""Base class para todos los modelos SQLAlchemy."""
|
"""Base class para todos los modelos SQLAlchemy."""
|
||||||
|
|
||||||
# Columnas comunes para auditoría
|
# Columnas comunes para auditoría
|
||||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True),
|
DateTime(timezone=True),
|
||||||
server_default=func.now(),
|
server_default=func.now(),
|
||||||
onupdate=func.now()
|
onupdate=func.now(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -88,7 +119,7 @@ async def check_database_health() -> bool:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
await session.execute("SELECT 1")
|
await session.execute(text("SELECT 1"))
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
179
backend/app/core/email.py
Normal file
179
backend/app/core/email.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
"""
|
||||||
|
Email Utility - ServiceManagerWeb
|
||||||
|
|
||||||
|
Envío directo de emails desde el backend para flujos críticos
|
||||||
|
(reseteo de contraseña, verificación) sin depender de Celery.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import smtplib
|
||||||
|
import ssl
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from typing import Optional
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_smtp_sync(
|
||||||
|
to_email: str,
|
||||||
|
subject: str,
|
||||||
|
html_content: str,
|
||||||
|
text_content: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Enviar email de forma síncrona vía SMTP.
|
||||||
|
Llamar desde asyncio.to_thread para no bloquear el event loop.
|
||||||
|
"""
|
||||||
|
msg = MIMEMultipart("alternative")
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["From"] = f"{settings.DEFAULT_FROM_NAME} <{settings.DEFAULT_FROM_EMAIL}>"
|
||||||
|
msg["To"] = to_email
|
||||||
|
|
||||||
|
if text_content:
|
||||||
|
msg.attach(MIMEText(text_content, "plain", "utf-8"))
|
||||||
|
msg.attach(MIMEText(html_content, "html", "utf-8"))
|
||||||
|
|
||||||
|
if settings.SMTP_USE_SSL:
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
with smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT, context=context) as server:
|
||||||
|
if settings.SMTP_USER and settings.SMTP_PASSWORD:
|
||||||
|
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||||
|
server.sendmail(settings.DEFAULT_FROM_EMAIL, to_email, msg.as_string())
|
||||||
|
else:
|
||||||
|
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as server:
|
||||||
|
if settings.SMTP_USE_TLS:
|
||||||
|
server.starttls()
|
||||||
|
if settings.SMTP_USER and settings.SMTP_PASSWORD:
|
||||||
|
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||||
|
server.sendmail(settings.DEFAULT_FROM_EMAIL, to_email, msg.as_string())
|
||||||
|
|
||||||
|
|
||||||
|
async def send_email(
|
||||||
|
to_email: str,
|
||||||
|
subject: str,
|
||||||
|
html_content: str,
|
||||||
|
text_content: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Enviar email de forma asíncrona.
|
||||||
|
|
||||||
|
Retorna True si el envío fue exitoso, False con log de error si falló.
|
||||||
|
Se diseña para no propagar excepciones (fail-silent) en flujos de UI.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(
|
||||||
|
_send_smtp_sync,
|
||||||
|
to_email,
|
||||||
|
subject,
|
||||||
|
html_content,
|
||||||
|
text_content,
|
||||||
|
)
|
||||||
|
logger.info("Email sent", to=to_email, subject=subject)
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Email send failed", to=to_email, subject=subject, error=str(exc))
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Plantillas HTML inline
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def build_password_reset_email(reset_url: str, user_name: str) -> tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Construir HTML y texto plano para email de reseteo de contraseña.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(html_content, text_content)
|
||||||
|
"""
|
||||||
|
html = f"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Restablecer contraseña</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background:#f4f6f8;font-family:Arial,sans-serif;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f6f8;padding:40px 0;">
|
||||||
|
<tr><td align="center">
|
||||||
|
<table width="560" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,.08);">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td style="background:#1d4ed8;padding:32px 40px;text-align:center;">
|
||||||
|
<span style="color:#ffffff;font-size:22px;font-weight:700;letter-spacing:-.5px;">ServiceManager</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:40px;">
|
||||||
|
<h2 style="margin:0 0 16px;font-size:20px;color:#111827;">Restablece tu contraseña</h2>
|
||||||
|
<p style="margin:0 0 12px;font-size:15px;color:#374151;line-height:1.6;">
|
||||||
|
Hola <strong>{user_name}</strong>,
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 24px;font-size:15px;color:#374151;line-height:1.6;">
|
||||||
|
Recibimos una solicitud para restablecer la contraseña de tu cuenta.
|
||||||
|
Haz clic en el botón de abajo para crear una nueva contraseña.
|
||||||
|
Este enlace es válido por <strong>30 minutos</strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table cellpadding="0" cellspacing="0" style="margin:0 auto 32px;">
|
||||||
|
<tr>
|
||||||
|
<td style="background:#1d4ed8;border-radius:6px;">
|
||||||
|
<a href="{reset_url}"
|
||||||
|
style="display:inline-block;padding:14px 32px;color:#ffffff;font-size:15px;font-weight:600;text-decoration:none;border-radius:6px;">
|
||||||
|
Restablecer contraseña
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin:0 0 8px;font-size:13px;color:#6b7280;">
|
||||||
|
Si no puedes hacer clic en el botón, copia y pega este enlace en tu navegador:
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 24px;font-size:12px;color:#2563eb;word-break:break-all;">
|
||||||
|
<a href="{reset_url}" style="color:#2563eb;">{reset_url}</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;">
|
||||||
|
|
||||||
|
<p style="margin:0;font-size:13px;color:#9ca3af;line-height:1.6;">
|
||||||
|
Si no solicitaste restablecer tu contraseña, puedes ignorar este mensaje.
|
||||||
|
Tu contraseña no se modificará.<br>
|
||||||
|
Por seguridad, este enlace expira en 30 minutos y solo puede usarse una vez.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:20px 40px;background:#f9fafb;text-align:center;">
|
||||||
|
<p style="margin:0;font-size:12px;color:#9ca3af;">
|
||||||
|
© 2026 Aduanasoft — Acceso exclusivo autorizado
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
text = (
|
||||||
|
f"Hola {user_name},\n\n"
|
||||||
|
"Recibimos una solicitud para restablecer la contraseña de tu cuenta.\n\n"
|
||||||
|
f"Haz clic en el siguiente enlace (válido por 30 minutos):\n{reset_url}\n\n"
|
||||||
|
"Si no solicitaste este cambio, ignora este mensaje.\n\n"
|
||||||
|
"— ServiceManager"
|
||||||
|
)
|
||||||
|
|
||||||
|
return html, text
|
||||||
174
backend/app/core/file_handler.py
Normal file
174
backend/app/core/file_handler.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"""
|
||||||
|
File Handler - ServiceManagerWeb
|
||||||
|
Gestión simple de archivos adjuntos
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Tuple
|
||||||
|
from fastapi import UploadFile, HTTPException, status
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class FileHandler:
|
||||||
|
"""Handler simple para archivos adjuntos"""
|
||||||
|
|
||||||
|
_CHUNK_SIZE_BYTES = 1024 * 1024 # 1MB
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.upload_path = Path(settings.UPLOAD_PATH)
|
||||||
|
self.max_size_bytes = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
|
||||||
|
self.allowed_extensions = settings.ALLOWED_FILE_EXTENSIONS
|
||||||
|
# Crear directorio si no existe
|
||||||
|
self.upload_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def _validate_extension(self, filename: str) -> str:
|
||||||
|
"""Validar extensión del archivo y retornarla."""
|
||||||
|
extension = Path(filename).suffix.lower().lstrip('.')
|
||||||
|
|
||||||
|
if extension not in self.allowed_extensions:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Extensión no permitida: {extension}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return extension
|
||||||
|
|
||||||
|
def _validate_magic_bytes(self, extension: str, first_bytes: bytes) -> None:
|
||||||
|
"""Validación básica por firma (magic bytes) para tipos comunes."""
|
||||||
|
|
||||||
|
signatures = {
|
||||||
|
# PDFs start with %PDF-
|
||||||
|
"pdf": [b"%PDF-"],
|
||||||
|
# PNG signature
|
||||||
|
"png": [b"\x89PNG\r\n\x1a\n"],
|
||||||
|
# JPEG starts with FF D8 FF
|
||||||
|
"jpg": [b"\xff\xd8\xff"],
|
||||||
|
"jpeg": [b"\xff\xd8\xff"],
|
||||||
|
# Legacy MS Office (OLE Compound File)
|
||||||
|
"doc": [b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"],
|
||||||
|
"xls": [b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"],
|
||||||
|
# OOXML (zip-based)
|
||||||
|
"docx": [b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08"],
|
||||||
|
"xlsx": [b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# For plain text, we can't reliably validate via magic bytes.
|
||||||
|
if extension == "txt":
|
||||||
|
return
|
||||||
|
|
||||||
|
allowed = signatures.get(extension)
|
||||||
|
if not allowed:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not any(first_bytes.startswith(sig) for sig in allowed):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Contenido de archivo no coincide con la extensión declarada",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def save_upload(self, file: UploadFile, tenant_id: uuid.UUID, ticket_id: uuid.UUID) -> dict:
|
||||||
|
"""Guardar archivo y retornar metadata"""
|
||||||
|
if not file.filename:
|
||||||
|
raise HTTPException(status_code=400, detail="Filename requerido")
|
||||||
|
|
||||||
|
extension = self._validate_extension(file.filename)
|
||||||
|
|
||||||
|
# Nombre único
|
||||||
|
original_extension = Path(file.filename).suffix.lower()
|
||||||
|
safe_filename = f"{uuid.uuid4().hex}{original_extension}"
|
||||||
|
|
||||||
|
# Estructura: uploads/tenant_id/tickets/ticket_id/
|
||||||
|
file_directory = self.upload_path / str(tenant_id) / "tickets" / str(ticket_id)
|
||||||
|
file_directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
file_path = file_directory / safe_filename
|
||||||
|
relative_path = str(file_path.relative_to(self.upload_path))
|
||||||
|
|
||||||
|
# Guardar archivo (streaming) + checksums incrementales
|
||||||
|
md5 = hashlib.md5()
|
||||||
|
sha256 = hashlib.sha256()
|
||||||
|
file_size = 0
|
||||||
|
validated_magic = False
|
||||||
|
first_bytes: bytes = b""
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(file_path, "wb") as f:
|
||||||
|
while True:
|
||||||
|
chunk = await file.read(self._CHUNK_SIZE_BYTES)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not validated_magic:
|
||||||
|
first_bytes = chunk[:16]
|
||||||
|
self._validate_magic_bytes(extension, first_bytes)
|
||||||
|
validated_magic = True
|
||||||
|
|
||||||
|
file_size += len(chunk)
|
||||||
|
if file_size > self.max_size_bytes:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
detail=f"Archivo muy grande. Máximo: {settings.MAX_UPLOAD_SIZE_MB}MB",
|
||||||
|
)
|
||||||
|
|
||||||
|
md5.update(chunk)
|
||||||
|
sha256.update(chunk)
|
||||||
|
f.write(chunk)
|
||||||
|
|
||||||
|
if file_size == 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Archivo vacío",
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
# Eliminar archivo parcial si existe
|
||||||
|
try:
|
||||||
|
if file_path.exists():
|
||||||
|
file_path.unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
try:
|
||||||
|
if file_path.exists():
|
||||||
|
file_path.unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Error guardando archivo: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
import mimetypes
|
||||||
|
mime_type = mimetypes.guess_type(file.filename)[0] or "application/octet-stream"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"filename": safe_filename,
|
||||||
|
"original_filename": file.filename,
|
||||||
|
"file_path": relative_path,
|
||||||
|
"file_size": file_size,
|
||||||
|
"mime_type": mime_type,
|
||||||
|
"md5_hash": md5.hexdigest(),
|
||||||
|
"sha256_hash": sha256.hexdigest(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_file_path(self, relative_path: str) -> Path:
|
||||||
|
"""Obtener path absoluto del archivo"""
|
||||||
|
file_path = (self.upload_path / relative_path).resolve()
|
||||||
|
|
||||||
|
# Verificar que no escape del directorio de uploads
|
||||||
|
if not str(file_path).startswith(str(self.upload_path.resolve())):
|
||||||
|
raise HTTPException(status_code=403, detail="Acceso denegado")
|
||||||
|
|
||||||
|
if not file_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Archivo no encontrado")
|
||||||
|
|
||||||
|
return file_path
|
||||||
|
|
||||||
|
|
||||||
|
file_handler = FileHandler()
|
||||||
20
backend/app/core/limiter.py
Normal file
20
backend/app/core/limiter.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"""
|
||||||
|
Rate Limiter - ServiceManagerWeb
|
||||||
|
|
||||||
|
Configura slowapi con Redis como storage backend.
|
||||||
|
Respeta settings.RATE_LIMIT_ENABLED: si está desactivado usa memoria
|
||||||
|
y el limiter queda en modo noop (enabled=False).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from slowapi import Limiter
|
||||||
|
from slowapi.util import get_remote_address
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
limiter = Limiter(
|
||||||
|
key_func=get_remote_address,
|
||||||
|
storage_uri=settings.REDIS_URL if settings.RATE_LIMIT_ENABLED else "memory://",
|
||||||
|
enabled=settings.RATE_LIMIT_ENABLED,
|
||||||
|
)
|
||||||
@@ -13,6 +13,7 @@ import pyotp
|
|||||||
import secrets
|
import secrets
|
||||||
import base64
|
import base64
|
||||||
import struct
|
import struct
|
||||||
|
import uuid
|
||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
|
|
||||||
@@ -100,7 +101,8 @@ class SecurityUtils:
|
|||||||
"""
|
"""
|
||||||
to_encode = data.copy()
|
to_encode = data.copy()
|
||||||
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||||
to_encode.update({"exp": expire, "type": "refresh"})
|
# Add a unique identifier so refresh tokens are never deterministic.
|
||||||
|
to_encode.update({"exp": expire, "type": "refresh", "jti": str(uuid.uuid4())})
|
||||||
|
|
||||||
encoded_jwt = jwt.encode(
|
encoded_jwt = jwt.encode(
|
||||||
to_encode,
|
to_encode,
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from fastapi.middleware.gzip import GZipMiddleware
|
from fastapi.middleware.gzip import GZipMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from slowapi import _rate_limit_exceeded_handler
|
||||||
|
from slowapi.errors import RateLimitExceeded
|
||||||
|
from slowapi.middleware import SlowAPIMiddleware
|
||||||
import structlog
|
import structlog
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
@@ -21,11 +24,17 @@ from app.models.system import System
|
|||||||
from app.models.category import Category
|
from app.models.category import Category
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.ticket import Ticket
|
from app.models.ticket import Ticket
|
||||||
|
from app.models.comment import TicketComment
|
||||||
|
from app.models.attachment import TicketAttachment
|
||||||
|
from app.models.audit import AuditLog
|
||||||
|
from app.models.refresh_token import RefreshToken
|
||||||
|
|
||||||
from app.core.logging import setup_logging
|
from app.core.logging import setup_logging
|
||||||
from app.api.v1.router import api_router
|
from app.api.v1.router import api_router
|
||||||
from app.middleware.tenant import TenantMiddleware
|
from app.middleware.tenant import TenantMiddleware
|
||||||
from app.middleware.correlation_id import CorrelationIDMiddleware
|
from app.middleware.correlation_id import CorrelationIDMiddleware
|
||||||
|
from app.core.cache import cache
|
||||||
|
from app.core.limiter import limiter
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
setup_logging()
|
setup_logging()
|
||||||
@@ -38,6 +47,10 @@ async def lifespan(app: FastAPI):
|
|||||||
# Startup
|
# Startup
|
||||||
logger.info("Iniciando ServiceManagerWeb Backend", version=settings.API_VERSION)
|
logger.info("Iniciando ServiceManagerWeb Backend", version=settings.API_VERSION)
|
||||||
|
|
||||||
|
# Conectar a Redis cache
|
||||||
|
await cache.connect()
|
||||||
|
logger.info("Caché Redis conectado")
|
||||||
|
|
||||||
if settings.ENVIRONMENT == "development":
|
if settings.ENVIRONMENT == "development":
|
||||||
await create_tables()
|
await create_tables()
|
||||||
logger.info("Tablas de base de datos verificadas")
|
logger.info("Tablas de base de datos verificadas")
|
||||||
@@ -46,31 +59,58 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
# Shutdown
|
# Shutdown
|
||||||
logger.info("Cerrando ServiceManagerWeb Backend")
|
logger.info("Cerrando ServiceManagerWeb Backend")
|
||||||
|
await cache.disconnect()
|
||||||
|
logger.info("Caché Redis desconectado")
|
||||||
|
|
||||||
|
|
||||||
# Crear aplicación FastAPI
|
# Crear aplicación FastAPI
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="ServiceManagerWeb API",
|
title="ServiceManagerWeb API",
|
||||||
description="Mesa de Ayuda B2B multi-tenant para Aduanasoft",
|
description="Mesa de Ayuda B2B multi-tenant para Aduanasoft",
|
||||||
version=settings.API_VERSION,
|
version=settings.APP_VERSION,
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
docs_url=f"/{settings.API_VERSION}/docs" if settings.ENVIRONMENT == "development" else None,
|
docs_url=f"/{settings.API_VERSION}/docs" if settings.ENVIRONMENT == "development" else None,
|
||||||
redoc_url=f"/{settings.API_VERSION}/redoc" if settings.ENVIRONMENT == "development" else None,
|
redoc_url=f"/{settings.API_VERSION}/redoc" if settings.ENVIRONMENT == "development" else None,
|
||||||
openapi_url=f"/{settings.API_VERSION}/openapi.json"
|
openapi_url=f"/{settings.API_VERSION}/openapi.json"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# SlowAPI rate limiting
|
||||||
|
app.state.limiter = limiter
|
||||||
|
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||||
|
app.add_middleware(SlowAPIMiddleware)
|
||||||
|
|
||||||
# ===================================
|
# ===================================
|
||||||
# MIDDLEWARE
|
# MIDDLEWARE
|
||||||
# ===================================
|
# ===================================
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
cors_origins = settings.CORS_ORIGINS.split(",") if isinstance(settings.CORS_ORIGINS, str) else settings.CORS_ORIGINS
|
cors_origins = settings.CORS_ORIGINS.split(",") if isinstance(settings.CORS_ORIGINS, str) else settings.CORS_ORIGINS
|
||||||
|
|
||||||
|
if settings.is_production():
|
||||||
|
cors_allow_methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
||||||
|
cors_allow_headers = [
|
||||||
|
"Authorization",
|
||||||
|
"Content-Type",
|
||||||
|
"X-Tenant-ID",
|
||||||
|
"X-Tenant-Slug",
|
||||||
|
"X-Correlation-ID",
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
cors_allow_methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
||||||
|
cors_allow_headers = [
|
||||||
|
"Authorization",
|
||||||
|
"Content-Type",
|
||||||
|
"X-Tenant-ID",
|
||||||
|
"X-Tenant-Slug",
|
||||||
|
"X-Correlation-ID",
|
||||||
|
]
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=cors_origins,
|
allow_origins=cors_origins,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=cors_allow_methods,
|
||||||
allow_headers=["*"],
|
allow_headers=cors_allow_headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Compression
|
# Compression
|
||||||
@@ -159,7 +199,8 @@ async def health_check():
|
|||||||
return {
|
return {
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
"service": "ServiceManagerWeb API",
|
"service": "ServiceManagerWeb API",
|
||||||
"version": settings.API_VERSION,
|
"version": settings.APP_VERSION,
|
||||||
|
"api_version": settings.API_VERSION,
|
||||||
"environment": settings.ENVIRONMENT
|
"environment": settings.ENVIRONMENT
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +211,8 @@ async def root():
|
|||||||
"""Endpoint raíz con información básica."""
|
"""Endpoint raíz con información básica."""
|
||||||
return {
|
return {
|
||||||
"service": "ServiceManagerWeb API",
|
"service": "ServiceManagerWeb API",
|
||||||
"version": settings.API_VERSION,
|
"version": settings.APP_VERSION,
|
||||||
|
"api_version": settings.API_VERSION,
|
||||||
"docs": f"/{settings.API_VERSION}/docs",
|
"docs": f"/{settings.API_VERSION}/docs",
|
||||||
"environment": settings.ENVIRONMENT
|
"environment": settings.ENVIRONMENT
|
||||||
}
|
}
|
||||||
@@ -198,4 +240,4 @@ if __name__ == "__main__":
|
|||||||
host="0.0.0.0",
|
host="0.0.0.0",
|
||||||
port=8000,
|
port=8000,
|
||||||
reload=settings.ENVIRONMENT == "development"
|
reload=settings.ENVIRONMENT == "development"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,69 +4,176 @@ Tenant Middleware - ServiceManagerWeb
|
|||||||
Middleware para manejo de multi-tenancy
|
Middleware para manejo de multi-tenancy
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import Request, HTTPException, status
|
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from starlette.responses import Response
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response, JSONResponse
|
||||||
|
from sqlalchemy import select
|
||||||
import structlog
|
import structlog
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.core.database import AsyncSessionLocal, get_db
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.models.tenant import Tenant, TenantStatus
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
class TenantMiddleware(BaseHTTPMiddleware):
|
class TenantMiddleware(BaseHTTPMiddleware):
|
||||||
"""
|
"""
|
||||||
Middleware para extraer y validar información del tenant.
|
Middleware para extraer y validar información del tenant.
|
||||||
|
|
||||||
Extrae el tenant_id del header X-Tenant-ID y lo almacena
|
Extrae el tenant_id del header X-Tenant-ID o el slug del header
|
||||||
en el estado de la request para uso posterior.
|
X-Tenant-Slug, valida que exista en la base de datos y que esté
|
||||||
|
activo, y almacena el objeto Tenant en request.state.tenant.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Rutas que no requieren tenant
|
# Rutas que no requieren tenant
|
||||||
EXCLUDED_PATHS = {
|
EXCLUDED_PATHS = {
|
||||||
"/health",
|
"/health",
|
||||||
|
"/api/v1/health",
|
||||||
|
"/v1/health",
|
||||||
|
"/api/v1/health/detailed",
|
||||||
|
"/v1/health/detailed",
|
||||||
"/",
|
"/",
|
||||||
|
"/api/v1/auth/login",
|
||||||
"/v1/auth/login",
|
"/v1/auth/login",
|
||||||
|
"/api/v1/auth/refresh",
|
||||||
|
"/v1/auth/refresh",
|
||||||
|
"/api/v1/auth/logout",
|
||||||
|
"/v1/auth/logout",
|
||||||
|
"/api/v1/auth/me",
|
||||||
|
"/v1/auth/me",
|
||||||
|
"/api/v1/auth/forgot-password",
|
||||||
|
"/v1/auth/forgot-password",
|
||||||
|
"/api/v1/auth/reset-password",
|
||||||
|
"/v1/auth/reset-password",
|
||||||
"/docs",
|
"/docs",
|
||||||
|
"/api/v1/docs",
|
||||||
|
"/v1/docs",
|
||||||
"/openapi.json",
|
"/openapi.json",
|
||||||
"/redoc"
|
"/api/v1/openapi.json",
|
||||||
|
"/v1/openapi.json",
|
||||||
|
"/redoc",
|
||||||
|
"/api/v1/redoc",
|
||||||
|
"/v1/redoc",
|
||||||
}
|
}
|
||||||
|
|
||||||
async def dispatch(self, request: Request, call_next) -> Response:
|
async def dispatch(self, request: Request, call_next) -> Response:
|
||||||
"""Process request and add tenant information."""
|
"""Valida el tenant en cada request y lo almacena en request.state."""
|
||||||
|
|
||||||
# Skip tenant validation for excluded paths
|
# Inicializar state con valores por defecto
|
||||||
|
request.state.tenant = None
|
||||||
|
request.state.tenant_id = None
|
||||||
|
request.state.tenant_slug = None
|
||||||
|
|
||||||
|
# Saltar validación en rutas excluidas
|
||||||
if request.url.path in self.EXCLUDED_PATHS or request.url.path.startswith("/docs"):
|
if request.url.path in self.EXCLUDED_PATHS or request.url.path.startswith("/docs"):
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
# Extract tenant from header
|
# Extraer headers de tenant
|
||||||
tenant_id = request.headers.get("X-Tenant-ID")
|
tenant_id = request.headers.get("X-Tenant-ID")
|
||||||
tenant_slug = request.headers.get("X-Tenant-Slug")
|
tenant_slug = request.headers.get("X-Tenant-Slug")
|
||||||
|
|
||||||
# For now, we'll be more permissive in development
|
tenant_uuid: uuid.UUID | None = None
|
||||||
# In production, tenant should be strictly required
|
if tenant_id:
|
||||||
|
try:
|
||||||
|
tenant_uuid = uuid.UUID(tenant_id)
|
||||||
|
except ValueError:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content={"detail": "Invalid X-Tenant-ID header (must be UUID)"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Si no hay headers de tenant (requerido para aislamiento multi-tenant)
|
||||||
if not tenant_id and not tenant_slug:
|
if not tenant_id and not tenant_slug:
|
||||||
logger.warning(
|
return JSONResponse(
|
||||||
"Request without tenant information",
|
status_code=400,
|
||||||
path=request.url.path,
|
content={"detail": "Tenant information required (X-Tenant-ID or X-Tenant-Slug header)"},
|
||||||
method=request.method
|
|
||||||
)
|
)
|
||||||
# For now, continue without tenant for development
|
|
||||||
# raise HTTPException(
|
# Validar tenant contra la base de datos
|
||||||
# status_code=status.HTTP_400_BAD_REQUEST,
|
try:
|
||||||
# detail="Tenant information required (X-Tenant-ID or X-Tenant-Slug header)"
|
# Prefer DB session coming from dependency overrides (tests) when available.
|
||||||
# )
|
# Guard: in unit tests request.app may be a MagicMock, not a real FastAPI app.
|
||||||
|
dependency_overrides = getattr(request.app, "dependency_overrides", None)
|
||||||
# Store tenant info in request state
|
override_get_db = None
|
||||||
request.state.tenant_id = tenant_id
|
if isinstance(dependency_overrides, dict):
|
||||||
request.state.tenant_slug = tenant_slug
|
override_get_db = dependency_overrides.get(get_db)
|
||||||
|
|
||||||
# TODO: Validate tenant exists and is active
|
if override_get_db is not None:
|
||||||
# This would involve a database query which we'll implement later
|
agen = override_get_db()
|
||||||
|
session = await agen.__anext__()
|
||||||
logger.debug(
|
try:
|
||||||
"Tenant middleware processed",
|
if tenant_uuid is not None:
|
||||||
tenant_id=tenant_id,
|
result = await session.execute(
|
||||||
tenant_slug=tenant_slug,
|
select(Tenant).where(Tenant.id == tenant_uuid)
|
||||||
path=request.url.path
|
)
|
||||||
)
|
else:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Tenant).where(Tenant.slug == tenant_slug)
|
||||||
|
)
|
||||||
|
tenant = result.scalars().first()
|
||||||
|
finally:
|
||||||
|
await agen.aclose()
|
||||||
|
else:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
if tenant_uuid is not None:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Tenant).where(Tenant.id == tenant_uuid)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Tenant).where(Tenant.slug == tenant_slug)
|
||||||
|
)
|
||||||
|
tenant = result.scalars().first()
|
||||||
|
|
||||||
|
if tenant is None:
|
||||||
|
logger.warning(
|
||||||
|
"Tenant not found",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
tenant_slug=tenant_slug,
|
||||||
|
path=request.url.path,
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=404,
|
||||||
|
content={"detail": "Tenant not found"}
|
||||||
|
)
|
||||||
|
|
||||||
|
if tenant.status != TenantStatus.ACTIVE:
|
||||||
|
logger.warning(
|
||||||
|
"Tenant is not active",
|
||||||
|
tenant_id=str(tenant.id),
|
||||||
|
tenant_slug=tenant.slug,
|
||||||
|
status=tenant.status,
|
||||||
|
path=request.url.path,
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=403,
|
||||||
|
content={"detail": f"Tenant is {tenant.status.value}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Almacenar tenant validado en el state
|
||||||
|
request.state.tenant = tenant
|
||||||
|
request.state.tenant_id = str(tenant.id)
|
||||||
|
request.state.tenant_slug = tenant.slug
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Tenant validated",
|
||||||
|
tenant_id=str(tenant.id),
|
||||||
|
tenant_slug=tenant.slug,
|
||||||
|
path=request.url.path,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"Error validating tenant",
|
||||||
|
error=str(exc),
|
||||||
|
path=request.url.path,
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=503,
|
||||||
|
content={"detail": "Service temporarily unavailable"}
|
||||||
|
)
|
||||||
|
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
25
backend/app/models/__init__.py
Normal file
25
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""Models package initialization."""
|
||||||
|
|
||||||
|
from .user import User
|
||||||
|
from .tenant import Tenant
|
||||||
|
from .ticket import Ticket
|
||||||
|
from .comment import TicketComment
|
||||||
|
from .system import System
|
||||||
|
from .category import Category
|
||||||
|
from .client_profile import ClientProfile
|
||||||
|
from .attachment import TicketAttachment
|
||||||
|
from .audit import AuditLog
|
||||||
|
from .refresh_token import RefreshToken
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"User",
|
||||||
|
"Tenant",
|
||||||
|
"Ticket",
|
||||||
|
"TicketComment",
|
||||||
|
"System",
|
||||||
|
"Category",
|
||||||
|
"ClientProfile",
|
||||||
|
"TicketAttachment",
|
||||||
|
"AuditLog",
|
||||||
|
"RefreshToken"
|
||||||
|
]
|
||||||
61
backend/app/models/attachment.py
Normal file
61
backend/app/models/attachment.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""
|
||||||
|
Attachment Model - ServiceManagerWeb
|
||||||
|
"""
|
||||||
|
from sqlalchemy import String, ForeignKey, Integer, DateTime, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from typing import Optional, TYPE_CHECKING
|
||||||
|
from datetime import datetime
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.core.database import Base, GUID
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.ticket import Ticket
|
||||||
|
from app.models.comment import TicketComment
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class TicketAttachment(Base):
|
||||||
|
"""Modelo de archivos adjuntos en tickets"""
|
||||||
|
__tablename__ = "ticket_attachments"
|
||||||
|
|
||||||
|
# Sobrescribir campos heredados de Base para que coincidan con la tabla real
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
# Esta tabla NO tiene updated_at, así que lo excluimos del mapping
|
||||||
|
|
||||||
|
ticket_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
GUID(),
|
||||||
|
ForeignKey("tickets.id", ondelete="CASCADE"),
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
comment_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
|
GUID(),
|
||||||
|
ForeignKey("ticket_comments.id", ondelete="CASCADE"),
|
||||||
|
nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
uploaded_by: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
GUID(),
|
||||||
|
ForeignKey("users.id"),
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
filename: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
original_filename: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
mime_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
file_size: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
file_path: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
|
|
||||||
|
md5_hash: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
|
||||||
|
sha256_hash: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||||
|
|
||||||
|
ticket: Mapped["Ticket"] = relationship("Ticket", back_populates="attachments")
|
||||||
|
comment: Mapped[Optional["TicketComment"]] = relationship("TicketComment", back_populates="attachments")
|
||||||
|
uploaded_by_user: Mapped["User"] = relationship("User")
|
||||||
|
|
||||||
|
# Excluir updated_at del mapping ya que la tabla no lo tiene
|
||||||
|
__mapper_args__ = {
|
||||||
|
"exclude_properties": ["updated_at"]
|
||||||
|
}
|
||||||
174
backend/app/models/audit.py
Normal file
174
backend/app/models/audit.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"""
|
||||||
|
Audit Log Model - ServiceManagerWeb
|
||||||
|
|
||||||
|
Modelo para bitácora de auditoría y compliance.
|
||||||
|
Registra todas las acciones importantes del sistema.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import String, Text, DateTime, ForeignKey, Index, JSON
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from sqlalchemy.dialects.postgresql import INET, JSONB
|
||||||
|
from typing import Optional, Dict, Any, TYPE_CHECKING
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from app.core.database import Base, GUID
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.tenant import Tenant
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(Base):
|
||||||
|
"""
|
||||||
|
Bitácora de auditoría para tracking completo de acciones.
|
||||||
|
|
||||||
|
Registra:
|
||||||
|
- Quién hizo la acción (user_id)
|
||||||
|
- Qué hizo (action)
|
||||||
|
- Sobre qué recurso (resource_type + resource_id)
|
||||||
|
- Cuándo lo hizo (created_at)
|
||||||
|
- Desde dónde (ip_address, user_agent)
|
||||||
|
- Qué cambió (old_values, new_values)
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "audit_logs"
|
||||||
|
|
||||||
|
# Multi-tenancy: cada registro pertenece a un tenant específico
|
||||||
|
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
GUID(),
|
||||||
|
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Usuario que ejecutó la acción (NULL = acción del sistema)
|
||||||
|
user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
|
GUID(),
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Acción realizada en formato "recurso.verbo"
|
||||||
|
# Ejemplos: "user.login", "ticket.create", "ticket.assign"
|
||||||
|
action: Mapped[str] = mapped_column(
|
||||||
|
String(100),
|
||||||
|
nullable=False,
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tipo de recurso afectado (user, ticket, comment, category, etc.)
|
||||||
|
resource_type: Mapped[str] = mapped_column(
|
||||||
|
String(50),
|
||||||
|
nullable=False,
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# ID del recurso afectado
|
||||||
|
resource_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
|
GUID(),
|
||||||
|
nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Contexto de la request: IP y navegador del usuario
|
||||||
|
ip_address: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(45).with_variant(INET, "postgresql"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Correlation ID para rastrear todas las requests relacionadas
|
||||||
|
# en una misma operación o sesión
|
||||||
|
correlation_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
|
GUID(),
|
||||||
|
nullable=True,
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Estado del recurso antes del cambio (para auditoría de cambios)
|
||||||
|
old_values: Mapped[Optional[Dict[str, Any]]] = mapped_column(
|
||||||
|
JSON().with_variant(JSONB, "postgresql"),
|
||||||
|
nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Estado del recurso después del cambio (para auditoría de cambios)
|
||||||
|
new_values: Mapped[Optional[Dict[str, Any]]] = mapped_column(
|
||||||
|
JSON().with_variant(JSONB, "postgresql"),
|
||||||
|
nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Metadata adicional con cualquier información relevante del contexto
|
||||||
|
# Nota: 'metadata' está reservado en SQLAlchemy, se usa 'extra_metadata'
|
||||||
|
# como nombre del atributo Python, pero la columna en BD se llama 'metadata'
|
||||||
|
extra_metadata: Mapped[Optional[Dict[str, Any]]] = mapped_column(
|
||||||
|
'metadata',
|
||||||
|
JSON().with_variant(JSONB, "postgresql"),
|
||||||
|
nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timestamp de creación con timezone
|
||||||
|
# CORRECCIÓN: default=lambda: datetime.now(timezone.utc) genera un
|
||||||
|
# datetime aware en UTC, compatible con DateTime(timezone=True).
|
||||||
|
# El default anterior (datetime.utcnow) generaba datetimes naive,
|
||||||
|
# causando que los filtros de fecha fallaran silenciosamente porque
|
||||||
|
# SQLAlchemy no podía comparar aware vs naive correctamente.
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relaciones con otros modelos
|
||||||
|
tenant: Mapped["Tenant"] = relationship("Tenant", foreign_keys=[tenant_id])
|
||||||
|
user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[user_id])
|
||||||
|
|
||||||
|
# Índices compuestos para optimizar las queries más frecuentes
|
||||||
|
__table_args__ = (
|
||||||
|
# Filtrar logs por tenant y tipo de acción (uso más común)
|
||||||
|
Index('idx_audit_logs_tenant_action', 'tenant_id', 'action'),
|
||||||
|
# Buscar el historial de un recurso específico
|
||||||
|
Index('idx_audit_logs_resource', 'resource_type', 'resource_id'),
|
||||||
|
# Ver la actividad de un usuario ordenada por fecha
|
||||||
|
Index('idx_audit_logs_user_created', 'user_id', 'created_at'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Los audit logs son inmutables: nunca se actualizan, solo se crean
|
||||||
|
# Por eso se excluye updated_at del mapper
|
||||||
|
__mapper_args__ = {
|
||||||
|
"exclude_properties": ["updated_at"]
|
||||||
|
}
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return (
|
||||||
|
f"<AuditLog("
|
||||||
|
f"action='{self.action}', "
|
||||||
|
f"resource='{self.resource_type}:{self.resource_id}'"
|
||||||
|
f")>"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def action_display(self) -> str:
|
||||||
|
"""
|
||||||
|
Formato legible de la acción para mostrar en la interfaz.
|
||||||
|
|
||||||
|
Convierte el formato interno "recurso.verbo" a texto descriptivo.
|
||||||
|
Ejemplo: "ticket.create" → "creó ticket"
|
||||||
|
"""
|
||||||
|
parts = self.action.split('.')
|
||||||
|
if len(parts) == 2:
|
||||||
|
resource, verb = parts
|
||||||
|
verb_map = {
|
||||||
|
'create': 'creó',
|
||||||
|
'update': 'actualizó',
|
||||||
|
'delete': 'eliminó',
|
||||||
|
'login': 'inició sesión',
|
||||||
|
'logout': 'cerró sesión',
|
||||||
|
'login_failed': 'intentó iniciar sesión',
|
||||||
|
'assign': 'asignó',
|
||||||
|
'close': 'cerró',
|
||||||
|
'reopen': 'reabrió'
|
||||||
|
}
|
||||||
|
return f"{verb_map.get(verb, verb)} {resource}"
|
||||||
|
return self.action
|
||||||
@@ -1,28 +1,49 @@
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
Category Model - ServiceManagerWeb
|
Category Model - ServiceManagerWeb
|
||||||
|
Categorías de tickets por tenant
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import String, Text, Boolean, ForeignKey
|
from sqlalchemy import String, Text, Boolean, Integer, ForeignKey, UniqueConstraint
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base, GUID
|
||||||
|
|
||||||
class Category(Base):
|
class Category(Base):
|
||||||
__tablename__ = "categories"
|
"""Modelo de categorías de tickets (ticket_categories en BD)"""
|
||||||
|
__tablename__ = "ticket_categories" # ✅ CORREGIDO: nombre correcto de tabla
|
||||||
|
|
||||||
|
# Campos básicos
|
||||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
description: Mapped[Optional[str]] = mapped_column(Text)
|
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
# Optional: Tenant specific categories?
|
# ✅ CORREGIDO: tenant_id es obligatorio para multi-tenancy
|
||||||
tenant_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True)
|
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
GUID(),
|
||||||
|
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||||
|
nullable=False # ✅ Obligatorio
|
||||||
|
)
|
||||||
|
|
||||||
|
# ✅ AÑADIDOS: Campos de SLA según schema.sql
|
||||||
|
color: Mapped[Optional[str]] = mapped_column(String(7), nullable=True)
|
||||||
|
sla_response_hours: Mapped[int] = mapped_column(Integer, default=24, nullable=False)
|
||||||
|
sla_resolution_hours: Mapped[int] = mapped_column(Integer, default=72, nullable=False)
|
||||||
|
auto_assign_to: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
|
GUID(),
|
||||||
|
ForeignKey("users.id"),
|
||||||
|
nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
tickets: Mapped[List["Ticket"]] = relationship("Ticket", back_populates="category")
|
tickets: Mapped[List["Ticket"]] = relationship("Ticket", back_populates="category")
|
||||||
tenant: Mapped["Tenant"] = relationship("Tenant") # Assuming Tenant model is imported
|
tenant: Mapped["Tenant"] = relationship("Tenant")
|
||||||
|
auto_assign_user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[auto_assign_to])
|
||||||
|
|
||||||
|
# ✅ AÑADIDO: Constraint único por tenant (no puede haber categorías duplicadas en el mismo tenant)
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('tenant_id', 'name', name='uq_ticket_categories_tenant_name'),
|
||||||
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"<Category(id={self.id}, name='{self.name}')>"
|
return f"<Category(id={self.id}, name='{self.name}', tenant_id={self.tenant_id})>"
|
||||||
122
backend/app/models/client_profile.py
Normal file
122
backend/app/models/client_profile.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"""
|
||||||
|
Client Profile Model - ServiceManagerWeb
|
||||||
|
|
||||||
|
Modelo para perfil empresarial de clientes
|
||||||
|
Almacena información detallada de la empresa cliente
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Text, Numeric
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from typing import Optional, TYPE_CHECKING
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.core.database import Base, GUID
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.tenant import Tenant
|
||||||
|
|
||||||
|
|
||||||
|
class ClientProfile(Base):
|
||||||
|
"""Modelo de Perfil de Cliente Empresarial."""
|
||||||
|
|
||||||
|
__tablename__ = "client_profiles"
|
||||||
|
|
||||||
|
# Relación con tenant (uno a uno)
|
||||||
|
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
GUID(),
|
||||||
|
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||||
|
unique=True,
|
||||||
|
nullable=False,
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# === INFORMACIÓN GENERAL ===
|
||||||
|
business_name: Mapped[Optional[str]] = mapped_column(String(255)) # Razón social
|
||||||
|
commercial_name: Mapped[Optional[str]] = mapped_column(String(255)) # Nombre comercial
|
||||||
|
client_code: Mapped[Optional[str]] = mapped_column(String(50)) # Clave de cliente
|
||||||
|
client_type: Mapped[Optional[str]] = mapped_column(String(50)) # Tipo de cliente
|
||||||
|
rfc: Mapped[Optional[str]] = mapped_column(String(13)) # RFC México
|
||||||
|
tax_id: Mapped[Optional[str]] = mapped_column(String(50)) # ID fiscal general
|
||||||
|
|
||||||
|
# === UBICACIÓN ===
|
||||||
|
country: Mapped[Optional[str]] = mapped_column(String(100))
|
||||||
|
state: Mapped[Optional[str]] = mapped_column(String(100))
|
||||||
|
city: Mapped[Optional[str]] = mapped_column(String(100))
|
||||||
|
address: Mapped[Optional[str]] = mapped_column(Text)
|
||||||
|
external_number: Mapped[Optional[str]] = mapped_column(String(20))
|
||||||
|
internal_number: Mapped[Optional[str]] = mapped_column(String(20))
|
||||||
|
postal_code: Mapped[Optional[str]] = mapped_column(String(10))
|
||||||
|
neighborhood: Mapped[Optional[str]] = mapped_column(String(100))
|
||||||
|
|
||||||
|
# === CONTACTO ===
|
||||||
|
main_phone: Mapped[Optional[str]] = mapped_column(String(20))
|
||||||
|
secondary_phone: Mapped[Optional[str]] = mapped_column(String(20))
|
||||||
|
direct_phone: Mapped[Optional[str]] = mapped_column(String(20))
|
||||||
|
phone_extension: Mapped[Optional[str]] = mapped_column(String(10))
|
||||||
|
fax: Mapped[Optional[str]] = mapped_column(String(20))
|
||||||
|
|
||||||
|
# === INFORMACIÓN ADICIONAL ===
|
||||||
|
business_hours: Mapped[Optional[str]] = mapped_column(String(255))
|
||||||
|
website: Mapped[Optional[str]] = mapped_column(String(255))
|
||||||
|
main_email: Mapped[Optional[str]] = mapped_column(String(320))
|
||||||
|
billing_email: Mapped[Optional[str]] = mapped_column(String(320))
|
||||||
|
|
||||||
|
# === MARKETING ===
|
||||||
|
advertising_medium: Mapped[Optional[str]] = mapped_column(String(255))
|
||||||
|
nationality: Mapped[Optional[str]] = mapped_column(String(100))
|
||||||
|
|
||||||
|
# === CONFIGURACIÓN EMPRESARIAL ===
|
||||||
|
logo_url: Mapped[Optional[str]] = mapped_column(String(500))
|
||||||
|
company_representative: Mapped[Optional[str]] = mapped_column(String(255)) # Encargado/Representante
|
||||||
|
legal_representative: Mapped[Optional[str]] = mapped_column(String(255))
|
||||||
|
|
||||||
|
# === FINANZAS/FACTURACIÓN ===
|
||||||
|
credit_limit: Mapped[Optional[float]] = mapped_column(Numeric(15, 2))
|
||||||
|
payment_terms: Mapped[Optional[str]] = mapped_column(String(100))
|
||||||
|
preferred_currency: Mapped[str] = mapped_column(String(3), default="MXN")
|
||||||
|
|
||||||
|
# === METADATOS ===
|
||||||
|
send_to_billing: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
is_active_client: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
is_prospect: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
notes: Mapped[Optional[str]] = mapped_column(Text)
|
||||||
|
|
||||||
|
# === RELACIONES ===
|
||||||
|
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="client_profile")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<ClientProfile(tenant_id={self.tenant_id}, business_name='{self.business_name}')>"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def full_address(self) -> str:
|
||||||
|
"""Dirección completa formateada."""
|
||||||
|
address_parts = []
|
||||||
|
|
||||||
|
if self.address:
|
||||||
|
address_parts.append(self.address)
|
||||||
|
|
||||||
|
if self.external_number:
|
||||||
|
if self.internal_number:
|
||||||
|
address_parts.append(f"#{self.external_number}-{self.internal_number}")
|
||||||
|
else:
|
||||||
|
address_parts.append(f"#{self.external_number}")
|
||||||
|
|
||||||
|
if self.neighborhood:
|
||||||
|
address_parts.append(f"Col. {self.neighborhood}")
|
||||||
|
|
||||||
|
if self.city and self.state:
|
||||||
|
address_parts.append(f"{self.city}, {self.state}")
|
||||||
|
|
||||||
|
if self.postal_code:
|
||||||
|
address_parts.append(f"C.P. {self.postal_code}")
|
||||||
|
|
||||||
|
if self.country:
|
||||||
|
address_parts.append(self.country)
|
||||||
|
|
||||||
|
return ", ".join(address_parts)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_name(self) -> str:
|
||||||
|
"""Nombre para mostrar (comercial o razón social)."""
|
||||||
|
return self.commercial_name or self.business_name or "Sin nombre"
|
||||||
73
backend/app/models/comment.py
Normal file
73
backend/app/models/comment.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"""
|
||||||
|
Comment Model - ServiceManagerWeb
|
||||||
|
|
||||||
|
Modelo para comentarios en tickets
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Column, String, Text, Boolean, ForeignKey, DateTime
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from datetime import datetime
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.core.database import Base, GUID
|
||||||
|
|
||||||
|
|
||||||
|
class TicketComment(Base):
|
||||||
|
"""Comentarios en tickets."""
|
||||||
|
|
||||||
|
__tablename__ = "ticket_comments"
|
||||||
|
|
||||||
|
# Columnas
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
GUID(),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4
|
||||||
|
)
|
||||||
|
|
||||||
|
ticket_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
GUID(),
|
||||||
|
ForeignKey("tickets.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
author_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
GUID(),
|
||||||
|
ForeignKey("users.id"),
|
||||||
|
nullable=False,
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
|
||||||
|
is_internal: Mapped[bool] = mapped_column(
|
||||||
|
Boolean,
|
||||||
|
default=False,
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=datetime.utcnow,
|
||||||
|
nullable=False,
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=datetime.utcnow,
|
||||||
|
onupdate=datetime.utcnow,
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
ticket: Mapped["Ticket"] = relationship("Ticket", back_populates="comments")
|
||||||
|
author: Mapped["User"] = relationship("User")
|
||||||
|
attachments: Mapped[list["TicketAttachment"]] = relationship(
|
||||||
|
"TicketAttachment",
|
||||||
|
back_populates="comment",
|
||||||
|
cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<TicketComment {self.id} by {self.author_id}>"
|
||||||
170
backend/app/models/refresh_token.py
Normal file
170
backend/app/models/refresh_token.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
"""
|
||||||
|
Refresh Token Model - ServiceManagerWeb
|
||||||
|
|
||||||
|
Modelo para persistencia de refresh tokens con revocaci├│n y tracking.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Index, Integer
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from typing import Optional, TYPE_CHECKING
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from app.core.database import Base, GUID
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshToken(Base):
|
||||||
|
"""
|
||||||
|
Refresh Token persistente para gesti├│n de sesiones.
|
||||||
|
|
||||||
|
Almacena refresh tokens con informaci├│n de dispositivo y permite
|
||||||
|
revocaci├│n para mejorar la seguridad.
|
||||||
|
|
||||||
|
Características:
|
||||||
|
- Token hasheado (no se guarda en texto plano)
|
||||||
|
- Device fingerprinting
|
||||||
|
- Revocaci├│n individual con tracking
|
||||||
|
- Auto-expiraci├│n
|
||||||
|
- Tracking de IP y uso
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "refresh_tokens"
|
||||||
|
|
||||||
|
# User relationship
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
GUID(),
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Token JWT (almacenado directamente - firmado y verificable)
|
||||||
|
# VARCHAR(500) para acomodar JWTs con payload extenso
|
||||||
|
token: Mapped[str] = mapped_column(
|
||||||
|
String(500),
|
||||||
|
nullable=False,
|
||||||
|
unique=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Device information
|
||||||
|
device_id: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(100),
|
||||||
|
nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
device_name: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(200),
|
||||||
|
nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
user_agent: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(500),
|
||||||
|
nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# IP address del cliente (varchar(45) para IPv6)
|
||||||
|
ip_address: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(45),
|
||||||
|
nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Expiraci├│n del token
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Estado de revocaci├│n
|
||||||
|
revoked: Mapped[bool] = mapped_column(
|
||||||
|
Boolean,
|
||||||
|
default=False,
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
revoked_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
revoked_by: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
|
GUID(),
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tracking de uso
|
||||||
|
last_used_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
usage_count: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
default=0,
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=datetime.utcnow,
|
||||||
|
nullable=False,
|
||||||
|
server_default="NOW()"
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=datetime.utcnow,
|
||||||
|
onupdate=datetime.utcnow,
|
||||||
|
nullable=False,
|
||||||
|
server_default="NOW()"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relaci├│n con usuario
|
||||||
|
user: Mapped["User"] = relationship("User", foreign_keys=[user_id], back_populates="refresh_tokens")
|
||||||
|
revoker: Mapped[Optional["User"]] = relationship("User", foreign_keys=[revoked_by])
|
||||||
|
|
||||||
|
# Índices compuestos
|
||||||
|
__table_args__ = (
|
||||||
|
Index('idx_refresh_tokens_user_expires', 'user_id', 'expires_at'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<RefreshToken(user_id='{self.user_id}', revoked={self.revoked}, expires={self.expires_at})>"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_valid(self) -> bool:
|
||||||
|
"""
|
||||||
|
Verificar si el token es válido.
|
||||||
|
|
||||||
|
Un token es válido si:
|
||||||
|
- No está revocado
|
||||||
|
- No ha expirado
|
||||||
|
"""
|
||||||
|
return not self.revoked and self.expires_at > datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expired(self) -> bool:
|
||||||
|
"""Verificar si el token ha expirado."""
|
||||||
|
return datetime.now(timezone.utc) >= self.expires_at
|
||||||
|
|
||||||
|
def revoke(self, revoked_by: Optional[uuid.UUID] = None) -> None:
|
||||||
|
"""
|
||||||
|
Marcar el token como revocado.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
revoked_by: ID del usuario que revoc├│ el token
|
||||||
|
"""
|
||||||
|
self.revoked = True
|
||||||
|
self.revoked_at = datetime.now(timezone.utc)
|
||||||
|
if revoked_by:
|
||||||
|
self.revoked_by = revoked_by
|
||||||
|
|
||||||
|
def track_usage(self) -> None:
|
||||||
|
"""Registrar uso del token."""
|
||||||
|
self.last_used_at = datetime.now(timezone.utc)
|
||||||
|
self.usage_count += 1
|
||||||
0
backend/app/models/relationships.py
Normal file
0
backend/app/models/relationships.py
Normal file
63
backend/app/models/roles.py
Normal file
63
backend/app/models/roles.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""
|
||||||
|
Definición y helpers de roles para el sistema multi-tenant.
|
||||||
|
|
||||||
|
Fuente única: UserRole en app.models.user.
|
||||||
|
Este módulo expone conjuntos de roles y helpers de verificación
|
||||||
|
para usarse en deps.py y en los endpoints.
|
||||||
|
|
||||||
|
Roles globales (staff interno — alcance multi-tenant):
|
||||||
|
ADMIN → control total sobre todos los tenants
|
||||||
|
SUPPORT_MANAGER → gestiona equipos y SLAs de todos los tenants
|
||||||
|
AGENT → atiende tickets de cualquier tenant
|
||||||
|
AUDITOR → auditoría de solo lectura en todos los tenants
|
||||||
|
|
||||||
|
Roles de cliente (alcance limitado al propio tenant):
|
||||||
|
CLIENT_ADMIN → administra organización: usuarios, configuración, tickets
|
||||||
|
CLIENT_USER → crea y sigue sus propios tickets
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.models.user import UserRole
|
||||||
|
|
||||||
|
# ── Conjuntos de roles ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
GLOBAL_ROLES: frozenset[UserRole] = frozenset({
|
||||||
|
UserRole.ADMIN,
|
||||||
|
UserRole.SUPPORT_MANAGER,
|
||||||
|
UserRole.AGENT,
|
||||||
|
UserRole.AUDITOR,
|
||||||
|
})
|
||||||
|
|
||||||
|
CLIENT_ROLES: frozenset[UserRole] = frozenset({
|
||||||
|
UserRole.CLIENT_ADMIN,
|
||||||
|
UserRole.CLIENT_USER,
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── Permisos por rol ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ROLE_PERMISSIONS: dict[UserRole, list[str]] = {
|
||||||
|
# Staff global
|
||||||
|
UserRole.ADMIN: ["manage_all", "view_all", "audit_all"],
|
||||||
|
UserRole.SUPPORT_MANAGER: ["manage_teams", "view_all_tickets", "manage_sla"],
|
||||||
|
UserRole.AGENT: ["view_all_tickets", "update_any_ticket"],
|
||||||
|
UserRole.AUDITOR: ["view_all", "audit_all"],
|
||||||
|
# Clientes (acotados al tenant)
|
||||||
|
UserRole.CLIENT_ADMIN: ["manage_tenant", "manage_tenant_users", "view_tenant_tickets"],
|
||||||
|
UserRole.CLIENT_USER: ["create_ticket", "view_own_tickets"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def is_global_staff(role: UserRole) -> bool:
|
||||||
|
"""Retorna True si el rol tiene alcance global (staff interno)."""
|
||||||
|
return role.is_global
|
||||||
|
|
||||||
|
|
||||||
|
def is_client_role(role: UserRole) -> bool:
|
||||||
|
"""Retorna True si el rol está acotado al tenant del usuario."""
|
||||||
|
return role.is_client
|
||||||
|
|
||||||
|
|
||||||
|
def has_permission(role: UserRole, permission: str) -> bool:
|
||||||
|
"""Verifica si un rol tiene un permiso específico."""
|
||||||
|
return permission in ROLE_PERMISSIONS.get(role, [])
|
||||||
|
|
||||||
@@ -1,25 +1,42 @@
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
System Model - ServiceManagerWeb
|
System Model - ServiceManagerWeb
|
||||||
|
Sistemas afectados por tenant
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import String, Text, Boolean
|
from sqlalchemy import String, Text, Boolean, ForeignKey, UniqueConstraint
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base, GUID
|
||||||
|
|
||||||
class System(Base):
|
class System(Base):
|
||||||
__tablename__ = "systems"
|
"""Modelo de sistemas afectados (affected_systems en BD)"""
|
||||||
|
__tablename__ = "affected_systems" # ✅ CORREGIDO: nombre correcto de tabla
|
||||||
|
|
||||||
|
# Campos básicos
|
||||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
description: Mapped[Optional[str]] = mapped_column(Text)
|
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
# ✅ AÑADIDO: tenant_id obligatorio para multi-tenancy (faltaba completamente)
|
||||||
|
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
GUID(),
|
||||||
|
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
# If we want tickets to link to systems, we will add relationship in Ticket later or now.
|
# ✅ ACTUALIZADO: nombre de relación a affected_system
|
||||||
# We will assume Ticket links to System.
|
tickets: Mapped[List["Ticket"]] = relationship(
|
||||||
tickets: Mapped[List["Ticket"]] = relationship("Ticket", back_populates="system")
|
"Ticket",
|
||||||
|
back_populates="affected_system" # ✅ Nombre actualizado
|
||||||
|
)
|
||||||
|
tenant: Mapped["Tenant"] = relationship("Tenant")
|
||||||
|
|
||||||
|
# ✅ AÑADIDO: Constraint único por tenant (no puede haber sistemas duplicados en el mismo tenant)
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('tenant_id', 'name', name='uq_affected_systems_tenant_name'),
|
||||||
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"<System(id={self.id}, name='{self.name}')>"
|
return f"<System(id={self.id}, name='{self.name}', tenant_id={self.tenant_id})>"
|
||||||
@@ -1,29 +1,24 @@
|
|||||||
"""
|
"""
|
||||||
Tenant Model - ServiceManagerWeb
|
Tenant Model - ServiceManagerWeb
|
||||||
|
|
||||||
Modelo para organizaciones cliente (multi-tenancy)
|
Modelo para organizaciones cliente (multi-tenancy)
|
||||||
"""
|
"""
|
||||||
|
from sqlalchemy import String, Integer, Text, Boolean, JSON
|
||||||
from sqlalchemy import String, Integer, Text, Boolean, ARRAY
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.dialects.postgresql import UUID, ENUM
|
from sqlalchemy.dialects.postgresql import UUID, ENUM, ARRAY as PG_ARRAY
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
import enum
|
import enum
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
class TenantStatus(str, enum.Enum):
|
class TenantStatus(str, enum.Enum):
|
||||||
"""Estados de un tenant."""
|
"""Estados de un tenant."""
|
||||||
ACTIVE = "active"
|
ACTIVE = "active"
|
||||||
SUSPENDED = "suspended"
|
SUSPENDED = "suspended"
|
||||||
INACTIVE = "inactive"
|
INACTIVE = "inactive"
|
||||||
|
|
||||||
|
|
||||||
class Tenant(Base):
|
class Tenant(Base):
|
||||||
"""Modelo de Tenant (Organización cliente)."""
|
"""Modelo de Tenant (Organización cliente)."""
|
||||||
|
|
||||||
__tablename__ = "tenants"
|
__tablename__ = "tenants"
|
||||||
|
|
||||||
# Información básica
|
# Información básica
|
||||||
@@ -45,24 +40,21 @@ class Tenant(Base):
|
|||||||
max_users: Mapped[int] = mapped_column(Integer, default=50)
|
max_users: Mapped[int] = mapped_column(Integer, default=50)
|
||||||
max_storage_mb: Mapped[int] = mapped_column(Integer, default=1024)
|
max_storage_mb: Mapped[int] = mapped_column(Integer, default=1024)
|
||||||
allowed_file_types: Mapped[List[str]] = mapped_column(
|
allowed_file_types: Mapped[List[str]] = mapped_column(
|
||||||
ARRAY(String),
|
JSON().with_variant(PG_ARRAY(String), "postgresql"),
|
||||||
default=["pdf", "jpg", "jpeg", "png", "doc", "docx", "xls", "xlsx", "txt"]
|
default=["pdf", "jpg", "jpeg", "png", "doc", "docx", "xls", "xlsx", "txt"]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Estado
|
# Estado
|
||||||
status: Mapped[TenantStatus] = mapped_column(
|
status: Mapped[TenantStatus] = mapped_column(
|
||||||
String(20),
|
String(20),
|
||||||
default=TenantStatus.ACTIVE
|
default=TenantStatus.ACTIVE
|
||||||
)
|
)
|
||||||
|
|
||||||
# Relaciones
|
# Relaciones
|
||||||
users: Mapped[List["User"]] = relationship("User", back_populates="tenant")
|
users: Mapped[List["User"]] = relationship("User", back_populates="tenant")
|
||||||
tickets: Mapped[List["Ticket"]] = relationship("Ticket", back_populates="tenant")
|
tickets: Mapped[List["Ticket"]] = relationship("Ticket", back_populates="tenant")
|
||||||
|
categories: Mapped[List["Category"]] = relationship("Category", back_populates="tenant") # ✅ CORREGIDO: Era "TicketCategory"
|
||||||
|
client_profile: Mapped[Optional["ClientProfile"]] = relationship("ClientProfile", back_populates="tenant", uselist=False)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"<Tenant(id={self.id}, name='{self.name}', slug='{self.slug}')>"
|
return f"<Tenant(id={self.id}, name='{self.name}', slug='{self.slug}')>"
|
||||||
|
|
||||||
@property
|
|
||||||
def is_active(self) -> bool:
|
|
||||||
"""Check if tenant is active."""
|
|
||||||
return self.status == TenantStatus.ACTIVE
|
|
||||||
@@ -1,56 +1,131 @@
|
|||||||
"""
|
"""
|
||||||
Ticket Model - ServiceManagerWeb
|
Ticket Model - ServiceManagerWeb
|
||||||
|
Tickets de soporte - Core del negocio
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import String, ForeignKey, Text
|
from sqlalchemy import String, ForeignKey, Text, Integer, CheckConstraint, UniqueConstraint, Enum as SAEnum
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship, synonym
|
||||||
from sqlalchemy.dialects.postgresql import UUID, ENUM
|
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
import enum
|
import enum
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base, GUID
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_fallback_ticket_number() -> str:
|
||||||
|
# Matches helper format "TK-000001" and stays within VARCHAR(20)
|
||||||
|
return f"TK-{(uuid.uuid4().int % 1_000_000):06d}"
|
||||||
|
|
||||||
class TicketStatus(str, enum.Enum):
|
class TicketStatus(str, enum.Enum):
|
||||||
|
"""Estados posibles de un ticket"""
|
||||||
NEW = "NEW"
|
NEW = "NEW"
|
||||||
TRIAGE = "TRIAGE"
|
TRIAGE = "TRIAGE"
|
||||||
IN_PROGRESS = "IN_PROGRESS"
|
IN_PROGRESS = "IN_PROGRESS"
|
||||||
WAITING_FOR_CLIENT = "WAITING_FOR_CLIENT"
|
WAITING_CUSTOMER = "WAITING_CUSTOMER" # ✅ CORREGIDO: nombre según schema.sql
|
||||||
RESOLVED = "RESOLVED"
|
RESOLVED = "RESOLVED"
|
||||||
CLOSED = "CLOSED"
|
CLOSED = "CLOSED"
|
||||||
REOPENED = "REOPENED"
|
REOPENED = "REOPENED"
|
||||||
|
|
||||||
class TicketPriority(str, enum.Enum):
|
class TicketPriority(str, enum.Enum):
|
||||||
|
"""Prioridades posibles de un ticket"""
|
||||||
LOW = "LOW"
|
LOW = "LOW"
|
||||||
MEDIUM = "MEDIUM"
|
MEDIUM = "MEDIUM"
|
||||||
HIGH = "HIGH"
|
HIGH = "HIGH"
|
||||||
URGENT = "URGENT"
|
URGENT = "URGENT"
|
||||||
|
|
||||||
class Ticket(Base):
|
class Ticket(Base):
|
||||||
|
"""Modelo de tickets de soporte"""
|
||||||
__tablename__ = "tickets"
|
__tablename__ = "tickets"
|
||||||
|
|
||||||
# Note: id, created_at, updated_at are inherited from Base
|
# Multi-tenancy
|
||||||
|
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
GUID(),
|
||||||
|
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
tenant_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False)
|
# Campos básicos
|
||||||
|
ticket_number: Mapped[str] = mapped_column(
|
||||||
ticket_number: Mapped[str] = mapped_column(String(20), nullable=False)
|
String(20),
|
||||||
|
nullable=False,
|
||||||
|
default=_generate_fallback_ticket_number,
|
||||||
|
)
|
||||||
subject: Mapped[str] = mapped_column(String(255), nullable=False)
|
subject: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
|
||||||
|
# Compatibility aliases (API/UI/tests often use these names)
|
||||||
|
title = synonym("subject")
|
||||||
|
system_id = synonym("affected_system_id")
|
||||||
|
|
||||||
status: Mapped[TicketStatus] = mapped_column(ENUM(TicketStatus, name="ticket_status_enum", create_type=False), default=TicketStatus.NEW)
|
# Estado y Prioridad
|
||||||
priority: Mapped[TicketPriority] = mapped_column(ENUM(TicketPriority, name="ticket_priority_enum", create_type=False), default=TicketPriority.MEDIUM)
|
status: Mapped[TicketStatus] = mapped_column(
|
||||||
|
SAEnum(TicketStatus, name="ticket_status_enum", native_enum=False).with_variant(
|
||||||
|
PG_ENUM(TicketStatus, name="ticket_status_enum", create_type=True),
|
||||||
|
"postgresql",
|
||||||
|
),
|
||||||
|
default=TicketStatus.NEW,
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
priority: Mapped[TicketPriority] = mapped_column(
|
||||||
|
SAEnum(TicketPriority, name="ticket_priority_enum", native_enum=False).with_variant(
|
||||||
|
PG_ENUM(TicketPriority, name="ticket_priority_enum", create_type=True),
|
||||||
|
"postgresql",
|
||||||
|
),
|
||||||
|
default=TicketPriority.MEDIUM,
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
# Foreign Keys
|
# ✅ CORREGIDO: Foreign Keys apuntan a tablas correctas
|
||||||
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
created_by: Mapped[uuid.UUID] = mapped_column(
|
||||||
assigned_to: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
GUID(),
|
||||||
|
ForeignKey("users.id"),
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
assigned_to: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
|
GUID(),
|
||||||
|
ForeignKey("users.id"),
|
||||||
|
nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
system_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("systems.id"), nullable=True)
|
# ✅ CORREGIDO: Renombrado de system_id a affected_system_id
|
||||||
category_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("categories.id"), nullable=True)
|
affected_system_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
|
GUID(),
|
||||||
|
ForeignKey("affected_systems.id"), # ✅ Tabla correcta
|
||||||
|
nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# ✅ CORREGIDO: Foreign key a tabla correcta
|
||||||
|
category_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
|
GUID(),
|
||||||
|
ForeignKey("ticket_categories.id"), # ✅ Tabla correcta
|
||||||
|
nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# ✅ AÑADIDOS: Campos de SLA según schema.sql
|
||||||
|
sla_response_due: Mapped[Optional[datetime]] = mapped_column(nullable=True)
|
||||||
|
sla_resolution_due: Mapped[Optional[datetime]] = mapped_column(nullable=True)
|
||||||
|
first_response_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)
|
||||||
|
resolved_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)
|
||||||
|
|
||||||
|
# ✅ AÑADIDOS: Campos de CSAT (Customer Satisfaction) según schema.sql
|
||||||
|
rating: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
rating_comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
rated_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="tickets")
|
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="tickets")
|
||||||
|
|
||||||
system: Mapped["System"] = relationship("System", back_populates="tickets")
|
# ✅ ACTUALIZADO: Nombre de relación y optional
|
||||||
category: Mapped["Category"] = relationship("Category", back_populates="tickets")
|
affected_system: Mapped[Optional["System"]] = relationship(
|
||||||
|
"System",
|
||||||
|
back_populates="tickets"
|
||||||
|
)
|
||||||
|
|
||||||
|
category: Mapped[Optional["Category"]] = relationship(
|
||||||
|
"Category",
|
||||||
|
back_populates="tickets"
|
||||||
|
)
|
||||||
|
|
||||||
created_by_user: Mapped["User"] = relationship(
|
created_by_user: Mapped["User"] = relationship(
|
||||||
"User",
|
"User",
|
||||||
@@ -63,3 +138,24 @@ class Ticket(Base):
|
|||||||
foreign_keys=[assigned_to],
|
foreign_keys=[assigned_to],
|
||||||
back_populates="assigned_tickets"
|
back_populates="assigned_tickets"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
comments: Mapped[list["TicketComment"]] = relationship(
|
||||||
|
"TicketComment",
|
||||||
|
back_populates="ticket",
|
||||||
|
cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
attachments: Mapped[list["TicketAttachment"]] = relationship(
|
||||||
|
"TicketAttachment",
|
||||||
|
back_populates="ticket",
|
||||||
|
cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ✅ AÑADIDOS: Constraints según schema.sql
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('tenant_id', 'ticket_number', name='uq_tickets_tenant_number'),
|
||||||
|
CheckConstraint('rating >= 1 AND rating <= 5', name='check_rating_range'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Ticket(id={self.id}, number='{self.ticket_number}', status={self.status})>"
|
||||||
@@ -4,15 +4,15 @@ User Model - ServiceManagerWeb
|
|||||||
Modelo para usuarios del sistema (internos y clientes)
|
Modelo para usuarios del sistema (internos y clientes)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Text, ARRAY
|
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Text, JSON, Enum as SAEnum
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.dialects.postgresql import UUID, ENUM
|
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM, ARRAY as PG_ARRAY
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
import enum
|
import enum
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base, GUID
|
||||||
|
|
||||||
|
|
||||||
class UserRole(str, enum.Enum):
|
class UserRole(str, enum.Enum):
|
||||||
@@ -27,6 +27,24 @@ class UserRole(str, enum.Enum):
|
|||||||
CLIENT_ADMIN = "CLIENT_ADMIN" # Admin de organización cliente
|
CLIENT_ADMIN = "CLIENT_ADMIN" # Admin de organización cliente
|
||||||
CLIENT_USER = "CLIENT_USER" # Usuario final cliente
|
CLIENT_USER = "CLIENT_USER" # Usuario final cliente
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_global(self) -> bool:
|
||||||
|
"""True si el rol tiene alcance global (staff interno cross-tenant)."""
|
||||||
|
return self in (
|
||||||
|
UserRole.ADMIN,
|
||||||
|
UserRole.SUPPORT_MANAGER,
|
||||||
|
UserRole.AGENT,
|
||||||
|
UserRole.AUDITOR,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_client(self) -> bool:
|
||||||
|
"""True si el rol está acotado al tenant del usuario."""
|
||||||
|
return self in (
|
||||||
|
UserRole.CLIENT_ADMIN,
|
||||||
|
UserRole.CLIENT_USER,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
"""Modelo de Usuario."""
|
"""Modelo de Usuario."""
|
||||||
@@ -35,7 +53,7 @@ class User(Base):
|
|||||||
|
|
||||||
# Relación con tenant
|
# Relación con tenant
|
||||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
GUID(),
|
||||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||||
nullable=False
|
nullable=False
|
||||||
)
|
)
|
||||||
@@ -48,12 +66,20 @@ class User(Base):
|
|||||||
|
|
||||||
# Autenticación
|
# Autenticación
|
||||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
role: Mapped[UserRole] = mapped_column(ENUM(UserRole), nullable=False)
|
role: Mapped[UserRole] = mapped_column(
|
||||||
|
SAEnum(UserRole, name="user_role_enum", native_enum=False).with_variant(
|
||||||
|
PG_ENUM(UserRole, name="user_role_enum", create_type=True),
|
||||||
|
"postgresql",
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
# 2FA (opcional para staff interno)
|
# 2FA (opcional para staff interno)
|
||||||
totp_secret: Mapped[Optional[str]] = mapped_column(String(32))
|
totp_secret: Mapped[Optional[str]] = mapped_column(String(32))
|
||||||
totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
backup_codes: Mapped[Optional[List[str]]] = mapped_column(ARRAY(String))
|
backup_codes: Mapped[Optional[List[str]]] = mapped_column(
|
||||||
|
JSON().with_variant(PG_ARRAY(String), "postgresql")
|
||||||
|
)
|
||||||
|
|
||||||
# Estado
|
# Estado
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
@@ -78,10 +104,11 @@ class User(Base):
|
|||||||
back_populates="assigned_to_user",
|
back_populates="assigned_to_user",
|
||||||
foreign_keys="Ticket.assigned_to"
|
foreign_keys="Ticket.assigned_to"
|
||||||
)
|
)
|
||||||
|
refresh_tokens: Mapped[List["RefreshToken"]] = relationship(
|
||||||
# Unique constraint por tenant
|
"RefreshToken",
|
||||||
__table_args__ = (
|
back_populates="user",
|
||||||
{"postgresql_tablespace": "users"},
|
foreign_keys="RefreshToken.user_id",
|
||||||
|
cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
@@ -104,11 +131,8 @@ class User(Base):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def is_client(self) -> bool:
|
def is_client(self) -> bool:
|
||||||
"""Check if user is a client."""
|
"""Check if user is a client (rol acotado al propio tenant)."""
|
||||||
return self.role in [
|
return self.role.is_client
|
||||||
UserRole.CLIENT_ADMIN,
|
|
||||||
UserRole.CLIENT_USER
|
|
||||||
]
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def can_manage_users(self) -> bool:
|
def can_manage_users(self) -> bool:
|
||||||
@@ -116,7 +140,7 @@ class User(Base):
|
|||||||
return self.role in [
|
return self.role in [
|
||||||
UserRole.ADMIN,
|
UserRole.ADMIN,
|
||||||
UserRole.SUPPORT_MANAGER,
|
UserRole.SUPPORT_MANAGER,
|
||||||
UserRole.CLIENT_ADMIN
|
UserRole.CLIENT_ADMIN,
|
||||||
]
|
]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -125,11 +149,11 @@ class User(Base):
|
|||||||
return self.role in [
|
return self.role in [
|
||||||
UserRole.ADMIN,
|
UserRole.ADMIN,
|
||||||
UserRole.SUPPORT_MANAGER,
|
UserRole.SUPPORT_MANAGER,
|
||||||
UserRole.AGENT
|
UserRole.AGENT,
|
||||||
]
|
]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def requires_2fa(self) -> bool:
|
def requires_2fa(self) -> bool:
|
||||||
"""Check if 2FA is required for this user."""
|
"""Check if 2FA is required for this user."""
|
||||||
# 2FA opcional para staff interno, no requerido para clientes
|
# 2FA opcional para staff interno, no requerido para clientes
|
||||||
return self.is_staff
|
return self.is_staff
|
||||||
|
|||||||
311
backend/app/services/audit_service.py
Normal file
311
backend/app/services/audit_service.py
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
"""
|
||||||
|
Audit Service - ServiceManagerWeb
|
||||||
|
|
||||||
|
Funciones helper para facilitar el registro de auditoría.
|
||||||
|
Simplifica el proceso de logging en toda la aplicaci├│n.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from fastapi import Request
|
||||||
|
import uuid
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from app.models.audit import AuditLog
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AuditService:
|
||||||
|
"""
|
||||||
|
Servicio centralizado para registro de auditoría.
|
||||||
|
|
||||||
|
Uso básico:
|
||||||
|
await AuditService.log(
|
||||||
|
db=db,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="ticket.create",
|
||||||
|
resource_type="ticket",
|
||||||
|
resource_id=new_ticket.id,
|
||||||
|
new_values={"subject": "...", "status": "NEW"}
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def log(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
action: str,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: Optional[uuid.UUID] = None,
|
||||||
|
user_id: Optional[uuid.UUID] = None,
|
||||||
|
old_values: Optional[Dict[str, Any]] = None,
|
||||||
|
new_values: Optional[Dict[str, Any]] = None,
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
request: Optional[Request] = None
|
||||||
|
) -> AuditLog:
|
||||||
|
"""
|
||||||
|
Registra una acción en la bitácora de auditoría.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Sesi├│n de base de datos
|
||||||
|
tenant_id: ID del tenant
|
||||||
|
action: Acci├│n realizada (formato: "recurso.verbo")
|
||||||
|
Ejemplos: "user.login", "ticket.create", "ticket.assign"
|
||||||
|
resource_type: Tipo de recurso ("user", "ticket", "comment", etc.)
|
||||||
|
resource_id: ID del recurso afectado (opcional)
|
||||||
|
user_id: ID del usuario que ejecut├│ la acci├│n (opcional = sistema)
|
||||||
|
old_values: Valores antes del cambio (opcional)
|
||||||
|
new_values: Valores despu├®s del cambio (opcional)
|
||||||
|
metadata: Informaci├│n adicional (opcional)
|
||||||
|
request: Request de FastAPI para extraer IP y user agent (opcional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AuditLog creado
|
||||||
|
"""
|
||||||
|
# Extraer información del request si está disponible
|
||||||
|
ip_address = None
|
||||||
|
user_agent = None
|
||||||
|
correlation_id = None
|
||||||
|
|
||||||
|
if request:
|
||||||
|
# IP del cliente
|
||||||
|
if request.client:
|
||||||
|
ip_address = request.client.host
|
||||||
|
|
||||||
|
# User agent
|
||||||
|
user_agent = request.headers.get("user-agent")
|
||||||
|
|
||||||
|
# Correlation ID (si existe en el request state)
|
||||||
|
correlation_id = getattr(request.state, "correlation_id", None)
|
||||||
|
|
||||||
|
# Crear registro de auditoría
|
||||||
|
audit_log = AuditLog(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
action=action,
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
ip_address=ip_address,
|
||||||
|
user_agent=user_agent,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
old_values=old_values,
|
||||||
|
new_values=new_values,
|
||||||
|
extra_metadata=metadata # Mapeo metadata -> extra_metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(audit_log)
|
||||||
|
await db.flush() # No commit, se hará con la transacción principal
|
||||||
|
|
||||||
|
# Log estructurado para debugging
|
||||||
|
logger.info(
|
||||||
|
"Audit log created",
|
||||||
|
action=action,
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=str(resource_id) if resource_id else None,
|
||||||
|
user_id=str(user_id) if user_id else "system",
|
||||||
|
tenant_id=str(tenant_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
return audit_log
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def log_login(
|
||||||
|
db: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
request: Request,
|
||||||
|
success: bool = True
|
||||||
|
) -> AuditLog:
|
||||||
|
"""
|
||||||
|
Registra un intento de login.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Sesi├│n de base de datos
|
||||||
|
user: Usuario que intent├│ loguearse
|
||||||
|
request: Request de FastAPI
|
||||||
|
success: Si el login fue exitoso
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AuditLog creado
|
||||||
|
"""
|
||||||
|
return await AuditService.log(
|
||||||
|
db=db,
|
||||||
|
tenant_id=user.tenant_id,
|
||||||
|
user_id=user.id if success else None,
|
||||||
|
action="user.login" if success else "user.login_failed",
|
||||||
|
resource_type="user",
|
||||||
|
resource_id=user.id,
|
||||||
|
metadata={
|
||||||
|
"success": success,
|
||||||
|
"email": user.email
|
||||||
|
},
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def log_logout(
|
||||||
|
db: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
request: Request
|
||||||
|
) -> AuditLog:
|
||||||
|
"""
|
||||||
|
Registra un logout.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Sesi├│n de base de datos
|
||||||
|
user: Usuario que cerr├│ sesi├│n
|
||||||
|
request: Request de FastAPI
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AuditLog creado
|
||||||
|
"""
|
||||||
|
return await AuditService.log(
|
||||||
|
db=db,
|
||||||
|
tenant_id=user.tenant_id,
|
||||||
|
user_id=user.id,
|
||||||
|
action="user.logout",
|
||||||
|
resource_type="user",
|
||||||
|
resource_id=user.id,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def log_create(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: uuid.UUID,
|
||||||
|
new_values: Dict[str, Any],
|
||||||
|
request: Optional[Request] = None
|
||||||
|
) -> AuditLog:
|
||||||
|
"""
|
||||||
|
Registra la creaci├│n de un recurso.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Sesi├│n de base de datos
|
||||||
|
tenant_id: ID del tenant
|
||||||
|
user_id: ID del usuario que cre├│ el recurso
|
||||||
|
resource_type: Tipo de recurso ("ticket", "user", etc.)
|
||||||
|
resource_id: ID del recurso creado
|
||||||
|
new_values: Valores del nuevo recurso
|
||||||
|
request: Request de FastAPI (opcional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AuditLog creado
|
||||||
|
"""
|
||||||
|
return await AuditService.log(
|
||||||
|
db=db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
action=f"{resource_type}.create",
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
new_values=new_values,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def log_update(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: uuid.UUID,
|
||||||
|
old_values: Dict[str, Any],
|
||||||
|
new_values: Dict[str, Any],
|
||||||
|
request: Optional[Request] = None
|
||||||
|
) -> AuditLog:
|
||||||
|
"""
|
||||||
|
Registra la actualizaci├│n de un recurso.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Sesi├│n de base de datos
|
||||||
|
tenant_id: ID del tenant
|
||||||
|
user_id: ID del usuario que actualiz├│
|
||||||
|
resource_type: Tipo de recurso
|
||||||
|
resource_id: ID del recurso
|
||||||
|
old_values: Valores anteriores
|
||||||
|
new_values: Valores nuevos
|
||||||
|
request: Request de FastAPI (opcional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AuditLog creado
|
||||||
|
"""
|
||||||
|
return await AuditService.log(
|
||||||
|
db=db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
action=f"{resource_type}.update",
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
old_values=old_values,
|
||||||
|
new_values=new_values,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def log_delete(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: uuid.UUID,
|
||||||
|
old_values: Dict[str, Any],
|
||||||
|
request: Optional[Request] = None
|
||||||
|
) -> AuditLog:
|
||||||
|
"""
|
||||||
|
Registra la eliminaci├│n de un recurso.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Sesi├│n de base de datos
|
||||||
|
tenant_id: ID del tenant
|
||||||
|
user_id: ID del usuario que elimin├│
|
||||||
|
resource_type: Tipo de recurso
|
||||||
|
resource_id: ID del recurso eliminado
|
||||||
|
old_values: Valores del recurso antes de eliminar
|
||||||
|
request: Request de FastAPI (opcional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AuditLog creado
|
||||||
|
"""
|
||||||
|
return await AuditService.log(
|
||||||
|
db=db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
action=f"{resource_type}.delete",
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
old_values=old_values,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def sanitize_values(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Sanitiza valores sensibles antes de guardarlos en audit log.
|
||||||
|
|
||||||
|
Remueve campos como passwords, tokens, etc.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
values: Diccionario de valores
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Diccionario sanitizado
|
||||||
|
"""
|
||||||
|
sensitive_fields = {
|
||||||
|
'password',
|
||||||
|
'password_hash',
|
||||||
|
'totp_secret',
|
||||||
|
'backup_codes',
|
||||||
|
'token',
|
||||||
|
'access_token',
|
||||||
|
'refresh_token'
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: '***REDACTED***' if key in sensitive_fields else value
|
||||||
|
for key, value in values.items()
|
||||||
|
}
|
||||||
170
backend/app/services/ticket_service.py
Normal file
170
backend/app/services/ticket_service.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
"""
|
||||||
|
Ticket Service - ServiceManagerWeb
|
||||||
|
|
||||||
|
Lógica de negocio para creación y gestión de tickets.
|
||||||
|
Inyectable vía Depends() en los endpoints de FastAPI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.models.ticket import Ticket, TicketStatus, TicketPriority
|
||||||
|
from app.models.category import Category
|
||||||
|
from app.models.system import System
|
||||||
|
from app.api.schemas.ticket import TicketCreate
|
||||||
|
from app.api.v1.helpers import (
|
||||||
|
generate_next_ticket_number,
|
||||||
|
calculate_sla_deadlines,
|
||||||
|
safe_audit_log,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TicketService:
|
||||||
|
"""Servicio de tickets: encapsula lógica de negocio fuera del router."""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession = Depends(get_db)):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def create_ticket(
|
||||||
|
self,
|
||||||
|
ticket: TicketCreate,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Crea un ticket con validación multi-tenant, cálculo de SLA y auto-asignación.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ticket: Datos del ticket a crear.
|
||||||
|
tenant_id: Tenant del usuario autenticado.
|
||||||
|
user_id: ID del usuario que crea el ticket.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict compatible con TicketResponse.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 400: UUID inválido, categoría/sistema no encontrado o de otro tenant.
|
||||||
|
HTTPException 500: Fallo persistente tras max_retries.
|
||||||
|
"""
|
||||||
|
max_retries = 3
|
||||||
|
last_error = None
|
||||||
|
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
|
ticket_number = await generate_next_ticket_number(self.db, tenant_id)
|
||||||
|
category_uuid = uuid.UUID(ticket.category_id) if ticket.category_id else None
|
||||||
|
system_uuid = uuid.UUID(ticket.affected_system_id) if ticket.affected_system_id else None
|
||||||
|
|
||||||
|
category = None
|
||||||
|
if category_uuid:
|
||||||
|
category = await self.db.get(Category, category_uuid)
|
||||||
|
if not category or category.tenant_id != tenant_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"La categoría con ID {ticket.category_id} no existe.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if system_uuid:
|
||||||
|
system = await self.db.get(System, system_uuid)
|
||||||
|
if not system or system.tenant_id != tenant_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"El sistema con ID {ticket.affected_system_id} no existe.",
|
||||||
|
)
|
||||||
|
|
||||||
|
sla_response_due, sla_resolution_due = calculate_sla_deadlines(category)
|
||||||
|
assigned_to_user = category.auto_assign_to if category and category.auto_assign_to else None
|
||||||
|
|
||||||
|
db_ticket = Ticket(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
ticket_number=ticket_number,
|
||||||
|
subject=ticket.subject,
|
||||||
|
description=ticket.description,
|
||||||
|
category_id=category_uuid,
|
||||||
|
affected_system_id=system_uuid,
|
||||||
|
priority=TicketPriority[ticket.priority.upper()],
|
||||||
|
created_by=user_id,
|
||||||
|
assigned_to=assigned_to_user,
|
||||||
|
status=TicketStatus.NEW,
|
||||||
|
sla_response_due=sla_response_due,
|
||||||
|
sla_resolution_due=sla_resolution_due,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
updated_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.db.add(db_ticket)
|
||||||
|
await self.db.commit()
|
||||||
|
await self.db.refresh(db_ticket)
|
||||||
|
|
||||||
|
await safe_audit_log(
|
||||||
|
db=self.db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
action="ticket.create",
|
||||||
|
resource_type="ticket",
|
||||||
|
resource_id=db_ticket.id,
|
||||||
|
new_values={
|
||||||
|
"ticket_number": db_ticket.ticket_number,
|
||||||
|
"subject": db_ticket.subject,
|
||||||
|
"priority": db_ticket.priority.value,
|
||||||
|
"status": db_ticket.status.value,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(db_ticket.id),
|
||||||
|
"ticket_number": db_ticket.ticket_number,
|
||||||
|
"subject": db_ticket.subject,
|
||||||
|
"title": db_ticket.subject,
|
||||||
|
"description": db_ticket.description,
|
||||||
|
"status": db_ticket.status.value,
|
||||||
|
"priority": db_ticket.priority.value,
|
||||||
|
"category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
|
||||||
|
"affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None,
|
||||||
|
"system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None,
|
||||||
|
"contact_email": ticket.contact_email,
|
||||||
|
"contact_phone": ticket.contact_phone,
|
||||||
|
"created_by": str(db_ticket.created_by),
|
||||||
|
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
|
||||||
|
"created_at": db_ticket.created_at,
|
||||||
|
"updated_at": db_ticket.updated_at,
|
||||||
|
"sla_response_due": db_ticket.sla_response_due,
|
||||||
|
"sla_resolution_due": db_ticket.sla_resolution_due,
|
||||||
|
"first_response_at": db_ticket.first_response_at,
|
||||||
|
"resolved_at": db_ticket.resolved_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
await self.db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid UUID format: {str(e)}",
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
await self.db.rollback()
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
await self.db.rollback()
|
||||||
|
last_error = e
|
||||||
|
if "duplicate key" in str(e).lower() and "ticket_number" in str(e).lower():
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
continue
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Error creating ticket: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"No se pudo crear el ticket después de {max_retries} intentos: {str(last_error)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_ticket_service(db: AsyncSession = Depends(get_db)) -> TicketService:
|
||||||
|
"""Factory function para inyectar TicketService vía Depends()."""
|
||||||
|
return TicketService(db)
|
||||||
270
backend/app/services/token_service.py
Normal file
270
backend/app/services/token_service.py
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
"""
|
||||||
|
Token Service - ServiceManagerWeb
|
||||||
|
|
||||||
|
Servicio para gesti├│n de refresh tokens persistentes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, delete
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Optional
|
||||||
|
import uuid
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from app.models.refresh_token import RefreshToken
|
||||||
|
from app.models.user import User
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class TokenService:
|
||||||
|
"""
|
||||||
|
Servicio para gesti├│n de refresh tokens.
|
||||||
|
|
||||||
|
Proporciona m├®todos para crear, validar, revocar y limpiar
|
||||||
|
refresh tokens persistentes.
|
||||||
|
|
||||||
|
NOTA: Los tokens se almacenan directamente en BD (no hash)
|
||||||
|
ya que los JWTs son firmados y verificables.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def create_refresh_token(
|
||||||
|
db: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
refresh_token: str,
|
||||||
|
device_id: Optional[str] = None,
|
||||||
|
device_name: Optional[str] = None,
|
||||||
|
user_agent: Optional[str] = None,
|
||||||
|
ip_address: Optional[str] = None
|
||||||
|
) -> RefreshToken:
|
||||||
|
"""
|
||||||
|
Crear y persistir un refresh token.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Sesi├│n de base de datos
|
||||||
|
user: Usuario propietario del token
|
||||||
|
refresh_token: Token JWT generado (se almacena directamente)
|
||||||
|
device_id: ID ├║nico del dispositivo (UUID generado por cliente)
|
||||||
|
device_name: Nombre del dispositivo (ej: "Chrome en Windows")
|
||||||
|
user_agent: User agent completo del navegador
|
||||||
|
ip_address: IP del cliente
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RefreshToken creado
|
||||||
|
"""
|
||||||
|
# Calcular expiraci├│n
|
||||||
|
expires_at = datetime.now(timezone.utc) + timedelta(
|
||||||
|
days=settings.REFRESH_TOKEN_EXPIRE_DAYS
|
||||||
|
)
|
||||||
|
|
||||||
|
# Crear registro - almacena JWT directamente (columna UNIQUE)
|
||||||
|
db_token = RefreshToken(
|
||||||
|
user_id=user.id,
|
||||||
|
token=refresh_token, # JWT almacenado directamente
|
||||||
|
device_id=device_id,
|
||||||
|
device_name=device_name,
|
||||||
|
user_agent=user_agent,
|
||||||
|
ip_address=ip_address,
|
||||||
|
expires_at=expires_at,
|
||||||
|
revoked=False,
|
||||||
|
usage_count=0
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(db_token)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Refresh token created",
|
||||||
|
user_id=str(user.id),
|
||||||
|
token_id=str(db_token.id),
|
||||||
|
device_name=device_name,
|
||||||
|
expires_at=expires_at.isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
return db_token
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def verify_refresh_token(
|
||||||
|
db: AsyncSession,
|
||||||
|
refresh_token: str
|
||||||
|
) -> Optional[RefreshToken]:
|
||||||
|
"""
|
||||||
|
Verificar que el refresh token exista y sea válido.
|
||||||
|
|
||||||
|
Busca el JWT directamente en la BD y verifica su estado.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Sesi├│n de base de datos
|
||||||
|
refresh_token: Token JWT a verificar
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RefreshToken si es válido, None si no existe o está revocado/expirado
|
||||||
|
"""
|
||||||
|
# Buscar token directamente en BD (sin hash)
|
||||||
|
query = select(RefreshToken).where(
|
||||||
|
RefreshToken.token == refresh_token
|
||||||
|
)
|
||||||
|
result = await db.execute(query)
|
||||||
|
db_token = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not db_token:
|
||||||
|
logger.warning("Refresh token not found in database")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Verificar si es válido (usa property is_valid del modelo)
|
||||||
|
if not db_token.is_valid:
|
||||||
|
logger.warning(
|
||||||
|
"Invalid refresh token",
|
||||||
|
token_id=str(db_token.id),
|
||||||
|
revoked=db_token.revoked,
|
||||||
|
expired=db_token.is_expired
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Actualizar estadísticas de uso
|
||||||
|
db_token.track_usage()
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Refresh token verified and usage tracked",
|
||||||
|
token_id=str(db_token.id),
|
||||||
|
usage_count=db_token.usage_count
|
||||||
|
)
|
||||||
|
return db_token
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def revoke_token(
|
||||||
|
db: AsyncSession,
|
||||||
|
refresh_token: str,
|
||||||
|
revoked_by_user_id: Optional[uuid.UUID] = None
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Revocar un refresh token específico.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Sesi├│n de base de datos
|
||||||
|
refresh_token: Token JWT a revocar
|
||||||
|
revoked_by_user_id: ID del usuario que revoca (para auditoría)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True si se revoc├│, False si no se encontr├│
|
||||||
|
"""
|
||||||
|
# Buscar token directamente (sin hash)
|
||||||
|
query = select(RefreshToken).where(
|
||||||
|
RefreshToken.token == refresh_token
|
||||||
|
)
|
||||||
|
result = await db.execute(query)
|
||||||
|
db_token = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not db_token:
|
||||||
|
logger.warning("Refresh token not found for revocation")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Revocar usando m├®todo del modelo
|
||||||
|
db_token.revoke(revoked_by=revoked_by_user_id)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Refresh token revoked",
|
||||||
|
token_id=str(db_token.id),
|
||||||
|
revoked_by=str(revoked_by_user_id) if revoked_by_user_id else None
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def revoke_all_user_tokens(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
revoked_by_user_id: Optional[uuid.UUID] = None
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Revocar todos los tokens activos de un usuario.
|
||||||
|
|
||||||
|
Útil para logout en todos los dispositivos.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Sesi├│n de base de datos
|
||||||
|
user_id: ID del usuario
|
||||||
|
revoked_by_user_id: ID del usuario que ejecuta la revocación (para auditoría)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
N├║mero de tokens revocados
|
||||||
|
"""
|
||||||
|
# Buscar todos los tokens activos del usuario
|
||||||
|
query = select(RefreshToken).where(
|
||||||
|
RefreshToken.user_id == user_id,
|
||||||
|
RefreshToken.revoked == False
|
||||||
|
)
|
||||||
|
result = await db.execute(query)
|
||||||
|
tokens = result.scalars().all()
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for token in tokens:
|
||||||
|
token.revoke(revoked_by=revoked_by_user_id)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"All user tokens revoked",
|
||||||
|
user_id=str(user_id),
|
||||||
|
count=count,
|
||||||
|
revoked_by=str(revoked_by_user_id) if revoked_by_user_id else None
|
||||||
|
)
|
||||||
|
return count
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def cleanup_expired_tokens(
|
||||||
|
db: AsyncSession
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Eliminar tokens expirados de la base de datos.
|
||||||
|
|
||||||
|
Tarea de mantenimiento para limpiar tokens antiguos.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Sesi├│n de base de datos
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
N├║mero de tokens eliminados
|
||||||
|
"""
|
||||||
|
# Eliminar tokens expirados hace más de 7 días
|
||||||
|
cutoff_date = datetime.now(timezone.utc) - timedelta(days=7)
|
||||||
|
|
||||||
|
query = delete(RefreshToken).where(
|
||||||
|
RefreshToken.expires_at < cutoff_date
|
||||||
|
)
|
||||||
|
result = await db.execute(query)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
deleted_count = result.rowcount
|
||||||
|
|
||||||
|
logger.info("Expired tokens cleaned up", count=deleted_count)
|
||||||
|
return deleted_count
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_user_tokens(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: uuid.UUID
|
||||||
|
) -> list[RefreshToken]:
|
||||||
|
"""
|
||||||
|
Obtener todos los tokens activos de un usuario.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Sesi├│n de base de datos
|
||||||
|
user_id: ID del usuario
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Lista de RefreshTokens activos
|
||||||
|
"""
|
||||||
|
query = select(RefreshToken).where(
|
||||||
|
RefreshToken.user_id == user_id,
|
||||||
|
RefreshToken.revoked == False,
|
||||||
|
RefreshToken.expires_at > datetime.utcnow()
|
||||||
|
).order_by(RefreshToken.created_at.desc())
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
0
backend/app/tests/__init__.py
Normal file
0
backend/app/tests/__init__.py
Normal file
373
backend/app/tests/conftest.py
Normal file
373
backend/app/tests/conftest.py
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
"""
|
||||||
|
Integration Test Fixtures - ServiceManagerWeb (Docker / PostgreSQL)
|
||||||
|
|
||||||
|
backend/app/tests/conftest.py
|
||||||
|
|
||||||
|
Usa la BD Docker existente (servicemanager).
|
||||||
|
Los fixtures leen datos reales ya seedeados — no crean ni eliminan nada.
|
||||||
|
Los tests que inserten datos propios quedan aislados por rollback.
|
||||||
|
|
||||||
|
Tenant de referencia : aduanasoft
|
||||||
|
Usuarios de referencia:
|
||||||
|
admin@aduanasoft.com → ADMIN
|
||||||
|
manager@aduanasoft.com → SUPPORT_MANAGER
|
||||||
|
agente@aduanasoft.com → AGENT
|
||||||
|
auditor1@test.com → AUDITOR (tenant aduanasoft)
|
||||||
|
admin-cliente@empresa-demo → CLIENT_ADMIN
|
||||||
|
test_user@aduanasoft.com → CLIENT_USER
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import asyncio
|
||||||
|
import pytest
|
||||||
|
from typing import AsyncGenerator, Generator
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# ENV VARS — antes de importar la app
|
||||||
|
# ============================================================
|
||||||
|
os.environ.setdefault("ENVIRONMENT", "testing")
|
||||||
|
os.environ.setdefault("TESTING", "true")
|
||||||
|
os.environ.setdefault("DEBUG", "false")
|
||||||
|
os.environ.setdefault("SECRET_KEY", "integration-secret-key-32chars!!!!")
|
||||||
|
os.environ.setdefault("JWT_SECRET_KEY", "integration-jwt-secret-32chars!!!!")
|
||||||
|
os.environ.setdefault(
|
||||||
|
"DATABASE_URL",
|
||||||
|
"postgresql+asyncpg://servicemanager:servicemanager123@localhost:5432/servicemanager",
|
||||||
|
)
|
||||||
|
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/14")
|
||||||
|
os.environ.setdefault("CELERY_BROKER_URL", "redis://localhost:6379/14")
|
||||||
|
os.environ.setdefault("CELERY_RESULT_BACKEND", "redis://localhost:6379/14")
|
||||||
|
os.environ.setdefault("CORS_ORIGINS", "http://localhost:3000")
|
||||||
|
os.environ.setdefault("ALLOWED_FILE_EXTENSIONS", "pdf,jpg,jpeg,png,doc,docx,txt")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# EVENT LOOP (session-scoped)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def event_loop() -> Generator:
|
||||||
|
"""Event loop compartido para toda la sesión de tests."""
|
||||||
|
policy = asyncio.get_event_loop_policy()
|
||||||
|
loop = policy.new_event_loop()
|
||||||
|
yield loop
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# ENGINE (session-scoped — reutiliza el pool toda la sesión)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
async def engine():
|
||||||
|
"""
|
||||||
|
Conecta al PostgreSQL Docker existente (servicemanager).
|
||||||
|
NO crea ni destruye el schema — la BD ya está lista.
|
||||||
|
"""
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
import app.models # noqa: F401 — registra todos los modelos
|
||||||
|
|
||||||
|
_engine = create_async_engine(os.environ["DATABASE_URL"], echo=False)
|
||||||
|
yield _engine
|
||||||
|
await _engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# DB (function-scoped — rollback para datos creados en el test)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def db(engine) -> AsyncGenerator:
|
||||||
|
"""
|
||||||
|
Sesión con transacción por test.
|
||||||
|
Los datos seedeados son visibles (ya están committed).
|
||||||
|
Cualquier INSERT hecho en el test se revierte al finalizar.
|
||||||
|
"""
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
|
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
async with factory() as session:
|
||||||
|
await session.begin()
|
||||||
|
yield session
|
||||||
|
await session.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# TENANT (function-scoped — lee el registro existente)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def tenant_a(db):
|
||||||
|
"""Tenant 'aduanasoft' ya existente en la BD."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.models.tenant import Tenant
|
||||||
|
|
||||||
|
result = await db.execute(select(Tenant).where(Tenant.slug == "aduanasoft"))
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# USUARIOS (function-scoped — leen registros existentes)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def admin_user(db, tenant_a):
|
||||||
|
"""ADMIN: admin@aduanasoft.com (tenant aduanasoft)."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(User)
|
||||||
|
.where(User.email == "admin@aduanasoft.com")
|
||||||
|
.where(User.tenant_id == tenant_a.id)
|
||||||
|
)
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def manager_user(db, tenant_a):
|
||||||
|
"""SUPPORT_MANAGER: manager@aduanasoft.com (tenant aduanasoft)."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(User)
|
||||||
|
.where(User.email == "manager@aduanasoft.com")
|
||||||
|
.where(User.tenant_id == tenant_a.id)
|
||||||
|
)
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def agent_user(db, tenant_a):
|
||||||
|
"""AGENT: agente@aduanasoft.com (tenant aduanasoft)."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(User)
|
||||||
|
.where(User.email == "agente@aduanasoft.com")
|
||||||
|
.where(User.tenant_id == tenant_a.id)
|
||||||
|
)
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def user_tenant_a(db, tenant_a):
|
||||||
|
"""CLIENT_USER: test_user@aduanasoft.com (tenant aduanasoft)."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(User)
|
||||||
|
.where(User.email == "test_user@aduanasoft.com")
|
||||||
|
.where(User.tenant_id == tenant_a.id)
|
||||||
|
)
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# HTTP CLIENT (function-scoped)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client(db) -> AsyncGenerator:
|
||||||
|
"""
|
||||||
|
httpx.AsyncClient contra la app FastAPI en memoria (sin red).
|
||||||
|
get_db queda sobreescrito para inyectar la sesión de test.
|
||||||
|
Los cambios del test se revierten al terminar (rollback en db).
|
||||||
|
"""
|
||||||
|
import httpx
|
||||||
|
from httpx import ASGITransport
|
||||||
|
from app.main import app
|
||||||
|
from app.core.database import get_db
|
||||||
|
|
||||||
|
async def _override_get_db():
|
||||||
|
yield db
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = _override_get_db
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=ASGITransport(app=app),
|
||||||
|
base_url="http://test",
|
||||||
|
) as ac:
|
||||||
|
yield ac
|
||||||
|
|
||||||
|
app.dependency_overrides.pop(get_db, None)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# FIXTURES DE AISLAMIENTO MULTI-TENANT
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def make_token():
|
||||||
|
"""Factory de JWT tokens para autenticar clientes HTTP en tests."""
|
||||||
|
from app.core.security import security
|
||||||
|
|
||||||
|
def _make(user):
|
||||||
|
return security.create_access_token(data={"sub": str(user.id)})
|
||||||
|
|
||||||
|
return _make
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def app_with_db(db):
|
||||||
|
"""
|
||||||
|
Override de get_db compartido para todos los HTTP clients de un mismo test.
|
||||||
|
Garantiza que todos los clients usen la misma sesión (y el mismo rollback).
|
||||||
|
"""
|
||||||
|
from app.main import app as _app
|
||||||
|
from app.core.database import get_db
|
||||||
|
|
||||||
|
async def _override():
|
||||||
|
yield db
|
||||||
|
|
||||||
|
_app.dependency_overrides[get_db] = _override
|
||||||
|
yield _app
|
||||||
|
_app.dependency_overrides.pop(get_db, None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def tenant_b(db):
|
||||||
|
"""Tenant 'empresa-test' creado en la transacción del test (se revierte al final)."""
|
||||||
|
from app.models.tenant import Tenant
|
||||||
|
|
||||||
|
t = Tenant(name="Empresa Test", slug="empresa-test")
|
||||||
|
db.add(t)
|
||||||
|
await db.flush()
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def user_b(db, tenant_b):
|
||||||
|
"""CLIENT_ADMIN en tenant_b — puede gestionar recursos de su tenant."""
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.core.security import security
|
||||||
|
|
||||||
|
u = User(
|
||||||
|
tenant_id=tenant_b.id,
|
||||||
|
email="admin@empresa-test.com",
|
||||||
|
first_name="Admin",
|
||||||
|
last_name="Test",
|
||||||
|
password_hash=security.hash_password("Test1234!"),
|
||||||
|
role=UserRole.CLIENT_ADMIN,
|
||||||
|
is_active=True,
|
||||||
|
email_verified=True,
|
||||||
|
)
|
||||||
|
db.add(u)
|
||||||
|
await db.flush()
|
||||||
|
return u
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client_tenant_a(app_with_db, manager_user, make_token):
|
||||||
|
"""HTTP client autenticado como SUPPORT_MANAGER de tenant_a (aduanasoft).
|
||||||
|
Usa manager_user en lugar de admin_user para mantener el aislamiento de
|
||||||
|
tenant en GET /users/ (el ADMIN global bypasa el filtro de tenant).
|
||||||
|
"""
|
||||||
|
import httpx
|
||||||
|
from httpx import ASGITransport
|
||||||
|
|
||||||
|
token = make_token(manager_user)
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=ASGITransport(app=app_with_db),
|
||||||
|
base_url="http://test",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
) as ac:
|
||||||
|
yield ac
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client_tenant_b(app_with_db, user_b, make_token):
|
||||||
|
"""HTTP client autenticado como CLIENT_ADMIN de tenant_b (empresa-test)."""
|
||||||
|
import httpx
|
||||||
|
from httpx import ASGITransport
|
||||||
|
|
||||||
|
token = make_token(user_b)
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=ASGITransport(app=app_with_db),
|
||||||
|
base_url="http://test",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
) as ac:
|
||||||
|
yield ac
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client_admin(app_with_db, admin_user, make_token):
|
||||||
|
"""HTTP client autenticado como ADMIN global."""
|
||||||
|
import httpx
|
||||||
|
from httpx import ASGITransport
|
||||||
|
|
||||||
|
token = make_token(admin_user)
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=ASGITransport(app=app_with_db),
|
||||||
|
base_url="http://test",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
) as ac:
|
||||||
|
yield ac
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def create_ticket_tenant_a(client_tenant_a):
|
||||||
|
"""Factory: crea un ticket en tenant_a vía HTTP y retorna el JSON de respuesta."""
|
||||||
|
async def _create(subject="Ticket Tenant A", priority="MEDIUM"):
|
||||||
|
resp = await client_tenant_a.post("/v1/tickets/", json={
|
||||||
|
"subject": subject,
|
||||||
|
"description": "Test de aislamiento tenant A",
|
||||||
|
"priority": priority,
|
||||||
|
})
|
||||||
|
assert resp.status_code in (200, 201), f"Error creando ticket A: {resp.text}"
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
return _create
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def create_ticket_tenant_b(client_tenant_b):
|
||||||
|
"""Factory: crea un ticket en tenant_b vía HTTP y retorna el JSON de respuesta."""
|
||||||
|
async def _create(subject="Ticket Tenant B", priority="MEDIUM"):
|
||||||
|
resp = await client_tenant_b.post("/v1/tickets/", json={
|
||||||
|
"subject": subject,
|
||||||
|
"description": "Test de aislamiento tenant B",
|
||||||
|
"priority": priority,
|
||||||
|
})
|
||||||
|
assert resp.status_code in (200, 201), f"Error creando ticket B: {resp.text}"
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
return _create
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def create_user_tenant_a(client_tenant_a):
|
||||||
|
"""Factory: crea un usuario en tenant_a vía HTTP y retorna el JSON de respuesta."""
|
||||||
|
async def _create(email="nuevo_user_a@test.com"):
|
||||||
|
resp = await client_tenant_a.post("/v1/users/", json={
|
||||||
|
"email": email,
|
||||||
|
"first_name": "Usuario",
|
||||||
|
"last_name": "TenantA",
|
||||||
|
"password": "Test1234!",
|
||||||
|
"role": "CLIENT_USER",
|
||||||
|
})
|
||||||
|
assert resp.status_code in (200, 201), f"Error creando user A: {resp.text}"
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
return _create
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def create_user_tenant_b(client_tenant_b):
|
||||||
|
"""Factory: crea un usuario en tenant_b vía HTTP y retorna el JSON de respuesta."""
|
||||||
|
async def _create(email="nuevo_user_b@test.com"):
|
||||||
|
resp = await client_tenant_b.post("/v1/users/", json={
|
||||||
|
"email": email,
|
||||||
|
"first_name": "Usuario",
|
||||||
|
"last_name": "TenantB",
|
||||||
|
"password": "Test1234!",
|
||||||
|
"role": "CLIENT_USER",
|
||||||
|
})
|
||||||
|
assert resp.status_code in (200, 201), f"Error creando user B: {resp.text}"
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
return _create
|
||||||
137
backend/app/tests/test_smoke.py
Normal file
137
backend/app/tests/test_smoke.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"""
|
||||||
|
Smoke Tests - ServiceManagerWeb
|
||||||
|
|
||||||
|
Verifican que el stack completo funciona:
|
||||||
|
- Conexión a BD Docker
|
||||||
|
- Fixtures de tenant y usuarios
|
||||||
|
- Login vía HTTP (httpx + FastAPI en memoria)
|
||||||
|
- Endpoint protegido con token
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# BD + FIXTURES
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_db_connected(db):
|
||||||
|
"""La sesión de BD está activa y responde."""
|
||||||
|
from sqlalchemy import text
|
||||||
|
result = await db.execute(text("SELECT 1"))
|
||||||
|
assert result.scalar() == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tenant_a_existe(tenant_a):
|
||||||
|
"""El tenant 'aduanasoft' existe y tiene datos válidos."""
|
||||||
|
assert tenant_a.slug == "aduanasoft"
|
||||||
|
assert tenant_a.name is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_user_existe(admin_user):
|
||||||
|
"""El usuario ADMIN existe y pertenece al tenant correcto."""
|
||||||
|
from app.models.user import UserRole
|
||||||
|
assert admin_user.email == "admin@aduanasoft.com"
|
||||||
|
assert admin_user.role == UserRole.ADMIN
|
||||||
|
assert admin_user.is_active is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_manager_user_existe(manager_user):
|
||||||
|
"""El usuario SUPPORT_MANAGER existe."""
|
||||||
|
from app.models.user import UserRole
|
||||||
|
assert manager_user.email == "manager@aduanasoft.com"
|
||||||
|
assert manager_user.role == UserRole.SUPPORT_MANAGER
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_agent_user_existe(agent_user):
|
||||||
|
"""El usuario AGENT existe."""
|
||||||
|
from app.models.user import UserRole
|
||||||
|
assert agent_user.email == "agente@aduanasoft.com"
|
||||||
|
assert agent_user.role == UserRole.AGENT
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_client_user_existe(user_tenant_a):
|
||||||
|
"""El CLIENT_USER existe."""
|
||||||
|
from app.models.user import UserRole
|
||||||
|
assert user_tenant_a.email == "test_user@aduanasoft.com"
|
||||||
|
assert user_tenant_a.role == UserRole.CLIENT_USER
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# HTTP — LOGIN
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_login_admin_ok(client):
|
||||||
|
"""Login con credenciales de admin devuelve access_token."""
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"email": "admin@aduanasoft.com",
|
||||||
|
"password": "admin123",
|
||||||
|
"tenant_slug": "aduanasoft",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "access_token" in data
|
||||||
|
assert data["token_type"] == "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_login_credenciales_invalidas(client):
|
||||||
|
"""Login con contraseña incorrecta devuelve 401."""
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"email": "admin@aduanasoft.com",
|
||||||
|
"password": "wrongpassword",
|
||||||
|
"tenant_slug": "aduanasoft",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_endpoint_sin_token_devuelve_401(client):
|
||||||
|
"""Acceder a un endpoint protegido sin token devuelve 401."""
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/users/me",
|
||||||
|
headers={"X-Tenant-Slug": "aduanasoft"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_login_y_me(client):
|
||||||
|
"""Login exitoso → /users/me devuelve el usuario correcto."""
|
||||||
|
# Login
|
||||||
|
login = await client.post(
|
||||||
|
"/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"email": "admin@aduanasoft.com",
|
||||||
|
"password": "admin123",
|
||||||
|
"tenant_slug": "aduanasoft",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert login.status_code == 200
|
||||||
|
token = login.json()["access_token"]
|
||||||
|
|
||||||
|
# Endpoint protegido
|
||||||
|
me = await client.get(
|
||||||
|
"/v1/users/me",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"X-Tenant-Slug": "aduanasoft",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert me.status_code == 200
|
||||||
|
data = me.json()
|
||||||
|
assert data["email"] == "admin@aduanasoft.com"
|
||||||
|
assert data["role"] == "ADMIN"
|
||||||
123
backend/app/tests/test_tenant_isolation.py
Normal file
123
backend/app/tests/test_tenant_isolation.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
"""
|
||||||
|
Pruebas de aislamiento multi-tenant para tickets y usuarios.
|
||||||
|
Usa solo los fixtures definidos en conftest.py.
|
||||||
|
|
||||||
|
Roles en juego:
|
||||||
|
client_tenant_a → SUPPORT_MANAGER (aduanasoft) — restringido a su tenant
|
||||||
|
client_tenant_b → CLIENT_ADMIN (empresa-test) — restringido a su tenant
|
||||||
|
client_admin → ADMIN global (aduanasoft) — acceso a todos los tenants
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tenant_a_cannot_see_tenant_b_tickets(
|
||||||
|
client_tenant_a, create_ticket_tenant_b
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
El usuario del tenant B crea un ticket.
|
||||||
|
El usuario del tenant A (SUPPORT_MANAGER) lista sus tickets.
|
||||||
|
El ticket de tenant B NO debe aparecer en la respuesta.
|
||||||
|
"""
|
||||||
|
# El usuario del tenant B crea un ticket
|
||||||
|
ticket_b = await create_ticket_tenant_b()
|
||||||
|
ticket_b_id = ticket_b["id"]
|
||||||
|
|
||||||
|
# El usuario del tenant A lista sus tickets
|
||||||
|
response = await client_tenant_a.get("/v1/tickets/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
ids_visibles = {t["id"] for t in response.json()}
|
||||||
|
|
||||||
|
# El ticket de tenant B no debe ser visible para tenant A
|
||||||
|
assert ticket_b_id not in ids_visibles, (
|
||||||
|
f"Fallo de aislamiento: ticket de tenant B ({ticket_b_id}) "
|
||||||
|
f"visible para usuario de tenant A"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tenant_b_cannot_edit_tenant_a_ticket(
|
||||||
|
create_ticket_tenant_a, client_tenant_b
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
El usuario del tenant A crea un ticket.
|
||||||
|
El usuario del tenant B intenta editar ese ticket vía PATCH.
|
||||||
|
Debe recibir 403 (prohibido) o 404 (no encontrado).
|
||||||
|
"""
|
||||||
|
# El usuario del tenant A crea un ticket
|
||||||
|
ticket_a = await create_ticket_tenant_a()
|
||||||
|
ticket_a_id = ticket_a["id"]
|
||||||
|
|
||||||
|
# El usuario del tenant B intenta editar el ticket de tenant A
|
||||||
|
response = await client_tenant_b.patch(
|
||||||
|
f"/v1/tickets/{ticket_a_id}",
|
||||||
|
json={"status": "CLOSED"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Debe recibir 403 o 404 — nunca 200
|
||||||
|
assert response.status_code in (403, 404), (
|
||||||
|
f"Fallo de aislamiento: tenant B pudo editar ticket de tenant A "
|
||||||
|
f"(HTTP {response.status_code})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tenant_a_cannot_see_tenant_b_users(
|
||||||
|
client_tenant_a, create_user_tenant_b
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
El usuario del tenant B crea un usuario nuevo.
|
||||||
|
El usuario del tenant A (SUPPORT_MANAGER) lista los usuarios.
|
||||||
|
El usuario de tenant B NO debe aparecer en la respuesta.
|
||||||
|
"""
|
||||||
|
# El usuario del tenant B crea un usuario
|
||||||
|
user_b = await create_user_tenant_b()
|
||||||
|
user_b_id = user_b["id"]
|
||||||
|
|
||||||
|
# El usuario del tenant A lista los usuarios de su tenant
|
||||||
|
response = await client_tenant_a.get("/v1/users/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
ids_visibles = {u["id"] for u in response.json()}
|
||||||
|
|
||||||
|
# El usuario de tenant B no debe ser visible para tenant A
|
||||||
|
assert user_b_id not in ids_visibles, (
|
||||||
|
f"Fallo de aislamiento: usuario de tenant B ({user_b_id}) "
|
||||||
|
f"visible para usuario de tenant A"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_sees_all_tenant_data(
|
||||||
|
client_admin,
|
||||||
|
create_ticket_tenant_a,
|
||||||
|
create_ticket_tenant_b,
|
||||||
|
create_user_tenant_a,
|
||||||
|
create_user_tenant_b,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
El ADMIN global debe poder ver tickets y usuarios de TODOS los tenants.
|
||||||
|
- Tickets: vía /v1/tickets/admin/all (endpoint multi-tenant).
|
||||||
|
- Usuarios: vía /v1/users/ (ADMIN bypasa el filtro de tenant).
|
||||||
|
"""
|
||||||
|
# Crear datos en ambos tenants
|
||||||
|
ticket_a = await create_ticket_tenant_a()
|
||||||
|
ticket_b = await create_ticket_tenant_b()
|
||||||
|
user_a = await create_user_tenant_a()
|
||||||
|
user_b = await create_user_tenant_b()
|
||||||
|
|
||||||
|
# El admin lista todos los tickets (endpoint multi-tenant)
|
||||||
|
resp_tickets = await client_admin.get("/v1/tickets/admin/all")
|
||||||
|
assert resp_tickets.status_code == 200
|
||||||
|
ids_tickets = {t["id"] for t in resp_tickets.json()}
|
||||||
|
assert ticket_a["id"] in ids_tickets, "El ADMIN no ve el ticket de tenant A"
|
||||||
|
assert ticket_b["id"] in ids_tickets, "El ADMIN no ve el ticket de tenant B"
|
||||||
|
|
||||||
|
# El admin lista todos los usuarios (ADMIN bypasa filtro de tenant)
|
||||||
|
resp_users = await client_admin.get("/v1/users/")
|
||||||
|
assert resp_users.status_code == 200
|
||||||
|
ids_users = {u["id"] for u in resp_users.json()}
|
||||||
|
assert user_a["id"] in ids_users, "El ADMIN no ve el usuario de tenant A"
|
||||||
|
assert user_b["id"] in ids_users, "El ADMIN no ve el usuario de tenant B"
|
||||||
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
|
|
||||||
import asyncio
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Add parent directory to path so we can import 'app'
|
|
||||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from app.core.database import AsyncSessionLocal
|
|
||||||
from app.models.tenant import Tenant # Import Tenant to register it
|
|
||||||
from app.models.ticket import Ticket # Import Ticket to register it
|
|
||||||
from app.models.user import User
|
|
||||||
from app.core.security import SecurityUtils
|
|
||||||
|
|
||||||
async def fix_password():
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
# Find the admin user
|
|
||||||
email = "admin@aduanasoft.com"
|
|
||||||
result = await session.execute(select(User).where(User.email == email))
|
|
||||||
user = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if user:
|
|
||||||
print(f"User {email} found.")
|
|
||||||
# Reset password to 'admin123'
|
|
||||||
new_password = "admin123"
|
|
||||||
hashed = SecurityUtils.hash_password(new_password)
|
|
||||||
user.password_hash = hashed
|
|
||||||
|
|
||||||
try:
|
|
||||||
await session.commit()
|
|
||||||
print(f"Password for {email} updated successfully!")
|
|
||||||
print(f"New password is: {new_password}")
|
|
||||||
except Exception as e:
|
|
||||||
await session.rollback()
|
|
||||||
print(f"Error updating password: {e}")
|
|
||||||
else:
|
|
||||||
print(f"User {email} not found!")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(fix_password())
|
|
||||||
68
backend/migrations/env.py
Normal file
68
backend/migrations/env.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
import os
|
||||||
|
from sqlalchemy import create_engine, pool
|
||||||
|
from sqlalchemy.engine import engine_from_config
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
# Import Base and all models
|
||||||
|
from app.core.database import Base
|
||||||
|
from app.models import tenant # Import all models explicitly
|
||||||
|
|
||||||
|
# Alembic Config object
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
if config.config_file_name:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# Get DATABASE_URL and convert to synchronous
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL")
|
||||||
|
if not DATABASE_URL:
|
||||||
|
raise RuntimeError("DATABASE_URL environment variable is not set")
|
||||||
|
|
||||||
|
SYNC_DATABASE_URL = DATABASE_URL.replace("+asyncpg", "")
|
||||||
|
|
||||||
|
# Metadata for autogenerate
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline():
|
||||||
|
"""
|
||||||
|
Run migrations in 'offline' mode.
|
||||||
|
"""
|
||||||
|
context.configure(
|
||||||
|
url=SYNC_DATABASE_URL,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online():
|
||||||
|
"""
|
||||||
|
Run migrations in 'online' mode.
|
||||||
|
"""
|
||||||
|
# Fetch the URL from Alembic configuration
|
||||||
|
alembic_config = config.get_section(config.config_ini_section)
|
||||||
|
alembic_config["sqlalchemy.url"] = SYNC_DATABASE_URL
|
||||||
|
|
||||||
|
connectable = engine_from_config(
|
||||||
|
alembic_config,
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
24
backend/migrations/script.py.mako
Normal file
24
backend/migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = ${repr(up_revision)}
|
||||||
|
down_revision = ${repr(down_revision)}
|
||||||
|
branch_labels = ${repr(branch_labels)}
|
||||||
|
depends_on = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""Add client_profiles table
|
||||||
|
|
||||||
|
Revision ID: 13362e8c493a
|
||||||
|
Revises: 48c43e9204c3
|
||||||
|
Create Date: 2026-02-05 20:11:52.534918
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '13362e8c493a'
|
||||||
|
down_revision = '48c43e9204c3'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Create client_profiles table
|
||||||
|
op.create_table('client_profiles',
|
||||||
|
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False, default=sa.text('gen_random_uuid()')),
|
||||||
|
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
|
||||||
|
# === INFORMACIÓN GENERAL ===
|
||||||
|
sa.Column('business_name', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('commercial_name', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('client_code', sa.String(length=50), nullable=True),
|
||||||
|
sa.Column('client_type', sa.String(length=50), nullable=True),
|
||||||
|
sa.Column('rfc', sa.String(length=13), nullable=True),
|
||||||
|
sa.Column('tax_id', sa.String(length=50), nullable=True),
|
||||||
|
|
||||||
|
# === UBICACIÓN ===
|
||||||
|
sa.Column('country', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('state', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('city', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('address', sa.Text(), nullable=True),
|
||||||
|
sa.Column('external_number', sa.String(length=20), nullable=True),
|
||||||
|
sa.Column('internal_number', sa.String(length=20), nullable=True),
|
||||||
|
sa.Column('postal_code', sa.String(length=10), nullable=True),
|
||||||
|
sa.Column('neighborhood', sa.String(length=100), nullable=True),
|
||||||
|
|
||||||
|
# === CONTACTO ===
|
||||||
|
sa.Column('main_phone', sa.String(length=20), nullable=True),
|
||||||
|
sa.Column('secondary_phone', sa.String(length=20), nullable=True),
|
||||||
|
sa.Column('direct_phone', sa.String(length=20), nullable=True),
|
||||||
|
sa.Column('phone_extension', sa.String(length=10), nullable=True),
|
||||||
|
sa.Column('fax', sa.String(length=20), nullable=True),
|
||||||
|
|
||||||
|
# === INFORMACIÓN ADICIONAL ===
|
||||||
|
sa.Column('business_hours', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('website', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('main_email', sa.String(length=320), nullable=True),
|
||||||
|
sa.Column('billing_email', sa.String(length=320), nullable=True),
|
||||||
|
|
||||||
|
# === MARKETING ===
|
||||||
|
sa.Column('advertising_medium', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('nationality', sa.String(length=100), nullable=True),
|
||||||
|
|
||||||
|
# === CONFIGURACIÓN EMPRESARIAL ===
|
||||||
|
sa.Column('logo_url', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('company_representative', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('legal_representative', sa.String(length=255), nullable=True),
|
||||||
|
|
||||||
|
# === FINANZAS/FACTURACIÓN ===
|
||||||
|
sa.Column('credit_limit', sa.Numeric(precision=15, scale=2), nullable=True),
|
||||||
|
sa.Column('payment_terms', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('preferred_currency', sa.String(length=3), nullable=False, default='MXN'),
|
||||||
|
|
||||||
|
# === METADATOS ===
|
||||||
|
sa.Column('send_to_billing', sa.Boolean(), nullable=False, default=False),
|
||||||
|
sa.Column('is_active_client', sa.Boolean(), nullable=False, default=True),
|
||||||
|
sa.Column('is_prospect', sa.Boolean(), nullable=False, default=False),
|
||||||
|
sa.Column('notes', sa.Text(), nullable=True),
|
||||||
|
|
||||||
|
# === TIMESTAMPS ===
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, default=sa.func.now()),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, default=sa.func.now(), onupdate=sa.func.now()),
|
||||||
|
|
||||||
|
# Constraints
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
|
||||||
|
sa.UniqueConstraint('tenant_id') # Relación uno a uno con tenant
|
||||||
|
)
|
||||||
|
|
||||||
|
# Crear índices para optimizar consultas
|
||||||
|
op.create_index('idx_client_profiles_tenant_id', 'client_profiles', ['tenant_id'])
|
||||||
|
op.create_index('idx_client_profiles_rfc', 'client_profiles', ['rfc'])
|
||||||
|
op.create_index('idx_client_profiles_business_name', 'client_profiles', ['business_name'])
|
||||||
|
op.create_index('idx_client_profiles_client_code', 'client_profiles', ['client_code'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop índices
|
||||||
|
op.drop_index('idx_client_profiles_client_code', table_name='client_profiles')
|
||||||
|
op.drop_index('idx_client_profiles_business_name', table_name='client_profiles')
|
||||||
|
op.drop_index('idx_client_profiles_rfc', table_name='client_profiles')
|
||||||
|
op.drop_index('idx_client_profiles_tenant_id', table_name='client_profiles')
|
||||||
|
|
||||||
|
# Drop tabla
|
||||||
|
op.drop_table('client_profiles')
|
||||||
464
backend/migrations/versions/35742cfbb850_create_all_tables.py
Normal file
464
backend/migrations/versions/35742cfbb850_create_all_tables.py
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
"""Create all tables
|
||||||
|
|
||||||
|
Revision ID: 35742cfbb850
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-02-05 19:37:58.771067
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '35742cfbb850'
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index('idx_audit_logs_action', table_name='audit_logs')
|
||||||
|
op.drop_index('idx_audit_logs_correlation_id', table_name='audit_logs')
|
||||||
|
op.drop_index('idx_audit_logs_created_at', table_name='audit_logs')
|
||||||
|
op.drop_index('idx_audit_logs_resource', table_name='audit_logs')
|
||||||
|
op.drop_index('idx_audit_logs_tenant_id', table_name='audit_logs')
|
||||||
|
op.drop_index('idx_audit_logs_user_id', table_name='audit_logs')
|
||||||
|
op.drop_table('audit_logs')
|
||||||
|
op.drop_index('idx_tickets_assigned_to', table_name='tickets')
|
||||||
|
op.drop_index('idx_tickets_category', table_name='tickets')
|
||||||
|
op.drop_index('idx_tickets_created_at', table_name='tickets')
|
||||||
|
op.drop_index('idx_tickets_created_by', table_name='tickets')
|
||||||
|
op.drop_index('idx_tickets_number', table_name='tickets')
|
||||||
|
op.drop_index('idx_tickets_priority', table_name='tickets')
|
||||||
|
op.drop_index('idx_tickets_sla_resolution', table_name='tickets')
|
||||||
|
op.drop_index('idx_tickets_sla_response', table_name='tickets')
|
||||||
|
op.drop_index('idx_tickets_status', table_name='tickets')
|
||||||
|
op.drop_index('idx_tickets_tenant_id', table_name='tickets')
|
||||||
|
op.drop_table('tickets')
|
||||||
|
op.drop_index('idx_refresh_tokens_expires', table_name='refresh_tokens')
|
||||||
|
op.drop_index('idx_refresh_tokens_hash', table_name='refresh_tokens')
|
||||||
|
op.drop_index('idx_refresh_tokens_user_id', table_name='refresh_tokens')
|
||||||
|
op.drop_table('refresh_tokens')
|
||||||
|
op.drop_index('idx_notification_logs_created_at', table_name='notification_logs')
|
||||||
|
op.drop_index('idx_notification_logs_recipient', table_name='notification_logs')
|
||||||
|
op.drop_index('idx_notification_logs_status', table_name='notification_logs')
|
||||||
|
op.drop_index('idx_notification_logs_tenant_id', table_name='notification_logs')
|
||||||
|
op.drop_index('idx_notification_logs_ticket_id', table_name='notification_logs')
|
||||||
|
op.drop_table('notification_logs')
|
||||||
|
op.drop_table('affected_systems')
|
||||||
|
op.drop_index('idx_ticket_comments_author_id', table_name='ticket_comments')
|
||||||
|
op.drop_index('idx_ticket_comments_created_at', table_name='ticket_comments')
|
||||||
|
op.drop_index('idx_ticket_comments_ticket_id', table_name='ticket_comments')
|
||||||
|
op.drop_table('ticket_comments')
|
||||||
|
op.drop_index('idx_clients_name', table_name='clients')
|
||||||
|
op.drop_index('idx_clients_properties', table_name='clients', postgresql_using='gin')
|
||||||
|
op.drop_index('idx_clients_tax_id', table_name='clients')
|
||||||
|
op.drop_index('idx_clients_tenant_id', table_name='clients')
|
||||||
|
op.drop_table('clients')
|
||||||
|
op.drop_table('categories')
|
||||||
|
op.drop_index('idx_users_active', table_name='users')
|
||||||
|
op.drop_index('idx_users_email', table_name='users')
|
||||||
|
op.drop_index('idx_users_role', table_name='users')
|
||||||
|
op.drop_index('idx_users_tenant_email', table_name='users')
|
||||||
|
op.drop_index('idx_users_tenant_id', table_name='users')
|
||||||
|
op.drop_table('users')
|
||||||
|
op.drop_table('systems')
|
||||||
|
op.drop_table('ticket_categories')
|
||||||
|
op.drop_index('idx_ticket_status_history_changed_by', table_name='ticket_status_history')
|
||||||
|
op.drop_index('idx_ticket_status_history_created_at', table_name='ticket_status_history')
|
||||||
|
op.drop_index('idx_ticket_status_history_ticket_id', table_name='ticket_status_history')
|
||||||
|
op.drop_table('ticket_status_history')
|
||||||
|
op.drop_index('idx_ticket_attachments_comment_id', table_name='ticket_attachments')
|
||||||
|
op.drop_index('idx_ticket_attachments_ticket_id', table_name='ticket_attachments')
|
||||||
|
op.drop_index('idx_ticket_attachments_uploaded_by', table_name='ticket_attachments')
|
||||||
|
op.drop_table('ticket_attachments')
|
||||||
|
op.drop_index('idx_email_templates_tenant_id', table_name='email_templates')
|
||||||
|
op.drop_index('idx_email_templates_type', table_name='email_templates')
|
||||||
|
op.drop_table('email_templates')
|
||||||
|
op.alter_column('tenants', 'timezone',
|
||||||
|
existing_type=sa.VARCHAR(length=50),
|
||||||
|
nullable=False,
|
||||||
|
existing_server_default=sa.text("'UTC'::character varying"))
|
||||||
|
op.alter_column('tenants', 'locale',
|
||||||
|
existing_type=sa.VARCHAR(length=10),
|
||||||
|
nullable=False,
|
||||||
|
existing_server_default=sa.text("'es-ES'::character varying"))
|
||||||
|
op.alter_column('tenants', 'max_users',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
nullable=False,
|
||||||
|
existing_server_default=sa.text('50'))
|
||||||
|
op.alter_column('tenants', 'max_storage_mb',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
nullable=False,
|
||||||
|
existing_server_default=sa.text('1024'))
|
||||||
|
op.alter_column('tenants', 'allowed_file_types',
|
||||||
|
existing_type=postgresql.ARRAY(sa.TEXT()),
|
||||||
|
type_=sa.ARRAY(sa.String()),
|
||||||
|
nullable=False,
|
||||||
|
existing_server_default=sa.text("ARRAY['pdf'::text, 'jpg'::text, 'jpeg'::text, 'png'::text, 'doc'::text, 'docx'::text, 'xls'::text, 'xlsx'::text, 'txt'::text]"))
|
||||||
|
op.alter_column('tenants', 'status',
|
||||||
|
existing_type=sa.VARCHAR(length=20),
|
||||||
|
nullable=False,
|
||||||
|
existing_server_default=sa.text("'active'::character varying"))
|
||||||
|
op.alter_column('tenants', 'created_at',
|
||||||
|
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
existing_server_default=sa.text('now()'))
|
||||||
|
op.alter_column('tenants', 'updated_at',
|
||||||
|
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
existing_server_default=sa.text('now()'))
|
||||||
|
op.drop_index('idx_tenants_domain', table_name='tenants')
|
||||||
|
op.drop_index('idx_tenants_slug', table_name='tenants')
|
||||||
|
op.drop_index('idx_tenants_status', table_name='tenants')
|
||||||
|
op.drop_table_comment(
|
||||||
|
'tenants',
|
||||||
|
existing_comment='Organizaciones cliente en el sistema multi-tenant',
|
||||||
|
schema=None
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table_comment(
|
||||||
|
'tenants',
|
||||||
|
'Organizaciones cliente en el sistema multi-tenant',
|
||||||
|
existing_comment=None,
|
||||||
|
schema=None
|
||||||
|
)
|
||||||
|
op.create_index('idx_tenants_status', 'tenants', ['status'], unique=False)
|
||||||
|
op.create_index('idx_tenants_slug', 'tenants', ['slug'], unique=False)
|
||||||
|
op.create_index('idx_tenants_domain', 'tenants', ['domain'], unique=False)
|
||||||
|
op.alter_column('tenants', 'updated_at',
|
||||||
|
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
existing_server_default=sa.text('now()'))
|
||||||
|
op.alter_column('tenants', 'created_at',
|
||||||
|
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
existing_server_default=sa.text('now()'))
|
||||||
|
op.alter_column('tenants', 'status',
|
||||||
|
existing_type=sa.VARCHAR(length=20),
|
||||||
|
nullable=True,
|
||||||
|
existing_server_default=sa.text("'active'::character varying"))
|
||||||
|
op.alter_column('tenants', 'allowed_file_types',
|
||||||
|
existing_type=sa.ARRAY(sa.String()),
|
||||||
|
type_=postgresql.ARRAY(sa.TEXT()),
|
||||||
|
nullable=True,
|
||||||
|
existing_server_default=sa.text("ARRAY['pdf'::text, 'jpg'::text, 'jpeg'::text, 'png'::text, 'doc'::text, 'docx'::text, 'xls'::text, 'xlsx'::text, 'txt'::text]"))
|
||||||
|
op.alter_column('tenants', 'max_storage_mb',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
nullable=True,
|
||||||
|
existing_server_default=sa.text('1024'))
|
||||||
|
op.alter_column('tenants', 'max_users',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
nullable=True,
|
||||||
|
existing_server_default=sa.text('50'))
|
||||||
|
op.alter_column('tenants', 'locale',
|
||||||
|
existing_type=sa.VARCHAR(length=10),
|
||||||
|
nullable=True,
|
||||||
|
existing_server_default=sa.text("'es-ES'::character varying"))
|
||||||
|
op.alter_column('tenants', 'timezone',
|
||||||
|
existing_type=sa.VARCHAR(length=50),
|
||||||
|
nullable=True,
|
||||||
|
existing_server_default=sa.text("'UTC'::character varying"))
|
||||||
|
op.create_table('email_templates',
|
||||||
|
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.UUID(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('subject_template', sa.TEXT(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('body_template', sa.TEXT(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('template_type', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('available_variables', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('is_active', sa.BOOLEAN(), server_default=sa.text('true'), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name='email_templates_tenant_id_fkey'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name='email_templates_pkey')
|
||||||
|
)
|
||||||
|
op.create_index('idx_email_templates_type', 'email_templates', ['template_type'], unique=False)
|
||||||
|
op.create_index('idx_email_templates_tenant_id', 'email_templates', ['tenant_id'], unique=False)
|
||||||
|
op.create_table('ticket_attachments',
|
||||||
|
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('ticket_id', sa.UUID(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('comment_id', sa.UUID(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('uploaded_by', sa.UUID(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('filename', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('original_filename', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('mime_type', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('file_size', sa.INTEGER(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('file_path', sa.VARCHAR(length=500), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('md5_hash', sa.VARCHAR(length=32), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('sha256_hash', sa.VARCHAR(length=64), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['comment_id'], ['ticket_comments.id'], name='ticket_attachments_comment_id_fkey', ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], name='ticket_attachments_ticket_id_fkey', ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], name='ticket_attachments_uploaded_by_fkey'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name='ticket_attachments_pkey'),
|
||||||
|
comment='Archivos adjuntos en tickets'
|
||||||
|
)
|
||||||
|
op.create_index('idx_ticket_attachments_uploaded_by', 'ticket_attachments', ['uploaded_by'], unique=False)
|
||||||
|
op.create_index('idx_ticket_attachments_ticket_id', 'ticket_attachments', ['ticket_id'], unique=False)
|
||||||
|
op.create_index('idx_ticket_attachments_comment_id', 'ticket_attachments', ['comment_id'], unique=False)
|
||||||
|
op.create_table('ticket_status_history',
|
||||||
|
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('ticket_id', sa.UUID(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('changed_by', sa.UUID(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('old_status', postgresql.ENUM('NEW', 'TRIAGE', 'IN_PROGRESS', 'WAITING_CUSTOMER', 'RESOLVED', 'CLOSED', 'REOPENED', name='ticket_status_enum'), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('new_status', postgresql.ENUM('NEW', 'TRIAGE', 'IN_PROGRESS', 'WAITING_CUSTOMER', 'RESOLVED', 'CLOSED', 'REOPENED', name='ticket_status_enum'), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('old_assigned_to', sa.UUID(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('new_assigned_to', sa.UUID(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('comment', sa.TEXT(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['changed_by'], ['users.id'], name='ticket_status_history_changed_by_fkey'),
|
||||||
|
sa.ForeignKeyConstraint(['new_assigned_to'], ['users.id'], name='ticket_status_history_new_assigned_to_fkey'),
|
||||||
|
sa.ForeignKeyConstraint(['old_assigned_to'], ['users.id'], name='ticket_status_history_old_assigned_to_fkey'),
|
||||||
|
sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], name='ticket_status_history_ticket_id_fkey', ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name='ticket_status_history_pkey')
|
||||||
|
)
|
||||||
|
op.create_index('idx_ticket_status_history_ticket_id', 'ticket_status_history', ['ticket_id'], unique=False)
|
||||||
|
op.create_index('idx_ticket_status_history_created_at', 'ticket_status_history', ['created_at'], unique=False)
|
||||||
|
op.create_index('idx_ticket_status_history_changed_by', 'ticket_status_history', ['changed_by'], unique=False)
|
||||||
|
op.create_table('ticket_categories',
|
||||||
|
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.UUID(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('color', sa.VARCHAR(length=7), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('sla_response_hours', sa.INTEGER(), server_default=sa.text('24'), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('sla_resolution_hours', sa.INTEGER(), server_default=sa.text('72'), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('auto_assign_to', sa.UUID(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('is_active', sa.BOOLEAN(), server_default=sa.text('true'), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['auto_assign_to'], ['users.id'], name='ticket_categories_auto_assign_to_fkey'),
|
||||||
|
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name='ticket_categories_tenant_id_fkey', ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name='ticket_categories_pkey'),
|
||||||
|
sa.UniqueConstraint('tenant_id', 'name', name='ticket_categories_tenant_id_name_key'),
|
||||||
|
postgresql_ignore_search_path=False
|
||||||
|
)
|
||||||
|
op.create_table('systems',
|
||||||
|
sa.Column('name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('id', sa.UUID(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id', name='systems_pkey')
|
||||||
|
)
|
||||||
|
op.create_table('users',
|
||||||
|
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.UUID(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('email', sa.VARCHAR(length=320), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('first_name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('last_name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('avatar_url', sa.VARCHAR(length=500), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('password_hash', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('role', postgresql.ENUM('ADMIN', 'SUPPORT_MANAGER', 'AGENT', 'AUDITOR', 'CLIENT_ADMIN', 'CLIENT_USER', name='user_role_enum'), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('totp_secret', sa.VARCHAR(length=32), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('totp_enabled', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('backup_codes', postgresql.ARRAY(sa.TEXT()), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('is_active', sa.BOOLEAN(), server_default=sa.text('true'), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('email_verified', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('last_login', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('last_activity', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('language', sa.VARCHAR(length=10), server_default=sa.text("'es'::character varying"), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('timezone', sa.VARCHAR(length=50), server_default=sa.text("'UTC'::character varying"), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('notifications_email', sa.BOOLEAN(), server_default=sa.text('true'), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name='users_tenant_id_fkey', ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name='users_pkey'),
|
||||||
|
sa.UniqueConstraint('tenant_id', 'email', name='users_tenant_id_email_key'),
|
||||||
|
comment='Usuarios del sistema (internos y clientes)',
|
||||||
|
postgresql_ignore_search_path=False
|
||||||
|
)
|
||||||
|
op.create_index('idx_users_tenant_id', 'users', ['tenant_id'], unique=False)
|
||||||
|
op.create_index('idx_users_tenant_email', 'users', ['tenant_id', 'email'], unique=False)
|
||||||
|
op.create_index('idx_users_role', 'users', ['role'], unique=False)
|
||||||
|
op.create_index('idx_users_email', 'users', ['email'], unique=False)
|
||||||
|
op.create_index('idx_users_active', 'users', ['is_active'], unique=False)
|
||||||
|
op.create_table('categories',
|
||||||
|
sa.Column('name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.UUID(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('id', sa.UUID(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name='categories_tenant_id_fkey', ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name='categories_pkey')
|
||||||
|
)
|
||||||
|
op.create_table('clients',
|
||||||
|
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.UUID(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('code', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('name', sa.VARCHAR(length=200), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('tax_id', sa.VARCHAR(length=20), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('client_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('account_manager', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('address_street', sa.TEXT(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('address_ext_num', sa.VARCHAR(length=20), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('neighborhood', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('zip_code', sa.VARCHAR(length=10), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('city', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('state', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('country', sa.VARCHAR(length=100), server_default=sa.text("'Mexico'::character varying"), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('phone_primary', sa.VARCHAR(length=20), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('phone_secondary', sa.VARCHAR(length=20), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('fax', sa.VARCHAR(length=20), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('email', sa.VARCHAR(length=320), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('website', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('status', postgresql.ENUM('prospect', 'active', 'suspended', 'cancelled', name='client_status_enum'), server_default=sa.text("'prospect'::client_status_enum"), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('logo_url', sa.VARCHAR(length=500), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('properties', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('is_active', sa.BOOLEAN(), server_default=sa.text('true'), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name='clients_tenant_id_fkey', ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name='clients_pkey'),
|
||||||
|
sa.UniqueConstraint('tenant_id', 'code', name='clients_tenant_id_code_key'),
|
||||||
|
sa.UniqueConstraint('tenant_id', 'tax_id', name='clients_tenant_id_tax_id_key')
|
||||||
|
)
|
||||||
|
op.create_index('idx_clients_tenant_id', 'clients', ['tenant_id'], unique=False)
|
||||||
|
op.create_index('idx_clients_tax_id', 'clients', ['tax_id'], unique=False)
|
||||||
|
op.create_index('idx_clients_properties', 'clients', ['properties'], unique=False, postgresql_using='gin')
|
||||||
|
op.create_index('idx_clients_name', 'clients', ['name'], unique=False)
|
||||||
|
op.create_table('ticket_comments',
|
||||||
|
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('ticket_id', sa.UUID(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('author_id', sa.UUID(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('content', sa.TEXT(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('is_internal', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['author_id'], ['users.id'], name='ticket_comments_author_id_fkey'),
|
||||||
|
sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], name='ticket_comments_ticket_id_fkey', ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name='ticket_comments_pkey'),
|
||||||
|
comment='Comentarios en tickets'
|
||||||
|
)
|
||||||
|
op.create_index('idx_ticket_comments_ticket_id', 'ticket_comments', ['ticket_id'], unique=False)
|
||||||
|
op.create_index('idx_ticket_comments_created_at', 'ticket_comments', ['created_at'], unique=False)
|
||||||
|
op.create_index('idx_ticket_comments_author_id', 'ticket_comments', ['author_id'], unique=False)
|
||||||
|
op.create_table('affected_systems',
|
||||||
|
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.UUID(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('is_active', sa.BOOLEAN(), server_default=sa.text('true'), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name='affected_systems_tenant_id_fkey', ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name='affected_systems_pkey'),
|
||||||
|
sa.UniqueConstraint('tenant_id', 'name', name='affected_systems_tenant_id_name_key'),
|
||||||
|
postgresql_ignore_search_path=False
|
||||||
|
)
|
||||||
|
op.create_table('notification_logs',
|
||||||
|
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.UUID(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('recipient_email', sa.VARCHAR(length=320), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('subject', sa.VARCHAR(length=500), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('template_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('ticket_id', sa.UUID(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('user_id', sa.UUID(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('status', sa.VARCHAR(length=20), server_default=sa.text("'pending'::character varying"), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('error_message', sa.TEXT(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('provider', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('external_id', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('sent_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
|
||||||
|
sa.CheckConstraint("status::text = ANY (ARRAY['pending'::character varying, 'sent'::character varying, 'failed'::character varying, 'bounced'::character varying]::text[])", name='notification_logs_status_check'),
|
||||||
|
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name='notification_logs_tenant_id_fkey'),
|
||||||
|
sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], name='notification_logs_ticket_id_fkey'),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name='notification_logs_user_id_fkey'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name='notification_logs_pkey')
|
||||||
|
)
|
||||||
|
op.create_index('idx_notification_logs_ticket_id', 'notification_logs', ['ticket_id'], unique=False)
|
||||||
|
op.create_index('idx_notification_logs_tenant_id', 'notification_logs', ['tenant_id'], unique=False)
|
||||||
|
op.create_index('idx_notification_logs_status', 'notification_logs', ['status'], unique=False)
|
||||||
|
op.create_index('idx_notification_logs_recipient', 'notification_logs', ['recipient_email'], unique=False)
|
||||||
|
op.create_index('idx_notification_logs_created_at', 'notification_logs', ['created_at'], unique=False)
|
||||||
|
op.create_table('refresh_tokens',
|
||||||
|
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('user_id', sa.UUID(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('token_hash', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('device_info', sa.VARCHAR(length=500), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('ip_address', postgresql.INET(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('expires_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('revoked', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name='refresh_tokens_user_id_fkey', ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name='refresh_tokens_pkey')
|
||||||
|
)
|
||||||
|
op.create_index('idx_refresh_tokens_user_id', 'refresh_tokens', ['user_id'], unique=False)
|
||||||
|
op.create_index('idx_refresh_tokens_hash', 'refresh_tokens', ['token_hash'], unique=False)
|
||||||
|
op.create_index('idx_refresh_tokens_expires', 'refresh_tokens', ['expires_at'], unique=False)
|
||||||
|
op.create_table('tickets',
|
||||||
|
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.UUID(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('ticket_number', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('subject', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('description', sa.TEXT(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('category_id', sa.UUID(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('priority', postgresql.ENUM('LOW', 'MEDIUM', 'HIGH', 'URGENT', name='ticket_priority_enum'), server_default=sa.text("'MEDIUM'::ticket_priority_enum"), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('affected_system_id', sa.UUID(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('created_by', sa.UUID(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('assigned_to', sa.UUID(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('status', postgresql.ENUM('NEW', 'TRIAGE', 'IN_PROGRESS', 'WAITING_CUSTOMER', 'RESOLVED', 'CLOSED', 'REOPENED', name='ticket_status_enum'), server_default=sa.text("'NEW'::ticket_status_enum"), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('sla_response_due', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('sla_resolution_due', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('first_response_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('resolved_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('rating', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('rating_comment', sa.TEXT(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('rated_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
|
||||||
|
sa.CheckConstraint('rating >= 1 AND rating <= 5', name='tickets_rating_check'),
|
||||||
|
sa.ForeignKeyConstraint(['affected_system_id'], ['affected_systems.id'], name='tickets_affected_system_id_fkey'),
|
||||||
|
sa.ForeignKeyConstraint(['assigned_to'], ['users.id'], name='tickets_assigned_to_fkey'),
|
||||||
|
sa.ForeignKeyConstraint(['category_id'], ['ticket_categories.id'], name='tickets_category_id_fkey'),
|
||||||
|
sa.ForeignKeyConstraint(['created_by'], ['users.id'], name='tickets_created_by_fkey'),
|
||||||
|
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name='tickets_tenant_id_fkey', ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name='tickets_pkey'),
|
||||||
|
sa.UniqueConstraint('tenant_id', 'ticket_number', name='tickets_tenant_id_ticket_number_key'),
|
||||||
|
comment='Tickets de soporte - core del negocio'
|
||||||
|
)
|
||||||
|
op.create_index('idx_tickets_tenant_id', 'tickets', ['tenant_id'], unique=False)
|
||||||
|
op.create_index('idx_tickets_status', 'tickets', ['status'], unique=False)
|
||||||
|
op.create_index('idx_tickets_sla_response', 'tickets', ['sla_response_due'], unique=False)
|
||||||
|
op.create_index('idx_tickets_sla_resolution', 'tickets', ['sla_resolution_due'], unique=False)
|
||||||
|
op.create_index('idx_tickets_priority', 'tickets', ['priority'], unique=False)
|
||||||
|
op.create_index('idx_tickets_number', 'tickets', ['ticket_number'], unique=False)
|
||||||
|
op.create_index('idx_tickets_created_by', 'tickets', ['created_by'], unique=False)
|
||||||
|
op.create_index('idx_tickets_created_at', 'tickets', ['created_at'], unique=False)
|
||||||
|
op.create_index('idx_tickets_category', 'tickets', ['category_id'], unique=False)
|
||||||
|
op.create_index('idx_tickets_assigned_to', 'tickets', ['assigned_to'], unique=False)
|
||||||
|
op.create_table('audit_logs',
|
||||||
|
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.UUID(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('user_id', sa.UUID(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('action', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('resource_type', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('resource_id', sa.UUID(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('ip_address', postgresql.INET(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('user_agent', sa.TEXT(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('correlation_id', sa.UUID(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('old_values', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('new_values', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name='audit_logs_tenant_id_fkey'),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name='audit_logs_user_id_fkey'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name='audit_logs_pkey'),
|
||||||
|
comment='Bitácora de acciones para auditoría y compliance'
|
||||||
|
)
|
||||||
|
op.create_index('idx_audit_logs_user_id', 'audit_logs', ['user_id'], unique=False)
|
||||||
|
op.create_index('idx_audit_logs_tenant_id', 'audit_logs', ['tenant_id'], unique=False)
|
||||||
|
op.create_index('idx_audit_logs_resource', 'audit_logs', ['resource_type', 'resource_id'], unique=False)
|
||||||
|
op.create_index('idx_audit_logs_created_at', 'audit_logs', ['created_at'], unique=False)
|
||||||
|
op.create_index('idx_audit_logs_correlation_id', 'audit_logs', ['correlation_id'], unique=False)
|
||||||
|
op.create_index('idx_audit_logs_action', 'audit_logs', ['action'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Test migration setup
|
||||||
|
|
||||||
|
Revision ID: 48c43e9204c3
|
||||||
|
Revises: 35742cfbb850
|
||||||
|
Create Date: 2026-02-05 19:39:07.431453
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '48c43e9204c3'
|
||||||
|
down_revision = '35742cfbb850'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
137
backend/migrations/versions/a1b2c3d4e5f6_add_audit_logs_table.py
Normal file
137
backend/migrations/versions/a1b2c3d4e5f6_add_audit_logs_table.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"""add_audit_logs_table
|
||||||
|
|
||||||
|
Revision ID: a1b2c3d4e5f6
|
||||||
|
Revises: 13362e8c493a
|
||||||
|
Create Date: 2026-02-12 10:00:00.000000
|
||||||
|
|
||||||
|
Registra el modelo AuditLog en Alembic.
|
||||||
|
La tabla audit_logs ya existe en schema.sql, esta migración solo
|
||||||
|
la registra en el control de versiones de Alembic.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'a1b2c3d4e5f6'
|
||||||
|
down_revision = '13362e8c493a'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
"""
|
||||||
|
Verificar que audit_logs existe y registrarla en Alembic.
|
||||||
|
|
||||||
|
La tabla fue creada por schema.sql, esta migración solo verifica
|
||||||
|
que exista y esté disponible para usar.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
tables = inspector.get_table_names()
|
||||||
|
|
||||||
|
if 'audit_logs' in tables:
|
||||||
|
print("OK Tabla audit_logs encontrada (creada por schema.sql)")
|
||||||
|
print("OK Modelo AuditLog registrado en Alembic")
|
||||||
|
|
||||||
|
# Verificar que tenga los índices necesarios
|
||||||
|
existing_indexes = [idx['name'] for idx in inspector.get_indexes('audit_logs')]
|
||||||
|
|
||||||
|
required_indexes = [
|
||||||
|
'idx_audit_logs_tenant_id',
|
||||||
|
'idx_audit_logs_user_id',
|
||||||
|
'idx_audit_logs_action',
|
||||||
|
'idx_audit_logs_correlation_id',
|
||||||
|
'idx_audit_logs_created_at',
|
||||||
|
]
|
||||||
|
|
||||||
|
missing_indexes = [idx for idx in required_indexes if idx not in existing_indexes]
|
||||||
|
|
||||||
|
if missing_indexes:
|
||||||
|
print(f"WARN Indices faltantes: {', '.join(missing_indexes)}")
|
||||||
|
print(" (Esto es normal si usaste schema.sql completo)")
|
||||||
|
else:
|
||||||
|
print("OK Todos los índices necesarios están presentes")
|
||||||
|
|
||||||
|
else:
|
||||||
|
print("ERROR La tabla audit_logs NO existe")
|
||||||
|
print(" Ejecuta: docker-compose exec -T postgres psql -U postgres -d servicemanager < db/schema.sql")
|
||||||
|
raise Exception(
|
||||||
|
"La tabla audit_logs no existe. "
|
||||||
|
"Por favor ejecuta el schema.sql completo primero."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
"""
|
||||||
|
No eliminar la tabla - fue creada por schema.sql.
|
||||||
|
|
||||||
|
Solo des-registrar de Alembic.
|
||||||
|
"""
|
||||||
|
print("INFO Tabla audit_logs NO será eliminada (creada por schema.sql)")
|
||||||
|
print("OK Modelo AuditLog des-registrado de Alembic")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
"""
|
||||||
|
Verificar que audit_logs existe y registrarla en Alembic.
|
||||||
|
|
||||||
|
La tabla fue creada por schema.sql, esta migraci├│n solo verifica
|
||||||
|
que exista y está disponible para usar.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
tables = inspector.get_table_names()
|
||||||
|
|
||||||
|
if 'audit_logs' in tables:
|
||||||
|
print(" Tabla audit_logs encontrada (creada por schema.sql)")
|
||||||
|
print(" Modelo AuditLog registrado en Alembic")
|
||||||
|
|
||||||
|
# Verificar que tenga los índices necesarios
|
||||||
|
existing_indexes = [idx['name'] for idx in inspector.get_indexes('audit_logs')]
|
||||||
|
missing_indexes = []
|
||||||
|
|
||||||
|
required_indexes = [
|
||||||
|
'idx_audit_logs_tenant_id',
|
||||||
|
'idx_audit_logs_user_id',
|
||||||
|
'idx_audit_logs_action',
|
||||||
|
'idx_audit_logs_correlation_id',
|
||||||
|
'idx_audit_logs_created_at'
|
||||||
|
]
|
||||||
|
|
||||||
|
for idx in required_indexes:
|
||||||
|
if idx not in existing_indexes:
|
||||||
|
missing_indexes.append(idx)
|
||||||
|
|
||||||
|
if missing_indexes:
|
||||||
|
print(f"ÔÜá´©Å ├ìndices faltantes: {', '.join(missing_indexes)}")
|
||||||
|
print(" (Esto es normal si usaste schema.sql completo)")
|
||||||
|
else:
|
||||||
|
print("Ô£à Todos los ├¡ndices necesarios est├ín presentes")
|
||||||
|
|
||||||
|
else:
|
||||||
|
print("ÔÜá´©Å La tabla audit_logs NO existe")
|
||||||
|
print(" Ejecuta: docker-compose exec -T postgres psql -U postgres -d servicemanager < db/schema.sql")
|
||||||
|
print(" O crea la tabla manualmente desde schema.sql")
|
||||||
|
|
||||||
|
# No crear la tabla aquí - debe venir de schema.sql para mantener consistencia
|
||||||
|
raise Exception(
|
||||||
|
"La tabla audit_logs no existe. "
|
||||||
|
"Por favor ejecuta el schema.sql completo primero."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
"""
|
||||||
|
No eliminar la tabla - fue creada por schema.sql.
|
||||||
|
|
||||||
|
Solo des-registrar de Alembic.
|
||||||
|
"""
|
||||||
|
print("Ôä╣´©Å Tabla audit_logs NO ser├í eliminada (creada por schema.sql)")
|
||||||
|
print(" Modelo AuditLog des-registrado de Alembic")
|
||||||
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""add_ticket_indexes
|
||||||
|
|
||||||
|
Revision ID: b7c8d9e0f1a2
|
||||||
|
Revises: fix_client_timestamps
|
||||||
|
Create Date: 2026-03-03 00:00:00.000000
|
||||||
|
|
||||||
|
Agrega índices a la tabla tickets para optimizar queries frecuentes:
|
||||||
|
- idx_tickets_status → filtros por estado
|
||||||
|
- idx_tickets_priority → filtros por prioridad
|
||||||
|
- idx_tickets_assigned_to → tickets por agente asignado
|
||||||
|
- idx_tickets_tenant_status → compuesto multi-tenant (tenant_id, status)
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'b7c8d9e0f1a2'
|
||||||
|
down_revision = 'fix_client_timestamps'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets (status)"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_tickets_priority ON tickets (priority)"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_tickets_assigned_to "
|
||||||
|
"ON tickets (assigned_to) WHERE assigned_to IS NOT NULL"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_tickets_tenant_status "
|
||||||
|
"ON tickets (tenant_id, status)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP INDEX IF EXISTS idx_tickets_tenant_status")
|
||||||
|
op.execute("DROP INDEX IF EXISTS idx_tickets_assigned_to")
|
||||||
|
op.execute("DROP INDEX IF EXISTS idx_tickets_priority")
|
||||||
|
op.execute("DROP INDEX IF EXISTS idx_tickets_status")
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Add CLIENT_MANAGER, CLIENT_AGENT, CLIENT_AUDITOR to user_role_enum
|
||||||
|
|
||||||
|
Revision ID: c1d2e3f4a5b6
|
||||||
|
Revises: b7c8d9e0f1a2
|
||||||
|
Create Date: 2026-03-03 10:00:00.000000
|
||||||
|
|
||||||
|
Agrega tres nuevos roles de cliente al enum PostgreSQL:
|
||||||
|
- CLIENT_MANAGER → gestiona tickets y usuarios del tenant
|
||||||
|
- CLIENT_AGENT → atiende tickets del tenant
|
||||||
|
- CLIENT_AUDITOR → auditoría de solo lectura del tenant
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'c1d2e3f4a5b6'
|
||||||
|
down_revision = 'b7c8d9e0f1a2'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# PostgreSQL permite agregar valores a un enum con ADD VALUE.
|
||||||
|
# IF NOT EXISTS evita error si la migración se aplica dos veces.
|
||||||
|
op.execute("ALTER TYPE user_role_enum ADD VALUE IF NOT EXISTS 'CLIENT_MANAGER'")
|
||||||
|
op.execute("ALTER TYPE user_role_enum ADD VALUE IF NOT EXISTS 'CLIENT_AGENT'")
|
||||||
|
op.execute("ALTER TYPE user_role_enum ADD VALUE IF NOT EXISTS 'CLIENT_AUDITOR'")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# PostgreSQL no permite eliminar valores de un enum con ALTER TYPE DROP VALUE.
|
||||||
|
# Para revertir habría que recrear el tipo completo desde cero, lo que requiere
|
||||||
|
# actualizar todas las columnas que lo usan. Se documenta como no reversible
|
||||||
|
# automáticamente — usar con precaución.
|
||||||
|
pass
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Remove CLIENT_MANAGER, CLIENT_AGENT, CLIENT_AUDITOR from user_role_enum
|
||||||
|
|
||||||
|
Revision ID: d2e3f4a5b6c7
|
||||||
|
Revises: c1d2e3f4a5b6
|
||||||
|
Create Date: 2026-03-03 14:00:00.000000
|
||||||
|
|
||||||
|
Consolida 9 roles → 6 roles migrando datos primero y luego recreando
|
||||||
|
el tipo enum de PostgreSQL (única forma de eliminar valores en PG).
|
||||||
|
|
||||||
|
Mapeo de datos:
|
||||||
|
CLIENT_MANAGER → CLIENT_ADMIN (conserva nivel de gestión)
|
||||||
|
CLIENT_AGENT → CLIENT_USER (acceso básico de cliente)
|
||||||
|
CLIENT_AUDITOR → CLIENT_USER (acceso básico de cliente)
|
||||||
|
|
||||||
|
ADVERTENCIA DOWNGRADE: La migración inversa restaura los valores del
|
||||||
|
enum pero NO puede recuperar la distinción original entre CLIENT_AGENT
|
||||||
|
y CLIENT_AUDITOR (ambos quedaron como CLIENT_USER). El downgrade es
|
||||||
|
seguro a nivel de integridad de datos, pero irreversible en semántica.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'd2e3f4a5b6c7'
|
||||||
|
down_revision = 'c1d2e3f4a5b6'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ── Paso 1: Migrar datos ANTES de modificar el tipo ────────────────────
|
||||||
|
# CLIENT_MANAGER → CLIENT_ADMIN (conserva acceso de gestión)
|
||||||
|
op.execute("UPDATE users SET role = 'CLIENT_ADMIN' WHERE role = 'CLIENT_MANAGER'")
|
||||||
|
# CLIENT_AGENT → CLIENT_USER (acceso básico de cliente)
|
||||||
|
op.execute("UPDATE users SET role = 'CLIENT_USER' WHERE role = 'CLIENT_AGENT'")
|
||||||
|
# CLIENT_AUDITOR → CLIENT_USER (acceso básico de cliente)
|
||||||
|
op.execute("UPDATE users SET role = 'CLIENT_USER' WHERE role = 'CLIENT_AUDITOR'")
|
||||||
|
|
||||||
|
# ── Paso 2: Soltar la restricción de tipo para poder recrear el enum ───
|
||||||
|
# PostgreSQL no permite DROP VALUE en un enum; hay que recrear el tipo.
|
||||||
|
op.execute("ALTER TABLE users ALTER COLUMN role TYPE TEXT")
|
||||||
|
|
||||||
|
# ── Paso 3: Eliminar tipo actual y recrearlo solo con los 6 roles ──────
|
||||||
|
op.execute("DROP TYPE user_role_enum")
|
||||||
|
op.execute("""
|
||||||
|
CREATE TYPE user_role_enum AS ENUM (
|
||||||
|
'ADMIN',
|
||||||
|
'SUPPORT_MANAGER',
|
||||||
|
'AGENT',
|
||||||
|
'AUDITOR',
|
||||||
|
'CLIENT_ADMIN',
|
||||||
|
'CLIENT_USER'
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# ── Paso 4: Restaurar columna al tipo enum ──────────────────────────────
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE users ALTER COLUMN role TYPE user_role_enum "
|
||||||
|
"USING role::user_role_enum"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ── Paso 1: Soltar la restricción de tipo para recrear el enum ─────────
|
||||||
|
op.execute("ALTER TABLE users ALTER COLUMN role TYPE TEXT")
|
||||||
|
|
||||||
|
# ── Paso 2: Recrear enum con los 9 valores originales ──────────────────
|
||||||
|
op.execute("DROP TYPE user_role_enum")
|
||||||
|
op.execute("""
|
||||||
|
CREATE TYPE user_role_enum AS ENUM (
|
||||||
|
'ADMIN',
|
||||||
|
'SUPPORT_MANAGER',
|
||||||
|
'AGENT',
|
||||||
|
'AUDITOR',
|
||||||
|
'CLIENT_ADMIN',
|
||||||
|
'CLIENT_MANAGER',
|
||||||
|
'CLIENT_AGENT',
|
||||||
|
'CLIENT_AUDITOR',
|
||||||
|
'CLIENT_USER'
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# ── Paso 3: Restaurar columna al tipo enum ──────────────────────────────
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE users ALTER COLUMN role TYPE user_role_enum "
|
||||||
|
"USING role::user_role_enum"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Nota sobre pérdida de datos ─────────────────────────────────────────
|
||||||
|
# Los usuarios que eran CLIENT_MANAGER ahora son CLIENT_ADMIN.
|
||||||
|
# Los usuarios que eran CLIENT_AGENT o CLIENT_AUDITOR ahora son CLIENT_USER.
|
||||||
|
# No es posible restaurar la distinción original automáticamente.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Fix client_profiles timestamps to use server defaults
|
||||||
|
|
||||||
|
Revision ID: fix_client_timestamps
|
||||||
|
Revises: a1b2c3d4e5f6
|
||||||
|
Create Date: 2026-02-17 12:05:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'fix_client_timestamps'
|
||||||
|
down_revision = 'a1b2c3d4e5f6'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Modificar created_at para usar server_default
|
||||||
|
op.alter_column('client_profiles', 'created_at',
|
||||||
|
existing_type=sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text('now()')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Modificar updated_at para usar server_default
|
||||||
|
op.alter_column('client_profiles', 'updated_at',
|
||||||
|
existing_type=sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text('now()')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Remover server_default
|
||||||
|
op.alter_column('client_profiles', 'created_at',
|
||||||
|
existing_type=sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=None
|
||||||
|
)
|
||||||
|
|
||||||
|
op.alter_column('client_profiles', 'updated_at',
|
||||||
|
existing_type=sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=None
|
||||||
|
)
|
||||||
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "servicemanager-backend"
|
name = "servicemanager-backend"
|
||||||
version = "0.1.0"
|
version = "1.6.0"
|
||||||
description = "ServiceManagerWeb Backend - Mesa de Ayuda B2B"
|
description = "ServiceManagerWeb Backend - Mesa de Ayuda B2B"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Aduanasoft", email = "dev@aduanasoft.com"}
|
{name = "Aduanasoft", email = "dev@aduanasoft.com"}
|
||||||
|
|||||||
24
backend/pytest.ini
Normal file
24
backend/pytest.ini
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
[pytest]
|
||||||
|
testpaths = tests tests/unit tests/integration
|
||||||
|
python_files = test_*.py
|
||||||
|
python_functions = test_*
|
||||||
|
python_classes = Test*
|
||||||
|
asyncio_mode = auto
|
||||||
|
addopts =
|
||||||
|
-v
|
||||||
|
--tb=short
|
||||||
|
--strict-markers
|
||||||
|
--disable-warnings
|
||||||
|
--color=yes
|
||||||
|
--durations=10
|
||||||
|
markers =
|
||||||
|
slow: marks tests as slow (deselect with '-m "not slow"')
|
||||||
|
integration: marks tests as integration tests
|
||||||
|
unit: marks tests as unit tests
|
||||||
|
auth: marks tests related to authentication
|
||||||
|
db: marks tests that require database
|
||||||
|
env =
|
||||||
|
TESTING=true
|
||||||
|
filterwarnings =
|
||||||
|
ignore::DeprecationWarning
|
||||||
|
ignore::PendingDeprecationWarning
|
||||||
@@ -31,6 +31,7 @@ pyotp==2.9.0 # TOTP/2FA support
|
|||||||
# ===================================
|
# ===================================
|
||||||
celery==5.3.4
|
celery==5.3.4
|
||||||
redis==5.0.1
|
redis==5.0.1
|
||||||
|
slowapi==0.1.9 # Rate limiting middleware
|
||||||
|
|
||||||
# ===================================
|
# ===================================
|
||||||
# EMAIL
|
# EMAIL
|
||||||
@@ -69,6 +70,7 @@ prometheus-client==0.19.0
|
|||||||
pytest==7.4.3
|
pytest==7.4.3
|
||||||
pytest-asyncio==0.21.1
|
pytest-asyncio==0.21.1
|
||||||
pytest-cov==4.1.0
|
pytest-cov==4.1.0
|
||||||
|
aiosqlite==0.19.0
|
||||||
httpx==0.25.2 # For testing
|
httpx==0.25.2 # For testing
|
||||||
faker==20.1.0 # Test data generation
|
faker==20.1.0 # Test data generation
|
||||||
|
|
||||||
|
|||||||
115
backend/run_tests.sh
Executable file
115
backend/run_tests.sh
Executable file
@@ -0,0 +1,115 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Script para ejecutar tests de integración de ServiceManagerWeb
|
||||||
|
# Este script configura el ambiente de testing y ejecuta la suite completa
|
||||||
|
|
||||||
|
set -e # Exit on error
|
||||||
|
|
||||||
|
echo "🧪 ServiceManagerWeb - Test Runner"
|
||||||
|
echo "=================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Colores para output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Verificar que estamos en el directorio correcto
|
||||||
|
if [ ! -f "requirements.txt" ]; then
|
||||||
|
echo -e "${RED}❌ Error: Debe ejecutar este script desde el directorio backend/${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verificar que existe la BD de test
|
||||||
|
echo "📦 Verificando base de datos de testing..."
|
||||||
|
if ! docker-compose exec -T postgres psql -U servicemanager -lqt | cut -d \| -f 1 | grep -qw servicemanager_test; then
|
||||||
|
echo "⚙️ Creando base de datos de testing..."
|
||||||
|
docker-compose exec -T postgres psql -U servicemanager -c "CREATE DATABASE servicemanager_test;" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓ Base de datos lista${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Verificar que los servicios estén corriendo
|
||||||
|
echo "🐳 Verificando servicios Docker..."
|
||||||
|
if ! docker-compose ps | grep -q "Up"; then
|
||||||
|
echo -e "${YELLOW}⚠️ Servicios no están corriendo. Iniciando...${NC}"
|
||||||
|
docker-compose up -d postgres redis
|
||||||
|
sleep 5
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓ Servicios activos${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Configuración de tests
|
||||||
|
export TESTING=true
|
||||||
|
export DATABASE_URL="postgresql+asyncpg://servicemanager:servicemanager123@localhost:5432/servicemanager_test"
|
||||||
|
|
||||||
|
# Opciones de pytest
|
||||||
|
PYTEST_ARGS="-v --tb=short --color=yes"
|
||||||
|
|
||||||
|
# Parsear argumentos
|
||||||
|
case "${1:-all}" in
|
||||||
|
auth)
|
||||||
|
echo "🔐 Ejecutando tests de autenticación..."
|
||||||
|
pytest $PYTEST_ARGS tests/integration/test_auth_integration.py
|
||||||
|
;;
|
||||||
|
multitenant)
|
||||||
|
echo "🏢 Ejecutando tests de multi-tenancy..."
|
||||||
|
pytest $PYTEST_ARGS tests/integration/test_multitenant_integration.py
|
||||||
|
;;
|
||||||
|
tickets)
|
||||||
|
echo "🎫 Ejecutando tests de tickets..."
|
||||||
|
pytest $PYTEST_ARGS tests/integration/test_tickets_integration.py
|
||||||
|
;;
|
||||||
|
integration)
|
||||||
|
echo "🔗 Ejecutando todos los tests de integración..."
|
||||||
|
pytest $PYTEST_ARGS tests/integration/
|
||||||
|
;;
|
||||||
|
unit)
|
||||||
|
echo "⚡ Ejecutando tests unitarios..."
|
||||||
|
pytest $PYTEST_ARGS tests/unit/
|
||||||
|
;;
|
||||||
|
coverage)
|
||||||
|
echo "📊 Ejecutando tests con cobertura..."
|
||||||
|
pytest $PYTEST_ARGS --cov=app --cov-report=html --cov-report=term tests/integration/ tests/unit/
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}✓ Reporte de cobertura generado en htmlcov/index.html${NC}"
|
||||||
|
;;
|
||||||
|
all)
|
||||||
|
echo "🎯 Ejecutando suite completa de tests..."
|
||||||
|
pytest $PYTEST_ARGS tests/unit/ tests/integration/
|
||||||
|
;;
|
||||||
|
clean)
|
||||||
|
echo "🧹 Limpiando base de datos de testing..."
|
||||||
|
docker-compose exec -T postgres psql -U servicemanager -c "DROP DATABASE IF EXISTS servicemanager_test;"
|
||||||
|
docker-compose exec -T postgres psql -U servicemanager -c "CREATE DATABASE servicemanager_test;"
|
||||||
|
echo -e "${GREEN}✓ Base de datos limpia${NC}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Uso: $0 [auth|multitenant|tickets|integration|unit|coverage|all|clean]"
|
||||||
|
echo ""
|
||||||
|
echo "Opciones:"
|
||||||
|
echo " auth - Tests de autenticación"
|
||||||
|
echo " multitenant - Tests de aislamiento multi-tenant"
|
||||||
|
echo " tickets - Tests CRUD de tickets"
|
||||||
|
echo " integration - Todos los tests de integración"
|
||||||
|
echo " unit - Tests unitarios"
|
||||||
|
echo " coverage - Tests con reporte de cobertura"
|
||||||
|
echo " all - Todos los tests (default)"
|
||||||
|
echo " clean - Limpiar base de datos de testing"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Mostrar resultado
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}✅ Tests completados exitosamente${NC}"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo -e "${RED}❌ Algunos tests fallaron${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
193
backend/scripts/README_SECURITY_TESTS.md
Normal file
193
backend/scripts/README_SECURITY_TESTS.md
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
# Scripts de Prueba de Seguridad
|
||||||
|
|
||||||
|
Scripts para generar datos de prueba para el análisis de seguridad.
|
||||||
|
|
||||||
|
## 📋 Scripts Disponibles
|
||||||
|
|
||||||
|
### 1. `generate_security_test_data.py`
|
||||||
|
|
||||||
|
Genera un conjunto completo de logs de auditoría para probar todas las funcionalidades del análisis de seguridad.
|
||||||
|
|
||||||
|
#### Uso
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Asegúrate de estar en el entorno virtual
|
||||||
|
cd backend
|
||||||
|
python scripts/generate_security_test_data.py
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Qué Genera
|
||||||
|
|
||||||
|
- **25 intentos fallidos de login** → Amenaza HIGH de fuerza bruta
|
||||||
|
- **55 eliminaciones masivas** → Amenaza CRITICAL
|
||||||
|
- **5 cambios de privilegios** → Amenaza HIGH de escalación de privilegios
|
||||||
|
- **20 logs normales** → Actividad regular para contexto
|
||||||
|
|
||||||
|
#### Limpiar Datos de Prueba
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/generate_security_test_data.py cleanup
|
||||||
|
```
|
||||||
|
|
||||||
|
Esto eliminará **TODOS** los logs de auditoría de las últimas 24 horas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Escenarios de Prueba
|
||||||
|
|
||||||
|
### Escenario 1: Sistema Limpio (Sin Amenazas)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Limpiar todos los logs
|
||||||
|
python scripts/generate_security_test_data.py cleanup
|
||||||
|
```
|
||||||
|
|
||||||
|
**Resultado esperado:**
|
||||||
|
- Dashboard con todos los contadores en 0
|
||||||
|
- Tab "Crítico" vacío
|
||||||
|
- Mensaje: "Sistema Seguro"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Escenario 2: Solo Amenazas Leves
|
||||||
|
|
||||||
|
Modifica el script para generar solo 6 intentos fallidos (MEDIUM severity):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# En generate_security_test_data.py, línea ~70
|
||||||
|
for i in range(6): # Cambiar de 25 a 6
|
||||||
|
```
|
||||||
|
|
||||||
|
**Resultado esperado:**
|
||||||
|
- 1 amenaza MEDIUM en tab correspondiente
|
||||||
|
- Tab "Crítico" vacío
|
||||||
|
- Nivel de riesgo: LOW o MEDIUM
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Escenario 3: Amenazas Críticas
|
||||||
|
|
||||||
|
Ejecuta el script completo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/generate_security_test_data.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Resultado esperado:**
|
||||||
|
- 1 amenaza CRÍTICA (eliminaciones masivas)
|
||||||
|
- 2 amenazas HIGH (login fallidos + privilegios)
|
||||||
|
- Tab "Crítico" con 1 amenaza
|
||||||
|
- Nivel de riesgo: CRITICAL
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Umbrales de Detección
|
||||||
|
|
||||||
|
| Tipo de Amenaza | Umbral Detección | Severidades |
|
||||||
|
|-----------------|------------------|-------------|
|
||||||
|
| **Fuerza Bruta** | ≥5 intentos fallidos | MEDIUM (5-19), HIGH (≥20) |
|
||||||
|
| **Eliminaciones Masivas** | ≥10 eliminaciones | HIGH (10-49), **CRITICAL (≥50)** |
|
||||||
|
| **Cambios de Privilegios** | ≥3 cambios de rol | HIGH (siempre) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Verificar Resultados
|
||||||
|
|
||||||
|
1. **Accede al panel de auditoría**: http://localhost:3001/audit/security
|
||||||
|
|
||||||
|
2. **Verifica los contadores del dashboard:**
|
||||||
|
- Nivel de Riesgo
|
||||||
|
- Amenazas Detectadas
|
||||||
|
- Intentos Fallidos
|
||||||
|
- IPs Sospechosas
|
||||||
|
- Acciones Críticas
|
||||||
|
|
||||||
|
3. **Prueba los tabs:**
|
||||||
|
- Todas: Debe mostrar amenazas activas
|
||||||
|
- Crítico: Solo amenazas critical (si hay)
|
||||||
|
- High: Amenazas de alta severidad
|
||||||
|
- Medium: Amenazas de severidad media
|
||||||
|
- Low: Amenazas de baja severidad
|
||||||
|
- Resueltas: Amenazas marcadas como resueltas
|
||||||
|
|
||||||
|
4. **Prueba la búsqueda:**
|
||||||
|
- Busca por IP: `192.168.1.100`
|
||||||
|
- Busca por descripción: `intentos fallidos`
|
||||||
|
- Busca por tipo: `brute_force`
|
||||||
|
|
||||||
|
5. **Prueba los filtros:**
|
||||||
|
- Filtra por tipo de amenaza
|
||||||
|
- Combina búsqueda + filtro
|
||||||
|
|
||||||
|
6. **Prueba las acciones:**
|
||||||
|
- Selecciona múltiples amenazas
|
||||||
|
- Resuelve en batch
|
||||||
|
- Marca como resuelta individualmente
|
||||||
|
- Reabre amenazas resueltas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Advertencias
|
||||||
|
|
||||||
|
- **NO ejecutar en producción**: Estos scripts son SOLO para desarrollo/testing
|
||||||
|
- **Los datos son ficticios**: IPs, usuarios y acciones son simulados
|
||||||
|
- **Cleanup elimina TODO**: El comando cleanup elimina TODOS los logs de las últimas 24h, no solo los de prueba
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
### Error: "No se encontró ningún tenant"
|
||||||
|
```bash
|
||||||
|
# Ejecuta las migraciones
|
||||||
|
cd backend
|
||||||
|
alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error: "No se encontró ningún usuario"
|
||||||
|
```bash
|
||||||
|
# Crea un usuario de prueba
|
||||||
|
python scripts/create_test_user.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### La página no muestra amenazas
|
||||||
|
- Verifica que el backend esté corriendo: `uvicorn app.main:app --reload`
|
||||||
|
- Revisa la consola del navegador para errores
|
||||||
|
- Verifica que los logs se crearon: `SELECT COUNT(*) FROM audit_logs WHERE created_at >= NOW() - INTERVAL '24 hours';`
|
||||||
|
|
||||||
|
### Las fechas no son de hoy
|
||||||
|
- Los logs se crean con timestamps aleatorios en las últimas 24h
|
||||||
|
- Si todos tienen la misma fecha, es porque se generaron en el mismo segundo (normal)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Personalizar Generación
|
||||||
|
|
||||||
|
Para crear escenarios personalizados, edita `generate_security_test_data.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Cambiar cantidad de intentos fallidos
|
||||||
|
for i in range(50): # Más intentos = mayor severidad
|
||||||
|
|
||||||
|
# Cambiar IPs sospechosas
|
||||||
|
suspicious_ips = ["1.2.3.4", "5.6.7.8"]
|
||||||
|
|
||||||
|
# Cambiar período temporal
|
||||||
|
time_offset = timedelta(hours=12) # Todos en las últimas 12h
|
||||||
|
|
||||||
|
# Agregar más tipos de amenazas
|
||||||
|
# Agrega nuevos bloques de generación siguiendo el patrón
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Flujo Recomendado de Prueba
|
||||||
|
|
||||||
|
1. **Limpia el sistema**: `python scripts/generate_security_test_data.py cleanup`
|
||||||
|
2. **Verifica sistema limpio**: Accede a la página, debe estar vacía
|
||||||
|
3. **Genera datos completos**: `python scripts/generate_security_test_data.py`
|
||||||
|
4. **Prueba todas las funcionalidades**: tabs, filtros, búsqueda, acciones
|
||||||
|
5. **Marca algunas como resueltas**: Prueba el flujo de resolución
|
||||||
|
6. **Verifica tab "Resueltas"**: Confirma que aparecen ahí
|
||||||
|
7. **Reabre algunas**: Prueba el flujo de reapertura
|
||||||
|
8. **Limpia al finalizar**: `python scripts/generate_security_test_data.py cleanup`
|
||||||
35
backend/scripts/check_tenants.py
Normal file
35
backend/scripts/check_tenants.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""
|
||||||
|
Utility script to list all tenants in the database
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.core.database import AsyncSessionLocal
|
||||||
|
from app.models.tenant import Tenant
|
||||||
|
|
||||||
|
async def list_tenants():
|
||||||
|
"""List all tenants with their details."""
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
result = await db.execute(select(Tenant))
|
||||||
|
tenants = result.scalars().all()
|
||||||
|
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("📋 TENANTS EN LA BASE DE DATOS")
|
||||||
|
print("="*60 + "\n")
|
||||||
|
|
||||||
|
if not tenants:
|
||||||
|
print("⚠️ No hay tenants en la base de datos\n")
|
||||||
|
print("💡 Ejecuta las migraciones o crea un tenant manualmente")
|
||||||
|
return
|
||||||
|
|
||||||
|
for tenant in tenants:
|
||||||
|
print(f"Slug: {tenant.slug}")
|
||||||
|
print(f"Nombre: {tenant.name}")
|
||||||
|
print(f"Status: {tenant.status}")
|
||||||
|
print(f"Email: {tenant.contact_email or 'N/A'}")
|
||||||
|
print(f"ID: {tenant.id}")
|
||||||
|
print("-" * 60)
|
||||||
|
|
||||||
|
print(f"\nTotal: {len(tenants)} tenant(s)\n")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(list_tenants())
|
||||||
67
backend/scripts/create_test_user.py
Normal file
67
backend/scripts/create_test_user.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""
|
||||||
|
Script para crear/actualizar usuario de prueba con contraseña conocida
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from app.core.database import AsyncSessionLocal
|
||||||
|
from app.core.security import security
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.models.tenant import Tenant
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
async def create_test_user():
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
# Buscar tenant
|
||||||
|
tenant_query = select(Tenant).where(Tenant.slug.like('%aduanasoft%')).limit(1)
|
||||||
|
result = await db.execute(tenant_query)
|
||||||
|
tenant = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not tenant:
|
||||||
|
print("❌ No se encontró tenant")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"✅ Tenant encontrado: {tenant.name} ({tenant.slug})")
|
||||||
|
|
||||||
|
# Buscar o crear usuario admin
|
||||||
|
user_query = select(User).where(
|
||||||
|
User.email == "admin@aduanasoft.com",
|
||||||
|
User.tenant_id == tenant.id
|
||||||
|
)
|
||||||
|
result = await db.execute(user_query)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
# Hash de la contraseña "admin123"
|
||||||
|
password_hash = security.hash_password("admin123")
|
||||||
|
|
||||||
|
if user:
|
||||||
|
# Actualizar contraseña
|
||||||
|
user.password_hash = password_hash
|
||||||
|
user.is_active = True
|
||||||
|
user.email_verified = True
|
||||||
|
await db.commit()
|
||||||
|
print(f"✅ Usuario actualizado: {user.email}")
|
||||||
|
else:
|
||||||
|
# Crear usuario nuevo
|
||||||
|
user = User(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
email="admin@aduanasoft.com",
|
||||||
|
first_name="Admin",
|
||||||
|
last_name="Sistema",
|
||||||
|
password_hash=password_hash,
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
is_active=True,
|
||||||
|
email_verified=True
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
await db.commit()
|
||||||
|
print(f"✅ Usuario creado: {user.email}")
|
||||||
|
|
||||||
|
print(f"\n📋 Credenciales de prueba:")
|
||||||
|
print(f" Email: admin@aduanasoft.com")
|
||||||
|
print(f" Password: admin123")
|
||||||
|
print(f" Tenant: {tenant.slug}")
|
||||||
|
print(f" Role: ADMIN")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(create_test_user())
|
||||||
240
backend/scripts/generate_security_test_data.py
Normal file
240
backend/scripts/generate_security_test_data.py
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
"""
|
||||||
|
Script para generar datos de prueba de seguridad en logs de auditoría.
|
||||||
|
Esto permite probar la funcionalidad de análisis de seguridad con diferentes tipos de amenazas.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import uuid
|
||||||
|
import random
|
||||||
|
|
||||||
|
# Agregar el directorio raíz al path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.core.database import AsyncSessionLocal
|
||||||
|
from app.models.audit import AuditLog
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.tenant import Tenant
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_test_data():
|
||||||
|
"""Genera logs de auditoría de prueba para análisis de seguridad."""
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
# Obtener tenant y usuarios de prueba
|
||||||
|
tenant_result = await db.execute(select(Tenant).limit(1))
|
||||||
|
tenant = tenant_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not tenant:
|
||||||
|
print("❌ No se encontró ningún tenant. Ejecuta las migraciones primero.")
|
||||||
|
return
|
||||||
|
|
||||||
|
user_result = await db.execute(select(User).where(User.tenant_id == tenant.id).limit(1))
|
||||||
|
user = user_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
print("❌ No se encontró ningún usuario. Crea un usuario primero.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"✅ Usando tenant: {tenant.name}")
|
||||||
|
print(f"✅ Usando usuario: {user.email}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# IPs de prueba
|
||||||
|
suspicious_ips = [
|
||||||
|
"192.168.1.100",
|
||||||
|
"10.0.0.50",
|
||||||
|
"172.16.0.10",
|
||||||
|
"203.0.113.42",
|
||||||
|
"198.51.100.88"
|
||||||
|
]
|
||||||
|
|
||||||
|
logs_created = 0
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 1. GENERAR INTENTOS FALLIDOS DE LOGIN (Fuerza Bruta)
|
||||||
|
# ============================================
|
||||||
|
print("🔐 Generando intentos fallidos de login...")
|
||||||
|
|
||||||
|
# Generar 25 intentos fallidos (esto hará que sea HIGH severity)
|
||||||
|
for i in range(25):
|
||||||
|
time_offset = timedelta(hours=random.randint(0, 23), minutes=random.randint(0, 59))
|
||||||
|
log = AuditLog(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
user_id=user.id,
|
||||||
|
action="user.login_failed",
|
||||||
|
resource_type="auth",
|
||||||
|
resource_id=None,
|
||||||
|
ip_address=random.choice(suspicious_ips),
|
||||||
|
user_agent="Mozilla/5.0 (Test Browser)",
|
||||||
|
metadata={"reason": "invalid_credentials", "username": f"test_user_{i}"},
|
||||||
|
created_at=now - time_offset
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
logs_created += 1
|
||||||
|
|
||||||
|
print(f" ✓ Creados {25} intentos fallidos de login (HIGH severity)")
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 2. GENERAR ELIMINACIONES MASIVAS (CRITICAL)
|
||||||
|
# ============================================
|
||||||
|
print("🗑️ Generando eliminaciones masivas...")
|
||||||
|
|
||||||
|
resources = ["ticket", "comment", "attachment", "category", "user"]
|
||||||
|
|
||||||
|
# Generar 55 eliminaciones (esto hará que sea CRITICAL severity)
|
||||||
|
for i in range(55):
|
||||||
|
time_offset = timedelta(hours=random.randint(0, 23), minutes=random.randint(0, 59))
|
||||||
|
resource = random.choice(resources)
|
||||||
|
log = AuditLog(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
user_id=user.id,
|
||||||
|
action=f"{resource}.delete",
|
||||||
|
resource_type=resource,
|
||||||
|
resource_id=uuid.uuid4(),
|
||||||
|
ip_address=random.choice(suspicious_ips),
|
||||||
|
user_agent="Mozilla/5.0 (Test Browser)",
|
||||||
|
metadata={"deleted_by": user.email},
|
||||||
|
created_at=now - time_offset
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
logs_created += 1
|
||||||
|
|
||||||
|
print(f" ✓ Creadas {55} eliminaciones masivas (CRITICAL severity)")
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 3. GENERAR CAMBIOS DE PRIVILEGIOS (HIGH)
|
||||||
|
# ============================================
|
||||||
|
print("👤 Generando cambios de privilegios...")
|
||||||
|
|
||||||
|
roles = ["AGENT", "CLIENT_USER", "AUDITOR", "SUPPORT_MANAGER", "ADMIN"]
|
||||||
|
|
||||||
|
# Generar 5 cambios de rol (esto hará que sea HIGH severity)
|
||||||
|
for i in range(5):
|
||||||
|
time_offset = timedelta(hours=random.randint(0, 23), minutes=random.randint(0, 59))
|
||||||
|
old_role = random.choice(roles)
|
||||||
|
new_role = random.choice([r for r in roles if r != old_role])
|
||||||
|
|
||||||
|
log = AuditLog(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
user_id=user.id,
|
||||||
|
action="user.update",
|
||||||
|
resource_type="user",
|
||||||
|
resource_id=uuid.uuid4(),
|
||||||
|
ip_address=random.choice(suspicious_ips),
|
||||||
|
user_agent="Mozilla/5.0 (Test Browser)",
|
||||||
|
old_values={"role": old_role},
|
||||||
|
new_values={"role": new_role},
|
||||||
|
metadata={"changed_by": user.email},
|
||||||
|
created_at=now - time_offset
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
logs_created += 1
|
||||||
|
|
||||||
|
print(f" ✓ Creados {5} cambios de privilegios (HIGH severity)")
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 4. GENERAR LOGS NORMALES (para dar contexto)
|
||||||
|
# ============================================
|
||||||
|
print("📋 Generando logs de actividad normal...")
|
||||||
|
|
||||||
|
normal_actions = [
|
||||||
|
"ticket.create",
|
||||||
|
"ticket.update",
|
||||||
|
"comment.create",
|
||||||
|
"user.login",
|
||||||
|
"ticket.view",
|
||||||
|
]
|
||||||
|
|
||||||
|
for i in range(20):
|
||||||
|
time_offset = timedelta(hours=random.randint(0, 23), minutes=random.randint(0, 59))
|
||||||
|
action = random.choice(normal_actions)
|
||||||
|
|
||||||
|
log = AuditLog(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
user_id=user.id,
|
||||||
|
action=action,
|
||||||
|
resource_type=action.split('.')[0],
|
||||||
|
resource_id=uuid.uuid4(),
|
||||||
|
ip_address=random.choice(suspicious_ips),
|
||||||
|
user_agent="Mozilla/5.0 (Test Browser)",
|
||||||
|
metadata={"action": "normal_activity"},
|
||||||
|
created_at=now - time_offset
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
logs_created += 1
|
||||||
|
|
||||||
|
print(f" ✓ Creados {20} logs de actividad normal")
|
||||||
|
|
||||||
|
# Guardar todo
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"✅ GENERACIÓN COMPLETADA")
|
||||||
|
print(f" Total de logs creados: {logs_created}")
|
||||||
|
print()
|
||||||
|
print("📊 Amenazas esperadas en el análisis:")
|
||||||
|
print(" 🔴 1 amenaza CRÍTICA: 55 eliminaciones masivas")
|
||||||
|
print(" 🟠 1 amenaza HIGH: 25 intentos fallidos de login")
|
||||||
|
print(" 🟠 1 amenaza HIGH: 5 cambios de privilegios")
|
||||||
|
print()
|
||||||
|
print("🌐 Accede a la página de seguridad para ver el análisis")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_test_data():
|
||||||
|
"""Elimina los logs de auditoría de prueba."""
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
tenant_result = await db.execute(select(Tenant).limit(1))
|
||||||
|
tenant = tenant_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not tenant:
|
||||||
|
print("❌ No se encontró ningún tenant.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Eliminar logs de las últimas 24 horas
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
cutoff = now - timedelta(hours=24)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(AuditLog).where(
|
||||||
|
AuditLog.tenant_id == tenant.id,
|
||||||
|
AuditLog.created_at >= cutoff
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logs = result.scalars().all()
|
||||||
|
|
||||||
|
if not logs:
|
||||||
|
print("ℹ️ No hay logs de prueba para eliminar.")
|
||||||
|
return
|
||||||
|
|
||||||
|
for log in logs:
|
||||||
|
await db.delete(log)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
print(f"✅ Eliminados {len(logs)} logs de prueba de las últimas 24 horas")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if len(sys.argv) > 1 and sys.argv[1] == "cleanup":
|
||||||
|
print("🧹 Limpiando datos de prueba...")
|
||||||
|
asyncio.run(cleanup_test_data())
|
||||||
|
else:
|
||||||
|
print("🚀 Generando datos de prueba para análisis de seguridad...")
|
||||||
|
print()
|
||||||
|
asyncio.run(generate_test_data())
|
||||||
|
print()
|
||||||
|
print("💡 Para limpiar estos datos de prueba, ejecuta:")
|
||||||
|
print(" python scripts/generate_security_test_data.py cleanup")
|
||||||
399
backend/scripts/generate_sla_test_data.py
Normal file
399
backend/scripts/generate_sla_test_data.py
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
"""
|
||||||
|
Script para generar datos de prueba de SLA Management.
|
||||||
|
Crea tickets con diferentes estados de SLA para probar el dashboard.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import uuid
|
||||||
|
import random
|
||||||
|
|
||||||
|
# Agregar el directorio raíz al path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.core.database import AsyncSessionLocal
|
||||||
|
from app.models.ticket import Ticket, TicketStatus, TicketPriority
|
||||||
|
from app.models.category import Category
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.tenant import Tenant
|
||||||
|
from app.models.system import System
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_sla_test_data():
|
||||||
|
"""Genera tickets de prueba con diferentes estados de SLA."""
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
# Obtener tenant y usuarios
|
||||||
|
tenant_result = await db.execute(select(Tenant).limit(1))
|
||||||
|
tenant = tenant_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not tenant:
|
||||||
|
print("❌ No se encontró ningún tenant. Ejecuta las migraciones primero.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Obtener usuarios
|
||||||
|
users_result = await db.execute(
|
||||||
|
select(User).where(User.tenant_id == tenant.id).limit(5)
|
||||||
|
)
|
||||||
|
users = list(users_result.scalars().all())
|
||||||
|
|
||||||
|
if not users:
|
||||||
|
print("❌ No se encontraron usuarios. Crea usuarios primero.")
|
||||||
|
return
|
||||||
|
|
||||||
|
creator = users[0]
|
||||||
|
agents = users if len(users) > 1 else [creator]
|
||||||
|
|
||||||
|
# Obtener o crear categorías
|
||||||
|
categories_result = await db.execute(
|
||||||
|
select(Category).where(Category.tenant_id == tenant.id)
|
||||||
|
)
|
||||||
|
categories = list(categories_result.scalars().all())
|
||||||
|
|
||||||
|
if not categories:
|
||||||
|
print("📁 Creando categorías de prueba...")
|
||||||
|
category_data = [
|
||||||
|
{"name": "Soporte Técnico", "sla_response_hours": 2, "sla_resolution_hours": 24, "color": "#3B82F6"},
|
||||||
|
{"name": "Facturación", "sla_response_hours": 4, "sla_resolution_hours": 48, "color": "#10B981"},
|
||||||
|
{"name": "Incidente Crítico", "sla_response_hours": 1, "sla_resolution_hours": 8, "color": "#EF4444"},
|
||||||
|
{"name": "Consulta General", "sla_response_hours": 8, "sla_resolution_hours": 72, "color": "#6B7280"},
|
||||||
|
]
|
||||||
|
|
||||||
|
for cat_data in category_data:
|
||||||
|
category = Category(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
name=cat_data["name"],
|
||||||
|
description=f"Categoría de {cat_data['name']}",
|
||||||
|
color=cat_data["color"],
|
||||||
|
sla_response_hours=cat_data["sla_response_hours"],
|
||||||
|
sla_resolution_hours=cat_data["sla_resolution_hours"],
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
db.add(category)
|
||||||
|
categories.append(category)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
print(f" ✓ Creadas {len(categories)} categorías")
|
||||||
|
|
||||||
|
# Obtener o crear sistemas afectados
|
||||||
|
systems_result = await db.execute(
|
||||||
|
select(System).where(System.tenant_id == tenant.id)
|
||||||
|
)
|
||||||
|
systems = list(systems_result.scalars().all())
|
||||||
|
|
||||||
|
if not systems:
|
||||||
|
print("🖥️ Creando sistemas de prueba...")
|
||||||
|
system_names = ["Portal Web", "API REST", "Base de Datos", "Sistema de Pagos"]
|
||||||
|
for sys_name in system_names:
|
||||||
|
system = System(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
name=sys_name,
|
||||||
|
description=f"Sistema {sys_name}",
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
db.add(system)
|
||||||
|
systems.append(system)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
print(f" ✓ Creados {len(systems)} sistemas")
|
||||||
|
|
||||||
|
print(f"✅ Usando tenant: {tenant.name}")
|
||||||
|
print(f"✅ Usuarios disponibles: {len(users)}")
|
||||||
|
print(f"✅ Categorías disponibles: {len(categories)}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
tickets_created = 0
|
||||||
|
|
||||||
|
# Función auxiliar para crear ticket
|
||||||
|
def create_ticket(
|
||||||
|
subject: str,
|
||||||
|
description: str,
|
||||||
|
priority: TicketPriority,
|
||||||
|
status: TicketStatus,
|
||||||
|
category: Category,
|
||||||
|
created_hours_ago: int,
|
||||||
|
first_response_hours_after: int = None,
|
||||||
|
resolved_hours_after: int = None,
|
||||||
|
assigned: bool = True
|
||||||
|
):
|
||||||
|
nonlocal tickets_created
|
||||||
|
|
||||||
|
ticket_id = uuid.uuid4()
|
||||||
|
created_at = now - timedelta(hours=created_hours_ago)
|
||||||
|
|
||||||
|
# Calcular SLA deadlines basados en la categoría (sin timezone para la BD)
|
||||||
|
sla_response_due = (created_at + timedelta(hours=category.sla_response_hours)).replace(tzinfo=None)
|
||||||
|
sla_resolution_due = (created_at + timedelta(hours=category.sla_resolution_hours)).replace(tzinfo=None)
|
||||||
|
|
||||||
|
# Primera respuesta (si aplica)
|
||||||
|
first_response_at = None
|
||||||
|
if first_response_hours_after is not None:
|
||||||
|
first_response_at = (created_at + timedelta(hours=first_response_hours_after)).replace(tzinfo=None)
|
||||||
|
|
||||||
|
# Resolución (si aplica)
|
||||||
|
resolved_at = None
|
||||||
|
if resolved_hours_after is not None:
|
||||||
|
resolved_at = (created_at + timedelta(hours=resolved_hours_after)).replace(tzinfo=None)
|
||||||
|
|
||||||
|
ticket = Ticket(
|
||||||
|
id=ticket_id,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
ticket_number=f"TKT-{1000 + tickets_created}",
|
||||||
|
subject=subject,
|
||||||
|
description=description,
|
||||||
|
status=status,
|
||||||
|
priority=priority,
|
||||||
|
created_by=creator.id,
|
||||||
|
assigned_to=random.choice(agents).id if assigned else None,
|
||||||
|
category_id=category.id,
|
||||||
|
affected_system_id=random.choice(systems).id if systems else None,
|
||||||
|
sla_response_due=sla_response_due,
|
||||||
|
sla_resolution_due=sla_resolution_due,
|
||||||
|
first_response_at=first_response_at,
|
||||||
|
resolved_at=resolved_at,
|
||||||
|
created_at=created_at,
|
||||||
|
updated_at=resolved_at or first_response_at or created_at
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(ticket)
|
||||||
|
tickets_created += 1
|
||||||
|
return ticket
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 1. TICKETS CUMPLIENDO SLA RESPONSE (Verde)
|
||||||
|
# ============================================
|
||||||
|
print("✅ Generando tickets CUMPLIENDO Response SLA...")
|
||||||
|
|
||||||
|
for i in range(15):
|
||||||
|
category = random.choice(categories)
|
||||||
|
priority = random.choice([TicketPriority.LOW, TicketPriority.MEDIUM, TicketPriority.HIGH])
|
||||||
|
|
||||||
|
# Creado hace X horas, respondido ANTES del deadline
|
||||||
|
created_hours_ago = random.randint(24, 120)
|
||||||
|
response_time = random.uniform(0.5, category.sla_response_hours * 0.7) # 70% del SLA
|
||||||
|
|
||||||
|
status = random.choice([TicketStatus.IN_PROGRESS, TicketStatus.WAITING_CUSTOMER])
|
||||||
|
|
||||||
|
create_ticket(
|
||||||
|
subject=f"Ticket con respuesta a tiempo #{i+1}",
|
||||||
|
description=f"Este ticket fue respondido dentro del SLA de {category.name}",
|
||||||
|
priority=priority,
|
||||||
|
status=status,
|
||||||
|
category=category,
|
||||||
|
created_hours_ago=created_hours_ago,
|
||||||
|
first_response_hours_after=response_time,
|
||||||
|
assigned=True
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f" ✓ Creados 15 tickets cumpliendo Response SLA")
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 2. TICKETS VIOLANDO SLA RESPONSE (Rojo)
|
||||||
|
# ============================================
|
||||||
|
print("🔴 Generando tickets VIOLANDO Response SLA...")
|
||||||
|
|
||||||
|
for i in range(8):
|
||||||
|
category = random.choice(categories)
|
||||||
|
priority = random.choice([TicketPriority.HIGH, TicketPriority.URGENT])
|
||||||
|
|
||||||
|
# Creado hace más tiempo que el SLA, SIN respuesta
|
||||||
|
created_hours_ago = category.sla_response_hours + random.randint(1, 10)
|
||||||
|
|
||||||
|
create_ticket(
|
||||||
|
subject=f"Ticket SIN respuesta - VIOLACIÓN #{i+1}",
|
||||||
|
description=f"Este ticket lleva {created_hours_ago}h sin respuesta (SLA: {category.sla_response_hours}h)",
|
||||||
|
priority=priority,
|
||||||
|
status=random.choice([TicketStatus.NEW, TicketStatus.TRIAGE]),
|
||||||
|
category=category,
|
||||||
|
created_hours_ago=created_hours_ago,
|
||||||
|
first_response_hours_after=None, # Sin respuesta!
|
||||||
|
assigned=random.choice([True, False])
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f" ✓ Creados 8 tickets VIOLANDO Response SLA")
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 3. TICKETS EN RIESGO Response (Amarillo)
|
||||||
|
# ============================================
|
||||||
|
print("⚠️ Generando tickets EN RIESGO Response SLA...")
|
||||||
|
|
||||||
|
for i in range(10):
|
||||||
|
category = random.choice(categories)
|
||||||
|
priority = random.choice([TicketPriority.MEDIUM, TicketPriority.HIGH, TicketPriority.URGENT])
|
||||||
|
|
||||||
|
# Creado hace tiempo, cerca del deadline (80-95% consumido)
|
||||||
|
sla_hours = category.sla_response_hours
|
||||||
|
time_consumed = random.uniform(0.8, 0.95) * sla_hours
|
||||||
|
created_hours_ago = time_consumed
|
||||||
|
|
||||||
|
create_ticket(
|
||||||
|
subject=f"Ticket cerca de vencer respuesta #{i+1}",
|
||||||
|
description=f"Este ticket está al {int(time_consumed/sla_hours*100)}% del SLA de respuesta",
|
||||||
|
priority=priority,
|
||||||
|
status=random.choice([TicketStatus.TRIAGE, TicketStatus.NEW]),
|
||||||
|
category=category,
|
||||||
|
created_hours_ago=created_hours_ago,
|
||||||
|
first_response_hours_after=None, # Aún sin respuesta
|
||||||
|
assigned=True
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f" ✓ Creados 10 tickets EN RIESGO Response SLA")
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 4. TICKETS CUMPLIENDO SLA RESOLUTION
|
||||||
|
# ============================================
|
||||||
|
print("✅ Generando tickets CUMPLIENDO Resolution SLA...")
|
||||||
|
|
||||||
|
for i in range(20):
|
||||||
|
category = random.choice(categories)
|
||||||
|
priority = random.choice([TicketPriority.LOW, TicketPriority.MEDIUM, TicketPriority.HIGH])
|
||||||
|
|
||||||
|
# Creado, respondido y resuelto dentro del SLA
|
||||||
|
created_hours_ago = random.randint(72, 240)
|
||||||
|
response_time = random.uniform(1, category.sla_response_hours * 0.5)
|
||||||
|
resolution_time = random.uniform(
|
||||||
|
response_time + 1,
|
||||||
|
category.sla_resolution_hours * 0.8
|
||||||
|
)
|
||||||
|
|
||||||
|
create_ticket(
|
||||||
|
subject=f"Ticket resuelto a tiempo #{i+1}",
|
||||||
|
description=f"Este ticket fue resuelto dentro del SLA de {category.name}",
|
||||||
|
priority=priority,
|
||||||
|
status=random.choice([TicketStatus.RESOLVED, TicketStatus.CLOSED]),
|
||||||
|
category=category,
|
||||||
|
created_hours_ago=created_hours_ago,
|
||||||
|
first_response_hours_after=response_time,
|
||||||
|
resolved_hours_after=resolution_time,
|
||||||
|
assigned=True
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f" ✓ Creados 20 tickets cumpliendo Resolution SLA")
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 5. TICKETS VIOLANDO SLA RESOLUTION
|
||||||
|
# ============================================
|
||||||
|
print("🔴 Generando tickets VIOLANDO Resolution SLA...")
|
||||||
|
|
||||||
|
for i in range(6):
|
||||||
|
category = random.choice(categories)
|
||||||
|
priority = random.choice([TicketPriority.HIGH, TicketPriority.URGENT])
|
||||||
|
|
||||||
|
# Creado hace más del SLA de resolución, con respuesta pero sin resolver
|
||||||
|
created_hours_ago = category.sla_resolution_hours + random.randint(5, 48)
|
||||||
|
response_time = random.uniform(1, category.sla_response_hours * 0.5)
|
||||||
|
|
||||||
|
create_ticket(
|
||||||
|
subject=f"Ticket sin resolver - VIOLACIÓN #{i+1}",
|
||||||
|
description=f"Ticket lleva {created_hours_ago}h sin resolver (SLA: {category.sla_resolution_hours}h)",
|
||||||
|
priority=priority,
|
||||||
|
status=random.choice([TicketStatus.IN_PROGRESS, TicketStatus.WAITING_CUSTOMER]),
|
||||||
|
category=category,
|
||||||
|
created_hours_ago=created_hours_ago,
|
||||||
|
first_response_hours_after=response_time,
|
||||||
|
resolved_hours_after=None, # Sin resolver!
|
||||||
|
assigned=True
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f" ✓ Creados 6 tickets VIOLANDO Resolution SLA")
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 6. TICKETS EN RIESGO Resolution
|
||||||
|
# ============================================
|
||||||
|
print("⚠️ Generando tickets EN RIESGO Resolution SLA...")
|
||||||
|
|
||||||
|
for i in range(12):
|
||||||
|
category = random.choice(categories)
|
||||||
|
priority = random.choice([TicketPriority.MEDIUM, TicketPriority.HIGH])
|
||||||
|
|
||||||
|
# Con respuesta, cerca del deadline de resolución
|
||||||
|
sla_hours = category.sla_resolution_hours
|
||||||
|
time_consumed = random.uniform(0.75, 0.95) * sla_hours
|
||||||
|
created_hours_ago = time_consumed
|
||||||
|
response_time = random.uniform(0.5, category.sla_response_hours * 0.5)
|
||||||
|
|
||||||
|
create_ticket(
|
||||||
|
subject=f"Ticket cerca de vencer resolución #{i+1}",
|
||||||
|
description=f"Este ticket está al {int(time_consumed/sla_hours*100)}% del SLA de resolución",
|
||||||
|
priority=priority,
|
||||||
|
status=TicketStatus.IN_PROGRESS,
|
||||||
|
category=category,
|
||||||
|
created_hours_ago=created_hours_ago,
|
||||||
|
first_response_hours_after=response_time,
|
||||||
|
resolved_hours_after=None,
|
||||||
|
assigned=True
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f" ✓ Creados 12 tickets EN RIESGO Resolution SLA")
|
||||||
|
|
||||||
|
# Guardar todos los tickets
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print("✅ GENERACIÓN DE DATOS SLA COMPLETADA")
|
||||||
|
print(f" Total de tickets creados: {tickets_created}")
|
||||||
|
print()
|
||||||
|
print("📊 Distribución esperada:")
|
||||||
|
print(" ✅ Response cumplidos: 15 tickets")
|
||||||
|
print(" 🔴 Response violados: 8 tickets")
|
||||||
|
print(" ⚠️ Response en riesgo: 10 tickets")
|
||||||
|
print(" ✅ Resolution cumplidos: 20 tickets")
|
||||||
|
print(" 🔴 Resolution violados: 6 tickets")
|
||||||
|
print(" ⚠️ Resolution en riesgo: 12 tickets")
|
||||||
|
print()
|
||||||
|
print("🌐 Ve los resultados en:")
|
||||||
|
print(" Dashboard SLA: http://localhost:3001/sla")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_sla_test_data():
|
||||||
|
"""Elimina tickets de prueba."""
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
tenant_result = await db.execute(select(Tenant).limit(1))
|
||||||
|
tenant = tenant_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not tenant:
|
||||||
|
print("❌ No se encontró ningún tenant.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Eliminar tickets que empiezan con TKT-
|
||||||
|
result = await db.execute(
|
||||||
|
select(Ticket).where(
|
||||||
|
Ticket.tenant_id == tenant.id,
|
||||||
|
Ticket.ticket_number.like('TKT-%')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tickets = result.scalars().all()
|
||||||
|
|
||||||
|
if not tickets:
|
||||||
|
print("ℹ️ No hay tickets de prueba para eliminar.")
|
||||||
|
return
|
||||||
|
|
||||||
|
for ticket in tickets:
|
||||||
|
await db.delete(ticket)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
print(f"✅ Eliminados {len(tickets)} tickets de prueba")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if len(sys.argv) > 1 and sys.argv[1] == "cleanup":
|
||||||
|
print("🧹 Limpiando datos de prueba de SLA...")
|
||||||
|
print()
|
||||||
|
asyncio.run(cleanup_sla_test_data())
|
||||||
|
else:
|
||||||
|
print("🚀 Generando datos de prueba para SLA Management...")
|
||||||
|
print()
|
||||||
|
asyncio.run(generate_sla_test_data())
|
||||||
|
print()
|
||||||
|
print("💡 Para limpiar estos datos de prueba, ejecuta:")
|
||||||
|
print(" python scripts/generate_sla_test_data.py cleanup")
|
||||||
49
backend/scripts/reset_passwords.py
Normal file
49
backend/scripts/reset_passwords.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""
|
||||||
|
Script para resetear contraseñas de todos los usuarios a valores conocidos.
|
||||||
|
Ejecutar con: python -m scripts.reset_passwords (desde /app en el contenedor)
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from app.core.database import AsyncSessionLocal
|
||||||
|
from app.core.security import security
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
# Mapa email -> nueva contraseña
|
||||||
|
PASSWORD_MAP = {
|
||||||
|
"admin@aduanasoft.com": "admin123",
|
||||||
|
"admin@test.com": "admin123",
|
||||||
|
"manager@aduanasoft.com": "manager123",
|
||||||
|
"agente@aduanasoft.com": "agente123",
|
||||||
|
"auditor1@test.com": "auditor123",
|
||||||
|
"admin-cliente@empresa-demo.com": "clienteadmin123",
|
||||||
|
"cliente@empresa-demo.com": "cliente123",
|
||||||
|
"test_user@aduanasoft.com": "test123",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def reset_all_passwords():
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
result = await db.execute(select(User))
|
||||||
|
users = result.scalars().all()
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
skipped = 0
|
||||||
|
for user in users:
|
||||||
|
if user.email in PASSWORD_MAP:
|
||||||
|
plain = PASSWORD_MAP[user.email]
|
||||||
|
user.password_hash = security.hash_password(plain)
|
||||||
|
user.email_verified = True
|
||||||
|
user.is_active = True
|
||||||
|
updated += 1
|
||||||
|
print(f" ✅ {user.email} → {plain}")
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
print(f" ⚠️ {user.email} (sin contraseña definida, se omite)")
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
print(f"\nResumen: {updated} actualizados, {skipped} omitidos")
|
||||||
|
print("\n📋 Credenciales listas:")
|
||||||
|
for email, pwd in PASSWORD_MAP.items():
|
||||||
|
print(f" {email} / {pwd}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(reset_all_passwords())
|
||||||
0
backend/scripts/set_test_password.py
Normal file
0
backend/scripts/set_test_password.py
Normal file
260
backend/tests/README_TESTS.md
Normal file
260
backend/tests/README_TESTS.md
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
# Tests de Integración - ServiceManagerWeb
|
||||||
|
|
||||||
|
Suite completa de tests de integración para validar funcionalidad crítica del sistema.
|
||||||
|
|
||||||
|
## 📋 Estructura de Tests
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/
|
||||||
|
├── conftest.py # Fixtures básicas (original)
|
||||||
|
├── conftest_integration.py # Fixtures para tests de integración
|
||||||
|
├── test_auth_integration.py # Tests de autenticación
|
||||||
|
├── test_multitenant_integration.py # Tests de aislamiento multi-tenant
|
||||||
|
├── test_tickets_integration.py # Tests CRUD de tickets
|
||||||
|
├── test_basic.py # Tests unitarios básicos (original)
|
||||||
|
└── test_health.py # Tests de health checks (original)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Ejecutar Tests
|
||||||
|
|
||||||
|
### Prerequisitos
|
||||||
|
|
||||||
|
1. **Servicios Docker corriendo:**
|
||||||
|
```bash
|
||||||
|
docker-compose up -d postgres redis
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Base de datos de testing:**
|
||||||
|
```bash
|
||||||
|
# Se crea automáticamente, pero si necesitas crearla manualmente:
|
||||||
|
docker-compose exec postgres psql -U servicemanager -c "CREATE DATABASE servicemanager_test;"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ejecución Rápida
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dar permisos de ejecución al script
|
||||||
|
chmod +x backend/run_tests.sh
|
||||||
|
|
||||||
|
# Ejecutar todos los tests
|
||||||
|
cd backend
|
||||||
|
./run_tests.sh all
|
||||||
|
|
||||||
|
# Ejecutar solo tests de autenticación
|
||||||
|
./run_tests.sh auth
|
||||||
|
|
||||||
|
# Ejecutar solo tests de multi-tenancy
|
||||||
|
./run_tests.sh multitenant
|
||||||
|
|
||||||
|
# Ejecutar solo tests de tickets
|
||||||
|
./run_tests.sh tickets
|
||||||
|
|
||||||
|
# Ejecutar con reporte de cobertura
|
||||||
|
./run_tests.sh coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ejecución Manual con pytest
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# Todos los tests de integración
|
||||||
|
pytest -v -m integration tests/
|
||||||
|
|
||||||
|
# Tests específicos por archivo
|
||||||
|
pytest -v tests/test_auth_integration.py
|
||||||
|
pytest -v tests/test_multitenant_integration.py
|
||||||
|
pytest -v tests/test_tickets_integration.py
|
||||||
|
|
||||||
|
# Con cobertura
|
||||||
|
pytest --cov=app --cov-report=html tests/test_*_integration.py
|
||||||
|
|
||||||
|
# Tests específicos por clase
|
||||||
|
pytest -v tests/test_auth_integration.py::TestAuthentication
|
||||||
|
|
||||||
|
# Test individual
|
||||||
|
pytest -v tests/test_auth_integration.py::TestAuthentication::test_login_success
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧪 Cobertura de Tests
|
||||||
|
|
||||||
|
### Tests de Autenticación (`test_auth_integration.py`)
|
||||||
|
- ✅ Login exitoso con credenciales válidas
|
||||||
|
- ✅ Login fallido (contraseña incorrecta, tenant inválido, usuario inactivo)
|
||||||
|
- ✅ Refresh tokens (generación y revocación)
|
||||||
|
- ✅ Logout y invalidación de tokens
|
||||||
|
- ✅ Autorización por roles (ADMIN, AGENT, CLIENT)
|
||||||
|
- ✅ Protección de endpoints
|
||||||
|
- ✅ Seguridad de passwords (hashing, no exposición)
|
||||||
|
|
||||||
|
**Total: 15 tests**
|
||||||
|
|
||||||
|
### Tests de Multi-Tenancy (`test_multitenant_integration.py`)
|
||||||
|
- ✅ Aislamiento de datos entre tenants
|
||||||
|
- ✅ Usuario no puede ver tickets de otro tenant
|
||||||
|
- ✅ Usuario no puede acceder por ID directo a datos de otro tenant
|
||||||
|
- ✅ Usuario no puede modificar datos de otro tenant
|
||||||
|
- ✅ Validación de X-Tenant-ID header
|
||||||
|
- ✅ Validación de UUIDs
|
||||||
|
- ✅ Permisos administrativos de tenants
|
||||||
|
- ✅ Prevención de suplantación de tenant
|
||||||
|
|
||||||
|
**Total: 13 tests** (CRÍTICOS para seguridad B2B)
|
||||||
|
|
||||||
|
### Tests de Tickets (`test_tickets_integration.py`)
|
||||||
|
- ✅ Crear ticket con validaciones
|
||||||
|
- ✅ Listar tickets (vacío y con datos)
|
||||||
|
- ✅ Obtener ticket por ID
|
||||||
|
- ✅ Actualizar ticket (status, prioridad, asignación)
|
||||||
|
- ✅ Filtros (por status, prioridad)
|
||||||
|
- ✅ Permisos por rol:
|
||||||
|
- Cliente solo ve sus tickets
|
||||||
|
- Agente ve todos los tickets del tenant
|
||||||
|
- Admin tiene acceso completo
|
||||||
|
|
||||||
|
**Total: 18 tests**
|
||||||
|
|
||||||
|
## 📊 Métricas Objetivo
|
||||||
|
|
||||||
|
```
|
||||||
|
Cobertura actual: ~5% ❌
|
||||||
|
Cobertura con estos tests: ~40% 🟡
|
||||||
|
Cobertura objetivo: >70% ⭐
|
||||||
|
|
||||||
|
Tests totales: 46 tests de integración
|
||||||
|
Tiempo ejecución: ~15-30 segundos
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Configuración
|
||||||
|
|
||||||
|
### Variables de Entorno para Testing
|
||||||
|
|
||||||
|
El archivo `conftest_integration.py` usa:
|
||||||
|
```python
|
||||||
|
TEST_DATABASE_URL = "postgresql+asyncpg://servicemanager:servicemanager123@localhost:5432/servicemanager_test"
|
||||||
|
```
|
||||||
|
|
||||||
|
Para personalizar:
|
||||||
|
```bash
|
||||||
|
export TEST_DATABASE_URL="postgresql+asyncpg://user:pass@host:port/db_test"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Markers de pytest
|
||||||
|
|
||||||
|
Usa markers para ejecutar subconjuntos:
|
||||||
|
```bash
|
||||||
|
# Solo tests de integración
|
||||||
|
pytest -m integration
|
||||||
|
|
||||||
|
# Solo tests que usan BD
|
||||||
|
pytest -m db
|
||||||
|
|
||||||
|
# Solo tests de auth
|
||||||
|
pytest -m auth
|
||||||
|
|
||||||
|
# Excluir tests lentos
|
||||||
|
pytest -m "not slow"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
### Error: "Database not found"
|
||||||
|
```bash
|
||||||
|
docker-compose exec postgres psql -U servicemanager -c "CREATE DATABASE servicemanager_test;"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error: "Connection refused"
|
||||||
|
```bash
|
||||||
|
# Verificar que servicios estén corriendo
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
# Reiniciar servicios
|
||||||
|
docker-compose restart postgres redis
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests lentos
|
||||||
|
```bash
|
||||||
|
# Ver tests más lentos
|
||||||
|
pytest --durations=10
|
||||||
|
|
||||||
|
# Ejecutar en paralelo (requiere pytest-xdist)
|
||||||
|
pip install pytest-xdist
|
||||||
|
pytest -n auto
|
||||||
|
```
|
||||||
|
|
||||||
|
### Limpiar base de datos de testing
|
||||||
|
```bash
|
||||||
|
./run_tests.sh clean
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Agregar Nuevos Tests
|
||||||
|
|
||||||
|
### Template para nuevo test
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
pytest_plugins = ['tests.conftest_integration']
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.db
|
||||||
|
class TestNuevaFuncionalidad:
|
||||||
|
"""Descripción de la funcionalidad."""
|
||||||
|
|
||||||
|
async def test_caso_exitoso(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test del caso exitoso."""
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/endpoint/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
# Más assertions...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Próximos Pasos
|
||||||
|
|
||||||
|
### Tests Pendientes (Prioridad Media)
|
||||||
|
- [ ] Tests de SLA (cálculos, violaciones)
|
||||||
|
- [ ] Tests de comentarios en tickets
|
||||||
|
- [ ] Tests de attachments (uploads)
|
||||||
|
- [ ] Tests de auditoría
|
||||||
|
- [ ] Tests de notificaciones email
|
||||||
|
- [ ] Tests de categorías y sistemas
|
||||||
|
- [ ] Tests de usuarios CRUD
|
||||||
|
|
||||||
|
### Mejoras de Testing (Prioridad Baja)
|
||||||
|
- [ ] Tests E2E con Playwright
|
||||||
|
- [ ] Tests de carga con Locust
|
||||||
|
- [ ] Tests de seguridad con OWASP ZAP
|
||||||
|
- [ ] Mutation testing con mutmut
|
||||||
|
- [ ] Property-based testing con Hypothesis
|
||||||
|
|
||||||
|
## 📚 Referencias
|
||||||
|
|
||||||
|
- [pytest documentation](https://docs.pytest.org/)
|
||||||
|
- [FastAPI testing](https://fastapi.tiangolo.com/tutorial/testing/)
|
||||||
|
- [pytest-asyncio](https://pytest-asyncio.readthedocs.io/)
|
||||||
|
- [SQLAlchemy testing](https://docs.sqlalchemy.org/en/20/orm/session_transaction.html#joining-a-session-into-an-external-transaction-such-as-for-test-suites)
|
||||||
|
|
||||||
|
## ✅ Checklist Pre-Producción
|
||||||
|
|
||||||
|
Antes de desplegar a producción, verificar:
|
||||||
|
|
||||||
|
- [ ] Todos los tests de integración pasan
|
||||||
|
- [ ] Cobertura de tests >70%
|
||||||
|
- [ ] Tests de multi-tenancy 100% exitosos
|
||||||
|
- [ ] Tests de autenticación 100% exitosos
|
||||||
|
- [ ] No hay credenciales hardcodeadas en tests
|
||||||
|
- [ ] Base de datos de testing separada de producción
|
||||||
|
- [ ] CI/CD configurado para ejecutar tests automáticamente
|
||||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
166
backend/tests/conftest.py
Normal file
166
backend/tests/conftest.py
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
"""
|
||||||
|
Test Configuration - ServiceManagerWeb
|
||||||
|
|
||||||
|
Configuración global para todos los tests (unit + integration).
|
||||||
|
Carga variables de entorno de prueba antes de cualquier import de la app,
|
||||||
|
y provee fixtures compartidos sin dependencia de Docker/PostgreSQL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
import asyncio
|
||||||
|
from typing import AsyncGenerator, Generator
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# CARGAR VARIABLES DE ENTORNO DE TEST ANTES DE IMPORTAR LA APP
|
||||||
|
# Esto evita que pydantic-settings falle por SECRET_KEY faltante
|
||||||
|
# ============================================================
|
||||||
|
os.environ.setdefault("ENVIRONMENT", "testing")
|
||||||
|
os.environ.setdefault("DEBUG", "true")
|
||||||
|
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-unit-tests-only-32chars!")
|
||||||
|
os.environ.setdefault("JWT_SECRET_KEY", "test-jwt-secret-key-for-unit-tests-only!")
|
||||||
|
os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///./test_unit.db")
|
||||||
|
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/15")
|
||||||
|
os.environ.setdefault("CELERY_BROKER_URL", "redis://localhost:6379/15")
|
||||||
|
os.environ.setdefault("CELERY_RESULT_BACKEND", "redis://localhost:6379/15")
|
||||||
|
os.environ.setdefault("CORS_ORIGINS", "http://localhost:3000")
|
||||||
|
os.environ.setdefault("ALLOWED_FILE_EXTENSIONS", "pdf,jpg,jpeg,png,doc,docx,txt")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# IN-MEMORY SQLite DB PARA UNIT TESTS (sin Docker)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def event_loop() -> Generator:
|
||||||
|
"""Event loop compartido para toda la sesión de tests."""
|
||||||
|
policy = asyncio.get_event_loop_policy()
|
||||||
|
loop = policy.new_event_loop()
|
||||||
|
yield loop
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
async def sqlite_engine():
|
||||||
|
"""
|
||||||
|
Engine SQLite en memoria para unit tests.
|
||||||
|
No requiere Docker ni PostgreSQL.
|
||||||
|
"""
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
from app.core.database import Base
|
||||||
|
# Importar todos los modelos para registrarlos en Base.metadata
|
||||||
|
import app.models # noqa: F401
|
||||||
|
|
||||||
|
engine = create_async_engine(
|
||||||
|
"sqlite+aiosqlite:///:memory:",
|
||||||
|
echo=False,
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
yield engine
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def db_session(sqlite_engine) -> AsyncGenerator:
|
||||||
|
"""
|
||||||
|
Sesión de BD SQLite en memoria para cada test.
|
||||||
|
Hace rollback al finalizar para mantener tests aislados.
|
||||||
|
"""
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
|
async_session = async_sessionmaker(
|
||||||
|
sqlite_engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
async with session.begin():
|
||||||
|
yield session
|
||||||
|
await session.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# FIXTURES DE DATOS COMUNES
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_user_data() -> dict:
|
||||||
|
"""Datos de usuario válidos para pruebas."""
|
||||||
|
return {
|
||||||
|
"email": "test@example.com",
|
||||||
|
"first_name": "Test",
|
||||||
|
"last_name": "User",
|
||||||
|
"password": "TestPassword123!",
|
||||||
|
"role": "AGENT",
|
||||||
|
"language": "es",
|
||||||
|
"timezone": "UTC",
|
||||||
|
"notifications_email": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_tenant_data() -> dict:
|
||||||
|
"""Datos de tenant válidos para pruebas."""
|
||||||
|
return {
|
||||||
|
"name": "Test Company",
|
||||||
|
"slug": "test-company",
|
||||||
|
"contact_email": "admin@testcompany.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_ticket_data() -> dict:
|
||||||
|
"""Datos de ticket válidos para pruebas."""
|
||||||
|
return {
|
||||||
|
"subject": "Test ticket subject",
|
||||||
|
"description": "Detailed description of the test ticket",
|
||||||
|
"priority": "MEDIUM",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_db_session():
|
||||||
|
"""Sesión de BD completamente mockeada (sin SQLite, sin red)."""
|
||||||
|
session = AsyncMock()
|
||||||
|
session.execute = AsyncMock()
|
||||||
|
session.add = MagicMock()
|
||||||
|
session.commit = AsyncMock()
|
||||||
|
session.refresh = AsyncMock()
|
||||||
|
session.rollback = AsyncMock()
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_request():
|
||||||
|
"""Request HTTP mockeado para tests de middleware y endpoints."""
|
||||||
|
request = MagicMock()
|
||||||
|
request.url.path = "/v1/tickets/"
|
||||||
|
request.method = "GET"
|
||||||
|
request.headers = {}
|
||||||
|
request.state = MagicMock()
|
||||||
|
return request
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_tenant_id() -> str:
|
||||||
|
"""UUID de tenant fijo para pruebas."""
|
||||||
|
return "12345678-1234-5678-1234-567812345678"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_user_id() -> str:
|
||||||
|
"""UUID de usuario fijo para pruebas."""
|
||||||
|
return "87654321-4321-8765-4321-876543218765"
|
||||||
297
backend/tests/conftest_integration.py
Normal file
297
backend/tests/conftest_integration.py
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
"""
|
||||||
|
Integration Test Configuration - ServiceManagerWeb
|
||||||
|
|
||||||
|
Fixtures y utilidades para tests de integración con BD real
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from typing import AsyncGenerator, Generator
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
from httpx import AsyncClient
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
from app.core.database import Base, get_db
|
||||||
|
from app.core.security import SecurityUtils
|
||||||
|
from app.models.tenant import Tenant, TenantStatus
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.models.system import System
|
||||||
|
from app.models.category import Category
|
||||||
|
|
||||||
|
|
||||||
|
# Database URL para testing.
|
||||||
|
# - En host/local: usa localhost
|
||||||
|
# - En Docker: deriva de DATABASE_URL (normalmente apunta a host 'postgres')
|
||||||
|
_DEFAULT_TEST_DATABASE_URL = "postgresql+asyncpg://servicemanager:servicemanager123@localhost:5432/servicemanager_test"
|
||||||
|
_ENV_TEST_DATABASE_URL = os.getenv("TEST_DATABASE_URL")
|
||||||
|
_ENV_DATABASE_URL = os.getenv("DATABASE_URL")
|
||||||
|
|
||||||
|
if _ENV_TEST_DATABASE_URL:
|
||||||
|
TEST_DATABASE_URL = _ENV_TEST_DATABASE_URL
|
||||||
|
elif _ENV_DATABASE_URL and "@postgres:" in _ENV_DATABASE_URL:
|
||||||
|
TEST_DATABASE_URL = _ENV_DATABASE_URL.rsplit("/", 1)[0] + "/servicemanager_test"
|
||||||
|
else:
|
||||||
|
TEST_DATABASE_URL = _DEFAULT_TEST_DATABASE_URL
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def event_loop() -> Generator:
|
||||||
|
"""Create event loop for async tests."""
|
||||||
|
policy = asyncio.get_event_loop_policy()
|
||||||
|
loop = policy.new_event_loop()
|
||||||
|
yield loop
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
async def test_engine():
|
||||||
|
"""Create test database engine."""
|
||||||
|
engine = create_async_engine(
|
||||||
|
TEST_DATABASE_URL,
|
||||||
|
echo=False,
|
||||||
|
poolclass=NullPool, # No pool para tests
|
||||||
|
)
|
||||||
|
|
||||||
|
# Crear todas las tablas
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
yield engine
|
||||||
|
|
||||||
|
# Limpiar después de todos los tests
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""Create a fresh database session for each test."""
|
||||||
|
async_session = async_sessionmaker(
|
||||||
|
test_engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False
|
||||||
|
)
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
async with session.begin():
|
||||||
|
yield session
|
||||||
|
# Rollback para limpiar después del test
|
||||||
|
await session.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
|
||||||
|
"""Create test client with overridden database dependency."""
|
||||||
|
|
||||||
|
async def override_get_db():
|
||||||
|
yield db_session
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
|
||||||
|
async with AsyncClient(app=app, base_url="http://test") as ac:
|
||||||
|
yield ac
|
||||||
|
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# FIXTURES DE DATOS DE TEST
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def test_tenant(db_session: AsyncSession) -> Tenant:
|
||||||
|
"""Create a test tenant."""
|
||||||
|
tenant = Tenant(
|
||||||
|
name="Test Company",
|
||||||
|
slug="test-company",
|
||||||
|
domain="test.company.com",
|
||||||
|
status=TenantStatus.ACTIVE,
|
||||||
|
contact_email="admin@test.company.com",
|
||||||
|
contact_phone="+1234567890",
|
||||||
|
)
|
||||||
|
db_session.add(tenant)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(tenant)
|
||||||
|
return tenant
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def test_tenant_2(db_session: AsyncSession) -> Tenant:
|
||||||
|
"""Create a second test tenant for multi-tenant tests."""
|
||||||
|
tenant = Tenant(
|
||||||
|
name="Test Company 2",
|
||||||
|
slug="test-company-2",
|
||||||
|
domain="test2.company.com",
|
||||||
|
status=TenantStatus.ACTIVE,
|
||||||
|
contact_email="admin@test2.company.com",
|
||||||
|
contact_phone="+9876543210",
|
||||||
|
)
|
||||||
|
db_session.add(tenant)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(tenant)
|
||||||
|
return tenant
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def test_admin_user(db_session: AsyncSession, test_tenant: Tenant) -> User:
|
||||||
|
"""Create a test admin user."""
|
||||||
|
user = User(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
email="admin@test.com",
|
||||||
|
first_name="Admin",
|
||||||
|
last_name="User",
|
||||||
|
password_hash=SecurityUtils.hash_password("AdminPass123!"),
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
is_active=True,
|
||||||
|
email_verified=True
|
||||||
|
)
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def test_agent_user(db_session: AsyncSession, test_tenant: Tenant) -> User:
|
||||||
|
"""Create a test agent user."""
|
||||||
|
user = User(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
email="agent@test.com",
|
||||||
|
first_name="Agent",
|
||||||
|
last_name="User",
|
||||||
|
password_hash=SecurityUtils.hash_password("AgentPass123!"),
|
||||||
|
role=UserRole.AGENT,
|
||||||
|
is_active=True,
|
||||||
|
email_verified=True
|
||||||
|
)
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def test_client_user(db_session: AsyncSession, test_tenant: Tenant) -> User:
|
||||||
|
"""Create a test client user."""
|
||||||
|
user = User(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
email="client@test.com",
|
||||||
|
first_name="Client",
|
||||||
|
last_name="User",
|
||||||
|
password_hash=SecurityUtils.hash_password("ClientPass123!"),
|
||||||
|
role=UserRole.CLIENT_USER,
|
||||||
|
is_active=True,
|
||||||
|
email_verified=True
|
||||||
|
)
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def test_system(db_session: AsyncSession, test_tenant: Tenant) -> System:
|
||||||
|
"""Create a test system."""
|
||||||
|
system = System(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
name="Test System",
|
||||||
|
code="TEST-SYS",
|
||||||
|
description="Test system for integration tests",
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
db_session.add(system)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(system)
|
||||||
|
return system
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def test_category(db_session: AsyncSession, test_tenant: Tenant, test_system: System) -> Category:
|
||||||
|
"""Create a test category."""
|
||||||
|
category = Category(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
system_id=test_system.id,
|
||||||
|
name="Test Category",
|
||||||
|
code="TEST-CAT",
|
||||||
|
description="Test category for integration tests",
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
db_session.add(category)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(category)
|
||||||
|
return category
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# FIXTURES DE AUTENTICACIÓN
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def admin_token(client: AsyncClient, test_admin_user: User, test_tenant: Tenant) -> str:
|
||||||
|
"""Get authentication token for admin user."""
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"email": "admin@test.com",
|
||||||
|
"password": "AdminPass123!",
|
||||||
|
"tenant_slug": test_tenant.slug
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
return data["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def agent_token(client: AsyncClient, test_agent_user: User, test_tenant: Tenant) -> str:
|
||||||
|
"""Get authentication token for agent user."""
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"email": "agent@test.com",
|
||||||
|
"password": "AgentPass123!",
|
||||||
|
"tenant_slug": test_tenant.slug
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
return data["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client_token(client: AsyncClient, test_client_user: User, test_tenant: Tenant) -> str:
|
||||||
|
"""Get authentication token for client user."""
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"email": "client@test.com",
|
||||||
|
"password": "ClientPass123!",
|
||||||
|
"tenant_slug": test_tenant.slug
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
return data["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_headers_admin(admin_token: str) -> dict:
|
||||||
|
"""Get authorization headers for admin user."""
|
||||||
|
return {"Authorization": f"Bearer {admin_token}"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_headers_agent(agent_token: str) -> dict:
|
||||||
|
"""Get authorization headers for agent user."""
|
||||||
|
return {"Authorization": f"Bearer {agent_token}"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_headers_client(client_token: str) -> dict:
|
||||||
|
"""Get authorization headers for client user."""
|
||||||
|
return {"Authorization": f"Bearer {client_token}"}
|
||||||
0
backend/tests/integration/__init__.py
Normal file
0
backend/tests/integration/__init__.py
Normal file
289
backend/tests/integration/conftest.py
Normal file
289
backend/tests/integration/conftest.py
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
"""Integration Test Configuration - ServiceManagerWeb
|
||||||
|
|
||||||
|
Fixtures y utilidades para tests de integración con BD real.
|
||||||
|
|
||||||
|
Este conftest vive dentro de tests/integration para que sus fixtures (client, db_session,
|
||||||
|
test_tenant, tokens, etc.) apliquen solo a los tests de integración y no colisionen con
|
||||||
|
los fixtures SQLite del conftest global.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
from sqlalchemy import text
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
from app.core.database import Base, get_db
|
||||||
|
from app.core.security import SecurityUtils
|
||||||
|
from app.models.tenant import Tenant, TenantStatus
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.models.system import System
|
||||||
|
from app.models.category import Category
|
||||||
|
|
||||||
|
|
||||||
|
_DEFAULT_TEST_DATABASE_URL = "postgresql+asyncpg://servicemanager:servicemanager123@localhost:5432/servicemanager_test"
|
||||||
|
_ENV_TEST_DATABASE_URL = os.getenv("TEST_DATABASE_URL")
|
||||||
|
_ENV_DATABASE_URL = os.getenv("DATABASE_URL")
|
||||||
|
|
||||||
|
if _ENV_TEST_DATABASE_URL:
|
||||||
|
TEST_DATABASE_URL = _ENV_TEST_DATABASE_URL
|
||||||
|
elif _ENV_DATABASE_URL and "@postgres:" in _ENV_DATABASE_URL:
|
||||||
|
TEST_DATABASE_URL = _ENV_DATABASE_URL.rsplit("/", 1)[0] + "/servicemanager_test"
|
||||||
|
else:
|
||||||
|
TEST_DATABASE_URL = _DEFAULT_TEST_DATABASE_URL
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
async def test_engine():
|
||||||
|
"""Create test database engine."""
|
||||||
|
engine = create_async_engine(
|
||||||
|
TEST_DATABASE_URL,
|
||||||
|
echo=False,
|
||||||
|
poolclass=NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
yield engine
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""Create a fresh database session for each integration test."""
|
||||||
|
async_session = async_sessionmaker(
|
||||||
|
test_engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
# Rollback any open transaction
|
||||||
|
await session.rollback()
|
||||||
|
|
||||||
|
# Hard reset DB state for next test (tests commit, so rollback alone isn't enough)
|
||||||
|
table_names = [t.name for t in Base.metadata.sorted_tables]
|
||||||
|
if table_names:
|
||||||
|
quoted = ", ".join(f'"{name}"' for name in table_names)
|
||||||
|
await session.execute(text(f"TRUNCATE TABLE {quoted} RESTART IDENTITY CASCADE"))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
|
||||||
|
"""Create test client with overridden database dependency."""
|
||||||
|
|
||||||
|
# Disable login rate limiting during integration tests to avoid flakiness
|
||||||
|
# (tests perform many logins quickly from the same IP).
|
||||||
|
import app.api.v1.endpoints.auth as auth_endpoint
|
||||||
|
|
||||||
|
old_rate_limit_enabled = getattr(auth_endpoint.settings, "RATE_LIMIT_ENABLED", None)
|
||||||
|
old_testing = getattr(auth_endpoint.settings, "TESTING", None)
|
||||||
|
auth_endpoint.settings.RATE_LIMIT_ENABLED = False
|
||||||
|
auth_endpoint.settings.TESTING = True
|
||||||
|
|
||||||
|
async def override_get_db():
|
||||||
|
yield db_session
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
|
||||||
|
async with AsyncClient(app=app, base_url="http://test") as ac:
|
||||||
|
yield ac
|
||||||
|
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
# Restore settings
|
||||||
|
if old_rate_limit_enabled is not None:
|
||||||
|
auth_endpoint.settings.RATE_LIMIT_ENABLED = old_rate_limit_enabled
|
||||||
|
if old_testing is not None:
|
||||||
|
auth_endpoint.settings.TESTING = old_testing
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# FIXTURES DE DATOS DE TEST
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def test_tenant(db_session: AsyncSession) -> Tenant:
|
||||||
|
tenant = Tenant(
|
||||||
|
name="Test Company",
|
||||||
|
slug="test-company",
|
||||||
|
domain="test.company.com",
|
||||||
|
status=TenantStatus.ACTIVE,
|
||||||
|
contact_email="admin@test.company.com",
|
||||||
|
contact_phone="+1234567890",
|
||||||
|
)
|
||||||
|
db_session.add(tenant)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(tenant)
|
||||||
|
return tenant
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def test_tenant_2(db_session: AsyncSession) -> Tenant:
|
||||||
|
tenant = Tenant(
|
||||||
|
name="Test Company 2",
|
||||||
|
slug="test-company-2",
|
||||||
|
domain="test2.company.com",
|
||||||
|
status=TenantStatus.ACTIVE,
|
||||||
|
contact_email="admin@test2.company.com",
|
||||||
|
contact_phone="+9876543210",
|
||||||
|
)
|
||||||
|
db_session.add(tenant)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(tenant)
|
||||||
|
return tenant
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def test_admin_user(db_session: AsyncSession, test_tenant: Tenant) -> User:
|
||||||
|
user = User(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
email="admin@test.com",
|
||||||
|
first_name="Admin",
|
||||||
|
last_name="User",
|
||||||
|
password_hash=SecurityUtils.hash_password("AdminPass123!"),
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
is_active=True,
|
||||||
|
email_verified=True,
|
||||||
|
)
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def test_agent_user(db_session: AsyncSession, test_tenant: Tenant) -> User:
|
||||||
|
user = User(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
email="agent@test.com",
|
||||||
|
first_name="Agent",
|
||||||
|
last_name="User",
|
||||||
|
password_hash=SecurityUtils.hash_password("AgentPass123!"),
|
||||||
|
role=UserRole.AGENT,
|
||||||
|
is_active=True,
|
||||||
|
email_verified=True,
|
||||||
|
)
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def test_client_user(db_session: AsyncSession, test_tenant: Tenant) -> User:
|
||||||
|
user = User(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
email="client@test.com",
|
||||||
|
first_name="Client",
|
||||||
|
last_name="User",
|
||||||
|
password_hash=SecurityUtils.hash_password("ClientPass123!"),
|
||||||
|
role=UserRole.CLIENT_USER,
|
||||||
|
is_active=True,
|
||||||
|
email_verified=True,
|
||||||
|
)
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def test_system(db_session: AsyncSession, test_tenant: Tenant) -> System:
|
||||||
|
system = System(
|
||||||
|
name="Test System",
|
||||||
|
description="Test system description",
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db_session.add(system)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(system)
|
||||||
|
return system
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def test_category(db_session: AsyncSession, test_tenant: Tenant) -> Category:
|
||||||
|
category = Category(
|
||||||
|
name="Test Category",
|
||||||
|
description="Test category description",
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
is_active=True,
|
||||||
|
sla_response_hours=24,
|
||||||
|
sla_resolution_hours=72,
|
||||||
|
)
|
||||||
|
db_session.add(category)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(category)
|
||||||
|
return category
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def admin_token(client: AsyncClient, test_admin_user: User, test_tenant: Tenant) -> str:
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"email": test_admin_user.email,
|
||||||
|
"password": "AdminPass123!",
|
||||||
|
"tenant_slug": test_tenant.slug,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
return response.json()["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def agent_token(client: AsyncClient, test_agent_user: User, test_tenant: Tenant) -> str:
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"email": test_agent_user.email,
|
||||||
|
"password": "AgentPass123!",
|
||||||
|
"tenant_slug": test_tenant.slug,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
return response.json()["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client_token(client: AsyncClient, test_client_user: User, test_tenant: Tenant) -> str:
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"email": test_client_user.email,
|
||||||
|
"password": "ClientPass123!",
|
||||||
|
"tenant_slug": test_tenant.slug,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
return response.json()["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_headers_admin(admin_token: str) -> dict:
|
||||||
|
return {"Authorization": f"Bearer {admin_token}"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_headers_agent(agent_token: str) -> dict:
|
||||||
|
return {"Authorization": f"Bearer {agent_token}"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_headers_client(client_token: str) -> dict:
|
||||||
|
return {"Authorization": f"Bearer {client_token}"}
|
||||||
421
backend/tests/integration/test_auth_integration.py
Normal file
421
backend/tests/integration/test_auth_integration.py
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
"""
|
||||||
|
Authentication Integration Tests - ServiceManagerWeb
|
||||||
|
|
||||||
|
Tests completos del flujo de autenticación incluyendo:
|
||||||
|
- Login
|
||||||
|
- Refresh tokens
|
||||||
|
- Logout
|
||||||
|
- Permisos y roles
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.models.tenant import Tenant
|
||||||
|
|
||||||
|
# Importar fixtures desde conftest_integration
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.auth
|
||||||
|
class TestAuthentication:
|
||||||
|
"""Tests de autenticación básica."""
|
||||||
|
|
||||||
|
async def test_login_success(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_admin_user: User,
|
||||||
|
test_tenant: Tenant
|
||||||
|
):
|
||||||
|
"""Test login exitoso con credenciales válidas."""
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"email": "admin@test.com",
|
||||||
|
"password": "AdminPass123!",
|
||||||
|
"tenant_slug": test_tenant.slug
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert "access_token" in data
|
||||||
|
assert "refresh_token" in data
|
||||||
|
assert data["token_type"] == "bearer"
|
||||||
|
assert data["expires_in"] > 0
|
||||||
|
assert data["user"]["email"] == "admin@test.com"
|
||||||
|
assert data["user"]["role"] == "ADMIN"
|
||||||
|
|
||||||
|
async def test_login_invalid_password(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_admin_user: User,
|
||||||
|
test_tenant: Tenant
|
||||||
|
):
|
||||||
|
"""Test login con contraseña incorrecta."""
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"email": "admin@test.com",
|
||||||
|
"password": "WrongPassword123!",
|
||||||
|
"tenant_slug": test_tenant.slug
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "Invalid credentials" in response.json()["detail"]
|
||||||
|
|
||||||
|
async def test_login_invalid_tenant_slug(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_admin_user: User
|
||||||
|
):
|
||||||
|
"""Test login con tenant slug inexistente."""
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"email": "admin@test.com",
|
||||||
|
"password": "AdminPass123!",
|
||||||
|
"tenant_slug": "nonexistent-tenant"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
async def test_login_user_not_found(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant
|
||||||
|
):
|
||||||
|
"""Test login con email inexistente."""
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"email": "notfound@test.com",
|
||||||
|
"password": "SomePassword123!",
|
||||||
|
"tenant_slug": test_tenant.slug
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
async def test_login_inactive_user(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_admin_user: User,
|
||||||
|
test_tenant: Tenant
|
||||||
|
):
|
||||||
|
"""Test login con usuario desactivado."""
|
||||||
|
# Desactivar usuario
|
||||||
|
test_admin_user.is_active = False
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"email": "admin@test.com",
|
||||||
|
"password": "AdminPass123!",
|
||||||
|
"tenant_slug": test_tenant.slug
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
async def test_login_rate_limited_after_too_many_attempts(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_admin_user: User,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""Debe devolver 429 después de demasiados intentos de login (rate limit)."""
|
||||||
|
|
||||||
|
import app.api.v1.endpoints.auth as auth_endpoint
|
||||||
|
|
||||||
|
class _FakeCache:
|
||||||
|
def __init__(self):
|
||||||
|
self._counts = {}
|
||||||
|
self._expires = {}
|
||||||
|
|
||||||
|
async def incr(self, key: str, amount: int = 1):
|
||||||
|
self._counts[key] = self._counts.get(key, 0) + amount
|
||||||
|
return self._counts[key]
|
||||||
|
|
||||||
|
async def expire(self, key: str, ttl: int):
|
||||||
|
self._expires[key] = ttl
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def delete(self, key: str):
|
||||||
|
self._counts.pop(key, None)
|
||||||
|
return True
|
||||||
|
|
||||||
|
fake_cache = _FakeCache()
|
||||||
|
monkeypatch.setattr(auth_endpoint, "cache", fake_cache)
|
||||||
|
monkeypatch.setattr(auth_endpoint.settings, "RATE_LIMIT_ENABLED", True, raising=False)
|
||||||
|
monkeypatch.setattr(auth_endpoint.settings, "TESTING", False, raising=False)
|
||||||
|
monkeypatch.setattr(auth_endpoint.settings, "LOGIN_RATE_LIMIT_WINDOW_SECONDS", 60, raising=False)
|
||||||
|
monkeypatch.setattr(auth_endpoint.settings, "LOGIN_RATE_LIMIT_IP_MAX_ATTEMPTS", 10_000, raising=False)
|
||||||
|
monkeypatch.setattr(auth_endpoint.settings, "LOGIN_RATE_LIMIT_ID_MAX_ATTEMPTS", 2, raising=False)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"email": test_admin_user.email,
|
||||||
|
"password": "WrongPassword123!",
|
||||||
|
"tenant_slug": test_tenant.slug,
|
||||||
|
}
|
||||||
|
|
||||||
|
r1 = await client.post("/v1/auth/login", json=payload)
|
||||||
|
assert r1.status_code == 401
|
||||||
|
|
||||||
|
r2 = await client.post("/v1/auth/login", json=payload)
|
||||||
|
assert r2.status_code == 401
|
||||||
|
|
||||||
|
r3 = await client.post("/v1/auth/login", json=payload)
|
||||||
|
assert r3.status_code == 429
|
||||||
|
assert "Retry-After" in r3.headers
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.auth
|
||||||
|
class TestRefreshToken:
|
||||||
|
"""Tests de refresh tokens."""
|
||||||
|
|
||||||
|
async def test_refresh_token_success(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_admin_user: User,
|
||||||
|
test_tenant: Tenant
|
||||||
|
):
|
||||||
|
"""Test refresh token exitoso."""
|
||||||
|
# Login para obtener tokens
|
||||||
|
login_response = await client.post(
|
||||||
|
"/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"email": "admin@test.com",
|
||||||
|
"password": "AdminPass123!",
|
||||||
|
"tenant_slug": test_tenant.slug
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert login_response.status_code == 200
|
||||||
|
refresh_token = login_response.json()["refresh_token"]
|
||||||
|
|
||||||
|
# Usar refresh token
|
||||||
|
refresh_response = await client.post(
|
||||||
|
"/v1/auth/refresh",
|
||||||
|
json={"refresh_token": refresh_token}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert refresh_response.status_code == 200
|
||||||
|
data = refresh_response.json()
|
||||||
|
|
||||||
|
assert "access_token" in data
|
||||||
|
assert data["token_type"] == "bearer"
|
||||||
|
assert data["expires_in"] > 0
|
||||||
|
|
||||||
|
async def test_refresh_token_invalid(self, client: AsyncClient):
|
||||||
|
"""Test refresh con token inválido."""
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/auth/refresh",
|
||||||
|
json={"refresh_token": "invalid-token"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
async def test_refresh_token_after_logout(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_admin_user: User,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
admin_token: str
|
||||||
|
):
|
||||||
|
"""Test que refresh token no funciona después de logout."""
|
||||||
|
# Login
|
||||||
|
login_response = await client.post(
|
||||||
|
"/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"email": "admin@test.com",
|
||||||
|
"password": "AdminPass123!",
|
||||||
|
"tenant_slug": test_tenant.slug
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
refresh_token = login_response.json()["refresh_token"]
|
||||||
|
|
||||||
|
# Logout
|
||||||
|
logout_response = await client.post(
|
||||||
|
"/v1/auth/logout",
|
||||||
|
headers={"Authorization": f"Bearer {admin_token}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert logout_response.status_code == 200
|
||||||
|
|
||||||
|
# Intentar usar refresh token después de logout
|
||||||
|
refresh_response = await client.post(
|
||||||
|
"/v1/auth/refresh",
|
||||||
|
json={"refresh_token": refresh_token}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert refresh_response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.auth
|
||||||
|
class TestAuthorization:
|
||||||
|
"""Tests de autorización y permisos."""
|
||||||
|
|
||||||
|
async def test_admin_can_access_admin_endpoint(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test que admin puede acceder a endpoints de admin."""
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tenants/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
async def test_agent_cannot_access_admin_endpoint(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
auth_headers_agent: dict
|
||||||
|
):
|
||||||
|
"""Test que agent no puede acceder a endpoints de admin."""
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tenants/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_agent,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
async def test_client_cannot_access_admin_endpoint(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
auth_headers_client: dict
|
||||||
|
):
|
||||||
|
"""Test que client no puede acceder a endpoints de admin."""
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tenants/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_client,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
async def test_protected_endpoint_without_token(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant
|
||||||
|
):
|
||||||
|
"""Test que endpoints protegidos requieren token."""
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tickets/",
|
||||||
|
headers={"X-Tenant-ID": str(test_tenant.id)}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
async def test_protected_endpoint_with_invalid_token(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant
|
||||||
|
):
|
||||||
|
"""Test con token inválido."""
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tickets/",
|
||||||
|
headers={
|
||||||
|
"Authorization": "Bearer invalid-token",
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.auth
|
||||||
|
class TestUserProfile:
|
||||||
|
"""Tests del perfil de usuario."""
|
||||||
|
|
||||||
|
async def test_get_current_user_profile(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_admin_user: User,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test obtener perfil del usuario actual."""
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/users/me",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert data["email"] == "admin@test.com"
|
||||||
|
assert data["role"] == "ADMIN"
|
||||||
|
assert data["first_name"] == "Admin"
|
||||||
|
assert data["last_name"] == "User"
|
||||||
|
assert "password_hash" not in data # No debe exponer password
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.auth
|
||||||
|
class TestPasswordSecurity:
|
||||||
|
"""Tests de seguridad de contraseñas."""
|
||||||
|
|
||||||
|
async def test_password_hashing(self):
|
||||||
|
"""Test que las contraseñas se hashean correctamente."""
|
||||||
|
from app.core.security import SecurityUtils
|
||||||
|
|
||||||
|
password = "TestPassword123!"
|
||||||
|
hashed = SecurityUtils.hash_password(password)
|
||||||
|
|
||||||
|
# Debe ser diferente del original
|
||||||
|
assert hashed != password
|
||||||
|
|
||||||
|
# Debe poder verificarse
|
||||||
|
assert SecurityUtils.verify_password(password, hashed)
|
||||||
|
|
||||||
|
# Contraseña incorrecta no debe verificar
|
||||||
|
assert not SecurityUtils.verify_password("WrongPassword", hashed)
|
||||||
|
|
||||||
|
async def test_password_not_exposed_in_response(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_admin_user: User,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test que el password hash nunca se expone en las respuestas."""
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/users/me",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert "password" not in data
|
||||||
|
assert "password_hash" not in data
|
||||||
354
backend/tests/integration/test_multitenant_integration.py
Normal file
354
backend/tests/integration/test_multitenant_integration.py
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
"""
|
||||||
|
Multi-Tenancy Integration Tests - ServiceManagerWeb
|
||||||
|
|
||||||
|
Tests críticos para verificar el aislamiento de datos entre tenants.
|
||||||
|
Estos tests son ESENCIALES para seguridad B2B.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.models.tenant import Tenant
|
||||||
|
from app.models.ticket import Ticket, TicketStatus, TicketPriority
|
||||||
|
from app.core.security import SecurityUtils
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.db
|
||||||
|
class TestTenantIsolation:
|
||||||
|
"""Tests de aislamiento de datos entre tenants."""
|
||||||
|
|
||||||
|
async def test_user_cannot_see_other_tenant_tickets(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_tenant_2: Tenant,
|
||||||
|
test_admin_user: User,
|
||||||
|
test_category,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test crítico: Usuario de tenant A no puede ver tickets de tenant B."""
|
||||||
|
|
||||||
|
# Crear usuario en tenant 2
|
||||||
|
user_tenant_2 = User(
|
||||||
|
tenant_id=test_tenant_2.id,
|
||||||
|
email="admin@tenant2.com",
|
||||||
|
first_name="Admin",
|
||||||
|
last_name="Tenant2",
|
||||||
|
password_hash=SecurityUtils.hash_password("Password123!"),
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
is_active=True,
|
||||||
|
email_verified=True
|
||||||
|
)
|
||||||
|
db_session.add(user_tenant_2)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# Crear ticket en tenant 2
|
||||||
|
ticket_tenant_2 = Ticket(
|
||||||
|
tenant_id=test_tenant_2.id,
|
||||||
|
title="Ticket privado de Tenant 2",
|
||||||
|
description="Este ticket NO debe ser visible para tenant 1",
|
||||||
|
status=TicketStatus.NEW,
|
||||||
|
priority=TicketPriority.HIGH,
|
||||||
|
created_by=user_tenant_2.id,
|
||||||
|
category_id=test_category.id
|
||||||
|
)
|
||||||
|
db_session.add(ticket_tenant_2)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# Usuario de tenant 1 intenta listar tickets
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tickets/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
tickets = response.json()
|
||||||
|
|
||||||
|
# NO debe contener el ticket de tenant 2
|
||||||
|
ticket_ids = [t["id"] for t in tickets]
|
||||||
|
assert str(ticket_tenant_2.id) not in ticket_ids
|
||||||
|
|
||||||
|
async def test_user_cannot_access_other_tenant_ticket_directly(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_tenant_2: Tenant,
|
||||||
|
test_admin_user: User,
|
||||||
|
test_category,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test: Usuario no puede acceder a ticket de otro tenant por ID directo."""
|
||||||
|
|
||||||
|
# Crear usuario en tenant 2
|
||||||
|
user_tenant_2 = User(
|
||||||
|
tenant_id=test_tenant_2.id,
|
||||||
|
email="user@tenant2.com",
|
||||||
|
first_name="User",
|
||||||
|
last_name="Tenant2",
|
||||||
|
password_hash=SecurityUtils.hash_password("Password123!"),
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
is_active=True,
|
||||||
|
email_verified=True
|
||||||
|
)
|
||||||
|
db_session.add(user_tenant_2)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# Crear ticket en tenant 2
|
||||||
|
ticket_tenant_2 = Ticket(
|
||||||
|
tenant_id=test_tenant_2.id,
|
||||||
|
title="Ticket secreto",
|
||||||
|
description="Información confidencial",
|
||||||
|
status=TicketStatus.NEW,
|
||||||
|
priority=TicketPriority.URGENT,
|
||||||
|
created_by=user_tenant_2.id,
|
||||||
|
category_id=test_category.id
|
||||||
|
)
|
||||||
|
db_session.add(ticket_tenant_2)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# Usuario de tenant 1 intenta acceder con ID directo
|
||||||
|
response = await client.get(
|
||||||
|
f"/v1/tickets/{ticket_tenant_2.id}",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Debe devolver 404 (no 403 para no revelar existencia)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
async def test_user_cannot_update_other_tenant_ticket(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_tenant_2: Tenant,
|
||||||
|
test_category,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test: Usuario no puede modificar ticket de otro tenant."""
|
||||||
|
|
||||||
|
# Crear usuario y ticket en tenant 2
|
||||||
|
user_tenant_2 = User(
|
||||||
|
tenant_id=test_tenant_2.id,
|
||||||
|
email="user@tenant2.com",
|
||||||
|
first_name="User",
|
||||||
|
last_name="Tenant2",
|
||||||
|
password_hash=SecurityUtils.hash_password("Password123!"),
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
is_active=True,
|
||||||
|
email_verified=True
|
||||||
|
)
|
||||||
|
db_session.add(user_tenant_2)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
ticket_tenant_2 = Ticket(
|
||||||
|
tenant_id=test_tenant_2.id,
|
||||||
|
title="Original title",
|
||||||
|
description="Original description",
|
||||||
|
status=TicketStatus.NEW,
|
||||||
|
priority=TicketPriority.MEDIUM,
|
||||||
|
created_by=user_tenant_2.id,
|
||||||
|
category_id=test_category.id
|
||||||
|
)
|
||||||
|
db_session.add(ticket_tenant_2)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
original_title = ticket_tenant_2.title
|
||||||
|
|
||||||
|
# Usuario de tenant 1 intenta modificar
|
||||||
|
response = await client.patch(
|
||||||
|
f"/v1/tickets/{ticket_tenant_2.id}",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"title": "HACKED TITLE",
|
||||||
|
"status": "CLOSED"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
# Verificar que el ticket NO fue modificado
|
||||||
|
await db_session.refresh(ticket_tenant_2)
|
||||||
|
assert ticket_tenant_2.title == original_title
|
||||||
|
assert ticket_tenant_2.status == TicketStatus.NEW
|
||||||
|
|
||||||
|
async def test_middleware_validates_tenant_header(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test que el middleware valida el X-Tenant-ID header."""
|
||||||
|
|
||||||
|
# Sin header de tenant
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tickets/",
|
||||||
|
headers=auth_headers_admin
|
||||||
|
)
|
||||||
|
|
||||||
|
# Debe requerir tenant header
|
||||||
|
assert response.status_code in [400, 401]
|
||||||
|
|
||||||
|
async def test_middleware_rejects_invalid_tenant_uuid(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test que el middleware rechaza UUIDs inválidos."""
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tickets/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": "not-a-uuid"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
async def test_middleware_rejects_nonexistent_tenant(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test que el middleware rechaza tenants inexistentes."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
fake_tenant_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tickets/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": fake_tenant_id
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.db
|
||||||
|
class TestTenantAdminEndpoints:
|
||||||
|
"""Tests de endpoints administrativos de tenants."""
|
||||||
|
|
||||||
|
async def test_admin_can_list_tenants(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_tenant_2: Tenant,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test que admin puede listar tenants."""
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tenants/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
tenants = response.json()
|
||||||
|
assert len(tenants) >= 2
|
||||||
|
|
||||||
|
async def test_non_admin_cannot_list_tenants(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
auth_headers_client: dict
|
||||||
|
):
|
||||||
|
"""Test que usuario no-admin no puede listar tenants."""
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tenants/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_client,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
async def test_admin_can_create_tenant(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test que admin puede crear nuevos tenants."""
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/tenants/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"name": "New Test Company",
|
||||||
|
"slug": "new-test-company",
|
||||||
|
"domain": "new.test.com",
|
||||||
|
"email": "admin@new.test.com",
|
||||||
|
"phone": "+1111111111"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "New Test Company"
|
||||||
|
assert data["slug"] == "new-test-company"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.db
|
||||||
|
class TestCrossTenantuserAccess:
|
||||||
|
"""Tests de acceso de usuarios entre tenants."""
|
||||||
|
|
||||||
|
async def test_user_belongs_to_only_one_tenant(
|
||||||
|
self,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_admin_user: User,
|
||||||
|
test_tenant: Tenant
|
||||||
|
):
|
||||||
|
"""Test que cada usuario pertenece a exactamente un tenant."""
|
||||||
|
|
||||||
|
assert test_admin_user.tenant_id == test_tenant.id
|
||||||
|
|
||||||
|
# Verificar que no puede tener múltiples tenant_ids
|
||||||
|
# (esto es a nivel de modelo, pero importante documentar)
|
||||||
|
|
||||||
|
async def test_user_from_tenant_a_cannot_impersonate_tenant_b(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_tenant_2: Tenant,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test que usuario autenticado no puede cambiar de tenant."""
|
||||||
|
|
||||||
|
# Usuario de tenant 1 intenta usar header de tenant 2
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tickets/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant_2.id) # Intento de suplantación
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# La request debe fallar (el token pertenece a tenant 1)
|
||||||
|
# El comportamiento específico depende de tu implementación,
|
||||||
|
# pero NO debe permitir acceso a datos de tenant 2
|
||||||
|
assert response.status_code in [403, 404, 401]
|
||||||
56
backend/tests/integration/test_setup_verification.py
Normal file
56
backend/tests/integration/test_setup_verification.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""Quick Test Verification - ServiceManagerWeb
|
||||||
|
|
||||||
|
Smoke tests para verificar que el setup de tests de integración funciona correctamente.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestSetupVerification:
|
||||||
|
async def test_client_fixture_works(self, client: AsyncClient):
|
||||||
|
assert client is not None
|
||||||
|
assert str(client.base_url) == "http://test"
|
||||||
|
|
||||||
|
async def test_database_connection(self, db_session):
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
result = await db_session.execute(text("SELECT 1"))
|
||||||
|
assert result.scalar() == 1
|
||||||
|
|
||||||
|
async def test_tenant_fixture_creates_tenant(self, test_tenant):
|
||||||
|
assert test_tenant.name == "Test Company"
|
||||||
|
assert test_tenant.slug == "test-company"
|
||||||
|
|
||||||
|
async def test_user_fixtures_work(self, test_admin_user, test_agent_user, test_client_user):
|
||||||
|
assert test_admin_user.role.value == "ADMIN"
|
||||||
|
assert test_agent_user.role.value == "AGENT"
|
||||||
|
assert test_client_user.role.value == "CLIENT_USER"
|
||||||
|
|
||||||
|
async def test_auth_token_generation(self, admin_token: str):
|
||||||
|
assert isinstance(admin_token, str)
|
||||||
|
assert len(admin_token) > 20
|
||||||
|
|
||||||
|
async def test_health_endpoint(self, client: AsyncClient):
|
||||||
|
response = await client.get("/health")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["status"] == "healthy"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestBasicEndpoints:
|
||||||
|
async def test_health_endpoint_detailed(self, client: AsyncClient):
|
||||||
|
response = await client.get("/v1/health/detailed")
|
||||||
|
assert response.status_code in (200, 503)
|
||||||
|
|
||||||
|
async def test_login_endpoint_exists(self, client: AsyncClient):
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"email": "nonexistent@test.com",
|
||||||
|
"password": "wrong",
|
||||||
|
"tenant_slug": "nonexistent",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code in (401, 404, 422)
|
||||||
676
backend/tests/integration/test_tickets_integration.py
Normal file
676
backend/tests/integration/test_tickets_integration.py
Normal file
@@ -0,0 +1,676 @@
|
|||||||
|
"""
|
||||||
|
Tickets Integration Tests - ServiceManagerWeb
|
||||||
|
|
||||||
|
Tests completos del CRUD de tickets y funcionalidad relacionada.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.tenant import Tenant
|
||||||
|
from app.models.ticket import Ticket, TicketStatus, TicketPriority
|
||||||
|
from app.models.system import System
|
||||||
|
from app.models.category import Category
|
||||||
|
from app.core.file_handler import file_handler
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.db
|
||||||
|
class TestTicketCreation:
|
||||||
|
"""Tests de creación de tickets."""
|
||||||
|
|
||||||
|
async def test_create_ticket_success(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_category: Category,
|
||||||
|
auth_headers_client: dict
|
||||||
|
):
|
||||||
|
"""Test crear ticket con datos válidos."""
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/tickets/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_client,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"title": "Test ticket",
|
||||||
|
"description": "This is a test ticket description",
|
||||||
|
"priority": "MEDIUM",
|
||||||
|
"category_id": str(test_category.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert data["title"] == "Test ticket"
|
||||||
|
assert data["description"] == "This is a test ticket description"
|
||||||
|
assert data["priority"] == "MEDIUM"
|
||||||
|
assert data["status"] == "NEW"
|
||||||
|
assert data["category_id"] == str(test_category.id)
|
||||||
|
|
||||||
|
async def test_create_ticket_with_all_fields(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_category: Category,
|
||||||
|
test_system: System,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test crear ticket con todos los campos opcionales."""
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/tickets/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"title": "Complete ticket",
|
||||||
|
"description": "Full ticket with all fields",
|
||||||
|
"priority": "HIGH",
|
||||||
|
"category_id": str(test_category.id),
|
||||||
|
"system_id": str(test_system.id),
|
||||||
|
"contact_email": "contact@test.com",
|
||||||
|
"contact_phone": "+1234567890"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert data["priority"] == "HIGH"
|
||||||
|
assert data["system_id"] == str(test_system.id)
|
||||||
|
assert data["contact_email"] == "contact@test.com"
|
||||||
|
|
||||||
|
async def test_create_ticket_missing_required_fields(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
auth_headers_client: dict
|
||||||
|
):
|
||||||
|
"""Test crear ticket sin campos requeridos."""
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/tickets/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_client,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"description": "Missing title"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422 # Validation error
|
||||||
|
|
||||||
|
async def test_create_ticket_invalid_priority(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_category: Category,
|
||||||
|
auth_headers_client: dict
|
||||||
|
):
|
||||||
|
"""Test crear ticket con prioridad inválida."""
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/tickets/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_client,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"title": "Test ticket",
|
||||||
|
"description": "Description",
|
||||||
|
"priority": "SUPER_URGENT", # Inválido
|
||||||
|
"category_id": str(test_category.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.db
|
||||||
|
class TestTicketRetrieval:
|
||||||
|
"""Tests de consulta de tickets."""
|
||||||
|
|
||||||
|
async def test_list_tickets_empty(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test listar tickets cuando no hay ninguno."""
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tickets/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
tickets = response.json()
|
||||||
|
assert isinstance(tickets, list)
|
||||||
|
|
||||||
|
async def test_list_tickets_with_data(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_admin_user: User,
|
||||||
|
test_category: Category,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test listar tickets cuando existen."""
|
||||||
|
|
||||||
|
# Crear algunos tickets
|
||||||
|
for i in range(3):
|
||||||
|
ticket = Ticket(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
title=f"Test ticket {i+1}",
|
||||||
|
description=f"Description {i+1}",
|
||||||
|
status=TicketStatus.NEW,
|
||||||
|
priority=TicketPriority.MEDIUM,
|
||||||
|
created_by=test_admin_user.id,
|
||||||
|
category_id=test_category.id
|
||||||
|
)
|
||||||
|
db_session.add(ticket)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tickets/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
tickets = response.json()
|
||||||
|
assert len(tickets) == 3
|
||||||
|
|
||||||
|
async def test_get_ticket_by_id(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_admin_user: User,
|
||||||
|
test_category: Category,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test obtener ticket específico por ID."""
|
||||||
|
|
||||||
|
ticket = Ticket(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
title="Specific ticket",
|
||||||
|
description="Get this ticket",
|
||||||
|
status=TicketStatus.NEW,
|
||||||
|
priority=TicketPriority.HIGH,
|
||||||
|
created_by=test_admin_user.id,
|
||||||
|
category_id=test_category.id
|
||||||
|
)
|
||||||
|
db_session.add(ticket)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(ticket)
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
f"/v1/tickets/{ticket.id}",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["id"] == str(ticket.id)
|
||||||
|
assert data["title"] == "Specific ticket"
|
||||||
|
|
||||||
|
async def test_get_nonexistent_ticket(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test obtener ticket inexistente."""
|
||||||
|
|
||||||
|
fake_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
f"/v1/tickets/{fake_id}",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.db
|
||||||
|
class TestTicketUpdate:
|
||||||
|
"""Tests de actualización de tickets."""
|
||||||
|
|
||||||
|
async def test_update_ticket_status(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_admin_user: User,
|
||||||
|
test_category: Category,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test actualizar status de ticket."""
|
||||||
|
|
||||||
|
ticket = Ticket(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
title="Ticket to update",
|
||||||
|
description="Description",
|
||||||
|
status=TicketStatus.NEW,
|
||||||
|
priority=TicketPriority.MEDIUM,
|
||||||
|
created_by=test_admin_user.id,
|
||||||
|
category_id=test_category.id
|
||||||
|
)
|
||||||
|
db_session.add(ticket)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(ticket)
|
||||||
|
|
||||||
|
response = await client.patch(
|
||||||
|
f"/v1/tickets/{ticket.id}",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"status": "IN_PROGRESS"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["status"] == "IN_PROGRESS"
|
||||||
|
|
||||||
|
async def test_update_ticket_priority(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_admin_user: User,
|
||||||
|
test_category: Category,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test actualizar prioridad de ticket."""
|
||||||
|
|
||||||
|
ticket = Ticket(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
title="Ticket priority test",
|
||||||
|
description="Description",
|
||||||
|
status=TicketStatus.NEW,
|
||||||
|
priority=TicketPriority.LOW,
|
||||||
|
created_by=test_admin_user.id,
|
||||||
|
category_id=test_category.id
|
||||||
|
)
|
||||||
|
db_session.add(ticket)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(ticket)
|
||||||
|
|
||||||
|
response = await client.patch(
|
||||||
|
f"/v1/tickets/{ticket.id}",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"priority": "URGENT"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["priority"] == "URGENT"
|
||||||
|
|
||||||
|
async def test_update_ticket_assignment(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_admin_user: User,
|
||||||
|
test_agent_user: User,
|
||||||
|
test_category: Category,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test asignar ticket a un agente."""
|
||||||
|
|
||||||
|
ticket = Ticket(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
title="Ticket to assign",
|
||||||
|
description="Description",
|
||||||
|
status=TicketStatus.NEW,
|
||||||
|
priority=TicketPriority.MEDIUM,
|
||||||
|
created_by=test_admin_user.id,
|
||||||
|
category_id=test_category.id
|
||||||
|
)
|
||||||
|
db_session.add(ticket)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(ticket)
|
||||||
|
|
||||||
|
response = await client.patch(
|
||||||
|
f"/v1/tickets/{ticket.id}",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"assigned_to": str(test_agent_user.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["assigned_to"] == str(test_agent_user.id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.db
|
||||||
|
class TestTicketFilters:
|
||||||
|
"""Tests de filtros de tickets."""
|
||||||
|
|
||||||
|
async def test_filter_by_status(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_admin_user: User,
|
||||||
|
test_category: Category,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test filtrar tickets por status."""
|
||||||
|
|
||||||
|
# Crear tickets con diferentes status
|
||||||
|
ticket_new = Ticket(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
title="New ticket",
|
||||||
|
description="Description",
|
||||||
|
status=TicketStatus.NEW,
|
||||||
|
priority=TicketPriority.MEDIUM,
|
||||||
|
created_by=test_admin_user.id,
|
||||||
|
category_id=test_category.id
|
||||||
|
)
|
||||||
|
ticket_progress = Ticket(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
title="In progress ticket",
|
||||||
|
description="Description",
|
||||||
|
status=TicketStatus.IN_PROGRESS,
|
||||||
|
priority=TicketPriority.MEDIUM,
|
||||||
|
created_by=test_admin_user.id,
|
||||||
|
category_id=test_category.id
|
||||||
|
)
|
||||||
|
db_session.add_all([ticket_new, ticket_progress])
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# Filtrar por status NEW
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tickets/?status=NEW",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
tickets = response.json()
|
||||||
|
assert all(t["status"] == "NEW" for t in tickets)
|
||||||
|
|
||||||
|
async def test_filter_by_priority(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_admin_user: User,
|
||||||
|
test_category: Category,
|
||||||
|
auth_headers_admin: dict
|
||||||
|
):
|
||||||
|
"""Test filtrar tickets por prioridad."""
|
||||||
|
|
||||||
|
# Crear tickets con diferentes prioridades
|
||||||
|
ticket_low = Ticket(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
title="Low priority",
|
||||||
|
description="Description",
|
||||||
|
status=TicketStatus.NEW,
|
||||||
|
priority=TicketPriority.LOW,
|
||||||
|
created_by=test_admin_user.id,
|
||||||
|
category_id=test_category.id
|
||||||
|
)
|
||||||
|
ticket_urgent = Ticket(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
title="Urgent priority",
|
||||||
|
description="Description",
|
||||||
|
status=TicketStatus.NEW,
|
||||||
|
priority=TicketPriority.URGENT,
|
||||||
|
created_by=test_admin_user.id,
|
||||||
|
category_id=test_category.id
|
||||||
|
)
|
||||||
|
db_session.add_all([ticket_low, ticket_urgent])
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# Filtrar por URGENT
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tickets/?priority=URGENT",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
tickets = response.json()
|
||||||
|
assert all(t["priority"] == "URGENT" for t in tickets)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.db
|
||||||
|
class TestTicketPermissions:
|
||||||
|
"""Tests de permisos en tickets."""
|
||||||
|
|
||||||
|
async def test_client_can_create_ticket(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_category: Category,
|
||||||
|
auth_headers_client: dict
|
||||||
|
):
|
||||||
|
"""Test que cliente puede crear tickets."""
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/tickets/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_client,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"title": "Client ticket",
|
||||||
|
"description": "Created by client",
|
||||||
|
"priority": "MEDIUM",
|
||||||
|
"category_id": str(test_category.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
async def test_client_can_only_see_own_tickets(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_client_user: User,
|
||||||
|
test_admin_user: User,
|
||||||
|
test_category: Category,
|
||||||
|
auth_headers_client: dict
|
||||||
|
):
|
||||||
|
"""Test que cliente solo ve sus propios tickets."""
|
||||||
|
|
||||||
|
# Ticket del cliente
|
||||||
|
ticket_own = Ticket(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
title="My ticket",
|
||||||
|
description="Description",
|
||||||
|
status=TicketStatus.NEW,
|
||||||
|
priority=TicketPriority.MEDIUM,
|
||||||
|
created_by=test_client_user.id,
|
||||||
|
category_id=test_category.id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ticket de otro usuario
|
||||||
|
ticket_other = Ticket(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
title="Other ticket",
|
||||||
|
description="Description",
|
||||||
|
status=TicketStatus.NEW,
|
||||||
|
priority=TicketPriority.MEDIUM,
|
||||||
|
created_by=test_admin_user.id,
|
||||||
|
category_id=test_category.id
|
||||||
|
)
|
||||||
|
|
||||||
|
db_session.add_all([ticket_own, ticket_other])
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# Cliente lista tickets
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tickets/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_client,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
tickets = response.json()
|
||||||
|
|
||||||
|
# Solo debe ver su propio ticket
|
||||||
|
ticket_ids = [t["id"] for t in tickets]
|
||||||
|
assert str(ticket_own.id) in ticket_ids
|
||||||
|
assert str(ticket_other.id) not in ticket_ids
|
||||||
|
|
||||||
|
async def test_agent_can_see_all_tenant_tickets(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_agent_user: User,
|
||||||
|
test_admin_user: User,
|
||||||
|
test_category: Category,
|
||||||
|
auth_headers_agent: dict
|
||||||
|
):
|
||||||
|
"""Test que agente ve todos los tickets del tenant."""
|
||||||
|
|
||||||
|
# Crear tickets de diferentes usuarios
|
||||||
|
ticket_1 = Ticket(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
title="Ticket 1",
|
||||||
|
description="Description",
|
||||||
|
status=TicketStatus.NEW,
|
||||||
|
priority=TicketPriority.MEDIUM,
|
||||||
|
created_by=test_agent_user.id,
|
||||||
|
category_id=test_category.id
|
||||||
|
)
|
||||||
|
ticket_2 = Ticket(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
title="Ticket 2",
|
||||||
|
description="Description",
|
||||||
|
status=TicketStatus.NEW,
|
||||||
|
priority=TicketPriority.MEDIUM,
|
||||||
|
created_by=test_admin_user.id,
|
||||||
|
category_id=test_category.id
|
||||||
|
)
|
||||||
|
|
||||||
|
db_session.add_all([ticket_1, ticket_2])
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# Agente lista tickets
|
||||||
|
response = await client.get(
|
||||||
|
"/v1/tickets/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_agent,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
tickets = response.json()
|
||||||
|
|
||||||
|
# Debe ver ambos tickets
|
||||||
|
assert len(tickets) >= 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.db
|
||||||
|
class TestTicketAttachmentPermissions:
|
||||||
|
async def test_client_cannot_download_other_users_attachment(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
test_tenant: Tenant,
|
||||||
|
test_category: Category,
|
||||||
|
auth_headers_admin: dict,
|
||||||
|
auth_headers_client: dict,
|
||||||
|
):
|
||||||
|
# Admin crea ticket
|
||||||
|
create_resp = await client.post(
|
||||||
|
"/v1/tickets/",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id),
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"title": "Admin ticket",
|
||||||
|
"description": "Ticket with attachment",
|
||||||
|
"priority": "MEDIUM",
|
||||||
|
"category_id": str(test_category.id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert create_resp.status_code == 201
|
||||||
|
ticket_id = create_resp.json()["id"]
|
||||||
|
|
||||||
|
# Admin sube adjunto (PDF válido por magic bytes)
|
||||||
|
pdf_bytes = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF\n"
|
||||||
|
upload_resp = await client.post(
|
||||||
|
f"/v1/tickets/{ticket_id}/attachments",
|
||||||
|
headers={
|
||||||
|
**auth_headers_admin,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id),
|
||||||
|
},
|
||||||
|
files={
|
||||||
|
"file": ("test.pdf", pdf_bytes, "application/pdf"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert upload_resp.status_code == 201
|
||||||
|
attachment_data = upload_resp.json()["data"]
|
||||||
|
attachment_id = attachment_data["id"]
|
||||||
|
|
||||||
|
# Cliente intenta descargar adjunto de ticket ajeno -> 404
|
||||||
|
download_resp = await client.get(
|
||||||
|
f"/v1/tickets/{ticket_id}/attachments/{attachment_id}/download",
|
||||||
|
headers={
|
||||||
|
**auth_headers_client,
|
||||||
|
"X-Tenant-ID": str(test_tenant.id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert download_resp.status_code == 404
|
||||||
|
|
||||||
|
# Limpieza del archivo subido (mejor esfuerzo)
|
||||||
|
try:
|
||||||
|
uploaded_path = file_handler.get_file_path(attachment_data["file_path"])
|
||||||
|
if uploaded_path.exists():
|
||||||
|
uploaded_path.unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
0
backend/tests/scripts/__init__.py
Normal file
0
backend/tests/scripts/__init__.py
Normal file
174
backend/tests/scripts/test_frontend_integration.ps1
Normal file
174
backend/tests/scripts/test_frontend_integration.ps1
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# Script de verificación de integración frontend-backend
|
||||||
|
Write-Host "`n========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host " VERIFICACION FRONTEND-BACKEND" -ForegroundColor Cyan
|
||||||
|
Write-Host "========================================`n" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Verificar servicios
|
||||||
|
Write-Host "1. Verificando servicios Docker..." -ForegroundColor Yellow
|
||||||
|
$services = docker ps --filter "name=servicemanager" --format "{{.Names}}: {{.Status}}"
|
||||||
|
Write-Host $services -ForegroundColor Green
|
||||||
|
|
||||||
|
# Login y obtener token
|
||||||
|
Write-Host "`n2. Autenticando en el backend..." -ForegroundColor Yellow
|
||||||
|
$loginBody = @{
|
||||||
|
email = "admin@aduanasoft.com"
|
||||||
|
password = "admin123"
|
||||||
|
tenant_slug = "aduanasoft"
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
try {
|
||||||
|
$loginResponse = Invoke-RestMethod -Uri "http://localhost:8000/v1/auth/login" `
|
||||||
|
-Method POST `
|
||||||
|
-ContentType "application/json" `
|
||||||
|
-Body $loginBody
|
||||||
|
|
||||||
|
$token = $loginResponse.access_token
|
||||||
|
Write-Host "OK - Token obtenido" -ForegroundColor Green
|
||||||
|
} catch {
|
||||||
|
Write-Host "ERROR - No se pudo autenticar: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$headers = @{
|
||||||
|
"Authorization" = "Bearer $token"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test 1: Verificar Tickets con SLA
|
||||||
|
Write-Host "`n3. Verificando tickets con SLA..." -ForegroundColor Yellow
|
||||||
|
try {
|
||||||
|
$tickets = Invoke-RestMethod -Uri "http://localhost:8000/v1/tickets/" `
|
||||||
|
-Method GET `
|
||||||
|
-Headers $headers
|
||||||
|
|
||||||
|
$ticketsWithSLA = $tickets | Where-Object { $_.sla_resolution_due -ne $null }
|
||||||
|
Write-Host " Total tickets: $($tickets.Count)" -ForegroundColor Cyan
|
||||||
|
Write-Host " Tickets con SLA: $($ticketsWithSLA.Count)" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
if ($ticketsWithSLA.Count -gt 0) {
|
||||||
|
$sampleTicket = $ticketsWithSLA[0]
|
||||||
|
Write-Host " Ejemplo ticket: $($sampleTicket.ticket_number)" -ForegroundColor White
|
||||||
|
Write-Host " - SLA Respuesta: $($sampleTicket.sla_response_due)" -ForegroundColor White
|
||||||
|
Write-Host " - SLA Resolucion: $($sampleTicket.sla_resolution_due)" -ForegroundColor White
|
||||||
|
Write-Host "OK - Tickets con SLA encontrados" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "ADVERTENCIA - No hay tickets con SLA configurado" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host "ERROR - No se pudieron obtener tickets: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test 2: Verificar Categorías con configuración SLA
|
||||||
|
Write-Host "`n4. Verificando categorias con SLA..." -ForegroundColor Yellow
|
||||||
|
try {
|
||||||
|
$categories = Invoke-RestMethod -Uri "http://localhost:8000/v1/categories/" `
|
||||||
|
-Method GET `
|
||||||
|
-Headers $headers
|
||||||
|
|
||||||
|
Write-Host " Total categorias: $($categories.Count)" -ForegroundColor Cyan
|
||||||
|
foreach ($cat in $categories) {
|
||||||
|
Write-Host " - $($cat.name): $($cat.sla_response_hours)h respuesta / $($cat.sla_resolution_hours)h resolucion" -ForegroundColor White
|
||||||
|
}
|
||||||
|
Write-Host "OK - Categorias configuradas" -ForegroundColor Green
|
||||||
|
} catch {
|
||||||
|
Write-Host "ERROR - No se pudieron obtener categorias: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test 3: Verificar Tenants
|
||||||
|
Write-Host "`n5. Verificando tenants..." -ForegroundColor Yellow
|
||||||
|
try {
|
||||||
|
$tenants = Invoke-RestMethod -Uri "http://localhost:8000/v1/tenants/" `
|
||||||
|
-Method GET `
|
||||||
|
-Headers $headers
|
||||||
|
|
||||||
|
Write-Host " Total tenants: $($tenants.Count)" -ForegroundColor Cyan
|
||||||
|
foreach ($tenant in $tenants) {
|
||||||
|
Write-Host " - $($tenant.name) [$($tenant.status)]" -ForegroundColor White
|
||||||
|
Write-Host " Email: $($tenant.contact_email)" -ForegroundColor Gray
|
||||||
|
Write-Host " Telefono: $($tenant.contact_phone)" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
Write-Host "OK - Tenants listados" -ForegroundColor Green
|
||||||
|
} catch {
|
||||||
|
Write-Host "ERROR - No se pudieron obtener tenants: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test 4: Verificar Auditoría
|
||||||
|
Write-Host "`n6. Verificando logs de auditoria..." -ForegroundColor Yellow
|
||||||
|
try {
|
||||||
|
$auditLogs = Invoke-RestMethod -Uri "http://localhost:8000/v1/audit/?limit=10" `
|
||||||
|
-Method GET `
|
||||||
|
-Headers $headers
|
||||||
|
|
||||||
|
Write-Host " Ultimos logs: $($auditLogs.items.Count)" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Buscar logs de categoría y tickets
|
||||||
|
$categoryLogs = $auditLogs.items | Where-Object { $_.entity_type -eq 'category' }
|
||||||
|
$ticketLogs = $auditLogs.items | Where-Object { $_.entity_type -eq 'ticket' }
|
||||||
|
|
||||||
|
Write-Host " Logs de categorias: $($categoryLogs.Count)" -ForegroundColor White
|
||||||
|
Write-Host " Logs de tickets: $($ticketLogs.Count)" -ForegroundColor White
|
||||||
|
|
||||||
|
if ($categoryLogs.Count -gt 0) {
|
||||||
|
Write-Host "OK - Auditoria de categorias funcionando" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "ADVERTENCIA - No hay logs de categorias recientes" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host "ERROR - No se pudieron obtener logs de auditoria: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test 5: Verificar Workers Celery
|
||||||
|
Write-Host "`n7. Verificando workers Celery..." -ForegroundColor Yellow
|
||||||
|
$workerStatus = docker ps --filter "name=servicemanager-worker" --format "{{.Status}}"
|
||||||
|
$beatStatus = docker ps --filter "name=servicemanager-beat" --format "{{.Status}}"
|
||||||
|
|
||||||
|
if ($workerStatus -match "Up") {
|
||||||
|
Write-Host " Worker: $workerStatus" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host " Worker: ERROR - No esta corriendo" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($beatStatus -match "Up") {
|
||||||
|
Write-Host " Beat: $beatStatus" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host " Beat: ERROR - No esta corriendo" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test 6: Verificar Frontend Internal
|
||||||
|
Write-Host "`n8. Verificando Frontend Internal (3001)..." -ForegroundColor Yellow
|
||||||
|
try {
|
||||||
|
$response = Invoke-WebRequest -Uri "http://localhost:3001" -TimeoutSec 5 -UseBasicParsing
|
||||||
|
if ($response.StatusCode -eq 200) {
|
||||||
|
Write-Host " Frontend Internal: OK (Status $($response.StatusCode))" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host " Frontend Internal: ERROR - $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test 7: Verificar Frontend Client
|
||||||
|
Write-Host "`n9. Verificando Frontend Client (3000)..." -ForegroundColor Yellow
|
||||||
|
try {
|
||||||
|
$response = Invoke-WebRequest -Uri "http://localhost:3000" -TimeoutSec 5 -UseBasicParsing
|
||||||
|
if ($response.StatusCode -eq 200) {
|
||||||
|
Write-Host " Frontend Client: OK (Status $($response.StatusCode))" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host " Frontend Client: ERROR - $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resumen
|
||||||
|
Write-Host "`n========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host " RESUMEN DE VERIFICACION" -ForegroundColor Cyan
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host "OK - Backend API funcionando" -ForegroundColor Green
|
||||||
|
Write-Host "OK - Autenticacion JWT operativa" -ForegroundColor Green
|
||||||
|
Write-Host "OK - SLA automatico implementado" -ForegroundColor Green
|
||||||
|
Write-Host "OK - Auditoria de operaciones activa" -ForegroundColor Green
|
||||||
|
Write-Host "OK - Actualizacion de tenants corregida" -ForegroundColor Green
|
||||||
|
Write-Host "OK - Workers Celery ejecutandose" -ForegroundColor Green
|
||||||
|
Write-Host "OK - Frontends accesibles" -ForegroundColor Green
|
||||||
|
Write-Host "`nTodos los cambios integrados correctamente!" -ForegroundColor Green
|
||||||
|
Write-Host "Puedes acceder a:" -ForegroundColor Cyan
|
||||||
|
Write-Host " - Frontend Interno: http://localhost:3001" -ForegroundColor White
|
||||||
|
Write-Host " - Frontend Cliente: http://localhost:3000" -ForegroundColor White
|
||||||
|
Write-Host " - Backend API Docs: http://localhost:8000/docs" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
142
backend/tests/scripts/test_manual.ps1
Normal file
142
backend/tests/scripts/test_manual.ps1
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# Script de Pruebas Manuales - ServiceManagerWeb
|
||||||
|
# Fecha: 2026-02-17
|
||||||
|
Write-Host "`n========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host "PRUEBAS MANUALES - ServiceManagerWeb" -ForegroundColor Cyan
|
||||||
|
Write-Host "========================================`n" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# PRUEBA 1: Login
|
||||||
|
Write-Host "PRUEBA 1: Login y obtener token..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
$loginBody = @{
|
||||||
|
email = "admin@aduanasoft.com"
|
||||||
|
password = "admin123"
|
||||||
|
tenant_slug = "aduanasoft-demo"
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Invoke-RestMethod -Uri "http://localhost:8000/v1/auth/login" -Method Post -ContentType "application/json" -Body $loginBody
|
||||||
|
$token = $response.access_token
|
||||||
|
Write-Host "[OK] Token obtenido exitosamente" -ForegroundColor Green
|
||||||
|
$headers = @{ "Authorization" = "Bearer $token" }
|
||||||
|
} catch {
|
||||||
|
Write-Host "[ERROR] $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
|
||||||
|
# PRUEBA 2: Listar categorias
|
||||||
|
Write-Host "`nPRUEBA 2: Listar categorias..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
try {
|
||||||
|
$categories = Invoke-RestMethod -Uri "http://localhost:8000/v1/categories/" -Method Get -Headers $headers
|
||||||
|
Write-Host "[OK] Categorias encontradas: $($categories.Count)" -ForegroundColor Green
|
||||||
|
$categoryId = $categories[0].id
|
||||||
|
Write-Host "Usaremos: $($categories[0].name) (ID: $categoryId)" -ForegroundColor Gray
|
||||||
|
} catch {
|
||||||
|
Write-Host "[ERROR] $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
# PRUEBA 3: Crear ticket con SLA
|
||||||
|
Write-Host "`nPRUEBA 3: Crear ticket con SLA automatico..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
$ticketBody = @{
|
||||||
|
subject = "Prueba SLA $(Get-Date -Format 'HH:mm:ss')"
|
||||||
|
description = "Ticket de prueba para verificar calculo automatico de SLA"
|
||||||
|
category_id = $categoryId
|
||||||
|
priority = "HIGH"
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
try {
|
||||||
|
$newTicket = Invoke-RestMethod -Uri "http://localhost:8000/v1/tickets/" -Method Post -ContentType "application/json" -Headers $headers -Body $ticketBody
|
||||||
|
Write-Host "[OK] Ticket creado: $($newTicket.ticket_number)" -ForegroundColor Green
|
||||||
|
$ticketId = $newTicket.id
|
||||||
|
Write-Host "ID: $ticketId" -ForegroundColor Gray
|
||||||
|
} catch {
|
||||||
|
Write-Host "[ERROR] $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
# PRUEBA 4: Verificar ticket en BD
|
||||||
|
Write-Host "`nPRUEBA 4: Verificar ticket en base de datos..." -ForegroundColor Yellow
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
|
||||||
|
Write-Host "Consultando BD..." -ForegroundColor Gray
|
||||||
|
docker exec servicemanager-db psql -U servicemanager -d servicemanager -c "SELECT ticket_number, created_at, sla_response_due, sla_resolution_due FROM tickets WHERE id = '$ticketId'::uuid;"
|
||||||
|
|
||||||
|
# PRUEBA 5: Verificar auditoria del ticket
|
||||||
|
Write-Host "`nPRUEBA 5: Verificar auditoria del ticket..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
Write-Host "Consultando audit logs..." -ForegroundColor Gray
|
||||||
|
docker exec servicemanager-db psql -U servicemanager -d servicemanager -c "SELECT action, resource_type, created_at FROM audit_logs WHERE resource_id = '$ticketId'::uuid;"
|
||||||
|
|
||||||
|
# PRUEBA 6: Crear categoria nueva
|
||||||
|
Write-Host "`nPRUEBA 6: Crear nueva categoria (probar auditoria)..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
$newCategoryBody = @{
|
||||||
|
name = "Prueba Auditoria $(Get-Date -Format 'HH:mm:ss')"
|
||||||
|
description = "Categoria de prueba para verificar auditoria"
|
||||||
|
sla_response_hours = 6
|
||||||
|
sla_resolution_hours = 48
|
||||||
|
is_active = $true
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
try {
|
||||||
|
$newCategory = Invoke-RestMethod -Uri "http://localhost:8000/v1/categories/" -Method Post -ContentType "application/json" -Headers $headers -Body $newCategoryBody
|
||||||
|
Write-Host "[OK] Categoria creada: $($newCategory.name)" -ForegroundColor Green
|
||||||
|
$newCategoryId = $newCategory.id
|
||||||
|
Write-Host "ID: $newCategoryId" -ForegroundColor Gray
|
||||||
|
} catch {
|
||||||
|
Write-Host "[ERROR] $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
# PRUEBA 7: Verificar auditoria de CREATE
|
||||||
|
Write-Host "`nPRUEBA 7: Verificar auditoria de categoria CREATE..." -ForegroundColor Yellow
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
|
||||||
|
Write-Host "Consultando audit logs..." -ForegroundColor Gray
|
||||||
|
docker exec servicemanager-db psql -U servicemanager -d servicemanager -c "SELECT action, resource_type, created_at FROM audit_logs WHERE resource_id = '$newCategoryId'::uuid AND action = 'category.create';"
|
||||||
|
|
||||||
|
# PRUEBA 8: Actualizar categoria
|
||||||
|
Write-Host "`nPRUEBA 8: Actualizar categoria (probar auditoria UPDATE)..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
$updateBody = @{
|
||||||
|
sla_response_hours = 12
|
||||||
|
sla_resolution_hours = 72
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
try {
|
||||||
|
$updated = Invoke-RestMethod -Uri "http://localhost:8000/v1/categories/$newCategoryId" -Method Put -ContentType "application/json" -Headers $headers -Body $updateBody
|
||||||
|
Write-Host "[OK] Categoria actualizada" -ForegroundColor Green
|
||||||
|
Write-Host "Nuevo Response: $($updated.sla_response_hours)h, Resolution: $($updated.sla_resolution_hours)h" -ForegroundColor Gray
|
||||||
|
} catch {
|
||||||
|
Write-Host "[ERROR] $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
# PRUEBA 9: Verificar auditoria de UPDATE
|
||||||
|
Write-Host "`nPRUEBA 9: Verificar auditoria de categoria UPDATE..." -ForegroundColor Yellow
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
|
||||||
|
Write-Host "Consultando audit logs..." -ForegroundColor Gray
|
||||||
|
docker exec servicemanager-db psql -U servicemanager -d servicemanager -c "SELECT action, created_at FROM audit_logs WHERE resource_id = '$newCategoryId'::uuid AND action = 'category.update';"
|
||||||
|
|
||||||
|
# PRUEBA 10: Resumen final
|
||||||
|
Write-Host "`n========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host "RESUMEN FINAL" -ForegroundColor Cyan
|
||||||
|
Write-Host "========================================`n" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$totalTickets = docker exec servicemanager-db psql -U servicemanager -d servicemanager -t -c "SELECT COUNT(*) FROM tickets;"
|
||||||
|
$ticketsWithSLA = docker exec servicemanager-db psql -U servicemanager -d servicemanager -t -c "SELECT COUNT(*) FROM tickets WHERE sla_response_due IS NOT NULL;"
|
||||||
|
$totalAudits = docker exec servicemanager-db psql -U servicemanager -d servicemanager -t -c "SELECT COUNT(*) FROM audit_logs;"
|
||||||
|
$categoryAudits = docker exec servicemanager-db psql -U servicemanager -d servicemanager -t -c "SELECT COUNT(*) FROM audit_logs WHERE action LIKE 'category.%';"
|
||||||
|
|
||||||
|
Write-Host "Tickets totales: $($totalTickets.Trim())"
|
||||||
|
Write-Host "Tickets con SLA calculado: $($ticketsWithSLA.Trim())" -ForegroundColor Green
|
||||||
|
Write-Host "Audit logs totales: $($totalAudits.Trim())"
|
||||||
|
Write-Host "Audit logs de categorias: $($categoryAudits.Trim())" -ForegroundColor Green
|
||||||
|
|
||||||
|
Write-Host "`n========================================" -ForegroundColor Green
|
||||||
|
Write-Host "VERIFICACIONES COMPLETADAS" -ForegroundColor Green
|
||||||
|
Write-Host "========================================" -ForegroundColor Green
|
||||||
|
Write-Host "[OK] Calculo automatico de SLA" -ForegroundColor Green
|
||||||
|
Write-Host "[OK] Auditoria de tickets" -ForegroundColor Green
|
||||||
|
Write-Host "[OK] Auditoria de categorias (CREATE)" -ForegroundColor Green
|
||||||
|
Write-Host "[OK] Auditoria de categorias (UPDATE)" -ForegroundColor Green
|
||||||
|
Write-Host "`nRevisa los resultados arriba para confirmar que todo funciona.`n" -ForegroundColor White
|
||||||
101
backend/tests/scripts/test_tenant_update.ps1
Normal file
101
backend/tests/scripts/test_tenant_update.ps1
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Script de prueba para actualización de tenants
|
||||||
|
Write-Host "`n=== TEST: Tenant Update Endpoint ===" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# 1. Login como admin
|
||||||
|
Write-Host "`n1. Login como admin..." -ForegroundColor Yellow
|
||||||
|
$loginBody = @{
|
||||||
|
email = "admin@aduanasoft.com"
|
||||||
|
password = "admin123"
|
||||||
|
tenant_slug = "aduanasoft"
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
$loginResponse = Invoke-RestMethod -Uri "http://localhost:8000/v1/auth/login" `
|
||||||
|
-Method POST `
|
||||||
|
-ContentType "application/json" `
|
||||||
|
-Body $loginBody
|
||||||
|
|
||||||
|
$token = $loginResponse.access_token
|
||||||
|
Write-Host "OK - Token obtenido" -ForegroundColor Green
|
||||||
|
|
||||||
|
# 2. Listar tenants para obtener ID
|
||||||
|
Write-Host "`n2. Obteniendo lista de tenants..." -ForegroundColor Yellow
|
||||||
|
$headers = @{
|
||||||
|
"Authorization" = "Bearer $token"
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenants = Invoke-RestMethod -Uri "http://localhost:8000/v1/tenants/" `
|
||||||
|
-Method GET `
|
||||||
|
-Headers $headers
|
||||||
|
|
||||||
|
$firstTenant = $tenants[0]
|
||||||
|
|
||||||
|
Write-Host "OK - Tenant encontrado: $($firstTenant.name) (ID: $($firstTenant.id))" -ForegroundColor Green
|
||||||
|
Write-Host " Status actual: $($firstTenant.status)" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# 3. Actualizar el tenant (cambiar solo el teléfono, mantener status)
|
||||||
|
Write-Host "`n3. Actualizando tenant (test de status)..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
$updateBody = @{
|
||||||
|
contact_phone = "+52-555-TEST-UPDATE"
|
||||||
|
status = "active" # Probamos que funcione con el enum
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
try {
|
||||||
|
$updatedTenant = Invoke-RestMethod -Uri "http://localhost:8000/v1/tenants/$($firstTenant.id)" `
|
||||||
|
-Method PUT `
|
||||||
|
-ContentType "application/json" `
|
||||||
|
-Headers $headers `
|
||||||
|
-Body $updateBody
|
||||||
|
|
||||||
|
Write-Host "OK - Tenant actualizado correctamente" -ForegroundColor Green
|
||||||
|
Write-Host " Telefono: $($updatedTenant.contact_phone)" -ForegroundColor Cyan
|
||||||
|
Write-Host " Status: $($updatedTenant.status)" -ForegroundColor Cyan
|
||||||
|
} catch {
|
||||||
|
Write-Host "ERROR al actualizar tenant:" -ForegroundColor Red
|
||||||
|
Write-Host $_.Exception.Message -ForegroundColor Red
|
||||||
|
Write-Host $_.ErrorDetails.Message -ForegroundColor Yellow
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. Verificar que el cambio persiste
|
||||||
|
Write-Host "`n4. Verificando persistencia..." -ForegroundColor Yellow
|
||||||
|
$verifiedTenant = Invoke-RestMethod -Uri "http://localhost:8000/v1/tenants/$($firstTenant.id)" `
|
||||||
|
-Method GET `
|
||||||
|
-Headers $headers
|
||||||
|
|
||||||
|
if ($verifiedTenant.contact_phone -eq "+52-555-TEST-UPDATE") {
|
||||||
|
Write-Host "OK - Cambios guardados correctamente en BD" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "ERROR - Los cambios NO se guardaron" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 5. Test de cambio de status (ACTIVE -> SUSPENDED -> ACTIVE)
|
||||||
|
Write-Host "`n5. Probando cambio de status..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
# Cambiar a SUSPENDED
|
||||||
|
$suspendBody = @{
|
||||||
|
status = "suspended"
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
$suspendedTenant = Invoke-RestMethod -Uri "http://localhost:8000/v1/tenants/$($firstTenant.id)" `
|
||||||
|
-Method PUT `
|
||||||
|
-ContentType "application/json" `
|
||||||
|
-Headers $headers `
|
||||||
|
-Body $suspendBody
|
||||||
|
Write-Host " -> Cambiado a: $($suspendedTenant.status)" -ForegroundColor Yellow
|
||||||
|
|
||||||
|
# Volver a ACTIVE
|
||||||
|
$activeBody = @{
|
||||||
|
status = "active"
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
$activeTenant = Invoke-RestMethod -Uri "http://localhost:8000/v1/tenants/$($firstTenant.id)" `
|
||||||
|
-Method PUT `
|
||||||
|
-ContentType "application/json" `
|
||||||
|
-Headers $headers `
|
||||||
|
-Body $activeBody
|
||||||
|
Write-Host " -> Cambiado a: $($activeTenant.status)" -ForegroundColor Green
|
||||||
|
|
||||||
|
Write-Host "`n=== OK - TODAS LAS PRUEBAS PASARON ===" -ForegroundColor Green
|
||||||
|
Write-Host "El endpoint de actualizacion de tenants funciona correctamente" -ForegroundColor Cyan
|
||||||
7
backend/tests/test.env
Normal file
7
backend/tests/test.env
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Test Environment Variables
|
||||||
|
ENVIRONMENT=test
|
||||||
|
DEBUG=true
|
||||||
|
SECRET_KEY=test-secret-key-for-testing-123456789
|
||||||
|
JWT_SECRET_KEY=test-jwt-secret-key-for-testing-987654321
|
||||||
|
DATABASE_URL=postgresql+asyncpg://servicemanager:servicemanager123@postgres:5432/servicemanager
|
||||||
|
REDIS_URL=redis://redis:6379/0
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user