Compare commits

...

52 Commits

Author SHA1 Message Date
cd3d7e816f Recuperar implementado 2026-02-27 10:08:30 -07:00
63925fe305 Login resuelto 2026-02-27 09:16:08 -07:00
c146a6c3c3 funcion dashboard y reporte de endpoints v1.16.0 2026-02-26 12:48:28 -07:00
ba779bde55 docs: guardar README original como README.legacy.md 2026-02-26 09:13:29 -07:00
ba94152074 docs: README completo para Windows/Linux/macOS + fix conflicto de puertos
- README.md reescrito con instrucciones detalladas para principiantes y expertos
  * Tabla de contenidos con 14 secciones
  * Inicio rápido con Docker (Windows, Linux, macOS)
  * Configuración de variables de entorno con explicaciones
  * Sección de desarrollo local sin Docker
  * Comandos útiles (Docker, Alembic, calidad de código, testing)
  * Solución de problemas extensa (puertos, módulos, tenant, migraciones, Node.js)
  * Historial de versiones

- Fix: conflicto de puertos cuando ambos frontends corren en local
  * frontend-internal/vite.config.js: usa PORT=3001 por defecto (3000 en Docker)
  * frontend-internal/package.json: dev script sin puerto hardcodeado
  * docker-compose.yml: frontend-internal recibe PORT=3000 como variable de env
2026-02-26 09:02:04 -07:00
bd21207aae v1.15.1 - modulo de reportes implementado
- Nuevo módulo de reportes: backend/app/api/v1/endpoints/reports.py
- Schemas de reportes: backend/app/api/schemas/reports.py
- Frontend: frontend-internal/src/routes/reports/
- Mejoras al módulo de auditoría (audit.py, audit_helpers.py)
- Modelo de auditoría actualizado
- Sidebar actualizado con enlace a reportes
2026-02-26 08:51:46 -07:00
1ccc39732b feat: Funcion de sistema tenants 2026-02-23 13:01:24 -07:00
ceea67eb2b feat: Version 1.11.0 - Mejoras en auditoría, SLA, frontend y correcciones de sincronización
- Refactorización de endpoints de auditoría y helpers
- Mejoras en esquemas de auditoría (audit.py)
- Correcciones en endpoint SLA
- Actualizaciones en múltiples rutas del frontend interno:
  layout, tickets, usuarios, tenants, categorías, sistemas,
  SLA (at-risk, violations), auditoría (main + security), login, perfil
- Actualización de tailwind.config.js
- Eliminación de docs de versiones anteriores (CAMBIOS_v1.10.0, v1.8.0, OPTIMIZACIONES)
- Nuevos scripts de prueba: generate_security_test_data.py, generate_sla_test_data.py
- Script de prueba de sincronización crítica (test_critical_sync.ps1)
- README actualizado en scripts/
2026-02-20 10:53:53 -07:00
517297e89a feat: Version 1.10.0 - Refactorizacion, optimizacion UI y mejoras de seguridad
- Extraccion de helpers en backend: audit_helpers.py, helpers.py
- Modularizacion de schemas en archivos individuales por dominio
- Reduccion de audit.py en 953 lineas (74% del archivo)
- Reduccion de tickets.py en 655 lineas (60% del archivo)
- Expansion de auth.py con recuperacion de contrasenia y tokens
- Nuevos modulos: core/email.py, core/cache.py
- Reorganizacion de scripts a backend/scripts/
- Frontend: refactorizacion de audit page con array-driven components
- Frontend: correccion de 11 errores ortograficos en tickets page
- Frontend: proxy Docker corregido en vite.config.js
- Frontend: nuevas rutas forgot-password, reset-password, organization, profile
- Nuevas utilidades TS: colorUtils.ts, dateFormats.ts
- 5 nuevos archivos de tests unitarios en backend/tests/unit/
- Eliminacion de 3 scripts temporales de prueba
- Documentacion tecnica: CAMBIOS_v1.10.0.md, OPTIMIZACIONES_RENDIMIENTO.md
2026-02-19 13:48:21 -07:00
Ernesto Herrera
16d795e8bd feat: Implementar suite completa de tests de integración v1.9.0
- Agregar 46 tests de integración (auth, multi-tenancy, tickets)
- Crear estructura organizada tests/integration/ y tests/unit/
- Implementar fixtures completas para testing con BD separada
- Agregar conftest_integration.py con setup async
- Mover scripts PowerShell de testing a tests/scripts/
- Actualizar pytest.ini con markers y configuración
- Crear run_tests.sh script ejecutable para testing
- Documentación completa en README_TESTS.md
- Fix: Remover opciones obsoletas de TypeScript (importsNotUsedAsValues)

Tests implementados:
- Authentication: 15 tests (login, refresh, permisos, seguridad)
- Multi-tenancy: 13 tests (aislamiento, validaciones, seguridad B2B)
- Tickets: 18 tests (CRUD, filtros, permisos por rol)
- Unit: 10 tests básicos
- Verificación: 8 tests de setup

Base de datos de testing: servicemanager_test (separada de producción)
Cobertura estimada: ~40% (desde 5%)

Próximos pasos: Agregar tests de SLA, attachments, auditoría
2026-02-18 13:08:32 -07:00
f80a57a697 docs: Agregar documentación técnica completa v1.8.0
- Reporte detallado de 54 páginas con todos los cambios
- Análisis técnico de modificaciones backend/frontend
- Ejemplos de código antes/después
- Métricas de rendimiento y mejoras
- Guía de despliegue y rollback
- Lecciones aprendidas y best practices
- Roadmap para v1.9.0
2026-02-17 12:47:48 -07:00
e6440395ea v1.8.0: Sistema funcional con filtros optimizados y UI mejorada
Mejoras en Módulo de Tickets:
- Implementado sistema de filtros funcional por estado y prioridad
- Tabla compacta estilo auditoría (50% más espacio visible)
- Backend actualizado: parámetros 'status' y 'priority' con validación
- Interfaz más limpia con labels reducidos y 2 columnas de filtros
- Eliminación de columna SLA duplicada en tabla

Correcciones Backend:
- Endpoint /v1/tickets/: filtros 'status' y 'priority' funcionan correctamente
- Endpoint /v1/sla/violations: timezone UTC y eager loading con selectinload
- Endpoint /v1/client-profile/: generación explícita de UUID
- Migración fix_client_profiles_timestamps aplicada

Mejoras UI Frontend:
- Tabla tickets: encabezados uppercase text-xs, celdas px-3 py-2
- Toggle de estado activo/inactivo en gestión de tenants (tabla + modal)
- Badges más compactos con rounded-full
- Botones de acciones con separador visual y transiciones
- Filtros con URLSearchParams para construcción correcta de queries

Arquitectura:
- SQLAlchemy: eager loading para evitar N+1 queries
- Timezone handling: datetime.now(timezone.utc) para comparaciones
- Svelte reactivity: keyed loops y spread operator para forzar updates
- API client: endpoint con query string completo

Estado del sistema: Totalmente funcional para producción MVP
2026-02-17 12:43:06 -07:00
cc1e964c3a Release v1.7.1 - Mejoras en SLA, Auditoria y Multi-tenant
 Características Nuevas:
- Cálculo automático de SLA en tickets basado en categoría
- Auto-asignación de tickets según configuración de categoría
- Auditoría completa en operaciones de categorías (create/update/delete)
- Visualización de estado SLA en listado y detalle de tickets

🐛 Correcciones:
- Fix actualización de status en tenants (manejo correcto de enum TenantStatus)
- Corrección de campos contact_phone y contact_email en tenants
- Corrección de modelo TicketResponse (agregar campos SLA y usar ConfigDict)
- Eliminación de archivo changelog duplicado

🔧 Mejoras de Infraestructura:
- Agregar montaje de backend en workers y beat para imports correctos
- Mejorar path handling en sla_tasks.py para Docker
- Scripts de testing integrados (test_frontend_integration, test_manual, test_tenant_update)
- Agregar database.py en workers/app/core para sesiones async

📝 Frontend:
- Actualizar UI de tenants con nuevos campos (email, teléfono, status enum)
- Agregar columna de SLA en listado de tickets
- Mostrar información detallada de SLA en vista de ticket individual
- Indicadores visuales de estado de SLA (vencido, cumplido, en plazo)
2026-02-17 10:26:56 -07:00
75726d915f v1.7.0 - Fix: Corregido error 500 en SLA Dashboard
- Fix error de sintaxis SQL en cálculo de tickets 'at risk'
- Fix error de timezone (offset-naive vs offset-aware datetimes)
- Implementado sistema completo de SLA Management
- Agregados endpoints: /sla/dashboard, /sla/violations, /sla/at-risk
- Creadas vistas frontend para dashboard, violaciones y tickets en riesgo
- Actualizado sistema de Celery para monitoreo automático de SLAs
- Mejorada configuración de categorías con tiempos SLA personalizables
- Corregidos problemas de proxy en configuración de Vite
- Agregado troubleshooting guide en README

Archivos principales modificados:
- backend/app/api/v1/endpoints/sla.py (nuevo)
- backend/app/api/schemas/sla.py (nuevo)
- frontend-internal/src/routes/sla/ (nuevo módulo completo)
- workers/app/tasks/sla_tasks.py (queries async mejoradas)

Documentación: docs/changelog-2026-02-17.md
2026-02-17 08:22:32 -07:00
42a5bb54cc style: Reemplazar escala de grises por colores semánticos en auditoría
- Estadísticas de auditoría con colores apropiados:
  * Total: Negro (neutro)
  * Hoy: Azul (actividad actual)
  * Esta Semana: Indigo (período reciente)
  * Incidentes Críticos: Rojo (alerta máxima)

- Estadísticas de seguridad con colores semánticos:
  * Amenazas Detectadas: Rojo (peligro alto)
  * Intentos Fallidos: Naranja (advertencia)
  * IPs Sospechosas: Amarillo (precaución)
  * Acciones Críticas: Rojo (crítico)

- Recomendaciones de seguridad con esquema azul (informativo)

- Actualizado safelist de Tailwind con nuevos colores:
  * text-blue-600, text-indigo-600, text-red-600
  * text-orange-600, text-yellow-600
  * text-*-400 para iconos
  * bg-blue-50, bg-red-50 para fondos
  * hover:text-red-700, hover:bg-red-50

Mejora significativa en la visualización y jerarquía visual de la información.
2026-02-16 13:09:54 -07:00
ae0bfc9d62 fix: Agregar safelist de colores en Tailwind para auditoría
Problema: Las clases de colores retornadas por funciones JavaScript
no eran detectadas por el purge de Tailwind, causando que los colores
no se mostraran (aparecían grises).

Solución: Agregar safelist en tailwind.config.js con todas las clases
de colores usadas dinámicamente:
- Colores de acciones: red, blue, green, indigo, orange, yellow
- Colores de severidad/riesgo con variantes 100, 300, 600, 800
- Bordes y textos correspondientes
- Estados hover y focus

Esto asegura que Tailwind incluya estas clases en el CSS compilado
independientemente de si se usan de forma estática o dinámica.
2026-02-16 13:01:17 -07:00
0bc4caf65d style: Restaurar esquema de colores en páginas de auditoría
- Cambiar funciones de color de grayscale a colores semánticos:
  * getActionColor: delete (red), update (blue), login (indigo), create (green)
  * getSeverityColor: critical (red), high (orange), medium (yellow), low (blue)
  * getStatusColor: active (blue), resolved (green), investigating (yellow)
  * getRiskColor: safe (green), low (blue), medium (yellow), high (orange), critical (red)

- Actualizar botones a esquema indigo del proyecto:
  * Botones de período de análisis: bg-indigo-600
  * Paginación: bg-indigo-600 para página actual
  * Botón actualizar: bg-indigo-600
  * Botón ejecutar acción: bg-indigo-600

- Botones de acción con colores apropiados:
  * Bloquear IP: bg-red-600 (acción crítica)
  * Resetear contraseña: bg-orange-600 (acción importante)
  * Notificar admin: bg-blue-600 (acción informativa)

Mantiene consistencia con el estilo visual del proyecto.
2026-02-16 12:53:26 -07:00
be762585d2 feat: Mejoras en auditoría - incidentes de seguridad y esquema de colores
- Backend:
  * Agregado endpoint /v1/audit/security/incidents con paginación y filtros
  * Nuevos schemas SecurityIncidentResponse y SecurityIncidentListResponse
  * Fix timezone: datetime.utcnow() → datetime.now(timezone.utc) en 4 ubicaciones
  * Detección automática de incidentes: mass deletion, brute force, privilege escalation

- Frontend (Internal):
  * Nueva sección de Incidentes de Seguridad con modal de detalles
  * Filtros por severidad, estado y tipo de incidente
  * Conversión completa a esquema grayscale (gray-100 a gray-900)
  * Eliminados todos los emojis de páginas audit y security
  * Implementada paginación para incidentes

- Fixes:
  * Resuelto error 500: TypeError con datetimes timezone-aware/naive
  * Resuelto error 404: endpoint de incidentes faltante
2026-02-16 12:45:33 -07:00
32cc8b6ccd Merge: Integrar Sistema de Análisis de Seguridad v1.6.0 a main
CARACTERÍSTICAS PRINCIPALES v1.6.0:
- Sistema de auditoría multi-tenant completo
- Análisis de seguridad con detección de amenazas en tiempo real
- Panel de seguridad con 4 algoritmos de detección:
  * Ataques de fuerza bruta
  * Escalada de privilegios
  * Eliminaciones masivas
  * Cuentas comprometidas
- Acciones de seguridad: bloquear IP, resetear contraseña, notificar admin
- Cross-tenant viewing para ADMIN/SUPPORT_MANAGER
- Frontend completamente funcional con nuevo menú Seguridad
- Sistema completamente verificado y operativo

Resolución de conflictos:
- Versiones actualizadas a 1.6.0 en todos los package.json y pyproject.toml
- Menú de seguridad integrado en Sidebar
- Tickets endpoint actualizado con auditoría
- Correcciones de middleware y queries SQL aplicadas
2026-02-16 11:04:30 -07:00
caeac3e96c Fix: Correcciones en análisis de seguridad y middleware tenant
- Corregido query SQL para detección de escalada de privilegios (JSONB operator)
- Añadidas rutas de autenticación faltantes al middleware tenant
- Sistema completamente verificado y funcional v1.6.0
2026-02-16 10:59:08 -07:00
6af80f1960 Feature: Sistema de Análisis de Seguridad y Detección de Vulnerabilidades v1.6.0
Nuevas funcionalidades:
- Sistema completo de análisis de seguridad con detección de amenazas
- Detección de patrones: fuerza bruta, escalada de privilegios, eliminaciones masivas, cuentas comprometidas
- Panel de vulnerabilidades con visualización detallada
- Acciones de seguridad: bloqueo de IPs, notificaciones, reset de contraseñas
- Análisis configurable (24h, 48h, 7 días)

Backend (/audit/security/):
- GET /analysis: Análisis completo de seguridad con amenazas detectadas
- POST /action: Ejecutar acciones de seguridad (solo ADMIN/SUPPORT_MANAGER)
- Schemas nuevos: SecurityAnalysisResponse, SecurityThreatPattern, SecurityActionRequest

Frontend (/audit/security):
- Panel completo de análisis con nivel de riesgo general
- Visualización de amenazas con severidad (critical, high, medium, low)
- Estadísticas: amenazas, intentos fallidos, IPs sospechosas, acciones críticas
- Opciones de acción por amenaza: bloquear IP, resetear contraseña, notificar admin
- Modal de ejecución de acciones de seguridad

Mejoras:
- Sidebar actualizado con enlace 'Seguridad'
- Permisos: Solo ADMIN/SUPPORT_MANAGER/AUDITOR pueden ver análisis
- Solo ADMIN/SUPPORT_MANAGER pueden ejecutar acciones
- Audit log de todas las acciones de seguridad ejecutadas
2026-02-16 10:09:36 -07:00
57944b364c Configure v1.6.0 display across application
- backend/app/core/config.py: Add APP_VERSION = '1.6.0' setting
- backend/app/main.py: Update health and root endpoints to show APP_VERSION
- frontend-internal/Sidebar.svelte: Add version display in sidebar footer
- frontend-client/+layout.svelte: Add version in page footer
- All endpoints now return both app_version (1.6.0) and api_version (v1)
2026-02-16 09:56:02 -07:00
3a061a005c Release v1.6.0 - Audit System Cross-Tenant Viewing
Features:
-  Cross-tenant audit log viewing for ADMIN/SUPPORT_MANAGER
-  New 'all_tenants' parameter in audit endpoints
-  Frontend toggle to view all clients' logs
-  Multi-tenant stats support in /audit/stats
-  Fixed timezone issue with date filters (UTC consistency)
-  Fixed date_to filter overlap causing duplicate records

Changes:
- backend/app/api/v1/endpoints/audit.py:
  * Added tenant_id and all_tenants query parameters
  * Permission checks for cross-tenant viewing
  * Dynamic tenant filtering based on user role
  * Fixed date_to filter (removed +1 day overlap)
  * Updated all stats queries for multi-tenant support

- frontend-internal/src/routes/audit/+page.svelte:
  * Import auth store for role detection
  * Added 'Ver todos los clientes' toggle for admins
  * Pass all_tenants parameter to API calls
  * UI badge indicating multi-tenant mode

- Version bump: 1.5.1.2 → 1.6.0 across all packages
2026-02-16 09:50:30 -07:00
d9b783107f fix: Corregir filtro de fechas en auditoría por zona horaria
- El filtro usaba hora local que se convertía incorrectamente a UTC
- Los registros de 'hoy' aparecían en 'ayer' por desfase horario
- Ahora trabaja directamente en UTC para consistencia
- Afecta todos los filtros: today, yesterday, last7days, last30days
- Custom dates también parseados correctamente como UTC
2026-02-16 09:26:43 -07:00
5a292daa0b feat: Script para crear usuario de prueba con contraseña conocida
- Permite crear/actualizar usuario admin@aduanasoft.com
- Password: admin123
- Facilita testing del sistema de auditoría
- Genera hash correcto con Argon2
2026-02-16 09:21:06 -07:00
87e094b668 feat: Integrar AuditService en todos los endpoints críticos
- Auth: login, logout, login_failed con registro automático
- Tickets: create, update, delete, assign con old/new values
- Users: create, update, delete con sanitización de passwords
- Stats: implementar top_users con JOIN a tabla users
- Schema: agregar índices compuestos para mejor performance
- Frontend: limpiar logs de debug en viewDetail
- Manejo de errores: audit logs no afectan flujo principal
2026-02-16 09:08:03 -07:00
2cb8b58808 Fix: Corregir funcionalidad del botón Ver en auditoría
- Eliminar click en fila completa que causaba conflicto
- Eliminar stopPropagation innecesario de botones
- Agregar type='button' a todos los botones Ver
- Mejorar hover states y transiciones
- Agregar títulos para accesibilidad
- Simplificar lógica de eventos de click
- Botones Ver ahora funcionan correctamente en tabla y tarjetas
- Tarjeta de vulnerabilidad: solo botón Ver es clickeable
2026-02-16 08:43:03 -07:00
9f973464b9 Implementar tarjeta de Vulnerabilidad y mejorar interactividad
- Reemplazar tarjeta 'Más Común' por 'Vulnerabilidad'
- Backend: Agregar campo critical_actions_today al schema de stats
- Backend: Calcular acciones críticas (delete, update sensibles, logout)
- Frontend: Mostrar contador de acciones críticas con código de color
- Frontend: Click en tarjeta filtra por acciones críticas del día
- Frontend: Botón 'Ver' funcional con icono
- Color rojo si >10 críticas, ámbar si >5, verde si <=5
- Función filterCriticalActions() para búsqueda rápida
- UX mejorada con hover effects y transiciones
2026-02-16 08:38:39 -07:00
90f9c9c6e6 Modernizar UI de registros de auditoría (UX mejorada)
- Diseño responsivo: tabla en desktop, tarjetas en móvil/tablet
- Altura máxima con scroll interno (evita scroll de página completa)
- Avatares circulares con iniciales del usuario
- Filas/tarjetas más compactas y eficientes
- Paginación sticky (siempre visible al fondo)
- Botones primera/última página para navegación rápida
- Página actual resaltada en color primary
- Click en toda la fila para ver detalles
- Diseño más moderno y limpio
- Mejor uso del espacio vertical
2026-02-16 08:31:07 -07:00
51a8515b40 Fix: Corregir formato de fechas en auditoría
- Enviar datetime ISO completo en lugar de solo fecha (YYYY-MM-DD)
- Backend requiere formato datetime ISO 8601
- Agregar hora 00:00:00 para fecha inicio y 23:59:59 para fecha fin
- Manejar correctamente fechas custom vacías
- Soluciona error 422 (Unprocessable Entity)
2026-02-16 08:24:14 -07:00
ff9a8998b2 Mejora UI de página de auditoría
- Por defecto muestra solo logs del día actual
- Agregado selector de período rápido (Hoy, Ayer, 7 días, 30 días, Personalizado)
- Filtros avanzados colapsables para mejor UX
- Interfaz más limpia y organizada
- Formato de fechas mejorado (hora corta para hoy)
- Estadísticas visuales mejoradas
- Tabla simplificada con información esencial
- Modal de detalles mantiene toda la información completa
2026-02-16 08:17:39 -07:00
1543397212 v1.5.1.2: Integración completa del sistema de auditoría
- Agregado módulo de auditoría con AuditLog model
- Implementado endpoint /api/v1/audit para consulta de logs
- Integrado audit_service para registro automático de acciones
- Agregado token_service para gestión de refresh tokens
- Frontend: Vista de auditoría en panel interno
- Actualizada versión en pyproject.toml y package.json
- Sistema de auditoría completamente funcional y testeado
2026-02-16 08:05:25 -07:00
2033a35a2b Version con fallas 2026-02-12 14:00:04 -07:00
96cd09476c Auditoria funcionando 2026-02-12 12:23:11 -07:00
96084b89c0 chore: Limpieza de proyecto y optimización
🗑️ Eliminados:
- Scripts temporales de debugging (9 archivos en backend/)
- Archivos temporales en raíz (4 archivos)
- Reportes de cobertura htmlcov/ (~543 KB)
- Directorio backups/
- Script de migración update_password_hashes.py

 Optimizaciones:
