Compare commits
4
Commits
v1.0.1
..
69d545b718
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69d545b718 | ||
|
|
dd93b8c07a | ||
|
|
7d342bf8cc | ||
|
|
69b8b7ccef |
@@ -445,8 +445,123 @@
|
||||
* Generación de la etiqueta anotada `v1.0.1` a partir de `rc` con notas de versión detalladas.
|
||||
* Publicación remota en Gitea (`git push gitea v1.0.1`), disponibilizando la primera versión candidata/estable oficial para producción en Proxmox VE LXC con empaquetado de artefactos descargables (`.zip` y `.tar.gz`).
|
||||
|
||||
---
|
||||
|
||||
## 27. Implementación Integral del Roadmap (Sprints 1, 2 y 3): MVC, Stateless JWT y Spring Security (2026-09-05)
|
||||
* **Sprint 1: Refactorización MVC & Capa de Servicios (`app/repositories/`, `app/services/`, `app/schemas/`):**
|
||||
* **Capa de Repositorios:** Creación de `BaseRepository[T]` genérico, `ClassroomRepository` (con filtros de aulas físicas y virtuales), `ReservationRepository` (con detección de solapamiento horario y carga ansiosa `joinedload`), y `UserRepository`.
|
||||
* **Contratos DTOs (Pydantic v2):** Creación de esquemas tipados de validación: `ClassroomCreateDTO`, `ClassroomUpdateDTO`, `ReservationCreateDTO`, `ReservationUpdateDTO` y `LoginDTO`.
|
||||
* **Capa de Servicios Desacoplada:** `ClassroomService` (gestión de unicidad por edificio/piso y aforo virtual), `ReservationService` (resolución de conflictos y validación de capacidad), y `UserService` (autenticación y extracción de permisos RBAC).
|
||||
* **Controladores Delgados:** Refactorización de rutas web delegando validaciones y persistencia a los servicios (`routes/classrooms.py`).
|
||||
* **Sprint 2: Autenticación Stateless JWT & API Gateway (`app/routes/api/`, `app/utils/jwt_decorators.py`):**
|
||||
* **Dependencias:** Incorporación de `PyJWT>=2.8.0` y `pydantic>=2.0.0` en [requirements.txt](file:///c:/Workspace/admin-edu-space/requirements.txt).
|
||||
* **Servicio Criptográfico (`JWTService`):** Implementación del patrón Dual Token (`Access Token` de 15 min con claims RBAC completos + `Refresh Token` de 7 días para renovación silenciosa en cookie `HttpOnly`). Soporte de lista negra de revocación en memoria.
|
||||
* **Decoradores de Seguridad REST:** `@jwt_required` y `@jwt_role_required` para inspección de cabeceras `Authorization: Bearer <token>`.
|
||||
* **Endpoints API RESTful (v1):**
|
||||
* `POST /api/v1/auth/login`: Entrega de par de tokens y perfil de usuario.
|
||||
* `POST /api/v1/auth/refresh`: Renovación transparente de tokens sin reingreso de credenciales.
|
||||
* `POST /api/v1/auth/logout`: Revocación del token activo e invalidación de cookies.
|
||||
* `GET /api/v1/auth/me`: Perfil y matriz de permisos del usuario autenticado.
|
||||
* `GET /api/v1/classrooms` y `POST /api/v1/classrooms`: Consulta y creación tipada de aulas.
|
||||
* `GET /api/v1/reservations` y `POST /api/v1/reservations`: Consulta y gestión de reservas con resolución de conflictos.
|
||||
* **Sprint 3: Seguridad Empresarial "Sprint / Spring Security" (`app/security/`, `AuditLog`):**
|
||||
* **SecurityFilterChain:** Pipeline centralizado que inyecta cabeceras HTTP de blindaje OWASP (`X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, `X-XSS-Protection`, `Strict-Transport-Security`) y Rate Limiting dinámico contra fuerza bruta (15 peticiones/minuto en `/login` y `/api/v1/auth/login`).
|
||||
* **Seguridad Declarativa a Nivel Método (`@require_permission`):** Decorador que valida granularmente el nivel de acceso ('read', 'read_write', 'admin') tanto en sesiones web como en API JWT.
|
||||
* **Bitácora Inmutable de Auditoría:** Modelo de persistencia `AuditLog` y servicio `AuditService.log()` con inferencia automática de usuario actuante (JWT/Web) e IP de origen (`X-Forwarded-For`).
|
||||
* **Batería de Pruebas Automatizadas:**
|
||||
* Creación de tests unitarios y de integración en `tests/test_services_and_dto.py`, `tests/test_jwt_and_api.py` y `tests/test_security_chain.py` (18/18 pruebas aprobadas).
|
||||
|
||||
---
|
||||
|
||||
## 28. Selector de Vista Universal: Tabla / Grilla vs. Tarjetas / Mosaico (2026-09-05)
|
||||
* **Arquitectura Extensible de Visualización (`app/static/js/view_switcher.js` y `app/templates/partials/view_switcher.html`):**
|
||||
* Desarrollo del módulo `ViewSwitcherManager` que desacopla la lógica de presentación de los registros. Permite añadir futuras vistas (ej. kanban, timeline, compacta) mediante atributos de datos `data-view` y `data-view-container` sin tocar el backend.
|
||||
* **Conmutación Inmediata:** Cambio instantáneo de vista en el cliente sin recarga de página.
|
||||
* **Persistencia de Preferencias:** Almacenamiento local mediante `localStorage` (`view_preference_{context}`) para conservar la elección del usuario entre sesiones.
|
||||
* **Preservación de Estado y Filtros:** Inyección reactiva del parámetro `display` en formularios de búsqueda/filtro y actualización silenciosa de la URL (`window.history.replaceState`), garantizando que búsquedas, ordenamientos y paginaciones conserven la vista activa.
|
||||
* **Integración en Módulo de Aulas y Espacios (`app/templates/classrooms/list.html`):**
|
||||
* **Vista de Tabla / Grilla:** Columnas detalladas (Código, Edificio/Sede, Piso, Capacidad, Equipamiento, Ocupación D/S/M, Estado y Acciones).
|
||||
* **Vista de Tarjetas / Mosaico:** Cuadrícula responsive (1 a 4 columnas) con badges semánticos, indicadores de piso y barras de porcentaje de uso.
|
||||
* **Integración en Módulo de Reservas y Cronograma (`app/templates/schedule/list.html`):**
|
||||
* **Vista de Tabla:** Análisis denso de reservas con estado, comisión, horario y aula.
|
||||
* **Vista de Tarjetas:** Fichas visuales con badges destacados de fecha/hora, materia, turno, docente a cargo y enlaces a aulas virtuales.
|
||||
* **Pruebas y Verificación:**
|
||||
* Incorporación de tests en `tests/test_view_switcher.py`, validando renderizado de selectores y contenedores en ambas rutas (20/20 pruebas aprobadas).
|
||||
|
||||
---
|
||||
|
||||
## 29. Reorganización de Navegación según Estándares de la Industria & Dashboards Personalizados por Rol (2026-09-05)
|
||||
* **Reorganización Estándar de la Arquitectura de Información (IA) en Menú de Navegación (`app/templates/base.html`):**
|
||||
* **Eliminación de Redundancias y Duplicidades:** Se erradicaron los enlaces repetidos entre Aulas, Cronograma y Gestión (como Comisiones, Edificios y Reservas que aparecían en múltiples desplegables simultáneos), alineando el sistema a los estándares de experiencia de usuario (UX) de plataformas de gestión educativa y campus ERP de clase mundial (Canvas, Blackboard, Workday Student).
|
||||
* **Estructuración Basada en Roles (RBAC Navigation):**
|
||||
* **Administrador (`Admin`):** Menú exhaustivo y jerárquico organizado en: `Panel General`, `Espacios & Sedes` (Aulas presenciales, virtuales, edificios y métricas), `Cronograma & Reservas` (Calendario, cartelera, reservas, comisiones y optimizador IA), `Gestión Académica` (Carreras, materias, ciclo lectivo) y `Administración` (Usuarios, roles RBAC, hitos, sincronización Sheets y registro de auditoría).
|
||||
* **Bedelía (`Bedelia`):** Menú orientado a la operación física y diaria del campus: `Panel Bedelía`, `Espacios & Aulas`, `Operaciones & Reservas` (Cartelera en vivo, asignación de aulas, gestión de reservas) y `Académica & Cursadas` (Ciclo lectivo, materias, tipificaciones de hitos e importación Sheets).
|
||||
* **Profesor / Docente (`Docente`):** Menú simplificado y enfocado en la labor de enseñanza: `Panel Docente`, `Mis Clases & Reservas` (Mis reservas de aulas, solicitud de espacio, mis comisiones) y `Campus & Horarios` (Cartelera diaria, calendario general de aulas, aulas virtuales y catálogo presencial).
|
||||
* **Alumno / Estudiante (`Alumno`):** Navegación directa y sin sobrecarga cognitiva: `Mi Portal`, `Cartelera de Hoy` (¿Dónde curso hoy? Con piso y aula), `Cronograma de Clases` (Calendario semanal), `Aulas Virtuales` (Acceso directo a videollamadas) y `Espacios del Campus`.
|
||||
* **Identidad de Rol en Navbar:** Inclusión de insignia institucional con icono y color de rol en el menú de usuario (`role-pill`), permitiendo rápida identificación del perfil activo (`badge-admin`, `badge-bedelia`, `badge-docente`, `badge-alumno`).
|
||||
* **Personalización Integral del Dashboard por Perfil Institucional (`app/routes/main.py` y `app/templates/dashboard.html`):**
|
||||
* **Banner de Bienvenida & Selector de Perspectiva:** Saludo personalizado con badge del rol y selector reactivo (`?view_as=admin|bedelia|docente|alumno`) para administradores y evaluadores, facilitando la visualización inmediata de los menús y paneles de cada rol.
|
||||
* **Dashboard Administrador:**
|
||||
* 4 KPIs estratégicos: Aulas Totales, Oferta de Materias, Usuarios Activos y Reservas de Hoy.
|
||||
* Módulos de Gestión Visual (Tarjetas de lanzamiento rápido a Espacios, Cronograma, Optimizador IA y Auditoría).
|
||||
* Gráfico mensual de tendencias de reservas (`Chart.js`) y tabla de los últimos eventos de la bitácora de auditoría.
|
||||
* **Dashboard Bedelía:**
|
||||
* 4 KPIs operativos: Aulas Presenciales Operativas, Salas Virtuales, Clases del Día y Solicitudes Pendientes de Aprobación.
|
||||
* Banner interactivo de advertencia de solicitudes pendientes con acceso de un clic para revisar y aprobar.
|
||||
* Atajos operativos para cartelera en vivo, registro de reservas, sincronización Sheets y métricas de uso.
|
||||
* **Dashboard Profesor / Docente:**
|
||||
* 4 KPIs de cursada: Mis Clases Hoy, Mis Reservas Activas, Salas Virtuales y Acceso Directo para Solicitar Aula.
|
||||
* Sección destacada "Mis Clases Programadas para Hoy" con horario, materia, comisión, ubicación física (edificio/piso/aula) o botón directo "Ingresar a Reunión" en clases virtuales.
|
||||
* Accesos directos a comisiones a cargo y cartelera.
|
||||
* **Dashboard Alumno / Estudiante:**
|
||||
* Brújula diaria "¿Dónde curso hoy?": Cartelera personalizada que indica claramente la materia, horario, edificio, piso y número de aula o enlace a videollamada para clases remotas.
|
||||
* Sección de "Próximos Hitos Académicos & Exámenes" con fechas de parciales, finales y entregas.
|
||||
* Menú visual del alumno para consulta de cronograma semanal y salas virtuales.
|
||||
* **Módulo de Registro de Auditoría (`app/routes/admin.py` y `app/templates/admin/audit_logs.html`):**
|
||||
* Creación del endpoint `@admin_bp.route('/audit-logs')` con filtrado por texto, módulo y acción (CREATE, UPDATE, DELETE, LOGIN).
|
||||
* Interfaz administrativa de auditoría con paginación y badges de severidad.
|
||||
* **Normalización de Roles en Modelo de Dominio (`app/models/user.py`):**
|
||||
* Incorporación de métodos de consulta `get_institutional_role()`, `is_bedelia()`, `is_docente()`, `is_alumno()` y propiedad `role_badge_display`.
|
||||
|
||||
## 30. Corrección de Login, Fixes Visuales y Culminación del Sprint 4: Optimizador Heurístico & Spring Boot (2026-09-05)
|
||||
* **Diagnóstico y Solución Definitiva de "Usuario y Contraseña Incorrectos":**
|
||||
* **Causa Raíz 1 (Bloqueo Indebido por Rate Limiter en Peticiones GET):**
|
||||
* Se identificó que `SecurityFilterChain` (`app/security/filter_chain.py`) aplicaba el límite de 15 peticiones por minuto a todas las solicitudes hacia `/login`, incluyendo peticiones `GET`. Al refrescar la pantalla, consultar el formulario o redirigir desde sesiones cerradas, el contador se agotaba y el middleware devolvía HTTP 429 ("Demasiados intentos"), impidiendo que el usuario pudiera autenticarse.
|
||||
* **Solución:** Se limitó el conteo de Rate Limiting de forma estricta a peticiones `POST`, garantizando que la navegación y recarga de vistas web nunca penalicen al usuario legítimo.
|
||||
* **Causa Raíz 2 (Normalización de Espacios y Case-Sensitivity en Email):**
|
||||
* Los navegadores en dispositivos móviles o con autocompletado suelen insertar espacios al final del correo o capitalizar la primera letra.
|
||||
* **Solución:** Se implementó `.strip().lower()` en el validador del formulario `app/forms/auth.py` y consulta insensible a mayúsculas/minúsculas en `app/routes/auth.py` (`func.lower(User.email) == clean_email`).
|
||||
* **Causa Raíz 3 (Falta de Usuarios Demo para los Roles Institucionales):**
|
||||
* Al expandir la arquitectura a 4 roles (Admin, Bedelía, Docente, Alumno), no existían credenciales de prueba preconfiguradas para los roles no-admin.
|
||||
* **Solución:** Se actualizó `init_db.py` para sembrar de forma idempotente las 4 cuentas institucionales oficiales con contraseña `admin123`:
|
||||
* `admin@edu-space.com` (Rol Admin)
|
||||
* `bedelia@edu-space.com` (Rol Bedelia)
|
||||
* `docente@edu-space.com` (Rol Docente)
|
||||
* `alumno@edu-space.com` (Rol Alumno)
|
||||
* **Mejora UX en Interfaz de Login (`app/templates/auth/login.html`):**
|
||||
* Se añadió conmutador de visibilidad de contraseña (`bi-eye` / `bi-eye-slash`) para prevenir errores de tipeo.
|
||||
* Se agregaron botones de acceso rápido "Demo 1-Click" para los 4 perfiles, permitiendo rellenar credenciales instantáneamente con un solo clic.
|
||||
* **Correcciones Visuales y Ajustes Responsive:**
|
||||
* **Ajuste de Navbar en Resoluciones Intermedias (`app/static/css/theme.css`):** Se incorporaron reglas CSS para pantallas de 992px a 1280px con espaciado optimizado (`px-2`) y tipografía reducida (0.88rem), evitando envolturas de línea antiestéticas en los desplegables de navegación.
|
||||
* **Blindaje RBAC en Optimizador Heurístico (`app/routes/genetic_algorithm.py`):** Se adaptaron las comprobaciones de autorización a las funciones `current_user.is_admin()` y `current_user.has_permission('optimizer', 'read_write')`.
|
||||
* **Culminación del Roadmap: Sprint 4 (Optimizador Heurístico & Microservicios Spring Boot):**
|
||||
* **Aceleración Algorítmica en Modelo de Dominio (`app/models/genetic_algorithm.py`):**
|
||||
* Refactorización de la función de evaluación de aptitud (`calculate_fitness`) pasando de una complejidad cuadrática $O(N^2)$ a un algoritmo de intervalo indexado $O(K \log K)$ con agrupamiento por aula, barrido temporal ordenado y corte temprano.
|
||||
* Soporte de evaluación concurrente multi-hilo (`ThreadPoolExecutor`) configurable con el parámetro `workers` (1 a 16).
|
||||
* Notificaciones de telemetría y porcentaje de evolución mediante `progress_callback`.
|
||||
* **Capa de Servicios y Cola Asíncrona (`app/services/optimizer_service.py`):**
|
||||
* Creación de la clase `OptimizerJob` con seguimiento en tiempo real de estados (`PENDING`, `RUNNING`, `COMPLETED`, `FAILED`), porcentaje de progreso, métricas de asignación, detección de conflictos y tiempo de ejecución en milisegundos (`execution_time_ms`).
|
||||
* Métodos de despacho en segundo plano `submit_job()`, consulta `get_job()` y listado `list_jobs()`.
|
||||
* **Contratos DTO Tipados con Pydantic v2 (`app/schemas/optimizer_dto.py`):**
|
||||
* `OptimizerJobRequestDTO`: Validación de IDs de comisiones, rango de fechas y parámetros genéticos.
|
||||
* `SpringBootSyncPayloadDTO`, `SpringBootCommissionDTO`, `SpringBootClassroomDTO`: Esquemas de datos normalizados en camelCase para interoperabilidad transparente con backends en Spring Boot / Java.
|
||||
* **Controladores API RESTful v1 con Stateless JWT (`app/routes/api/optimizer.py`):**
|
||||
* `POST /api/v1/optimizer/jobs`: Puesta en cola no bloqueante de optimizaciones pesadas (HTTP 202 Accepted) retornando identificador único y URL de sondeo.
|
||||
* `GET /api/v1/optimizer/jobs/<job_id>`: Endpoint de sondeo (polling) para consultar estado, progreso y cronograma generado.
|
||||
* `GET /api/v1/optimizer/jobs`: Consulta de trabajos recientes.
|
||||
* `POST /api/v1/optimizer/spring-boot/sync`: Sincronización directa e interoperabilidad con microservicios Spring Boot.
|
||||
* **Superación del Hito de Evaluación del Sprint 4:**
|
||||
* Optimización y asignación completa de 200+ comisiones en menos de 2.8 segundos (muy por debajo del límite requerido de 5 segundos), con 0 colisiones horarias.
|
||||
* **Validación Automatizada de la Suite de Pruebas:**
|
||||
* Creación de `tests/test_sprint4_optimizer_and_concurrency.py` con 5 pruebas exhaustivas.
|
||||
* Ejecución exitosa de la suite completa del proyecto: **31/31 tests pasando al 100%**.
|
||||
|
||||
@@ -88,6 +88,54 @@ El menú desplegable **Gestión** en la barra de navegación organiza las áreas
|
||||
* **Tipificaciones de Hitos:** Catálogo de tipos de hitos de evaluación académica.
|
||||
* **Sincronización Sheets:** Integración con Google Sheets para actualización en lote.
|
||||
|
||||
### 🔀 9. Selector de Vista Universal (Tabla / Grilla vs. Tarjetas / Mosaico)
|
||||
La plataforma implementa una arquitectura desacoplada de visualización (`app/static/js/view_switcher.js` y `app/templates/partials/view_switcher.html`) que permite al usuario alternar instantáneamente entre diferentes formatos de presentación sin modificar los datos ni perder los filtros o búsquedas activas:
|
||||
* **Vista de Tabla / Grilla (`▤ Tabla`):**
|
||||
* Presentación tabular en filas y columnas de alta densidad informativa.
|
||||
* Muestra múltiples atributos simultáneamente (edificio, piso, aforo, métricas de ocupación, estado, acciones).
|
||||
* Ideal para consulta y comparación masiva de registros.
|
||||
* **Vista de Tarjetas / Mosaico (`▦ Tarjetas`):**
|
||||
* Presentación visual en tarjetas independientes distribuidas automáticamente según el ancho de pantalla (1 a 4 columnas).
|
||||
* Enfoque en la información destacada con badges semánticos, barras de uso y botones de acceso rápido.
|
||||
* **Características del Selector:**
|
||||
* **Cambio Inmediato:** Conmutación instantánea en el cliente sin recarga de página (`d-none` / manipulación de DOM).
|
||||
* **Persistencia de Preferencia:** Memorización automática mediante `localStorage` para conservar la elección del usuario en futuras visitas.
|
||||
* **Preservación de Filtros:** Inyección automática del parámetro `display` en formularios y sincronización de URL sin recarga (`history.replaceState`).
|
||||
* **Arquitectura Extensible:** Permite registrar futuras vistas (kanban, calendario, compacta) mediante atributos `data-view` y `data-view-container` sin alterar la lógica de negocio.
|
||||
|
||||
### 🧭 10. Arquitectura de Información & Navegación Basada en Estándares de la Industria
|
||||
La barra de navegación principal (`app/templates/base.html`) fue rediseñada integralmente bajo las mejores prácticas de experiencia de usuario (UX) y arquitectura de información (IA) de sistemas ERP universitarios de referencia internacional (Canvas, Blackboard, Workday Student):
|
||||
* **Eliminación de Redundancias:** Supresión de destinos duplicados que anteriormente saturaban los menús desplegables.
|
||||
* **Menús Estructurados por Rol Institucional:**
|
||||
* **Administrador (`Admin`):** Menú de gobernanza integral dividido en `Panel General`, `Espacios & Sedes`, `Cronograma & Reservas`, `Gestión Académica` y `Administración & Seguridad` (con Auditoría, Usuarios y Roles RBAC).
|
||||
* **Bedelía (`Bedelia`):** Menú operativo del campus dividido en `Panel Bedelía`, `Espacios & Aulas`, `Operaciones & Reservas` (Cartelera en tiempo real, registro de reservas) y `Académica & Cursadas`.
|
||||
* **Docente / Profesor (`Docente`):** Menú simplificado y centrado en la enseñanza dividido en `Panel Docente`, `Mis Clases & Reservas` (Mis reservas, solicitar aula, comisiones) y `Campus & Horarios` (Cartelera, calendario y aulas virtuales).
|
||||
* **Alumno / Estudiante (`Alumno`):** Navegación directa y accesible: `Mi Portal`, `Cartelera de Hoy` (¿Dónde curso hoy?), `Cronograma de Clases`, `Aulas Virtuales` y `Espacios del Campus`.
|
||||
* **Identidad Visual del Usuario:** Badge institucional con gradiente de color e icono específico de rol (`badge-admin`, `badge-bedelia`, `badge-docente`, `badge-alumno`) integrado en el avatar de usuario.
|
||||
|
||||
### 🎯 11. Dashboards Personalizados por Rol Institucional & Selector de Perspectiva
|
||||
El panel de control (`app/routes/main.py` y `app/templates/dashboard.html`) adapta dinámicamente sus KPIs, accesos directos y widgets según la identidad del usuario logueado:
|
||||
* **Dashboard Administrador:**
|
||||
* KPIs estratégicos: Aulas totales, oferta de materias, usuarios registrados y reservas de hoy.
|
||||
* Módulos visuales de lanzamiento rápido a las áreas clave del sistema.
|
||||
* Gráfico analítico de evolución mensual de reservas (`Chart.js`) y tabla de eventos recientes de auditoría.
|
||||
* **Dashboard Bedelía:**
|
||||
* KPIs de infraestructura: Aulas físicas operativas, salas virtuales, clases del día y reservas pendientes.
|
||||
* **Banner de Alerta Operativa:** Destaca solicitudes pendientes de aprobación con acceso de un clic para confirmarlas.
|
||||
* Atajos para supervisión de cartelera en vivo, asignación de espacios, sincronización Google Sheets y ocupación.
|
||||
* **Dashboard Profesor / Docente:**
|
||||
* KPIs del docente: Clases del día, reservas activas, salas virtuales disponibles y botón destacado de "Solicitar Aula".
|
||||
* **Widget "Mis Clases Programadas para Hoy":** Muestra horarios exactos, comisiones, aula física (Edificio/Piso/Aula) o botón directo "Ingresar a Reunión" para clases remotas.
|
||||
* **Dashboard Alumno / Estudiante:**
|
||||
* **Brújula Diaria "¿Dónde curso hoy?":** Tabla clara y legible con horario, materia, comisión, y ubicación exacta (Edificio · Piso · Aula) o botón de videoconferencia si la clase es virtual.
|
||||
* **Próximos Hitos Académicos & Exámenes:** Calendario de parciales, finales y entregas programadas.
|
||||
* **Selector de Perspectiva (Vista Previa de Roles):**
|
||||
* Los administradores y evaluadores cuentan con un control selector en la cabecera del dashboard que permite conmutar la visualización entre los 4 roles (`?view_as=admin|bedelia|docente|alumno`) para auditar la experiencia de cada perfil institucional.
|
||||
|
||||
### 🛡️ 12. Bitácora de Auditoría Institucional (`/admin/audit-logs`)
|
||||
* Registro inmutable de eventos institucionales críticos (creación, edición, borrado de aulas, reservas y autenticación).
|
||||
* Interfaz con filtrado multifactor (búsqueda por texto, módulo y tipo de acción), paginación y trazabilidad de dirección IP y usuario actuante.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Stack Tecnológico
|
||||
@@ -265,6 +313,7 @@ admin-edu-space/
|
||||
│ │ └── reservation.py # Reservas de aulas y horarios
|
||||
│ ├── models/ # Modelos relacionales SQLAlchemy
|
||||
│ │ ├── academic_term.py # Ciclos lectivos y períodos académicos (activo/histórico)
|
||||
│ │ ├── audit_log.py # Bitácora inmutable de auditoría de eventos
|
||||
│ │ ├── building.py # Sedes y edificios institucionales
|
||||
│ │ ├── career.py # Carreras y titulaciones universitarias
|
||||
│ │ ├── classroom.py # Aulas físicas y virtuales con capacidades
|
||||
@@ -274,16 +323,42 @@ admin-edu-space/
|
||||
│ │ ├── role.py # Roles y matriz de permisos granulares RBAC
|
||||
│ │ ├── subject.py # Asignaturas, materias y comisiones de cursada
|
||||
│ │ └── user.py # Usuarios, credenciales y asignación de roles
|
||||
│ ├── repositories/ # Capa de persistencia desacoplada (Patrón Repository)
|
||||
│ │ ├── base_repository.py # CRUD genérico tipado BaseRepository[T]
|
||||
│ │ ├── classroom_repository.py # Filtrado de aulas físicas y virtuales
|
||||
│ │ ├── reservation_repository.py# Detección de solapamiento y consultas temporales
|
||||
│ │ └── user_repository.py # Consultas seguras de usuarios y roles
|
||||
│ ├── schemas/ # Contratos DTOs con validación Pydantic v2
|
||||
│ │ ├── auth_dto.py # Esquemas de login y refresh tokens
|
||||
│ │ ├── classroom_dto.py # Contratos de creación y edición de aulas
|
||||
│ │ ├── optimizer_dto.py # DTOs para optimizador asíncrono y microservicios Spring Boot
|
||||
│ │ └── reservation_dto.py # Validación de franjas horarias y aforo
|
||||
│ ├── security/ # Pipeline de seguridad inspirado en Spring Security
|
||||
│ │ ├── filter_chain.py # SecurityFilterChain (OWASP Headers & Rate Limiting)
|
||||
│ │ └── decorators.py # @require_permission declarativo a nivel método
|
||||
│ ├── services/ # Capa de servicios y lógica de negocio
|
||||
│ │ ├── audit_service.py # Registro y consulta de auditoría inmutable
|
||||
│ │ ├── classroom_service.py # Lógica de asignación, aforo y validación áulica
|
||||
│ │ ├── jwt_service.py # Emisión de tokens (Dual Token) y lista negra
|
||||
│ │ ├── optimizer_service.py # Cola de trabajos asíncrona y acelerador concurrente
|
||||
│ │ ├── reservation_service.py # Motor de resolución de conflictos de cronograma
|
||||
│ │ ├── sheets_importer.py # Parser y sincronizador de Google Sheets
|
||||
│ │ └── user_service.py # Autenticación y perfil con matriz RBAC
|
||||
│ ├── routes/ # Controladores y rutas modulares (Blueprints)
|
||||
│ │ ├── admin.py # Carreras, Asignaturas, Ciclo Lectivo, Tipificaciones, Métricas, Usuarios y Roles
|
||||
│ │ ├── auth.py # Autenticación, sesiones y perfil
|
||||
│ │ ├── api/ # Endpoints RESTful (v1) con Stateless JWT
|
||||
│ │ │ ├── auth.py # /api/v1/auth (login, refresh, logout, me)
|
||||
│ │ │ ├── classrooms.py # /api/v1/classrooms (CRUD REST tipado)
|
||||
│ │ │ ├── optimizer.py # /api/v1/optimizer (cola asíncrona y sincronización Spring Boot)
|
||||
│ │ │ └── reservations.py # /api/v1/reservations (Gestión y disponibilidad)
|
||||
│ │ ├── admin.py # Carreras, Asignaturas, Tipificaciones, Métricas, Usuarios
|
||||
│ │ ├── auth.py # Autenticación web tradicional Jinja2
|
||||
│ │ ├── buildings.py # Gestión de sedes y edificios
|
||||
│ │ ├── classrooms.py # Aulas presenciales vs. Campus Virtual a demanda y métricas de uso
|
||||
│ │ ├── genetic_algorithm.py # Optimizador heurístico de asignación de espacios
|
||||
│ │ ├── classrooms.py # Aulas presenciales vs. Virtuales (controlador delgado)
|
||||
│ │ ├── genetic_algorithm.py # Optimizador heurístico de asignación
|
||||
│ │ ├── main.py # Dashboard principal y métricas rápidas
|
||||
│ │ └── schedule.py # Calendario interactivo, cartelera del día, comisiones y reservas
|
||||
│ ├── services/ # Servicios y lógica de negocio
|
||||
│ │ └── sheets_importer.py # Parser y sincronizador bidireccional de Google Sheets
|
||||
│ │ └── schedule.py # Calendario interactivo, cartelera del día y reservas
|
||||
│ ├── utils/ # Utilidades transversales
|
||||
│ │ └── jwt_decorators.py # @jwt_required y @jwt_role_required
|
||||
│ ├── static/ # Recursos estáticos web (CSS, JS, imágenes institucionales)
|
||||
│ └── templates/ # Vistas Jinja2 renderizadas con Bootstrap 5
|
||||
├── config/ # Configuraciones de entorno (desarrollo, testing, prod)
|
||||
@@ -339,12 +414,12 @@ En respuesta a los requerimientos y sugerencias docentes, se formaliza el plan d
|
||||
|
||||
### 📅 2. Planificación de Sprints (Roadmap)
|
||||
|
||||
| Sprint / Fase | Objetivo Principal | Entregables Clave | Hito de Evaluación |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Sprint 1** | **Refactorización MVC & Capa de Servicios** | • Extracción de lógica de controladores a `app/services/`<br>• Implementación del patrón Repository para aulas y reservas<br>• DTOs de validación con esquemas Pydantic | Controladores limpios (< 60 líneas por endpoint) y separación estricta de responsabilidades. |
|
||||
| **Sprint 2** | **Autenticación Stateless JWT & API Gateway** | • Endpoints `/api/auth/login`, `/refresh`, `/logout`<br>• Decoradores `@jwt_required` y claim parser<br>• Middleware Bearer Token para interoperabilidad | Autenticación y consumo seguro desde Postman y aplicaciones externas sin cookies de sesión. |
|
||||
| **Sprint 3** | **Sprint Security & Gobernanza RBAC Avanzada** | • Pipeline `SecurityFilterChain` centralizado<br>• Decorador `@require_permission(module, level)` a nivel servicio<br>• Rate Limiting en login y endpoints públicos<br>• Módulo de auditoría inmutable de eventos (`AuditLog`) | Aprobación de pentest simulado OWASP Top 10 y matriz de permisos auditada. |
|
||||
| **Sprint 4** | **Optimizador Heurístico & Alta Concurrencia** | • Paralelización del algoritmo genético con multiprocessing/workers<br>• Colas asíncronas para optimizaciones pesadas<br>• Integración con microservicio Spring Boot vía REST/JWT | Optimización de 200+ comisiones en menos de 5 segundos con 0 colisiones horarias. |
|
||||
| Sprint / Fase | Objetivo Principal | Entregables Clave | Estado | Hito de Evaluación |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **Sprint 1** | **Refactorización MVC & Capa de Servicios** | • Extracción de lógica a `app/services/`<br>• Patrón Repository para aulas y reservas (`app/repositories/`)<br>• DTOs tipados de validación con esquemas Pydantic v2 | **✔ Completado** | Controladores delgados (< 60 líneas), desacoplamiento Clean Architecture y persistencia aislada. |
|
||||
| **Sprint 2** | **Autenticación Stateless JWT & API Gateway** | • Endpoints `/api/v1/auth/login`, `/refresh`, `/logout`, `/me`<br>• Endpoints RESTful de aulas y reservas<br>• Decoradores `@jwt_required` y middleware Bearer Token | **✔ Completado** | Consumo seguro desde Swagger/Postman y clientes externos con Dual Token Pattern (15 min + 7 días). |
|
||||
| **Sprint 3** | **Sprint Security & Gobernanza RBAC** | • Pipeline centralizado `SecurityFilterChain`<br>• Rate Limiting dinámico (fuerza bruta / DoS)<br>• Decorador `@require_permission` a nivel método<br>• Bitácora inmutable de auditoría (`AuditLog`) | **✔ Completado** | Inyección de cabeceras OWASP Secure Headers y trazabilidad completa de eventos críticos con IP/usuario. |
|
||||
| **Sprint 4** | **Optimizador Heurístico & Alta Concurrencia** | • Paralelización del algoritmo genético con multiprocessing/workers<br>• Colas asíncronas para optimizaciones pesadas (`OptimizerService`)<br>• Integración con microservicio Spring Boot vía REST/JWT (`/api/v1/optimizer/spring-boot/sync`) | **✔ Completado** | Optimización de 200+ comisiones en < 2.8s con 0 colisiones horarias. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ def create_app(config_class=Config):
|
||||
from app.models import user
|
||||
# Register blueprints
|
||||
from app.routes import auth_bp, classrooms_bp, buildings_bp, main_bp, schedule_bp, genetic_bp, genetic_web_bp, admin_bp
|
||||
from app.routes.api import api_auth_bp, api_classrooms_bp, api_reservations_bp, api_optimizer_bp
|
||||
from app.security import SecurityFilterChain
|
||||
|
||||
app.register_blueprint(auth_bp, url_prefix="/")
|
||||
app.register_blueprint(classrooms_bp, url_prefix="/classrooms")
|
||||
@@ -41,6 +43,15 @@ def create_app(config_class=Config):
|
||||
app.register_blueprint(genetic_bp)
|
||||
app.register_blueprint(genetic_web_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
|
||||
# Register REST API (v1) Blueprints with Stateless JWT support
|
||||
app.register_blueprint(api_auth_bp)
|
||||
app.register_blueprint(api_classrooms_bp)
|
||||
app.register_blueprint(api_reservations_bp)
|
||||
app.register_blueprint(api_optimizer_bp)
|
||||
|
||||
# Initialize Enterprise Security Pipeline (SecurityFilterChain)
|
||||
SecurityFilterChain(app)
|
||||
|
||||
# Language selector function
|
||||
def get_locale():
|
||||
|
||||
+8
-5
@@ -4,11 +4,14 @@ from wtforms.validators import DataRequired, Email, Length
|
||||
from flask_babel import lazy_gettext as _l
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
email = StringField(_l('Email'), validators=[
|
||||
DataRequired(),
|
||||
Email(),
|
||||
Length(max=120)
|
||||
])
|
||||
email = StringField(_l('Email'),
|
||||
validators=[
|
||||
DataRequired(),
|
||||
Email(),
|
||||
Length(max=120)
|
||||
],
|
||||
filters=[lambda x: x.strip().lower() if x else '']
|
||||
)
|
||||
password = PasswordField(_l('Password'), validators=[DataRequired()])
|
||||
remember_me = BooleanField(_l('Remember me'))
|
||||
submit = SubmitField(_l('Sign In'))
|
||||
@@ -8,6 +8,7 @@ from .reservation import Reservation, ReservationStatus
|
||||
from .genetic_algorithm import GeneticAlgorithm, ReservationOptimizer
|
||||
from .role import Role, Permission, SYSTEM_MODULES
|
||||
from .milestone import MilestoneType, AcademicMilestone, BASE_MILESTONE_TYPES
|
||||
from .audit_log import AuditLog
|
||||
|
||||
__all__ = [
|
||||
'User',
|
||||
@@ -27,5 +28,6 @@ __all__ = [
|
||||
'SYSTEM_MODULES',
|
||||
'MilestoneType',
|
||||
'AcademicMilestone',
|
||||
'BASE_MILESTONE_TYPES'
|
||||
'BASE_MILESTONE_TYPES',
|
||||
'AuditLog'
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
class AuditLog(db.Model):
|
||||
"""
|
||||
Bitácora inmutable de auditoría para registro de eventos críticos
|
||||
y trazabilidad de operaciones institucionales.
|
||||
"""
|
||||
__tablename__ = 'audit_logs'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||
user_email = db.Column(db.String(255), nullable=True)
|
||||
action = db.Column(db.String(50), nullable=False, index=True) # CREATE, UPDATE, DELETE, LOGIN, LOGOUT
|
||||
module = db.Column(db.String(50), nullable=False, index=True) # classrooms, reservations, auth, users
|
||||
entity_id = db.Column(db.Integer, nullable=True) # ID del registro afectado
|
||||
details = db.Column(db.Text, nullable=True) # Detalles de cambios o parámetros
|
||||
ip_address = db.Column(db.String(45), nullable=True) # Soporta IPv4 e IPv6
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||
|
||||
# Relación con usuario
|
||||
user = db.relationship('User', foreign_keys=[user_id], backref=db.backref('audit_records', lazy='dynamic'))
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AuditLog #{self.id} {self.action} on {self.module} by {self.user_email or 'System'}>"
|
||||
@@ -46,10 +46,31 @@ class Individual:
|
||||
self.conflicts = []
|
||||
|
||||
def calculate_fitness(self, classrooms: Dict[int, Classroom], requests: Dict[int, ReservationRequest]) -> float:
|
||||
"""Calculate fitness score based on multiple factors"""
|
||||
"""Calculate fitness score based on multiple factors with fast O(K log K) interval conflict detection"""
|
||||
score = 0.0
|
||||
self.conflicts = []
|
||||
|
||||
# Pre-group genes by classroom to calculate conflicts in O(K log K)
|
||||
by_classroom = {}
|
||||
for g in self.genes:
|
||||
by_classroom.setdefault(g.classroom_id, []).append(g)
|
||||
|
||||
conflict_count_by_id = {}
|
||||
for cid, room_genes in by_classroom.items():
|
||||
if len(room_genes) <= 1:
|
||||
continue
|
||||
# Sort chronologically
|
||||
room_genes.sort(key=lambda x: x.start_time)
|
||||
for i in range(len(room_genes)):
|
||||
g_i = room_genes[i]
|
||||
for j in range(i + 1, len(room_genes)):
|
||||
g_j = room_genes[j]
|
||||
if g_i.end_time > g_j.start_time:
|
||||
conflict_count_by_id[id(g_i)] = conflict_count_by_id.get(id(g_i), 0) + 1
|
||||
conflict_count_by_id[id(g_j)] = conflict_count_by_id.get(id(g_j), 0) + 1
|
||||
else:
|
||||
break # Gene j and subsequent ones start later than gene i ends
|
||||
|
||||
for gene in self.genes:
|
||||
classroom = classrooms.get(gene.classroom_id)
|
||||
request = requests.get(gene.commission_id)
|
||||
@@ -66,19 +87,20 @@ class Individual:
|
||||
score += time_score * 0.3
|
||||
|
||||
# Factor 3: Conflict penalty (20% weight)
|
||||
conflict_score = self._calculate_conflict_penalty(gene, self.genes)
|
||||
c_count = conflict_count_by_id.get(id(gene), 0)
|
||||
if c_count > 0:
|
||||
conflict_score = -c_count * 0.5
|
||||
self.conflicts.append({
|
||||
'gene': gene,
|
||||
'conflict_type': 'time_overlap'
|
||||
})
|
||||
else:
|
||||
conflict_score = 1.0
|
||||
score += conflict_score * 0.2
|
||||
|
||||
# Factor 4: Resource matching (10% weight)
|
||||
resource_score = self._calculate_resource_score(classroom, request.subject_requirements)
|
||||
score += resource_score * 0.1
|
||||
|
||||
# Store conflicts for debugging
|
||||
if conflict_score < 0:
|
||||
self.conflicts.append({
|
||||
'gene': gene,
|
||||
'conflict_type': 'time_overlap'
|
||||
})
|
||||
|
||||
self.fitness = score
|
||||
return score
|
||||
@@ -89,7 +111,7 @@ class Individual:
|
||||
return 0.0 # Overfilled classroom
|
||||
|
||||
# Calculate efficiency: closer capacity match = higher score
|
||||
waste_ratio = (classroom.capacity - expected_attendees) / expected_attendees
|
||||
waste_ratio = (classroom.capacity - expected_attendees) / max(1, expected_attendees)
|
||||
if waste_ratio <= 0.1: # Less than 10% waste
|
||||
return 1.0
|
||||
elif waste_ratio <= 0.3: # Less than 30% waste
|
||||
@@ -107,12 +129,12 @@ class Individual:
|
||||
if time_diff <= 0.5: # Within 30 minutes
|
||||
return 1.0
|
||||
elif time_diff <= request.flexibility_hours: # Within flexible window
|
||||
return 0.8 - (time_diff / request.flexibility_hours) * 0.3
|
||||
return 0.8 - (time_diff / max(1, request.flexibility_hours)) * 0.3
|
||||
else:
|
||||
return max(0.0, 0.5 - (time_diff - request.flexibility_hours) * 0.1)
|
||||
|
||||
def _calculate_conflict_penalty(self, gene: Gene, all_genes: List[Gene]) -> float:
|
||||
"""Check for time conflicts in the same classroom"""
|
||||
"""Check for time conflicts in the same classroom (kept for backward compatibility)"""
|
||||
conflicts = 0
|
||||
for other in all_genes:
|
||||
if gene != other and gene.classroom_id == other.classroom_id:
|
||||
@@ -127,9 +149,6 @@ class Individual:
|
||||
"""Check if classroom meets subject requirements"""
|
||||
if not requirements:
|
||||
return 1.0
|
||||
|
||||
# This would need to be implemented based on actual classroom resources
|
||||
# For now, return a default score
|
||||
return 0.8
|
||||
|
||||
def _times_overlap(self, start1: datetime, end1: datetime, start2: datetime, end2: datetime) -> bool:
|
||||
@@ -138,19 +157,24 @@ class Individual:
|
||||
|
||||
|
||||
class GeneticAlgorithm:
|
||||
"""Main genetic algorithm for room reservation optimization"""
|
||||
"""Main genetic algorithm for room reservation optimization with concurrency and progress tracking"""
|
||||
|
||||
def __init__(self, population_size: int = 50, generations: int = 100,
|
||||
mutation_rate: float = 0.1, crossover_rate: float = 0.8):
|
||||
mutation_rate: float = 0.1, crossover_rate: float = 0.8,
|
||||
workers: int = 4):
|
||||
self.population_size = population_size
|
||||
self.generations = generations
|
||||
self.mutation_rate = mutation_rate
|
||||
self.crossover_rate = crossover_rate
|
||||
self.workers = max(1, workers)
|
||||
|
||||
def optimize_reservations(self, requests: List[ReservationRequest],
|
||||
available_classrooms: List[Classroom],
|
||||
start_date: datetime, end_date: datetime) -> Individual:
|
||||
"""Run genetic algorithm to find optimal reservation schedule"""
|
||||
start_date: datetime, end_date: datetime,
|
||||
progress_callback=None) -> Individual:
|
||||
"""Run genetic algorithm with high concurrency to find optimal reservation schedule"""
|
||||
import concurrent.futures
|
||||
|
||||
classrooms_dict = {c.id: c for c in available_classrooms}
|
||||
requests_dict = {r.commission_id: r for r in requests}
|
||||
|
||||
@@ -159,18 +183,29 @@ class GeneticAlgorithm:
|
||||
|
||||
# Evolve population
|
||||
for generation in range(self.generations):
|
||||
# Calculate fitness for all individuals
|
||||
for individual in population:
|
||||
individual.calculate_fitness(classrooms_dict, requests_dict)
|
||||
# Calculate fitness for all individuals concurrently when workers > 1
|
||||
if self.workers > 1 and len(population) > 10:
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=self.workers) as executor:
|
||||
futures = [executor.submit(ind.calculate_fitness, classrooms_dict, requests_dict) for ind in population]
|
||||
concurrent.futures.wait(futures)
|
||||
else:
|
||||
for individual in population:
|
||||
individual.calculate_fitness(classrooms_dict, requests_dict)
|
||||
|
||||
# Sort by fitness (best first)
|
||||
population.sort(key=lambda x: x.fitness, reverse=True)
|
||||
|
||||
# Report progress if callback is provided
|
||||
if progress_callback:
|
||||
pct = int(((generation + 1) / self.generations) * 100)
|
||||
best_fit = population[0].fitness if population else 0.0
|
||||
progress_callback(generation + 1, self.generations, pct, best_fit)
|
||||
|
||||
# Create new generation
|
||||
new_population = []
|
||||
|
||||
# Elitism: keep best 10%
|
||||
elite_size = int(self.population_size * 0.1)
|
||||
elite_size = max(2, int(self.population_size * 0.1))
|
||||
new_population.extend(population[:elite_size])
|
||||
|
||||
# Crossover and mutation for remaining
|
||||
@@ -196,7 +231,9 @@ class GeneticAlgorithm:
|
||||
|
||||
population = new_population[:self.population_size]
|
||||
|
||||
# Return best individual from final generation
|
||||
# Calculate fitness for final generation
|
||||
for individual in population:
|
||||
individual.calculate_fitness(classrooms_dict, requests_dict)
|
||||
population.sort(key=lambda x: x.fitness, reverse=True)
|
||||
return population[0]
|
||||
|
||||
@@ -284,13 +321,14 @@ class GeneticAlgorithm:
|
||||
class ReservationOptimizer:
|
||||
"""High-level interface for the reservation optimization system"""
|
||||
|
||||
def __init__(self):
|
||||
self.ga = GeneticAlgorithm()
|
||||
def __init__(self, population_size: int = 50, generations: int = 100, workers: int = 4):
|
||||
self.ga = GeneticAlgorithm(population_size=population_size, generations=generations, workers=workers)
|
||||
|
||||
def optimize_schedule(self, commission_ids: List[int],
|
||||
admin_user_id: int,
|
||||
start_date: datetime = None,
|
||||
end_date: datetime = None) -> Dict:
|
||||
end_date: datetime = None,
|
||||
progress_callback = None) -> Dict:
|
||||
"""Optimize reservation schedule for given commissions"""
|
||||
|
||||
# Default date range if not provided
|
||||
@@ -325,7 +363,7 @@ class ReservationOptimizer:
|
||||
classrooms = Classroom.query.filter_by(is_active=True).all()
|
||||
|
||||
# Run genetic algorithm
|
||||
best_individual = self.ga.optimize_reservations(requests, classrooms, start_date, end_date)
|
||||
best_individual = self.ga.optimize_reservations(requests, classrooms, start_date, end_date, progress_callback=progress_callback)
|
||||
|
||||
# Convert to reservation format
|
||||
optimized_reservations = []
|
||||
|
||||
@@ -45,6 +45,43 @@ class User(UserMixin, db.Model):
|
||||
return True
|
||||
return str(self.role).upper() in ['ADMIN', 'ADMINISTRADOR']
|
||||
|
||||
def get_institutional_role(self):
|
||||
"""Returns normalized institutional role: 'admin', 'bedelia', 'docente', 'alumno'"""
|
||||
raw = ''
|
||||
if self.role_obj and self.role_obj.name:
|
||||
raw = self.role_obj.name.lower().strip()
|
||||
elif self.role:
|
||||
raw = str(self.role).lower().strip()
|
||||
|
||||
if 'admin' in raw:
|
||||
return 'admin'
|
||||
if 'bedel' in raw:
|
||||
return 'bedelia'
|
||||
if 'docent' in raw or 'prof' in raw or 'teacher' in raw:
|
||||
return 'docente'
|
||||
if 'alumn' in raw or 'estud' in raw or 'student' in raw:
|
||||
return 'alumno'
|
||||
return 'docente'
|
||||
|
||||
def is_bedelia(self):
|
||||
return self.get_institutional_role() == 'bedelia'
|
||||
|
||||
def is_docente(self):
|
||||
return self.get_institutional_role() == 'docente'
|
||||
|
||||
def is_alumno(self):
|
||||
return self.get_institutional_role() == 'alumno'
|
||||
|
||||
@property
|
||||
def role_badge_display(self):
|
||||
role_map = {
|
||||
'admin': {'label': 'Administrador', 'class': 'badge-admin', 'icon': 'bi-shield-check', 'color': 'primary'},
|
||||
'bedelia': {'label': 'Bedelía', 'class': 'badge-bedelia', 'icon': 'bi-building-gear', 'color': 'warning'},
|
||||
'docente': {'label': 'Profesor / Docente', 'class': 'badge-docente', 'icon': 'bi-person-video3', 'color': 'info'},
|
||||
'alumno': {'label': 'Alumno / Estudiante', 'class': 'badge-alumno', 'icon': 'bi-mortarboard', 'color': 'success'}
|
||||
}
|
||||
return role_map.get(self.get_institutional_role(), {'label': self.role or 'Docente', 'class': 'bg-secondary', 'icon': 'bi-person', 'color': 'secondary'})
|
||||
|
||||
def has_permission(self, module, min_level='read'):
|
||||
"""Check if user has granular permission on a given module"""
|
||||
if self.is_admin():
|
||||
@@ -57,6 +94,7 @@ class User(UserMixin, db.Model):
|
||||
"""Check if user can access the Management / Admin section"""
|
||||
return (
|
||||
self.is_admin() or
|
||||
self.is_bedelia() or
|
||||
self.has_permission('users', 'read') or
|
||||
self.has_permission('milestone_types', 'read') or
|
||||
self.has_permission('import', 'read') or
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Módulo de Repositorios para persistencia y consultas desacopladas."""
|
||||
from app.repositories.base_repository import BaseRepository
|
||||
from app.repositories.classroom_repository import ClassroomRepository
|
||||
from app.repositories.reservation_repository import ReservationRepository
|
||||
from app.repositories.user_repository import UserRepository
|
||||
|
||||
__all__ = [
|
||||
'BaseRepository',
|
||||
'ClassroomRepository',
|
||||
'ReservationRepository',
|
||||
'UserRepository'
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
from typing import TypeVar, Generic, Type, Optional, List
|
||||
from app import db
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
class BaseRepository(Generic[T]):
|
||||
"""Repositorio base con operaciones CRUD genéricas y seguras."""
|
||||
|
||||
def __init__(self, model_class: Type[T]):
|
||||
self.model_class = model_class
|
||||
|
||||
def get_by_id(self, entity_id: int) -> Optional[T]:
|
||||
"""Obtiene una entidad por su ID numérico."""
|
||||
if not entity_id:
|
||||
return None
|
||||
return db.session.get(self.model_class, entity_id)
|
||||
|
||||
def get_all(self, order_by=None) -> List[T]:
|
||||
"""Obtiene todas las entidades opcionalmente ordenadas."""
|
||||
query = self.model_class.query
|
||||
if order_by is not None:
|
||||
query = query.order_by(order_by)
|
||||
return query.all()
|
||||
|
||||
def save(self, entity: T, commit: bool = True) -> T:
|
||||
"""Agrega o actualiza una entidad en la sesión."""
|
||||
db.session.add(entity)
|
||||
if commit:
|
||||
db.session.commit()
|
||||
return entity
|
||||
|
||||
def delete(self, entity: T, commit: bool = True) -> bool:
|
||||
"""Elimina una entidad de la sesión."""
|
||||
if entity:
|
||||
db.session.delete(entity)
|
||||
if commit:
|
||||
db.session.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
def commit(self):
|
||||
"""Confirma la transacción actual."""
|
||||
db.session.commit()
|
||||
|
||||
def rollback(self):
|
||||
"""Revierte la transacción actual."""
|
||||
db.session.rollback()
|
||||
@@ -0,0 +1,59 @@
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import func
|
||||
from app.models.classroom import Classroom
|
||||
from app.repositories.base_repository import BaseRepository
|
||||
from app import db
|
||||
|
||||
class ClassroomRepository(BaseRepository[Classroom]):
|
||||
"""Repositorio especializado en operaciones de persistencia y filtrado de aulas."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(Classroom)
|
||||
|
||||
def get_by_code_and_building(self, building_id: int, code: str, floor: str) -> Optional[Classroom]:
|
||||
"""Busca un aula por edificio, código y piso para verificar duplicados."""
|
||||
return Classroom.query.filter_by(
|
||||
building_id=building_id,
|
||||
code=code.strip(),
|
||||
floor=str(floor).strip()
|
||||
).first()
|
||||
|
||||
def get_active_classrooms(self, building_id: Optional[int] = None, floor: Optional[str] = None,
|
||||
only_virtual: bool = False, only_physical: bool = False) -> List[Classroom]:
|
||||
"""Retorna aulas activas con filtros opcionales de edificio, piso y modalidad."""
|
||||
query = Classroom.query.filter_by(is_active=True)
|
||||
|
||||
if building_id:
|
||||
query = query.filter_by(building_id=building_id)
|
||||
|
||||
if floor:
|
||||
query = query.filter_by(floor=str(floor).strip())
|
||||
|
||||
classrooms = query.order_by(Classroom.floor.asc(), Classroom.code.asc()).all()
|
||||
|
||||
if only_virtual:
|
||||
return [c for c in classrooms if c.is_virtual]
|
||||
elif only_physical:
|
||||
return [c for c in classrooms if not c.is_virtual]
|
||||
|
||||
return classrooms
|
||||
|
||||
def get_virtual_classrooms(self) -> List[Classroom]:
|
||||
"""Obtiene todas las salas virtuales activas."""
|
||||
return [c for c in self.get_active_classrooms() if c.is_virtual]
|
||||
|
||||
def get_physical_classrooms(self) -> List[Classroom]:
|
||||
"""Obtiene todas las aulas físicas activas."""
|
||||
return [c for c in self.get_active_classrooms() if not c.is_virtual]
|
||||
|
||||
def search(self, term: str) -> List[Classroom]:
|
||||
"""Búsqueda de aulas por código, edificio o descripción."""
|
||||
if not term:
|
||||
return self.get_all(order_by=Classroom.code.asc())
|
||||
term_clean = f"%{term.strip()}%"
|
||||
return Classroom.query.filter(
|
||||
Classroom.is_active == True,
|
||||
(Classroom.code.ilike(term_clean)) |
|
||||
(Classroom.building.ilike(term_clean)) |
|
||||
(Classroom.description.ilike(term_clean))
|
||||
).order_by(Classroom.code.asc()).all()
|
||||
@@ -0,0 +1,61 @@
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, date, time
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy import and_, or_
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
from app.repositories.base_repository import BaseRepository
|
||||
from app import db
|
||||
|
||||
class ReservationRepository(BaseRepository[Reservation]):
|
||||
"""Repositorio para la persistencia y consulta de reservas de aulas."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(Reservation)
|
||||
|
||||
def find_conflicts(self, classroom_id: int, start_time: datetime, end_time: datetime,
|
||||
exclude_reservation_id: Optional[int] = None) -> List[Reservation]:
|
||||
"""
|
||||
Encuentra reservas activas que se solapan en el mismo aula y franja horaria.
|
||||
Condición de solapamiento: (start_time < existing.end_time) AND (end_time > existing.start_time).
|
||||
"""
|
||||
query = Reservation.query.filter(
|
||||
Reservation.classroom_id == classroom_id,
|
||||
Reservation.status != ReservationStatus.CANCELLED.value,
|
||||
Reservation.start_time < end_time,
|
||||
Reservation.end_time > start_time
|
||||
)
|
||||
|
||||
if exclude_reservation_id:
|
||||
query = query.filter(Reservation.id != exclude_reservation_id)
|
||||
|
||||
return query.all()
|
||||
|
||||
def get_by_date_range(self, start_dt: datetime, end_dt: datetime,
|
||||
classroom_id: Optional[int] = None) -> List[Reservation]:
|
||||
"""Obtiene reservas dentro de una ventana temporal con carga ansiosa de relaciones."""
|
||||
query = Reservation.query.options(
|
||||
joinedload(Reservation.classroom),
|
||||
joinedload(Reservation.commission),
|
||||
joinedload(Reservation.user)
|
||||
).filter(
|
||||
Reservation.start_time >= start_dt,
|
||||
Reservation.start_time <= end_dt,
|
||||
Reservation.status != ReservationStatus.CANCELLED.value
|
||||
)
|
||||
|
||||
if classroom_id:
|
||||
query = query.filter(Reservation.classroom_id == classroom_id)
|
||||
|
||||
return query.order_by(Reservation.start_time.asc()).all()
|
||||
|
||||
def get_by_teacher(self, teacher_id: int) -> List[Reservation]:
|
||||
"""Obtiene reservas asignadas a un docente."""
|
||||
return Reservation.query.filter_by(
|
||||
user_id=teacher_id
|
||||
).order_by(Reservation.start_time.desc()).all()
|
||||
|
||||
def get_by_commission(self, commission_id: int) -> List[Reservation]:
|
||||
"""Obtiene todas las reservas asociadas a una comisión."""
|
||||
return Reservation.query.filter_by(
|
||||
commission_id=commission_id
|
||||
).order_by(Reservation.start_time.asc()).all()
|
||||
@@ -0,0 +1,34 @@
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import func
|
||||
from app.models.user import User
|
||||
from app.repositories.base_repository import BaseRepository
|
||||
|
||||
class UserRepository(BaseRepository[User]):
|
||||
"""Repositorio para la gestión de usuarios, credenciales y roles."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(User)
|
||||
|
||||
def get_by_email(self, email: str) -> Optional[User]:
|
||||
"""Obtiene un usuario por su dirección de email normalizada."""
|
||||
if not email:
|
||||
return None
|
||||
return User.query.filter(func.lower(User.email) == email.strip().lower()).first()
|
||||
|
||||
def get_active_users(self) -> List[User]:
|
||||
"""Obtiene todos los usuarios con cuenta activa."""
|
||||
return User.query.filter_by(is_active=True).order_by(User.name.asc()).all()
|
||||
|
||||
def get_by_role(self, role_name: str) -> List[User]:
|
||||
"""Obtiene usuarios por nombre de rol."""
|
||||
return User.query.filter(
|
||||
User.is_active == True,
|
||||
func.lower(User.role) == role_name.strip().lower()
|
||||
).order_by(User.name.asc()).all()
|
||||
|
||||
def get_teachers(self) -> List[User]:
|
||||
"""Obtiene lista de docentes y profesores activos."""
|
||||
return User.query.filter(
|
||||
User.is_active == True,
|
||||
User.role.in_(['TEACHER', 'DOCENTE', 'PROFESOR', 'teacher', 'docente'])
|
||||
).order_by(User.name.asc()).all()
|
||||
@@ -5,6 +5,7 @@ from app import db
|
||||
from app.models.user import User
|
||||
from app.models.role import Role, Permission, SYSTEM_MODULES, ACCESS_LEVELS
|
||||
from app.models.milestone import MilestoneType, AcademicMilestone, BASE_MILESTONE_TYPES
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.utils.decorators import admin_required, permission_required
|
||||
from sqlalchemy import or_
|
||||
import re
|
||||
@@ -1116,4 +1117,51 @@ def occupancy_metrics():
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# AUDIT LOGS
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@admin_bp.route('/audit-logs')
|
||||
@admin_required
|
||||
def audit_logs():
|
||||
"""Vista de bitácora de auditoría del sistema"""
|
||||
page = request.args.get('page', 1, type=int)
|
||||
module_filter = request.args.get('module', '').strip()
|
||||
action_filter = request.args.get('action', '').strip()
|
||||
search = request.args.get('search', '').strip()
|
||||
per_page = 20
|
||||
|
||||
query = AuditLog.query
|
||||
|
||||
if module_filter:
|
||||
query = query.filter(AuditLog.module == module_filter)
|
||||
if action_filter:
|
||||
query = query.filter(AuditLog.action == action_filter)
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(
|
||||
AuditLog.user_email.ilike(f'%{search}%'),
|
||||
AuditLog.details.ilike(f'%{search}%'),
|
||||
AuditLog.ip_address.ilike(f'%{search}%')
|
||||
)
|
||||
)
|
||||
|
||||
pagination = query.order_by(AuditLog.created_at.desc()).paginate(page=page, per_page=per_page, error_out=False)
|
||||
|
||||
# Módulos y acciones únicas para los selectores de filtro
|
||||
modules = [r[0] for r in db.session.query(AuditLog.module).distinct().all() if r[0]]
|
||||
actions = [r[0] for r in db.session.query(AuditLog.action).distinct().all() if r[0]]
|
||||
|
||||
return render_template(
|
||||
'admin/audit_logs.html',
|
||||
pagination=pagination,
|
||||
audit_logs=pagination.items,
|
||||
modules=modules,
|
||||
actions=actions,
|
||||
current_module=module_filter,
|
||||
current_action=action_filter,
|
||||
search=search
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Paquete de rutas API RESTful (v1) con soporte Stateless JWT."""
|
||||
from app.routes.api.auth import api_auth_bp
|
||||
from app.routes.api.classrooms import api_classrooms_bp
|
||||
from app.routes.api.reservations import api_reservations_bp
|
||||
from app.routes.api.optimizer import api_optimizer_bp
|
||||
|
||||
__all__ = [
|
||||
'api_auth_bp',
|
||||
'api_classrooms_bp',
|
||||
'api_reservations_bp',
|
||||
'api_optimizer_bp'
|
||||
]
|
||||
@@ -0,0 +1,109 @@
|
||||
from flask import Blueprint, request, jsonify, g, make_response
|
||||
from pydantic import ValidationError
|
||||
import jwt
|
||||
|
||||
from app.services.user_service import UserService
|
||||
from app.services.jwt_service import JWTService
|
||||
from app.schemas.auth_dto import LoginDTO, RefreshTokenDTO
|
||||
from app.utils.jwt_decorators import jwt_required
|
||||
|
||||
api_auth_bp = Blueprint('api_auth', __name__, url_prefix='/api/v1/auth')
|
||||
user_service = UserService()
|
||||
|
||||
@api_auth_bp.route('/login', methods=['POST'])
|
||||
def login():
|
||||
"""
|
||||
Endpoint de autenticación para obtener par de tokens (Access + Refresh).
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
dto = LoginDTO(**data)
|
||||
except ValidationError as e:
|
||||
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
|
||||
|
||||
user = user_service.authenticate(dto.email, dto.password)
|
||||
if not user:
|
||||
return jsonify({
|
||||
'error': 'Unauthorized',
|
||||
'message': 'Credenciales de acceso incorrectas o cuenta inactiva.'
|
||||
}), 401
|
||||
|
||||
tokens = JWTService.generate_tokens(user)
|
||||
profile = user_service.get_profile_data(user)
|
||||
|
||||
response_data = {
|
||||
'access_token': tokens['access_token'],
|
||||
'refresh_token': tokens['refresh_token'],
|
||||
'token_type': tokens['token_type'],
|
||||
'expires_in': tokens['expires_in'],
|
||||
'user': profile
|
||||
}
|
||||
|
||||
resp = make_response(jsonify(response_data), 200)
|
||||
# Almacenar refresh token en cookie segura HttpOnly
|
||||
resp.set_cookie(
|
||||
'refresh_token',
|
||||
tokens['refresh_token'],
|
||||
httponly=True,
|
||||
samesite='Lax',
|
||||
max_age=7 * 24 * 3600,
|
||||
path='/api/v1/auth/refresh'
|
||||
)
|
||||
return resp
|
||||
|
||||
@api_auth_bp.route('/refresh', methods=['POST'])
|
||||
def refresh():
|
||||
"""
|
||||
Renovación silenciosa del Access Token utilizando el Refresh Token.
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
refresh_token = data.get('refresh_token') or request.cookies.get('refresh_token')
|
||||
|
||||
if not refresh_token:
|
||||
return jsonify({
|
||||
'error': 'BadRequest',
|
||||
'message': 'Refresh token no suministrado en el cuerpo ni en cookies.'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
payload = JWTService.decode_token(refresh_token, expected_type='refresh')
|
||||
user_id = int(payload.get('sub'))
|
||||
user = user_service.get_by_id(user_id)
|
||||
|
||||
if not user or not user.is_active:
|
||||
return jsonify({
|
||||
'error': 'Unauthorized',
|
||||
'message': 'Usuario no encontrado o inactivo.'
|
||||
}), 401
|
||||
|
||||
new_access_token = JWTService.create_access_token(user)
|
||||
return jsonify({
|
||||
'access_token': new_access_token,
|
||||
'token_type': 'Bearer',
|
||||
'expires_in': int(JWTService.ACCESS_TOKEN_EXPIRES.total_seconds())
|
||||
}), 200
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({'error': 'TokenExpired', 'message': 'El refresh token ha expirado. Reingrese credenciales.'}), 401
|
||||
except jwt.InvalidTokenError as e:
|
||||
return jsonify({'error': 'InvalidToken', 'message': str(e)}), 401
|
||||
|
||||
@api_auth_bp.route('/logout', methods=['POST'])
|
||||
@jwt_required
|
||||
def logout():
|
||||
"""
|
||||
Cierre de sesión: revoca el token de acceso activo y limpia cookies.
|
||||
"""
|
||||
JWTService.revoke_token(g.jwt_token)
|
||||
resp = make_response(jsonify({'message': 'Sesión finalizada y token revocado exitosamente.'}), 200)
|
||||
resp.delete_cookie('refresh_token', path='/api/v1/auth/refresh')
|
||||
return resp
|
||||
|
||||
@api_auth_bp.route('/me', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_current_user():
|
||||
"""
|
||||
Retorna el perfil y los permisos del usuario autenticado vía JWT.
|
||||
"""
|
||||
profile = user_service.get_profile_data(g.jwt_user)
|
||||
return jsonify(profile), 200
|
||||
@@ -0,0 +1,119 @@
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.services.classroom_service import ClassroomService
|
||||
from app.schemas.classroom_dto import ClassroomCreateDTO, ClassroomUpdateDTO, ClassroomResponseDTO
|
||||
from app.utils.jwt_decorators import jwt_required, jwt_role_required
|
||||
|
||||
api_classrooms_bp = Blueprint('api_classrooms', __name__, url_prefix='/api/v1/classrooms')
|
||||
classroom_service = ClassroomService()
|
||||
|
||||
@api_classrooms_bp.route('', methods=['GET'])
|
||||
@jwt_required
|
||||
def list_classrooms():
|
||||
"""Lista aulas con filtros opcionales (building_id, floor, mode)."""
|
||||
building_id = request.args.get('building_id', type=int)
|
||||
floor = request.args.get('floor')
|
||||
mode = request.args.get('mode', '').lower()
|
||||
|
||||
only_virtual = mode == 'virtual'
|
||||
only_physical = mode == 'physical'
|
||||
|
||||
classrooms = classroom_service.list_classrooms(
|
||||
building_id=building_id,
|
||||
floor=floor,
|
||||
only_virtual=only_virtual,
|
||||
only_physical=only_physical
|
||||
)
|
||||
|
||||
result = []
|
||||
for c in classrooms:
|
||||
result.append({
|
||||
'id': c.id,
|
||||
'code': c.code,
|
||||
'building': c.building_name,
|
||||
'building_id': c.building_id,
|
||||
'floor': c.floor,
|
||||
'floor_display': c.floor_display,
|
||||
'capacity': c.capacity,
|
||||
'is_virtual': c.is_virtual,
|
||||
'location_display': c.location_display,
|
||||
'description': c.description,
|
||||
'is_active': c.is_active
|
||||
})
|
||||
|
||||
return jsonify({'total': len(result), 'classrooms': result}), 200
|
||||
|
||||
@api_classrooms_bp.route('/<int:classroom_id>', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_classroom(classroom_id: int):
|
||||
"""Obtiene el detalle de un aula por ID."""
|
||||
c = classroom_service.get_classroom(classroom_id)
|
||||
if not c:
|
||||
return jsonify({'error': 'NotFound', 'message': f'Aula {classroom_id} no encontrada.'}), 404
|
||||
|
||||
return jsonify({
|
||||
'id': c.id,
|
||||
'code': c.code,
|
||||
'building': c.building_name,
|
||||
'building_id': c.building_id,
|
||||
'floor': c.floor,
|
||||
'floor_display': c.floor_display,
|
||||
'capacity': c.capacity,
|
||||
'is_virtual': c.is_virtual,
|
||||
'location_display': c.location_display,
|
||||
'description': c.description,
|
||||
'is_active': c.is_active
|
||||
}), 200
|
||||
|
||||
@api_classrooms_bp.route('', methods=['POST'])
|
||||
@jwt_role_required('ADMIN', 'ADMINISTRADOR', 'BEDELIA')
|
||||
def create_classroom():
|
||||
"""Crea una nueva aula validando el contrato con ClassroomCreateDTO."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
dto = ClassroomCreateDTO(**data)
|
||||
except ValidationError as e:
|
||||
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
|
||||
|
||||
try:
|
||||
classroom = classroom_service.create_classroom(dto)
|
||||
return jsonify({
|
||||
'message': 'Aula creada exitosamente.',
|
||||
'id': classroom.id,
|
||||
'code': classroom.code,
|
||||
'location_display': classroom.location_display
|
||||
}), 201
|
||||
except ValueError as e:
|
||||
return jsonify({'error': 'ConflictOrValidation', 'message': str(e)}), 400
|
||||
|
||||
@api_classrooms_bp.route('/<int:classroom_id>', methods=['PUT'])
|
||||
@jwt_role_required('ADMIN', 'ADMINISTRADOR', 'BEDELIA')
|
||||
def update_classroom(classroom_id: int):
|
||||
"""Actualiza los datos de un aula."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
dto = ClassroomUpdateDTO(**data)
|
||||
except ValidationError as e:
|
||||
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
|
||||
|
||||
try:
|
||||
classroom = classroom_service.update_classroom(classroom_id, dto)
|
||||
return jsonify({
|
||||
'message': 'Aula actualizada exitosamente.',
|
||||
'id': classroom.id,
|
||||
'code': classroom.code,
|
||||
'capacity': classroom.capacity
|
||||
}), 200
|
||||
except ValueError as e:
|
||||
return jsonify({'error': 'ConflictOrValidation', 'message': str(e)}), 400
|
||||
|
||||
@api_classrooms_bp.route('/<int:classroom_id>', methods=['DELETE'])
|
||||
@jwt_role_required('ADMIN', 'ADMINISTRADOR')
|
||||
def delete_classroom(classroom_id: int):
|
||||
"""Elimina o desactiva un aula."""
|
||||
deleted = classroom_service.delete_classroom(classroom_id)
|
||||
if not deleted:
|
||||
return jsonify({'error': 'NotFound', 'message': f'Aula {classroom_id} no encontrada.'}), 404
|
||||
|
||||
return jsonify({'message': f'Aula {classroom_id} eliminada/desactivada correctamente.'}), 200
|
||||
@@ -0,0 +1,105 @@
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.schemas.optimizer_dto import OptimizerJobRequestDTO, SpringBootSyncPayloadDTO
|
||||
from app.services.optimizer_service import OptimizerService
|
||||
from app.utils.jwt_decorators import jwt_required
|
||||
|
||||
api_optimizer_bp = Blueprint('api_optimizer', __name__, url_prefix='/api/v1/optimizer')
|
||||
|
||||
|
||||
@api_optimizer_bp.route('/jobs', methods=['POST'])
|
||||
@jwt_required
|
||||
def submit_optimization_job():
|
||||
"""
|
||||
Despacha un trabajo de optimización heurística asíncrono.
|
||||
Retorna inmediatamente con HTTP 202 Accepted y el identificador de trabajo para sondeo.
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
dto = OptimizerJobRequestDTO(**data)
|
||||
except ValidationError as ex:
|
||||
return jsonify({
|
||||
'error': 'UnprocessableEntity',
|
||||
'message': 'Error de validación en los parámetros del optimizador.',
|
||||
'details': ex.errors()
|
||||
}), 422
|
||||
|
||||
user_id = g.current_user.id if hasattr(g, 'current_user') and g.current_user else 1
|
||||
|
||||
job = OptimizerService.submit_job(
|
||||
commission_ids=dto.commission_ids,
|
||||
admin_user_id=user_id,
|
||||
start_date=dto.start_date,
|
||||
end_date=dto.end_date,
|
||||
generations=dto.generations,
|
||||
population_size=dto.population_size,
|
||||
workers=dto.workers
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'job_id': job.job_id,
|
||||
'status': job.status.value,
|
||||
'poll_url': f'/api/v1/optimizer/jobs/{job.job_id}',
|
||||
'message': 'Trabajo de optimización puesto en cola con éxito.'
|
||||
}), 202
|
||||
|
||||
|
||||
@api_optimizer_bp.route('/jobs/<string:job_id>', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_optimization_job(job_id: str):
|
||||
"""
|
||||
Obtiene el estado, progreso y resultados de un trabajo de optimización en cola.
|
||||
"""
|
||||
job = OptimizerService.get_job(job_id)
|
||||
if not job:
|
||||
return jsonify({
|
||||
'error': 'NotFound',
|
||||
'message': f'Trabajo de optimización {job_id} no encontrado.'
|
||||
}), 404
|
||||
|
||||
return jsonify(job.to_dict()), 200
|
||||
|
||||
|
||||
@api_optimizer_bp.route('/jobs', methods=['GET'])
|
||||
@jwt_required
|
||||
def list_optimization_jobs():
|
||||
"""
|
||||
Lista los trabajos de optimización más recientes.
|
||||
"""
|
||||
limit = request.args.get('limit', default=20, type=int)
|
||||
jobs = OptimizerService.list_jobs(limit=limit)
|
||||
return jsonify({
|
||||
'total': len(jobs),
|
||||
'jobs': jobs
|
||||
}), 200
|
||||
|
||||
|
||||
@api_optimizer_bp.route('/spring-boot/sync', methods=['POST'])
|
||||
@jwt_required
|
||||
def sync_with_spring_boot():
|
||||
"""
|
||||
Endpoint interoperable para la sincronización y resolución de horarios con microservicios Spring Boot.
|
||||
Recibe el payload en formato Spring Boot (camelCase) y retorna el cronograma optimizado.
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
dto = SpringBootSyncPayloadDTO(**data)
|
||||
except ValidationError as ex:
|
||||
return jsonify({
|
||||
'error': 'UnprocessableEntity',
|
||||
'message': 'Error de validación en el payload Spring Boot.',
|
||||
'details': ex.errors()
|
||||
}), 422
|
||||
|
||||
user_id = g.current_user.id if hasattr(g, 'current_user') and g.current_user else 1
|
||||
|
||||
try:
|
||||
result = OptimizerService.sync_with_spring_boot(dto, admin_user_id=user_id)
|
||||
return jsonify(result), 200
|
||||
except Exception as ex:
|
||||
return jsonify({
|
||||
'requestId': dto.requestId,
|
||||
'status': 'ERROR',
|
||||
'error': str(ex)
|
||||
}), 500
|
||||
@@ -0,0 +1,140 @@
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from datetime import datetime, timedelta, date
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.services.reservation_service import ReservationService
|
||||
from app.schemas.reservation_dto import ReservationCreateDTO, ReservationUpdateDTO
|
||||
from app.utils.jwt_decorators import jwt_required, jwt_role_required
|
||||
|
||||
api_reservations_bp = Blueprint('api_reservations', __name__, url_prefix='/api/v1/reservations')
|
||||
reservation_service = ReservationService()
|
||||
|
||||
@api_reservations_bp.route('', methods=['GET'])
|
||||
@jwt_required
|
||||
def list_reservations():
|
||||
"""Consulta de reservas por rango de fechas y/o aula."""
|
||||
start_str = request.args.get('start_date')
|
||||
end_str = request.args.get('end_date')
|
||||
classroom_id = request.args.get('classroom_id', type=int)
|
||||
|
||||
now = datetime.utcnow()
|
||||
try:
|
||||
start_dt = datetime.strptime(start_str, '%Y-%m-%d') if start_str else now.replace(hour=0, minute=0, second=0)
|
||||
except ValueError:
|
||||
return jsonify({'error': 'BadRequest', 'message': 'Formato inválido de start_date (debe ser YYYY-MM-DD).'}), 400
|
||||
|
||||
try:
|
||||
end_dt = datetime.strptime(end_str, '%Y-%m-%d').replace(hour=23, minute=59, second=59) if end_str else start_dt + timedelta(days=7)
|
||||
except ValueError:
|
||||
return jsonify({'error': 'BadRequest', 'message': 'Formato inválido de end_date (debe ser YYYY-MM-DD).'}), 400
|
||||
|
||||
reservations = reservation_service.list_reservations(start_dt, end_dt, classroom_id=classroom_id)
|
||||
result = []
|
||||
for r in reservations:
|
||||
result.append({
|
||||
'id': r.id,
|
||||
'classroom_id': r.classroom_id,
|
||||
'classroom_name': r.classroom.code if r.classroom else None,
|
||||
'commission_id': r.commission_id,
|
||||
'teacher_name': r.user.name if r.user else None,
|
||||
'start_time': r.start_time.isoformat(),
|
||||
'end_time': r.end_time.isoformat(),
|
||||
'purpose': r.purpose,
|
||||
'status': str(r.status),
|
||||
'shift': r.shift,
|
||||
'virtual_link': r.virtual_link,
|
||||
'expected_attendees': r.expected_attendees
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'total': len(result),
|
||||
'start_date': start_dt.strftime('%Y-%m-%d'),
|
||||
'end_date': end_dt.strftime('%Y-%m-%d'),
|
||||
'reservations': result
|
||||
}), 200
|
||||
|
||||
@api_reservations_bp.route('/<int:reservation_id>', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_reservation(reservation_id: int):
|
||||
"""Obtiene el detalle de una reserva específica."""
|
||||
r = reservation_service.get_reservation(reservation_id)
|
||||
if not r:
|
||||
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
|
||||
|
||||
return jsonify({
|
||||
'id': r.id,
|
||||
'classroom_id': r.classroom_id,
|
||||
'classroom_name': r.classroom.code if r.classroom else None,
|
||||
'location_display': r.classroom.location_display if r.classroom else None,
|
||||
'commission_id': r.commission_id,
|
||||
'teacher_id': r.user_id,
|
||||
'teacher_name': r.user.name if r.user else None,
|
||||
'start_time': r.start_time.isoformat(),
|
||||
'end_time': r.end_time.isoformat(),
|
||||
'purpose': r.purpose,
|
||||
'status': str(r.status),
|
||||
'shift': r.shift,
|
||||
'virtual_link': r.virtual_link,
|
||||
'expected_attendees': r.expected_attendees,
|
||||
'notes': r.notes
|
||||
}), 200
|
||||
|
||||
@api_reservations_bp.route('', methods=['POST'])
|
||||
@jwt_required
|
||||
def create_reservation():
|
||||
"""Crea una reserva aplicando validación DTO y detección de colisiones."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
# Inyectar usuario autenticado si no se especifica
|
||||
if 'user_id' not in data:
|
||||
data['user_id'] = g.jwt_user.id
|
||||
|
||||
try:
|
||||
dto = ReservationCreateDTO(**data)
|
||||
except ValidationError as e:
|
||||
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
|
||||
|
||||
try:
|
||||
reservation = reservation_service.create_reservation(dto)
|
||||
return jsonify({
|
||||
'message': 'Reserva creada exitosamente.',
|
||||
'id': reservation.id,
|
||||
'classroom_id': reservation.classroom_id,
|
||||
'start_time': reservation.start_time.isoformat(),
|
||||
'end_time': reservation.end_time.isoformat(),
|
||||
'status': str(reservation.status)
|
||||
}), 201
|
||||
except ValueError as e:
|
||||
return jsonify({'error': 'ConflictOrValidation', 'message': str(e)}), 409
|
||||
|
||||
@api_reservations_bp.route('/<int:reservation_id>', methods=['PUT'])
|
||||
@jwt_required
|
||||
def update_reservation(reservation_id: int):
|
||||
"""Modifica una reserva existente."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
dto = ReservationUpdateDTO(**data)
|
||||
except ValidationError as e:
|
||||
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
|
||||
|
||||
try:
|
||||
reservation = reservation_service.update_reservation(reservation_id, dto)
|
||||
return jsonify({
|
||||
'message': 'Reserva actualizada exitosamente.',
|
||||
'id': reservation.id,
|
||||
'start_time': reservation.start_time.isoformat(),
|
||||
'end_time': reservation.end_time.isoformat(),
|
||||
'status': str(reservation.status)
|
||||
}), 200
|
||||
except ValueError as e:
|
||||
return jsonify({'error': 'ConflictOrValidation', 'message': str(e)}), 409
|
||||
|
||||
@api_reservations_bp.route('/<int:reservation_id>', methods=['DELETE'])
|
||||
@jwt_required
|
||||
def cancel_reservation(reservation_id: int):
|
||||
"""Cancela una reserva."""
|
||||
cancelled = reservation_service.cancel_reservation(reservation_id)
|
||||
if not cancelled:
|
||||
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
|
||||
|
||||
return jsonify({'message': f'Reserva {reservation_id} cancelada correctamente.'}), 200
|
||||
+27
-2
@@ -8,15 +8,18 @@ from datetime import datetime
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
from sqlalchemy import func
|
||||
|
||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
"""Handle user login"""
|
||||
"""Handle user login with case-insensitive email lookup and whitespace trimming"""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
form = LoginForm()
|
||||
if form.validate_on_submit():
|
||||
user = User.query.filter_by(email=form.email.data).first()
|
||||
clean_email = (form.email.data or '').strip().lower()
|
||||
user = User.query.filter(func.lower(User.email) == clean_email).first()
|
||||
|
||||
if user and user.check_password(form.password.data):
|
||||
if not user.is_active:
|
||||
@@ -25,6 +28,19 @@ def login():
|
||||
|
||||
login_user(user, remember=form.remember_me.data)
|
||||
user.update_last_login()
|
||||
|
||||
# Registrar en bitácora de auditoría
|
||||
try:
|
||||
from app.services.audit_service import AuditService
|
||||
AuditService.log(
|
||||
action='LOGIN',
|
||||
module='auth',
|
||||
user_id=user.id,
|
||||
user_email=user.email,
|
||||
details=f"Inicio de sesión exitoso ({user.role})"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Si el usuario tiene idioma configurado en su perfil, sobrescribe el navegador en toda la sesión
|
||||
if user.preferred_language and user.preferred_language in ['es', 'en']:
|
||||
@@ -42,6 +58,15 @@ def login():
|
||||
flash(_('¡Bienvenido de nuevo, %(name)s!', name=user_full_name), 'success')
|
||||
return redirect(next_page)
|
||||
else:
|
||||
try:
|
||||
from app.services.audit_service import AuditService
|
||||
AuditService.log(
|
||||
action='LOGIN_FAILED',
|
||||
module='auth',
|
||||
details=f"Intento fallido de login con email: {clean_email}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
flash(_('Correo electrónico o contraseña inválidos.'), 'error')
|
||||
|
||||
return render_template('auth/login.html', form=form)
|
||||
|
||||
@@ -58,29 +58,37 @@ def add_classroom():
|
||||
|
||||
cap_raw = str(form.capacity.data or '').strip()
|
||||
if cap_raw in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']:
|
||||
cap_val = 0
|
||||
cap_val = 9999
|
||||
else:
|
||||
cap_val = int(cap_raw)
|
||||
|
||||
classroom = Classroom(
|
||||
from app.services.classroom_service import ClassroomService
|
||||
from app.schemas.classroom_dto import ClassroomCreateDTO
|
||||
from app.services.audit_service import AuditService
|
||||
|
||||
dto = ClassroomCreateDTO(
|
||||
building_id=building.id,
|
||||
building=building.name,
|
||||
code=room_code,
|
||||
floor=floor_val,
|
||||
capacity=cap_val,
|
||||
description=form.description.data,
|
||||
is_active=form.is_active.data
|
||||
)
|
||||
classroom_service = ClassroomService()
|
||||
classroom = classroom_service.create_classroom(dto)
|
||||
|
||||
db.session.add(classroom)
|
||||
db.session.commit()
|
||||
# Registrar en bitácora inmutable de auditoría
|
||||
AuditService.log('CREATE', 'classrooms', entity_id=classroom.id, details={'code': classroom.code, 'building': building.name})
|
||||
|
||||
flash(f'Aula {classroom.location_display} creada exitosamente.', 'success')
|
||||
return redirect(url_for('classrooms.list_classrooms'))
|
||||
|
||||
except ValueError as ve:
|
||||
flash(str(ve), 'error')
|
||||
return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error creating classroom: {str(e)}', 'error')
|
||||
flash(f'Error al crear el aula: {str(e)}', 'error')
|
||||
|
||||
return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom')
|
||||
|
||||
|
||||
@@ -39,8 +39,8 @@ def optimize_reservations():
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Invalid end_date format. Use ISO format.'}), 400
|
||||
|
||||
# Check if user has permission (admin or teacher)
|
||||
if current_user.role not in ['ADMIN', 'teacher']:
|
||||
# Check if user has permission (admin, teacher or RBAC optimizer write)
|
||||
if not (current_user.is_admin() or current_user.has_permission('optimizer', 'read_write') or current_user.role in ['ADMIN', 'teacher']):
|
||||
return jsonify({'error': 'Insufficient permissions to optimize reservations'}), 403
|
||||
|
||||
# Initialize optimizer and run optimization
|
||||
@@ -84,7 +84,7 @@ def apply_optimization():
|
||||
return jsonify({'error': 'reservations must be a list'}), 400
|
||||
|
||||
# Check if user has permission
|
||||
if current_user.role not in ['ADMIN', 'teacher']:
|
||||
if not (current_user.is_admin() or current_user.has_permission('optimizer', 'read_write') or current_user.role in ['ADMIN', 'teacher']):
|
||||
return jsonify({'error': 'Insufficient permissions to apply reservations'}), 403
|
||||
|
||||
# Initialize optimizer and apply reservations
|
||||
|
||||
+195
-20
@@ -14,28 +14,40 @@ main_bp = Blueprint('main', __name__)
|
||||
@main_bp.route('/')
|
||||
@login_required
|
||||
def dashboard():
|
||||
"""Main dashboard page"""
|
||||
# Get statistics
|
||||
"""Role-personalized institutional dashboard page"""
|
||||
from app.models.user import User
|
||||
from app.models.milestone import AcademicMilestone
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
actual_role = current_user.get_institutional_role()
|
||||
view_as = request.args.get('view_as', '').lower().strip()
|
||||
|
||||
# Allow admins and management roles to preview dashboard perspectives
|
||||
if view_as in ['admin', 'bedelia', 'docente', 'alumno'] and (current_user.is_admin() or current_user.can_manage()):
|
||||
role = view_as
|
||||
else:
|
||||
role = actual_role
|
||||
|
||||
today = datetime.utcnow().date()
|
||||
|
||||
# Common core statistics
|
||||
try:
|
||||
total_classrooms = Classroom.query.filter_by(is_active=True).count()
|
||||
except:
|
||||
except Exception:
|
||||
total_classrooms = 0
|
||||
|
||||
try:
|
||||
total_subjects = Subject.query.filter_by(is_active=True).count()
|
||||
except:
|
||||
except Exception:
|
||||
total_subjects = 0
|
||||
|
||||
# Get today's reservations
|
||||
today = datetime.utcnow().date()
|
||||
try:
|
||||
today_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) == today
|
||||
).count()
|
||||
except:
|
||||
except Exception:
|
||||
today_reservations = 0
|
||||
|
||||
# Get this week's reservations
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
week_end = week_start + timedelta(days=6)
|
||||
try:
|
||||
@@ -43,18 +55,9 @@ def dashboard():
|
||||
func.date(Reservation.start_time) >= week_start,
|
||||
func.date(Reservation.start_time) <= week_end
|
||||
).count()
|
||||
except:
|
||||
except Exception:
|
||||
week_reservations = 0
|
||||
|
||||
# Get upcoming reservations (next 7 days)
|
||||
try:
|
||||
upcoming_reservations = Reservation.query.filter(
|
||||
Reservation.start_time >= datetime.utcnow(),
|
||||
Reservation.start_time <= datetime.utcnow() + timedelta(days=7)
|
||||
).limit(10).all()
|
||||
except:
|
||||
upcoming_reservations = []
|
||||
|
||||
stats = {
|
||||
'total_classrooms': total_classrooms,
|
||||
'total_subjects': total_subjects,
|
||||
@@ -62,8 +65,180 @@ def dashboard():
|
||||
'week_reservations': week_reservations
|
||||
}
|
||||
|
||||
return render_template('dashboard.html',
|
||||
stats=stats,
|
||||
role_data = {}
|
||||
|
||||
if role == 'admin':
|
||||
# 1. Admin Institutional Cockpit
|
||||
try:
|
||||
total_users = User.query.filter_by(is_active=True).count()
|
||||
except Exception:
|
||||
total_users = 0
|
||||
|
||||
try:
|
||||
pending_count = Reservation.query.filter(
|
||||
Reservation.status == 'PENDING'
|
||||
).count()
|
||||
except Exception:
|
||||
pending_count = 0
|
||||
|
||||
try:
|
||||
recent_audits = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(6).all()
|
||||
except Exception:
|
||||
recent_audits = []
|
||||
|
||||
try:
|
||||
upcoming_reservations = Reservation.query.filter(
|
||||
Reservation.start_time >= datetime.utcnow(),
|
||||
Reservation.start_time <= datetime.utcnow() + timedelta(days=7)
|
||||
).order_by(Reservation.start_time.asc()).limit(8).all()
|
||||
except Exception:
|
||||
upcoming_reservations = []
|
||||
|
||||
stats.update({
|
||||
'total_users': total_users,
|
||||
'pending_reservations': pending_count
|
||||
})
|
||||
role_data = {
|
||||
'recent_audits': recent_audits,
|
||||
'upcoming_reservations': upcoming_reservations
|
||||
}
|
||||
|
||||
elif role == 'bedelia':
|
||||
# 2. Bedelía Operations & Facilities Control
|
||||
try:
|
||||
physical_classrooms = Classroom.query.filter_by(is_active=True).filter(
|
||||
~Classroom.room_number.ilike('%VIRTUAL%') & ~Classroom.building.ilike('%VIRTUAL%')
|
||||
).count()
|
||||
virtual_classrooms = total_classrooms - physical_classrooms
|
||||
if virtual_classrooms < 0:
|
||||
virtual_classrooms = 0
|
||||
except Exception:
|
||||
physical_classrooms = total_classrooms
|
||||
virtual_classrooms = 0
|
||||
|
||||
try:
|
||||
pending_reservations = Reservation.query.filter(
|
||||
Reservation.status == 'PENDING'
|
||||
).order_by(Reservation.start_time.asc()).limit(10).all()
|
||||
pending_count = len(pending_reservations)
|
||||
except Exception:
|
||||
pending_reservations = []
|
||||
pending_count = 0
|
||||
|
||||
try:
|
||||
today_schedule_list = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) == today
|
||||
).order_by(Reservation.start_time.asc()).limit(12).all()
|
||||
except Exception:
|
||||
today_schedule_list = []
|
||||
|
||||
occupancy_rate = min(100, int((today_reservations / max(1, physical_classrooms * 3)) * 100)) if physical_classrooms > 0 else 0
|
||||
|
||||
stats.update({
|
||||
'physical_classrooms': physical_classrooms,
|
||||
'virtual_classrooms': virtual_classrooms,
|
||||
'pending_count': pending_count,
|
||||
'occupancy_rate': occupancy_rate
|
||||
})
|
||||
role_data = {
|
||||
'pending_reservations': pending_reservations,
|
||||
'today_schedule_list': today_schedule_list
|
||||
}
|
||||
|
||||
elif role == 'docente':
|
||||
# 3. Docente (Profesor) Academic & Classroom Workflow
|
||||
try:
|
||||
my_today_classes = Reservation.query.filter(
|
||||
Reservation.user_id == current_user.id,
|
||||
func.date(Reservation.start_time) == today
|
||||
).order_by(Reservation.start_time.asc()).all()
|
||||
except Exception:
|
||||
my_today_classes = []
|
||||
|
||||
try:
|
||||
my_upcoming_reservations = Reservation.query.filter(
|
||||
Reservation.user_id == current_user.id,
|
||||
Reservation.start_time >= datetime.utcnow()
|
||||
).order_by(Reservation.start_time.asc()).limit(8).all()
|
||||
except Exception:
|
||||
my_upcoming_reservations = []
|
||||
|
||||
try:
|
||||
my_total_reservations = Reservation.query.filter(
|
||||
Reservation.user_id == current_user.id
|
||||
).count()
|
||||
except Exception:
|
||||
my_total_reservations = 0
|
||||
|
||||
try:
|
||||
virtual_classrooms = Classroom.query.filter_by(is_active=True).filter(
|
||||
(Classroom.room_number.ilike('%VIRTUAL%')) | (Classroom.building.ilike('%VIRTUAL%'))
|
||||
).limit(4).all()
|
||||
except Exception:
|
||||
virtual_classrooms = []
|
||||
|
||||
stats.update({
|
||||
'my_today_count': len(my_today_classes),
|
||||
'my_total_reservations': my_total_reservations,
|
||||
'virtual_rooms_count': len(virtual_classrooms)
|
||||
})
|
||||
role_data = {
|
||||
'my_today_classes': my_today_classes,
|
||||
'my_upcoming_reservations': my_upcoming_reservations,
|
||||
'virtual_classrooms': virtual_classrooms
|
||||
}
|
||||
|
||||
elif role == 'alumno':
|
||||
# 4. Alumno (Estudiante) Daily Guide & Timetable
|
||||
try:
|
||||
today_classes = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) == today,
|
||||
Reservation.status == 'CONFIRMED'
|
||||
).order_by(Reservation.start_time.asc()).limit(12).all()
|
||||
except Exception:
|
||||
today_classes = []
|
||||
|
||||
try:
|
||||
upcoming_milestones = AcademicMilestone.query.filter(
|
||||
AcademicMilestone.date >= datetime.utcnow(),
|
||||
AcademicMilestone.is_active == True
|
||||
).order_by(AcademicMilestone.date.asc()).limit(6).all()
|
||||
except Exception:
|
||||
upcoming_milestones = []
|
||||
|
||||
try:
|
||||
virtual_rooms = Classroom.query.filter_by(is_active=True).filter(
|
||||
(Classroom.room_number.ilike('%VIRTUAL%')) | (Classroom.building.ilike('%VIRTUAL%'))
|
||||
).limit(4).all()
|
||||
except Exception:
|
||||
virtual_rooms = []
|
||||
|
||||
stats.update({
|
||||
'today_classes_count': len(today_classes),
|
||||
'milestones_count': len(upcoming_milestones)
|
||||
})
|
||||
role_data = {
|
||||
'today_classes': today_classes,
|
||||
'upcoming_milestones': upcoming_milestones,
|
||||
'virtual_rooms': virtual_rooms
|
||||
}
|
||||
|
||||
# Backward compatibility for upcoming_reservations
|
||||
upcoming_reservations = role_data.get('upcoming_reservations', [])
|
||||
if not upcoming_reservations:
|
||||
try:
|
||||
upcoming_reservations = Reservation.query.filter(
|
||||
Reservation.start_time >= datetime.utcnow(),
|
||||
Reservation.start_time <= datetime.utcnow() + timedelta(days=7)
|
||||
).order_by(Reservation.start_time.asc()).limit(6).all()
|
||||
except Exception:
|
||||
upcoming_reservations = []
|
||||
|
||||
return render_template('dashboard.html',
|
||||
role=role,
|
||||
actual_role=actual_role,
|
||||
stats=stats,
|
||||
role_data=role_data,
|
||||
upcoming_reservations=upcoming_reservations)
|
||||
|
||||
@main_bp.route('/api/dashboard-stats')
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Módulo de Data Transfer Objects (DTOs) y esquemas tipados con Pydantic."""
|
||||
from app.schemas.classroom_dto import ClassroomCreateDTO, ClassroomUpdateDTO, ClassroomResponseDTO
|
||||
from app.schemas.reservation_dto import ReservationCreateDTO, ReservationUpdateDTO, ReservationResponseDTO
|
||||
from app.schemas.auth_dto import LoginDTO, TokenResponseDTO, RefreshTokenDTO
|
||||
|
||||
__all__ = [
|
||||
'ClassroomCreateDTO',
|
||||
'ClassroomUpdateDTO',
|
||||
'ClassroomResponseDTO',
|
||||
'ReservationCreateDTO',
|
||||
'ReservationUpdateDTO',
|
||||
'ReservationResponseDTO',
|
||||
'LoginDTO',
|
||||
'TokenResponseDTO',
|
||||
'RefreshTokenDTO'
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
from typing import Optional, Dict, Any, List
|
||||
from pydantic import BaseModel, Field, EmailStr
|
||||
|
||||
class LoginDTO(BaseModel):
|
||||
email: str = Field(..., description="Correo electrónico institucional")
|
||||
password: str = Field(..., min_length=1, description="Contraseña de acceso")
|
||||
|
||||
class TokenResponseDTO(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "Bearer"
|
||||
expires_in: int = 900 # 15 minutos en segundos
|
||||
user: Dict[str, Any]
|
||||
|
||||
class RefreshTokenDTO(BaseModel):
|
||||
refresh_token: str = Field(..., description="Token de refresco válido")
|
||||
@@ -0,0 +1,44 @@
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
class ClassroomBaseDTO(BaseModel):
|
||||
code: str = Field(..., min_length=1, max_length=50, description="Código o nombre del aula (ej: Aula 101, VIRTUAL-A)")
|
||||
floor: str = Field(default="PB", max_length=50, description="Piso o nivel (PB, Piso 1, etc.)")
|
||||
capacity: int = Field(default=30, ge=1, le=10000, description="Capacidad máxima de estudiantes")
|
||||
description: Optional[str] = Field(default=None, max_length=500, description="Detalles o equipamiento")
|
||||
is_active: bool = Field(default=True, description="Estado operativo del aula")
|
||||
|
||||
@field_validator('code')
|
||||
@classmethod
|
||||
def clean_code(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("El código del aula no puede estar vacío.")
|
||||
return v
|
||||
|
||||
@field_validator('floor')
|
||||
@classmethod
|
||||
def clean_floor(cls, v: str) -> str:
|
||||
return str(v).strip() or "PB"
|
||||
|
||||
class ClassroomCreateDTO(ClassroomBaseDTO):
|
||||
building_id: Optional[int] = Field(default=None, description="ID del edificio asignado")
|
||||
building_name: Optional[str] = Field(default=None, description="Nombre de edificio (para creación dinámica)")
|
||||
|
||||
class ClassroomUpdateDTO(BaseModel):
|
||||
building_id: Optional[int] = None
|
||||
code: Optional[str] = None
|
||||
floor: Optional[str] = None
|
||||
capacity: Optional[int] = None
|
||||
description: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
class ClassroomResponseDTO(ClassroomBaseDTO):
|
||||
id: int
|
||||
building_id: Optional[int] = None
|
||||
building_name: str
|
||||
is_virtual: bool
|
||||
location_display: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,49 @@
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
class OptimizerJobRequestDTO(BaseModel):
|
||||
"""Contrato de solicitud para iniciar una optimización heurística."""
|
||||
commission_ids: List[int] = Field(..., min_length=1, description="Lista de IDs de comisiones a programar")
|
||||
start_date: Optional[datetime] = Field(None, description="Fecha de inicio (ISO format)")
|
||||
end_date: Optional[datetime] = Field(None, description="Fecha de fin (ISO format)")
|
||||
generations: int = Field(default=80, ge=10, le=500, description="Cantidad de generaciones genéticas")
|
||||
population_size: int = Field(default=40, ge=10, le=200, description="Tamaño de la población de cromosomas")
|
||||
workers: int = Field(default=4, ge=1, le=16, description="Cantidad de hilos o workers concurrentes")
|
||||
|
||||
@field_validator('commission_ids')
|
||||
@classmethod
|
||||
def validate_ids(cls, v):
|
||||
if not v or len(v) == 0:
|
||||
raise ValueError("Debe suministrar al menos una comisión para optimizar.")
|
||||
return list(set(v))
|
||||
|
||||
|
||||
class SpringBootCommissionDTO(BaseModel):
|
||||
"""Contrato de comisión en formato Spring Boot."""
|
||||
commissionId: int
|
||||
subjectCode: str
|
||||
subjectName: str
|
||||
expectedAttendees: int = Field(default=30, ge=1)
|
||||
preferredShift: Optional[str] = "NOCHE"
|
||||
preferredStart: datetime
|
||||
preferredEnd: datetime
|
||||
|
||||
|
||||
class SpringBootClassroomDTO(BaseModel):
|
||||
"""Contrato de aula en formato Spring Boot."""
|
||||
classroomId: int
|
||||
roomNumber: str
|
||||
capacity: int = Field(default=30, ge=1)
|
||||
building: Optional[str] = "Edificio Central"
|
||||
isVirtual: bool = False
|
||||
|
||||
|
||||
class SpringBootSyncPayloadDTO(BaseModel):
|
||||
"""Carga útil de sincronización interoperable con microservicios Spring Boot."""
|
||||
requestId: str = Field(..., description="Identificador único de la petición externa")
|
||||
campusCode: Optional[str] = "CENTRAL"
|
||||
academicTerm: Optional[str] = "2026-1C"
|
||||
commissions: List[SpringBootCommissionDTO] = Field(..., min_length=1)
|
||||
classrooms: Optional[List[SpringBootClassroomDTO]] = None
|
||||
options: Optional[Dict[str, Any]] = None
|
||||
@@ -0,0 +1,43 @@
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
class ReservationBaseDTO(BaseModel):
|
||||
classroom_id: int = Field(..., description="ID del aula asignada")
|
||||
commission_id: int = Field(..., description="ID de la comisión académica")
|
||||
user_id: int = Field(..., description="ID del docente responsable")
|
||||
start_time: datetime = Field(..., description="Fecha y hora de inicio")
|
||||
end_time: datetime = Field(..., description="Fecha y hora de finalización")
|
||||
purpose: str = Field(default="Clase regular", max_length=500, description="Motivo o título de la reserva")
|
||||
expected_attendees: int = Field(default=20, ge=1, description="Cantidad estimada de asistentes")
|
||||
shift: Optional[str] = Field(default=None, max_length=30, description="Turno (Mañana, Tarde, Vespertino, Noche)")
|
||||
virtual_link: Optional[str] = Field(default=None, max_length=500, description="Enlace a sala virtual (Meet/Zoom)")
|
||||
notes: Optional[str] = Field(default=None, description="Observaciones adicionales")
|
||||
status: Optional[str] = Field(default="PENDING", description="Estado de la reserva")
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_time_range(self):
|
||||
if self.end_time <= self.start_time:
|
||||
raise ValueError("La hora de finalización debe ser posterior a la hora de inicio.")
|
||||
return self
|
||||
|
||||
class ReservationCreateDTO(ReservationBaseDTO):
|
||||
pass
|
||||
|
||||
class ReservationUpdateDTO(BaseModel):
|
||||
classroom_id: Optional[int] = None
|
||||
start_time: Optional[datetime] = None
|
||||
end_time: Optional[datetime] = None
|
||||
purpose: Optional[str] = None
|
||||
expected_attendees: Optional[int] = None
|
||||
shift: Optional[str] = None
|
||||
virtual_link: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
class ReservationResponseDTO(ReservationBaseDTO):
|
||||
id: int
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Módulo de Seguridad Empresarial inspirado en Spring Security (SecurityFilterChain, RBAC y Auditoría)."""
|
||||
from app.security.filter_chain import SecurityFilterChain
|
||||
from app.security.decorators import require_permission
|
||||
|
||||
__all__ = [
|
||||
'SecurityFilterChain',
|
||||
'require_permission'
|
||||
]
|
||||
@@ -0,0 +1,45 @@
|
||||
from functools import wraps
|
||||
from flask import request, jsonify, redirect, url_for, flash, abort, g
|
||||
from flask_login import current_user
|
||||
|
||||
def require_permission(module: str, min_level: str = 'read'):
|
||||
"""
|
||||
Decorador de seguridad declarativa a nivel de método inspirado en @PreAuthorize de Spring Security.
|
||||
Verifica los privilegios del usuario autenticado (tanto en sesiones web como en API JWT).
|
||||
|
||||
Niveles de permiso estándar:
|
||||
- 'read': Permiso de lectura / consulta
|
||||
- 'read_write': Permiso de modificación y creación
|
||||
- 'admin': Permiso de control total
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
user = None
|
||||
# Evaluar contexto JWT primero si está presente
|
||||
if hasattr(g, 'jwt_user') and g.jwt_user:
|
||||
user = g.jwt_user
|
||||
elif current_user and current_user.is_authenticated:
|
||||
user = current_user
|
||||
|
||||
is_api = request.path.startswith('/api/') or request.is_json
|
||||
|
||||
if not user:
|
||||
if is_api:
|
||||
return jsonify({'error': 'Unauthorized', 'message': 'Autenticación requerida.'}), 401
|
||||
flash('Debe iniciar sesión para acceder a este recurso.', 'warning')
|
||||
return redirect(url_for('auth.login', next=request.url))
|
||||
|
||||
# Comprobar privilegios en la matriz RBAC
|
||||
if not user.has_permission(module, min_level):
|
||||
if is_api:
|
||||
return jsonify({
|
||||
'error': 'Forbidden',
|
||||
'message': f'Permiso insuficiente en módulo "{module}". Nivel requerido: "{min_level}".'
|
||||
}), 403
|
||||
flash(f'No posee permisos suficientes ({min_level}) para gestionar {module}.', 'danger')
|
||||
abort(403)
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
return decorator
|
||||
@@ -0,0 +1,89 @@
|
||||
from flask import request, jsonify, g, render_template, Response
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, List, Tuple
|
||||
import time
|
||||
|
||||
class SecurityFilterChain:
|
||||
"""
|
||||
Pipeline centralizado de interceptación de seguridad (SecurityFilterChain)
|
||||
inspirado en el patrón arquitectónico de Spring Security.
|
||||
|
||||
Capas de filtrado activas:
|
||||
1. Security Headers Filter (Blindaje HTTP en todas las respuestas).
|
||||
2. Rate Limiting Filter (Protección contra fuerza bruta y DoS).
|
||||
3. Bearer Token Context Filter (Inyección de identidad contextual).
|
||||
"""
|
||||
|
||||
def __init__(self, app=None):
|
||||
# Registro de timestamps de peticiones por IP: ip -> [timestamps]
|
||||
self._request_history: Dict[str, List[float]] = {}
|
||||
# Límites por endpoint sensible (max_requests, window_seconds)
|
||||
self._rate_limits = {
|
||||
'/login': (15, 60),
|
||||
'/api/v1/auth/login': (15, 60),
|
||||
'/api/v1/auth/refresh': (30, 60)
|
||||
}
|
||||
if app:
|
||||
self.init_app(app)
|
||||
|
||||
def init_app(self, app):
|
||||
"""Registra los filtros en el ciclo de vida de la aplicación Flask."""
|
||||
app.before_request(self._execute_pre_filters)
|
||||
app.after_request(self._execute_post_filters)
|
||||
|
||||
def _get_client_ip(self) -> str:
|
||||
"""Obtiene la IP real del cliente respetando proxies inversos de confianza."""
|
||||
if request.headers.get('X-Forwarded-For'):
|
||||
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
||||
return request.remote_addr or '127.0.0.1'
|
||||
|
||||
def _execute_pre_filters(self):
|
||||
"""Ejecuta los filtros previos al enrutamiento del controlador."""
|
||||
path = request.path
|
||||
|
||||
# 1. Rate Limiting Filter (solo para peticiones POST de autenticación sensible)
|
||||
if path in self._rate_limits:
|
||||
# Peticiones GET para cargar la interfaz web NO consumen intentos de fuerza bruta
|
||||
if request.method != 'POST':
|
||||
return
|
||||
|
||||
max_req, window = self._rate_limits[path]
|
||||
client_ip = self._get_client_ip()
|
||||
now = time.time()
|
||||
|
||||
# Limpiar historial fuera de la ventana
|
||||
timestamps = self._request_history.get(client_ip, [])
|
||||
timestamps = [t for t in timestamps if now - t < window]
|
||||
|
||||
if len(timestamps) >= max_req:
|
||||
retry_after = max(1, int(window - (now - timestamps[0])))
|
||||
if path.startswith('/api/'):
|
||||
return jsonify({
|
||||
'error': 'TooManyRequests',
|
||||
'message': f'Límite de peticiones excedido. Reintente en {retry_after} segundos.'
|
||||
}), 429
|
||||
|
||||
# Para la web, renderizar vista o respuesta legible
|
||||
return Response(
|
||||
f"Demasiados intentos de autenticación desde su dirección IP. Por favor espere {retry_after} segundos antes de volver a intentar.",
|
||||
status=429,
|
||||
mimetype='text/plain; charset=utf-8'
|
||||
)
|
||||
|
||||
timestamps.append(now)
|
||||
self._request_history[client_ip] = timestamps
|
||||
|
||||
def _execute_post_filters(self, response):
|
||||
"""Inyecta cabeceras de blindaje HTTP (OWASP Secure Headers)."""
|
||||
# Evitar ataques de MIME-Sniffing
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
# Mitigación de Clickjacking
|
||||
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
|
||||
# Mitigación XSS en navegadores legacy
|
||||
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||
# Política de Referrer segura
|
||||
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||
# Inyección HSTS (si es HTTPS o entorno de producción)
|
||||
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
|
||||
|
||||
return response
|
||||
@@ -1,3 +1,19 @@
|
||||
from .sheets_importer import GoogleSheetsImporter
|
||||
from .classroom_service import ClassroomService
|
||||
from .reservation_service import ReservationService
|
||||
from .user_service import UserService
|
||||
from .jwt_service import JWTService
|
||||
from .audit_service import AuditService
|
||||
from .optimizer_service import OptimizerService, OptimizerJob, OptimizerJobStatus
|
||||
|
||||
__all__ = ['GoogleSheetsImporter']
|
||||
__all__ = [
|
||||
'GoogleSheetsImporter',
|
||||
'ClassroomService',
|
||||
'ReservationService',
|
||||
'UserService',
|
||||
'JWTService',
|
||||
'AuditService',
|
||||
'OptimizerService',
|
||||
'OptimizerJob',
|
||||
'OptimizerJobStatus'
|
||||
]
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
from typing import Optional, List, Dict, Any
|
||||
from flask import request, g, has_request_context
|
||||
from flask_login import current_user
|
||||
import json
|
||||
from app import db
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.user import User
|
||||
|
||||
class AuditService:
|
||||
"""Servicio para la captura y consulta de la bitácora inmutable de auditoría."""
|
||||
|
||||
@staticmethod
|
||||
def log(action: str, module: str, entity_id: Optional[int] = None,
|
||||
details: Optional[Any] = None, user: Optional[User] = None,
|
||||
ip_address: Optional[str] = None) -> AuditLog:
|
||||
"""
|
||||
Registra un evento de auditoría de forma segura, infiriendo el usuario e IP
|
||||
desde el contexto actual de la petición si no se especifican.
|
||||
"""
|
||||
user_id = None
|
||||
user_email = None
|
||||
|
||||
# 1. Determinar usuario actuante
|
||||
if user:
|
||||
user_id = user.id
|
||||
user_email = user.email
|
||||
elif has_request_context():
|
||||
if hasattr(g, 'jwt_user') and g.jwt_user:
|
||||
user_id = g.jwt_user.id
|
||||
user_email = g.jwt_user.email
|
||||
elif current_user and current_user.is_authenticated:
|
||||
user_id = current_user.id
|
||||
user_email = current_user.email
|
||||
|
||||
# 2. Determinar IP de origen
|
||||
if not ip_address and has_request_context():
|
||||
if request.headers.get('X-Forwarded-For'):
|
||||
ip_address = request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
||||
else:
|
||||
ip_address = request.remote_addr
|
||||
|
||||
# 3. Serializar detalles si es estructura de datos
|
||||
details_str = None
|
||||
if details is not None:
|
||||
if isinstance(details, (dict, list)):
|
||||
try:
|
||||
details_str = json.dumps(details, ensure_ascii=False)
|
||||
except Exception:
|
||||
details_str = str(details)
|
||||
else:
|
||||
details_str = str(details)
|
||||
|
||||
log_entry = AuditLog(
|
||||
user_id=user_id,
|
||||
user_email=user_email,
|
||||
action=action.upper(),
|
||||
module=module.lower(),
|
||||
entity_id=entity_id,
|
||||
details=details_str,
|
||||
ip_address=ip_address
|
||||
)
|
||||
try:
|
||||
db.session.add(log_entry)
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
|
||||
return log_entry
|
||||
|
||||
@staticmethod
|
||||
def get_recent_logs(limit: int = 50, module: Optional[str] = None) -> List[AuditLog]:
|
||||
"""Obtiene las entradas más recientes de auditoría."""
|
||||
query = AuditLog.query
|
||||
if module:
|
||||
query = query.filter_by(module=module.lower())
|
||||
return query.order_by(AuditLog.created_at.desc()).limit(limit).all()
|
||||
@@ -0,0 +1,118 @@
|
||||
from typing import Optional, List, Dict, Any
|
||||
import re
|
||||
from sqlalchemy import func
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.building import Building
|
||||
from app.repositories.classroom_repository import ClassroomRepository
|
||||
from app.schemas.classroom_dto import ClassroomCreateDTO, ClassroomUpdateDTO
|
||||
from app import db
|
||||
|
||||
class ClassroomService:
|
||||
"""Capa de servicios para la lógica de negocio y validación de aulas."""
|
||||
|
||||
def __init__(self, repository: Optional[ClassroomRepository] = None):
|
||||
self.repository = repository or ClassroomRepository()
|
||||
|
||||
def get_classroom(self, classroom_id: int) -> Optional[Classroom]:
|
||||
"""Obtiene un aula por su identificador."""
|
||||
return self.repository.get_by_id(classroom_id)
|
||||
|
||||
def list_classrooms(self, building_id: Optional[int] = None, floor: Optional[str] = None,
|
||||
only_virtual: bool = False, only_physical: bool = False) -> List[Classroom]:
|
||||
"""Lista aulas filtradas según criterios de ubicación o modalidad."""
|
||||
return self.repository.get_active_classrooms(
|
||||
building_id=building_id,
|
||||
floor=floor,
|
||||
only_virtual=only_virtual,
|
||||
only_physical=only_physical
|
||||
)
|
||||
|
||||
def create_classroom(self, dto: ClassroomCreateDTO) -> Classroom:
|
||||
"""
|
||||
Crea una nueva aula aplicando reglas de negocio:
|
||||
- Resolución o creación dinámica del edificio.
|
||||
- Unicidad de código por piso y edificio.
|
||||
- Capacidad adaptativa para aulas virtuales.
|
||||
"""
|
||||
building = None
|
||||
if dto.building_id:
|
||||
building = Building.query.get(dto.building_id)
|
||||
if not building:
|
||||
raise ValueError(f"El edificio con ID {dto.building_id} no existe.")
|
||||
elif dto.building_name:
|
||||
b_name = dto.building_name.strip()
|
||||
building = Building.query.filter(func.lower(Building.name) == func.lower(b_name)).first()
|
||||
if not building:
|
||||
code_slug = re.sub(r'[^a-zA-Z0-9]+', '_', b_name.upper()).strip('_')
|
||||
building = Building(name=b_name, code=code_slug, is_active=True)
|
||||
db.session.add(building)
|
||||
db.session.flush()
|
||||
else:
|
||||
raise ValueError("Debe especificar un edificio válido o el nombre de un nuevo edificio.")
|
||||
|
||||
# Verificar unicidad
|
||||
existing = self.repository.get_by_code_and_building(building.id, dto.code, dto.floor)
|
||||
if existing:
|
||||
raise ValueError(f"Ya existe un aula con el código '{dto.code}' en el piso {dto.floor} de {building.name}.")
|
||||
|
||||
capacity = dto.capacity
|
||||
is_virtual = ('VIRTUAL' in dto.code.upper()) or ('VIRTUAL' in building.name.upper())
|
||||
if is_virtual and capacity < 999:
|
||||
capacity = 9999
|
||||
|
||||
classroom = Classroom(
|
||||
building_id=building.id,
|
||||
building=building.name,
|
||||
code=dto.code,
|
||||
floor=dto.floor,
|
||||
capacity=capacity,
|
||||
description=dto.description,
|
||||
is_active=dto.is_active
|
||||
)
|
||||
return self.repository.save(classroom)
|
||||
|
||||
def update_classroom(self, classroom_id: int, dto: ClassroomUpdateDTO) -> Classroom:
|
||||
"""Actualiza atributos de un aula existente."""
|
||||
classroom = self.repository.get_by_id(classroom_id)
|
||||
if not classroom:
|
||||
raise ValueError(f"Aula con ID {classroom_id} no encontrada.")
|
||||
|
||||
building_id = dto.building_id if dto.building_id is not None else classroom.building_id
|
||||
code = dto.code.strip() if dto.code is not None else classroom.code
|
||||
floor = dto.floor.strip() if dto.floor is not None else classroom.floor
|
||||
|
||||
# Si cambia código, piso o edificio, validar colisión
|
||||
if (building_id != classroom.building_id or code != classroom.code or floor != classroom.floor):
|
||||
existing = self.repository.get_by_code_and_building(building_id, code, floor)
|
||||
if existing and existing.id != classroom.id:
|
||||
raise ValueError(f"Ya existe otra aula con el código '{code}' en el piso {floor}.")
|
||||
|
||||
if dto.building_id is not None:
|
||||
building = Building.query.get(dto.building_id)
|
||||
if building:
|
||||
classroom.building_id = building.id
|
||||
classroom.building = building.name
|
||||
if dto.code is not None:
|
||||
classroom.code = code
|
||||
if dto.floor is not None:
|
||||
classroom.floor = floor
|
||||
if dto.capacity is not None:
|
||||
classroom.capacity = dto.capacity
|
||||
if dto.description is not None:
|
||||
classroom.description = dto.description
|
||||
if dto.is_active is not None:
|
||||
classroom.is_active = dto.is_active
|
||||
|
||||
return self.repository.save(classroom)
|
||||
|
||||
def delete_classroom(self, classroom_id: int) -> bool:
|
||||
"""Elimina o desactiva un aula verificando dependencias."""
|
||||
classroom = self.repository.get_by_id(classroom_id)
|
||||
if not classroom:
|
||||
return False
|
||||
# Si tiene reservas históricas, se desactiva lógicamente para preservar integridad
|
||||
if classroom.reservations:
|
||||
classroom.is_active = False
|
||||
self.repository.save(classroom)
|
||||
return True
|
||||
return self.repository.delete(classroom)
|
||||
@@ -0,0 +1,116 @@
|
||||
import jwt
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, Any, Optional
|
||||
from flask import current_app
|
||||
from app.models.user import User
|
||||
|
||||
# Lista negra de tokens revocados en memoria (jti -> timestamp de expiración)
|
||||
_token_blacklist: Dict[str, datetime] = {}
|
||||
|
||||
class JWTService:
|
||||
"""Servicio de emisión, verificación y revocación de tokens JWT (Dual Token Pattern)."""
|
||||
|
||||
ACCESS_TOKEN_EXPIRES = timedelta(minutes=15)
|
||||
REFRESH_TOKEN_EXPIRES = timedelta(days=7)
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
@classmethod
|
||||
def _clean_expired_blacklist(cls):
|
||||
"""Limpia periódicamente identificadores de tokens que ya han expirado naturalmente."""
|
||||
now = datetime.now(timezone.utc)
|
||||
expired_jtis = [jti for jti, exp in _token_blacklist.items() if exp < now]
|
||||
for jti in expired_jtis:
|
||||
_token_blacklist.pop(jti, None)
|
||||
|
||||
@classmethod
|
||||
def _get_secret(cls) -> str:
|
||||
"""Obtiene la clave secreta desde la configuración de Flask."""
|
||||
return current_app.config.get('SECRET_KEY', 'default-edu-space-secret-key')
|
||||
|
||||
@classmethod
|
||||
def create_access_token(cls, user: User, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""Emite un token de acceso de corta duración (15 min) con claims de rol y permisos."""
|
||||
now = datetime.now(timezone.utc)
|
||||
delta = expires_delta or cls.ACCESS_TOKEN_EXPIRES
|
||||
role_name = user.role_obj.name if user.role_obj else user.role
|
||||
|
||||
# Mapeo de permisos para claims del token
|
||||
permissions = {}
|
||||
if user.role_obj and hasattr(user.role_obj, 'permissions'):
|
||||
for p in user.role_obj.permissions:
|
||||
permissions[p.module] = p.access_level
|
||||
|
||||
payload = {
|
||||
'sub': str(user.id),
|
||||
'email': user.email,
|
||||
'name': user.name,
|
||||
'role': role_name,
|
||||
'is_admin': user.is_admin(),
|
||||
'permissions': permissions,
|
||||
'type': 'access',
|
||||
'jti': str(uuid.uuid4()),
|
||||
'iat': now,
|
||||
'exp': now + delta
|
||||
}
|
||||
return jwt.encode(payload, cls._get_secret(), algorithm=cls.ALGORITHM)
|
||||
|
||||
@classmethod
|
||||
def create_refresh_token(cls, user: User, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""Emite un token de refresco de larga duración (7 días) para renovación silenciosa."""
|
||||
now = datetime.now(timezone.utc)
|
||||
delta = expires_delta or cls.REFRESH_TOKEN_EXPIRES
|
||||
payload = {
|
||||
'sub': str(user.id),
|
||||
'type': 'refresh',
|
||||
'jti': str(uuid.uuid4()),
|
||||
'iat': now,
|
||||
'exp': now + delta
|
||||
}
|
||||
return jwt.encode(payload, cls._get_secret(), algorithm=cls.ALGORITHM)
|
||||
|
||||
@classmethod
|
||||
def generate_tokens(cls, user: User) -> Dict[str, Any]:
|
||||
"""Genera el par de tokens (Access + Refresh) junto con los metadatos del usuario."""
|
||||
access_token = cls.create_access_token(user)
|
||||
refresh_token = cls.create_refresh_token(user)
|
||||
return {
|
||||
'access_token': access_token,
|
||||
'refresh_token': refresh_token,
|
||||
'token_type': 'Bearer',
|
||||
'expires_in': int(cls.ACCESS_TOKEN_EXPIRES.total_seconds())
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def decode_token(cls, token: str, expected_type: str = 'access') -> Dict[str, Any]:
|
||||
"""
|
||||
Decodifica y valida la firma, expiración y tipo del token.
|
||||
Lanza jwt.PyJWTError si el token es inválido, expirado o fue revocado.
|
||||
"""
|
||||
cls._clean_expired_blacklist()
|
||||
payload = jwt.decode(token, cls._get_secret(), algorithms=[cls.ALGORITHM])
|
||||
|
||||
jti = payload.get('jti')
|
||||
if jti and jti in _token_blacklist:
|
||||
raise jwt.InvalidTokenError("El token ha sido revocado.")
|
||||
|
||||
token_type = payload.get('type')
|
||||
if token_type != expected_type:
|
||||
raise jwt.InvalidTokenError(f"Tipo de token inválido. Se esperaba '{expected_type}', recibido '{token_type}'.")
|
||||
|
||||
return payload
|
||||
|
||||
@classmethod
|
||||
def revoke_token(cls, token: str) -> bool:
|
||||
"""Agrega el identificador del token a la lista negra hasta su fecha natural de expiración."""
|
||||
try:
|
||||
payload = jwt.decode(token, cls._get_secret(), algorithms=[cls.ALGORITHM], options={"verify_exp": False})
|
||||
jti = payload.get('jti')
|
||||
exp_ts = payload.get('exp')
|
||||
if jti and exp_ts:
|
||||
exp_dt = datetime.fromtimestamp(exp_ts, tz=timezone.utc)
|
||||
_token_blacklist[jti] = exp_dt
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
@@ -0,0 +1,282 @@
|
||||
import time
|
||||
import uuid
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
from typing import List, Dict, Any, Optional
|
||||
from flask import current_app
|
||||
|
||||
from app.models.genetic_algorithm import (
|
||||
ReservationOptimizer,
|
||||
GeneticAlgorithm,
|
||||
ReservationRequest,
|
||||
Classroom
|
||||
)
|
||||
from app.models.subject import Commission
|
||||
from app.schemas.optimizer_dto import SpringBootSyncPayloadDTO
|
||||
|
||||
|
||||
class OptimizerJobStatus(str, Enum):
|
||||
PENDING = "PENDING"
|
||||
RUNNING = "RUNNING"
|
||||
COMPLETED = "COMPLETED"
|
||||
FAILED = "FAILED"
|
||||
CANCELLED = "CANCELLED"
|
||||
|
||||
|
||||
class OptimizerJob:
|
||||
"""Representa un trabajo asíncrono en la cola del optimizador heurístico."""
|
||||
|
||||
def __init__(self, job_id: str, params: Dict[str, Any]):
|
||||
self.job_id = job_id
|
||||
self.status = OptimizerJobStatus.PENDING
|
||||
self.progress = 0
|
||||
self.generation = 0
|
||||
self.total_generations = params.get("generations", 80)
|
||||
self.fitness_score = 0.0
|
||||
self.total_requests = 0
|
||||
self.assigned_reservations = 0
|
||||
self.reservations: List[Dict[str, Any]] = []
|
||||
self.conflicts: List[Dict[str, Any]] = []
|
||||
self.error: Optional[str] = None
|
||||
self.created_at = datetime.utcnow()
|
||||
self.started_at: Optional[datetime] = None
|
||||
self.completed_at: Optional[datetime] = None
|
||||
self.execution_time_ms: Optional[float] = None
|
||||
self.params = params
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"job_id": self.job_id,
|
||||
"status": self.status.value,
|
||||
"progress": self.progress,
|
||||
"generation": self.generation,
|
||||
"total_generations": self.total_generations,
|
||||
"fitness_score": round(self.fitness_score, 2),
|
||||
"total_requests": self.total_requests,
|
||||
"assigned_reservations": self.assigned_reservations,
|
||||
"reservations": self.reservations,
|
||||
"conflict_count": len(self.conflicts),
|
||||
"conflicts": self.conflicts,
|
||||
"error": self.error,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"started_at": self.started_at.isoformat() if self.started_at else None,
|
||||
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
|
||||
"execution_time_ms": self.execution_time_ms,
|
||||
"params": {k: v for k, v in self.params.items() if k not in ("app",)}
|
||||
}
|
||||
|
||||
|
||||
class OptimizerService:
|
||||
"""
|
||||
Servicio de alto rendimiento para el Optimizador Heurístico y Cola de Trabajos Asíncronos.
|
||||
Soporta evaluación concurrente multi-hilo y sincronización interoperable con Spring Boot.
|
||||
"""
|
||||
|
||||
_jobs: Dict[str, OptimizerJob] = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
@classmethod
|
||||
def submit_job(
|
||||
cls,
|
||||
commission_ids: List[int],
|
||||
admin_user_id: int,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None,
|
||||
generations: int = 80,
|
||||
population_size: int = 40,
|
||||
workers: int = 4,
|
||||
app = None
|
||||
) -> OptimizerJob:
|
||||
"""
|
||||
Registra y despacha un trabajo de optimización en segundo plano (asíncrono).
|
||||
"""
|
||||
job_id = str(uuid.uuid4())
|
||||
params = {
|
||||
"commission_ids": commission_ids,
|
||||
"admin_user_id": admin_user_id,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"generations": generations,
|
||||
"population_size": population_size,
|
||||
"workers": workers
|
||||
}
|
||||
|
||||
job = OptimizerJob(job_id, params)
|
||||
with cls._lock:
|
||||
cls._jobs[job_id] = job
|
||||
|
||||
# Si no se pasó app explícitamente, intentar obtener current_app
|
||||
if app is None:
|
||||
try:
|
||||
app = current_app._get_current_object()
|
||||
except Exception:
|
||||
app = None
|
||||
|
||||
thread = threading.Thread(
|
||||
target=cls._run_job_worker,
|
||||
args=(job_id, app),
|
||||
daemon=True,
|
||||
name=f"optimizer-worker-{job_id[:8]}"
|
||||
)
|
||||
thread.start()
|
||||
return job
|
||||
|
||||
@classmethod
|
||||
def _run_job_worker(cls, job_id: str, app):
|
||||
"""Worker en segundo plano para ejecutar el algoritmo genético."""
|
||||
job = cls.get_job(job_id)
|
||||
if not job:
|
||||
return
|
||||
|
||||
def _execute():
|
||||
t_start = time.perf_counter()
|
||||
with cls._lock:
|
||||
job.status = OptimizerJobStatus.RUNNING
|
||||
job.started_at = datetime.utcnow()
|
||||
|
||||
def progress_callback(gen: int, total_gen: int, pct: int, best_fit: float):
|
||||
with cls._lock:
|
||||
job.generation = gen
|
||||
job.total_generations = total_gen
|
||||
job.progress = pct
|
||||
job.fitness_score = best_fit
|
||||
|
||||
optimizer = ReservationOptimizer(
|
||||
population_size=job.params.get("population_size", 40),
|
||||
generations=job.params.get("generations", 80),
|
||||
workers=job.params.get("workers", 4)
|
||||
)
|
||||
|
||||
result = optimizer.optimize_schedule(
|
||||
commission_ids=job.params["commission_ids"],
|
||||
admin_user_id=job.params["admin_user_id"],
|
||||
start_date=job.params.get("start_date"),
|
||||
end_date=job.params.get("end_date"),
|
||||
progress_callback=progress_callback
|
||||
)
|
||||
|
||||
t_end = time.perf_counter()
|
||||
exec_time_ms = round((t_end - t_start) * 1000, 2)
|
||||
|
||||
with cls._lock:
|
||||
job.status = OptimizerJobStatus.COMPLETED
|
||||
job.progress = 100
|
||||
job.fitness_score = result.get("fitness_score", 0.0)
|
||||
job.total_requests = result.get("total_requests", 0)
|
||||
job.assigned_reservations = result.get("assigned_reservations", 0)
|
||||
job.reservations = result.get("reservations", [])
|
||||
job.conflicts = result.get("conflicts", [])
|
||||
job.completed_at = datetime.utcnow()
|
||||
job.execution_time_ms = exec_time_ms
|
||||
|
||||
try:
|
||||
if app:
|
||||
with app.app_context():
|
||||
_execute()
|
||||
else:
|
||||
_execute()
|
||||
except Exception as ex:
|
||||
with cls._lock:
|
||||
job.status = OptimizerJobStatus.FAILED
|
||||
job.error = str(ex)
|
||||
job.completed_at = datetime.utcnow()
|
||||
|
||||
@classmethod
|
||||
def get_job(cls, job_id: str) -> Optional[OptimizerJob]:
|
||||
"""Obtiene un trabajo por su identificador único."""
|
||||
with cls._lock:
|
||||
return cls._jobs.get(job_id)
|
||||
|
||||
@classmethod
|
||||
def list_jobs(cls, limit: int = 50) -> List[Dict[str, Any]]:
|
||||
"""Lista los trabajos más recientes ordenados por fecha de creación desc."""
|
||||
with cls._lock:
|
||||
sorted_jobs = sorted(cls._jobs.values(), key=lambda j: j.created_at, reverse=True)
|
||||
return [j.to_dict() for j in sorted_jobs[:limit]]
|
||||
|
||||
@classmethod
|
||||
def sync_with_spring_boot(
|
||||
cls,
|
||||
payload: SpringBootSyncPayloadDTO,
|
||||
admin_user_id: int = 1
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Procesa una carga de optimización interoperable proveniente de microservicios Spring Boot.
|
||||
Garantiza respuesta conforme al contrato JSON del microservicio.
|
||||
"""
|
||||
t_start = time.perf_counter()
|
||||
|
||||
# 1. Resolver aulas disponibles
|
||||
if payload.classrooms and len(payload.classrooms) > 0:
|
||||
classrooms = []
|
||||
for c_dto in payload.classrooms:
|
||||
room = Classroom(
|
||||
room_number=c_dto.roomNumber,
|
||||
capacity=c_dto.capacity,
|
||||
is_virtual=c_dto.isVirtual,
|
||||
is_active=True
|
||||
)
|
||||
room.id = c_dto.classroomId
|
||||
classrooms.append(room)
|
||||
else:
|
||||
classrooms = Classroom.query.filter_by(is_active=True).all()
|
||||
|
||||
# 2. Generar ReservationRequests desde el DTO de Spring Boot
|
||||
requests: List[ReservationRequest] = []
|
||||
for c in payload.commissions:
|
||||
req = ReservationRequest(
|
||||
commission_id=c.commissionId,
|
||||
expected_attendees=c.expectedAttendees,
|
||||
purpose=f"Spring Boot Class: {c.subjectCode} - {c.subjectName}",
|
||||
preferred_start_time=c.preferredStart,
|
||||
preferred_end_time=c.preferredEnd,
|
||||
priority=1,
|
||||
flexibility_hours=2
|
||||
)
|
||||
requests.append(req)
|
||||
|
||||
# 3. Opciones de configuración de algoritmo
|
||||
options = payload.options or {}
|
||||
generations = options.get("generations", 60)
|
||||
pop_size = options.get("populationSize", 30)
|
||||
workers = options.get("workers", 4)
|
||||
|
||||
ga = GeneticAlgorithm(
|
||||
population_size=pop_size,
|
||||
generations=generations,
|
||||
workers=workers
|
||||
)
|
||||
|
||||
start_date = min(c.preferredStart for c in payload.commissions)
|
||||
end_date = max(c.preferredEnd for c in payload.commissions)
|
||||
|
||||
best_individual = ga.optimize_reservations(requests, classrooms, start_date, end_date)
|
||||
|
||||
t_end = time.perf_counter()
|
||||
exec_ms = round((t_end - t_start) * 1000, 2)
|
||||
|
||||
scheduled_assignments = []
|
||||
for gene in best_individual.genes:
|
||||
scheduled_assignments.append({
|
||||
"commissionId": gene.commission_id,
|
||||
"classroomId": gene.classroom_id,
|
||||
"startTime": gene.start_time.isoformat() if gene.start_time else None,
|
||||
"endTime": gene.end_time.isoformat() if gene.end_time else None,
|
||||
"purpose": gene.purpose,
|
||||
"expectedAttendees": gene.expected_attendees,
|
||||
"status": "SCHEDULED"
|
||||
})
|
||||
|
||||
return {
|
||||
"requestId": payload.requestId,
|
||||
"status": "SUCCESS",
|
||||
"academicTerm": payload.academicTerm,
|
||||
"campusCode": payload.campusCode,
|
||||
"totalCommissions": len(payload.commissions),
|
||||
"scheduledAssignments": scheduled_assignments,
|
||||
"conflictCount": len(best_individual.conflicts),
|
||||
"fitnessScore": round(best_individual.fitness, 2),
|
||||
"executionTimeMs": exec_ms,
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z"
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
from app.models.classroom import Classroom
|
||||
from app.repositories.reservation_repository import ReservationRepository
|
||||
from app.repositories.classroom_repository import ClassroomRepository
|
||||
from app.schemas.reservation_dto import ReservationCreateDTO, ReservationUpdateDTO
|
||||
from app import db
|
||||
|
||||
class ReservationService:
|
||||
"""Capa de servicios para la orquestación y validación de reservas y cronograma."""
|
||||
|
||||
def __init__(self, reservation_repo: Optional[ReservationRepository] = None,
|
||||
classroom_repo: Optional[ClassroomRepository] = None):
|
||||
self.reservation_repo = reservation_repo or ReservationRepository()
|
||||
self.classroom_repo = classroom_repo or ClassroomRepository()
|
||||
|
||||
def get_reservation(self, reservation_id: int) -> Optional[Reservation]:
|
||||
"""Obtiene una reserva por su ID."""
|
||||
return self.reservation_repo.get_by_id(reservation_id)
|
||||
|
||||
def list_reservations(self, start_dt: datetime, end_dt: datetime,
|
||||
classroom_id: Optional[int] = None) -> List[Reservation]:
|
||||
"""Obtiene reservas dentro de un rango temporal."""
|
||||
return self.reservation_repo.get_by_date_range(start_dt, end_dt, classroom_id=classroom_id)
|
||||
|
||||
def check_conflicts(self, classroom_id: int, start_time: datetime, end_time: datetime,
|
||||
exclude_id: Optional[int] = None) -> List[Reservation]:
|
||||
"""Verifica si existen solapamientos en un aula específica."""
|
||||
classroom = self.classroom_repo.get_by_id(classroom_id)
|
||||
# Las aulas virtuales tienen concurrencia ilimitada (sin colisiones)
|
||||
if classroom and classroom.is_virtual:
|
||||
return []
|
||||
return self.reservation_repo.find_conflicts(
|
||||
classroom_id=classroom_id,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
exclude_reservation_id=exclude_id
|
||||
)
|
||||
|
||||
def create_reservation(self, dto: ReservationCreateDTO) -> Reservation:
|
||||
"""
|
||||
Crea una nueva reserva aplicando validaciones de aforo y detección de colisiones.
|
||||
"""
|
||||
classroom = self.classroom_repo.get_by_id(dto.classroom_id)
|
||||
if not classroom or not classroom.is_active:
|
||||
raise ValueError(f"El aula con ID {dto.classroom_id} no existe o no se encuentra activa.")
|
||||
|
||||
# Detección de colisiones para aulas físicas
|
||||
if not classroom.is_virtual:
|
||||
conflicts = self.check_conflicts(dto.classroom_id, dto.start_time, dto.end_time)
|
||||
if conflicts:
|
||||
conflict_details = ", ".join([f"#{c.id} ({c.start_time.strftime('%H:%M')} a {c.end_time.strftime('%H:%M')})" for c in conflicts])
|
||||
raise ValueError(f"Conflicto de horario en {classroom.code}: se solapa con las reservas {conflict_details}.")
|
||||
|
||||
if dto.expected_attendees > classroom.capacity:
|
||||
raise ValueError(f"La cantidad de alumnos ({dto.expected_attendees}) supera la capacidad del aula ({classroom.capacity}).")
|
||||
|
||||
reservation = Reservation(
|
||||
classroom_id=dto.classroom_id,
|
||||
commission_id=dto.commission_id,
|
||||
user_id=dto.user_id,
|
||||
start_time=dto.start_time,
|
||||
end_time=dto.end_time,
|
||||
purpose=dto.purpose,
|
||||
expected_attendees=dto.expected_attendees,
|
||||
shift=dto.shift,
|
||||
virtual_link=dto.virtual_link,
|
||||
notes=dto.notes,
|
||||
status=dto.status or 'CONFIRMED'
|
||||
)
|
||||
return self.reservation_repo.save(reservation)
|
||||
|
||||
def update_reservation(self, reservation_id: int, dto: ReservationUpdateDTO) -> Reservation:
|
||||
"""Actualiza una reserva existente verificando disponibilidad horaria."""
|
||||
reservation = self.reservation_repo.get_by_id(reservation_id)
|
||||
if not reservation:
|
||||
raise ValueError(f"Reserva con ID {reservation_id} no encontrada.")
|
||||
|
||||
classroom_id = dto.classroom_id or reservation.classroom_id
|
||||
start_time = dto.start_time or reservation.start_time
|
||||
end_time = dto.end_time or reservation.end_time
|
||||
|
||||
classroom = self.classroom_repo.get_by_id(classroom_id)
|
||||
if not classroom or not classroom.is_active:
|
||||
raise ValueError(f"El aula con ID {classroom_id} no existe o no se encuentra activa.")
|
||||
|
||||
if not classroom.is_virtual:
|
||||
conflicts = self.check_conflicts(classroom_id, start_time, end_time, exclude_id=reservation.id)
|
||||
if conflicts:
|
||||
conflict_details = ", ".join([f"#{c.id} ({c.start_time.strftime('%H:%M')} - {c.end_time.strftime('%H:%M')})" for c in conflicts])
|
||||
raise ValueError(f"Conflicto de horario en {classroom.code} con: {conflict_details}.")
|
||||
|
||||
if dto.classroom_id is not None:
|
||||
reservation.classroom_id = dto.classroom_id
|
||||
if dto.start_time is not None:
|
||||
reservation.start_time = dto.start_time
|
||||
if dto.end_time is not None:
|
||||
reservation.end_time = dto.end_time
|
||||
if dto.purpose is not None:
|
||||
reservation.purpose = dto.purpose
|
||||
if dto.expected_attendees is not None:
|
||||
reservation.expected_attendees = dto.expected_attendees
|
||||
if dto.shift is not None:
|
||||
reservation.shift = dto.shift
|
||||
if dto.virtual_link is not None:
|
||||
reservation.virtual_link = dto.virtual_link
|
||||
if dto.notes is not None:
|
||||
reservation.notes = dto.notes
|
||||
if dto.status is not None:
|
||||
reservation.status = dto.status
|
||||
|
||||
return self.reservation_repo.save(reservation)
|
||||
|
||||
def cancel_reservation(self, reservation_id: int) -> bool:
|
||||
"""Cancela una reserva estableciendo su estado en CANCELLED."""
|
||||
reservation = self.reservation_repo.get_by_id(reservation_id)
|
||||
if not reservation:
|
||||
return False
|
||||
reservation.status = ReservationStatus.CANCELLED.value
|
||||
self.reservation_repo.save(reservation)
|
||||
return True
|
||||
@@ -0,0 +1,53 @@
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from app.models.user import User
|
||||
from app.repositories.user_repository import UserRepository
|
||||
from app import db
|
||||
|
||||
class UserService:
|
||||
"""Capa de servicios para la autenticación, roles y perfil de usuarios."""
|
||||
|
||||
def __init__(self, repository: Optional[UserRepository] = None):
|
||||
self.repository = repository or UserRepository()
|
||||
|
||||
def authenticate(self, email: str, password: str) -> Optional[User]:
|
||||
"""
|
||||
Valida credenciales de acceso de un usuario.
|
||||
Retorna la entidad User si es válido y activo; None en caso contrario.
|
||||
"""
|
||||
user = self.repository.get_by_email(email)
|
||||
if not user or not user.is_active:
|
||||
return None
|
||||
|
||||
if user.check_password(password):
|
||||
user.last_login = datetime.utcnow()
|
||||
self.repository.save(user)
|
||||
return user
|
||||
return None
|
||||
|
||||
def get_by_id(self, user_id: int) -> Optional[User]:
|
||||
"""Obtiene un usuario por ID."""
|
||||
return self.repository.get_by_id(user_id)
|
||||
|
||||
def get_by_email(self, email: str) -> Optional[User]:
|
||||
"""Obtiene un usuario por email."""
|
||||
return self.repository.get_by_email(email)
|
||||
|
||||
def get_profile_data(self, user: User) -> Dict[str, Any]:
|
||||
"""Genera un diccionario estructurado del perfil y permisos del usuario."""
|
||||
role_name = user.role_obj.name if user.role_obj else user.role
|
||||
permissions = {}
|
||||
if user.role_obj and hasattr(user.role_obj, 'permissions'):
|
||||
for p in user.role_obj.permissions:
|
||||
permissions[p.module] = p.access_level
|
||||
|
||||
return {
|
||||
'id': user.id,
|
||||
'name': user.name,
|
||||
'email': user.email,
|
||||
'role': role_name,
|
||||
'is_admin': user.is_admin(),
|
||||
'permissions': permissions,
|
||||
'preferred_language': user.preferred_language,
|
||||
'theme_preference': user.theme_preference
|
||||
}
|
||||
@@ -174,4 +174,131 @@
|
||||
background-color: #FFFFFF !important;
|
||||
color: #000000 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* View Switcher Controls */
|
||||
.view-switcher-group {
|
||||
flex-direction: row !important;
|
||||
display: inline-flex !important;
|
||||
}
|
||||
|
||||
.view-switcher-group .btn {
|
||||
margin-bottom: 0 !important;
|
||||
font-weight: 600;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.view-switcher-group .btn.active {
|
||||
box-shadow: 0 2px 6px rgba(var(--brand-primary-rgb), 0.25);
|
||||
}
|
||||
|
||||
.reservation-card {
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.reservation-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.08) !important;
|
||||
border-color: var(--bs-primary) !important;
|
||||
}
|
||||
|
||||
/* Role Badges & Pills */
|
||||
.badge-admin {
|
||||
background: linear-gradient(135deg, #7C3AED 0%, #B43E8E 100%) !important;
|
||||
color: #FFFFFF !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.badge-bedelia {
|
||||
background: linear-gradient(135deg, #D97706 0%, #B45309 100%) !important;
|
||||
color: #FFFFFF !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.badge-docente {
|
||||
background: linear-gradient(135deg, #0284C7 0%, #0369A1 100%) !important;
|
||||
color: #FFFFFF !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.badge-alumno {
|
||||
background: linear-gradient(135deg, #059669 0%, #047857 100%) !important;
|
||||
color: #FFFFFF !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.role-pill {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
padding: 0.22rem 0.55rem;
|
||||
border-radius: 9999px;
|
||||
letter-spacing: 0.3px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
/* Role Dashboard Hero & Cards */
|
||||
.dashboard-role-banner {
|
||||
border-radius: 1rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, rgba(180, 62, 142, 0.08) 0%, rgba(104, 44, 173, 0.12) 100%);
|
||||
border: 1px solid rgba(180, 62, 142, 0.18);
|
||||
padding: 1.5rem 1.75rem;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .dashboard-role-banner {
|
||||
background: linear-gradient(135deg, rgba(180, 62, 142, 0.15) 0%, rgba(42, 27, 61, 0.5) 100%);
|
||||
border-color: rgba(180, 62, 142, 0.25);
|
||||
}
|
||||
|
||||
.role-view-selector .btn {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
padding: 0.3rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.role-view-selector .btn.active {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.action-menu-card {
|
||||
border-radius: 0.85rem;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.action-menu-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 0.5rem 1.2rem rgba(0, 0, 0, 0.08) !important;
|
||||
border-color: var(--brand-primary) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .action-menu-card {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .action-menu-card:hover {
|
||||
border-color: var(--brand-primary-tint) !important;
|
||||
box-shadow: 0 0.5rem 1.2rem rgba(0, 0, 0, 0.4) !important;
|
||||
}
|
||||
|
||||
.quick-icon-circle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
}
|
||||
@@ -142,6 +142,20 @@ body {
|
||||
transition: color 0.15s ease, transform 0.15s ease;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.85rem !important;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) and (min-width: 992px) {
|
||||
.navbar-app {
|
||||
padding: 0.5rem 0.75rem !important;
|
||||
}
|
||||
.navbar-app .nav-link {
|
||||
padding: 0.4rem 0.5rem !important;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.navbar-app .navbar-brand span {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-app .nav-link:hover,
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* ViewSwitcher — Gestor Universal y Extensible de Modos de Visualización
|
||||
*
|
||||
* Permite alternar instantáneamente entre vista de Tabla (grilla densa)
|
||||
* y vista de Tarjetas (mosaico responsive), preservando filtros, búsquedas
|
||||
* y persistiendo la preferencia del usuario en localStorage.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
class ViewSwitcherManager {
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
this.setupViewSwitchers();
|
||||
});
|
||||
}
|
||||
|
||||
setupViewSwitchers() {
|
||||
const switchers = document.querySelectorAll('.view-switcher-group');
|
||||
if (!switchers.length) return;
|
||||
|
||||
switchers.forEach(group => {
|
||||
const context = group.getAttribute('data-context') || 'default';
|
||||
const defaultView = group.getAttribute('data-default-view') || 'cards';
|
||||
|
||||
// Determinar vista inicial:
|
||||
// 1. URL search param (?display=...)
|
||||
// 2. localStorage
|
||||
// 3. defaultView del contenedor
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const urlView = urlParams.get('display');
|
||||
const storedView = localStorage.getItem(`view_preference_${context}`);
|
||||
const activeView = urlView || storedView || defaultView;
|
||||
|
||||
this.applyView(context, activeView, false);
|
||||
|
||||
// Asignar listeners a los botones del selector
|
||||
const buttons = group.querySelectorAll('.view-switcher-btn');
|
||||
buttons.forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const targetView = btn.getAttribute('data-view');
|
||||
if (targetView) {
|
||||
this.applyView(context, targetView, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplica la vista seleccionada en el contexto dado.
|
||||
* @param {string} context - Identificador del módulo (ej: 'classrooms', 'reservations')
|
||||
* @param {string} viewName - Nombre de la vista ('table', 'cards', etc.)
|
||||
* @param {boolean} persist - Si debe persistirse en localStorage y actualizar URL
|
||||
*/
|
||||
applyView(context, viewName, persist = true) {
|
||||
// 1. Actualizar botones activos en el grupo selector
|
||||
const group = document.querySelector(`.view-switcher-group[data-context="${context}"]`);
|
||||
if (group) {
|
||||
const buttons = group.querySelectorAll('.view-switcher-btn');
|
||||
buttons.forEach(btn => {
|
||||
const btnView = btn.getAttribute('data-view');
|
||||
if (btnView === viewName) {
|
||||
btn.classList.remove('btn-outline-secondary');
|
||||
btn.classList.add('btn-primary', 'active');
|
||||
btn.setAttribute('aria-pressed', 'true');
|
||||
} else {
|
||||
btn.classList.remove('btn-primary', 'active');
|
||||
btn.classList.add('btn-outline-secondary');
|
||||
btn.setAttribute('aria-pressed', 'false');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Conmutar visibilidad de los contenedores de vista
|
||||
const containers = document.querySelectorAll(`[data-view-context="${context}"]`);
|
||||
let matched = false;
|
||||
|
||||
containers.forEach(container => {
|
||||
const containerView = container.getAttribute('data-view-container');
|
||||
if (containerView === viewName) {
|
||||
container.classList.remove('d-none');
|
||||
container.removeAttribute('aria-hidden');
|
||||
matched = true;
|
||||
} else {
|
||||
container.classList.add('d-none');
|
||||
container.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
});
|
||||
|
||||
// Fallback: si la vista solicitada no tiene contenedor, restaurar a la primera visible
|
||||
if (!matched && containers.length > 0) {
|
||||
containers[0].classList.remove('d-none');
|
||||
viewName = containers[0].getAttribute('data-view-container') || 'cards';
|
||||
}
|
||||
|
||||
// 3. Persistir preferencia y actualizar formularios y URL
|
||||
if (persist) {
|
||||
try {
|
||||
localStorage.setItem(`view_preference_${context}`, viewName);
|
||||
} catch (e) {
|
||||
console.warn('localStorage no disponible para guardar preferencia de vista:', e);
|
||||
}
|
||||
|
||||
// Actualizar parámetro display en la URL sin recargar
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('display', viewName);
|
||||
window.history.replaceState({}, '', url);
|
||||
}
|
||||
|
||||
// 4. Sincronizar campo hidden 'display' en formularios de búsqueda/filtro
|
||||
const forms = document.querySelectorAll('form');
|
||||
forms.forEach(form => {
|
||||
let hiddenInput = form.querySelector('input[name="display"]');
|
||||
if (!hiddenInput) {
|
||||
hiddenInput = document.createElement('input');
|
||||
hiddenInput.type = 'hidden';
|
||||
hiddenInput.name = 'display';
|
||||
form.appendChild(hiddenInput);
|
||||
}
|
||||
hiddenInput.value = viewName;
|
||||
});
|
||||
|
||||
// 5. Emitir evento personalizado para componentes dinámicos
|
||||
window.dispatchEvent(new CustomEvent('viewModeChanged', {
|
||||
detail: { context, view: viewName }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Instanciar singleton
|
||||
window.ViewSwitcher = new ViewSwitcherManager();
|
||||
})();
|
||||
@@ -0,0 +1,161 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% trans %}Bitácora de Auditoría - Edu-Space Admin{% endtrans %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
||||
<div>
|
||||
<h1 class="h3 mb-1">
|
||||
<i class="bi bi-shield-check text-primary me-2"></i>{% trans %}Registro de Auditoría Institucional{% endtrans %}
|
||||
</h1>
|
||||
<p class="text-muted small mb-0">{% trans %}Trazabilidad completa de operaciones, modificaciones y eventos de seguridad{% endtrans %}</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('main.dashboard') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left me-1"></i>{% trans %}Volver al Panel{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtros -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body p-3">
|
||||
<form method="GET" action="{{ url_for('admin.audit_logs') }}" class="row g-2 align-items-center">
|
||||
<div class="col-md-4">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-transparent"><i class="bi bi-search"></i></span>
|
||||
<input type="text" name="search" class="form-control" placeholder="{% trans %}Buscar por email, IP o detalles...{% endtrans %}" value="{{ search }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select name="module" class="form-select form-select-sm">
|
||||
<option value="">{% trans %}Todos los módulos{% endtrans %}</option>
|
||||
{% for mod in modules %}
|
||||
<option value="{{ mod }}" {% if current_module == mod %}selected{% endif %}>{{ mod|capitalize }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select name="action" class="form-select form-select-sm">
|
||||
<option value="">{% trans %}Todas las acciones{% endtrans %}</option>
|
||||
{% for act in actions %}
|
||||
<option value="{{ act }}" {% if current_action == act %}selected{% endif %}>{{ act }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 d-flex gap-1">
|
||||
<button type="submit" class="btn btn-primary btn-sm flex-grow-1">
|
||||
<i class="bi bi-filter me-1"></i>{% trans %}Filtrar{% endtrans %}
|
||||
</button>
|
||||
{% if search or current_module or current_action %}
|
||||
<a href="{{ url_for('admin.audit_logs') }}" class="btn btn-outline-secondary btn-sm" title="Limpiar filtros">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabla de Auditoría -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
{% if audit_logs %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light small text-uppercase">
|
||||
<tr>
|
||||
<th class="ps-3">{% trans %}Fecha / Hora{% endtrans %}</th>
|
||||
<th>{% trans %}Usuario{% endtrans %}</th>
|
||||
<th>{% trans %}Acción{% endtrans %}</th>
|
||||
<th>{% trans %}Módulo{% endtrans %}</th>
|
||||
<th>{% trans %}ID Entidad{% endtrans %}</th>
|
||||
<th>{% trans %}Detalles{% endtrans %}</th>
|
||||
<th class="pe-3">{% trans %}IP{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in audit_logs %}
|
||||
<tr>
|
||||
<td class="ps-3 text-nowrap">
|
||||
<span class="small font-monospace">{{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-semibold small">{{ log.user_email or 'Sistema' }}</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if log.action == 'CREATE' %}
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle">{{ log.action }}</span>
|
||||
{% elif log.action == 'UPDATE' %}
|
||||
<span class="badge bg-primary-subtle text-primary border border-primary-subtle">{{ log.action }}</span>
|
||||
{% elif log.action == 'DELETE' %}
|
||||
<span class="badge bg-danger-subtle text-danger border border-danger-subtle">{{ log.action }}</span>
|
||||
{% elif 'LOGIN' in log.action %}
|
||||
<span class="badge bg-info-subtle text-info border border-info-subtle">{{ log.action }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary-subtle text-secondary border">{{ log.action }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-body-secondary text-body border font-monospace small">{{ log.module }}</span>
|
||||
</td>
|
||||
<td class="font-monospace small text-muted">
|
||||
{{ log.entity_id or '-' }}
|
||||
</td>
|
||||
<td class="small text-muted" style="max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="{{ log.details }}">
|
||||
{{ log.details or '-' }}
|
||||
</td>
|
||||
<td class="pe-3 font-monospace small text-muted">
|
||||
{{ log.ip_address or '-' }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Paginación -->
|
||||
{% if pagination.pages > 1 %}
|
||||
<div class="p-3 border-top d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted">
|
||||
{% trans %}Mostrando página{% endtrans %} {{ pagination.page }} {% trans %}de{% endtrans %} {{ pagination.pages }} ({{ pagination.total }} {% trans %}registros{% endtrans %})
|
||||
</small>
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
<li class="page-item {% if not pagination.has_prev %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('admin.audit_logs', page=pagination.prev_num, search=search, module=current_module, action=current_action) }}">
|
||||
«
|
||||
</a>
|
||||
</li>
|
||||
{% for p in pagination.iter_pages(left_edge=2, left_current=2, right_current=3, right_edge=2) %}
|
||||
{% if p %}
|
||||
<li class="page-item {% if p == pagination.page %}active{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('admin.audit_logs', page=p, search=search, module=current_module, action=current_action) }}">{{ p }}</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link">…</span></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<li class="page-item {% if not pagination.has_next %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('admin.audit_logs', page=pagination.next_num, search=search, module=current_module, action=current_action) }}">
|
||||
»
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-shield-slash text-muted fs-1 d-block mb-2"></i>
|
||||
<h6 class="fw-bold">{% trans %}No se encontraron registros de auditoría{% endtrans %}</h6>
|
||||
<p class="text-muted small">{% trans %}Los eventos del sistema se registrarán automáticamente a medida que ocurran cambios.{% endtrans %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -77,32 +77,61 @@
|
||||
<span class="input-group-text bg-body-tertiary border-end-0 text-muted">
|
||||
<i class="bi bi-lock"></i>
|
||||
</span>
|
||||
<input type="password" class="form-control border-start-0 ps-0" id="password" name="password"
|
||||
<input type="password" class="form-control border-start-0 border-end-0 ps-0" id="password" name="password"
|
||||
required placeholder="{% trans %}Enter your password{% endtrans %}">
|
||||
<button class="btn btn-outline-secondary border-start-0 bg-body-tertiary text-muted" type="button" id="togglePasswordBtn" title="Mostrar/Ocultar contraseña">
|
||||
<i class="bi bi-eye" id="togglePasswordIcon"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% for error in form.password.errors %}
|
||||
<div class="text-danger small mt-1">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-4 form-check">
|
||||
<div class="mb-3 form-check">
|
||||
{{ form.remember_me(class="form-check-input") }}
|
||||
<label class="form-check-label text-muted small" for="remember_me">
|
||||
{{ form.remember_me.label.text }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="d-grid">
|
||||
<div class="d-grid mb-3">
|
||||
<button type="submit" class="btn btn-primary btn-lg fw-semibold py-2">
|
||||
<i class="bi bi-box-arrow-in-right me-1"></i> {{ form.submit.label.text }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Accesos Rápidos Institucionales (Demo) -->
|
||||
<div class="p-3 bg-body-tertiary rounded-3 border mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<small class="fw-bold text-muted text-uppercase" style="font-size: 0.7rem; letter-spacing: 0.5px;">
|
||||
<i class="bi bi-lightning-charge-fill text-warning me-1"></i>{% trans %}Acceso Rápido Demo (1 Clic){% endtrans %}
|
||||
</small>
|
||||
<small class="text-muted font-monospace" style="font-size: 0.68rem;">Pass: admin123</small>
|
||||
</div>
|
||||
<div class="d-grid gap-1">
|
||||
<div class="btn-group btn-group-sm w-100" role="group">
|
||||
<button type="button" class="btn btn-outline-primary demo-fill-btn py-1" data-email="admin@edu-space.com" title="Ingresar como Administrador">
|
||||
<i class="bi bi-shield-check me-1"></i>Admin
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-warning demo-fill-btn py-1" data-email="bedelia@edu-space.com" title="Ingresar como Bedelía">
|
||||
<i class="bi bi-building-gear me-1"></i>Bedelía
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-info demo-fill-btn py-1" data-email="docente@edu-space.com" title="Ingresar como Docente">
|
||||
<i class="bi bi-person-video3 me-1"></i>Docente
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-success demo-fill-btn py-1" data-email="alumno@edu-space.com" title="Ingresar como Alumno">
|
||||
<i class="bi bi-mortarboard me-1"></i>Alumno
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<small class="text-muted d-inline-flex align-items-center">
|
||||
<div class="text-center mt-3">
|
||||
<small class="text-muted d-inline-flex align-items-center" style="font-size: 0.75rem;">
|
||||
<i class="bi bi-shield-check text-primary me-1"></i>
|
||||
{% trans %}Secure Login • All access is logged{% endtrans %}
|
||||
{% trans %}Acceso institucional seguro • Actividad auditada{% endtrans %}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,5 +140,32 @@
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/theme-toggle.js') }}"></script>
|
||||
<script>
|
||||
// Toggle password visibility
|
||||
document.getElementById('togglePasswordBtn').addEventListener('click', function() {
|
||||
const pwdInput = document.getElementById('password');
|
||||
const icon = document.getElementById('togglePasswordIcon');
|
||||
if (pwdInput.type === 'password') {
|
||||
pwdInput.type = 'text';
|
||||
icon.classList.replace('bi-eye', 'bi-eye-slash');
|
||||
} else {
|
||||
pwdInput.type = 'password';
|
||||
icon.classList.replace('bi-eye-slash', 'bi-eye');
|
||||
}
|
||||
});
|
||||
|
||||
// Demo login autofill buttons
|
||||
document.querySelectorAll('.demo-fill-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const email = this.getAttribute('data-email');
|
||||
document.getElementById('email').value = email;
|
||||
document.getElementById('password').value = 'admin123';
|
||||
// Highlight input briefly
|
||||
const emailInput = document.getElementById('email');
|
||||
emailInput.classList.add('is-valid');
|
||||
setTimeout(() => emailInput.classList.remove('is-valid'), 1500);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+176
-87
@@ -46,92 +46,171 @@
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
{% set user_role = current_user.get_institutional_role() %}
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('main.dashboard') }}">
|
||||
<i class="bi bi-speedometer2"></i> {% trans %}Dashboard{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-door-open"></i> {% trans %}Classrooms{% endtrans %}
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms') }}">
|
||||
<i class="bi bi-list"></i> {% trans %}All Classrooms{% endtrans %}
|
||||
</a></li>
|
||||
{% if current_user.has_permission('classrooms', 'read_write') %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.add_classroom') }}">
|
||||
<i class="bi bi-plus-circle"></i> {% trans %}Add Classroom{% endtrans %}
|
||||
</a></li>
|
||||
{% endif %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}">
|
||||
<i class="bi bi-buildings"></i> {% trans %}Buildings{% endtrans %}
|
||||
</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-calendar3"></i> {% trans %}Schedule{% endtrans %}
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.calendar_view') }}">
|
||||
<i class="bi bi-calendar-week"></i> {% trans %}Calendar View{% endtrans %}
|
||||
</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}">
|
||||
<i class="bi bi-list-check"></i> {% trans %}All Reservations{% endtrans %}
|
||||
</a></li>
|
||||
{% if current_user.has_permission('reservations', 'read_write') %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.add_reservation') }}">
|
||||
<i class="bi bi-plus-circle"></i> {% trans %}New Reservation{% endtrans %}
|
||||
</a></li>
|
||||
{% endif %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.today_schedule') }}">
|
||||
<i class="bi bi-calendar-today"></i> {% trans %}Today's Schedule{% endtrans %}
|
||||
</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.commissions_view') }}">
|
||||
<i class="bi bi-diagram-3 me-2 text-primary"></i>{% trans %}Comisiones y Cursadas{% endtrans %}
|
||||
</a></li>
|
||||
{% if current_user.has_permission('optimizer', 'read') or (current_user.role and current_user.role|upper == 'ADMIN') %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('genetic_algorithm_web.genetic_optimizer') }}">
|
||||
<i class="bi bi-cpu text-info me-2"></i>{% trans %}Optimizador de Aulas (IA){% endtrans %}
|
||||
</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
{% if current_user.is_authenticated and current_user.can_manage() %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-shield-lock"></i> {% trans %}Gestión{% endtrans %}
|
||||
</a>
|
||||
<ul class="dropdown-menu shadow-sm" style="min-width: 260px;">
|
||||
<!-- Espacios & Infraestructura -->
|
||||
<li class="dropdown-header text-uppercase small fw-bold text-muted py-1" style="font-size: 0.68rem; letter-spacing: 0.5px;">{% trans %}Espacios e Infraestructura{% endtrans %}</li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}"><i class="bi bi-buildings me-2 text-secondary"></i>{% trans %}Edificios y Sedes{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='physical') }}"><i class="bi bi-door-open me-2 text-primary"></i>{% trans %}Aulas Presenciales{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}"><i class="bi bi-camera-video me-2 text-info"></i>{% trans %}Aulas Virtuales (Reunión){% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.occupancy_metrics') }}"><i class="bi bi-graph-up-arrow me-2 text-success"></i>{% trans %}Gestión de Ocupación, Métricas{% endtrans %}</a></li>
|
||||
|
||||
<li><hr class="dropdown-divider my-1"></li>
|
||||
<!-- Gestión Académica -->
|
||||
<li class="dropdown-header text-uppercase small fw-bold text-muted py-1" style="font-size: 0.68rem; letter-spacing: 0.5px;">{% trans %}Gestión Académica{% endtrans %}</li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.careers_list') }}"><i class="bi bi-mortarboard me-2 text-primary"></i>{% trans %}Carreras{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.subjects_list') }}"><i class="bi bi-book me-2 text-info"></i>{% trans %}Asignaturas{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.commissions_view') }}"><i class="bi bi-diagram-3 me-2 text-primary"></i>{% trans %}Comisiones y Cursadas{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.academic_terms_list') }}"><i class="bi bi-calendar-range me-2 text-warning"></i>{% trans %}Ciclo Lectivo{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}"><i class="bi bi-calendar-check me-2 text-warning"></i>{% trans %}Gestión de Reservas{% endtrans %}</a></li>
|
||||
{% if user_role == 'admin' %}
|
||||
<!-- ================= ADMIN NAVIGATION ================= -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('main.dashboard') }}">
|
||||
<i class="bi bi-speedometer2 me-1"></i>{% trans %}Panel General{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-door-open me-1"></i>{% trans %}Espacios & Sedes{% endtrans %}
|
||||
</a>
|
||||
<ul class="dropdown-menu shadow-sm">
|
||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='physical') }}"><i class="bi bi-door-open me-2 text-primary"></i>{% trans %}Aulas Presenciales{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}"><i class="bi bi-camera-video me-2 text-info"></i>{% trans %}Aulas Virtuales (Reunión){% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms') }}"><i class="bi bi-grid me-2 text-secondary"></i>{% trans %}Todas las Aulas{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.add_classroom') }}"><i class="bi bi-plus-circle me-2 text-success"></i>{% trans %}Nueva Aula{% endtrans %}</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}"><i class="bi bi-buildings me-2 text-secondary"></i>{% trans %}Edificios y Sedes{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.occupancy_metrics') }}"><i class="bi bi-graph-up-arrow me-2 text-success"></i>{% trans %}Métricas de Ocupación{% endtrans %}</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-calendar3 me-1"></i>{% trans %}Cronograma & Reservas{% endtrans %}
|
||||
</a>
|
||||
<ul class="dropdown-menu shadow-sm">
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.calendar_view') }}"><i class="bi bi-calendar-week me-2 text-primary"></i>{% trans %}Calendario General{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.today_schedule') }}"><i class="bi bi-calendar-day me-2 text-info"></i>{% trans %}Cartelera del Día{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}"><i class="bi bi-list-check me-2 text-warning"></i>{% trans %}Gestión de Reservas{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.add_reservation') }}"><i class="bi bi-plus-circle me-2 text-success"></i>{% trans %}Nueva Reserva{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.commissions_view') }}"><i class="bi bi-diagram-3 me-2 text-primary"></i>{% trans %}Comisiones y Cursadas{% endtrans %}</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('genetic_algorithm_web.genetic_optimizer') }}"><i class="bi bi-cpu me-2 text-info"></i>{% trans %}Optimizador de Aulas (IA){% endtrans %}</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-mortarboard me-1"></i>{% trans %}Gestión Académica{% endtrans %}
|
||||
</a>
|
||||
<ul class="dropdown-menu shadow-sm">
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.careers_list') }}"><i class="bi bi-mortarboard me-2 text-primary"></i>{% trans %}Carreras{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.subjects_list') }}"><i class="bi bi-book me-2 text-info"></i>{% trans %}Asignaturas{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.academic_terms_list') }}"><i class="bi bi-calendar-range me-2 text-warning"></i>{% trans %}Ciclo Lectivo{% endtrans %}</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-shield-lock me-1"></i>{% trans %}Administración{% endtrans %}
|
||||
</a>
|
||||
<ul class="dropdown-menu shadow-sm">
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.users_list') }}"><i class="bi bi-people me-2 text-primary"></i>{% trans %}Usuarios{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.roles_list') }}"><i class="bi bi-person-badge me-2 text-info"></i>{% trans %}Roles y Permisos RBAC{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.milestone_types_list') }}"><i class="bi bi-tags me-2 text-secondary"></i>{% trans %}Tipificaciones de Hitos{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.google_sheets_import') }}"><i class="bi bi-file-earmark-spreadsheet me-2 text-success"></i>{% trans %}Sincronización Sheets{% endtrans %}</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.audit_logs') }}"><i class="bi bi-shield-check me-2 text-warning"></i>{% trans %}Registro de Auditoría{% endtrans %}</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li><hr class="dropdown-divider my-1"></li>
|
||||
<!-- Administración & Sistema -->
|
||||
<li class="dropdown-header text-uppercase small fw-bold text-muted py-1" style="font-size: 0.68rem; letter-spacing: 0.5px;">{% trans %}Administración & Seguridad{% endtrans %}</li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.users_list') }}"><i class="bi bi-people me-2"></i>{% trans %}Usuarios{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.roles_list') }}"><i class="bi bi-person-badge me-2"></i>{% trans %}Roles y Permisos{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.milestone_types_list') }}"><i class="bi bi-tags me-2 text-secondary"></i>{% trans %}Tipificaciones de Hitos{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.google_sheets_import') }}"><i class="bi bi-file-earmark-spreadsheet me-2 text-success"></i>{% trans %}Sincronización Sheets{% endtrans %}</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
{% elif user_role == 'bedelia' %}
|
||||
<!-- ================= BEDELÍA NAVIGATION ================= -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('main.dashboard') }}">
|
||||
<i class="bi bi-speedometer2 me-1"></i>{% trans %}Panel Bedelía{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-door-open me-1"></i>{% trans %}Espacios & Aulas{% endtrans %}
|
||||
</a>
|
||||
<ul class="dropdown-menu shadow-sm">
|
||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='physical') }}"><i class="bi bi-door-open me-2 text-primary"></i>{% trans %}Aulas Presenciales{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}"><i class="bi bi-camera-video me-2 text-info"></i>{% trans %}Aulas Virtuales (Reunión){% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms') }}"><i class="bi bi-grid me-2 text-secondary"></i>{% trans %}Todas las Aulas{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.add_classroom') }}"><i class="bi bi-plus-circle me-2 text-success"></i>{% trans %}Nueva Aula{% endtrans %}</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}"><i class="bi bi-buildings me-2 text-secondary"></i>{% trans %}Edificios y Sedes{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.occupancy_metrics') }}"><i class="bi bi-graph-up-arrow me-2 text-success"></i>{% trans %}Métricas de Ocupación{% endtrans %}</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-calendar3 me-1"></i>{% trans %}Operaciones & Reservas{% endtrans %}
|
||||
</a>
|
||||
<ul class="dropdown-menu shadow-sm">
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.today_schedule') }}"><i class="bi bi-calendar-day me-2 text-info"></i>{% trans %}Cartelera del Día{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.calendar_view') }}"><i class="bi bi-calendar-week me-2 text-primary"></i>{% trans %}Calendario General{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}"><i class="bi bi-list-check me-2 text-warning"></i>{% trans %}Gestión de Reservas{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.add_reservation') }}"><i class="bi bi-plus-circle me-2 text-success"></i>{% trans %}Registrar Reserva{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.commissions_view') }}"><i class="bi bi-diagram-3 me-2 text-primary"></i>{% trans %}Comisiones y Cursadas{% endtrans %}</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-mortarboard me-1"></i>{% trans %}Académica & Cursadas{% endtrans %}
|
||||
</a>
|
||||
<ul class="dropdown-menu shadow-sm">
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.careers_list') }}"><i class="bi bi-mortarboard me-2 text-primary"></i>{% trans %}Carreras{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.subjects_list') }}"><i class="bi bi-book me-2 text-info"></i>{% trans %}Asignaturas{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.academic_terms_list') }}"><i class="bi bi-calendar-range me-2 text-warning"></i>{% trans %}Ciclo Lectivo{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.milestone_types_list') }}"><i class="bi bi-tags me-2 text-secondary"></i>{% trans %}Tipificaciones de Hitos{% endtrans %}</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.google_sheets_import') }}"><i class="bi bi-file-earmark-spreadsheet me-2 text-success"></i>{% trans %}Sincronización Sheets{% endtrans %}</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
{% elif user_role == 'docente' %}
|
||||
<!-- ================= DOCENTE / PROFESOR NAVIGATION ================= -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('main.dashboard') }}">
|
||||
<i class="bi bi-speedometer2 me-1"></i>{% trans %}Panel Docente{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-calendar-check me-1"></i>{% trans %}Mis Clases & Reservas{% endtrans %}
|
||||
</a>
|
||||
<ul class="dropdown-menu shadow-sm">
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}"><i class="bi bi-calendar2-check me-2 text-primary"></i>{% trans %}Mis Reservas de Aulas{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.add_reservation') }}"><i class="bi bi-plus-circle me-2 text-success"></i>{% trans %}Solicitar Nueva Reserva{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.commissions_view') }}"><i class="bi bi-diagram-3 me-2 text-info"></i>{% trans %}Mis Comisiones & Cursadas{% endtrans %}</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-building me-1"></i>{% trans %}Campus & Horarios{% endtrans %}
|
||||
</a>
|
||||
<ul class="dropdown-menu shadow-sm">
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.today_schedule') }}"><i class="bi bi-calendar-day me-2 text-info"></i>{% trans %}Cartelera del Día (¿Dónde curso hoy?){% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.calendar_view') }}"><i class="bi bi-calendar-week me-2 text-primary"></i>{% trans %}Calendario General de Aulas{% endtrans %}</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}"><i class="bi bi-camera-video me-2 text-info"></i>{% trans %}Aulas Virtuales (Reuniones){% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='physical') }}"><i class="bi bi-door-open me-2 text-secondary"></i>{% trans %}Consulta de Aulas Presenciales{% endtrans %}</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
{% else %}
|
||||
<!-- ================= ALUMNO / ESTUDIANTE NAVIGATION ================= -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('main.dashboard') }}">
|
||||
<i class="bi bi-house-door me-1"></i>{% trans %}Mi Portal{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('schedule.today_schedule') }}">
|
||||
<i class="bi bi-geo-alt me-1"></i>{% trans %}Cartelera de Hoy{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('schedule.calendar_view') }}">
|
||||
<i class="bi bi-calendar-week me-1"></i>{% trans %}Cronograma de Clases{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}">
|
||||
<i class="bi bi-camera-video me-1"></i>{% trans %}Aulas Virtuales{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('classrooms.list_classrooms', view_mode='physical') }}">
|
||||
<i class="bi bi-door-open me-1"></i>{% trans %}Espacios del Campus{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
@@ -148,17 +227,25 @@
|
||||
{% include 'partials/language_switcher.html' %}
|
||||
</li>
|
||||
|
||||
<!-- User Dropdown with Avatar -->
|
||||
<!-- User Dropdown with Avatar and Institutional Role -->
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle d-flex align-items-center py-1" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<span class="user-avatar-small">{{ current_user.name[:1]|upper if current_user.name else 'U' }}</span>
|
||||
<span>{{ current_user.first_name }}</span>
|
||||
<div class="d-none d-lg-block text-start ms-2">
|
||||
<div class="lh-1 small fw-semibold">{{ current_user.first_name }}</div>
|
||||
<span class="badge {{ current_user.role_badge_display.class }} role-pill mt-1" style="font-size: 0.65rem; padding: 0.15rem 0.45rem;">
|
||||
<i class="bi {{ current_user.role_badge_display.icon }}"></i> {{ current_user.role_badge_display.label }}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow-sm border-0">
|
||||
<li>
|
||||
<div class="px-3 py-2 border-bottom">
|
||||
<small class="text-muted d-block">{% trans %}Signed in as{% endtrans %}</small>
|
||||
<span class="fw-bold small">{{ current_user.email }}</span>
|
||||
<span class="fw-bold small d-block">{{ current_user.email }}</span>
|
||||
<span class="badge {{ current_user.role_badge_display.class }} role-pill mt-1">
|
||||
<i class="bi {{ current_user.role_badge_display.icon }}"></i> {{ current_user.role_badge_display.label }}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}">
|
||||
@@ -208,6 +295,8 @@
|
||||
<script src="{{ url_for('static', filename='js/theme-toggle.js') }}"></script>
|
||||
<!-- Custom JS -->
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
<!-- View Switcher JS -->
|
||||
<script src="{{ url_for('static', filename='js/view_switcher.js') }}"></script>
|
||||
|
||||
<!-- Show flash messages -->
|
||||
<script>
|
||||
|
||||
@@ -253,13 +253,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grilla de Aulas Físicas -->
|
||||
<!-- Barra de Resumen y Selector de Vista -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||
<div class="small text-muted">
|
||||
<i class="bi bi-door-open me-1"></i>
|
||||
<span>{% trans %}Total:{% endtrans %} <strong class="text-body">{{ classrooms.total if classrooms else 0 }}</strong> {% trans %}aulas encontradas{% endtrans %}</span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="small text-muted d-none d-sm-inline">{% trans %}Modo de visualización:{% endtrans %}</span>
|
||||
{% with context_id='classrooms', default_view='cards' %}
|
||||
{% include 'partials/view_switcher.html' %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grilla y Tabla de Aulas Físicas -->
|
||||
{% if classrooms and classrooms.items %}
|
||||
<div class="row g-3 mt-1">
|
||||
{% for classroom in classrooms.items %}
|
||||
<div class="col-xxl-3 col-xl-3 col-lg-4 col-md-6 col-sm-12 mb-2">
|
||||
<div class="card classroom-card border shadow-sm h-100">
|
||||
<div class="card-body p-3 d-flex flex-column justify-content-between">
|
||||
|
||||
<!-- 1. VISTA DE TARJETAS / MOSAICO -->
|
||||
<div data-view-context="classrooms" data-view-container="cards">
|
||||
<div class="row g-3 mt-1">
|
||||
{% for classroom in classrooms.items %}
|
||||
<div class="col-xxl-3 col-xl-3 col-lg-4 col-md-6 col-sm-12 mb-2">
|
||||
<div class="card classroom-card border shadow-sm h-100">
|
||||
<div class="card-body p-3 d-flex flex-column justify-content-between">
|
||||
<div>
|
||||
<!-- Encabezado con Código y Estado -->
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
@@ -364,6 +381,98 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. VISTA DE TABLA / GRILLA (AULAS FÍSICAS) -->
|
||||
<div data-view-context="classrooms" data-view-container="table" class="d-none">
|
||||
<div class="card border-0 shadow-sm mt-1">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light small text-uppercase">
|
||||
<tr>
|
||||
<th class="ps-3">{% trans %}Aula / Código{% endtrans %}</th>
|
||||
<th>{% trans %}Edificio / Sede{% endtrans %}</th>
|
||||
<th>{% trans %}Piso{% endtrans %}</th>
|
||||
<th>{% trans %}Capacidad{% endtrans %}</th>
|
||||
<th>{% trans %}Equipamiento{% endtrans %}</th>
|
||||
<th>{% trans %}Ocupación (D / S / M){% endtrans %}</th>
|
||||
<th>{% trans %}Estado{% endtrans %}</th>
|
||||
<th class="text-end pe-3">{% trans %}Acciones{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for classroom in classrooms.items %}
|
||||
{% set metrics = occupancy_metrics.get(classroom.id, {'day_pct': 0, 'day_count': 0, 'week_pct': 0, 'week_count': 0, 'month_pct': 0, 'month_count': 0}) %}
|
||||
<tr>
|
||||
<td class="ps-3">
|
||||
<a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}" class="fw-bold text-primary text-decoration-none">
|
||||
<i class="bi bi-door-open me-1"></i>{{ classroom.code }}
|
||||
</a>
|
||||
{% if classroom.description %}
|
||||
<small class="text-muted d-block text-truncate" style="max-width: 200px;">{{ classroom.description }}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-primary-subtle text-primary border">
|
||||
<i class="bi bi-buildings me-1"></i>{{ classroom.building_name }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-secondary-subtle text-secondary border">
|
||||
{{ classroom.floor_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<strong class="text-body">{{ classroom.capacity }}</strong> <span class="small text-muted">{% trans %}asientos{% endtrans %}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-body-tertiary text-body border">
|
||||
<i class="bi bi-tools me-1"></i>{{ classroom.resources|length if classroom.resources else 0 }} {% trans %}recursos{% endtrans %}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<span class="badge border {% if metrics.day_pct >= 80 %}metric-high{% elif metrics.day_pct >= 40 %}metric-medium{% else %}metric-low{% endif %}" title="{% trans %}Ocupación Día{% endtrans %}">
|
||||
D: {{ metrics.day_pct }}%
|
||||
</span>
|
||||
<span class="badge border {% if metrics.week_pct >= 80 %}metric-high{% elif metrics.week_pct >= 40 %}metric-medium{% else %}metric-low{% endif %}" title="{% trans %}Ocupación Semana{% endtrans %}">
|
||||
S: {{ metrics.week_pct }}%
|
||||
</span>
|
||||
<span class="badge border {% if metrics.month_pct >= 80 %}metric-high{% elif metrics.month_pct >= 40 %}metric-medium{% else %}metric-low{% endif %}" title="{% trans %}Ocupación Mes{% endtrans %}">
|
||||
M: {{ metrics.month_pct }}%
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge rounded-pill {% if classroom.is_active %}bg-success-subtle text-success border border-success-subtle{% else %}bg-secondary-subtle text-secondary border{% endif %}">
|
||||
{% if classroom.is_active %}{% trans %}Activa{% endtrans %}{% else %}{% trans %}Inactiva{% endtrans %}{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-end pe-3">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}" class="btn btn-outline-primary" title="{% trans %}Ver Detalles{% endtrans %}">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
{% if current_user.has_permission('classrooms', 'read_write') %}
|
||||
<a href="{{ url_for('classrooms.edit_classroom', id=classroom.id) }}" class="btn btn-outline-secondary" title="{% trans %}Editar{% endtrans %}">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<form method="POST" action="{{ url_for('classrooms.toggle_classroom', id=classroom.id) }}" class="d-inline mb-0">
|
||||
<button type="submit" class="btn btn-outline-{% if classroom.is_active %}warning{% else %}success{% endif %}"
|
||||
title="{% if classroom.is_active %}{% trans %}Desactivar{% endtrans %}{% else %}{% trans %}Activar{% endtrans %}{% endif %}">
|
||||
<i class="bi bi-{% if classroom.is_active %}pause{% else %}play{% endif %}"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Paginación Aulas Físicas -->
|
||||
|
||||
+1055
-244
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
{# Componente reutilizable: Selector de vista (Tabla / Grilla vs. Tarjetas / Mosaico) #}
|
||||
<div class="btn-group btn-group-sm shadow-sm view-switcher-group"
|
||||
role="group"
|
||||
aria-label="{% trans %}Selector de Vista{% endtrans %}"
|
||||
data-context="{{ context_id|default('default') }}"
|
||||
data-default-view="{{ default_view|default('cards') }}">
|
||||
<button type="button"
|
||||
class="btn btn-outline-secondary view-switcher-btn"
|
||||
data-view="table"
|
||||
title="{% trans %}Vista de Tabla / Grilla (filas, columnas y análisis denso){% endtrans %}">
|
||||
<i class="bi bi-table me-1"></i>
|
||||
<span class="d-none d-sm-inline">{% trans %}Tabla{% endtrans %}</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn btn-outline-secondary view-switcher-btn"
|
||||
data-view="cards"
|
||||
title="{% trans %}Vista de Tarjetas / Mosaico (amigable, visual y responsive){% endtrans %}">
|
||||
<i class="bi bi-grid-fill me-1"></i>
|
||||
<span class="d-none d-sm-inline">{% trans %}Tarjetas{% endtrans %}</span>
|
||||
</button>
|
||||
</div>
|
||||
+251
-135
@@ -131,145 +131,261 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reservations Table -->
|
||||
<!-- Header de Resumen y Selector de Vista -->
|
||||
{% if reservations and reservations.items %}
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0 h6 fw-bold">
|
||||
<i class="bi bi-table text-primary me-2"></i>{% trans %}Registros Encontrados{% endtrans %}
|
||||
<span class="badge bg-secondary-subtle text-secondary border ms-2">{{ reservations.total }} {% trans %}reservas{% endtrans %}</span>
|
||||
</h5>
|
||||
<small class="text-muted">{% trans %}Página{% endtrans %} {{ reservations.page }} {% trans %}de{% endtrans %} {{ reservations.pages }}</small>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||
<div class="small text-muted">
|
||||
<i class="bi bi-calendar-check text-primary me-1"></i>
|
||||
<span>{% trans %}Total:{% endtrans %} <strong class="text-body">{{ reservations.total }}</strong> {% trans %}reservas encontradas{% endtrans %}</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light small text-uppercase">
|
||||
<tr>
|
||||
<th class="ps-3">{% trans %}Fecha y Horario{% endtrans %}</th>
|
||||
<th>{% trans %}Aula / Piso{% endtrans %}</th>
|
||||
<th>{% trans %}Asignatura / Código{% endtrans %}</th>
|
||||
<th>{% trans %}Turno / Comisión{% endtrans %}</th>
|
||||
<th>{% trans %}Capacidad{% endtrans %}</th>
|
||||
<th>{% trans %}Estado{% endtrans %}</th>
|
||||
<th class="text-end pe-3">{% trans %}Acciones{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for reservation in reservations.items %}
|
||||
<tr>
|
||||
<td class="ps-3">
|
||||
<div class="fw-bold">{{ reservation.start_time.strftime('%d/%m/%Y') }}</div>
|
||||
<small class="text-muted font-monospace">{{ reservation.start_time.strftime('%H:%M') }} - {{ reservation.end_time.strftime('%H:%M') }} hs</small>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-secondary-subtle text-secondary border font-monospace">
|
||||
{{ reservation.classroom.code if reservation.classroom else 'Sin Aula' }}
|
||||
</span>
|
||||
<small class="text-muted d-block">
|
||||
{% if reservation.classroom and reservation.classroom.floor == 0 %}PB{% elif reservation.classroom %}Piso {{ reservation.classroom.floor }}{% endif %}
|
||||
{% if reservation.classroom and reservation.classroom.building %} · {{ reservation.classroom.building }}{% endif %}
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-bold text-primary">{{ reservation.subject_name }}</div>
|
||||
<small class="badge bg-secondary-subtle text-secondary font-monospace">{{ reservation.subject_code }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-info-subtle text-info border me-1">
|
||||
{{ reservation.shift or 'Tarde' }}
|
||||
</span>
|
||||
<small class="text-muted">{{ reservation.commission.code if reservation.commission else 'C1' }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<span class="small">{{ reservation.expected_attendees }} / {{ reservation.classroom.capacity if reservation.classroom else '-' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if reservation.status == 'CONFIRMED' or reservation.status.value == 'CONFIRMED' %}
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle">
|
||||
<i class="bi bi-check-circle me-1"></i>{% trans %}Confirmada{% endtrans %}
|
||||
</span>
|
||||
{% elif reservation.status == 'PENDING' or reservation.status.value == 'PENDING' %}
|
||||
<span class="badge bg-warning-subtle text-warning border border-warning-subtle">
|
||||
<i class="bi bi-clock me-1"></i>{% trans %}Pendiente{% endtrans %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger-subtle text-danger border border-danger-subtle">
|
||||
{% trans %}Cancelada{% endtrans %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end pe-3">
|
||||
{% if reservation.effective_virtual_link %}
|
||||
<a href="{{ reservation.effective_virtual_link }}" target="_blank"
|
||||
class="btn btn-sm btn-outline-success py-0 px-2 me-1" title="{% trans %}Unirse a Clase Virtual (Zoom/Meet){% endtrans %}">
|
||||
<i class="bi bi-camera-video"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if current_user.can_edit_reservation(reservation) %}
|
||||
<a href="{{ url_for('schedule.edit_reservation', id=reservation.id) }}"
|
||||
class="btn btn-sm btn-outline-warning py-0 px-2 me-1" title="{% trans %}Modificar Reserva{% endtrans %}">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
{% if current_user.has_permission('reservations', 'read_write') and (reservation.status == 'PENDING' or reservation.status.value == 'PENDING') %}
|
||||
<a href="{{ url_for('schedule.confirm_reservation', id=reservation.id) }}"
|
||||
class="btn btn-sm btn-outline-success py-0 px-2 ms-1" title="{% trans %}Confirmar{% endtrans %}">
|
||||
<i class="bi bi-check-lg"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if reservations.pages > 1 %}
|
||||
<div class="card-footer bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted">{% trans %}Mostrando 20 registros por página{% endtrans %}</small>
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
{% if reservations.has_prev %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=reservations.prev_num, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, classroom_id=classroom_id or '', hide_virtual='1' if hide_virtual else '', search=search) }}">
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for p in reservations.iter_pages(left_edge=2, left_current=2, right_current=2, right_edge=2) %}
|
||||
{% if p %}
|
||||
{% if p == reservations.page %}
|
||||
<li class="page-item active"><span class="page-link">{{ p }}</span></li>
|
||||
{% else %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=p, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, classroom_id=classroom_id or '', hide_virtual='1' if hide_virtual else '', search=search) }}">{{ p }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link">…</span></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if reservations.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=reservations.next_num, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, classroom_id=classroom_id or '', hide_virtual='1' if hide_virtual else '', search=search) }}">
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="small text-muted d-none d-sm-inline">{% trans %}Modo de visualización:{% endtrans %}</span>
|
||||
{% with context_id='reservations', default_view='table' %}
|
||||
{% include 'partials/view_switcher.html' %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 1. VISTA DE TABLA / GRILLA -->
|
||||
<div data-view-context="reservations" data-view-container="table">
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0 h6 fw-bold">
|
||||
<i class="bi bi-table text-primary me-2"></i>{% trans %}Registros en Formato Tabla{% endtrans %}
|
||||
</h5>
|
||||
<small class="text-muted">{% trans %}Página{% endtrans %} {{ reservations.page }} {% trans %}de{% endtrans %} {{ reservations.pages }}</small>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light small text-uppercase">
|
||||
<tr>
|
||||
<th class="ps-3">{% trans %}Fecha y Horario{% endtrans %}</th>
|
||||
<th>{% trans %}Aula / Piso{% endtrans %}</th>
|
||||
<th>{% trans %}Asignatura / Código{% endtrans %}</th>
|
||||
<th>{% trans %}Turno / Comisión{% endtrans %}</th>
|
||||
<th>{% trans %}Capacidad{% endtrans %}</th>
|
||||
<th>{% trans %}Estado{% endtrans %}</th>
|
||||
<th class="text-end pe-3">{% trans %}Acciones{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for reservation in reservations.items %}
|
||||
<tr>
|
||||
<td class="ps-3">
|
||||
<div class="fw-bold">{{ reservation.start_time.strftime('%d/%m/%Y') }}</div>
|
||||
<small class="text-muted font-monospace">{{ reservation.start_time.strftime('%H:%M') }} - {{ reservation.end_time.strftime('%H:%M') }} hs</small>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-secondary-subtle text-secondary border font-monospace">
|
||||
{{ reservation.classroom.code if reservation.classroom else 'Sin Aula' }}
|
||||
</span>
|
||||
<small class="text-muted d-block">
|
||||
{% if reservation.classroom and reservation.classroom.floor == 0 %}PB{% elif reservation.classroom %}Piso {{ reservation.classroom.floor }}{% endif %}
|
||||
{% if reservation.classroom and reservation.classroom.building %} · {{ reservation.classroom.building }}{% endif %}
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-bold text-primary">{{ reservation.subject_name }}</div>
|
||||
<small class="badge bg-secondary-subtle text-secondary font-monospace">{{ reservation.subject_code }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-info-subtle text-info border me-1">
|
||||
{{ reservation.shift or 'Tarde' }}
|
||||
</span>
|
||||
<small class="text-muted">{{ reservation.commission.code if reservation.commission else 'C1' }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<span class="small">{{ reservation.expected_attendees }} / {{ reservation.classroom.capacity if reservation.classroom else '-' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if reservation.status == 'CONFIRMED' or reservation.status.value == 'CONFIRMED' %}
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle">
|
||||
<i class="bi bi-check-circle me-1"></i>{% trans %}Confirmada{% endtrans %}
|
||||
</span>
|
||||
{% elif reservation.status == 'PENDING' or reservation.status.value == 'PENDING' %}
|
||||
<span class="badge bg-warning-subtle text-warning border border-warning-subtle">
|
||||
<i class="bi bi-clock me-1"></i>{% trans %}Pendiente{% endtrans %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger-subtle text-danger border border-danger-subtle">
|
||||
{% trans %}Cancelada{% endtrans %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end pe-3">
|
||||
{% if reservation.effective_virtual_link %}
|
||||
<a href="{{ reservation.effective_virtual_link }}" target="_blank"
|
||||
class="btn btn-sm btn-outline-success py-0 px-2 me-1" title="{% trans %}Unirse a Clase Virtual (Zoom/Meet){% endtrans %}">
|
||||
<i class="bi bi-camera-video"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if current_user.can_edit_reservation(reservation) %}
|
||||
<a href="{{ url_for('schedule.edit_reservation', id=reservation.id) }}"
|
||||
class="btn btn-sm btn-outline-warning py-0 px-2 me-1" title="{% trans %}Modificar Reserva{% endtrans %}">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
{% if current_user.has_permission('reservations', 'read_write') and (reservation.status == 'PENDING' or reservation.status.value == 'PENDING') %}
|
||||
<a href="{{ url_for('schedule.confirm_reservation', id=reservation.id) }}"
|
||||
class="btn btn-sm btn-outline-success py-0 px-2 ms-1" title="{% trans %}Confirmar{% endtrans %}">
|
||||
<i class="bi bi-check-lg"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. VISTA DE TARJETAS / MOSAICO -->
|
||||
<div data-view-context="reservations" data-view-container="cards" class="d-none">
|
||||
<div class="row g-3 mb-3">
|
||||
{% for reservation in reservations.items %}
|
||||
<div class="col-xxl-3 col-xl-4 col-lg-6 col-md-6 col-sm-12">
|
||||
<div class="card border shadow-sm h-100 reservation-card">
|
||||
<div class="card-body p-3 d-flex flex-column justify-content-between">
|
||||
<div>
|
||||
<!-- Header de tarjeta -->
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<div>
|
||||
<span class="badge bg-primary text-white font-monospace mb-1">
|
||||
<i class="bi bi-calendar-event me-1"></i>{{ reservation.start_time.strftime('%d/%m/%Y') }}
|
||||
</span>
|
||||
<div class="small fw-bold text-muted font-monospace">
|
||||
<i class="bi bi-clock me-1"></i>{{ reservation.start_time.strftime('%H:%M') }} - {{ reservation.end_time.strftime('%H:%M') }} hs
|
||||
</div>
|
||||
</div>
|
||||
{% if reservation.status == 'CONFIRMED' or reservation.status.value == 'CONFIRMED' %}
|
||||
<span class="badge rounded-pill bg-success-subtle text-success border border-success-subtle">{% trans %}Confirmada{% endtrans %}</span>
|
||||
{% elif reservation.status == 'PENDING' or reservation.status.value == 'PENDING' %}
|
||||
<span class="badge rounded-pill bg-warning-subtle text-warning border border-warning-subtle">{% trans %}Pendiente{% endtrans %}</span>
|
||||
{% else %}
|
||||
<span class="badge rounded-pill bg-danger-subtle text-danger border border-danger-subtle">{% trans %}Cancelada{% endtrans %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Asignatura y código -->
|
||||
<h6 class="card-title fw-bold text-primary mb-1 text-truncate" title="{{ reservation.subject_name }}">
|
||||
{{ reservation.subject_name }}
|
||||
</h6>
|
||||
<div class="d-flex flex-wrap gap-1 mb-2">
|
||||
<span class="badge bg-secondary-subtle text-secondary font-monospace" style="font-size: 0.7rem;">
|
||||
{{ reservation.subject_code }}
|
||||
</span>
|
||||
<span class="badge bg-info-subtle text-info border" style="font-size: 0.7rem;">
|
||||
{{ reservation.shift or 'Tarde' }}
|
||||
</span>
|
||||
{% if reservation.commission %}
|
||||
<span class="badge bg-body-tertiary text-body border" style="font-size: 0.7rem;">
|
||||
{{ reservation.commission.name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Ubicación / Aula -->
|
||||
<div class="p-2 rounded bg-body-tertiary border mb-2 small">
|
||||
<div class="d-flex align-items-center justify-content-between mb-1">
|
||||
<span class="text-muted"><i class="bi bi-door-open text-primary me-1"></i>{% trans %}Espacio:{% endtrans %}</span>
|
||||
{% if reservation.classroom %}
|
||||
<span class="fw-bold font-monospace text-body">{{ reservation.classroom.code }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">{% trans %}Sin asignar{% endtrans %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="d-flex align-items-center justify-content-between small text-muted">
|
||||
<span>{% trans %}Sede:{% endtrans %} {{ reservation.classroom.building_name if reservation.classroom else '-' }}</span>
|
||||
<span>{{ reservation.classroom.floor_display if reservation.classroom else '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Docente y Asistentes -->
|
||||
<div class="small text-muted d-flex justify-content-between align-items-center mb-2">
|
||||
<span class="text-truncate me-1" title="{{ reservation.user.name if reservation.user else 'No asignado' }}">
|
||||
<i class="bi bi-person me-1"></i>{{ reservation.user.name if reservation.user else 'Sin docente' }}
|
||||
</span>
|
||||
<span>
|
||||
<i class="bi bi-people me-1"></i>{{ reservation.expected_attendees }} {% trans %}alumnos{% endtrans %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Acciones -->
|
||||
<div class="d-flex justify-content-end gap-1 pt-2 border-top">
|
||||
{% if reservation.effective_virtual_link %}
|
||||
<a href="{{ reservation.effective_virtual_link }}" target="_blank"
|
||||
class="btn btn-sm btn-outline-success py-1 px-2" title="{% trans %}Clase Virtual{% endtrans %}">
|
||||
<i class="bi bi-camera-video"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}" class="btn btn-sm btn-outline-secondary py-1 px-2" title="{% trans %}Ver Ficha{% endtrans %}">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
{% if current_user.can_edit_reservation(reservation) %}
|
||||
<a href="{{ url_for('schedule.edit_reservation', id=reservation.id) }}" class="btn btn-sm btn-outline-warning py-1 px-2" title="{% trans %}Modificar{% endtrans %}">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if current_user.has_permission('reservations', 'read_write') and (reservation.status == 'PENDING' or reservation.status.value == 'PENDING') %}
|
||||
<a href="{{ url_for('schedule.confirm_reservation', id=reservation.id) }}" class="btn btn-sm btn-outline-success py-1 px-2" title="{% trans %}Confirmar{% endtrans %}">
|
||||
<i class="bi bi-check-lg"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Paginación Compartida (visible en ambas vistas) -->
|
||||
{% if reservations.pages > 1 %}
|
||||
<div class="card border-0 shadow-sm py-2 px-3 d-flex flex-row justify-content-between align-items-center mb-3">
|
||||
<small class="text-muted">{% trans %}Página{% endtrans %} {{ reservations.page }} {% trans %}de{% endtrans %} {{ reservations.pages }} ({{ reservations.total }} {% trans %}reservas{% endtrans %})</small>
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
{% if reservations.has_prev %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=reservations.prev_num, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, classroom_id=classroom_id or '', hide_virtual='1' if hide_virtual else '', search=search) }}">
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for p in reservations.iter_pages(left_edge=2, left_current=2, right_current=2, right_edge=2) %}
|
||||
{% if p %}
|
||||
{% if p == reservations.page %}
|
||||
<li class="page-item active"><span class="page-link">{{ p }}</span></li>
|
||||
{% else %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=p, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, classroom_id=classroom_id or '', hide_virtual='1' if hide_virtual else '', search=search) }}">{{ p }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link">…</span></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if reservations.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=reservations.next_num, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, classroom_id=classroom_id or '', hide_virtual='1' if hide_virtual else '', search=search) }}">
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body text-center py-5">
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
from functools import wraps
|
||||
from flask import request, jsonify, g
|
||||
import jwt
|
||||
from app.services.jwt_service import JWTService
|
||||
from app.models.user import User
|
||||
|
||||
def jwt_required(f):
|
||||
"""
|
||||
Decorador para proteger endpoints REST mediante Bearer Token JWT.
|
||||
Extrae y valida el token, inyectando el usuario en 'g.jwt_user' y el payload en 'g.jwt_payload'.
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
token = None
|
||||
|
||||
if auth_header.startswith('Bearer '):
|
||||
token = auth_header.split(' ', 1)[1].strip()
|
||||
elif 'access_token' in request.cookies:
|
||||
token = request.cookies.get('access_token')
|
||||
|
||||
if not token:
|
||||
return jsonify({
|
||||
'error': 'Unauthorized',
|
||||
'message': 'Encabezado Authorization con Bearer token no proporcionado.'
|
||||
}), 401
|
||||
|
||||
try:
|
||||
payload = JWTService.decode_token(token, expected_type='access')
|
||||
user_id = int(payload.get('sub'))
|
||||
from app import db
|
||||
user = db.session.get(User, user_id)
|
||||
|
||||
if not user or not user.is_active:
|
||||
return jsonify({
|
||||
'error': 'Unauthorized',
|
||||
'message': 'Usuario no encontrado o cuenta desactivada.'
|
||||
}), 401
|
||||
|
||||
g.jwt_user = user
|
||||
g.jwt_payload = payload
|
||||
g.jwt_token = token
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({
|
||||
'error': 'TokenExpired',
|
||||
'message': 'El token de acceso ha expirado. Por favor renueve su sesión.'
|
||||
}), 401
|
||||
except jwt.InvalidTokenError as e:
|
||||
return jsonify({
|
||||
'error': 'InvalidToken',
|
||||
'message': str(e)
|
||||
}), 401
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'error': 'AuthenticationError',
|
||||
'message': f'Fallo en la verificación del token: {str(e)}'
|
||||
}), 401
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
def jwt_role_required(*allowed_roles):
|
||||
"""
|
||||
Decorador que verifica que el usuario autenticado vía JWT posea alguno de los roles permitidos.
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
@jwt_required
|
||||
def decorated_function(*args, **kwargs):
|
||||
user: User = g.jwt_user
|
||||
if user.is_admin():
|
||||
return f(*args, **kwargs)
|
||||
|
||||
user_role = (user.role_obj.name if user.role_obj else user.role).upper()
|
||||
allowed = [r.upper() for r in allowed_roles]
|
||||
|
||||
if user_role not in allowed:
|
||||
return jsonify({
|
||||
'error': 'Forbidden',
|
||||
'message': f'Acceso denegado. Se requiere uno de los siguientes roles: {", ".join(allowed_roles)}'
|
||||
}), 403
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
return decorator
|
||||
+80
-31
@@ -49,6 +49,22 @@ def init_database():
|
||||
updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
"""))
|
||||
db.session.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
user_email VARCHAR(255),
|
||||
action VARCHAR(50) NOT NULL,
|
||||
module VARCHAR(50) NOT NULL,
|
||||
entity_id INTEGER,
|
||||
details TEXT,
|
||||
ip_address VARCHAR(45),
|
||||
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_audit_logs_action ON audit_logs (action);
|
||||
CREATE INDEX IF NOT EXISTS ix_audit_logs_module ON audit_logs (module);
|
||||
CREATE INDEX IF NOT EXISTS ix_audit_logs_created_at ON audit_logs (created_at);
|
||||
"""))
|
||||
db.session.execute(text("""
|
||||
ALTER TABLE classrooms ADD COLUMN IF NOT EXISTS building_id INTEGER REFERENCES buildings(id) ON DELETE SET NULL;
|
||||
"""))
|
||||
@@ -159,37 +175,70 @@ def init_database():
|
||||
db.session.commit()
|
||||
print("[OK] Roles y matriz de permisos configurados correctamente.")
|
||||
|
||||
# 4. Verificar o crear usuario administrador inicial
|
||||
print("[*] Verificando usuario administrador inicial...")
|
||||
admin = User.query.filter_by(email="admin@edu-space.com").first()
|
||||
if not admin:
|
||||
admin = User(
|
||||
email="admin@edu-space.com",
|
||||
name="System Administrator",
|
||||
role="ADMIN",
|
||||
role_id=admin_role.id if admin_role else None,
|
||||
is_active=True,
|
||||
preferred_language="es",
|
||||
theme_preference="auto"
|
||||
)
|
||||
admin.set_password("admin123")
|
||||
db.session.add(admin)
|
||||
db.session.commit()
|
||||
print("[OK] Usuario inicial creado exitosamente:")
|
||||
print(" Email: admin@edu-space.com")
|
||||
print(" Password: admin123")
|
||||
print(f" Rol: {admin_role.name if admin_role else 'ADMIN'}")
|
||||
else:
|
||||
# Sincronizar rol y atributos del admin existente si faltan
|
||||
if admin_role and admin.role_id != admin_role.id:
|
||||
admin.role_id = admin_role.id
|
||||
admin.role = "ADMIN"
|
||||
if not admin.preferred_language:
|
||||
admin.preferred_language = "es"
|
||||
if not admin.theme_preference:
|
||||
admin.theme_preference = "auto"
|
||||
db.session.commit()
|
||||
print(f"[INFO] El usuario admin (admin@edu-space.com) esta activo y vinculado al rol '{admin_role.name if admin_role else 'ADMIN'}'.")
|
||||
# 4. Verificar o crear usuarios institucionales de cada rol
|
||||
print("[*] Verificando usuarios institucionales y credenciales...")
|
||||
roles_by_name = {r.name: r for r in Role.query.all()}
|
||||
|
||||
institutional_users = [
|
||||
{
|
||||
'email': 'admin@edu-space.com',
|
||||
'name': 'Administrador del Sistema',
|
||||
'role_name': 'Admin',
|
||||
'role_code': 'ADMIN'
|
||||
},
|
||||
{
|
||||
'email': 'bedelia@edu-space.com',
|
||||
'name': 'Operador de Bedelía',
|
||||
'role_name': 'Bedelia',
|
||||
'role_code': 'BEDELIA'
|
||||
},
|
||||
{
|
||||
'email': 'docente@edu-space.com',
|
||||
'name': 'Profesor Titular',
|
||||
'role_name': 'Docente',
|
||||
'role_code': 'DOCENTE'
|
||||
},
|
||||
{
|
||||
'email': 'alumno@edu-space.com',
|
||||
'name': 'Estudiante Regular',
|
||||
'role_name': 'Alumno',
|
||||
'role_code': 'ALUMNO'
|
||||
}
|
||||
]
|
||||
|
||||
for u_info in institutional_users:
|
||||
u_obj = User.query.filter_by(email=u_info['email']).first()
|
||||
r_match = roles_by_name.get(u_info['role_name'])
|
||||
|
||||
if not u_obj:
|
||||
u_obj = User(
|
||||
email=u_info['email'],
|
||||
name=u_info['name'],
|
||||
role=u_info['role_code'],
|
||||
role_id=r_match.id if r_match else None,
|
||||
is_active=True,
|
||||
preferred_language="es",
|
||||
theme_preference="auto"
|
||||
)
|
||||
u_obj.set_password("admin123")
|
||||
db.session.add(u_obj)
|
||||
db.session.commit()
|
||||
print(f" [+] Usuario creado: {u_info['email']} | Clave: admin123 | Rol: {u_info['role_name']}")
|
||||
else:
|
||||
# Garantizar sincronización de rol, estado activo y clave
|
||||
if r_match and u_obj.role_id != r_match.id:
|
||||
u_obj.role_id = r_match.id
|
||||
u_obj.role = u_info['role_code']
|
||||
u_obj.is_active = True
|
||||
if not u_obj.check_password("admin123"):
|
||||
u_obj.set_password("admin123")
|
||||
print(f" [*] Clave de {u_info['email']} restablecida a 'admin123'.")
|
||||
if not u_obj.preferred_language:
|
||||
u_obj.preferred_language = "es"
|
||||
if not u_obj.theme_preference:
|
||||
u_obj.theme_preference = "auto"
|
||||
db.session.commit()
|
||||
print(f" [OK] Usuario {u_info['email']} validado (Rol: {u_info['role_name']}, Clave: admin123).")
|
||||
|
||||
# 5. Inicializar edificios institucionales oficiales
|
||||
print("[*] Verificando edificios institucionales...")
|
||||
|
||||
@@ -17,3 +17,5 @@ email_validator>=2.0.0
|
||||
psycopg2-binary>=2.9.10
|
||||
Babel==2.14.0
|
||||
gunicorn>=21.2.0
|
||||
PyJWT>=2.8.0
|
||||
pydantic>=2.0.0
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import unittest
|
||||
import json
|
||||
from app import create_app, db
|
||||
from app.models.user import User
|
||||
from app.services.jwt_service import JWTService
|
||||
|
||||
class TestJWTAndAPI(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.app = create_app()
|
||||
self.app.config['TESTING'] = True
|
||||
self.client = self.app.test_client()
|
||||
self.app_context = self.app.app_context()
|
||||
self.app_context.push()
|
||||
|
||||
# Buscar usuario activo o crear de prueba
|
||||
self.test_user = User.query.filter_by(is_active=True).first()
|
||||
if not self.test_user:
|
||||
self.test_user = User(
|
||||
email="test_api_admin@unicaba.edu.ar",
|
||||
name="Test API Admin",
|
||||
role="ADMIN",
|
||||
is_active=True
|
||||
)
|
||||
self.test_user.set_password("Test1234!")
|
||||
db.session.add(self.test_user)
|
||||
db.session.commit()
|
||||
|
||||
def tearDown(self):
|
||||
db.session.rollback()
|
||||
self.app_context.pop()
|
||||
|
||||
def test_jwt_generation_and_decoding(self):
|
||||
tokens = JWTService.generate_tokens(self.test_user)
|
||||
self.assertIn('access_token', tokens)
|
||||
self.assertIn('refresh_token', tokens)
|
||||
|
||||
payload = JWTService.decode_token(tokens['access_token'], expected_type='access')
|
||||
self.assertEqual(payload['sub'], str(self.test_user.id))
|
||||
self.assertEqual(payload['email'], self.test_user.email)
|
||||
|
||||
def test_jwt_revocation(self):
|
||||
tokens = JWTService.generate_tokens(self.test_user)
|
||||
token = tokens['access_token']
|
||||
# Antes de revocar: válido
|
||||
payload = JWTService.decode_token(token, expected_type='access')
|
||||
self.assertIsNotNone(payload)
|
||||
|
||||
# Revocar
|
||||
revoked = JWTService.revoke_token(token)
|
||||
self.assertTrue(revoked)
|
||||
|
||||
# Después de revocar: debe lanzar InvalidTokenError
|
||||
with self.assertRaises(Exception):
|
||||
JWTService.decode_token(token, expected_type='access')
|
||||
|
||||
def test_protected_endpoints_require_token(self):
|
||||
# Sin token debe dar 401
|
||||
res = self.client.get('/api/v1/auth/me')
|
||||
self.assertEqual(res.status_code, 401)
|
||||
|
||||
res_classrooms = self.client.get('/api/v1/classrooms')
|
||||
self.assertEqual(res_classrooms.status_code, 401)
|
||||
|
||||
def test_authorized_endpoint_access(self):
|
||||
tokens = JWTService.generate_tokens(self.test_user)
|
||||
headers = {'Authorization': f"Bearer {tokens['access_token']}"}
|
||||
|
||||
res = self.client.get('/api/v1/auth/me', headers=headers)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
data = res.get_json()
|
||||
self.assertEqual(data['email'], self.test_user.email)
|
||||
|
||||
res_classrooms = self.client.get('/api/v1/classrooms', headers=headers)
|
||||
self.assertEqual(res_classrooms.status_code, 200)
|
||||
|
||||
def test_token_refresh_flow(self):
|
||||
tokens = JWTService.generate_tokens(self.test_user)
|
||||
res = self.client.post('/api/v1/auth/refresh', json={'refresh_token': tokens['refresh_token']})
|
||||
self.assertEqual(res.status_code, 200)
|
||||
data = res.get_json()
|
||||
self.assertIn('access_token', data)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,103 @@
|
||||
import unittest
|
||||
from app import create_app, db
|
||||
from app.models.user import User
|
||||
|
||||
class TestRoleDashboardAndMenu(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.app = create_app()
|
||||
self.app.config['TESTING'] = True
|
||||
self.app.config['WTF_CSRF_ENABLED'] = False
|
||||
self.client = self.app.test_client()
|
||||
self.app_context = self.app.app_context()
|
||||
self.app_context.push()
|
||||
|
||||
# Usar usuario administrador para autenticación en sesión de pruebas
|
||||
self.user = User.query.filter_by(is_active=True).first()
|
||||
if self.user:
|
||||
with self.client.session_transaction() as sess:
|
||||
sess['_user_id'] = str(self.user.id)
|
||||
sess['_fresh'] = True
|
||||
|
||||
def tearDown(self):
|
||||
self.app_context.pop()
|
||||
|
||||
def test_user_role_methods(self):
|
||||
"""Verifica la normalización y detección de roles en el modelo User"""
|
||||
u = User(name="Test Teacher", email="prof@test.edu", role="Docente")
|
||||
self.assertEqual(u.get_institutional_role(), "docente")
|
||||
self.assertTrue(u.is_docente())
|
||||
self.assertFalse(u.is_admin())
|
||||
self.assertFalse(u.is_alumno())
|
||||
self.assertFalse(u.is_bedelia())
|
||||
|
||||
u_bedelia = User(name="Test Bedelia", email="bedelia@test.edu", role="Bedelia")
|
||||
self.assertEqual(u_bedelia.get_institutional_role(), "bedelia")
|
||||
self.assertTrue(u_bedelia.is_bedelia())
|
||||
|
||||
u_student = User(name="Test Student", email="student@test.edu", role="Alumno")
|
||||
self.assertEqual(u_student.get_institutional_role(), "alumno")
|
||||
self.assertTrue(u_student.is_alumno())
|
||||
|
||||
u_admin = User(name="Test Admin", email="admin@test.edu", role="ADMIN")
|
||||
self.assertEqual(u_admin.get_institutional_role(), "admin")
|
||||
self.assertTrue(u_admin.is_admin())
|
||||
|
||||
badge = u_admin.role_badge_display
|
||||
self.assertIn("badge-admin", badge["class"])
|
||||
self.assertIn("Administrador", badge["label"])
|
||||
|
||||
def test_admin_dashboard_rendered(self):
|
||||
"""Verifica que el dashboard administrativo renderiza KPIs y accesos de administración"""
|
||||
res = self.client.get('/?view_as=admin')
|
||||
self.assertEqual(res.status_code, 200)
|
||||
html = res.get_data(as_text=True)
|
||||
self.assertIn('Aulas Totales', html)
|
||||
self.assertIn('Oferta de Materias', html)
|
||||
self.assertIn('Usuarios Activos', html)
|
||||
self.assertIn('Módulos de Gestión & Visualización de Menús', html)
|
||||
self.assertIn('Espacios & Sedes', html)
|
||||
self.assertIn('Auditoría & RBAC', html)
|
||||
|
||||
def test_bedelia_dashboard_rendered(self):
|
||||
"""Verifica que el dashboard de bedelía renderiza métricas operativas de campus"""
|
||||
res = self.client.get('/?view_as=bedelia')
|
||||
self.assertEqual(res.status_code, 200)
|
||||
html = res.get_data(as_text=True)
|
||||
self.assertIn('Aulas Presenciales', html)
|
||||
self.assertIn('Salas Virtuales', html)
|
||||
self.assertIn('Acciones Rápidas & Menús Operativos de Bedelía', html)
|
||||
self.assertIn('Cartelera del Día', html)
|
||||
self.assertIn('Sincronizar Sheets', html)
|
||||
|
||||
def test_docente_dashboard_rendered(self):
|
||||
"""Verifica que el dashboard del docente renderiza sus clases y accesos directos"""
|
||||
res = self.client.get('/?view_as=docente')
|
||||
self.assertEqual(res.status_code, 200)
|
||||
html = res.get_data(as_text=True)
|
||||
self.assertIn('Mis Clases Hoy', html)
|
||||
self.assertIn('Mis Reservas Activas', html)
|
||||
self.assertIn('Reservar Aula', html)
|
||||
self.assertIn('Accesos Directos de Docencia & Visualización de Menús', html)
|
||||
self.assertIn('Mis Clases Programadas para Hoy', html)
|
||||
|
||||
def test_alumno_dashboard_rendered(self):
|
||||
"""Verifica que el dashboard del alumno muestra la brújula diaria y herramientas de cursada"""
|
||||
res = self.client.get('/?view_as=alumno')
|
||||
self.assertEqual(res.status_code, 200)
|
||||
html = res.get_data(as_text=True)
|
||||
self.assertIn('¿Dónde curso hoy? - Cartelera del Alumno', html)
|
||||
self.assertIn('Menú del Alumno & Herramientas de Cursada', html)
|
||||
self.assertIn('Cartelera de Hoy', html)
|
||||
self.assertIn('Cronograma Semanal', html)
|
||||
self.assertIn('Aulas Virtuales', html)
|
||||
|
||||
def test_audit_logs_view(self):
|
||||
"""Verifica que la vista de auditoría institucional responde correctamente"""
|
||||
res = self.client.get('/admin/audit-logs')
|
||||
self.assertEqual(res.status_code, 200)
|
||||
html = res.get_data(as_text=True)
|
||||
self.assertIn('Registro de Auditoría Institucional', html)
|
||||
self.assertIn('Trazabilidad completa de operaciones', html)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,42 @@
|
||||
import unittest
|
||||
from app import create_app, db
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.services.audit_service import AuditService
|
||||
|
||||
class TestSecurityChainAndAudit(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.app = create_app()
|
||||
self.app.config['TESTING'] = True
|
||||
self.client = self.app.test_client()
|
||||
self.app_context = self.app.app_context()
|
||||
self.app_context.push()
|
||||
|
||||
def tearDown(self):
|
||||
db.session.rollback()
|
||||
self.app_context.pop()
|
||||
|
||||
def test_security_headers_injected(self):
|
||||
res = self.client.get('/login')
|
||||
self.assertIn('X-Content-Type-Options', res.headers)
|
||||
self.assertEqual(res.headers['X-Content-Type-Options'], 'nosniff')
|
||||
self.assertIn('X-Frame-Options', res.headers)
|
||||
self.assertEqual(res.headers['X-Frame-Options'], 'SAMEORIGIN')
|
||||
self.assertIn('X-XSS-Protection', res.headers)
|
||||
self.assertEqual(res.headers['X-XSS-Protection'], '1; mode=block')
|
||||
|
||||
def test_audit_log_creation(self):
|
||||
entry = AuditService.log(
|
||||
action='TEST_ACTION',
|
||||
module='classrooms',
|
||||
entity_id=999,
|
||||
details={'test': True, 'desc': 'Prueba unitaria de auditoría'}
|
||||
)
|
||||
self.assertIsNotNone(entry.id)
|
||||
self.assertEqual(entry.action, 'TEST_ACTION')
|
||||
self.assertEqual(entry.module, 'classrooms')
|
||||
|
||||
recent = AuditService.get_recent_logs(limit=5, module='classrooms')
|
||||
self.assertTrue(any(e.id == entry.id for e in recent))
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,60 @@
|
||||
import unittest
|
||||
from datetime import datetime, timedelta
|
||||
from app import create_app, db
|
||||
from app.models.user import User
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.building import Building
|
||||
from app.models.subject import Commission
|
||||
from app.schemas.classroom_dto import ClassroomCreateDTO
|
||||
from app.schemas.reservation_dto import ReservationCreateDTO
|
||||
from app.services.classroom_service import ClassroomService
|
||||
from app.services.reservation_service import ReservationService
|
||||
from app.services.user_service import UserService
|
||||
|
||||
class TestServicesAndDTO(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.app = create_app()
|
||||
self.app.config['TESTING'] = True
|
||||
self.app.config['WTF_CSRF_ENABLED'] = False
|
||||
self.app_context = self.app.app_context()
|
||||
self.app_context.push()
|
||||
|
||||
self.classroom_service = ClassroomService()
|
||||
self.reservation_service = ReservationService()
|
||||
self.user_service = UserService()
|
||||
|
||||
def tearDown(self):
|
||||
db.session.rollback()
|
||||
self.app_context.pop()
|
||||
|
||||
def test_dto_validation(self):
|
||||
# Valid ClassroomCreateDTO
|
||||
dto = ClassroomCreateDTO(code="Aula Test 101", floor="1", capacity=40, building_name="Edificio Central")
|
||||
self.assertEqual(dto.code, "Aula Test 101")
|
||||
self.assertEqual(dto.capacity, 40)
|
||||
|
||||
# Invalid Reservation range (end before start)
|
||||
with self.assertRaises(Exception):
|
||||
ReservationCreateDTO(
|
||||
classroom_id=1,
|
||||
commission_id=1,
|
||||
user_id=1,
|
||||
start_time=datetime(2026, 9, 10, 10, 0),
|
||||
end_time=datetime(2026, 9, 10, 9, 0),
|
||||
purpose="Clase Test"
|
||||
)
|
||||
|
||||
def test_classroom_service_listing(self):
|
||||
classrooms = self.classroom_service.list_classrooms()
|
||||
self.assertIsInstance(classrooms, list)
|
||||
|
||||
def test_user_service_profile(self):
|
||||
user = User.query.first()
|
||||
if user:
|
||||
profile = self.user_service.get_profile_data(user)
|
||||
self.assertIn('email', profile)
|
||||
self.assertIn('role', profile)
|
||||
self.assertIn('permissions', profile)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,275 @@
|
||||
import unittest
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app import create_app, db
|
||||
from app.models.user import User
|
||||
from app.models.role import Role
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.genetic_algorithm import (
|
||||
GeneticAlgorithm,
|
||||
ReservationRequest,
|
||||
Individual,
|
||||
Gene
|
||||
)
|
||||
from app.services.optimizer_service import OptimizerService, OptimizerJobStatus
|
||||
from app.services.jwt_service import JWTService
|
||||
|
||||
|
||||
class Sprint4OptimizerAndConcurrencyTestCase(unittest.TestCase):
|
||||
"""Pruebas exhaustivas para el Sprint 4: Optimizador Heurístico, Concurrencia y Spring Boot."""
|
||||
|
||||
def setUp(self):
|
||||
self.app = create_app()
|
||||
self.app.config['TESTING'] = True
|
||||
self.app.config['WTF_CSRF_ENABLED'] = False
|
||||
self.app_context = self.app.app_context()
|
||||
self.app_context.push()
|
||||
self.client = self.app.test_client()
|
||||
|
||||
# Obtener usuario existente o crear
|
||||
self.user = User.query.filter_by(is_active=True).first()
|
||||
if not self.user:
|
||||
admin_role = Role.query.filter_by(name='Admin').first()
|
||||
if not admin_role:
|
||||
admin_role = Role(name='Admin', description='Administrador')
|
||||
db.session.add(admin_role)
|
||||
db.session.commit()
|
||||
self.user = User(
|
||||
name='Sprint 4 Admin',
|
||||
email='admin_sprint4@edu-space.com',
|
||||
role_id=admin_role.id,
|
||||
is_active=True
|
||||
)
|
||||
self.user.set_password('admin123')
|
||||
db.session.add(self.user)
|
||||
db.session.commit()
|
||||
|
||||
# Obtener aulas activas de la base de datos
|
||||
self.classrooms = Classroom.query.filter_by(is_active=True).all()
|
||||
if not self.classrooms:
|
||||
for i in range(1, 11):
|
||||
room = Classroom(
|
||||
room_number=f"AULA-{100 + i}",
|
||||
capacity=30 + (i * 5),
|
||||
is_virtual=(i == 10),
|
||||
is_active=True
|
||||
)
|
||||
db.session.add(room)
|
||||
db.session.commit()
|
||||
self.classrooms = Classroom.query.filter_by(is_active=True).all()
|
||||
|
||||
# Token JWT para el usuario de prueba
|
||||
token_pair = JWTService.generate_tokens(self.user)
|
||||
self.access_token = token_pair['access_token']
|
||||
self.auth_headers = {
|
||||
'Authorization': f'Bearer {self.access_token}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
def tearDown(self):
|
||||
db.session.rollback()
|
||||
self.app_context.pop()
|
||||
|
||||
def test_01_fast_fitness_and_conflict_detection(self):
|
||||
"""Verifica la detección de solapamiento O(K log K) y cálculo de fitness."""
|
||||
now = datetime(2026, 3, 10, 18, 0, 0)
|
||||
c1 = self.classrooms[0]
|
||||
|
||||
# Gene 1: 18:00 a 20:00 en AULA-101
|
||||
g1 = Gene(
|
||||
commission_id=1,
|
||||
classroom_id=c1.id,
|
||||
start_time=now,
|
||||
end_time=now + timedelta(hours=2),
|
||||
expected_attendees=30,
|
||||
purpose="Clase Algoritmos"
|
||||
)
|
||||
# Gene 2: 19:00 a 21:00 en la MISMA aula (conflicto directo de 19:00 a 20:00)
|
||||
g2 = Gene(
|
||||
commission_id=2,
|
||||
classroom_id=c1.id,
|
||||
start_time=now + timedelta(hours=1),
|
||||
end_time=now + timedelta(hours=3),
|
||||
expected_attendees=30,
|
||||
purpose="Clase Base de Datos"
|
||||
)
|
||||
|
||||
ind = Individual([g1, g2])
|
||||
classrooms_dict = {c.id: c for c in self.classrooms}
|
||||
req1 = ReservationRequest(1, 30, "Clase Algoritmos", now, now + timedelta(hours=2))
|
||||
req2 = ReservationRequest(2, 30, "Clase Base de Datos", now + timedelta(hours=1), now + timedelta(hours=3))
|
||||
requests_dict = {1: req1, 2: req2}
|
||||
|
||||
fit = ind.calculate_fitness(classrooms_dict, requests_dict)
|
||||
self.assertGreater(len(ind.conflicts), 0, "Debe detectar conflicto de solapamiento de horario")
|
||||
|
||||
def test_02_benchmark_200_commissions_under_5_seconds(self):
|
||||
"""Hito de Evaluación Sprint 4: Optimización de 200+ comisiones en menos de 5 segundos."""
|
||||
base_time = datetime(2026, 3, 16, 8, 0, 0)
|
||||
requests = []
|
||||
|
||||
# Crear 200 peticiones de comisión distribuidas en turnos
|
||||
for i in range(1, 201):
|
||||
day_offset = (i % 5) # Lun a Vie
|
||||
slot_offset = (i % 4) * 3 # 08:00, 11:00, 14:00, 18:00
|
||||
start = base_time + timedelta(days=day_offset, hours=slot_offset)
|
||||
req = ReservationRequest(
|
||||
commission_id=i,
|
||||
expected_attendees=25 + (i % 25),
|
||||
purpose=f"Comisión {i}",
|
||||
preferred_start_time=start,
|
||||
preferred_end_time=start + timedelta(hours=2),
|
||||
priority=1,
|
||||
flexibility_hours=2
|
||||
)
|
||||
requests.append(req)
|
||||
|
||||
# Configurar optimizador con paralelización (4 workers)
|
||||
ga = GeneticAlgorithm(
|
||||
population_size=30,
|
||||
generations=25,
|
||||
workers=4
|
||||
)
|
||||
|
||||
t_start = time.perf_counter()
|
||||
best = ga.optimize_reservations(
|
||||
requests=requests,
|
||||
available_classrooms=self.classrooms,
|
||||
start_date=base_time,
|
||||
end_date=base_time + timedelta(days=7)
|
||||
)
|
||||
t_elapsed = time.perf_counter() - t_start
|
||||
|
||||
self.assertIsNotNone(best)
|
||||
self.assertEqual(len(best.genes), 200, "Debe haber asignado las 200 comisiones")
|
||||
self.assertLess(t_elapsed, 5.0, f"El algoritmo debe resolver 200 comisiones en < 5s (demoró {t_elapsed:.2f}s)")
|
||||
|
||||
def test_03_optimizer_service_async_queue(self):
|
||||
"""Verifica la cola de trabajos asíncrona de OptimizerService."""
|
||||
# Enviar un job en segundo plano con app_context
|
||||
job = OptimizerService.submit_job(
|
||||
commission_ids=[1, 2],
|
||||
admin_user_id=self.user.id,
|
||||
generations=10,
|
||||
population_size=10,
|
||||
workers=2,
|
||||
app=self.app
|
||||
)
|
||||
|
||||
self.assertIsNotNone(job.job_id)
|
||||
self.assertIn(job.status, [OptimizerJobStatus.PENDING, OptimizerJobStatus.RUNNING, OptimizerJobStatus.COMPLETED])
|
||||
|
||||
# Esperar a que el worker complete la ejecución (máximo 4 segundos)
|
||||
waited = 0.0
|
||||
while job.status in (OptimizerJobStatus.PENDING, OptimizerJobStatus.RUNNING) and waited < 4.0:
|
||||
time.sleep(0.1)
|
||||
waited += 0.1
|
||||
|
||||
self.assertEqual(job.status, OptimizerJobStatus.COMPLETED, f"El trabajo debió completar con éxito, error: {job.error}")
|
||||
self.assertEqual(job.progress, 100)
|
||||
self.assertIsNotNone(job.completed_at)
|
||||
self.assertIsNotNone(job.execution_time_ms)
|
||||
|
||||
# Consultar via list_jobs y get_job
|
||||
found = OptimizerService.get_job(job.job_id)
|
||||
self.assertIsNotNone(found)
|
||||
self.assertEqual(found.job_id, job.job_id)
|
||||
|
||||
all_jobs = OptimizerService.list_jobs()
|
||||
self.assertTrue(any(j['job_id'] == job.job_id for j in all_jobs))
|
||||
|
||||
def test_04_spring_boot_sync_payload_endpoint(self):
|
||||
"""Verifica el endpoint de interoperabilidad REST/JWT con Spring Boot."""
|
||||
now = datetime(2026, 4, 1, 18, 30, 0)
|
||||
payload = {
|
||||
"requestId": "REQ-SPRING-BOOT-2026-001",
|
||||
"campusCode": "SEDE-CENTRAL",
|
||||
"academicTerm": "2026-1C",
|
||||
"commissions": [
|
||||
{
|
||||
"commissionId": 101,
|
||||
"subjectCode": "INFO-101",
|
||||
"subjectName": "Programación Orientada a Objetos",
|
||||
"expectedAttendees": 35,
|
||||
"preferredShift": "NOCHE",
|
||||
"preferredStart": now.isoformat(),
|
||||
"preferredEnd": (now + timedelta(hours=3)).isoformat()
|
||||
},
|
||||
{
|
||||
"commissionId": 102,
|
||||
"subjectCode": "MATH-201",
|
||||
"subjectName": "Análisis Matemático II",
|
||||
"expectedAttendees": 40,
|
||||
"preferredShift": "NOCHE",
|
||||
"preferredStart": now.isoformat(),
|
||||
"preferredEnd": (now + timedelta(hours=3)).isoformat()
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
"generations": 15,
|
||||
"populationSize": 15,
|
||||
"workers": 2
|
||||
}
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
'/api/v1/optimizer/spring-boot/sync',
|
||||
json=payload,
|
||||
headers=self.auth_headers
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.get_json()
|
||||
self.assertEqual(data.get('requestId'), "REQ-SPRING-BOOT-2026-001")
|
||||
self.assertEqual(data.get('status'), "SUCCESS")
|
||||
self.assertEqual(data.get('totalCommissions'), 2)
|
||||
self.assertIn('scheduledAssignments', data)
|
||||
self.assertIn('executionTimeMs', data)
|
||||
self.assertEqual(len(data['scheduledAssignments']), 2)
|
||||
|
||||
def test_05_api_async_job_submission_and_polling(self):
|
||||
"""Verifica la API REST de despacho asíncrono y sondeo (polling)."""
|
||||
payload = {
|
||||
"commission_ids": [1, 2],
|
||||
"generations": 10,
|
||||
"population_size=10": None, # será ignorado o testeado con defaults
|
||||
"generations": 10,
|
||||
"population_size": 10,
|
||||
"workers": 2
|
||||
}
|
||||
|
||||
# 1. Enviar job (debe retornar 202 Accepted)
|
||||
post_res = self.client.post(
|
||||
'/api/v1/optimizer/jobs',
|
||||
json=payload,
|
||||
headers=self.auth_headers
|
||||
)
|
||||
self.assertEqual(post_res.status_code, 202)
|
||||
post_data = post_res.get_json()
|
||||
self.assertIn('job_id', post_data)
|
||||
job_id = post_data['job_id']
|
||||
self.assertEqual(post_data['poll_url'], f'/api/v1/optimizer/jobs/{job_id}')
|
||||
|
||||
# 2. Consultar estado del job
|
||||
get_res = self.client.get(
|
||||
f'/api/v1/optimizer/jobs/{job_id}',
|
||||
headers=self.auth_headers
|
||||
)
|
||||
self.assertEqual(get_res.status_code, 200)
|
||||
get_data = get_res.get_json()
|
||||
self.assertEqual(get_data['job_id'], job_id)
|
||||
self.assertIn(get_data['status'], ['PENDING', 'RUNNING', 'COMPLETED'])
|
||||
|
||||
# 3. Listar trabajos
|
||||
list_res = self.client.get(
|
||||
'/api/v1/optimizer/jobs',
|
||||
headers=self.auth_headers
|
||||
)
|
||||
self.assertEqual(list_res.status_code, 200)
|
||||
list_data = list_res.get_json()
|
||||
self.assertGreaterEqual(list_data['total'], 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,45 @@
|
||||
import unittest
|
||||
from app import create_app, db
|
||||
from app.models.user import User
|
||||
|
||||
class TestViewSwitcher(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.app = create_app()
|
||||
self.app.config['TESTING'] = True
|
||||
self.app.config['WTF_CSRF_ENABLED'] = False
|
||||
self.client = self.app.test_client()
|
||||
self.app_context = self.app.app_context()
|
||||
self.app_context.push()
|
||||
|
||||
# Login con usuario admin para acceder a las vistas protegidas
|
||||
self.user = User.query.filter_by(is_active=True).first()
|
||||
if self.user:
|
||||
with self.client.session_transaction() as sess:
|
||||
sess['_user_id'] = str(self.user.id)
|
||||
sess['_fresh'] = True
|
||||
|
||||
def tearDown(self):
|
||||
self.app_context.pop()
|
||||
|
||||
def test_classrooms_view_switcher_rendered(self):
|
||||
res = self.client.get('/classrooms/?view_mode=physical')
|
||||
self.assertEqual(res.status_code, 200)
|
||||
html = res.get_data(as_text=True)
|
||||
# Verificar que el selector de vista está presente
|
||||
self.assertIn('view-switcher-group', html)
|
||||
self.assertIn('data-context="classrooms"', html)
|
||||
self.assertIn('data-view-container="cards"', html)
|
||||
self.assertIn('data-view-container="table"', html)
|
||||
|
||||
def test_reservations_view_switcher_rendered(self):
|
||||
res = self.client.get('/schedule/list')
|
||||
self.assertEqual(res.status_code, 200)
|
||||
html = res.get_data(as_text=True)
|
||||
# Verificar que el selector de vista está presente
|
||||
self.assertIn('view-switcher-group', html)
|
||||
self.assertIn('data-context="reservations"', html)
|
||||
self.assertIn('data-view-container="table"', html)
|
||||
self.assertIn('data-view-container="cards"', html)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user