- Creado scripts/db_utils.py - Herramienta consolidada para administración
- Actualizado README.md con sección de Utilidades Administrativas
- Mejorado .gitignore para prevenir archivos temporales futuros

📦 Espacio liberado: ~600-800 KB

Las funcionalidades de debugging ahora están consolidadas en:
- scripts/db_utils.py (herramienta CLI profesional)
- Documentación en README.md
- Alternativas sugeridas (psql, tests, API docs)
2026-02-12 09:34:30 -07:00
771b6eba30 Release v1.5.1 - Control de Acceso Basado en Roles y Correcciones Críticas
🔒 Seguridad:
- Implementación completa de RBAC (Role-Based Access Control)
- Staff interno (ADMIN/AGENT/SUPPORT_MANAGER) accede a todos los tickets del tenant
- Clientes (CLIENT_USER/CLIENT_ADMIN) solo acceden a sus propios tickets
- Restricción de creación/modificación de categories/systems a ADMIN/SUPPORT_MANAGER
- Agregado header X-Tenant-ID en frontend-internal para multi-tenancy

🐛 Correcciones:
- Fix crítico: Prevención de números de ticket duplicados
- Implementado retry logic con 3 intentos en creación de tickets
- Generación de ticket_number basada en MAX existente (no contador simple)
- Corrección de filtros en GET /tickets según roles

 Mejoras:
- Validación robusta de permisos en todos los endpoints
- Mejor manejo de excepciones y mensajes de error
- Multi-tenancy reforzado con validaciones adicionales

📚 Documentación:
- Agregado CHANGELOG.md con historial de versiones
- Actualizada versión a 1.5.1 en package.json y pyproject.toml
- Scripts de prueba para validación de RBAC
2026-02-12 09:00:16 -07:00
0f94d1cc67 feat: Funcionando 2026-02-12 08:45:33 -07:00
a8e7af87dc Descarga de archivos implementada 2026-02-10 13:39:39 -07:00
e766e5b747 Typescript resuelto 2026-02-10 13:19:12 -07:00
471c263158 feat: Advanced filtering system v1.4.1.2
''
2026-02-10 13:04:46 -07:00
5cff309422 hotfix: Advanced filtering system v1.4.1.1 2026-02-10 08:49:37 -07:00
94e9d91586 🚀 Release v1.4.1 - Enhanced Ticket Management Features
 New Features:
• Added admin filter by client/organization in internal frontend
• Enhanced attachments viewer with toggle button in client frontend
• Added attachment counter and improved UX in ticket conversation

🔧 Backend Improvements:
• New `/tickets/admin/all` endpoint for administrators
• Advanced filtering by tenant, status, and priority
• Rich response data with tenant and user information
• Proper admin permissions validation

🎨 Frontend Enhancements:
• Client filter dropdown in admin panel
• Enhanced ticket table with client/organization info
• Collapsible attachments section with visual indicator
• Improved API parameter handling (fixed undefined filters)
• Better responsive design and hover effects

🐛 Bug Fixes:
• Fixed undefined URL parameters in API calls
• Corrected parameter filtering in API utility
• Improved error handling for admin endpoints

📱 UI/UX:
• Added visual badges for attachment count
• Enhanced table columns with client/creator information
• Improved button styling and interaction feedback
• Better organization of ticket conversation layout
2026-02-10 08:18:53 -07:00
c9dd024c7e 🔧 Fix critical issues and improve project stability
- Fixed TypeScript errors in tickets.ts (FastAPIValidationError interface)
- Corrected SQLAlchemy imports with TYPE_CHECKING pattern in models
- Created missing tsconfig.json files for both frontends
- Enhanced .env configuration with additional security settings
- Completed /auth/me endpoint implementation with database queries
- Fixed Alembic migrations issue (a7f9c8d2e4b1 → 13362e8c493a)
- Set up basic testing framework with pytest
- Resolved configuration issues in Pydantic Settings
- Cleaned up duplicate migrations structure
- Format improvements in tsconfig.json files

 All services healthy, tests passing (9/10), migrations working
2026-02-09 13:55:10 -07:00
a13a8cb0e8 feat: Add maintenance scripts and testing utilities
- Added password management utilities for user administration
- Added PowerShell scripts for testing attachment endpoints
- Enhanced development and testing workflow capabilities
- Completed v1.4.0 with all maintenance tools included"
2026-02-09 13:33:49 -07:00
659fc2f446 chore: Add testing configuration and clean up duplicate files
- Added pytest configuration and test suite
- Added TypeScript configuration for both frontend apps
- Removed duplicate migration files from backend/backend/migrations/
- Enhanced project structure for better testing and development"
2026-02-09 13:30:39 -07:00
91ff49cdec feat(backend): Update models and endpoints configuration
- Enhanced ticket and comment models with proper relationships
- Updated client_profile model for better data handling
- Improved auth endpoint with better error handling
- Updated main app configuration and imports
- Added new dependencies to requirements.txt
- Enhanced tickets endpoint with attachment support
2026-02-09 13:28:40 -07:00
ac0cb6f132 fix(frontend): Fix client API calls and improve profile UI
- Fixed proxy configuration in vite.config.js (servicemanager-backend -> backend)
- Added X-Tenant-ID header to client-profile API calls
- Improved error handling with proper authentication checks
- Updated profile page UI to white theme (removed icons and colors)
- Changed all btn-primary buttons to white theme styling
- Enhanced API call error handling in tickets store
2026-02-09 13:28:16 -07:00
d4ff32dac7 feat(backend): Fix client-profile endpoint and add attachment support
- Fixed client-profile GET endpoint to prevent 500 errors
- Made ClientProfileResponse fields optional (id, created_at, updated_at)
- Returns empty profile data instead of creating DB entry on GET
- Added new attachment model and schemas for file handling
- Added file handler core utility for upload management
2026-02-09 13:27:45 -07:00
ea682d8cd9 Add new changes to client profile and related files 2026-02-05 14:03:56 -07:00
896c99d586 🚀 Release v1.3.2: Sistema completamente configurado y optimizado
 Nuevas funcionalidades:
-  Sistema de migraciones Alembic implementado
-  Dependencias frontend resueltas (SvelteKit + TypeScript)
-  Configuraciones VS Code optimizadas
-  GitHub Copilot configuración enterprise
-  Testing completo 100% exitoso

🔧 Cambios técnicos:
- Alembic: Configuración completa con templates
- Frontend: 686 paquetes npm instalados
- VS Code: Debugger modernizado (python -> debugpy)
- Database: 16 tablas sincronizadas
- Docker: 8 servicios funcionando correctamente

🏗️ Arquitectura:
- Multi-tenant B2B system ready
- Production-ready configuration
- Enterprise-grade development environment
2026-02-05 13:00:56 -07:00
2125504831 Release version 1.3.1: Updated backend models and endpoints 2026-02-05 11:19:36 -07:00
0d7cdf51ca Versión 1.3.0: Conexion completa del proyecto 2026-02-03 10:38:54 -07:00
171 changed files with 39744 additions and 1932 deletions

187
.github/copilot-context.md vendored Normal file
View 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.

View File

@@ -98,4 +98,98 @@ black .
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.

178
README.legacy.md Normal file
View 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
View File

@@ -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)
- **Backend**: Python FastAPI + Pydantic v2
- **Workers**: Celery + Redis (notificaciones, SLAs, jobs)
- **BD**: PostgreSQL + Alembic migrations
- **Auth**: JWT + Refresh tokens + 2FA opcional (TOTP)
- **Infra**: Docker Compose local, preparado para producción
## Tabla de Contenidos
## Estructura del Monorepo
1. [Requisitos previos](#requisitos-previos)
2. [Inicio rápido con Docker (recomendado)](#inicio-rápido-con-docker-recomendado)
3. [Configuración de variables de entorno](#configuración-de-variables-de-entorno)
4. [Cargar datos de prueba](#cargar-datos-de-prueba)
5. [URLs y puertos por defecto](#urls-y-puertos-por-defecto)
6. [Credenciales de prueba](#credenciales-de-prueba)
7. [Desarrollo local sin Docker](#desarrollo-local-sin-docker)
8. [Arquitectura del proyecto](#arquitectura-del-proyecto)
9. [Roles y permisos](#roles-y-permisos)
10. [Comandos útiles](#comandos-útiles)
11. [Pruebas (testing)](#pruebas-testing)
12. [Solución de problemas](#solución-de-problemas)
13. [Contribución](#contribución)
14. [Historial de versiones](#historial-de-versiones)
---
## Requisitos previos
Antes de clonar el proyecto, asegúrate de tener instalado:
| Herramienta | Versión mínima | Descarga |
|-------------|---------------|---------|
| **Git** | 2.x | https://git-scm.com/downloads |
| **Docker Desktop** | 24.x | https://www.docker.com/products/docker-desktop |
| **Docker Compose** | v2.x (incluido en Docker Desktop) | — |
> **Nota para desarrolladores que quieran editar código localmente (sin Docker):**
> también necesitarás Python 3.11+ y Node.js 18+. Ver sección
> [Desarrollo local sin Docker](#desarrollo-local-sin-docker).
### Verificar que Docker esté corriendo
```bash
docker --version # Debe mostrar Docker version 24.x o superior
docker compose version # Debe mostrar Docker Compose version v2.x
```
Si `docker compose version` falla, prueba `docker-compose --version` (versión standalone).
---
## Inicio rápido con Docker (recomendado)
Este es el método más simple y funciona igual en **Windows, Linux y macOS**.
Solo necesitas Docker Desktop instalado y corriendo.
### Paso 1 — Clonar el repositorio
```bash
git clone https://git.aduanasoft.com/ADUANASOFT/service_manager.git
cd service_manager
```
### Paso 2 — Crear el archivo de variables de entorno
**Linux / macOS:**
```bash
cp .env.example .env
```
**Windows (PowerShell):**
```powershell
Copy-Item .env.example .env
```
**Windows (CMD):**
```cmd
copy .env.example .env
```
> **Importante:** El archivo `.env` nunca se sube a git (está en `.gitignore`).
> Para desarrollo local los valores del `.env.example` funcionan sin cambios.
> En producción **debes** generar claves secretas únicas (ver sección de variables de entorno).
### Paso 3 — Levantar todos los servicios
```bash
docker compose up -d
```
Este comando descarga las imágenes, construye los contenedores e inicia todo el stack.
La primera vez tarda entre 3 y 8 minutos dependiendo de la conexión a internet.
> **Alternativa con herramientas de desarrollo** (Adminer, MailHog, Redis Commander):
> ```bash
> docker compose --profile dev up -d
> ```
### Paso 4 — Verificar que todo esté funcionando
```bash
docker compose ps
```
Deberías ver todos los servicios con estado `Up` o `healthy`:
```
NAME STATUS
servicemanager-db Up (healthy)
servicemanager-redis Up (healthy)
servicemanager-backend Up (healthy)
servicemanager-worker Up
servicemanager-beat Up
servicemanager-client-frontend Up
servicemanager-internal-... Up
servicemanager-nginx Up
```
Si algún servicio muestra `Exit` o `Restarting`, revisa la sección
[Solución de problemas](#solución-de-problemas).
### Paso 5 — Cargar datos de ejemplo (opcional pero recomendado)
```bash
docker exec servicemanager-backend python /scripts/seed_data.py
```
Esto crea el tenant de demostración, categorías, usuarios y tickets de prueba.
### ¡Listo! Abre el navegador
| Aplicación | URL |
|------------|-----|
| Portal de clientes | http://localhost:3000 |
| Panel interno (staff) | http://localhost:3001 |
| API REST | http://localhost:8000 |
| Documentación API (Swagger) | http://localhost:8000/docs |
| Documentación API (ReDoc) | http://localhost:8000/redoc |
| Health check | http://localhost:8000/health |
> **Con perfil dev** activo también tendrás:
> - Adminer (gestor visual de PostgreSQL): http://localhost:8080
> - MailHog (pruebas de email): http://localhost:8025
> - Redis Commander (inspector de Redis): http://localhost:8081
---
## Configuración de variables de entorno
El archivo `.env` controla todo el comportamiento de la aplicación.
Copia `.env.example` como `.env` y revisa los valores siguientes:
### Variables críticas
| Variable | Descripción | Valor por defecto (dev) |
|----------|-------------|-------------------------|
| `SECRET_KEY` | Clave secreta general de Flask/FastAPI | _(cambiar en producción)_ |
| `JWT_SECRET_KEY` | Clave para firmar tokens JWT | _(cambiar en producción)_ |
| `DATABASE_URL` | Cadena de conexión a PostgreSQL | `postgresql+asyncpg://servicemanager:...@postgres:5432/servicemanager` |
| `REDIS_URL` | URL de conexión a Redis | `redis://redis:6379/0` |
| `ENVIRONMENT` | Entorno actual | `development` |
| `DEBUG` | Modo debug (muestra errores detallados) | `true` |
### Generar claves seguras para producción
**Linux / macOS:**
```bash
openssl rand -base64 32 # Genera SECRET_KEY
openssl rand -base64 32 # Genera JWT_SECRET_KEY
```
**Windows (PowerShell):**
```powershell
[Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Maximum 256 }))
```
> **Advertencia:** Nunca uses las claves del `.env.example` en producción.
> Cambiar las claves en producción invalida todas las sesiones activas.
### Desarrollo local vs Docker
En `.env.example` las URLs apuntan a nombres de servicio Docker (`postgres`, `redis`, `backend`).
Si ejecutas el backend directamente en tu máquina (sin Docker), cambia:
```dotenv
# Para desarrollo local sin Docker:
DATABASE_URL=postgresql+asyncpg://servicemanager:servicemanager123@localhost:5432/servicemanager
REDIS_URL=redis://localhost:6379/0
CELERY_BROKER_URL=redis://localhost:6379/0
```
---
## Cargar datos de prueba
El script `seed_data.py` crea datos iniciales en la base de datos.
**Con Docker (recomendado):**
```bash
docker exec servicemanager-backend python /scripts/seed_data.py
```
**Sin Docker:**
```bash
cd backend
python ../scripts/seed_data.py
```
El script crea:
- Tenant de demostración: `aduanasoft-demo`
- Categorías de tickets (Soporte Técnico, Facturación, Incidentes Críticos, etc.)
- Sistemas registrados
- Usuarios de prueba con distintos roles
---
## URLs y puertos por defecto
| Servicio | Puerto | Descripción |
|----------|--------|-------------|
| Frontend Clientes | **3000** | Portal para usuarios clientes |
| Frontend Interno | **3001** | Panel para staff (agentes, admins) |
| Backend API | **8000** | FastAPI — endpoints REST |
| PostgreSQL | **5432** | Base de datos (no exponer en producción) |
| Redis | **6379** | Cache y broker Celery (no exponer en producción) |
| Nginx | **80** | Reverse proxy |
| Adminer *(perfil dev)* | **8080** | GUI para PostgreSQL |
| MailHog *(perfil dev)* | **8025** | Capturador de emails en desarrollo |
| Redis Commander *(perfil dev)* | **8081** | GUI para Redis |
### ¿Conflicto de puertos?
Si algún puerto ya está en uso en tu máquina, edita `docker-compose.yml` y cambia
el número **izquierdo** del mapeo `host:container`. Por ejemplo, para backend en el 8080:
```yaml
ports:
- "8080:8000" # ahora accesible en localhost:8080
```
---
## Credenciales de prueba
Después de ejecutar el seed, puedes iniciar sesión con:
| Campo | Valor |
|-------|-------|
| Email | `admin@aduanasoft.com` |
| Contraseña | `admin123` |
| Tenant | `aduanasoft-demo` |
| Rol | `ADMIN` |
> Otros usuarios creados por el seed tienen el mismo sufijo de contraseña (`123`).
> Revisa `scripts/seed_data.py` para ver la lista completa.
---
## Desarrollo local sin Docker
Útil cuando necesitas depurar el código con breakpoints o acelerar el ciclo de desarrollo.
Requiere que **PostgreSQL y Redis sí corran en Docker** (o instalación nativa).
### Requisitos adicionales
| Herramienta | Versión | Descarga |
|------------|---------|---------|
| Python | 3.11 o 3.12 | https://www.python.org/downloads/ |
| Node.js (con npm) | 18 LTS | https://nodejs.org/ |
| pip | incluido con Python | — |
### Iniciar solo la base de datos y Redis
```bash
docker compose up -d postgres redis
```
### Backend (FastAPI)
```bash
cd backend
# Crear entorno virtual (solo la primera vez)
python -m venv ../.venv
# Activar entorno virtual
# Linux / macOS:
source ../.venv/bin/activate
# Windows (PowerShell):
..\.venv\Scripts\Activate.ps1
# Windows (CMD):
..\.venv\Scripts\activate.bat
# Instalar dependencias (solo la primera vez o cuando cambie requirements.txt)
pip install -r requirements.txt
# Ejecutar migraciones de base de datos
alembic upgrade head
# Iniciar servidor de desarrollo
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
> Si `uvicorn` no se encuentra, asegúrate de que el entorno virtual está activado
> (`(.venv)` debe aparecer en tu terminal).
### Frontend Clientes
```bash
cd frontend-client
# Instalar dependencias (solo la primera vez)
npm install
# Iniciar servidor de desarrollo en puerto 3000
npm run dev
```
### Frontend Interno (staff)
```bash
cd frontend-internal
# Instalar dependencias (solo la primera vez)
npm install
# Iniciar servidor de desarrollo en puerto 3001
npm run dev
```
> Los dos frontends tienen puertos distintos (3000 y 3001) para que no haya conflicto
> cuando corren al mismo tiempo.
### Workers Celery (opcional en desarrollo)
Necesario solo si desarrollas funcionalidades de notificaciones o SLAs automáticos.
```bash
cd workers
# Activar el mismo entorno virtual del backend:
# Linux / macOS:
source ../.venv/bin/activate
# Windows:
..\.venv\Scripts\Activate.ps1
pip install -r requirements.txt
# Worker principal
celery -A app.celery worker --loglevel=info
# Scheduler de tareas periódicas (en otra terminal)
celery -A app.celery beat --loglevel=info --schedule=/tmp/celerybeat-schedule
```
---
## Arquitectura del proyecto
```
ServiceManagerWeb/
├── backend/ # FastAPI app
├── frontend-client/ # SvelteKit app para clientes
├── frontend-internal/ # SvelteKit app para staff interno
├── workers/ # Celery tasks
├── db/ # Migrations y esquemas
├── docker/ # Dockerfiles específicos
├── docs/ # Documentación adicional
├── scripts/ # Scripts de desarrollo/despliegue
├── docker-compose.yml # Orquestación completa
└── .env.example # Variables de entorno
├── backend/ # Aplicación FastAPI (Python 3.11)
│ ├── app/
│ │ ├── main.py # Punto de entrada, lifespan, middlewares
├── api/v1/
├── router.py # Registro de todos los routers
└── endpoints/ # Endpoints REST por dominio
│ │ ├── core/ # Config, seguridad, base de datos, caché
│ │ ├── models/ # Modelos SQLAlchemy (ORM)
│ │ ├── services/ # Lógica de negocio
│ │ └── middleware/ # Tenant context, Correlation ID
│ ├── migrations/ # Migraciones Alembic
│ ├── tests/ # Pruebas backend
│ └── requirements.txt # Dependencias Python
├── frontend-client/ # Portal de clientes (SvelteKit + TypeScript)
│ └── src/routes/ # Páginas: login, tickets, perfil
├── frontend-internal/ # Panel de staff (SvelteKit + TypeScript)
│ └── src/routes/ # Páginas: dashboard, tickets, reportes, auditoría
├── workers/ # Tareas asíncronas Celery
│ └── app/tasks/ # email_tasks.py, sla_tasks.py, etc.
├── docker/ # Dockerfiles y configuración Nginx
├── db/ # schema.sql inicial
├── docs/ # Documentación técnica adicional
├── scripts/ # seed_data.py, setup-dev.sh, etc.
├── docker-compose.yml # Orquestación completa
└── .env.example # Plantilla de variables de entorno
```
## Stack Tecnológico
### Stack tecnológico
### Backend (Python)
- FastAPI (async)
- Pydantic v2
- SQLAlchemy 2.0 (async)
- Alembic (migrations)
- Argon2 (hashing passwords)
- PyJWT
- Celery + Redis
**Backend:** Python 3.11 · FastAPI · Pydantic v2 · SQLAlchemy 2.0 (async) · Alembic · Argon2 · PyJWT · Celery · Redis
### Frontend (JavaScript/TypeScript)
- SvelteKit
- TypeScript
- TailwindCSS
- shadcn/ui o similar
- Zod (validación)
**Frontend:** Node.js 18 · SvelteKit · TypeScript · TailwindCSS · Zod
### Infraestructura
- PostgreSQL 15+
- Redis 7+
- Docker & Docker Compose
- Nginx (reverse proxy)
**Infraestructura:** PostgreSQL 15 · Redis 7 · Docker Compose · Nginx
## Dominios del Sistema
---
1. **Auth**: Usuarios, roles, permisos, 2FA
2. **Tenants**: Multi-tenancy, organizaciones
3. **Tickets**: Gestión de tickets, estados, SLAs
4. **Notifications**: Email, plantillas, logs
5. **Audit**: Bitácora de acciones
## Roles y permisos
## Roles de Usuario
### Internos (Staff)
- `ADMIN`: Control total del sistema
- `SUPPORT_MANAGER`: Gestión de equipos y SLAs
- `AGENT`: Atención de tickets
- `AUDITOR`: Solo lectura para auditoría
### Personal interno (staff)
| Rol | Descripción |
|-----|-------------|
| `ADMIN` | Control total del sistema |
| `SUPPORT_MANAGER` | Gestión de equipos y configuración de SLAs |
| `AGENT` | Atención y resolución de tickets |
| `AUDITOR` | Solo lectura para revisiones y cumplimiento |
### Clientes
- `CLIENT_ADMIN`: Gestión de organización cliente
- `CLIENT_USER`: Creación y seguimiento de tickets
| Rol | Descripción |
|-----|-------------|
| `CLIENT_ADMIN` | Gestión de su organización cliente |
| `CLIENT_USER` | Creación y seguimiento de sus propios tickets |
## Quick Start
---
## Comandos útiles
### Docker Compose
```bash
# Clonar y configurar
git clone <repo>
cd ServiceManagerWeb
cp .env.example .env
# Levantar todos los servicios (segundo plano)
docker compose up -d
# Levantar servicios
docker-compose up -d
# Levantar con herramientas de desarrollo
docker compose --profile dev up -d
# Verificar estado
docker-compose ps
# Ver logs en tiempo real de todos los servicios
docker compose logs -f
# Ver logs de un servicio específico
docker compose logs -f backend
docker compose logs -f frontend-internal
# Detener todos los servicios (mantiene los datos)
docker compose down
# Detener Y borrar todos los volúmenes (¡borra la base de datos!)
docker compose down -v
# Reconstruir imagen de un servicio (después de cambiar Dockerfile o requirements)
docker compose build backend
docker compose up -d backend
# Reiniciar un servicio
docker compose restart backend
```
## URLs por Defecto
- Frontend Clientes: http://localhost:3000
- Frontend Interno: http://localhost:3001
- API Backend: http://localhost:8000
- API Docs: http://localhost:8000/docs
- Adminer (DB): http://localhost:8080
## Scripts de Desarrollo
### Base de datos (Alembic)
```bash
# Backend
# Aplicar todas las migraciones pendientes
cd backend
python -m uvicorn app.main:app --reload --port 8000
alembic upgrade head
# Frontend Cliente
cd frontend-client
npm run dev -- --port 3000
# Ver estado de migraciones
alembic current
# Frontend Interno
cd frontend-internal
npm run dev -- --port 3001
# Revertir última migración
alembic downgrade -1
# Workers
cd workers
celery -A app.worker worker --loglevel=info
celery -A app.worker beat --loglevel=info
# Crear nueva migración (después de modificar models/)
alembic revision --autogenerate -m "nombre descriptivo del cambio"
# Con Docker:
docker exec servicemanager-backend alembic upgrade head
```
## Testing
### Calidad de código
```bash
# Backend tests
cd backend
# Linter y auto-fix
ruff check . --fix
# Formateador
black .
# Verificación de tipos
mypy .
# Todo de una vez
ruff check . --fix && black . && mypy .
```
---
## Pruebas (testing)
### Backend
```bash
cd backend
# Ejecutar todas las pruebas
pytest
# Frontend tests
cd frontend-client
npm test
cd ../frontend-internal
npm test
# Con cobertura detallada
pytest --cov=app --cov-report=html
# Abrir reporte de cobertura (Linux/macOS)
open htmlcov/index.html
# Windows
start htmlcov/index.html
# Prueba específica
pytest tests/test_auth.py -v
# Con Docker
docker exec servicemanager-backend pytest -v --cov=app
```
### Frontend
```bash
cd frontend-internal # o frontend-client
npm test # Ejecutar una vez
npm run test:watch # Modo observador
```
---
## Solución de problemas
### El backend no inicia — error en `DATABASE_URL`
**Síntoma:** El contenedor `servicemanager-backend` reinicia continuamente.
**Causa frecuente:** El archivo `.env` no existe o tiene `DATABASE_URL` apuntando a `localhost`
en lugar del nombre del servicio Docker `postgres`.
**Solución:**
```bash
# Verificar que .env existe
ls .env # Linux/macOS
dir .env # Windows
# Si no existe, crearlo
cp .env.example .env # Linux/macOS
Copy-Item .env.example .env # Windows PowerShell
# Verificar el valor correcto en .env:
# DATABASE_URL=postgresql+asyncpg://servicemanager:servicemanager123@postgres:5432/servicemanager
# ^^^^^^^
# Nombre de servicio Docker, NO localhost
```
---
### Error 500 en login / "connect ECONNREFUSED"
**Síntoma:** El frontend muestra error 500 al hacer login, o la consola del navegador
muestra `ECONNREFUSED 127.0.0.1:8000`.
**Causa:** El proxy de Vite no encuentra el backend.
**Solución en Docker:** El proxy ya está configurado para usar `PUBLIC_API_URL`.
Verifica en `docker-compose.yml` que `frontend-internal` y `frontend-client` tienen:
```yaml
environment:
- PUBLIC_API_URL=http://backend:8000
```
Después reinicia:
```bash
docker compose restart frontend-internal frontend-client
```
**Solución en desarrollo local:** Asegúrate de que el backend está corriendo:
```bash
curl http://localhost:8000/health
# Debe responder: {"status": "ok", ...}
```
---
### El frontend-internal y frontend-client usan el mismo puerto localmente
**Síntoma:** Al correr ambos frontends sin Docker, uno de los dos falla
con `Port 3000 is already in use`.
**Solución:**
- `frontend-client` → usa el puerto **3000** (por defecto con `npm run dev`)
- `frontend-internal` → usa el puerto **3001** (configurado en `vite.config.js`)
Nunca hay conflicto si los iniciaste con `npm run dev` en cada carpeta por separado.
Si aún hay conflicto, mata el proceso en ese puerto:
```bash
# Linux / macOS
lsof -ti:3000 | xargs kill -9
# Windows (PowerShell)
Get-Process -Id (Get-NetTCPConnection -LocalPort 3000).OwningProcess | Stop-Process -Force
```
---
### El tenant slug es incorrecto al hacer login
**Síntoma:** Login falla con "credenciales inválidas" aunque el email y contraseña son correctos.
**Causa:** El campo `tenant_slug` no corresponde a ningún tenant en la base de datos.
**Solución:**
```bash
# Ver los tenants disponibles
docker exec servicemanager-backend python -c "
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy import text
import os
async def main():
engine = create_async_engine(os.environ['DATABASE_URL'])
async with AsyncSession(engine) as s:
result = await s.execute(text('SELECT slug, name FROM tenants'))
for row in result:
print(row)
asyncio.run(main())
"
```
Tenant por defecto (después del seed): **`aduanasoft-demo`**
---
### Puerto ocupado — cambiar puertos de los servicios
Edita `docker-compose.yml` y modifica **solo el número izquierdo** del mapeo de puertos:
```yaml
# Ejemplo: mover el backend al puerto 9000
backend:
ports:
- "9000:8000" # accesible en localhost:9000
# Ejemplo: mover el frontend al puerto 4000
frontend-client:
ports:
- "4000:3000" # accesible en localhost:4000
```
---
### Migraciones fallidas — `alembic upgrade head` da error
```bash
# Verificar el estado actual
docker exec servicemanager-backend alembic current
# Si hay conflicto, hacer downgrade hasta la base y volver a subir
docker exec servicemanager-backend alembic downgrade base
docker exec servicemanager-backend alembic upgrade head
```
---
### Módulo Python no encontrado (`ModuleNotFoundError`)
**Con Docker:** El módulo no está en `requirements.txt` o la imagen no fue reconstruida.
```bash
# Reconstruir la imagen del backend
docker compose build backend
docker compose up -d backend
```
**Local:** El entorno virtual no está activado.
```bash
# Verificar que el venv está activo (debe aparecer (.venv) en el prompt)
which python # Linux/macOS — debe apuntar a .venv/
# Windows:
where python # debe apuntar a .venv\Scripts\python.exe
```
---
### `npm: command not found` o versión de Node incorrecta
```bash
node --version # Debe ser v18.x o superior
npm --version # Debe ser 9.x o superior
```
Si Node no está instalado, descárgalo desde https://nodejs.org/ (elige "LTS").
En macOS con Homebrew:
```bash
brew install node@18
```
En Linux (Ubuntu/Debian):
```bash
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt-get install -y nodejs
```
---
### `docker-compose` no se reconoce como comando
En versiones modernas de Docker Desktop, el comando es `docker compose` (con espacio, sin guion).
Si tienes instalación separada de Docker Compose v1, usa `docker-compose` (con guion).
---
### Logs de los contenedores
```bash
# Ver qué está fallando
docker compose logs backend --tail=50
docker compose logs frontend-internal --tail=50
docker compose logs postgres --tail=20
```
---
## Contribución
1. Fork del proyecto
2. Crear feature branch (`git checkout -b feature/nueva-funcionalidad`)
3. Commit cambios (`git commit -am 'Agregar nueva funcionalidad'`)
4. Push a branch (`git push origin feature/nueva-funcionalidad`)
5. Crear Pull Request
1. Haz fork del proyecto
2. Crea una rama de funcionalidad: `git checkout -b feature/nombre-funcionalidad`
3. Realiza tus cambios siguiendo las convenciones del proyecto
4. Ejecuta las pruebas: `pytest` y el linter: `ruff check .`
5. Haz commit con un mensaje descriptivo: `git commit -m "feat: agregar exportación a CSV"`
6. Sube tu rama: `git push origin feature/nombre-funcionalidad`
7. Abre un Pull Request hacia `main`
### Convenciones de nombres
- **Modelos**: `PascalCase``User`, `Ticket`, `TenantOrganization`
- **Endpoints (URL)**: `kebab-case``/api/v1/user-management/`
- **Componentes Svelte**: `PascalCase.svelte``TicketCard.svelte`
- **Stores**: `camelCase``ticketStore.ts`
---
## Historial de versiones
| Versión | Descripción |
|---------|-------------|
| **v1.15.1** | Módulo de reportes implementado |
| v1.14.x | Mejoras al módulo de auditoría |
| v1.13.x | Sistema de SLAs automático |
| v1.12.x | Notificaciones por email |
| v1.0.0 | MVP inicial — tickets, tenants, autenticación |
---
## Licencia
Propietario - Aduanasoft © 2026
Propietario Aduanasoft © 2026. Todos los derechos reservados.

BIN
backend/.coverage Normal file

Binary file not shown.

View File

@@ -56,6 +56,22 @@ backend/
- [x] TOTP 2FA implementation
- [x] Validation con Pydantic v2
### Rate limiting (login)
El endpoint `/{API_VERSION}/auth/login` incluye rate limiting (best-effort) usando Redis:
- Por IP: limita intentos totales por ventana
- Por identidad: limita por `(tenant_id, email)` por ventana
Responde `429 Too Many Requests` con header `Retry-After`.
Variables de entorno (ver `app/core/config.py`):
- `RATE_LIMIT_ENABLED` (default: `true`)
- `LOGIN_RATE_LIMIT_WINDOW_SECONDS` (default: `300`)
- `LOGIN_RATE_LIMIT_IP_MAX_ATTEMPTS` (default: `30`)
- `LOGIN_RATE_LIMIT_ID_MAX_ATTEMPTS` (default: `10`)
## Quick Start
```bash
@@ -157,8 +173,8 @@ Ver `.env.example` para todas las variables disponibles.
- [x] CORS restrictivo
- [x] Input validation con Pydantic
- [x] SQL injection protection (SQLAlchemy)
- [x] Rate limiting (TODO: implementar)
- [x] File upload validation (TODO: implementar)
- [x] Rate limiting (login)
- [x] File upload validation (extensión + firma básica + tamaño + streaming)
- [x] XSS protection (headers en nginx)
## Próximos pasos

View File

@@ -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
View 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

View File

@@ -1,4 +1,5 @@
from fastapi import Depends, HTTPException, status
from starlette.requests import Request
from fastapi.security import OAuth2PasswordBearer
from jose import jwt, JWTError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -9,15 +10,15 @@ from app.core.database import get_db
from app.core.security import security
from app.core.config import get_settings
from app.models.user import User, UserRole
from app.models.tenant import Tenant
settings = get_settings()
# Define OAuth2 scheme here or import from auth if needed.
# Defining here creates a separate instance which is fine as they share config.
# Ideally auth.py should import from here, but modifying auth.py is risky now.
# Esquema OAuth2 centralizado — auth.py importa desde aquí
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/{settings.API_VERSION}/auth/login")
async def get_current_user(
request: Request,
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db)
) -> User:
@@ -44,6 +45,15 @@ async def get_current_user(
if not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
# Enforce that tenant header (if present) matches the authenticated user's tenant.
# Prevents cross-tenant header impersonation.
request_tenant_id = getattr(getattr(request, "state", None), "tenant_id", None)
if request_tenant_id and str(user.tenant_id) != str(request_tenant_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Tenant header does not match authenticated user",
)
return user
@@ -55,3 +65,20 @@ async def get_current_active_superuser(
status_code=403, detail="The user doesn't have enough privileges"
)
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

View 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",
]

View 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)

View 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

View 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

View 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)

View 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"

View 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)

View 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]

View 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)

View 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)

View 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)

View 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)

View 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

View 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
)

File diff suppressed because it is too large Load Diff

View File

@@ -4,11 +4,11 @@ Authentication Endpoints - ServiceManagerWeb
Endpoints para autenticación y autorización
"""
from fastapi import APIRouter, HTTPException, status, Depends
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from fastapi import APIRouter, HTTPException, status, Depends, Request
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel, EmailStr
from sqlalchemy.orm import selectinload
from typing import Optional
import structlog
@@ -17,47 +17,21 @@ from app.core.security import security
from app.core.config import get_settings
from app.models.user import User
from app.models.tenant import Tenant
from app.services.audit_service import AuditService
from app.services.token_service import TokenService
from app.api.deps import oauth2_scheme, get_current_user
from app.core.cache import cache, cache_key
from app.api.schemas.auth import (
LoginRequest, LoginResponse, RefreshTokenRequest, TokenResponse,
TwoFactorStatusResponse, TwoFactorSetupResponse,
TwoFactorEnableRequest, TwoFactorEnableResponse, TwoFactorDisableRequest,
ChangePasswordRequest, ForgotPasswordRequest, ResetPasswordRequest,
)
router = APIRouter()
logger = structlog.get_logger(__name__)
settings = get_settings()
# OAuth2 scheme
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/{settings.API_VERSION}/auth/login")
# ===================================
# PYDANTIC SCHEMAS
# ===================================
class LoginRequest(BaseModel):
"""Schema for login request."""
email: EmailStr
password: str
tenant_slug: str
totp_code: Optional[str] = None
class LoginResponse(BaseModel):
"""Schema for login response."""
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int
user: dict
class RefreshTokenRequest(BaseModel):
"""Schema for refresh token request."""
refresh_token: str
class TokenResponse(BaseModel):
"""Schema for token response."""
access_token: str
token_type: str = "bearer"
expires_in: int
# ===================================
# ENDPOINTS
@@ -66,6 +40,7 @@ class TokenResponse(BaseModel):
@router.post("/login", response_model=LoginResponse)
async def login(
login_data: LoginRequest,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""
@@ -86,34 +61,138 @@ async def login(
email=login_data.email,
tenant_slug=login_data.tenant_slug
)
# Rate limiting (best-effort): by IP before any tenant/user lookup.
if settings.RATE_LIMIT_ENABLED and not settings.TESTING:
client_ip = request.client.host if request.client else "unknown"
ip_key = cache_key("rl", "login", "ip", client_ip)
ip_count = await cache.incr(ip_key, 1)
if ip_count == 1:
await cache.expire(ip_key, settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)
if ip_count is not None and ip_count > settings.LOGIN_RATE_LIMIT_IP_MAX_ATTEMPTS:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many login attempts. Try again later.",
headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)},
)
# 1. Buscar usuario en base de datos
query = select(User).where(User.email == login_data.email)
# 1. Validar tenant
tenant_result = await db.execute(
select(Tenant).where(Tenant.slug == login_data.tenant_slug)
)
tenant = tenant_result.scalar_one_or_none()
if tenant is None:
logger.warning(
"Login failed - tenant not found",
email=login_data.email,
tenant_slug=login_data.tenant_slug,
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found",
)
# Rate limiting (best-effort): by (tenant,email) to slow brute force.
ident_key = None
if settings.RATE_LIMIT_ENABLED and not settings.TESTING:
email_norm = login_data.email.strip().lower()
ident_key = cache_key("rl", "login", "id", str(tenant.id), email_norm)
ident_count = await cache.incr(ident_key, 1)
if ident_count == 1:
await cache.expire(ident_key, settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)
if ident_count is not None and ident_count > settings.LOGIN_RATE_LIMIT_ID_MAX_ATTEMPTS:
try:
await AuditService.log(
db=db,
tenant_id=tenant.id,
user_id=None,
action="user.login_rate_limited",
resource_type="user",
resource_id=None,
metadata={
"email": email_norm,
"tenant_slug": login_data.tenant_slug,
"ip": request.client.host if request.client else None,
"scope": "tenant_email",
"window_seconds": settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS,
"max_attempts": settings.LOGIN_RATE_LIMIT_ID_MAX_ATTEMPTS,
},
request=request,
)
await db.commit()
except Exception as e:
logger.warning("Failed to log rate limit audit entry", error=str(e))
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many login attempts. Try again later.",
headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)},
)
# 2. Buscar usuario en base de datos (aislado por tenant)
query = select(User).where(
User.email == login_data.email,
User.tenant_id == tenant.id,
)
result = await db.execute(query)
user = result.scalar_one_or_none()
# 2. Verificar usuario y contraseña
# 3. Verificar usuario y contraseña
if not user or not security.verify_password(login_data.password, user.password_hash):
logger.warning(
"Login failed - invalid credentials",
email=login_data.email
)
# Registrar intento fallido en auditoría (si el usuario existe)
if user:
try:
await AuditService.log(
db=db,
tenant_id=user.tenant_id,
user_id=None, # Login fallido = sin user_id
action="user.login_failed",
resource_type="user",
resource_id=user.id,
metadata={"email": login_data.email, "reason": "invalid_password"}
)
await db.commit()
except Exception as e:
logger.warning("Failed to log audit entry", error=str(e))
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Credenciales inválidas"
detail="Invalid credentials",
)
# 3. Verificar si está activo
# 4. Verificar si está activo
if not user.is_active:
logger.warning(
"Login failed - user inactive",
email=login_data.email
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Usuario inactivo"
status_code=status.HTTP_403_FORBIDDEN,
detail="User inactive",
)
# 5. Verificar 2FA si está habilitado
if user.totp_enabled:
if not login_data.totp_code:
# Indicar al frontend que debe pedir el código TOTP
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Se requiere autenticación de dos factores (2FA). Ingresa tu código."
)
if not security.verify_totp(user.totp_secret, login_data.totp_code):
logger.warning("Login failed - invalid 2FA code", email=login_data.email)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Código 2FA inválido o expirado"
)
# Create tokens
token_data = {
"sub": str(user.id),
@@ -124,6 +203,39 @@ async def login(
access_token = security.create_access_token(token_data)
refresh_token = security.create_refresh_token(token_data)
# Persist refresh token so it can be revoked/validated later
try:
await TokenService.create_refresh_token(
db=db,
user=user,
refresh_token=refresh_token,
user_agent=request.headers.get("user-agent"),
ip_address=request.client.host if request.client else None,
)
await db.commit()
except Exception as e:
# If persistence fails, do not leak tokens
logger.error("Failed to persist refresh token", error=str(e), user_id=str(user.id))
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Service temporarily unavailable",
)
# Registrar login exitoso en auditoría
try:
await AuditService.log(
db=db,
tenant_id=user.tenant_id,
user_id=user.id,
action="user.login",
resource_type="user",
resource_id=user.id,
metadata={"email": user.email, "success": True}
)
await db.commit()
except Exception as e:
logger.warning("Failed to log audit entry", error=str(e))
logger.info(
"Login successful",
@@ -131,6 +243,10 @@ async def login(
tenant_slug=login_data.tenant_slug,
user_id=str(user.id)
)
# Best-effort: clear per-identity limiter on success.
if ident_key:
await cache.delete(ident_key)
return LoginResponse(
access_token=access_token,
@@ -179,7 +295,20 @@ async def refresh_token(
detail="Invalid refresh token"
)
# TODO: Check if refresh token exists in database and is not revoked
# Check token exists in database and is not revoked/expired
db_token = await TokenService.verify_refresh_token(db=db, refresh_token=refresh_data.refresh_token)
if db_token is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
)
# Defensive: ensure DB token belongs to same subject
if str(db_token.user_id) != str(payload.get("sub")):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
)
# Create new access token
token_data = {
@@ -224,7 +353,38 @@ async def logout(
detail="Invalid token"
)
# TODO: Revoke refresh token in database
# Revoke all active refresh tokens for this user (logout invalidates refresh)
try:
import uuid
user_id = uuid.UUID(payload["sub"])
await TokenService.revoke_all_user_tokens(
db=db,
user_id=user_id,
revoked_by_user_id=user_id,
)
await db.commit()
except Exception as e:
logger.warning("Failed to revoke refresh tokens on logout", error=str(e))
# Registrar logout en auditoría
try:
import uuid
user_id = uuid.UUID(payload["sub"])
tenant_id = uuid.UUID(payload["tenant_id"])
await AuditService.log(
db=db,
tenant_id=tenant_id,
user_id=user_id,
action="user.logout",
resource_type="user",
resource_id=user_id,
metadata={"email": payload.get("email")}
)
await db.commit()
except Exception as e:
logger.warning("Failed to log audit entry", error=str(e))
logger.info("Logout successful", user_id=payload["sub"])
@@ -257,41 +417,393 @@ async def get_current_user(
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 {
"id": payload["sub"],
"email": payload["email"],
"role": payload["role"],
"tenant_id": payload["tenant_id"]
"id": str(user.id),
"email": user.email,
"first_name": user.first_name,
"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 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.
Args:
token: Access token
Consultar si el 2FA está habilitado para el usuario actual.
Returns:
Current user data
Raises:
HTTPException: If token is invalid or user is inactive
Estado de 2FA del usuario autenticado.
"""
payload = security.verify_token(token)
if not payload:
return TwoFactorStatusResponse(enabled=bool(current_user.totp_enabled))
@router.post("/2fa/setup", response_model=TwoFactorSetupResponse)
async def setup_2fa(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Iniciar configuración de 2FA: genera un nuevo TOTP secret y QR URI.
El secret se guarda en BD pero 2FA NO se activa todavía.
Se necesita llamar a /2fa/enable con un código válido para activarlo.
Returns:
Secret y QR URI para escanear con la app autenticadora.
"""
new_secret = security.generate_totp_secret()
qr_uri = security.generate_totp_uri(new_secret, current_user.email)
# Guardar el secret (sin habilitar aún)
current_user.totp_secret = new_secret
await db.commit()
logger.info("2FA setup initiated", user_id=str(current_user.id))
return TwoFactorSetupResponse(secret=new_secret, qr_uri=qr_uri)
@router.post("/2fa/enable", response_model=TwoFactorEnableResponse)
async def enable_2fa(
data: TwoFactorEnableRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Activar 2FA verificando que el usuario escaneó correctamente el QR.
Requiere que /2fa/setup haya sido llamado previamente.
Args:
data: Código TOTP generado por la app autenticadora.
Returns:
Confirmación y lista de códigos de respaldo.
"""
if not current_user.totp_secret:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"},
status_code=status.HTTP_400_BAD_REQUEST,
detail="Primero inicia el proceso de configuración con /2fa/setup"
)
# TODO: Verify user exists and is active
return payload
if not security.verify_totp(current_user.totp_secret, data.totp_code):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Código TOTP inválido. Verifica la hora de tu dispositivo e intenta de nuevo."
)
# Activar 2FA y generar códigos de respaldo
backup_codes = security.generate_backup_codes()
current_user.totp_enabled = True
current_user.backup_codes = backup_codes
await db.commit()
await AuditService.log(
db=db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="user.2fa_enabled",
resource_type="user",
resource_id=current_user.id,
)
await db.commit()
logger.info("2FA enabled", user_id=str(current_user.id))
return TwoFactorEnableResponse(enabled=True, backup_codes=backup_codes)
@router.post("/2fa/disable")
async def disable_2fa(
data: TwoFactorDisableRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Deshabilitar 2FA verificando con código TOTP o código de respaldo.
Args:
data: totp_code o backup_code para verificar identidad.
Returns:
Mensaje de confirmación.
"""
if not current_user.totp_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="El 2FA no está habilitado en esta cuenta"
)
# Verificar con TOTP o código de respaldo
verified = False
if data.totp_code:
verified = security.verify_totp(current_user.totp_secret, data.totp_code)
elif data.backup_code and current_user.backup_codes:
if data.backup_code in current_user.backup_codes:
verified = True
# Invalidar el código de respaldo usado
current_user.backup_codes = [
c for c in current_user.backup_codes if c != data.backup_code
]
if not verified:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Verificación fallida. Proporciona un código TOTP o un código de respaldo válido."
)
# Deshabilitar 2FA
current_user.totp_enabled = False
current_user.totp_secret = None
current_user.backup_codes = None
await db.commit()
await AuditService.log(
db=db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="user.2fa_disabled",
resource_type="user",
resource_id=current_user.id,
)
await db.commit()
logger.info("2FA disabled", user_id=str(current_user.id))
return {"message": "Autenticación de dos factores deshabilitada correctamente"}
@router.post("/change-password", status_code=status.HTTP_200_OK)
async def change_password(
data: ChangePasswordRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Cambiar la contraseña del usuario autenticado.
Verifica la contraseña actual antes de actualizar.
Requiere autenticación activa.
"""
from datetime import datetime
# Validar longitud mínima
if len(data.new_password) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="La nueva contraseña debe tener al menos 8 caracteres"
)
# Verificar que la contraseña actual sea correcta
if not security.verify_password(data.current_password, current_user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="La contraseña actual es incorrecta"
)
# No permitir que la nueva sea igual a la actual
if security.verify_password(data.new_password, current_user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="La nueva contraseña no puede ser igual a la actual"
)
current_user.password_hash = security.hash_password(data.new_password)
current_user.updated_at = datetime.utcnow()
await db.commit()
await AuditService.log(
db=db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="user.password_changed",
resource_type="user",
resource_id=current_user.id,
)
await db.commit()
logger.info("Password changed", user_id=str(current_user.id))
return {"message": "Contraseña actualizada correctamente"}
# ============================================================
# Recuperación de contraseña (forgot / reset)
# ============================================================
_RESET_TOKEN_TTL = 1800 # 30 minutos en segundos
_RESET_KEY_PREFIX = "pwd_reset:"
@router.post("/forgot-password", status_code=status.HTTP_200_OK)
async def forgot_password(
data: ForgotPasswordRequest,
db: AsyncSession = Depends(get_db),
):
"""
Solicitar reseteo de contraseña.
Siempre retorna 200 aunque el email no exista, para no revelar
si una dirección está registrada en el sistema.
"""
import secrets
from redis.asyncio import from_url as redis_from_url
from app.core.email import send_email, build_password_reset_email
# Buscar usuario activo con ese email
result = await db.execute(
select(User).where(
User.email == data.email,
User.is_active == True, # noqa: E712
).limit(1)
)
user = result.scalar_one_or_none()
if not user:
# Respuesta idéntica — no revelar existencia
logger.info("Forgot password: email not found", email=data.email)
return {"message": "Si el correo está registrado recibirás un enlace en breve."}
# Generar token seguro
token = secrets.token_urlsafe(32)
redis_key = f"{_RESET_KEY_PREFIX}{token}"
# Guardar en Redis con TTL de 30 min
redis = redis_from_url(settings.REDIS_URL, decode_responses=True)
try:
await redis.setex(redis_key, _RESET_TOKEN_TTL, str(user.id))
finally:
await redis.aclose()
# Construir URL y enviar email
reset_url = f"{settings.CLIENT_FRONTEND_URL}/reset-password?token={token}"
user_name = f"{user.first_name} {user.last_name}".strip() or user.email
html, text = build_password_reset_email(reset_url, user_name)
await send_email(
to_email=user.email,
subject="Restablece tu contraseña — ServiceManager",
html_content=html,
text_content=text,
)
await AuditService.log(
db=db,
tenant_id=user.tenant_id,
user_id=user.id,
action="user.password_reset_requested",
resource_type="user",
resource_id=user.id,
new_values={"email": user.email},
)
await db.commit()
logger.info("Password reset email sent", user_id=str(user.id))
return {"message": "Si el correo está registrado recibirás un enlace en breve."}
@router.post("/reset-password", status_code=status.HTTP_200_OK)
async def reset_password(
data: ResetPasswordRequest,
db: AsyncSession = Depends(get_db),
):
"""
Aplicar nueva contraseña usando el token recibido por email.
El token es de un solo uso: se elimina de Redis al usarse.
"""
from datetime import datetime
from redis.asyncio import from_url as redis_from_url
import uuid
if len(data.new_password) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="La contraseña debe tener al menos 8 caracteres"
)
redis_key = f"{_RESET_KEY_PREFIX}{data.token}"
redis = redis_from_url(settings.REDIS_URL, decode_responses=True)
try:
user_id_str = await redis.get(redis_key)
if not user_id_str:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="El enlace de reseteo es inválido o ya expiró. Solicita uno nuevo."
)
# Eliminar token inmediatamente (un solo uso)
await redis.delete(redis_key)
finally:
await redis.aclose()
# Buscar y actualizar usuario
user = await db.get(User, uuid.UUID(user_id_str))
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Usuario no encontrado o inactivo"
)
user.password_hash = security.hash_password(data.new_password)
user.updated_at = datetime.utcnow()
await db.commit()
await AuditService.log(
db=db,
tenant_id=user.tenant_id,
user_id=user.id,
action="user.password_reset_completed",
resource_type="user",
resource_id=user.id,
)
await db.commit()
logger.info("Password reset completed", user_id=str(user.id))
return {"message": "Contraseña actualizada correctamente. Ya puedes iniciar sesión."}

View File

@@ -1,55 +1,267 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel, ConfigDict
from typing import List, Optional
from datetime import datetime
import uuid
from app.core.database import get_db
from app.core.cache import cache, cache_key
from app.models.category import Category
from app.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()
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
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)
# ===================================
# ENDPOINTS
# ===================================
@router.get("/", response_model=List[CategoryResponse])
async def read_categories(
skip: int = 0,
limit: int = 100,
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)
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(
category: CategoryCreate,
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)
await db.commit()
await db.refresh(db_category)
# Invalidar caché de categorías para este tenant
await cache.delete_pattern(f"categories:tenant:{current_user.tenant_id}:*")
# Registrar creación en auditoría
try:
await AuditService.log(
db=db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="category.create",
resource_type="category",
resource_id=db_category.id,
new_values={
"name": db_category.name,
"sla_response_hours": db_category.sla_response_hours,
"sla_resolution_hours": db_category.sla_resolution_hours,
"is_active": db_category.is_active
}
)
await db.commit()
except Exception:
pass # No fallar si falla el audit log
return db_category
@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

View 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

View File

@@ -0,0 +1,761 @@
"""
Reports Endpoints - ServiceManagerWeb
Módulo de reportes y estadísticas del sistema.
Accesible por ADMIN y SUPPORT_MANAGER.
"""
from fastapi import APIRouter, Depends, Query, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, case, text
from typing import Optional, List
from datetime import datetime, timedelta, timezone
import uuid
from app.core.database import get_db
from app.api.deps import get_current_user
from app.models.user import User, UserRole
from app.models.ticket import Ticket, TicketStatus, TicketPriority
from app.models.category import Category
from app.models.system import System
from app.models.tenant import Tenant, TenantStatus
from app.api.schemas.reports import (
ReportSummaryResponse,
TicketsByStatus,
TicketsByPriority,
AgentReportResponse,
AgentReportRow,
CategoryReportResponse,
CategoryReportRow,
ClientReportResponse,
ClientReportRow,
TrendsReportResponse,
TrendDataPoint,
CSATReportResponse,
CSATDistribution,
SystemReportResponse,
SystemReportRow,
)
router = APIRouter()
CLOSED_STATUSES = {TicketStatus.RESOLVED, TicketStatus.CLOSED}
# ===================================
# HELPERS
# ===================================
def require_reports_access(current_user: User = Depends(get_current_user)) -> User:
"""ADMIN, SUPPORT_MANAGER y AUDITOR pueden leer reportes."""
allowed = [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AUDITOR]
if current_user.role not in allowed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Solo ADMIN, SUPPORT_MANAGER y AUDITOR pueden acceder a los reportes.",
)
return current_user
def require_admin(current_user: User = Depends(get_current_user)) -> User:
"""Solo ADMIN puede ver reportes entre tenants."""
if current_user.role != UserRole.ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Solo ADMIN puede ver reportes de todos los clientes.",
)
return current_user
def _period_dates(days: int) -> tuple[datetime, datetime]:
"""Devuelve (inicio, fin) del período solicitado en UTC."""
end = datetime.now(timezone.utc)
start = end - timedelta(days=days)
return start, end
# ===================================
# 1. RESUMEN GENERAL
# ===================================
@router.get("/summary", response_model=ReportSummaryResponse)
async def get_report_summary(
days: int = Query(default=30, ge=1, le=365, description="Días hacia atrás del período"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_reports_access),
):
"""
Resumen ejecutivo del período seleccionado.
Incluye:
- Total de tickets creados
- Tickets abiertos vs resueltos
- Tiempo promedio de resolución
- Calificación promedio (CSAT)
- Desglose por estado y prioridad
- Comparación con el período anterior
"""
period_start, period_end = _period_dates(days)
prev_start = period_start - timedelta(days=days)
tenant_filter = Ticket.tenant_id == current_user.tenant_id
# ── Conteos por estado ──
status_rows = (await db.execute(
select(Ticket.status, func.count(Ticket.id).label("cnt"))
.where(and_(tenant_filter, Ticket.created_at >= period_start))
.group_by(Ticket.status)
)).all()
by_status = TicketsByStatus()
for row in status_rows:
s = row.status.value if hasattr(row.status, "value") else str(row.status)
setattr(by_status, s.lower(), row.cnt)
by_status.total = sum(
[by_status.new, by_status.triage, by_status.in_progress,
by_status.waiting_customer, by_status.resolved, by_status.closed, by_status.reopened]
)
# ── Conteos por prioridad ──
priority_rows = (await db.execute(
select(Ticket.priority, func.count(Ticket.id).label("cnt"))
.where(and_(tenant_filter, Ticket.created_at >= period_start))
.group_by(Ticket.priority)
)).all()
by_priority = TicketsByPriority()
for row in priority_rows:
p = row.priority.value if hasattr(row.priority, "value") else str(row.priority)
setattr(by_priority, p.lower(), row.cnt)
by_priority.total = sum([by_priority.low, by_priority.medium, by_priority.high, by_priority.urgent])
total_tickets = by_status.total
resolved_tickets = by_status.resolved + by_status.closed
open_tickets = total_tickets - resolved_tickets
# ── Promedio de tiempo de resolución (segundos → horas) ──
res_time_row = (await db.execute(
select(func.avg(
func.extract("epoch", Ticket.resolved_at - Ticket.created_at)
).label("avg_seconds"))
.where(and_(
tenant_filter,
Ticket.created_at >= period_start,
Ticket.resolved_at.isnot(None),
))
)).scalar_one_or_none()
avg_resolution_hours = round(res_time_row / 3600, 2) if res_time_row else None
# ── Promedio de primera respuesta ──
resp_time_row = (await db.execute(
select(func.avg(
func.extract("epoch", Ticket.first_response_at - Ticket.created_at)
).label("avg_seconds"))
.where(and_(
tenant_filter,
Ticket.created_at >= period_start,
Ticket.first_response_at.isnot(None),
))
)).scalar_one_or_none()
avg_first_response_hours = round(resp_time_row / 3600, 2) if resp_time_row else None
# ── CSAT ──
csat_row = (await db.execute(
select(func.avg(Ticket.rating).label("avg"), func.count(Ticket.rating).label("cnt"))
.where(and_(tenant_filter, Ticket.created_at >= period_start, Ticket.rating.isnot(None)))
)).one()
avg_rating = round(float(csat_row.avg), 2) if csat_row.avg else None
total_rated = csat_row.cnt or 0
# ── Comparación con período anterior ──
prev_total = (await db.execute(
select(func.count(Ticket.id))
.where(and_(tenant_filter, Ticket.created_at >= prev_start, Ticket.created_at < period_start))
)).scalar_one_or_none() or 0
prev_resolved = (await db.execute(
select(func.count(Ticket.id))
.where(and_(
tenant_filter,
Ticket.created_at >= prev_start,
Ticket.created_at < period_start,
Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]),
))
)).scalar_one_or_none() or 0
tickets_change_pct = None
if prev_total > 0:
tickets_change_pct = round(((total_tickets - prev_total) / prev_total) * 100, 1)
resolution_change_pct = None
if prev_total > 0 and total_tickets > 0:
cur_rate = resolved_tickets / total_tickets * 100
prev_rate = prev_resolved / prev_total * 100 if prev_total > 0 else 0
resolution_change_pct = round(cur_rate - prev_rate, 1)
return ReportSummaryResponse(
period_start=period_start,
period_end=period_end,
generated_at=datetime.now(timezone.utc),
total_tickets=total_tickets,
open_tickets=open_tickets,
resolved_tickets=resolved_tickets,
avg_resolution_hours=avg_resolution_hours,
avg_first_response_hours=avg_first_response_hours,
avg_rating=avg_rating,
total_rated=total_rated,
by_status=by_status,
by_priority=by_priority,
tickets_change_pct=tickets_change_pct,
resolution_change_pct=resolution_change_pct,
)
# ===================================
# 2. RENDIMIENTO POR AGENTE
# ===================================
@router.get("/by-agent", response_model=AgentReportResponse)
async def get_report_by_agent(
days: int = Query(default=30, ge=1, le=365),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_reports_access),
):
"""
Rendimiento de cada agente en el período:
- Tickets asignados y resueltos
- Tasa de resolución
- Tiempo promedio de resolución
- Calificación promedio (CSAT)
"""
period_start, period_end = _period_dates(days)
tenant_filter = and_(
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_at >= period_start,
Ticket.assigned_to.isnot(None),
)
# Obtener todos los agentes del tenant
agents_result = await db.execute(
select(User).where(
and_(
User.tenant_id == current_user.tenant_id,
User.role.in_([UserRole.AGENT, UserRole.SUPPORT_MANAGER, UserRole.ADMIN]),
User.is_active == True,
)
)
)
agents = agents_result.scalars().all()
rows: List[AgentReportRow] = []
for agent in agents:
agent_filter = and_(tenant_filter, Ticket.assigned_to == agent.id)
total_assigned = (await db.execute(
select(func.count(Ticket.id)).where(agent_filter)
)).scalar_one_or_none() or 0
if total_assigned == 0:
continue # omitir agentes sin tickets en el período
resolved = (await db.execute(
select(func.count(Ticket.id)).where(
and_(agent_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]))
)
)).scalar_one_or_none() or 0
avg_res_seconds = (await db.execute(
select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at)))
.where(and_(agent_filter, Ticket.resolved_at.isnot(None)))
)).scalar_one_or_none()
csat = (await db.execute(
select(func.avg(Ticket.rating), func.count(Ticket.rating))
.where(and_(agent_filter, Ticket.rating.isnot(None)))
)).one()
urgent_handled = (await db.execute(
select(func.count(Ticket.id)).where(
and_(agent_filter, Ticket.priority == TicketPriority.URGENT)
)
)).scalar_one_or_none() or 0
rows.append(AgentReportRow(
agent_id=str(agent.id),
agent_name=f"{agent.first_name} {agent.last_name}",
agent_email=agent.email,
total_assigned=total_assigned,
resolved=resolved,
open=total_assigned - resolved,
resolution_rate=round((resolved / total_assigned * 100), 1) if total_assigned else 0,
avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None,
avg_rating=round(float(csat[0]), 2) if csat[0] else None,
total_rated=csat[1] or 0,
urgent_handled=urgent_handled,
))
rows.sort(key=lambda r: r.resolved, reverse=True)
return AgentReportResponse(
period_start=period_start,
period_end=period_end,
generated_at=datetime.now(timezone.utc),
agents=rows,
total_agents=len(rows),
)
# ===================================
# 3. TICKETS POR CATEGORÍA
# ===================================
@router.get("/by-category", response_model=CategoryReportResponse)
async def get_report_by_category(
days: int = Query(default=30, ge=1, le=365),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_reports_access),
):
"""
Tickets agrupados por categoría con tasa de cumplimiento SLA.
"""
period_start, _ = _period_dates(days)
period_end = datetime.now(timezone.utc)
tenant_filter = and_(
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_at >= period_start,
)
categories_result = await db.execute(
select(Category).where(
and_(Category.tenant_id == current_user.tenant_id, Category.is_active == True)
)
)
categories = categories_result.scalars().all()
rows: List[CategoryReportRow] = []
for cat in categories:
cat_filter = and_(tenant_filter, Ticket.category_id == cat.id)
total = (await db.execute(
select(func.count(Ticket.id)).where(cat_filter)
)).scalar_one_or_none() or 0
if total == 0:
continue
resolved = (await db.execute(
select(func.count(Ticket.id)).where(
and_(cat_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]))
)
)).scalar_one_or_none() or 0
avg_res_seconds = (await db.execute(
select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at)))
.where(and_(cat_filter, Ticket.resolved_at.isnot(None)))
)).scalar_one_or_none()
# SLA compliance: tickets resueltos ANTES del deadline
sla_met = (await db.execute(
select(func.count(Ticket.id)).where(
and_(
cat_filter,
Ticket.resolved_at.isnot(None),
Ticket.sla_resolution_due.isnot(None),
Ticket.resolved_at <= Ticket.sla_resolution_due,
)
)
)).scalar_one_or_none() or 0
tickets_with_sla = (await db.execute(
select(func.count(Ticket.id)).where(
and_(cat_filter, Ticket.sla_resolution_due.isnot(None), Ticket.resolved_at.isnot(None))
)
)).scalar_one_or_none() or 0
sla_compliance_pct = round((sla_met / tickets_with_sla * 100), 1) if tickets_with_sla else 0.0
rows.append(CategoryReportRow(
category_id=str(cat.id),
category_name=cat.name,
total_tickets=total,
open_tickets=total - resolved,
resolved_tickets=resolved,
avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None,
sla_response_hours=cat.sla_response_hours,
sla_resolution_hours=cat.sla_resolution_hours,
sla_compliance_pct=sla_compliance_pct,
))
# Sin categoría
uncategorized = (await db.execute(
select(func.count(Ticket.id)).where(
and_(tenant_filter, Ticket.category_id.is_(None))
)
)).scalar_one_or_none() or 0
rows.sort(key=lambda r: r.total_tickets, reverse=True)
return CategoryReportResponse(
period_start=period_start,
period_end=period_end,
generated_at=datetime.now(timezone.utc),
categories=rows,
uncategorized_count=uncategorized,
)
# ===================================
# 4. TICKETS POR CLIENTE (solo ADMIN)
# ===================================
@router.get("/by-client", response_model=ClientReportResponse)
async def get_report_by_client(
days: int = Query(default=30, ge=1, le=365),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_admin),
):
"""
Tickets agrupados por cliente (tenant). Solo accesible por ADMIN.
Útil para ver qué clientes generan más trabajo.
"""
period_start, period_end = _period_dates(days)
tenants_result = await db.execute(select(Tenant).where(Tenant.status == TenantStatus.ACTIVE))
tenants = tenants_result.scalars().all()
rows: List[ClientReportRow] = []
for tenant in tenants:
t_filter = and_(
Ticket.tenant_id == tenant.id,
Ticket.created_at >= period_start,
)
total = (await db.execute(
select(func.count(Ticket.id)).where(t_filter)
)).scalar_one_or_none() or 0
if total == 0:
continue
resolved = (await db.execute(
select(func.count(Ticket.id)).where(
and_(t_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]))
)
)).scalar_one_or_none() or 0
urgent = (await db.execute(
select(func.count(Ticket.id)).where(
and_(t_filter, Ticket.priority == TicketPriority.URGENT)
)
)).scalar_one_or_none() or 0
csat_row = (await db.execute(
select(func.avg(Ticket.rating))
.where(and_(t_filter, Ticket.rating.isnot(None)))
)).scalar_one_or_none()
avg_res_seconds = (await db.execute(
select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at)))
.where(and_(t_filter, Ticket.resolved_at.isnot(None)))
)).scalar_one_or_none()
last_ticket = (await db.execute(
select(func.max(Ticket.created_at)).where(t_filter)
)).scalar_one_or_none()
rows.append(ClientReportRow(
tenant_id=str(tenant.id),
tenant_name=tenant.name,
total_tickets=total,
open_tickets=total - resolved,
resolved_tickets=resolved,
urgent_tickets=urgent,
avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None,
avg_rating=round(float(csat_row), 2) if csat_row else None,
last_ticket_at=last_ticket,
))
rows.sort(key=lambda r: r.total_tickets, reverse=True)
return ClientReportResponse(
period_start=period_start,
period_end=period_end,
generated_at=datetime.now(timezone.utc),
clients=rows,
total_clients=len(rows),
)
# ===================================
# 5. TENDENCIAS (TICKETS EN EL TIEMPO)
# ===================================
@router.get("/trends", response_model=TrendsReportResponse)
async def get_report_trends(
days: int = Query(default=30, ge=7, le=90, description="Número de días (7-90)"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_reports_access),
):
"""
Evolución diaria de tickets creados y resueltos.
Útil para detectar picos de trabajo.
"""
period_start, period_end = _period_dates(days)
tenant_filter = Ticket.tenant_id == current_user.tenant_id
# Tickets creados por día
created_rows = (await db.execute(
select(
func.date_trunc("day", Ticket.created_at).label("day"),
func.count(Ticket.id).label("cnt"),
)
.where(and_(tenant_filter, Ticket.created_at >= period_start))
.group_by(func.date_trunc("day", Ticket.created_at))
.order_by(func.date_trunc("day", Ticket.created_at))
)).all()
# Tickets resueltos por día (según resolved_at)
resolved_rows = (await db.execute(
select(
func.date_trunc("day", Ticket.resolved_at).label("day"),
func.count(Ticket.id).label("cnt"),
)
.where(and_(
tenant_filter,
Ticket.resolved_at >= period_start,
Ticket.resolved_at.isnot(None),
))
.group_by(func.date_trunc("day", Ticket.resolved_at))
.order_by(func.date_trunc("day", Ticket.resolved_at))
)).all()
created_map: dict[str, int] = {r.day.strftime("%Y-%m-%d"): r.cnt for r in created_rows}
resolved_map: dict[str, int] = {r.day.strftime("%Y-%m-%d"): r.cnt for r in resolved_rows}
# Un punto por cada día del período
data_points: List[TrendDataPoint] = []
current = period_start
while current <= period_end:
date_str = current.strftime("%Y-%m-%d")
c = created_map.get(date_str, 0)
r = resolved_map.get(date_str, 0)
data_points.append(TrendDataPoint(date=date_str, created=c, resolved=r, net_open=c - r))
current += timedelta(days=1)
return TrendsReportResponse(
period_start=period_start,
period_end=period_end,
generated_at=datetime.now(timezone.utc),
data_points=data_points,
total_days=len(data_points),
)
# ===================================
# 6. SATISFACCIÓN DEL CLIENTE (CSAT)
# ===================================
@router.get("/csat", response_model=CSATReportResponse)
async def get_report_csat(
days: int = Query(default=30, ge=1, le=365),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_reports_access),
):
"""
Reporte de satisfacción del cliente (calificaciones 1-5).
Incluye distribución, promedio por categoría y por agente.
"""
period_start, period_end = _period_dates(days)
tenant_filter = and_(
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_at >= period_start,
)
# Total y promedio general
general = (await db.execute(
select(func.avg(Ticket.rating).label("avg"), func.count(Ticket.rating).label("rated"))
.where(and_(tenant_filter, Ticket.rating.isnot(None)))
)).one()
total_tickets = (await db.execute(
select(func.count(Ticket.id)).where(tenant_filter)
)).scalar_one_or_none() or 0
# Distribución por estrellas
dist_rows = (await db.execute(
select(Ticket.rating, func.count(Ticket.id).label("cnt"))
.where(and_(tenant_filter, Ticket.rating.isnot(None)))
.group_by(Ticket.rating)
)).all()
dist = CSATDistribution()
for row in dist_rows:
setattr(dist, f"rating_{row.rating}", row.cnt)
# Promedio por categoría
cat_rows = (await db.execute(
select(
Category.name.label("cat_name"),
func.avg(Ticket.rating).label("avg"),
func.count(Ticket.rating).label("cnt"),
)
.join(Category, Ticket.category_id == Category.id, isouter=True)
.where(and_(tenant_filter, Ticket.rating.isnot(None)))
.group_by(Category.name)
.order_by(func.avg(Ticket.rating).desc())
)).all()
by_category = [
{
"category": row.cat_name or "Sin categoría",
"avg_rating": round(float(row.avg), 2) if row.avg else None,
"total_rated": row.cnt,
}
for row in cat_rows
]
# Promedio por agente
agent_rows = (await db.execute(
select(
User.first_name.label("fname"),
User.last_name.label("lname"),
func.avg(Ticket.rating).label("avg"),
func.count(Ticket.rating).label("cnt"),
)
.join(User, Ticket.assigned_to == User.id, isouter=True)
.where(and_(tenant_filter, Ticket.rating.isnot(None)))
.group_by(User.first_name, User.last_name)
.order_by(func.avg(Ticket.rating).desc())
)).all()
by_agent = [
{
"agent": f"{row.fname or ''} {row.lname or ''}".strip() or "Sin asignar",
"avg_rating": round(float(row.avg), 2) if row.avg else None,
"total_rated": row.cnt,
}
for row in agent_rows
]
# Últimos comentarios de calificación (rating_comment)
comment_rows = (await db.execute(
select(Ticket.rating, Ticket.rating_comment, Ticket.rated_at)
.where(and_(
tenant_filter,
Ticket.rating.isnot(None),
Ticket.rating_comment.isnot(None),
Ticket.rating_comment != "",
))
.order_by(Ticket.rated_at.desc())
.limit(10)
)).all()
recent_comments = [
{
"rating": row.rating,
"comment": row.rating_comment,
"rated_at": row.rated_at.isoformat() if row.rated_at else None,
}
for row in comment_rows
]
total_rated = general.rated or 0
response_rate = round((total_rated / total_tickets * 100), 1) if total_tickets else 0.0
return CSATReportResponse(
period_start=period_start,
period_end=period_end,
generated_at=datetime.now(timezone.utc),
avg_rating=round(float(general.avg), 2) if general.avg else None,
total_rated=total_rated,
total_tickets=total_tickets,
response_rate=response_rate,
distribution=dist,
by_category=by_category,
by_agent=by_agent,
recent_comments=recent_comments,
)
# ===================================
# 7. TICKETS POR SISTEMA AFECTADO
# ===================================
@router.get("/by-system", response_model=SystemReportResponse)
async def get_report_by_system(
days: int = Query(default=30, ge=1, le=365),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_reports_access),
):
"""
Tickets agrupados por sistema afectado.
Útil para detectar qué sistemas generan más incidentes.
"""
period_start, period_end = _period_dates(days)
tenant_filter = and_(
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_at >= period_start,
)
systems_result = await db.execute(
select(System).where(
and_(System.tenant_id == current_user.tenant_id, System.is_active == True)
)
)
systems = systems_result.scalars().all()
rows: List[SystemReportRow] = []
for sys in systems:
sys_filter = and_(tenant_filter, Ticket.affected_system_id == sys.id)
total = (await db.execute(
select(func.count(Ticket.id)).where(sys_filter)
)).scalar_one_or_none() or 0
if total == 0:
continue
resolved = (await db.execute(
select(func.count(Ticket.id)).where(
and_(sys_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]))
)
)).scalar_one_or_none() or 0
urgent = (await db.execute(
select(func.count(Ticket.id)).where(
and_(sys_filter, Ticket.priority == TicketPriority.URGENT)
)
)).scalar_one_or_none() or 0
avg_res_seconds = (await db.execute(
select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at)))
.where(and_(sys_filter, Ticket.resolved_at.isnot(None)))
)).scalar_one_or_none()
rows.append(SystemReportRow(
system_id=str(sys.id),
system_name=sys.name,
total_tickets=total,
open_tickets=total - resolved,
resolved_tickets=resolved,
urgent_tickets=urgent,
avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None,
))
# Sin sistema asignado
no_system = (await db.execute(
select(func.count(Ticket.id)).where(
and_(tenant_filter, Ticket.affected_system_id.is_(None))
)
)).scalar_one_or_none() or 0
rows.sort(key=lambda r: r.total_tickets, reverse=True)
return SystemReportResponse(
period_start=period_start,
period_end=period_end,
generated_at=datetime.now(timezone.utc),
systems=rows,
no_system_count=no_system,
)

View 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
]
)

View File

@@ -1,53 +1,154 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel, ConfigDict
from typing import List, Optional
from datetime import datetime
import uuid
from app.core.database import get_db
from app.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()
class SystemBase(BaseModel):
name: str
description: Optional[str] = None
is_active: bool = True
class SystemCreate(SystemBase):
pass
class SystemUpdate(SystemBase):
name: Optional[str] = None
description: Optional[str] = None
is_active: Optional[bool] = None
class SystemResponse(SystemBase):
id: uuid.UUID
model_config = ConfigDict(from_attributes=True)
# ===================================
# ENDPOINTS
# ===================================
@router.get("/", response_model=List[SystemResponse])
async def read_systems(
skip: int = 0,
limit: int = 100,
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)
return result.scalars().all()
@router.post("/", response_model=SystemResponse)
@router.post("/", response_model=SystemResponse, status_code=status.HTTP_201_CREATED)
async def create_system(
system: SystemCreate,
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)
await db.commit()
await db.refresh(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

View File

@@ -1,38 +1,16 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel, ConfigDict, EmailStr
from typing import List, Optional
import uuid
from app.core.database import get_db
from app.models.tenant import Tenant, TenantStatus
from app.api import deps
from app.api import deps
from app.api.schemas.tenant import TenantBase, TenantCreate, TenantUpdate, TenantResponse
router = APIRouter()
class TenantBase(BaseModel):
name: str
slug: str
domain: Optional[str] = None
contact_email: Optional[EmailStr] = None
class TenantCreate(TenantBase):
pass
class TenantUpdate(BaseModel):
name: Optional[str] = None
slug: Optional[str] = None
domain: Optional[str] = None
contact_email: Optional[EmailStr] = None
status: Optional[TenantStatus] = None
class TenantResponse(TenantBase):
id: uuid.UUID
status: TenantStatus
model_config = ConfigDict(from_attributes=True)
@router.get("/", response_model=List[TenantResponse])
async def read_tenants(
skip: int = 0,
@@ -85,10 +63,32 @@ async def update_tenant(
raise HTTPException(status_code=404, detail="Tenant not found")
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():
setattr(tenant, field, value)
db.add(tenant)
await db.commit()
await db.refresh(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"}

View File

@@ -1,427 +1,478 @@
"""
Tickets endpoints - ServiceManagerWeb
"""
"""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
from datetime import datetime, timedelta
import uuid
from app.core.database import get_db
from app.api.deps import get_current_user
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
from pydantic import BaseModel
import uuid
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
router = APIRouter()
# ===================================
# SCHEMAS
# ===================================
class TicketCreate(BaseModel):
subject: str
description: str
category_id: Optional[str] = None
system_id: Optional[str] = None
priority: str = "MEDIUM"
class TicketUpdate(BaseModel):
subject: Optional[str] = None
description: Optional[str] = None
status: Optional[str] = None
priority: Optional[str] = None
assigned_to: Optional[str] = None
class TicketResponse(BaseModel):
id: str
ticket_number: str
subject: str
description: str
status: str
priority: str
category_id: Optional[str] = None
system_id: Optional[str] = None
created_by: str
assigned_to: Optional[str] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class TicketCloseRequest(BaseModel):
resolution: Optional[str] = None
# ===================================
# TICKET ENDPOINTS
# ===================================
@router.post("/", response_model=TicketResponse, status_code=status.HTTP_201_CREATED)
async def create_ticket(
ticket: TicketCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Crear un nuevo ticket
"""
try:
# Generar número de ticket único
result = await db.execute(
select(func.count(Ticket.id)).where(Ticket.tenant_id == current_user.tenant_id)
)
count = result.scalar() or 0
ticket_number = f"TK-{count + 1:06d}"
# Convertir IDs de string a UUID si son proporcionados
category_uuid = uuid.UUID(ticket.category_id) if ticket.category_id else None
system_uuid = uuid.UUID(ticket.system_id) if ticket.system_id else None
db_ticket = Ticket(
id=uuid.uuid4(),
tenant_id=current_user.tenant_id,
ticket_number=ticket_number,
subject=ticket.subject,
description=ticket.description,
category_id=category_uuid,
system_id=system_uuid,
priority=TicketPriority[ticket.priority.upper()],
created_by=current_user.id,
status=TicketStatus.NEW,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
db.add(db_ticket)
await db.commit()
await db.refresh(db_ticket)
# Convertir a respuesta
return {
"id": str(db_ticket.id),
"ticket_number": db_ticket.ticket_number,
"subject": db_ticket.subject,
"description": db_ticket.description,
"status": db_ticket.status.value,
"priority": db_ticket.priority.value,
"category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
"system_id": str(db_ticket.system_id) if db_ticket.system_id else None,
"created_by": str(db_ticket.created_by),
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
"created_at": db_ticket.created_at,
"updated_at": db_ticket.updated_at
}
except ValueError as e:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid UUID format: {str(e)}"
)
except Exception as e:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Error creating ticket: {str(e)}"
)
async def create_ticket(ticket: TicketCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Crear un nuevo ticket"""
max_retries = 3
last_error = None
for attempt in range(max_retries):
try:
ticket_number = await generate_next_ticket_number(db, current_user.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 db.get(Category, category_uuid)
if not category:
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 db.get(System, system_uuid)
if not system:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"El sistema con ID {ticket.affected_system_id} no existe.")
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=current_user.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=current_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()
)
db.add(db_ticket)
await db.commit()
await db.refresh(db_ticket)
await safe_audit_log(db=db, tenant_id=current_user.tenant_id, user_id=current_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 db.rollback()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid UUID format: {str(e)}")
except HTTPException:
await db.rollback()
raise
except Exception as e:
await 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)}")
@router.get("/", response_model=List[TicketResponse])
async def get_tickets(
skip: int = 0,
limit: int = 100,
status_filter: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Obtener tickets del usuario actual
"""
query = select(Ticket).where(
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_by == current_user.id
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)
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
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)
)
if status_filter:
try:
status_enum = TicketStatus[status_filter.upper()]
query = query.where(Ticket.status == status_enum)
except KeyError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid status: {status_filter}"
)
query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query)
tickets = result.scalars().all()
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 ["ADMIN", "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 == "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(t.id),
"ticket_number": t.ticket_number,
"subject": t.subject,
"description": t.description,
"status": t.status.value,
"priority": t.priority.value,
"category_id": str(t.category_id) if t.category_id else None,
"system_id": str(t.system_id) if t.system_id else None,
"created_by": str(t.created_by),
"assigned_to": str(t.assigned_to) if t.assigned_to else None,
"created_at": t.created_at,
"updated_at": t.updated_at
}
for t in tickets
{"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 específico
"""
try:
ticket_uuid = uuid.UUID(ticket_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid ticket ID format"
)
query = select(Ticket).where(
Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_by == current_user.id
)
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 in ["CLIENT_USER", "CLIENT_ADMIN"]:
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)
ticket = result.scalars().first()
db_ticket = result.scalars().first()
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Ticket {ticket_id} not found"
)
if not db_ticket:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
return {
"id": str(ticket.id),
"ticket_number": ticket.ticket_number,
"subject": ticket.subject,
"description": ticket.description,
"status": ticket.status.value,
"priority": ticket.priority.value,
"category_id": str(ticket.category_id) if ticket.category_id else None,
"system_id": str(ticket.system_id) if ticket.system_id else None,
"created_by": str(ticket.created_by),
"assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None,
"created_at": ticket.created_at,
"updated_at": ticket.updated_at
}
return ticket_to_dict(db_ticket)
@router.patch("/{ticket_id}", response_model=TicketResponse)
async def update_ticket(
ticket_id: str,
ticket_update: TicketUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Actualizar un ticket
"""
try:
ticket_uuid = uuid.UUID(ticket_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid ticket ID format"
)
query = select(Ticket).where(
Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_by == current_user.id
)
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 in ["CLIENT_USER", "CLIENT_ADMIN"]:
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"
)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
try:
update_data = ticket_update.dict(exclude_unset=True)
for field, value in update_data.items():
if field == "status" and value:
setattr(db_ticket, field, TicketStatus[value.upper()])
elif field == "priority" and value:
setattr(db_ticket, field, TicketPriority[value.upper()])
elif field == "assigned_to" and value:
setattr(db_ticket, field, uuid.UUID(value))
else:
setattr(db_ticket, field, value)
db_ticket.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(db_ticket)
return {
"id": str(db_ticket.id),
"ticket_number": db_ticket.ticket_number,
"subject": db_ticket.subject,
"description": db_ticket.description,
"status": db_ticket.status.value,
"priority": db_ticket.priority.value,
"category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
"system_id": str(db_ticket.system_id) if db_ticket.system_id else None,
"created_by": str(db_ticket.created_by),
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
"created_at": db_ticket.created_at,
"updated_at": db_ticket.updated_at
}
except Exception as e:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Error updating ticket: {str(e)}"
)
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
"""
try:
ticket_uuid = uuid.UUID(ticket_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid ticket ID format"
)
query = select(Ticket).where(
Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_by == current_user.id
)
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"
)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
try:
db_ticket.status = TicketStatus.CLOSED
db_ticket.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(db_ticket)
return {
"id": str(db_ticket.id),
"ticket_number": db_ticket.ticket_number,
"subject": db_ticket.subject,
"description": db_ticket.description,
"status": db_ticket.status.value,
"priority": db_ticket.priority.value,
"category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
"system_id": str(db_ticket.system_id) if db_ticket.system_id else None,
"created_by": str(db_ticket.created_by),
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
"created_at": db_ticket.created_at,
"updated_at": db_ticket.updated_at
}
except Exception as e:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Error closing ticket: {str(e)}"
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
]
# ===================================
# COMMENT ENDPOINTS (placeholder)
# ===================================
@router.get("/{ticket_id}/comments")
async def get_ticket_comments(
ticket_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Obtener comentarios de un ticket
"""
return []
@router.post("/{ticket_id}/comments", status_code=status.HTTP_201_CREATED)
async def create_comment(
ticket_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Agregar un comentario a un ticket
"""
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Comments not yet implemented"
@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"}
# ===================================
# ATTACHMENT ENDPOINTS (placeholder)
# ===================================
@router.get("/{ticket_id}/attachments")
async def get_ticket_attachments(
ticket_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Obtener adjuntos de un ticket
"""
return []
@router.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)
):
"""
Subir un archivo adjunto a un ticket
"""
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="File uploads not yet implemented"
)
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)

File diff suppressed because it is too large Load Diff

View File

@@ -1,68 +1,387 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel, ConfigDict, EmailStr
from typing import List, Optional
from datetime import datetime
import uuid
from app.core.database import get_db
from app.core.security import security
from app.models.user import User, UserRole
from app.api import deps
from app.services.audit_service import AuditService
from app.api import deps
from app.api.schemas.user import UserCreate, UserUpdate, UserResponse
router = APIRouter()
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):
id: uuid.UUID
model_config = ConfigDict(from_attributes=True)
@router.get("/me", response_model=UserResponse)
async def read_current_user(
current_user: User = Depends(deps.get_current_user),
):
"""Obtener el perfil del usuario actual."""
return current_user
@router.get("/", response_model=List[UserResponse])
async def read_users(
skip: int = 0,
limit: int = 100,
limit: int = 100,
role: Optional[UserRole] = None,
is_active: Optional[bool] = None,
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
"""
# ✅ CORREGIDO: Filtrar por tenant_id
query = select(User).where(User.tenant_id == current_user.tenant_id)
# 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)
return result.scalars().all()
@router.post("/", response_model=UserResponse)
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def create_user(
user: UserCreate,
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)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email already registered")
user_data = user.model_dump(exclude={"password"})
password_hash = security.get_password_hash(user.password)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered in this tenant"
)
# 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)
await db.commit()
await db.refresh(db_user)
# Registrar creación en auditoría
try:
await AuditService.log(
db=db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="user.create",
resource_type="user",
resource_id=db_user.id,
new_values=AuditService.sanitize_values({
"email": db_user.email,
"first_name": db_user.first_name,
"last_name": db_user.last_name,
"role": db_user.role.value
})
)
await db.commit()
except Exception as e:
# No fallar si falla el audit log
pass
return db_user
@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

View 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)
}

View File

@@ -5,7 +5,8 @@ Router principal para la API v1
"""
from fastapi import APIRouter
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla, reports
api_router = APIRouter()
@@ -51,4 +52,32 @@ 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
View 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

View File

@@ -23,101 +23,103 @@ class Settings(BaseSettings):
# ===================================
# GENERAL
# ===================================
ENVIRONMENT: str = Field(default="development", env="ENVIRONMENT")
DEBUG: bool = Field(default=False, env="DEBUG")
SECRET_KEY: str = Field(..., env="SECRET_KEY")
API_VERSION: str = Field(default="v1", env="API_VERSION")
ENVIRONMENT: str = Field(default="development")
TESTING: bool = Field(default=False)
DEBUG: bool = Field(default=False)
SECRET_KEY: str = Field(...)
API_VERSION: str = Field(default="v1")
APP_VERSION: str = Field(default="1.9.0")
# ===================================
# DATABASE
# ===================================
DATABASE_URL: str = Field(..., env="DATABASE_URL")
DATABASE_URL: str = Field(...)
# ===================================
# REDIS
# ===================================
REDIS_URL: str = Field(..., env="REDIS_URL")
REDIS_URL: str = Field(...)
# ===================================
# JWT AUTHENTICATION
# ===================================
JWT_SECRET_KEY: str = Field(..., env="JWT_SECRET_KEY")
JWT_ALGORITHM: str = Field(default="HS256", env="JWT_ALGORITHM")
ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=60, env="ACCESS_TOKEN_EXPIRE_MINUTES")
REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7, env="REFRESH_TOKEN_EXPIRE_DAYS")
JWT_SECRET_KEY: str = Field(...)
JWT_ALGORITHM: str = Field(default="HS256")
ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=60)
REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7)
# ===================================
# CORS
# ===================================
CORS_ORIGINS: str = Field(
default="http://localhost:3000,http://localhost:3001",
env="CORS_ORIGINS"
default="http://localhost:3000,http://localhost:3001"
)
# ===================================
# EMAIL
# ===================================
SMTP_HOST: str = Field(default="localhost", env="SMTP_HOST")
SMTP_PORT: int = Field(default=587, env="SMTP_PORT")
SMTP_USER: Optional[str] = Field(default=None, env="SMTP_USER")
SMTP_PASSWORD: Optional[str] = Field(default=None, env="SMTP_PASSWORD")
SMTP_USE_TLS: bool = Field(default=True, env="SMTP_USE_TLS")
SMTP_USE_SSL: bool = Field(default=False, env="SMTP_USE_SSL")
SMTP_HOST: str = Field(default="localhost")
SMTP_PORT: int = Field(default=587)
SMTP_USER: Optional[str] = Field(default=None)
SMTP_PASSWORD: Optional[str] = Field(default=None)
SMTP_USE_TLS: bool = Field(default=True)
SMTP_USE_SSL: bool = Field(default=False)
DEFAULT_FROM_EMAIL: str = Field(default="noreply@servicemanager.local", env="DEFAULT_FROM_EMAIL")
DEFAULT_FROM_NAME: str = Field(default="ServiceManager", env="DEFAULT_FROM_NAME")
DEFAULT_FROM_EMAIL: str = Field(default="noreply@servicemanager.local")
DEFAULT_FROM_NAME: str = Field(default="ServiceManager")
# ===================================
# FILE UPLOADS
# ===================================
MAX_UPLOAD_SIZE_MB: int = Field(default=10, env="MAX_UPLOAD_SIZE_MB")
ALLOWED_FILE_EXTENSIONS: List[str] = Field(
default=["pdf", "jpg", "jpeg", "png", "doc", "docx", "xls", "xlsx", "txt"],
env="ALLOWED_FILE_EXTENSIONS"
MAX_UPLOAD_SIZE_MB: int = Field(default=10)
ALLOWED_FILE_EXTENSIONS_STR: str = Field(
default="pdf,jpg,jpeg,png,doc,docx,xls,xlsx,txt",
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')
@classmethod
def validate_file_extensions(cls, v):
if isinstance(v, str):
return [ext.strip().lower() for ext in v.split(",")]
return [ext.lower() for ext in v]
@property
def ALLOWED_FILE_EXTENSIONS(self) -> List[str]:
"""Parse the comma-separated file extensions."""
return [ext.strip().lower() for ext in self.ALLOWED_FILE_EXTENSIONS_STR.split(",")]
# ===================================
# SECURITY
# ===================================
RATE_LIMIT_ENABLED: bool = Field(default=True, env="RATE_LIMIT_ENABLED")
PASSWORD_MIN_LENGTH: int = Field(default=8, env="PASSWORD_MIN_LENGTH")
RATE_LIMIT_ENABLED: bool = Field(default=True)
LOGIN_RATE_LIMIT_WINDOW_SECONDS: int = Field(default=300)
LOGIN_RATE_LIMIT_IP_MAX_ATTEMPTS: int = Field(default=30)
LOGIN_RATE_LIMIT_ID_MAX_ATTEMPTS: int = Field(default=10)
PASSWORD_MIN_LENGTH: int = Field(default=8)
# Argon2 settings
ARGON2_TIME_COST: int = Field(default=3, env="ARGON2_TIME_COST")
ARGON2_MEMORY_COST: int = Field(default=65536, env="ARGON2_MEMORY_COST")
ARGON2_PARALLELISM: int = Field(default=4, env="ARGON2_PARALLELISM")
ARGON2_TIME_COST: int = Field(default=3)
ARGON2_MEMORY_COST: int = Field(default=65536)
ARGON2_PARALLELISM: int = Field(default=4)
# ===================================
# LOGGING
# ===================================
LOG_LEVEL: str = Field(default="INFO", env="LOG_LEVEL")
LOG_FORMAT: str = Field(default="json", env="LOG_FORMAT")
LOG_FILE: Optional[str] = Field(default=None, env="LOG_FILE")
LOG_LEVEL: str = Field(default="INFO")
LOG_FORMAT: str = Field(default="json")
LOG_FILE: Optional[str] = Field(default=None)
# ===================================
# FRONTEND URLS
# ===================================
CLIENT_FRONTEND_URL: str = Field(default="http://localhost:3000", env="CLIENT_FRONTEND_URL")
INTERNAL_FRONTEND_URL: str = Field(default="http://localhost:3001", env="INTERNAL_FRONTEND_URL")
CLIENT_FRONTEND_URL: str = Field(default="http://localhost:3000")
INTERNAL_FRONTEND_URL: str = Field(default="http://localhost:3001")
# ===================================
# HEALTH CHECKS
# ===================================
HEALTH_CHECK_TIMEOUT: int = Field(default=30, env="HEALTH_CHECK_TIMEOUT")
HEALTH_CHECK_TIMEOUT: int = Field(default=30)
# ===================================
# CELERY
# ===================================
CELERY_BROKER_URL: str = Field(..., env="CELERY_BROKER_URL")
CELERY_RESULT_BACKEND: str = Field(..., env="CELERY_RESULT_BACKEND")
CELERY_BROKER_URL: str = Field(...)
CELERY_RESULT_BACKEND: str = Field(...)
def is_production(self) -> bool:
"""Check if environment is production."""

View File

@@ -6,7 +6,9 @@ SQLAlchemy 2.0 async setup con PostgreSQL
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import String, DateTime, func
from sqlalchemy import String, DateTime, func, text
from sqlalchemy.types import TypeDecorator, CHAR
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from typing import AsyncGenerator
import uuid
from datetime import datetime
@@ -19,10 +21,11 @@ settings = get_settings()
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
pool_size=5,
max_overflow=10,
pool_size=20, # Increased for better concurrency
max_overflow=30, # Increased for peak loads
pool_pre_ping=True, # Verify connections before use
pool_recycle=3600, # Recycle connections after 1 hour
pool_timeout=30, # Wait up to 30s for connection from pool
)
# Create session factory
@@ -33,18 +36,46 @@ AsyncSessionLocal = async_sessionmaker(
autoflush=True,
autocommit=False
)
class GUID(TypeDecorator):
"""UUID portable: UUID nativo en Postgres, CHAR(36) en otros dialectos (SQLite para tests)."""
impl = CHAR
cache_ok = True
def load_dialect_impl(self, dialect):
if dialect.name == "postgresql":
return dialect.type_descriptor(PG_UUID(as_uuid=True))
return dialect.type_descriptor(CHAR(36))
def process_bind_param(self, value, dialect):
if value is None:
return None
if dialect.name == "postgresql":
return value
if isinstance(value, uuid.UUID):
return str(value)
return str(uuid.UUID(str(value)))
def process_result_value(self, value, dialect):
if value is None:
return None
if isinstance(value, uuid.UUID):
return value
return uuid.UUID(str(value))
class Base(DeclarativeBase):
"""Base class para todos los modelos SQLAlchemy."""
# Columnas comunes para auditoría
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now()
onupdate=func.now(),
)
@@ -88,7 +119,7 @@ async def check_database_health() -> bool:
"""
try:
async with AsyncSessionLocal() as session:
await session.execute("SELECT 1")
await session.execute(text("SELECT 1"))
return True
except Exception:
return False

179
backend/app/core/email.py Normal file
View 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;">
&copy; 2026 Aduanasoft &mdash; 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

View 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()

View File

@@ -13,6 +13,7 @@ import pyotp
import secrets
import base64
import struct
import uuid
from app.core.config import get_settings
@@ -100,7 +101,8 @@ class SecurityUtils:
"""
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "type": "refresh"})
# Add a unique identifier so refresh tokens are never deterministic.
to_encode.update({"exp": expire, "type": "refresh", "jti": str(uuid.uuid4())})
encoded_jwt = jwt.encode(
to_encode,

View File

@@ -21,11 +21,16 @@ from app.models.system import System
from app.models.category import Category
from app.models.user import User
from app.models.ticket import Ticket
from app.models.comment import TicketComment
from app.models.attachment import TicketAttachment
from app.models.audit import AuditLog
from app.models.refresh_token import RefreshToken
from app.core.logging import setup_logging
from app.api.v1.router import api_router
from app.middleware.tenant import TenantMiddleware
from app.middleware.correlation_id import CorrelationIDMiddleware
from app.core.cache import cache
settings = get_settings()
setup_logging()
@@ -38,6 +43,10 @@ async def lifespan(app: FastAPI):
# Startup
logger.info("Iniciando ServiceManagerWeb Backend", version=settings.API_VERSION)
# Conectar a Redis cache
await cache.connect()
logger.info("Caché Redis conectado")
if settings.ENVIRONMENT == "development":
await create_tables()
logger.info("Tablas de base de datos verificadas")
@@ -46,13 +55,15 @@ async def lifespan(app: FastAPI):
# Shutdown
logger.info("Cerrando ServiceManagerWeb Backend")
await cache.disconnect()
logger.info("Caché Redis desconectado")
# Crear aplicación FastAPI
app = FastAPI(
title="ServiceManagerWeb API",
description="Mesa de Ayuda B2B multi-tenant para Aduanasoft",
version=settings.API_VERSION,
version=settings.APP_VERSION,
lifespan=lifespan,
docs_url=f"/{settings.API_VERSION}/docs" if settings.ENVIRONMENT == "development" else None,
redoc_url=f"/{settings.API_VERSION}/redoc" if settings.ENVIRONMENT == "development" else None,
@@ -65,12 +76,26 @@ app = FastAPI(
# CORS
cors_origins = settings.CORS_ORIGINS.split(",") if isinstance(settings.CORS_ORIGINS, str) else settings.CORS_ORIGINS
if settings.is_production():
cors_allow_methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
cors_allow_headers = [
"Authorization",
"Content-Type",
"X-Tenant-ID",
"X-Tenant-Slug",
"X-Correlation-ID",
]
else:
cors_allow_methods = ["*"]
cors_allow_headers = ["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_methods=cors_allow_methods,
allow_headers=cors_allow_headers,
)
# Compression
@@ -159,7 +184,8 @@ async def health_check():
return {
"status": "healthy",
"service": "ServiceManagerWeb API",
"version": settings.API_VERSION,
"version": settings.APP_VERSION,
"api_version": settings.API_VERSION,
"environment": settings.ENVIRONMENT
}
@@ -170,7 +196,8 @@ async def root():
"""Endpoint raíz con información básica."""
return {
"service": "ServiceManagerWeb API",
"version": settings.API_VERSION,
"version": settings.APP_VERSION,
"api_version": settings.API_VERSION,
"docs": f"/{settings.API_VERSION}/docs",
"environment": settings.ENVIRONMENT
}
@@ -198,4 +225,4 @@ if __name__ == "__main__":
host="0.0.0.0",
port=8000,
reload=settings.ENVIRONMENT == "development"
)
)

View File

@@ -4,69 +4,174 @@ Tenant Middleware - ServiceManagerWeb
Middleware para manejo de multi-tenancy
"""
from fastapi import Request, HTTPException, status
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
from starlette.requests import Request
from starlette.responses import Response, JSONResponse
from sqlalchemy import select
import structlog
import uuid
from app.core.database import AsyncSessionLocal, get_db
from app.core.config import get_settings
from app.models.tenant import Tenant, TenantStatus
logger = structlog.get_logger(__name__)
settings = get_settings()
class TenantMiddleware(BaseHTTPMiddleware):
"""
Middleware para extraer y validar información del tenant.
Extrae el tenant_id del header X-Tenant-ID y lo almacena
en el estado de la request para uso posterior.
Extrae el tenant_id del header X-Tenant-ID o el slug del header
X-Tenant-Slug, valida que exista en la base de datos y que esté
activo, y almacena el objeto Tenant en request.state.tenant.
"""
# Rutas que no requieren tenant
EXCLUDED_PATHS = {
"/health",
"/api/v1/health",
"/v1/health",
"/api/v1/health/detailed",
"/v1/health/detailed",
"/",
"/api/v1/auth/login",
"/v1/auth/login",
"/api/v1/auth/refresh",
"/v1/auth/refresh",
"/api/v1/auth/logout",
"/v1/auth/logout",
"/api/v1/auth/forgot-password",
"/v1/auth/forgot-password",
"/api/v1/auth/reset-password",
"/v1/auth/reset-password",
"/docs",
"/api/v1/docs",
"/v1/docs",
"/openapi.json",
"/redoc"
"/api/v1/openapi.json",
"/v1/openapi.json",
"/redoc",
"/api/v1/redoc",
"/v1/redoc",
}
async def dispatch(self, request: Request, call_next) -> Response:
"""Process request and add tenant information."""
# Skip tenant validation for excluded paths
"""Valida el tenant en cada request y lo almacena en request.state."""
# Inicializar state con valores por defecto
request.state.tenant = None
request.state.tenant_id = None
request.state.tenant_slug = None
# Saltar validación en rutas excluidas
if request.url.path in self.EXCLUDED_PATHS or request.url.path.startswith("/docs"):
return await call_next(request)
# Extract tenant from header
# Extraer headers de tenant
tenant_id = request.headers.get("X-Tenant-ID")
tenant_slug = request.headers.get("X-Tenant-Slug")
# For now, we'll be more permissive in development
# In production, tenant should be strictly required
tenant_uuid: uuid.UUID | None = None
if tenant_id:
try:
tenant_uuid = uuid.UUID(tenant_id)
except ValueError:
return JSONResponse(
status_code=400,
content={"detail": "Invalid X-Tenant-ID header (must be UUID)"},
)
# Si no hay headers de tenant (requerido para aislamiento multi-tenant)
if not tenant_id and not tenant_slug:
logger.warning(
"Request without tenant information",
path=request.url.path,
method=request.method
return JSONResponse(
status_code=400,
content={"detail": "Tenant information required (X-Tenant-ID or X-Tenant-Slug header)"},
)
# For now, continue without tenant for development
# raise HTTPException(
# status_code=status.HTTP_400_BAD_REQUEST,
# detail="Tenant information required (X-Tenant-ID or X-Tenant-Slug header)"
# )
# Store tenant info in request state
request.state.tenant_id = tenant_id
request.state.tenant_slug = tenant_slug
# TODO: Validate tenant exists and is active
# This would involve a database query which we'll implement later
logger.debug(
"Tenant middleware processed",
tenant_id=tenant_id,
tenant_slug=tenant_slug,
path=request.url.path
)
# Validar tenant contra la base de datos
try:
# Prefer DB session coming from dependency overrides (tests) when available.
# Guard: in unit tests request.app may be a MagicMock, not a real FastAPI app.
dependency_overrides = getattr(request.app, "dependency_overrides", None)
override_get_db = None
if isinstance(dependency_overrides, dict):
override_get_db = dependency_overrides.get(get_db)
if override_get_db is not None:
agen = override_get_db()
session = await agen.__anext__()
try:
if tenant_uuid is not None:
result = await session.execute(
select(Tenant).where(Tenant.id == tenant_uuid)
)
else:
result = await session.execute(
select(Tenant).where(Tenant.slug == tenant_slug)
)
tenant = result.scalars().first()
finally:
await agen.aclose()
else:
async with AsyncSessionLocal() as session:
if tenant_uuid is not None:
result = await session.execute(
select(Tenant).where(Tenant.id == tenant_uuid)
)
else:
result = await session.execute(
select(Tenant).where(Tenant.slug == tenant_slug)
)
tenant = result.scalars().first()
if tenant is None:
logger.warning(
"Tenant not found",
tenant_id=tenant_id,
tenant_slug=tenant_slug,
path=request.url.path,
)
return JSONResponse(
status_code=404,
content={"detail": "Tenant not found"}
)
if tenant.status != TenantStatus.ACTIVE:
logger.warning(
"Tenant is not active",
tenant_id=str(tenant.id),
tenant_slug=tenant.slug,
status=tenant.status,
path=request.url.path,
)
return JSONResponse(
status_code=403,
content={"detail": f"Tenant is {tenant.status.value}"}
)
# Almacenar tenant validado en el state
request.state.tenant = tenant
request.state.tenant_id = str(tenant.id)
request.state.tenant_slug = tenant.slug
logger.debug(
"Tenant validated",
tenant_id=str(tenant.id),
tenant_slug=tenant.slug,
path=request.url.path,
)
except Exception as exc:
logger.error(
"Error validating tenant",
error=str(exc),
path=request.url.path,
)
return JSONResponse(
status_code=503,
content={"detail": "Service temporarily unavailable"}
)
return await call_next(request)

View 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"
]

View 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
View 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

View File

@@ -1,28 +1,49 @@
"""
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.dialects.postgresql import UUID
from typing import List, Optional
import uuid
from app.core.database import Base
from app.core.database import Base, GUID
class Category(Base):
__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)
description: Mapped[Optional[str]] = mapped_column(Text)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# Optional: Tenant specific categories?
tenant_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True)
# ✅ CORREGIDO: tenant_id es obligatorio para multi-tenancy
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
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:
return f"<Category(id={self.id}, name='{self.name}')>"
return f"<Category(id={self.id}, name='{self.name}', tenant_id={self.tenant_id})>"

View 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"

View 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}>"

View 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

View File

View File

@@ -1,25 +1,42 @@
"""
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 typing import List, Optional
import uuid
from app.core.database import Base
from app.core.database import Base, GUID
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)
description: Mapped[Optional[str]] = mapped_column(Text)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=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
# If we want tickets to link to systems, we will add relationship in Ticket later or now.
# We will assume Ticket links to System.
tickets: Mapped[List["Ticket"]] = relationship("Ticket", back_populates="system")
# ✅ ACTUALIZADO: nombre de relación a affected_system
tickets: Mapped[List["Ticket"]] = relationship(
"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:
return f"<System(id={self.id}, name='{self.name}')>"
return f"<System(id={self.id}, name='{self.name}', tenant_id={self.tenant_id})>"

View File

@@ -1,29 +1,24 @@
"""
Tenant Model - ServiceManagerWeb
Modelo para organizaciones cliente (multi-tenancy)
"""
from sqlalchemy import String, Integer, Text, Boolean, ARRAY
from sqlalchemy import String, Integer, Text, Boolean, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, ENUM
from sqlalchemy.dialects.postgresql import UUID, ENUM, ARRAY as PG_ARRAY
from typing import List, Optional
import enum
import uuid
from app.core.database import Base
class TenantStatus(str, enum.Enum):
"""Estados de un tenant."""
ACTIVE = "active"
SUSPENDED = "suspended"
INACTIVE = "inactive"
class Tenant(Base):
"""Modelo de Tenant (Organización cliente)."""
__tablename__ = "tenants"
# Información básica
@@ -45,24 +40,21 @@ class Tenant(Base):
max_users: Mapped[int] = mapped_column(Integer, default=50)
max_storage_mb: Mapped[int] = mapped_column(Integer, default=1024)
allowed_file_types: Mapped[List[str]] = mapped_column(
ARRAY(String),
JSON().with_variant(PG_ARRAY(String), "postgresql"),
default=["pdf", "jpg", "jpeg", "png", "doc", "docx", "xls", "xlsx", "txt"]
)
# Estado
status: Mapped[TenantStatus] = mapped_column(
String(20),
String(20),
default=TenantStatus.ACTIVE
)
# Relaciones
users: Mapped[List["User"]] = relationship("User", 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:
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

View File

@@ -1,56 +1,131 @@
"""
Ticket Model - ServiceManagerWeb
Tickets de soporte - Core del negocio
"""
from sqlalchemy import String, ForeignKey, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, ENUM
from sqlalchemy import String, ForeignKey, Text, Integer, CheckConstraint, UniqueConstraint, Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship, synonym
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM
from typing import Optional
from datetime import datetime
import enum
import uuid
from app.core.database import Base
from app.core.database import Base, GUID
def _generate_fallback_ticket_number() -> str:
# Matches helper format "TK-000001" and stays within VARCHAR(20)
return f"TK-{(uuid.uuid4().int % 1_000_000):06d}"
class TicketStatus(str, enum.Enum):
"""Estados posibles de un ticket"""
NEW = "NEW"
TRIAGE = "TRIAGE"
IN_PROGRESS = "IN_PROGRESS"
WAITING_FOR_CLIENT = "WAITING_FOR_CLIENT"
WAITING_CUSTOMER = "WAITING_CUSTOMER" # ✅ CORREGIDO: nombre según schema.sql
RESOLVED = "RESOLVED"
CLOSED = "CLOSED"
REOPENED = "REOPENED"
class TicketPriority(str, enum.Enum):
"""Prioridades posibles de un ticket"""
LOW = "LOW"
MEDIUM = "MEDIUM"
HIGH = "HIGH"
URGENT = "URGENT"
class Ticket(Base):
"""Modelo de tickets de soporte"""
__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)
ticket_number: Mapped[str] = mapped_column(String(20), nullable=False)
# Campos básicos
ticket_number: Mapped[str] = mapped_column(
String(20),
nullable=False,
default=_generate_fallback_ticket_number,
)
subject: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False)
# Compatibility aliases (API/UI/tests often use these names)
title = synonym("subject")
system_id = synonym("affected_system_id")
status: Mapped[TicketStatus] = mapped_column(ENUM(TicketStatus, name="ticket_status_enum", create_type=False), default=TicketStatus.NEW)
priority: Mapped[TicketPriority] = mapped_column(ENUM(TicketPriority, name="ticket_priority_enum", create_type=False), default=TicketPriority.MEDIUM)
# Estado y Prioridad
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
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
assigned_to: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
# ✅ CORREGIDO: Foreign Keys apuntan a tablas correctas
created_by: Mapped[uuid.UUID] = mapped_column(
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)
category_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("categories.id"), nullable=True)
# ✅ CORREGIDO: Renombrado de system_id a affected_system_id
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
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="tickets")
system: Mapped["System"] = relationship("System", back_populates="tickets")
category: Mapped["Category"] = relationship("Category", back_populates="tickets")
# ✅ ACTUALIZADO: Nombre de relación y optional
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(
"User",
@@ -63,3 +138,24 @@ class Ticket(Base):
foreign_keys=[assigned_to],
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})>"

View File

@@ -4,15 +4,15 @@ User Model - ServiceManagerWeb
Modelo para usuarios del sistema (internos y clientes)
"""
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Text, ARRAY
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Text, JSON, Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, ENUM
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM, ARRAY as PG_ARRAY
from typing import Optional, List
import enum
import uuid
from datetime import datetime
from app.core.database import Base
from app.core.database import Base, GUID
class UserRole(str, enum.Enum):
@@ -35,7 +35,7 @@ class User(Base):
# Relación con tenant
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
GUID(),
ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False
)
@@ -48,12 +48,20 @@ class User(Base):
# Autenticación
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[UserRole] = mapped_column(ENUM(UserRole), nullable=False)
role: Mapped[UserRole] = mapped_column(
SAEnum(UserRole, name="user_role_enum", native_enum=False).with_variant(
PG_ENUM(UserRole, name="user_role_enum", create_type=True),
"postgresql",
),
nullable=False,
)
# 2FA (opcional para staff interno)
totp_secret: Mapped[Optional[str]] = mapped_column(String(32))
totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
backup_codes: Mapped[Optional[List[str]]] = mapped_column(ARRAY(String))
backup_codes: Mapped[Optional[List[str]]] = mapped_column(
JSON().with_variant(PG_ARRAY(String), "postgresql")
)
# Estado
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
@@ -78,10 +86,11 @@ class User(Base):
back_populates="assigned_to_user",
foreign_keys="Ticket.assigned_to"
)
# Unique constraint por tenant
__table_args__ = (
{"postgresql_tablespace": "users"},
refresh_tokens: Mapped[List["RefreshToken"]] = relationship(
"RefreshToken",
back_populates="user",
foreign_keys="RefreshToken.user_id",
cascade="all, delete-orphan"
)
def __repr__(self) -> str:
@@ -132,4 +141,4 @@ class User(Base):
def requires_2fa(self) -> bool:
"""Check if 2FA is required for this user."""
# 2FA opcional para staff interno, no requerido para clientes
return self.is_staff
return self.is_staff

View 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()
}

View 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())

View File

@@ -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
View 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()

View 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"}

View File

@@ -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')

View 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 ###

View File

@@ -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

View 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")

View File

@@ -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
)

View File

@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "servicemanager-backend"
version = "0.1.0"
version = "1.6.0"
description = "ServiceManagerWeb Backend - Mesa de Ayuda B2B"
authors = [
{name = "Aduanasoft", email = "dev@aduanasoft.com"}

24
backend/pytest.ini Normal file
View 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

View File

@@ -69,6 +69,7 @@ prometheus-client==0.19.0
pytest==7.4.3
pytest-asyncio==0.21.1
pytest-cov==4.1.0
aiosqlite==0.19.0
httpx==0.25.2 # For testing
faker==20.1.0 # Test data generation

115
backend/run_tests.sh Executable file
View 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

View 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`

View 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())

View 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())

View 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")

View 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")

View 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())

View File

View 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

View File

166
backend/tests/conftest.py Normal file
View 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"

View 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}"}

View File

View 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}"}

View 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

View 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]

View 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)

View 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

View File

View 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 ""

View 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

View 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
View 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

View File

View File

@@ -0,0 +1,191 @@
"""
Unit Tests - Audit Service - ServiceManagerWeb
Tests para app.services.audit_service usando mocks de BD.
No requieren base de datos real ni red.
"""
import pytest
import uuid
from unittest.mock import AsyncMock, MagicMock, patch
class TestAuditServiceLog:
"""Tests para AuditService.log()."""
@pytest.mark.asyncio
async def test_log_creates_audit_entry(self):
"""AuditService.log() debe crear un registro en la BD."""
from app.services.audit_service import AuditService
mock_db = AsyncMock()
mock_db.add = MagicMock()
mock_db.commit = AsyncMock()
mock_db.refresh = AsyncMock()
tenant_id = uuid.uuid4()
user_id = uuid.uuid4()
resource_id = uuid.uuid4()
result = await AuditService.log(
db=mock_db,
tenant_id=tenant_id,
user_id=user_id,
action="ticket.create",
resource_type="ticket",
resource_id=resource_id,
new_values={"subject": "Test ticket", "status": "NEW"},
)
# Se debe haber llamado a db.add con el AuditLog
mock_db.add.assert_called_once()
# El resultado debe ser un AuditLog
assert result is not None
@pytest.mark.asyncio
async def test_log_without_user_id(self):
"""AuditService.log() funciona sin user_id (acciones del sistema)."""
from app.services.audit_service import AuditService
mock_db = AsyncMock()
mock_db.add = MagicMock()
mock_db.commit = AsyncMock()
mock_db.refresh = AsyncMock()
result = await AuditService.log(
db=mock_db,
tenant_id=uuid.uuid4(),
action="system.startup",
resource_type="system",
)
mock_db.add.assert_called_once()
assert result is not None
@pytest.mark.asyncio
async def test_log_with_old_and_new_values(self):
"""AuditService.log() acepta old_values y new_values para auditoría de cambios."""
from app.services.audit_service import AuditService
mock_db = AsyncMock()
mock_db.add = MagicMock()
mock_db.commit = AsyncMock()
mock_db.refresh = AsyncMock()
await AuditService.log(
db=mock_db,
tenant_id=uuid.uuid4(),
user_id=uuid.uuid4(),
action="ticket.update",
resource_type="ticket",
resource_id=uuid.uuid4(),
old_values={"status": "NEW", "priority": "LOW"},
new_values={"status": "IN_PROGRESS", "priority": "HIGH"},
)
mock_db.add.assert_called_once()
# Verificar que el AuditLog tiene old_values y new_values
audit_log = mock_db.add.call_args[0][0]
assert audit_log.old_values == {"status": "NEW", "priority": "LOW"}
assert audit_log.new_values == {"status": "IN_PROGRESS", "priority": "HIGH"}
@pytest.mark.asyncio
async def test_log_action_stored_correctly(self):
"""AuditService.log() almacena la acción correctamente."""
from app.services.audit_service import AuditService
mock_db = AsyncMock()
mock_db.add = MagicMock()
mock_db.commit = AsyncMock()
mock_db.refresh = AsyncMock()
await AuditService.log(
db=mock_db,
tenant_id=uuid.uuid4(),
action="user.login",
resource_type="user",
)
audit_log = mock_db.add.call_args[0][0]
assert audit_log.action == "user.login"
assert audit_log.resource_type == "user"
@pytest.mark.asyncio
async def test_log_tenant_id_stored_correctly(self):
"""AuditService.log() almacena el tenant_id correctamente."""
from app.services.audit_service import AuditService
mock_db = AsyncMock()
mock_db.add = MagicMock()
mock_db.commit = AsyncMock()
mock_db.refresh = AsyncMock()
tenant_id = uuid.uuid4()
await AuditService.log(
db=mock_db,
tenant_id=tenant_id,
action="ticket.delete",
resource_type="ticket",
)
audit_log = mock_db.add.call_args[0][0]
assert audit_log.tenant_id == tenant_id
@pytest.mark.asyncio
async def test_log_with_request_extracts_ip(self):
"""AuditService.log() extrae información del request si se provee."""
from app.services.audit_service import AuditService
mock_db = AsyncMock()
mock_db.add = MagicMock()
mock_db.commit = AsyncMock()
mock_db.refresh = AsyncMock()
mock_request = MagicMock()
mock_request.client.host = "192.168.1.100"
mock_request.headers = {"user-agent": "TestBrowser/1.0"}
mock_request.state.correlation_id = "test-correlation-id"
await AuditService.log(
db=mock_db,
tenant_id=uuid.uuid4(),
action="ticket.view",
resource_type="ticket",
request=mock_request,
)
mock_db.add.assert_called_once()
class TestAuditServiceMetadata:
"""Tests para metadata adicional en registros de auditoría."""
@pytest.mark.asyncio
async def test_log_with_custom_metadata(self):
"""AuditService.log() almacena metadata personalizada en extra_metadata.
Nota: El campo Python es 'extra_metadata' (no 'metadata') porque
SQLAlchemy reserva el atributo 'metadata' para MetaData de la tabla.
La columna en BD sí se llama 'metadata'.
"""
from app.services.audit_service import AuditService
mock_db = AsyncMock()
mock_db.add = MagicMock()
mock_db.commit = AsyncMock()
mock_db.refresh = AsyncMock()
metadata = {"source": "api", "version": "1.9.0", "client_ip": "10.0.0.1"}
await AuditService.log(
db=mock_db,
tenant_id=uuid.uuid4(),
action="tenant.update",
resource_type="tenant",
metadata=metadata,
)
audit_log = mock_db.add.call_args[0][0]
# El atributo Python es extra_metadata (columna BD: metadata)
assert audit_log.extra_metadata == metadata

View File

@@ -0,0 +1,58 @@
"""
Very basic tests - ServiceManagerWeb
Tests simplísimos para verificar que pytest funciona
"""
import pytest
def test_basic_math():
"""Test basic functionality."""
assert 1 + 1 == 2
assert 2 * 3 == 6
assert 10 // 3 == 3
def test_string_operations():
"""Test string operations."""
text = "ServiceManager"
assert text.lower() == "servicemanager"
assert len(text) == 14
assert "Manager" in text
@pytest.mark.asyncio
async def test_async_operation():
"""Test async functionality works."""
import asyncio
await asyncio.sleep(0.001) # Very short sleep
assert True
def test_list_operations():
"""Test list operations."""
items = ["tickets", "users", "tenants"]
assert len(items) == 3
assert "tickets" in items
assert items[0] == "tickets"
def test_dict_operations():
"""Test dictionary operations."""
data = {
"name": "Test User",
"email": "test@example.com",
"active": True
}
assert data["name"] == "Test User"
assert data.get("email") is not None
assert data["active"] is True
# Mark for later when configuration is fixed
@pytest.mark.skip(reason="Configuration issue with ALLOWED_FILE_EXTENSIONS")
def test_security_imports():
"""Test security imports - skip for now due to config issue."""
from app.core.security import security
assert security is not None

View File

@@ -0,0 +1,136 @@
"""
Unit Tests - Configuration - ServiceManagerWeb
Tests para app.core.config: carga de settings, valores por defecto
y propiedades derivadas. No requieren base de datos ni red.
"""
import pytest
class TestSettings:
"""Tests para la configuración centralizada de la aplicación."""
def test_settings_loads_without_error(self):
"""get_settings() debe cargar sin lanzar excepciones."""
from app.core.config import get_settings
settings = get_settings()
assert settings is not None
def test_settings_is_singleton(self):
"""get_settings() debe retornar la misma instancia (lru_cache)."""
from app.core.config import get_settings
s1 = get_settings()
s2 = get_settings()
assert s1 is s2
def test_environment_is_valid(self):
"""ENVIRONMENT debe ser uno de los valores válidos del sistema."""
from app.core.config import get_settings
settings = get_settings()
valid_envs = {"development", "staging", "production", "testing"}
assert settings.ENVIRONMENT in valid_envs, (
f"ENVIRONMENT='{settings.ENVIRONMENT}' no es un valor válido. "
f"Debe ser uno de: {valid_envs}"
)
def test_app_version_is_set(self):
"""APP_VERSION debe estar definido."""
from app.core.config import get_settings
settings = get_settings()
assert settings.APP_VERSION is not None
assert len(settings.APP_VERSION) > 0
def test_app_version_is_1_9_0(self):
"""APP_VERSION debe ser 1.9.0 en esta versión del proyecto."""
from app.core.config import get_settings
settings = get_settings()
assert settings.APP_VERSION == "1.9.0"
def test_api_version_default(self):
"""API_VERSION debe ser v1 por defecto."""
from app.core.config import get_settings
settings = get_settings()
assert settings.API_VERSION == "v1"
def test_jwt_algorithm_default(self):
"""JWT_ALGORITHM debe ser HS256 por defecto."""
from app.core.config import get_settings
settings = get_settings()
assert settings.JWT_ALGORITHM == "HS256"
def test_access_token_expire_minutes(self):
"""ACCESS_TOKEN_EXPIRE_MINUTES debe ser un entero positivo."""
from app.core.config import get_settings
settings = get_settings()
assert isinstance(settings.ACCESS_TOKEN_EXPIRE_MINUTES, int)
assert settings.ACCESS_TOKEN_EXPIRE_MINUTES > 0
def test_refresh_token_expire_days(self):
"""REFRESH_TOKEN_EXPIRE_DAYS debe ser un entero positivo."""
from app.core.config import get_settings
settings = get_settings()
assert isinstance(settings.REFRESH_TOKEN_EXPIRE_DAYS, int)
assert settings.REFRESH_TOKEN_EXPIRE_DAYS > 0
def test_secret_key_is_set(self):
"""SECRET_KEY debe estar definido y no vacío."""
from app.core.config import get_settings
settings = get_settings()
assert settings.SECRET_KEY
assert len(settings.SECRET_KEY) > 0
def test_allowed_file_extensions_is_list(self):
"""ALLOWED_FILE_EXTENSIONS debe retornar una lista."""
from app.core.config import get_settings
settings = get_settings()
extensions = settings.ALLOWED_FILE_EXTENSIONS
assert isinstance(extensions, list)
assert len(extensions) > 0
def test_allowed_file_extensions_lowercase(self):
"""Las extensiones de archivo deben estar en minúsculas."""
from app.core.config import get_settings
settings = get_settings()
for ext in settings.ALLOWED_FILE_EXTENSIONS:
assert ext == ext.lower(), f"Extensión '{ext}' no está en minúsculas"
def test_is_development_consistent(self):
"""is_development() debe ser consistente con el valor de ENVIRONMENT."""
from app.core.config import get_settings
settings = get_settings()
expected = settings.ENVIRONMENT == "development"
assert settings.is_development() is expected
def test_is_testing_consistent(self):
"""is_testing() debe ser consistente con el valor de ENVIRONMENT."""
from app.core.config import get_settings
settings = get_settings()
expected = settings.ENVIRONMENT == "testing"
assert settings.is_testing() is expected
def test_is_production_returns_false_in_testing(self):
"""is_production() debe retornar False en entorno de test."""
from app.core.config import get_settings
settings = get_settings()
assert settings.is_production() is False
def test_argon2_settings_positive(self):
"""Los parámetros de Argon2 deben ser enteros positivos."""
from app.core.config import get_settings
settings = get_settings()
assert settings.ARGON2_TIME_COST > 0
assert settings.ARGON2_MEMORY_COST > 0
assert settings.ARGON2_PARALLELISM > 0
def test_max_upload_size_positive(self):
"""MAX_UPLOAD_SIZE_MB debe ser positivo."""
from app.core.config import get_settings
settings = get_settings()
assert settings.MAX_UPLOAD_SIZE_MB > 0
def test_password_min_length(self):
"""PASSWORD_MIN_LENGTH debe ser al menos 8."""
from app.core.config import get_settings
settings = get_settings()
assert settings.PASSWORD_MIN_LENGTH >= 8

View File

@@ -0,0 +1,80 @@
"""Unit Tests - FileHandler - ServiceManagerWeb
Tests para app.core.file_handler.FileHandler.
"""
import io
import uuid
import tempfile
import pytest
from fastapi import UploadFile
from fastapi import HTTPException
@pytest.mark.asyncio
async def test_save_upload_pdf_valid_streaming():
from app.core.file_handler import FileHandler, settings
with tempfile.TemporaryDirectory() as tmp:
settings.UPLOAD_PATH = tmp
handler = FileHandler()
tenant_id = uuid.uuid4()
ticket_id = uuid.uuid4()
content = b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n1 0 obj\n<<>>\nendobj\n"
up = UploadFile(filename="test.pdf", file=io.BytesIO(content))
meta = await handler.save_upload(up, tenant_id=tenant_id, ticket_id=ticket_id)
assert meta["file_size"] == len(content)
assert meta["original_filename"] == "test.pdf"
assert meta["filename"].endswith(".pdf")
assert meta["md5_hash"]
assert meta["sha256_hash"]
@pytest.mark.asyncio
async def test_save_upload_pdf_invalid_magic_bytes_rejected():
from app.core.file_handler import FileHandler, settings
with tempfile.TemporaryDirectory() as tmp:
settings.UPLOAD_PATH = tmp
handler = FileHandler()
up = UploadFile(filename="bad.pdf", file=io.BytesIO(b"NOTPDF"))
with pytest.raises(HTTPException) as exc:
await handler.save_upload(up, tenant_id=uuid.uuid4(), ticket_id=uuid.uuid4())
assert exc.value.status_code == 400
@pytest.mark.asyncio
async def test_save_upload_oversize_rejected_and_file_removed():
from app.core.file_handler import FileHandler, settings
with tempfile.TemporaryDirectory() as tmp:
settings.UPLOAD_PATH = tmp
settings.MAX_UPLOAD_SIZE_MB = 0 # 0MB => max 0 bytes
handler = FileHandler()
up = UploadFile(filename="a.txt", file=io.BytesIO(b"x"))
with pytest.raises(HTTPException) as exc:
await handler.save_upload(up, tenant_id=uuid.uuid4(), ticket_id=uuid.uuid4())
assert exc.value.status_code == 413
def test_get_file_path_prevents_path_traversal():
from app.core.file_handler import FileHandler, settings
with tempfile.TemporaryDirectory() as tmp:
settings.UPLOAD_PATH = tmp
handler = FileHandler()
with pytest.raises(HTTPException) as exc:
handler.get_file_path("../../etc/passwd")
assert exc.value.status_code == 403

View File

@@ -0,0 +1,50 @@
"""
Tests for Health Check endpoints - ServiceManagerWeb
Tests básicos para verificar que la configuración de testing funciona
"""
import pytest
import asyncio
@pytest.mark.asyncio
async def test_health_check_async():
"""Test that async operations work in testing."""
# Simple async test to verify setup
await asyncio.sleep(0.01)
assert True
def test_basic_math():
"""Test basic functionality."""
assert 1 + 1 == 2
# Test básico de importación de módulos principales
def test_imports():
"""Test that core modules can be imported without errors."""
try:
from app.core.config import get_settings
from app.core.security import security
# Test que las funciones básicas existen
assert get_settings is not None
assert security is not None
assert hasattr(security, 'hash_password')
assert hasattr(security, 'verify_password')
except ImportError as e:
pytest.fail(f"Failed to import core modules: {e}")
def test_security_functions():
"""Test basic security functions."""
from app.core.security import security
password = "TestPassword123!"
hashed = security.hash_password(password)
assert hashed != password # Should be hashed
assert security.verify_password(password, hashed) # Should verify
assert not security.verify_password("wrong", hashed) # Should not verify wrong password

View File

@@ -0,0 +1,279 @@
"""
Unit Tests - Tenant Middleware - ServiceManagerWeb
Tests para app.middleware.tenant: extracción de headers, rutas excluidas,
y comportamiento con tenants válidos/inválidos usando mocks.
No requieren base de datos real ni red.
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
# ============================================================
# EXCLUDED PATHS
# ============================================================
class TestExcludedPaths:
"""Tests para las rutas que no requieren validación de tenant."""
def test_excluded_paths_contains_health(self):
"""El health check debe estar en rutas excluidas."""
from app.middleware.tenant import TenantMiddleware
assert "/health" in TenantMiddleware.EXCLUDED_PATHS
def test_excluded_paths_contains_login(self):
"""El endpoint de login debe estar excluido."""
from app.middleware.tenant import TenantMiddleware
assert "/api/v1/auth/login" in TenantMiddleware.EXCLUDED_PATHS
assert "/v1/auth/login" in TenantMiddleware.EXCLUDED_PATHS
def test_excluded_paths_contains_refresh(self):
"""El endpoint de refresh token debe estar excluido."""
from app.middleware.tenant import TenantMiddleware
assert "/api/v1/auth/refresh" in TenantMiddleware.EXCLUDED_PATHS
assert "/v1/auth/refresh" in TenantMiddleware.EXCLUDED_PATHS
def test_excluded_paths_contains_docs(self):
"""Los endpoints de documentación deben estar excluidos."""
from app.middleware.tenant import TenantMiddleware
assert "/docs" in TenantMiddleware.EXCLUDED_PATHS
assert "/redoc" in TenantMiddleware.EXCLUDED_PATHS
def test_excluded_paths_contains_openapi(self):
"""El endpoint openapi.json debe estar excluido."""
from app.middleware.tenant import TenantMiddleware
assert "/openapi.json" in TenantMiddleware.EXCLUDED_PATHS
def test_root_path_is_excluded(self):
"""La ruta raíz debe estar excluida."""
from app.middleware.tenant import TenantMiddleware
assert "/" in TenantMiddleware.EXCLUDED_PATHS
# ============================================================
# MIDDLEWARE DISPATCH — RUTAS EXCLUIDAS
# ============================================================
class TestMiddlewareExcludedRoutes:
"""Tests que verifican que las rutas excluidas pasan sin validación."""
@pytest.mark.asyncio
async def test_health_route_bypasses_tenant_validation(self):
"""La ruta /health pasa sin validación de tenant."""
from app.middleware.tenant import TenantMiddleware
mock_app = AsyncMock()
middleware = TenantMiddleware(mock_app)
# Simular request a /health sin headers de tenant
request = MagicMock()
request.url.path = "/health"
request.headers = {}
request.state = MagicMock()
call_next = AsyncMock(return_value=MagicMock(status_code=200))
await middleware.dispatch(request, call_next)
# call_next debe haberse llamado (pasó sin bloquear)
call_next.assert_called_once_with(request)
@pytest.mark.asyncio
async def test_login_route_bypasses_tenant_validation(self):
"""La ruta /api/v1/auth/login pasa sin validación de tenant."""
from app.middleware.tenant import TenantMiddleware
mock_app = AsyncMock()
middleware = TenantMiddleware(mock_app)
request = MagicMock()
request.url.path = "/api/v1/auth/login"
request.headers = {}
request.state = MagicMock()
call_next = AsyncMock(return_value=MagicMock(status_code=200))
await middleware.dispatch(request, call_next)
call_next.assert_called_once_with(request)
@pytest.mark.asyncio
async def test_docs_prefix_bypasses_tenant_validation(self):
"""Rutas que empiezan con /docs pasan sin validación."""
from app.middleware.tenant import TenantMiddleware
mock_app = AsyncMock()
middleware = TenantMiddleware(mock_app)
request = MagicMock()
request.url.path = "/docs/swagger-ui"
request.headers = {}
request.state = MagicMock()
call_next = AsyncMock(return_value=MagicMock(status_code=200))
await middleware.dispatch(request, call_next)
call_next.assert_called_once_with(request)
# ============================================================
# MIDDLEWARE DISPATCH — SIN HEADERS DE TENANT
# ============================================================
class TestMiddlewareNoTenantHeaders:
"""Tests para requests sin headers de tenant."""
@pytest.mark.asyncio
async def test_missing_tenant_headers_returns_400(self):
"""Sin tenant headers debe retornar 400 (requerido para aislamiento multi-tenant)."""
from app.middleware.tenant import TenantMiddleware
mock_app = AsyncMock()
middleware = TenantMiddleware(mock_app)
request = MagicMock()
request.url.path = "/v1/tickets/"
request.method = "GET"
request.headers = {}
request.state = MagicMock()
call_next = AsyncMock(return_value=MagicMock(status_code=200))
response = await middleware.dispatch(request, call_next)
assert response.status_code == 400
call_next.assert_not_called()
@pytest.mark.asyncio
async def test_missing_tenant_headers_does_not_call_next(self):
"""Sin tenant headers no debe llegar al handler (call_next)."""
from app.middleware.tenant import TenantMiddleware
mock_app = AsyncMock()
middleware = TenantMiddleware(mock_app)
request = MagicMock()
request.url.path = "/v1/tickets/"
request.method = "GET"
request.headers = {}
request.state = MagicMock()
call_next = AsyncMock(return_value=MagicMock(status_code=200))
response = await middleware.dispatch(request, call_next)
assert response.status_code == 400
call_next.assert_not_called()
# ============================================================
# MIDDLEWARE DISPATCH — CON TENANT VÁLIDO
# ============================================================
class TestMiddlewareValidTenant:
"""Tests para requests con tenant válido."""
@pytest.mark.asyncio
async def test_valid_tenant_id_sets_state(self):
"""Un tenant_id válido debe almacenarse en request.state."""
from app.middleware.tenant import TenantMiddleware
from app.models.tenant import TenantStatus
mock_app = AsyncMock()
middleware = TenantMiddleware(mock_app)
# Crear tenant mock
mock_tenant = MagicMock()
mock_tenant.id = "12345678-1234-5678-1234-567812345678"
mock_tenant.slug = "test-company"
mock_tenant.status = TenantStatus.ACTIVE
request = MagicMock()
request.url.path = "/v1/tickets/"
request.method = "GET"
request.headers = {"X-Tenant-ID": str(mock_tenant.id)}
request.state = MagicMock()
call_next = AsyncMock(return_value=MagicMock(status_code=200))
# Mock de la sesión de BD
mock_result = MagicMock()
mock_result.scalars.return_value.first.return_value = mock_tenant
mock_session = AsyncMock()
mock_session.execute = AsyncMock(return_value=mock_result)
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
with patch("app.middleware.tenant.AsyncSessionLocal", return_value=mock_session):
await middleware.dispatch(request, call_next)
# El tenant debe haber sido asignado al state
assert request.state.tenant == mock_tenant
call_next.assert_called_once()
@pytest.mark.asyncio
async def test_inactive_tenant_returns_403(self):
"""Un tenant suspendido debe retornar 403."""
from app.middleware.tenant import TenantMiddleware
from app.models.tenant import TenantStatus
mock_app = AsyncMock()
middleware = TenantMiddleware(mock_app)
mock_tenant = MagicMock()
mock_tenant.id = "12345678-1234-5678-1234-567812345678"
mock_tenant.slug = "suspended-company"
mock_tenant.status = TenantStatus.SUSPENDED
request = MagicMock()
request.url.path = "/v1/tickets/"
request.method = "GET"
request.headers = {"X-Tenant-ID": str(mock_tenant.id)}
request.state = MagicMock()
call_next = AsyncMock(return_value=MagicMock(status_code=200))
mock_result = MagicMock()
mock_result.scalars.return_value.first.return_value = mock_tenant
mock_session = AsyncMock()
mock_session.execute = AsyncMock(return_value=mock_result)
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
with patch("app.middleware.tenant.AsyncSessionLocal", return_value=mock_session):
response = await middleware.dispatch(request, call_next)
assert response.status_code == 403
call_next.assert_not_called()
@pytest.mark.asyncio
async def test_nonexistent_tenant_returns_404(self):
"""Un tenant_id que no existe en BD debe retornar 404."""
from app.middleware.tenant import TenantMiddleware
mock_app = AsyncMock()
middleware = TenantMiddleware(mock_app)
request = MagicMock()
request.url.path = "/v1/tickets/"
request.method = "GET"
request.headers = {"X-Tenant-ID": "00000000-0000-0000-0000-000000000000"}
request.state = MagicMock()
call_next = AsyncMock(return_value=MagicMock(status_code=200))
mock_result = MagicMock()
mock_result.scalars.return_value.first.return_value = None # No encontrado
mock_session = AsyncMock()
mock_session.execute = AsyncMock(return_value=mock_result)
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
with patch("app.middleware.tenant.AsyncSessionLocal", return_value=mock_session):
response = await middleware.dispatch(request, call_next)
assert response.status_code == 404
call_next.assert_not_called()

View File

@@ -0,0 +1,264 @@
"""
Unit Tests - Pydantic Schemas - ServiceManagerWeb
Tests para validación de schemas en app.api.schemas.
No requieren base de datos ni red.
"""
import pytest
from pydantic import ValidationError
import uuid
# ============================================================
# AUTH SCHEMAS
# ============================================================
class TestAuthSchemas:
"""Tests para schemas de autenticación."""
def test_login_request_valid(self):
"""LoginRequest acepta datos válidos."""
from app.api.schemas.auth import LoginRequest
schema = LoginRequest(
email="user@example.com",
password="Pass123!",
tenant_slug="my-tenant",
)
assert schema.email == "user@example.com"
assert schema.tenant_slug == "my-tenant"
assert schema.totp_code is None
def test_login_request_invalid_email(self):
"""LoginRequest rechaza email inválido."""
from app.api.schemas.auth import LoginRequest
with pytest.raises(ValidationError):
LoginRequest(email="not-an-email", password="Pass123!", tenant_slug="t")
def test_login_request_with_totp(self):
"""LoginRequest acepta código TOTP opcional."""
from app.api.schemas.auth import LoginRequest
schema = LoginRequest(
email="user@example.com",
password="Pass123!",
tenant_slug="my-tenant",
totp_code="123456",
)
assert schema.totp_code == "123456"
def test_token_response_default_type(self):
"""TokenResponse tiene token_type=bearer por defecto."""
from app.api.schemas.auth import TokenResponse
schema = TokenResponse(access_token="abc123", expires_in=3600)
assert schema.token_type == "bearer"
# ============================================================
# TENANT SCHEMAS
# ============================================================
class TestTenantSchemas:
"""Tests para schemas de tenants."""
def test_tenant_create_valid(self):
"""TenantCreate acepta datos mínimos válidos."""
from app.api.schemas.tenant import TenantCreate
schema = TenantCreate(name="ACME Corp", slug="acme-corp")
assert schema.name == "ACME Corp"
assert schema.slug == "acme-corp"
assert schema.domain is None
def test_tenant_create_with_all_fields(self):
"""TenantCreate acepta todos los campos opcionales."""
from app.api.schemas.tenant import TenantCreate
schema = TenantCreate(
name="ACME Corp",
slug="acme-corp",
domain="acme.com",
contact_email="admin@acme.com",
contact_phone="+1234567890",
)
assert schema.contact_email == "admin@acme.com"
def test_tenant_create_invalid_email(self):
"""TenantCreate rechaza email de contacto inválido."""
from app.api.schemas.tenant import TenantCreate
with pytest.raises(ValidationError):
TenantCreate(name="Corp", slug="corp", contact_email="bad-email")
def test_tenant_update_all_optional(self):
"""TenantUpdate permite actualización parcial (todos opcionales)."""
from app.api.schemas.tenant import TenantUpdate
schema = TenantUpdate()
assert schema.name is None
assert schema.slug is None
assert schema.status is None
def test_tenant_update_only_name(self):
"""TenantUpdate permite actualizar solo el nombre."""
from app.api.schemas.tenant import TenantUpdate
schema = TenantUpdate(name="New Name")
assert schema.name == "New Name"
assert schema.slug is None
# ============================================================
# USER SCHEMAS
# ============================================================
class TestUserSchemas:
"""Tests para schemas de usuarios."""
def test_user_create_valid(self):
"""UserCreate acepta datos válidos con defaults."""
from app.api.schemas.user import UserCreate
from app.models.user import UserRole
schema = UserCreate(
email="agent@company.com",
first_name="John",
last_name="Doe",
role=UserRole.AGENT,
password="SecurePass123!",
)
assert schema.email == "agent@company.com"
assert schema.language == "es"
assert schema.timezone == "UTC"
assert schema.notifications_email is True
def test_user_create_invalid_email(self):
"""UserCreate rechaza email inválido."""
from app.api.schemas.user import UserCreate
from app.models.user import UserRole
with pytest.raises(ValidationError):
UserCreate(
email="not-valid",
first_name="John",
last_name="Doe",
role=UserRole.AGENT,
password="Pass123!",
)
def test_user_create_invalid_role(self):
"""UserCreate rechaza rol inválido."""
from app.api.schemas.user import UserCreate
with pytest.raises(ValidationError):
UserCreate(
email="user@test.com",
first_name="John",
last_name="Doe",
role="SUPER_VILLAIN",
password="Pass123!",
)
def test_user_update_all_optional(self):
"""UserUpdate permite actualización parcial."""
from app.api.schemas.user import UserUpdate
schema = UserUpdate()
assert schema.email is None
assert schema.first_name is None
assert schema.is_active is None
# ============================================================
# TICKET SCHEMAS
# ============================================================
class TestTicketSchemas:
"""Tests para schemas de tickets."""
def test_ticket_create_valid_minimal(self):
"""TicketCreate acepta datos mínimos con priority por defecto."""
from app.api.schemas.ticket import TicketCreate
schema = TicketCreate(
subject="Mi impresora no funciona",
description="La impresora del piso 3 no enciende desde esta mañana.",
)
assert schema.subject == "Mi impresora no funciona"
assert schema.priority == "MEDIUM"
assert schema.category_id is None
assert schema.affected_system_id is None
def test_ticket_create_with_priority(self):
"""TicketCreate acepta prioridad personalizada."""
from app.api.schemas.ticket import TicketCreate
schema = TicketCreate(
subject="Sistema caído",
description="El sistema principal no responde.",
priority="URGENT",
)
assert schema.priority == "URGENT"
def test_ticket_update_all_optional(self):
"""TicketUpdate permite actualización parcial."""
from app.api.schemas.ticket import TicketUpdate
schema = TicketUpdate()
assert schema.subject is None
assert schema.status is None
assert schema.assigned_to is None
def test_ticket_close_request_optional_resolution(self):
"""TicketCloseRequest acepta resolución vacía."""
from app.api.schemas.ticket import TicketCloseRequest
schema = TicketCloseRequest()
assert schema.resolution is None
def test_comment_create_defaults(self):
"""CommentCreate tiene is_internal=False por defecto."""
from app.api.schemas.ticket import CommentCreate
schema = CommentCreate(content="Este es un comentario de prueba.")
assert schema.is_internal is False
def test_comment_create_internal(self):
"""CommentCreate acepta comentario interno."""
from app.api.schemas.ticket import CommentCreate
schema = CommentCreate(content="Nota interna.", is_internal=True)
assert schema.is_internal is True
# ============================================================
# CATEGORY SCHEMAS
# ============================================================
class TestCategorySchemas:
"""Tests para schemas de categorías."""
def test_category_create_defaults(self):
"""CategoryCreate tiene SLAs por defecto correctos."""
from app.api.schemas.category import CategoryCreate
schema = CategoryCreate(name="Hardware")
assert schema.sla_response_hours == 24
assert schema.sla_resolution_hours == 72
assert schema.is_active if hasattr(schema, "is_active") else True
def test_category_create_custom_sla(self):
"""CategoryCreate acepta SLAs personalizados."""
from app.api.schemas.category import CategoryCreate
schema = CategoryCreate(
name="Urgente",
sla_response_hours=1,
sla_resolution_hours=4,
)
assert schema.sla_response_hours == 1
assert schema.sla_resolution_hours == 4
# ============================================================
# SYSTEM SCHEMAS
# ============================================================
class TestSystemSchemas:
"""Tests para schemas de sistemas."""
def test_system_create_valid(self):
"""SystemCreate acepta datos válidos."""
from app.api.schemas.system import SystemCreate
schema = SystemCreate(name="ERP Principal")
assert schema.name == "ERP Principal"
assert schema.description is None
def test_system_update_all_optional(self):
"""SystemUpdate permite actualización parcial."""
from app.api.schemas.system import SystemUpdate
schema = SystemUpdate(is_active=False)
assert schema.is_active is False
assert schema.name is None

Some files were not shown because too many files have changed in this diff Show More