Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05e197e49f | ||
|
|
e93d784473 | ||
|
|
620e470614 | ||
|
|
b9a11d810a | ||
|
|
5087e22988 | ||
|
|
42b31ec44e | ||
|
|
500aeeb9df | ||
|
|
1080acd43a | ||
|
|
47b50ede7e | ||
|
|
865538b727 | ||
|
|
1fda344248 | ||
|
|
69d545b718 | ||
|
|
dd93b8c07a | ||
|
|
7d342bf8cc | ||
|
|
69b8b7ccef |
@@ -210,3 +210,14 @@ cython_debug/
|
||||
marimo/_static/
|
||||
marimo/_lsp/
|
||||
__marimo__/
|
||||
|
||||
# Node.js / Frontend
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
frontend/.env
|
||||
|
||||
legacy_admin-edu-space/
|
||||
.vscode/
|
||||
@@ -0,0 +1,129 @@
|
||||
# Changelog Oficial — Admin Edu-Space (UniCABA)
|
||||
|
||||
Todas las modificaciones notables a este proyecto serán documentadas en este archivo.
|
||||
El formato está basado en [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/) y este proyecto se adhiere a [Semantic Versioning](https://semver.org/lang/es/).
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
### Planificado (Fase 2 del Roadmap MVP)
|
||||
- **Co-docencia en Cátedras:** Asignación de múltiples profesores (titular, adjunto, ayudante) a una misma comisión.
|
||||
- **Matriz de Conflictos:** Detección de solapamiento físico, docente y control de aforo en tiempo real.
|
||||
- **Regla de Restricción Diaria del Alumno:** Validación para impedir cursar dos materias regulares el mismo día, con motor de excepciones para cursos cortos o autorización de Bedelía.
|
||||
- **Libro de Calificaciones (*Gradebook*):** Carga rápida de notas y seguimiento de regularidad.
|
||||
- **Grilla Semanal Drag & Drop:** Asignación visual interactiva para la oficina de Bedelía.
|
||||
|
||||
---
|
||||
|
||||
## [2.3.0] - 2026-09-19
|
||||
### Añadido
|
||||
- **Monitoreo y Percentiles de Asignación de Aulas Físicas (Admin & Bedelía):**
|
||||
- Panel analítico *"Estado y Percentiles de Asignación de Aulas Físicas"* incorporado en los dashboards de Administrador (`/dashboard?role=admin`) y Bedelía (`/dashboard?role=bedelia`).
|
||||
- Cálculo dinámico en tiempo real de **Aulas Asignadas** en número y percentil (**26 aulas · 96.3%**) con enlace directo a cursadas activas.
|
||||
- Cálculo dinámico en tiempo real de **Aulas Sin Asignar / Libres** en número y percentil (**1 aula · 3.7%**) con enlace directo al formulario de asignación de espacios.
|
||||
- Barras de progreso visuales con desglose dual: **Planificación Cuatrimestral** (96.3% asignado vs 3.7% disponible) y **Ocupación Operativa del Día** (aulas con clases hoy vs aulas libres).
|
||||
- Exposición de métricas de asignación y percentiles en el endpoint de la API REST `GET /api/v1/dashboard/stats`.
|
||||
- **Módulo de Cursadas Asignadas y Métricas Reales en Catálogo Presencial (`/classrooms/list_classrooms?view_mode=physical`):**
|
||||
- Incorporación de la sección *"Cursadas Asignadas"* dentro de cada tarjeta de aula física y columna en vista de tabla, detallando asignaturas, comisiones y turnos.
|
||||
- Métricas de ocupación física real por período (Día, Semana, Mes) con porcentajes calculados dinámicamente (6%, 11%, 17%, 22% según la carga horaria del aula).
|
||||
- Botones de acción directa con ID dinámico: **Ficha** (`/classrooms/view_classroom?id=...`), **Horarios en Calendario** (`/schedule/calendar_view?classroom_id=...`) y **Editar**.
|
||||
- Conexión de la tabla *"Próximas Clases y Reservas"* en la ficha individual del aula (`/classrooms/view_classroom`) con las reservas reales del cuatrimestre y enlaces a cada reserva (`/schedule/view_reservation?id=...`).
|
||||
|
||||
### Modificado
|
||||
- **Dashboard Docente (`/dashboard?role=docente`):**
|
||||
- Eliminado el botón flotante *"Reservar Aula / Solicitar espacio para clase"* (`/schedule/add_reservation`) a petición de los requerimientos funcionales del cuerpo docente.
|
||||
- Reorganizada la grilla superior a 3 columnas simétricas (`col-md-4`) para las tarjetas de *Mis Clases Hoy*, *Mis Reservas Activas* y *Aulas Virtuales*.
|
||||
|
||||
### Corregido
|
||||
- **Fallo Crítico de Renderizado en Nunjucks (`memberLookup` con `.items`):**
|
||||
- Solucionada la incompatibilidad de `nunjucks/src/jinja-compat` que interceptaba la propiedad `.items` convirtiendo `classrooms.items` en una función interna en lugar de un array, lo que impedía dibujar las tarjetas de aulas presenciales en `frontend/views/classrooms/list.html`.
|
||||
- Implementado interceptor en `memberLookup` en `frontend/src/app.js` para preservar arrays intactos.
|
||||
- **Clasificación Errónea de Reservas Físicas como Virtuales (`is_virtual`):**
|
||||
- Corregida la propiedad `@property is_virtual` en `Reservation` (`backend/app/models/reservation.py`) para garantizar que si una reserva tiene aula física asignada (`self.classroom and not self.classroom.is_virtual`), siempre sea clasificada como presencial (`is_virtual = False`), evitando falsos positivos cuando la comisión posee un enlace virtual predeterminado.
|
||||
- **Excepción de SQLAlchemy en Estadísticas de Dashboard:**
|
||||
- Corregido el error de ejecución generado al invocar `.ilike()` sobre la propiedad Python `@property Classroom.room_number` en `backend/app/routes/api/dashboard.py`, migrando la clasificación de aulas presenciales y virtuales a una evaluación robusta en memoria Python (`Classroom.code` y `Classroom.building`).
|
||||
|
||||
---
|
||||
|
||||
## [2.2.0] - 2026-09-19
|
||||
### Añadido
|
||||
- **Herramienta de Purga y Reinicio de Datos Académicos (`reset_academic_data`):**
|
||||
- Script CLI `backend/scripts/reset_academic_data.py` con argumentos de limpieza granular (`--all`, `--reservations`, `--classrooms`, `--careers`, `--yes`).
|
||||
- Endpoint de API `POST /api/v1/admin/reset-academic-data` protegido por JWT (rol `admin`) con registro en `AuditLog`.
|
||||
- Panel interactivo "Zona de Reinicio y Purga Académica" en `/admin/google_sheets_import` con checkboxes independientes para reservas, aulas, comisiones, asignaturas y carreras.
|
||||
- Modal modal de confirmación con advertencias de seguridad para prevenir purgas accidentales.
|
||||
- Preservación íntegra de usuarios del sistema, credenciales de acceso, roles RBAC y registros de auditoría.
|
||||
- **Configuración Global Dinámica de Google Sheets:**
|
||||
- Nuevo modelo `SystemSetting` (`backend/app/models/setting.py`) para almacenamiento persistente clave-valor en base de datos.
|
||||
- Endpoints `GET /api/v1/admin/sheets-config` y `POST /api/v1/admin/sheets-config`.
|
||||
- Normalizador de enlaces en `GoogleSheetsImporter` compatible con URLs compartidas de edición (`/spreadsheets/d/.../edit`) o enlaces publicados (`/pub?output=csv`).
|
||||
- Interfaz web con formulario de edición de URL, botón de guardado en caliente, restauración a URL oficial predeterminada y acceso directo a Google Docs.
|
||||
- **Visualización de Asignaturas Reales:**
|
||||
- Tabla de asignaturas recientemente sincronizadas conectada a la base de datos real en lugar de datos de muestra (*mock*).
|
||||
|
||||
### Corregido
|
||||
- **Fallo por Timeout en Google Sheets Academic Sync:**
|
||||
- Solucionado el corte prematuro de conexión en el BFF (`frontend/src/services/apiClient.js`), aumentando el timeout base a 25s y el timeout específico de sincronización a 90s (anteriormente limitado a 5s en operaciones que toman ~6.7s).
|
||||
- Eliminado el enmascaramiento silencioso de errores en el BFF, permitiendo reportar fallos reales o éxitos detallados (+carreras, +aulas, +materias, +comisiones, +reservas).
|
||||
|
||||
---
|
||||
|
||||
## [2.1.0] - 2026-09-19
|
||||
### Añadido
|
||||
- **Ficha Detallada de Reserva (`schedule.view_reservation`):**
|
||||
- Implementación en BFF Node.js de la ruta `/schedule/view_reservation` (soportando parámetros `:id`, `?id=` o selección automática del primer registro).
|
||||
- Vistas y controladores para modificar reservas con selectores en cascada (`/schedule/edit_reservation`).
|
||||
- Endpoints de acción rápida en backend y BFF: `POST /schedule/confirm_reservation` y `POST /schedule/cancel_reservation`.
|
||||
- Actualización de enlaces en `today.html`, `list.html`, `dashboard.html` y `view.html` para navegar directamente con el identificador de la reserva (`?id=...`).
|
||||
- **Creación y Asociación de Comisiones a Asignaturas (`admin/commissions_list`):**
|
||||
- Botón destacado `+ Nueva Comisión` en el encabezado y en el estado vacío de la tabla.
|
||||
- Modal interactivo `#modalAddCommission` con selección obligatoria y clara de la **Asignatura / Materia Asociada** (`<select name="subject_id" required>`), mostrando código oficial, nombre y carrera.
|
||||
- Campos completos de cursada: cuatrimestre (1C/2C/Anual), año lectivo, turno, docente a cargo, cupo máximo y días/horarios.
|
||||
- Generación y configuración automática de enlace a Google Meet institucional cuando no se ingresa un enlace personalizado.
|
||||
- Alerta de confirmación visual (`?created=1`) al registrar y vincular una comisión exitosamente.
|
||||
- Tabla de comisiones actualizada con visualización explícita del código de materia, nombre y badge de carrera.
|
||||
- **Enriquecimiento de la API REST:**
|
||||
- Endpoint `GET /api/v1/reservations/<id>` ampliado con objetos relacionales anidados: `classroom` (código, edificio, piso, capacidad, descripción, virtualidad), `commission` (código, código compuesto, materia), `user` (docente, nombre, apellido, correo), duración en minutos y enlaces virtuales.
|
||||
- Endpoint `POST /api/v1/admin/commissions` con validación estricta de existencia de asignatura y resolución inteligente de unicidad.
|
||||
|
||||
---
|
||||
|
||||
## [2.0.0] - 2026-09-18
|
||||
### Añadido
|
||||
- **Reingeniería de Arquitectura Fullstack (Desacople Monolito -> Backend + BFF):**
|
||||
- Desacople completo del backend Python/Flask como una **API REST pura** bajo `/api/v1/` retornando exclusivamente JSON.
|
||||
- Implementación del **Frontend BFF** en Node.js + Express con renderizado de servidor mediante Nunjucks.
|
||||
- Autenticación sin estado (*Stateless*) basada en tokens JWT firmados con algoritmo HS256.
|
||||
- Almacenamiento seguro del token de acceso en cookies del navegador con directivas `HttpOnly`, `SameSite=Lax` y `Secure`.
|
||||
- Inyección transparente del token `Bearer` en peticiones internas mediante cliente Axios centralizado.
|
||||
- **Dashboards Personalizados por Rol Institucional:**
|
||||
- **Dashboard de Bedelía (`/dashboard?role=bedelia`):** Métricas operativas de campus físico y virtual, cartelera del día y panel de aprobación de solicitudes pendientes.
|
||||
- **Dashboard de Docente (`/dashboard?role=docente`):** Sesiones del día con botones directos para ingresar a reuniones virtuales (Meet/Teams/Zoom) y formulario de solicitud de aulas.
|
||||
- **Dashboard de Alumno (`/dashboard?role=alumno`):** Brújula de cursada *"¿Dónde curso hoy?"* con ubicación por piso/aula o enlace virtual, junto al calendario de exámenes y entregas.
|
||||
- **Dashboard de Administrador (`/dashboard?role=admin`):** Cockpit institucional de capacidad, usuarios, auditoría RBAC y distribución de comisiones.
|
||||
- Selector dinámico de vista previa de roles en el banner superior para administradores.
|
||||
- **Cartelera del Día Interactiva (`/schedule/today_schedule`):**
|
||||
- Grilla estructurada en bloques horarios (07:00 a 21:00 hs) con métricas en tiempo real de ocupación y reservas confirmadas/pendientes.
|
||||
- Filtro dinámico para ocultar clases virtuales (`hide_virtual=1`) y segmentación por pisos o turnos.
|
||||
- **Seguridad Enterprise (SecurityFilterChain):**
|
||||
- Pipeline centralizado de cabeceras de blindaje OWASP (`nosniff`, `SAMEORIGIN`, `X-XSS-Protection`, `Strict-Transport-Security`).
|
||||
- Rate limiting dinámico contra ataques de fuerza bruta en endpoints de autenticación.
|
||||
|
||||
### Corregido
|
||||
- **Resolución de Colisión de Horarios en Clases Virtuales (Importador de Google Sheets):**
|
||||
- Corrección de la búsqueda de duplicados en `sheets_importer.py` que limitaba indebidamente la unicidad a `(classroom_id, start_time, end_time)`.
|
||||
- Habilitación de concurrencia ilimitada para el aula virtual (`Campus Virtual · VIRTUAL`) mediante discriminación por `commission_id`.
|
||||
- El total de reservas registradas aumentó de **363 a 643** (incorporando **280 clases virtuales** que eran descartadas por falso conflicto).
|
||||
- Generación y guardado automático de enlaces institucionales de Google Meet (`https://meet.google.com/edu-{materia}-{comision}`).
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0] - 2026-09-04
|
||||
### Añadido
|
||||
- **Versión Monolítica Base (`legacy_admin-edu-space`):**
|
||||
- Aplicación monolítica en Flask con plantillas Jinja2 y persistencia SQLAlchemy (SQLite / PostgreSQL).
|
||||
- Modelado de datos inicial para sedes, aulas físicas, materias, comisiones, usuarios y reservas.
|
||||
- Sistema de roles inicial (Admin, Bedelía, Docente, Alumno) basado en sesión tradicional de Flask-Login.
|
||||
- Calendario de reservas mensual y semanal con FullCalendar.
|
||||
- Prototipo experimental de algoritmo genético para optimización de ocupación áulica.
|
||||
- Módulo de sincronización inicial con planillas de Google Sheets.
|
||||
@@ -0,0 +1,223 @@
|
||||
# Estado Actual de la Migración y Arquitectura Objetivo
|
||||
## Sistema Integral de Gestión de Aulas, Cursadas y Seguimiento Académico — UniCABA
|
||||
|
||||
**Rol:** Arquitecto de Software y Analista Funcional Senior
|
||||
**Institución:** Universidad de la Ciudad de Buenos Aires (UniCABA)
|
||||
**Fecha de Actualización:** 19 de Septiembre de 2026
|
||||
**Rama Activa:** `testing`
|
||||
|
||||
---
|
||||
|
||||
## 1. De Dónde Venimos: La Versión Monolítica (`legacy_admin-edu-space`)
|
||||
|
||||
La versión original del sistema correspondía a un monolito clásico desarrollado en Python (Flask) con renderizado del lado del servidor mediante plantillas Jinja2 y ORM SQLAlchemy:
|
||||
|
||||
```
|
||||
[Cliente Web / Navegador]
|
||||
│
|
||||
▼ (HTTP / HTML)
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ Monolito Legacy (legacy_admin-edu-space) │
|
||||
│ - Rutas Flask con render_template (Jinja2) │
|
||||
│ - Sesión en Cookies de Flask (Flask-Login con estado) │
|
||||
│ - Consultas directas SQLAlchemy acopladas a vistas │
|
||||
│ - Base de Datos (SQLite / PostgreSQL) │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Fortalezas Heredadas del Monolito
|
||||
1. **Modelado de Dominio Inicial:** Entidades base para Sedes (`Building`), Aulas (`Classroom`), Materias (`Subject`), Comisiones (`Commission`), Reservas (`Reservation`) y Usuarios (`User`).
|
||||
2. **Importador de Planillas Oficiales:** Módulo de sincronización con Google Sheets para alimentar la cartelera con la oferta de comisiones y clases.
|
||||
3. **Algoritmo Genético Experimental:** Prototipo de optimización predictiva para asignación de aulas físicas según aforo y equipamiento.
|
||||
|
||||
### Limitaciones Críticas que Forzaron la Migración
|
||||
* **Acoplamiento Servidor-Vista:** Lógica de negocio mezclada dentro de controladores web y macros de plantillas Jinja2, impidiendo el consumo móvil (app institucional) o integraciones externas (SIU Guaraní, Moodle).
|
||||
* **Fallas Graves en Virtualidad:** El importador de Google Sheets descartaba más del 75% de las clases virtuales por asumir que el aula `Campus Virtual · VIRTUAL` sufría colisiones físicas de horario ante comisiones simultáneas.
|
||||
* **Falta de Perspectiva por Rol:** El dashboard era único y uniforme; no distinguía entre el trabajo administrativo de Bedelía, la consulta rápida de cursada del Alumno, o la gestión evaluativa del Docente.
|
||||
* **Escalabilidad y Seguridad:** Autenticación dependiente de la sesión en memoria del servidor web, sin soporte de tokens JWT sin estado ni separación de responsabilidades (BFF pattern).
|
||||
|
||||
---
|
||||
|
||||
## 2. Dónde Estamos Actualmente: Arquitectura Desacoplada Backend-BFF
|
||||
|
||||
La infraestructura ha sido transformada en un modelo desacoplado y de alta disponibilidad:
|
||||
|
||||
```
|
||||
[ Navegador Web / Cliente ]
|
||||
│
|
||||
│ (HTTP / HTML + Cookies HttpOnly)
|
||||
▼
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ Frontend BFF (Node.js + Express) │
|
||||
│ Puerto: 3000 │
|
||||
│ - Renderizado Nunjucks con herencia limpia │
|
||||
│ - Orquestación y agregación de UI │
|
||||
│ - Gestión de Cookie JWT y Contexto de Rol │
|
||||
│ - Cliente Axios con Interceptor Bearer │
|
||||
└──────────────────────┬───────────────────────┘
|
||||
│
|
||||
│ (REST JSON / JWT Bearer)
|
||||
▼
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ Backend Core API (Python + Flask) │
|
||||
│ Puerto: 5000 │
|
||||
│ - Endpoints RESTful bajo /api/v1/ │
|
||||
│ - Autenticación Stateless JWT (HS256) │
|
||||
│ - Pipeline Enterprise SecurityFilterChain │
|
||||
│ - Capa de Servicios y DTOs (Pydantic) │
|
||||
│ - Persistencia SQLAlchemy (Transaccional) │
|
||||
└──────────────────────┬───────────────────────┘
|
||||
│
|
||||
▼
|
||||
[ Base de Datos SQLite / PG ]
|
||||
```
|
||||
|
||||
### Estado de Implementación al Día de la Fecha
|
||||
|
||||
| Componente / Módulo | Estado en Legacy | Estado Actual (Backend API + BFF) | Nivel de Paridad |
|
||||
| :--- | :--- | :--- | :---: |
|
||||
| **Autenticación & Sesión** | Flask-Login basado en sesión de servidor | Autenticación REST JWT con cookie HttpOnly segura y RBAC centralizado | **120% (Superado)** |
|
||||
| **Dashboards por Rol** | Dashboard monolítico único para todos | 4 Dashboards especializados (`admin`, `bedelia`, `docente`, `alumno`) con selector dinámico | **150% (Superado)** |
|
||||
| **Campus Virtual & Enlaces** | Colisión de clases virtuales; pérdida de enlaces Meet | Aulas virtuales concurrentes sin límite, auto-generación de enlaces Google Meet por comisión | **140% (Superado)** |
|
||||
| **Importador Google Sheets** | 363 reservas importadas (colisiones masivas) | 643 reservas totales (388 virtuales) sin colisión horaria entre comisiones | **100% (Paridad Total)** |
|
||||
| **Cartelera del Día (`today_schedule`)** | Vista Jinja con bloques rígidos | Timeline interactivo por horas (7 a 21 hs), filtros por piso/turno y switch `hide_virtual` | **110% (Superado)** |
|
||||
| **Ficha de Reserva (`view_reservation`)** | Vista monolítica rígida | Acoplada al BFF con relaciones anidadas completas, edición en cascada, confirmación y cancelación | **100% (Paridad Total)** |
|
||||
| **Gestión de Comisiones (`commissions_list`)** | Lista plana sin modal de creación en admin | Modal `#modalAddCommission` con vinculación obligatoria a Asignatura, docente, cupo, horarios y Meet | **125% (Superado)** |
|
||||
| **Calendario FullCalendar** | Feed JSON básico | Endpoint `/api/v1/reservations` con discriminación cromática (`#6366F1` virtuales) | **100% (Paridad Total)** |
|
||||
|
||||
---
|
||||
|
||||
## 3. A Dónde Queremos Ir: El Sistema Integral UniCABA
|
||||
|
||||
El objetivo institucional es consolidar la plataforma como el **ERP Académico y Gestor de Espacios Oficial de UniCABA**, dando soporte a las dinámicas pedagógicas físicas y digitales de la universidad.
|
||||
|
||||
### A. Entorno Físico vs. Campus Virtual
|
||||
1. **Edificio Físico (Central):**
|
||||
- Espacios con aforo estrictamente limitado, asignación por piso/ala y equipamiento técnico (proyectores, laboratorios informáticos, acústica).
|
||||
- Validación matemática de no-solapamiento temporal para evitar doble asignación de aula física.
|
||||
2. **Campus Virtual:**
|
||||
- Capacidad física infinita; asignación lógica de salas vinculadas a la plataforma oficial (Google Meet institucional, Zoom o Microsoft Teams).
|
||||
- Control de concurrencia temporal de docentes y estudiantes (evitar que un docente o alumno deba estar en dos salas remotas al mismo tiempo).
|
||||
|
||||
---
|
||||
|
||||
## 4. Reglas de Negocio del Sistema Objetivo
|
||||
|
||||
### 1. Perfiles y Permisos (RBAC Avanzado)
|
||||
* **Administrador General (Superadmin):**
|
||||
* Gobernanza total del sistema, configuración de periodos y parametrización de reglas de conflicto.
|
||||
* **Modo Impersonación / Vista Previa:** Visualización en tiempo real del sistema desde la perspectiva exacta de cualquier usuario (Bedelía, Docente, Alumno) para soporte y auditoría.
|
||||
* Flexibilidad académica para autorizar excepciones de cursada simultánea y apertura de comisiones extraordinarias.
|
||||
* **Bedelía / Gestión Operativa:**
|
||||
* Administración integral del inventario físico (Sedes, Edificios, Pisos, Aulas) y virtual.
|
||||
* Creación y parametrización de Ciclos Lectivos, Asignaturas y Comisiones.
|
||||
* **Asignación Docente Co-docente:** Posibilidad de asignar más de un profesor a una misma comisión (equipo de cátedra: titulares, adjuntos, ayudantes).
|
||||
* Gestión de cupos máximos, padrón de alumnos matriculados y resolución de conflictos de asignación.
|
||||
* Supervisión de hitos evaluativos institucionales.
|
||||
* **Docente:**
|
||||
* Vista segmentada estrictamente a sus comisiones asignadas.
|
||||
* Calendario de clases presenciales y accesos directos a sesiones de videoconferencia.
|
||||
* Gestión del calendario de evaluación: carga de fechas de parciales, recuperatorios, entregas de trabajos prácticos y finales.
|
||||
* Matriz ágil de carga y edición de calificaciones y estados de regularidad.
|
||||
* **Alumno:**
|
||||
* Acceso enfocado a sus materias cursadas en el periodo lectivo activo.
|
||||
* Brújula de cursada diaria (*¿Dónde curso hoy?* con sede, piso y aula física o enlace virtual).
|
||||
* Calendario unificado de entregas de TPs, parciales y finales con avisos preventivos.
|
||||
* Consulta histórica de notas y retroalimentaciones pedagógicas.
|
||||
|
||||
### 2. Estructura Académica y Ciclo Lectivo
|
||||
* **Ciclo Lectivo Flexible:** Estructurado por Año (ej. `2026`) y Periodos estándar (`1C`, `2C`, `Anual`), permitiendo el alta de "Cursos Cortos Intensivos" o trayectos formativos extracurriculares con fechas especiales.
|
||||
* **Restricción Diaria de Cursada del Estudiante:**
|
||||
* **Regla por defecto:** Un estudiante universitario no puede cursar dos asignaturas regulares el mismo día calendario.
|
||||
* **Excepción parametrizable:** Se autoriza la coincidencia en el mismo día si una de las materias corresponde a un curso corto intensivo o mediante autorización explícita de Bedelía/Secretaría Académica.
|
||||
|
||||
### 3. Matriz de Conflictos y Validaciones de Solapamiento
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Req([Solicitud de Asignación / Reserva]) --> V1{¿Es Aula Física?}
|
||||
V1 -- Sí --> C1{¿Aula ocupada en ese día y horario?}
|
||||
C1 -- Sí --> Err1[BLOQUEO: Conflicto Físico Infranqueable]
|
||||
C1 -- No --> C2{¿Aforo de alumnos <= Capacidad aula?}
|
||||
C2 -- No --> Err2[BLOQUEO: Aforo Excedido]
|
||||
C2 -- Sí --> V2
|
||||
V1 -- No (Virtual) --> V2
|
||||
|
||||
V2{¿Docente en otra clase en el mismo horario?}
|
||||
V2 -- Sí --> C3{¿Comisión cuenta con Co-Docencia?}
|
||||
C3 -- No --> Err3[BLOQUEO: Conflicto Docente]
|
||||
C3 -- Sí --> Warn1[ALERTA: Excepción Co-Docente Justificada]
|
||||
C3 --> V3
|
||||
V2 -- No --> V3
|
||||
|
||||
V3{¿Alumno cursa otra materia regular el mismo día?}
|
||||
V3 -- Sí --> C4{¿Es Curso Corto o Autorizado?}
|
||||
C4 -- No --> Err4[BLOQUEO: Regla Restricción Diaria]
|
||||
C4 -- Sí --> Ok[ASIGNACIÓN EXITOSA]
|
||||
V3 -- No --> Ok
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Diseño Técnico: Base de Datos Relacional Normalizada
|
||||
|
||||
Para soportar las reglas de co-docencia, inscripciones, evaluaciones y restricciones de día, el esquema de datos evolucionará hacia la siguiente estructura:
|
||||
|
||||
### Entidades Principales
|
||||
1. **`academic_terms` (Ciclos Lectivos):** `id`, `name`, `code` (ej. '2026-1C'), `year`, `term_type` ('1C', '2C', 'ANUAL', 'CORTO'), `start_date`, `end_date`, `is_active`.
|
||||
2. **`subjects` (Materias / Asignaturas):** `id`, `code`, `name`, `career_id`, `is_short_course` (boolean), `credits`, `active`.
|
||||
3. **`commissions` (Comisiones de Cursada):** `id`, `subject_id`, `term_id`, `code`, `shift`, `schedule_display`, `max_students`, `virtual_link`, `active`.
|
||||
4. **`commission_teachers` (Co-docencia / Equipo Docente):** `id`, `commission_id`, `teacher_id`, `role` ('TITULAR', 'ADJUNTO', 'AYUDANTE'), `can_grade` (boolean).
|
||||
5. **`commission_schedules` (Asignación Horaria y Espacial):**
|
||||
* `id`, `commission_id`, `day_of_week` (1=Lunes .. 7=Domingo), `start_time`, `end_time`, `classroom_id` (física o virtual), `is_virtual`.
|
||||
* *Constraint de Integridad:* `UNIQUE(classroom_id, day_of_week, start_time, end_time)` para aulas no virtuales.
|
||||
6. **`enrollments` (Inscripciones de Alumnos):** `id`, `commission_id`, `student_id`, `status` ('REGULAR', 'CONDICIONAL', 'LIBRE', 'PROMOVIDO'), `enrolled_at`, `allow_same_day_exception` (boolean).
|
||||
7. **`evaluation_milestones` (Hitos Evaluativos):** `id`, `commission_id`, `milestone_type_id`, `title`, `date`, `start_time`, `weight_percentage`, `is_mandatory`.
|
||||
8. **`student_grades` (Calificaciones y Actas):** `id`, `milestone_id`, `student_id`, `numeric_score`, `concept_score`, `feedback`, `graded_by_teacher_id`, `graded_at`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Diseño de Contratos de API RESTful (Backend Core)
|
||||
|
||||
### A. Módulo de Comisiones y Co-docencia
|
||||
* `GET /api/v1/commissions`: Filtro por término académico, carrera, asignatura, turno y estado.
|
||||
* `POST /api/v1/commissions`: Alta de comisión vinculada a una asignatura con generación automática de Meet.
|
||||
* `POST /api/v1/commissions/{id}/teachers`: Asignación de docentes con rol en cátedra (co-docencia).
|
||||
* `DELETE /api/v1/commissions/{id}/teachers/{teacher_id}`: Desvinculación de docente.
|
||||
|
||||
### B. Módulo de Matriz de Conflictos y Validación
|
||||
* `POST /api/v1/schedules/validate`:
|
||||
* **Payload:** `{ commission_id, classroom_id, day_of_week, start_time, end_time, teacher_ids, student_ids }`
|
||||
* **Respuesta:** `{ valid: boolean, conflicts: [ { type: 'PHYSICAL'|'TEACHER'|'STUDENT_DAILY', message, details } ] }`
|
||||
|
||||
### C. Módulo de Hitos Evaluativos y Libro de Calificaciones
|
||||
* `GET /api/v1/commissions/{id}/milestones`: Listado de exámenes, entregas y parciales.
|
||||
* `POST /api/v1/commissions/{id}/milestones`: Creación de hito evaluativo.
|
||||
* `GET /api/v1/commissions/{id}/gradebook`: Matriz completa de alumnos y notas de la comisión.
|
||||
* `PUT /api/v1/commissions/{id}/gradebook`: Carga masiva o individual de calificaciones por docente.
|
||||
|
||||
### D. Modo Impersonación de Superadmin
|
||||
* Cabecera HTTP: `X-Impersonate-User: {user_id}`
|
||||
* Procesamiento en `authMiddleware`: Si el token emisor corresponde al rol `SUPERADMIN`, el contexto de ejecución adopta los permisos y vistas del usuario destino para auditoría, dejando registro en `audit_logs`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Directrices UI/UX para el Frontend BFF
|
||||
|
||||
1. **Panel de Bedelía:**
|
||||
- Grilla interactiva semanal (tipo TimeGrid) donde los bloques de comisiones se visualizan codificados por color según edificio, aula física o aula virtual.
|
||||
- Detección inmediata de colisiones con carteles de advertencia flotantes antes de confirmar cambios.
|
||||
2. **Matriz de Carga Rápida para Docentes:**
|
||||
- Tabla editable en línea (*spreadsheet-like*) para volcar notas de parciales y entregas sin recargar la página, con autoguardado asíncrono.
|
||||
3. **Perspectiva del Alumno:**
|
||||
- Interfaz simplificada con diseño enfocado en dispositivos móviles, mostrando en la tarjeta de inicio la clase del momento con botón directo para ingresar a la videollamada o indicador de piso y aula física.
|
||||
|
||||
---
|
||||
|
||||
## 8. Conclusión del Diagnóstico
|
||||
|
||||
El sistema ha superado con éxito la fase de desacople inicial y estabilización de virtualidad:
|
||||
- Se logró paridad total con `legacy_admin-edu-space` en importación de cronograma y cartelera.
|
||||
- Se eliminaron las fallas de colisión virtual y se establecieron los 4 dashboards por rol.
|
||||
- Se completó el acople de la ficha de reserva (`schedule.view_reservation`) y la creación asistida de comisiones en el BFF.
|
||||
|
||||
Las siguientes etapas se concentrarán en la consolidación de las reglas académicas complejas (co-docencia, restricción diaria de cursada, matriz de conflictos y libro de calificaciones) detalladas en el **`ROADMAP_MVP.md`**.
|
||||
@@ -1,378 +1,119 @@
|
||||
# Panel de Administración Edu-Space (UniCABA)
|
||||
|
||||
Plataforma integral de gestión áulica, asignación de espacios, control de horarios y reservas académicas para la **Universidad de la Ciudad de Buenos Aires (UniCABA)**, desarrollada con **Python / Flask**, **SQLAlchemy**, **PostgreSQL** y **Bootstrap 5**.
|
||||
|
||||
---
|
||||
|
||||
## 🏛️ Sede Institucional y Ubicaciones
|
||||
* **Sede Central UniCABA:** Tte. Gral. Juan Domingo Perón 802, Ciudad Autónoma de Buenos Aires (CABA) — Aulas de grado, auditorio principal y laboratorios distribuidos desde Planta Baja (Piso 0) hasta el 4.° Piso.
|
||||
* **Campus Virtual:** Espacio áulico digital integrado (Microsoft Teams / Moodle) configurado con **capacidad ilimitada ($\infty$)**.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Características Principales
|
||||
|
||||
### 🛡️ 1. Control de Accesos RBAC Granular (Matriz estilo FortiGate)
|
||||
* **Gestión por Módulos:** Permisos independientes por cada área del sistema:
|
||||
1. **Aulas y Espacios:** Creación, edición, capacidad y estado de aulas físicas y virtuales.
|
||||
2. **Edificios y Sedes:** Administración de sedes, pisos y arquitectura edilicia.
|
||||
3. **Reservas de Aulas:** Solicitud, aprobación, confirmación y cancelación.
|
||||
4. **Cartelera y Cronograma:** Calendario interactivo, visualización diaria y comisiones.
|
||||
5. **Gestión Académica:** Carreras, asignaturas, códigos y comisiones.
|
||||
6. **Sincronización Sheets:** Importador y sincronizador de Google Sheets.
|
||||
7. **Optimizador IA:** Algoritmo genético de distribución y resolución de conflictos.
|
||||
8. **Usuarios y Accesos RBAC:** Cuentas, roles y matriz de permisos.
|
||||
* **Niveles de Permiso Granulares:** `Ninguno`, `Solo Lectura` o `Lectura y Escritura`.
|
||||
* **Controles Rápidos:** Botón *"Establecer Todos"* para configuración masiva inmediata.
|
||||
* **Roles Predefinidos:** `Admin` (control total), `Docente`, `Operador` y `Consulta`.
|
||||
|
||||
### 📊 2. Sincronización e Importación desde Google Sheets
|
||||
* Integración directa con la cartelera académica de UniCABA en Google Sheets.
|
||||
* Extracción e importación normalizada de los 5 campos clave:
|
||||
* **Código Académico:** Códigos reales de asignatura (ej. `INGA0003`, `ASIG00165`).
|
||||
* **Asignatura:** Nombre oficial de la materia.
|
||||
* **Aula:** Mapeo a aulas físicas (PB a 4.° piso) o aula `VIRTUAL` ($\infty$).
|
||||
* **Carrera:** Normalización y vinculación relacional con entidades de carrera.
|
||||
* **Horarios y Turnos:** Detección automática de franjas horarias y delimitadores de bloque (`Turno Mañana` y `Turno Tarde / Vespertino`).
|
||||
|
||||
### 📅 3. Calendario Interactivo de Reservas (FullCalendar)
|
||||
* **Visualización Semanal, Mensual y Diaria:** Grilla ágil de alta legibilidad con jerarquía visual:
|
||||
* Asignatura destacada en tipografía grande y negrita.
|
||||
* Badges con código de materia, aula asignada, turno y franja horaria.
|
||||
* **Filtros Dinámicos Combinables:**
|
||||
* Filtrar por **Aula**.
|
||||
* Filtrar por **Materia / Asignatura**.
|
||||
* Filtrar por **Carrera**.
|
||||
* Filtrar por **Turno** (`Turno Mañana` / `Turno Tarde`).
|
||||
* Filtrar por **Piso / Planta** (`Planta Baja`, `Piso 1`, `Piso 2`, `Piso 3`, `Piso 4`).
|
||||
* **Switch "Ocultar Aulas Virtuales":** Aísla la ocupación física real en el edificio central excluyendo los eventos virtuales de capacidad infinita.
|
||||
* **Ficha Modal Instantánea:** Vista previa detallada al hacer clic en cualquier evento.
|
||||
|
||||
### 📋 4. Cartelera del Día y Listado de Reservas
|
||||
* **Cartelera del Día (`/schedule/today`):**
|
||||
* **Vista Cronológica (Timeline):** Bloques horarios de 07:00 a 22:00 hs con indicador de ocupación.
|
||||
* **Vista en Tabla:** Lista ordenada con acciones rápidas y detalles de comisión.
|
||||
* Filtros directos por piso, turno y switch para ocultar virtuales.
|
||||
* **Listado Paginado de Reservas (`/schedule/list`):**
|
||||
* Paginación de 20 registros por página preservando filtros aplicados.
|
||||
* Búsqueda por texto, estado (`Confirmada`, `Pendiente`, `Cancelada`), rango de fechas y piso.
|
||||
|
||||
### 🏫 5. Gestión de Aulas con Capacidad Infinita ($\infty$)
|
||||
* Soporte nativo para el símbolo **`∞`** o `0` para designar aulas virtuales o espacios sin aforo restringido.
|
||||
* Botón de un solo clic `Ilimitada (∞)` en formularios de alta y edición con leyendas explicativas.
|
||||
* Badges distintivos `∞ Ilimitada` en listados, tarjetas y fichas técnicas.
|
||||
|
||||
### 🤖 6. Optimizador Inteligente de Aulas (Algoritmo Genético)
|
||||
* Motor de optimización heurística para asignación automática de aulas a comisiones.
|
||||
* Pondera capacidad requerida vs. capacidad de aula, equipamiento tecnológico y minimización de desplazamientos entre pisos.
|
||||
|
||||
### 🎨 7. Experiencia de Usuario, Temas y Localización
|
||||
* **Tema Oscuro / Claro:** Alternador con botón flotante y persistencia en `localStorage` y perfil de usuario.
|
||||
* **Soporte Multilingüe:** Internacionalización integral (Español e Inglés) compilada con GNU gettext (`pybabel`).
|
||||
* **Tarjetas Estadísticas Interactivas:** Accesos directos rápidos desde el panel general hacia las distintas secciones operativas.
|
||||
|
||||
### 🏛️ 8. Sistema Integral de Gestión Institucional (8 Módulos)
|
||||
El menú desplegable **Gestión** en la barra de navegación organiza las áreas operativas de la universidad:
|
||||
* **Espacios e Infraestructura:**
|
||||
* **Edificios y Sedes (`/buildings/list`):** Administración de edificios físicos, arquitectura y estado edilicio.
|
||||
* **Aulas Presenciales (`/classrooms/list?view_mode=physical`):** Catálogo de aulas físicas por piso, capacidades y zócalos compactos con métricas de ocupación en tiempo real (día, semana, mes).
|
||||
* **Aulas Virtuales con Reunión (`/classrooms/list?view_mode=virtual`):** Campus Virtual con salas a demanda agrupadas por comisión, capacidad ilimitada ($\infty$) y enlaces directos de acceso a videollamadas (Zoom, Google Meet, Microsoft Teams).
|
||||
* **Gestión de Ocupación y Métricas (`/admin/occupancy-metrics`):** Dashboard analítico de tasas de ocupación horaria (día, semana, mes), horas pico, distribución por turno y comparativa física vs. virtual.
|
||||
* **Gestión Académica:**
|
||||
* **Carreras (`/admin/careers`):** ABM de carreras universitarias y planes de estudio con contador de asignaturas y comisiones activas.
|
||||
* **Asignaturas (`/admin/subjects`):** Catálogo institucional de materias con créditos, departamentos y filtros por carrera y estado.
|
||||
* **Comisiones y Cursadas (`/schedule/commissions`):** Gestión de cursadas, turnos, docentes, enlaces virtuales e hitos de evaluación.
|
||||
* **Ciclo Lectivo (`/admin/academic-terms`):** Modelo y panel de administración de períodos académicos, cuatrimestres y activación del ciclo lectivo en curso.
|
||||
* **Administración & Seguridad:**
|
||||
* **Usuarios y Roles RBAC:** Cuentas, asignación de permisos matriciales y auditoría.
|
||||
* **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.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Stack Tecnológico
|
||||
|
||||
| Componente | Tecnología |
|
||||
| :--- | :--- |
|
||||
| **Backend** | Python 3.12+ / Flask |
|
||||
| **Base de Datos** | PostgreSQL 16+ / SQLAlchemy ORM |
|
||||
| **Seguridad & Sesiones** | Flask-Login / Werkzeug / CSRF Protect |
|
||||
| **Formularios** | Flask-WTF / WTForms |
|
||||
| **Internacionalización** | Flask-Babel / GNU gettext |
|
||||
| **Frontend** | Bootstrap 5.3, Bootstrap Icons, Vanilla JS, CSS Variables |
|
||||
| **Calendario** | FullCalendar 5.11 |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Despliegue en Proxmox VE (LXC) con Entorno Virtual `venv`
|
||||
|
||||
El sistema está especialmente optimizado para ejecutarse en contenedores **LXC (Debian 12 Bookworm o Ubuntu 22.04/24.04 LTS)** dentro de **Proxmox VE**, utilizando un entorno virtual nativo de Python (`venv`), el motor **PostgreSQL** instalado en el propio contenedor y el servidor WSGI **Gunicorn** gestionado como servicio por **systemd**.
|
||||
|
||||
---
|
||||
|
||||
### 📋 Paso 1: Creación del Contenedor LXC en Proxmox VE
|
||||
|
||||
Podés crear el contenedor desde la interfaz Web de Proxmox (*Create CT*) o por terminal Proxmox con `pct create`:
|
||||
|
||||
* **Plantilla (Template):** `debian-12-standard` (*Recomendado*) o `ubuntu-22.04-standard` / `ubuntu-24.04-standard`.
|
||||
* **CPU:** 1 a 2 vCPU.
|
||||
* **Memoria RAM:** 1024 MB a 2048 MB (Swap: 512 MB).
|
||||
* **Disco:** 8 GB a 15 GB (almacenamiento `local-lvm` o `zfs`).
|
||||
* **Red (Network):** Modo Bridge `vmbr0` con IP estática en tu red local o DHCP (ej: `192.168.1.150/24`, Gateway: `192.168.1.1`).
|
||||
* **Opciones del Contenedor:**
|
||||
* *Unprivileged container:* Sí (marcado por defecto, compatible).
|
||||
* *Features (Características):* Habilitar **Nesting (`nesting=1`)** en `CT > Opciones > Características` (requerido para systemd).
|
||||
|
||||
---
|
||||
|
||||
### ⚡ Paso 2: Instalación Automatizada con `install.sh`
|
||||
|
||||
1. **Ingresar a la consola del contenedor LXC:**
|
||||
Desde la Web UI de Proxmox (consola noVNC) o desde el shell de tu nodo Proxmox:
|
||||
```bash
|
||||
pct enter <ID_DEL_CONTENEDOR>
|
||||
```
|
||||
|
||||
2. **Clonar el repositorio y lanzar el instalador:**
|
||||
```bash
|
||||
apt update && apt install -y git
|
||||
git clone https://gitea.oemspot.com.ar/carlostellocba/admin-edu-space.git
|
||||
cd admin-edu-space
|
||||
bash install.sh
|
||||
```
|
||||
|
||||
3. **Flujo de Ejecución Automatizado en el LXC:**
|
||||
* **Migración segura fuera de `/root`:** Si el repositorio se clonó en `/root`, el instalador lo traslada automáticamente a `/opt/admin-edu-space` para garantizar que el usuario del servicio (`eduspace`) tenga acceso de lectura y ejecución.
|
||||
* **Locales UTF-8:** Configura `en_US.UTF-8` y `es_AR.UTF-8` para asegurar la internacionalización con `Flask-Babel` y prevenir errores de encoding.
|
||||
* **Motor PostgreSQL:** Instala el motor PostgreSQL, inicia el servicio y verifica conectividad activa con bucle de espera.
|
||||
* **Restauración del Snapshot Oficial ([classrooms_db.sql](file:///c:/Workspace/admin-edu-space/classrooms_db.sql)):**
|
||||
* Detecta el volcado completo de datos.
|
||||
* Crea la base de datos `classrooms_db` y el usuario `eduspace_user`.
|
||||
* Restaura **25 aulas** (físicas y Campus Virtual $\infty$), **206 comisiones**, **206 materias**, **412 reservas históricas**, **24 carreras**, matrices RBAC y cuentas de acceso.
|
||||
* Otorga privilegios totales (`GRANT ALL`) en el esquema `public`, todas las tablas y **todas las secuencias** al usuario `eduspace_user`.
|
||||
* Ejecuta `init_db.py` de forma idempotente para validar roles y coherencia.
|
||||
* **Entorno Virtual Python (`venv`):** Crea el entorno aislado en `/opt/admin-edu-space/venv` e instala todas las dependencias oficiales (`Flask`, `SQLAlchemy`, `Gunicorn`, etc.).
|
||||
* **Servicio Systemd de Producción:** Configura el servicio `admin-edu-space.service` gestionado por **Gunicorn** (3 workers, puerto 5000), con reinicio automático ante fallos y habilitado al inicio del contenedor.
|
||||
|
||||
---
|
||||
|
||||
### 🌐 Paso 3: Acceso Inmediato a la Aplicación
|
||||
|
||||
Al finalizar la instalación, el script mostrará la dirección IP del contenedor:
|
||||
* **URL:** **`http://<IP-DEL-LXC>:5000`** (ejemplo: `http://192.168.1.150:5000`)
|
||||
* **Email Administrador:** `admin@edu-space.com`
|
||||
* **Contraseña Administrador:** `admin123`
|
||||
|
||||
---
|
||||
|
||||
### 🛠️ Comandos de Gestión del Servicio en el LXC
|
||||
|
||||
```bash
|
||||
# Consultar estado en tiempo real
|
||||
systemctl status admin-edu-space
|
||||
|
||||
# Seguir logs de la aplicación
|
||||
journalctl -u admin-edu-space -f
|
||||
|
||||
# Reiniciar la aplicación
|
||||
systemctl restart admin-edu-space
|
||||
|
||||
# Detener el servicio
|
||||
systemctl stop admin-edu-space
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 💾 Generación de Backups y Migración a Otros Contenedores LXC
|
||||
|
||||
Para respaldar la base de datos en ejecución o clonarla a otro contenedor LXC:
|
||||
|
||||
```bash
|
||||
# Dentro del contenedor LXC:
|
||||
cd /opt/admin-edu-space
|
||||
bash backup_db.sh
|
||||
```
|
||||
|
||||
* El script genera un archivo fechado en `backups/classrooms_db_YYYYMMDD_HHMMSS.sql`.
|
||||
* Actualiza automáticamente el archivo principal [classrooms_db.sql](file:///c:/Workspace/admin-edu-space/classrooms_db.sql).
|
||||
* **Para migrar a otro contenedor:** Podés transferir `classrooms_db.sql` mediante SCP o Git y ejecutar `bash install.sh` en el nuevo LXC.
|
||||
|
||||
---
|
||||
|
||||
### 💻 Instalación Manual para Desarrollo Local (con `venv`)
|
||||
|
||||
Si deseás correr el proyecto en una máquina de desarrollo (Windows / Linux / macOS) sin usar el instalador de Proxmox:
|
||||
|
||||
1. **Crear y activar el entorno virtual:**
|
||||
* En Windows (PowerShell):
|
||||
```powershell
|
||||
python -m venv venv
|
||||
.\venv\Scripts\Activate.ps1
|
||||
```
|
||||
* En Linux / macOS:
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
2. **Instalar dependencias:**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. **Configurar base de datos local y restaurar snapshot:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
|
||||
# Crear base de datos en PostgreSQL local y cargar datos
|
||||
sudo -u postgres psql -c "CREATE DATABASE classrooms_db;"
|
||||
sudo -u postgres psql -d classrooms_db < classrooms_db.sql
|
||||
```
|
||||
|
||||
4. **Compilar catálogos de traducción:**
|
||||
```bash
|
||||
pybabel compile -d translations
|
||||
```
|
||||
|
||||
5. **Iniciar el servidor:**
|
||||
```bash
|
||||
python wsgi.py
|
||||
```
|
||||
Disponible en: **`http://localhost:5000`**
|
||||
|
||||
---
|
||||
|
||||
## 🔑 Credenciales Predeterminadas
|
||||
|
||||
| Usuario | Correo Electrónico | Contraseña | Rol |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Administrador del Sistema** | `admin@edu-space.com` | `admin123` | `Admin` (Acceso Total) |
|
||||
|
||||
> ⚠️ **Seguridad:** Cambiá la contraseña del administrador desde la interfaz web inmediatamente después del primer inicio de sesión.
|
||||
|
||||
---
|
||||
|
||||
## 📁 Estructura del Proyecto
|
||||
|
||||
```text
|
||||
admin-edu-space/
|
||||
├── app/
|
||||
│ ├── __init__.py # Factoría de la aplicación Flask y extensiones
|
||||
│ ├── forms/ # Formularios Flask-WTF con validación CSRF
|
||||
│ │ ├── auth.py # Login, perfil y cambio de clave
|
||||
│ │ ├── building.py # Sedes y edificios
|
||||
│ │ ├── classroom.py # Aulas y capacidades (con soporte ∞)
|
||||
│ │ └── reservation.py # Reservas de aulas y horarios
|
||||
│ ├── models/ # Modelos relacionales SQLAlchemy
|
||||
│ │ ├── academic_term.py # Ciclos lectivos y períodos académicos (activo/histórico)
|
||||
│ │ ├── building.py # Sedes y edificios institucionales
|
||||
│ │ ├── career.py # Carreras y titulaciones universitarias
|
||||
│ │ ├── classroom.py # Aulas físicas y virtuales con capacidades
|
||||
│ │ ├── genetic_algorithm.py # Estructuras de optimización heurística
|
||||
│ │ ├── milestone.py # Tipificaciones de hitos académicos y exámenes
|
||||
│ │ ├── reservation.py # Reservas horarias, estados y turnos
|
||||
│ │ ├── 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
|
||||
│ ├── 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
|
||||
│ │ ├── 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
|
||||
│ │ ├── 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
|
||||
│ ├── static/ # Recursos estáticos web (CSS, JS, imágenes institucionales)
|
||||
│ └── templates/ # Vistas Jinja2 renderizadas con Bootstrap 5
|
||||
├── config/ # Configuraciones de entorno (desarrollo, testing, prod)
|
||||
├── translations/ # Catálogos GNU gettext compilados (es / en)
|
||||
├── classrooms_db.sql # Snapshot completo de base de datos (PostgreSQL 14+)
|
||||
├── install.sh # Instalador automatizado para Proxmox VE LXC (venv + PostgreSQL)
|
||||
├── backup_db.sh # Script para generar nuevos backups y actualizar snapshot
|
||||
├── init_db.py # Inicializador DDL y sincronizador de esquema/roles
|
||||
├── CHANGELOG_SESSION.md # Historial técnico cronológico
|
||||
├── requirements.txt # Dependencias Python (incluye Gunicorn)
|
||||
├── .env.example # Plantilla de variables de entorno
|
||||
└── wsgi.py # Punto de entrada de la aplicación Flask
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Roadmap Académico & Propuesta de Evolución Tecnológica
|
||||
|
||||
En respuesta a los requerimientos y sugerencias docentes, se formaliza el plan de evolución arquitectónica, estándares de seguridad y entrega de valor institucional para la plataforma **Admin Edu-Space (UniCABA)**.
|
||||
|
||||
### 💡 1. Propuesta de Mejora Integral
|
||||
|
||||
#### A. Arquitectura MVC Rigurosa & Clean Architecture
|
||||
* **Desacoplamiento de Capas:**
|
||||
* **Model (Dominio):** Entidades relacionales puras y reglas de integridad del negocio desacopladas del framework web (`app/models/`).
|
||||
* **View (Presentación):** Vistas web en Jinja2 con Bootstrap 5 y respuestas JSON estructuradas para clientes API/móviles.
|
||||
* **Controller (Controladores/Blueprints):** Controladores delgados en `app/routes/` enfocados exclusivamente en la orquestación HTTP y redirección.
|
||||
* **Service Layer & Repositories (`app/services/` & `app/repositories/`):** Aislamiento de la lógica de negocio (detección de colisiones, algoritmo genético de asignación, ETL de Google Sheets) y operaciones de persistencia encapsuladas.
|
||||
* **DTOs y Validación de Entrada:** Incorporación de esquemas tipados (Pydantic / Marshmallow) para filtrar y sanitizar datos de entrada antes de interactuar con el ORM, eliminando validaciones ad-hoc en los controladores.
|
||||
|
||||
#### B. Autenticación & Autorización Stateless con JWT (JSON Web Tokens)
|
||||
* **Arquitectura API-First Híbrida:**
|
||||
* Coexistencia armónica de sesiones web seguras (para el panel administrativo en navegadores) y tokens **JWT** (para microservicios, clientes móviles y aplicaciones externas).
|
||||
* **Esquema Dual de Tokens (Dual Token Pattern):**
|
||||
* `Access Token` (firmado con RS256/HS256, expiración corta de 15 minutos) con claims contextuales (`sub`, `roles`, `permissions`, `exp`).
|
||||
* `Refresh Token` (almacenado en cookie segura `HttpOnly`, `Secure`, `SameSite=Strict`, expiración de 7 días) para renovación silenciosa sin reingreso de credenciales.
|
||||
* **Mecanismo de Revocación Inmediata (Token Blacklist):** Almacenamiento en caché distribuida (Redis) para invalidación instantánea ante cierres de sesión forzados o rotación de contraseñas.
|
||||
|
||||
#### C. Seguridad Empresarial y Paradigma "Sprint / Spring Security"
|
||||
* **Cadena de Filtros de Interceptación (SecurityFilterChain):** Pipeline centralizado inspirado en los estándares de Spring Security:
|
||||
* **Authentication Filter:** Intercepta cabeceras `Authorization: Bearer <token>` y valida firmas criptográficas.
|
||||
* **Security Headers Filter:** Inyección obligatoria de cabeceras de blindaje HTTP (HSTS, CSP, X-Frame-Options: DENY, X-Content-Type-Options: nosniff).
|
||||
* **Rate Limiting Filter:** Prevención de ataques de fuerza bruta y DDoS en endpoints sensibles mediante límites dinámicos (`Flask-Limiter` / Redis).
|
||||
* **Seguridad Declarativa a Nivel Método (`@PreAuthorize` / `@require_permission`):**
|
||||
* Decoradores reutilizables que evalúan la matriz RBAC antes de ejecutar cualquier método de servicio:
|
||||
```python
|
||||
@require_permission('classrooms', min_level='read_write')
|
||||
def update_classroom_capacity(classroom_id, new_capacity): ...
|
||||
```
|
||||
* **Bitácora Inmutable de Auditoría (Audit Trail):** Registro trazable de eventos críticos (creación/modificación de reservas, cambios de roles y accesos fallidos) con IP de origen, usuario actuante, timestamp y delta de cambios.
|
||||
|
||||
---
|
||||
|
||||
### 📅 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. |
|
||||
|
||||
---
|
||||
|
||||
### 💎 3. Valor Agregado & Retorno Institucional
|
||||
|
||||
1. **Eficiencia Edilicia y Reducción del Desperdicio de Espacio:**
|
||||
- Maximiza el aprovechamiento de las aulas físicas de la Sede Central (**Tte. Gral. Juan Domingo Perón 802**), pasando de una asignación manual y empírica a un modelo algorítmico basado en capacidad real, accesibilidad de pisos y equipamiento.
|
||||
2. **Ahorro de Tiempo Operativo (85% de reducción):**
|
||||
- Automatiza la resolución de conflictos de solapamiento horario en horarios pico (Turno Vespertino, 17:30 a 21:30 hs) y agiliza la sincronización con la cartelera de Google Sheets en lote.
|
||||
3. **Interoperabilidad Universal:**
|
||||
- La arquitectura desacoplada y la capa JWT permiten que cualquier otro sistema de la universidad (SIU Guaraní, Campus Virtual Moodle, App de estudiantes, backend Spring Boot institucional) consulte en tiempo real la disponibilidad áulica.
|
||||
4. **Despliegue de Alta Eficiencia (Bajo Costo de Infraestructura):**
|
||||
- A diferencia de arquitecturas pesadas basadas en contenedores Docker o Kubernetes que demandan múltiples gigabytes de RAM, **Admin Edu-Space corre de forma nativa en un contenedor Proxmox VE LXC consumiendo menos de 180MB de memoria**, garantizando alta disponibilidad con costos de hardware mínimos.
|
||||
|
||||
---
|
||||
|
||||
### 🧩 4. Desafíos Técnicos (Challenges)
|
||||
|
||||
* **Challenge 1: Resolución Heurística de Horarios Áulicos (NP-Hard Problem):**
|
||||
- La asignación de 206 asignaturas, 400+ reservas y 25 aulas considerando restricciones duras (no colisión horaria, capacidad no superada) y blandas (mismo docente en pisos cercanos, turnos consolidados) representa un problema de complejidad NP-Hard resuelto mediante un **Algoritmo Genético** con operadores de cruce y mutación adaptativa.
|
||||
* **Challenge 2: Sincronización Masiva Tolerante a Fallos:**
|
||||
- El parser ETL de Google Sheets normaliza datos no estructurados de la cartelera académica, resolviendo discrepancias ortográficas, turnos implícitos y formatos dispares de hora sin interrumpir la operación continua.
|
||||
* **Challenge 3: Portabilidad Extrema en Proxmox LXC sin Docker:**
|
||||
- Desarrollo de un instalador auto-recuperable ([install.sh](file:///c:/Workspace/admin-edu-space/install.sh)) capaz de levantar PostgreSQL nativo en Debian 12 y 13 dentro de `tmpfs`, gestionar clusters con `pg_createcluster`, operar de forma autónoma con o sin `sudo`, y garantizar persistencia íntegra de base de datos entre dispositivos.
|
||||
|
||||
---
|
||||
|
||||
## 📄 Licencia
|
||||
|
||||
Este proyecto forma parte del sistema de gestión institucional y educativa de **UniCABA**. Todos los derechos reservados.
|
||||
␍
|
||||
# Panel de Administración Edu-Space (UniCABA)
|
||||
|
||||
Plataforma integral de gestión áulica, asignación de espacios, control de horarios y reservas académicas para la **Universidad de la Ciudad de Buenos Aires (UniCABA)**.
|
||||
|
||||
El proyecto se basa en una arquitectura **desacoplada (Frontend / Backend)** en formato monorepo.
|
||||
|
||||
---
|
||||
|
||||
## 🏛️ Sede Institucional y Ubicaciones
|
||||
* **Sede Central UniCABA:** Tte. Gral. Juan Domingo Perón 802, Ciudad Autónoma de Buenos Aires (CABA) — Aulas de grado, auditorio principal y laboratorios distribuidos desde Planta Baja (Piso 0) hasta el 4.° Piso.
|
||||
* **Campus Virtual:** Espacio áulico digital integrado (Microsoft Teams / Moodle) configurado con **capacidad ilimitada ($\infty$)**.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Arquitectura del Proyecto (Monorepo)
|
||||
|
||||
El repositorio está dividido en dos aplicaciones principales que interactúan mediante una API REST y un patrón Backend-For-Frontend (BFF).
|
||||
|
||||
```text
|
||||
/
|
||||
├── backend/ # API REST Backend (Python / Flask)
|
||||
│ ├── app/ # Lógica de negocio, base de datos y endpoints
|
||||
│ ├── tests/ # Pruebas unitarias e integración
|
||||
│ └── requirements.txt # Dependencias de Python
|
||||
│
|
||||
├── frontend/ # Backend-For-Frontend (Node.js / Express)
|
||||
│ ├── src/ # Controladores, middleware, servicios (Axios)
|
||||
│ ├── views/ # Plantillas Nunjucks (.html)
|
||||
│ ├── public/ # Archivos estáticos (CSS, JS cliente)
|
||||
│ └── package.json # Dependencias de Node.js
|
||||
│
|
||||
├── docs/ # Historial de cambios y documentación técnica
|
||||
└── unicaba_prerequitos/ # Requisitos académicos y de negocio
|
||||
```
|
||||
|
||||
### 🐍 Backend (Python + Flask)
|
||||
- Funciona exclusivamente como una **REST API** que expone endpoints bajo `/api/v1/` retornando **JSON**.
|
||||
- Encargado de la persistencia de datos (SQLAlchemy / PostgreSQL), lógica de negocio dura, JWT auth service, seguridad, y algoritmos de optimización (Módulo Predictivo).
|
||||
- **Prohibido** retornar HTML o utilizar Jinja2/`render_template` en este entorno.
|
||||
|
||||
### 🟢 Frontend BFF (Node.js + Express)
|
||||
- Aplicación de servicio que renderiza las vistas para el cliente mediante **Nunjucks**.
|
||||
- Gestiona la sesión segura del usuario almacenando el token JWT recibido de Flask en una **Cookie HttpOnly**.
|
||||
- Actúa como proxy de conexión con el Backend de Python empleando **Axios** (inyectando el Bearer token interceptado).
|
||||
- Utiliza **Chart.js** y JS vanilla en el lado del cliente para visualizaciones interactivas.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Dashboards Personalizados por Rol
|
||||
|
||||
La plataforma cuenta con paneles adaptados a las necesidades operativas de cada perfil institucional:
|
||||
|
||||
* **Administrador (`/dashboard?role=admin`):** Cockpit institucional con métricas globales, auditoría de seguridad RBAC, tendencias mensuales y panel interactivo de **Estado y Percentiles de Asignación de Aulas Físicas** (asignadas vs disponibles en número y porcentaje).
|
||||
* **Bedelía (`/dashboard?role=bedelia`):** Control operativo del campus en tiempo real, balance de aulas presenciales asignadas (96.3%) y libres (3.7%), cartelera del día y bandeja de aprobación de reservas pendientes.
|
||||
* **Docente / Profesor (`/dashboard?role=docente`):** Sesiones del día con accesos directos a videollamadas y gestión de comisiones.
|
||||
* **Alumno / Estudiante (`/dashboard?role=alumno`):** Brújula de cursada *"¿Dónde curso hoy?"* con ubicación por piso y aula o enlace virtual, junto al cronograma de exámenes y entregas de TPs.
|
||||
|
||||
> **Modo Vista Previa:** Los usuarios con perfil administrador o directivo pueden previsualizar cualquier perspectiva utilizando el selector de roles en la barra superior o pasando el parámetro `?role={admin|bedelia|docente|alumno}` en la URL.
|
||||
|
||||
---
|
||||
|
||||
## ☁️ Sincronización Google Sheets, Configuración Global & Purga Académica
|
||||
|
||||
El sistema incluye sincronización automatizada con las planillas oficiales de cursada de UniCABA:
|
||||
* **Configuración Global Dinámica:** Selección y persistencia de la URL de Google Sheets directamente desde la interfaz web (`/admin/google_sheets_import`) sin valores fijos en el código.
|
||||
* **Herramienta de Purga y Reinicio (`reset_academic_data`):** Módulo CLI y web para limpiar selectivamente reservas, aulas, asignaturas, comisiones o carreras, garantizando pruebas limpias desde cero y preservando siempre usuarios y credenciales.
|
||||
* **Concurrencia Virtual Ilimitada:** Reconocimiento de espacios áulicos remotos (`Campus Virtual · VIRTUAL`), permitiendo la coexistencia de múltiples comisiones en la misma franja horaria sin colisiones de agenda.
|
||||
* **Catálogo Presencial con Cursadas Asignadas:** Listado de aulas físicas con cálculo de ocupación real (semana/mes), vista previa de asignaturas y enlaces directos a calendario y ficha del espacio.
|
||||
* **Enlaces de Videollamada Automáticos:** Generación de enlaces seguros (Google Meet / Teams) para acceso instantáneo de docentes y estudiantes.
|
||||
* **Filtros en Cartelera:** Opciones para filtrar por turno, piso u ocultar aulas virtuales mediante el switch dedicado en `/schedule/today_schedule`.
|
||||
|
||||
---
|
||||
|
||||
## 📅 Ficha de Reserva & Gestión de Comisiones
|
||||
|
||||
* **Ficha de Reserva Detallada (`/schedule/view_reservation?id={id}`):** Información completa del espacio físico o virtual asignado, capacidad y asistencia prevista, comisión académica vinculada, docente a cargo, botones de acción inmediata (*Modificar Reserva*, *Confirmar Reserva*, *Cancelar Reserva*) y acceso con un clic a la sala de videollamada.
|
||||
* **Gestión de Comisiones y Vinculación con Asignaturas (`/admin/commissions_list`):**
|
||||
- Botón y modal interactivo **Nueva Comisión** (`#modalAddCommission`) para registrar comisiones y vincularlas directamente a su **Asignatura / Materia**, indicando cuatrimestre, año, turno, cupo y docente responsable.
|
||||
- Generación automática de enlace institucional de Google Meet cuando no se especifica un enlace propio.
|
||||
- Tabla operativa con visualización explícita del código de comisión, nombre de materia, código de asignatura y carrera.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Instalación y Despliegue Local
|
||||
|
||||
### Requisitos previos
|
||||
- Node.js (v18 o superior)
|
||||
- Python 3.10+
|
||||
- PostgreSQL / SQLite
|
||||
|
||||
### 1. Iniciar el Backend (Flask)
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv venv
|
||||
# Activar venv (Windows: venv\Scripts\activate, Linux/Mac: source venv/bin/activate)
|
||||
pip install -r requirements.txt
|
||||
flask run --port=5000
|
||||
```
|
||||
*(El backend quedará escuchando en http://localhost:5000)*
|
||||
|
||||
### 2. Iniciar el Frontend (Node/Express)
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
# o en su defecto:
|
||||
node src/app.js
|
||||
```
|
||||
*(El frontend quedará escuchando en http://localhost:3000)*
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentación Técnica & Changelogs
|
||||
* [Estado de la Migración y Arquitectura Objetivo UniCABA](ESTADO_MIGRACION_Y_ARQUITECTURA_OBJETIVO.md)
|
||||
* [Roadmap MVP: Fases y Estadios del Proyecto](ROADMAP_MVP.md)
|
||||
* [Changelog Oficial del Proyecto](CHANGELOG.md)
|
||||
* [Changelog Detallado: Clases Virtuales y Dashboards](docs/CHANGELOG_VIRTUAL_CLASSES_AND_DASHBOARDS.md)
|
||||
* [Auditoría y Sesión de Reingeniería](docs/CHANGELOG_SESSION.md)
|
||||
* [Guía de Integración de Cronograma](docs/SCHEDULE_INTEGRATION_GUIDE.md)
|
||||
@@ -0,0 +1,131 @@
|
||||
# ROADMAP MVP — Sistema Integral UniCABA
|
||||
## Gestión de Espacios Áulicos, Actividad de Cursada y Seguimiento Académico
|
||||
|
||||
**Institución:** Universidad de la Ciudad de Buenos Aires (UniCABA)
|
||||
**Versión:** 2.1.0
|
||||
**Fecha de Actualización:** 19 de Septiembre de 2026
|
||||
**Rama Base:** `testing`
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Visión del Proyecto
|
||||
Construir y evolucionar la plataforma de gestión académica y espacial de **UniCABA** hacia un sistema integral de clase universitaria, desacoplado en una arquitectura **Backend RESTful (Python/Flask) + Frontend BFF (Node.js/Express/Nunjucks)**, capaz de gobernar tanto los espacios físicos de la sede central como la dinámica áulica del campus virtual, garantizando una experiencia fluida para **Administradores**, **Bedelía**, **Docentes** y **Estudiantes**.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Estado Global de Progreso
|
||||
|
||||
```
|
||||
Fase 0: Desacople y Arquitectura Base [████████████████████] 100% (Completado)
|
||||
Fase 1: Paridad Legacy y Estabilización [████████████████████] 100% (Completado)
|
||||
Fase 2: Co-docencia y Matriz Conflictos [░░░░░░░░░░░░░░░░░░░░] 0% (Próxima)
|
||||
Fase 3: Hitos Evaluativos y Calificaciones[░░░░░░░░░░░░░░░░░░░░] 0% (Planificada)
|
||||
Fase 4: UI/UX Drag & Drop e Impersonación[░░░░░░░░░░░░░░░░░░░░] 0% (Planificada)
|
||||
Fase 5: Despliegue Producción e Integrac.[░░░░░░░░░░░░░░░░░░░░] 0% (Planificada)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Detalle de Fases y Estadios
|
||||
|
||||
### Fase 0: Desacople y Arquitectura Base ✅ *(Completada)*
|
||||
* **Objetivo:** Separar el monolito Jinja2 legacy en dos servicios independientes de alto rendimiento.
|
||||
* **Entregables:**
|
||||
* [x] Creación del Backend Flask como API REST pura bajo `/api/v1/` retornando exclusivamente JSON.
|
||||
* [x] Implementación del Frontend BFF en Node.js + Express con renderizado de plantillas mediante Nunjucks.
|
||||
* [x] Autenticación desacoplada mediante JWT (`access_token`) transportado en Cookie `HttpOnly` segura.
|
||||
* [x] Pipeline de seguridad `SecurityFilterChain` con cabeceras de blindaje OWASP y rate limiting dinámico.
|
||||
* [x] Cliente HTTP Axios centralizado con inyección automática de Bearer token y manejo de sesiones.
|
||||
|
||||
---
|
||||
|
||||
### Fase 1: Paridad Legacy, Estabilización de Virtualidad y BFF Core ✅ *(Completada)*
|
||||
* **Objetivo:** Alcanzar paridad funcional total con la versión legacy, subsanando defectos críticos de virtualidad y dotando de interfaz específica a cada rol.
|
||||
* **Entregables:**
|
||||
* [x] **Importador de Google Sheets Corregido:**
|
||||
* Eliminación de la colisión de horarios en el aula virtual única (`Campus Virtual · VIRTUAL`).
|
||||
* Incremento de reservas registradas de 363 a 643 (incorporando 280 clases virtuales que se descartaban).
|
||||
* Generación y persistencia automática de enlaces de Google Meet institucional por comisión y reserva.
|
||||
* [x] **Dashboards Especializados por Rol:**
|
||||
* Dashboard interactivo para **Bedelía** (aulas físicas, salas virtuales, aprobaciones pendientes).
|
||||
* Dashboard interactivo para **Docente** (clases del día, comisiones, enlaces directos a Meet).
|
||||
* Dashboard interactivo para **Alumno** (brújula de cursada *"¿Dónde curso hoy?"*, hitos y calendario).
|
||||
* Cockpit institucional para **Admin** (métricas de capacidad, auditoría RBAC, demanda mensual).
|
||||
* Selector dinámico de vista previa / roles en tiempo real (`?role=...`).
|
||||
* [x] **Cartelera del Día (`today_schedule`):**
|
||||
* Grilla temporal por bloques horarios (07:00 a 21:00 hs) con indicadores físicos y virtuales.
|
||||
* Filtro dinámico para ocultar clases virtuales (`hide_virtual=1`) y segmentación por piso o turno.
|
||||
* [x] **Acople de Ficha de Reserva (`schedule.view_reservation`):**
|
||||
* Ruteo en BFF (`/schedule/view_reservation?id=...` y `/view/:id`).
|
||||
* Endpoint REST `/api/v1/reservations/<id>` con enriquecimiento relacional (aula, comisión, docente).
|
||||
* Soporte para modificar reserva (`edit_reservation`) con selectores en cascada, confirmación y cancelación.
|
||||
* [x] **Gestión de Comisiones (`admin/commissions_list`):**
|
||||
* Botón y modal interactivo `+ Nueva Comisión` (`#modalAddCommission`).
|
||||
* Vinculación obligatoria y explícita a la **Asignatura / Materia**, indicando cuatrimestre, año lectivo, turno, docente a cargo, cupo y enlace virtual.
|
||||
* Visualización en tabla de la materia vinculada con su código y carrera asociada.
|
||||
|
||||
---
|
||||
|
||||
### Fase 2: Co-docencia, Matriz de Conflictos y Regla Diaria ⏳ *(En curso / Inmediata)*
|
||||
* **Objetivo:** Implementar las reglas de negocio académicas complejas para asignación de cátedras y cursada de alumnos.
|
||||
* **Estadios:**
|
||||
1. **Estadio 2.1 — Modelo de Co-docencia:**
|
||||
- [ ] Tabla relacional `commission_teachers` para soportar múltiples docentes por comisión (titulares, adjuntos, ayudantes).
|
||||
- [ ] Endpoints `/api/v1/commissions/<id>/teachers` para asociar y desvincular docentes de cátedra.
|
||||
- [ ] Adaptación de vistas de Bedelía y Comisiones para visualizar el equipo docente completo.
|
||||
2. **Estadio 2.2 — Matriz de Conflictos y Validación:**
|
||||
- [ ] Validación estricta de conflicto físico: bloqueo de doble asignación de aula en mismo día y horario.
|
||||
- [ ] Validación de conflicto docente: detección de superposición horaria del profesor, con soporte de excepción justificada cuando la comisión cuenta con co-docencia.
|
||||
- [ ] Validación de aforo: bloqueo cuando la cantidad de alumnos excede la capacidad del aula física.
|
||||
3. **Estadio 2.3 — Regla de Restricción Diaria del Alumno:**
|
||||
- [ ] Validación al matricular: un alumno no puede cursar dos asignaturas regulares en el mismo día calendario.
|
||||
- [ ] Excepción parametrizable: autorización automática si al menos una de las materias es un "curso corto" o si existe autorización manual de Bedelía (`allow_same_day_exception`).
|
||||
|
||||
---
|
||||
|
||||
### Fase 3: Gestión de Hitos Evaluativos y Libro de Calificaciones 📅 *(Planificada)*
|
||||
* **Objetivo:** Proveer al cuerpo docente y estudiantil el seguimiento completo de instancias evaluativas.
|
||||
* **Estadios:**
|
||||
1. **Estadio 3.1 — Calendario de Evaluación por Comisión:**
|
||||
- [ ] Programación de parciales, recuperatorios, entregas de trabajos prácticos y exámenes finales.
|
||||
- [ ] Notificaciones preventivas en el dashboard del alumno con cuenta regresiva para exámenes.
|
||||
2. **Estadio 3.2 — Libro de Calificaciones (*Gradebook*):**
|
||||
- [ ] Matriz de carga rápida de notas para docentes con autoguardado asíncrono.
|
||||
- [ ] Consulta individualizada de calificaciones y retroalimentaciones pedagógicas para alumnos.
|
||||
- [ ] Cierre de actas de regularidad y promoción para Bedelía.
|
||||
|
||||
---
|
||||
|
||||
### Fase 4: UI/UX Avanzada para Bedelía & Modo Impersonación 🖥️ *(Planificada)*
|
||||
* **Objetivo:** Dotar a la oficina de Bedelía y a la Administración de herramientas interactivas de última generación.
|
||||
* **Estadios:**
|
||||
1. **Estadio 4.1 — Grilla Semanal Interactiva (Drag-and-Drop):**
|
||||
- [ ] Interfaz tipo TimeGrid interactiva con arrastre de comisiones para asignación rápida de aulas y turnos.
|
||||
- [ ] Detección visual en tiempo real de aulas ocupadas o incompatibles.
|
||||
2. **Estadio 4.2 — Modo Impersonación del Superadmin:**
|
||||
- [ ] Funcionalidad de *login as* o vista previa con cabecera `X-Impersonate-User` para replicar exactamente lo que ve cualquier docente o alumno.
|
||||
- [ ] Trazabilidad obligatoria de acciones bajo impersonación en el registro de auditoría (`audit_logs`).
|
||||
3. **Estadio 4.3 — Integración del Optimizador Genético:**
|
||||
- [ ] Acople del motor predictivo de algoritmos genéticos a la base de datos real para sugerir redistribución óptima de aulas según aforo.
|
||||
|
||||
---
|
||||
|
||||
### Fase 5: Producción Institucional, CI/CD e Integraciones 🏢 *(Planificada)*
|
||||
* **Objetivo:** Puesta en marcha definitiva en la infraestructura de servidores de UniCABA.
|
||||
* **Estadios:**
|
||||
1. **Estadio 5.1 — Despliegue e Infraestructura:**
|
||||
- [ ] `docker-compose.yml` productivo orquestando Flask (Gunicorn), Node.js BFF (PM2) y PostgreSQL 15.
|
||||
- [ ] Proxy inverso Nginx con certificados SSL/TLS y compresión Brotli/Gzip.
|
||||
2. **Estadio 5.2 — Integraciones Universitarias:**
|
||||
- [ ] Módulo de exportación/importación compatible con SIU Guaraní 3.
|
||||
- [ ] Sincronización automática de aulas virtuales con Moodle institucional.
|
||||
3. **Estadio 5.3 — Pruebas de Carga y Hardening:**
|
||||
- [ ] Pruebas de estrés de concurrencia en días pico de inicio de cuatrimestre (10.000 solicitudes simultáneas).
|
||||
|
||||
---
|
||||
|
||||
## 📈 Criterios de Aceptación del MVP
|
||||
1. **Zero Colisiones Físicas:** Imposibilidad absoluta de asignar dos materias simultáneas a una misma aula física.
|
||||
2. **Virtualidad Fluida:** Acceso con un clic a reuniones virtuales válidas para el 100% de las comisiones remotas.
|
||||
3. **Roles Seguros:** Separación total de permisos donde ningún alumno o docente acceda a configuración global ni a datos privados de otras comisiones.
|
||||
4. **Respuesta Sub-segundo:** Carga de carteleras y calendarios en menos de 300 ms en el BFF Node.js.
|
||||
@@ -1,112 +0,0 @@
|
||||
from flask import Flask, request, session
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
from flask_login import LoginManager
|
||||
from flask_cors import CORS
|
||||
from flask_babel import Babel, gettext as _
|
||||
from config.config import Config
|
||||
|
||||
# Initialize extensions
|
||||
db = SQLAlchemy()
|
||||
migrate = Migrate()
|
||||
login_manager = LoginManager()
|
||||
cors = CORS()
|
||||
babel = Babel()
|
||||
|
||||
def create_app(config_class=Config):
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(config_class)
|
||||
|
||||
# Initialize extensions with app
|
||||
db.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
login_manager.init_app(app)
|
||||
cors.init_app(app)
|
||||
|
||||
# Login manager configuration
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.login_message = 'Por favor inicia sesión para acceder a esta página.'
|
||||
login_manager.login_message_category = 'info'
|
||||
|
||||
# Import models to ensure they are registered
|
||||
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
|
||||
|
||||
app.register_blueprint(auth_bp, url_prefix="/")
|
||||
app.register_blueprint(classrooms_bp, url_prefix="/classrooms")
|
||||
app.register_blueprint(buildings_bp)
|
||||
app.register_blueprint(main_bp, url_prefix="/")
|
||||
app.register_blueprint(schedule_bp, url_prefix="/schedule")
|
||||
app.register_blueprint(genetic_bp)
|
||||
app.register_blueprint(genetic_web_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
|
||||
# Language selector function
|
||||
def get_locale():
|
||||
# 1. Parámetro explícito en la URL (?lang=es o ?lang=en)
|
||||
lang = request.args.get('lang')
|
||||
if lang and lang in app.config['LANGUAGES']:
|
||||
session['language'] = lang
|
||||
return lang
|
||||
|
||||
# 2. Si el usuario está autenticado y tiene idioma configurado en su perfil, prevalece en toda la sesión
|
||||
from flask_login import current_user
|
||||
if current_user.is_authenticated and getattr(current_user, 'preferred_language', None):
|
||||
if current_user.preferred_language in app.config['LANGUAGES']:
|
||||
return current_user.preferred_language
|
||||
|
||||
# 3. En la pantalla de Login (usuario no autenticado):
|
||||
# El idioma predeterminado del login debe ser español o respetar el idioma del navegador.
|
||||
# No debe verse afectado por cookies o sesiones residuales de otros usuarios.
|
||||
is_login = (request.endpoint == 'auth.login' or request.path.rstrip('/') == '/login')
|
||||
if is_login:
|
||||
# Solo si el usuario cambió el idioma explícitamente en el selector del login
|
||||
if session.get('login_lang_explicit') in app.config['LANGUAGES']:
|
||||
return session['login_lang_explicit']
|
||||
|
||||
# Detectar según el navegador del usuario (soporta es-419, es-AR, es-ES, etc.)
|
||||
if request.accept_languages:
|
||||
for accepted_lang, _quality in request.accept_languages:
|
||||
primary = accepted_lang.split('-')[0].lower()
|
||||
if primary in app.config['LANGUAGES']:
|
||||
return primary
|
||||
|
||||
# Idioma predeterminado por defecto del login: Español
|
||||
return 'es'
|
||||
|
||||
# 4. Para otras rutas: verificar si hay idioma en sesión (ej. selector manual)
|
||||
if 'language' in session and session['language'] in app.config['LANGUAGES']:
|
||||
return session['language']
|
||||
|
||||
# 5. Detección automática del navegador por defecto (soporta subtags regionales)
|
||||
if request.accept_languages:
|
||||
for accepted_lang, _quality in request.accept_languages:
|
||||
primary = accepted_lang.split('-')[0].lower()
|
||||
if primary in app.config['LANGUAGES']:
|
||||
return primary
|
||||
|
||||
# Fallback del sistema: Español
|
||||
return app.config['BABEL_DEFAULT_LOCALE']
|
||||
|
||||
|
||||
# Babel context processor
|
||||
@app.context_processor
|
||||
def inject_conf_vars():
|
||||
return dict(
|
||||
languages=app.config['LANGUAGES'],
|
||||
current_lang=str(get_locale())
|
||||
)
|
||||
|
||||
|
||||
# Configure Babel
|
||||
babel.init_app(app, locale_selector=get_locale)
|
||||
|
||||
# Register localized date and datetime formatters for Jinja2 templates
|
||||
from flask_babel import format_date, format_datetime
|
||||
app.jinja_env.globals['format_date'] = format_date
|
||||
app.jinja_env.globals['format_datetime'] = format_datetime
|
||||
app.jinja_env.filters['format_date'] = format_date
|
||||
app.jinja_env.filters['format_datetime'] = format_datetime
|
||||
|
||||
return app
|
||||
@@ -1,158 +0,0 @@
|
||||
from flask import Blueprint, render_template, jsonify, redirect, url_for, session, request, flash
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.reservation import Reservation
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.subject import Subject
|
||||
from app.models.subject import Commission
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import func
|
||||
from flask_babel import gettext as _
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
@main_bp.route('/')
|
||||
@login_required
|
||||
def dashboard():
|
||||
"""Main dashboard page"""
|
||||
# Get statistics
|
||||
try:
|
||||
total_classrooms = Classroom.query.filter_by(is_active=True).count()
|
||||
except:
|
||||
total_classrooms = 0
|
||||
|
||||
try:
|
||||
total_subjects = Subject.query.filter_by(is_active=True).count()
|
||||
except:
|
||||
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:
|
||||
today_reservations = 0
|
||||
|
||||
# Get this week's reservations
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
week_end = week_start + timedelta(days=6)
|
||||
try:
|
||||
week_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) >= week_start,
|
||||
func.date(Reservation.start_time) <= week_end
|
||||
).count()
|
||||
except:
|
||||
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,
|
||||
'today_reservations': today_reservations,
|
||||
'week_reservations': week_reservations
|
||||
}
|
||||
|
||||
return render_template('dashboard.html',
|
||||
stats=stats,
|
||||
upcoming_reservations=upcoming_reservations)
|
||||
|
||||
@main_bp.route('/api/dashboard-stats')
|
||||
@login_required
|
||||
def dashboard_stats_api():
|
||||
"""API endpoint for dashboard statistics"""
|
||||
# Get today's data
|
||||
today = datetime.utcnow().date()
|
||||
today_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) == today
|
||||
).count()
|
||||
|
||||
# Get this week's data
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
week_end = week_start + timedelta(days=6)
|
||||
week_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) >= week_start,
|
||||
func.date(Reservation.start_time) <= week_end
|
||||
).count()
|
||||
|
||||
# Get monthly data for chart
|
||||
current_date = datetime.utcnow()
|
||||
monthly_data = []
|
||||
|
||||
for i in range(12):
|
||||
month_date = current_date - timedelta(days=30 * i)
|
||||
month_start = month_date.replace(day=1)
|
||||
month_end = (month_start + timedelta(days=32)).replace(day=1) - timedelta(days=1)
|
||||
|
||||
monthly_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) >= month_start.date(),
|
||||
func.date(Reservation.start_time) <= month_end.date()
|
||||
).count()
|
||||
|
||||
monthly_data.append({
|
||||
'month': month_start.strftime('%Y-%m'),
|
||||
'reservations': monthly_reservations
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'today_reservations': today_reservations,
|
||||
'week_reservations': week_reservations,
|
||||
'monthly_data': list(reversed(monthly_data))
|
||||
})
|
||||
|
||||
@main_bp.route('/genetic-optimizer')
|
||||
@login_required
|
||||
def genetic_optimizer():
|
||||
"""Serve the genetic algorithm optimization interface"""
|
||||
if not (current_user.has_permission('optimizer', 'read') or (current_user.role and current_user.role.upper() == 'ADMIN')):
|
||||
flash(_('No tienes permisos para acceder a la herramienta de optimización.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
return render_template('genetic_optimizer.html')
|
||||
|
||||
@main_bp.route('/set_language/<language>')
|
||||
def set_language(language=None):
|
||||
if language not in ['en', 'es']:
|
||||
language = 'es'
|
||||
|
||||
session['language'] = language
|
||||
|
||||
# Si se selecciona el idioma desde la pantalla de login, registrar la selección explícita
|
||||
if request.referrer and 'login' in request.referrer:
|
||||
session['login_lang_explicit'] = language
|
||||
|
||||
if current_user.is_authenticated:
|
||||
current_user.preferred_language = language
|
||||
db.session.commit()
|
||||
|
||||
return redirect(request.referrer or url_for('main.dashboard'))
|
||||
|
||||
|
||||
@main_bp.route('/api/milestone-types')
|
||||
def api_milestone_types():
|
||||
"""Retorna las tipificaciones de hitos activas en JSON para consumo de frontend/mobile"""
|
||||
from app.models.milestone import MilestoneType, BASE_MILESTONE_TYPES
|
||||
if MilestoneType.query.count() == 0:
|
||||
for item in BASE_MILESTONE_TYPES:
|
||||
mt = MilestoneType(
|
||||
name=item['name'],
|
||||
code=item['code'],
|
||||
color=item['color'],
|
||||
description=item['description'],
|
||||
sort_order=item['sort_order'],
|
||||
is_active=True
|
||||
)
|
||||
db.session.add(mt)
|
||||
db.session.commit()
|
||||
|
||||
types = MilestoneType.query.filter_by(is_active=True).order_by(MilestoneType.sort_order.asc(), MilestoneType.id.asc()).all()
|
||||
return jsonify([t.to_dict() for t in types])␍
|
||||
@@ -1,3 +0,0 @@
|
||||
from .sheets_importer import GoogleSheetsImporter
|
||||
|
||||
__all__ = ['GoogleSheetsImporter']
|
||||
@@ -1,242 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% trans %}Import from Google Sheets{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-1">
|
||||
<i class="bi bi-file-earmark-spreadsheet text-success me-2"></i>{% trans %}Google Sheets Academic Sync{% endtrans %}
|
||||
</h1>
|
||||
<p class="text-muted small mb-0">{% trans %}Extract and synchronize classrooms, careers, subjects, and weekly schedules directly from the official Google Sheet.{% endtrans %}</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('main.dashboard') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>{% trans %}Dashboard{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metrics Cards -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-sm-6 col-xl-2">
|
||||
<div class="card border-0 shadow-sm text-center p-3 h-100">
|
||||
<div class="text-primary mb-1"><i class="bi bi-mortarboard fs-3"></i></div>
|
||||
<div class="h4 mb-0 fw-bold">{{ metrics.total_careers }}</div>
|
||||
<small class="text-muted">{% trans %}Careers{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-2">
|
||||
<div class="card border-0 shadow-sm text-center p-3 h-100">
|
||||
<div class="text-info mb-1"><i class="bi bi-book fs-3"></i></div>
|
||||
<div class="h4 mb-0 fw-bold">{{ metrics.total_subjects }}</div>
|
||||
<small class="text-muted">{% trans %}Subjects{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-2">
|
||||
<div class="card border-0 shadow-sm text-center p-3 h-100">
|
||||
<div class="text-success mb-1"><i class="bi bi-door-open fs-3"></i></div>
|
||||
<div class="h4 mb-0 fw-bold">{{ metrics.total_classrooms }}</div>
|
||||
<small class="text-muted">{% trans %}Classrooms{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-3">
|
||||
<div class="card border-0 shadow-sm text-center p-3 h-100">
|
||||
<div class="text-warning mb-1"><i class="bi bi-people fs-3"></i></div>
|
||||
<div class="h4 mb-0 fw-bold">{{ metrics.total_commissions }}</div>
|
||||
<small class="text-muted">{% trans %}Commissions{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-3">
|
||||
<div class="card border-0 shadow-sm text-center p-3 h-100">
|
||||
<div class="text-danger mb-1"><i class="bi bi-calendar-check fs-3"></i></div>
|
||||
<div class="h4 mb-0 fw-bold">{{ metrics.total_reservations }}</div>
|
||||
<small class="text-muted">{% trans %}Calendar Classes{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sync Action Banner -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body p-4">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-lg-8 mb-3 mb-lg-0">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle px-3 py-1 me-2 fw-semibold">
|
||||
<i class="bi bi-cloud-check me-1"></i>Google Sheets Live Integration
|
||||
</span>
|
||||
<span class="badge bg-light text-muted border">UniCABA 2026</span>
|
||||
</div>
|
||||
<h4 class="h5 mb-2 fw-bold">{% trans %}Official Schedule Board (Cartelera de Aulas){% endtrans %}</h4>
|
||||
<p class="text-muted small mb-2">
|
||||
{% trans %}Reads published CSV sheets for all 6 days of the week, automatically creating or updating academic careers, classrooms, subject codes, commissions, and weekly calendar entries without duplicating records.{% endtrans %}
|
||||
</p>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
{% for s in metrics.sheets %}
|
||||
<span class="badge bg-body-tertiary text-secondary border small">
|
||||
<i class="bi bi-calendar-event me-1 text-primary"></i>{{ s.name }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 text-lg-end">
|
||||
<form method="POST" id="syncForm" onsubmit="startSync()">
|
||||
<button type="submit" id="syncBtn" class="btn btn-success btn-lg px-4 py-2 shadow fw-semibold">
|
||||
<i class="bi bi-arrow-repeat me-2"></i>{% trans %}Sync Data Now{% endtrans %}
|
||||
</button>
|
||||
</form>
|
||||
<div id="syncSpinner" class="d-none mt-2 text-muted small">
|
||||
<div class="spinner-border spinner-border-sm text-success me-1" role="status"></div>
|
||||
{% trans %}Connecting to Google Sheets and processing records...{% endtrans %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sync Results (if just completed) -->
|
||||
{% if stats %}
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-success bg-opacity-10 text-success py-3">
|
||||
<h5 class="card-title h6 mb-0 fw-bold">
|
||||
<i class="bi bi-check-circle-fill me-2"></i>{% trans %}Synchronization Report{% endtrans %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3 text-center mb-3">
|
||||
<div class="col-md-2">
|
||||
<div class="p-2 border rounded bg-body-tertiary">
|
||||
<div class="fw-bold fs-5">{{ stats.total_rows_processed }}</div>
|
||||
<small class="text-muted">{% trans %}Rows Processed{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="p-2 border rounded bg-body-tertiary">
|
||||
<div class="fw-bold fs-5 text-primary">+{{ stats.careers_created }}</div>
|
||||
<small class="text-muted">{% trans %}Careers Created{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="p-2 border rounded bg-body-tertiary">
|
||||
<div class="fw-bold fs-5 text-success">+{{ stats.classrooms_created }}</div>
|
||||
<small class="text-muted">{% trans %}Classrooms Created{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="p-2 border rounded bg-body-tertiary">
|
||||
<div class="fw-bold fs-5 text-info">+{{ stats.subjects_created }}</div>
|
||||
<small class="text-muted">{% trans %}Subjects Created{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="p-2 border rounded bg-body-tertiary">
|
||||
<div class="fw-bold fs-5 text-warning">+{{ stats.commissions_created }}</div>
|
||||
<small class="text-muted">{% trans %}Commissions Created{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="p-2 border rounded bg-body-tertiary">
|
||||
<div class="fw-bold fs-5 text-danger">+{{ stats.reservations_created }}</div>
|
||||
<small class="text-muted">{% trans %}Reservations Created{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if stats.errors %}
|
||||
<div class="alert alert-warning mb-0 small">
|
||||
<h6 class="alert-heading fw-bold mb-1">{% trans %}Warnings / Errors detected:{% endtrans %}</h6>
|
||||
<ul class="mb-0 ps-3">
|
||||
{% for err in stats.errors %}
|
||||
<li>{{ err }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Database Preview Table -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-body-tertiary d-flex justify-content-between align-items-center py-3">
|
||||
<h5 class="card-title h6 mb-0">
|
||||
<i class="bi bi-table text-primary me-2"></i>{% trans %}Recently Synchronized Subjects & Comissions{% endtrans %}
|
||||
</h5>
|
||||
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-calendar3 me-1"></i>{% trans %}View Calendar{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="bg-body-tertiary">
|
||||
<tr>
|
||||
<th class="ps-4">{% trans %}Code{% endtrans %}</th>
|
||||
<th>{% trans %}Subject Name{% endtrans %}</th>
|
||||
<th>{% trans %}Career{% endtrans %}</th>
|
||||
<th>{% trans %}Commissions{% endtrans %}</th>
|
||||
<th>{% trans %}Status{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in recent_subjects %}
|
||||
<tr>
|
||||
<td class="ps-4">
|
||||
<span class="badge bg-secondary-subtle text-secondary font-monospace border">
|
||||
{{ s.code }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-semibold">{{ s.name }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-brand small">
|
||||
{{ s.career_obj.name if s.career_obj else (s.department or _('General')) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="small text-muted">
|
||||
{{ s.commissions|length }} {% trans %}commission(s){% endtrans %}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if s.is_active %}
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle small">
|
||||
<i class="bi bi-check-circle me-1"></i>{% trans %}Active{% endtrans %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary small">{% trans %}Inactive{% endtrans %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-cloud-arrow-down fs-1 d-block mb-2 text-secondary"></i>
|
||||
{% trans %}No subjects in database yet. Click "Sync Data Now" to import.{% endtrans %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function startSync() {
|
||||
const btn = document.getElementById('syncBtn');
|
||||
const spinner = document.getElementById('syncSpinner');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status"></span>Sincronizando...';
|
||||
}
|
||||
if (spinner) {
|
||||
spinner.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,115 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ current_lang }}" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% trans %}Login - Edu-Space Admin{% endtrans %}</title>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="{{ url_for('static', filename='img/favicon.png') }}">
|
||||
|
||||
<!-- Script Anti-Flicker para Modo Claro / Oscuro -->
|
||||
<script>
|
||||
(function() {
|
||||
const storedTheme = localStorage.getItem('theme');
|
||||
const theme = storedTheme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Bootstrap 5 CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<!-- Design System Theme -->
|
||||
<link href="{{ url_for('static', filename='css/theme.css') }}" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-wrapper">
|
||||
<div class="auth-card">
|
||||
<!-- Header con Controles Superiores & Logo Oficial -->
|
||||
<div class="auth-header">
|
||||
<div class="auth-controls">
|
||||
<button class="theme-toggle-btn" type="button" aria-label="Cambiar tema" title="Modo Claro/Oscuro">
|
||||
<i class="bi bi-moon-stars-fill"></i>
|
||||
</button>
|
||||
{% include 'partials/language_switcher.html' %}
|
||||
</div>
|
||||
|
||||
<img src="{{ url_for('static', filename='img/logo_unicaba.png') }}" alt="UniCABA" class="auth-logo">
|
||||
<h4 class="fw-bold mb-1">Edu-Space Admin</h4>
|
||||
<p class="text-muted small mb-0">{% trans %}Classroom Management System{% endtrans %}</p>
|
||||
</div>
|
||||
|
||||
<div class="px-4 pb-4 pt-2">
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show rounded-3 py-2 small" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close py-2" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" novalidate>
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label small fw-semibold text-muted">{{ form.email.label.text }}</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-body-tertiary border-end-0 text-muted">
|
||||
<i class="bi bi-envelope"></i>
|
||||
</span>
|
||||
<input type="email" class="form-control border-start-0 ps-0" id="email" name="email"
|
||||
value="{{ form.email.data or '' }}" required
|
||||
placeholder="{% trans %}Enter your email{% endtrans %}">
|
||||
</div>
|
||||
{% for error in form.email.errors %}
|
||||
<div class="text-danger small mt-1">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label small fw-semibold text-muted">{{ form.password.label.text }}</label>
|
||||
<div class="input-group">
|
||||
<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"
|
||||
required placeholder="{% trans %}Enter your password{% endtrans %}">
|
||||
</div>
|
||||
{% for error in form.password.errors %}
|
||||
<div class="text-danger small mt-1">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-4 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">
|
||||
<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>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<small class="text-muted d-inline-flex align-items-center">
|
||||
<i class="bi bi-shield-check text-primary me-1"></i>
|
||||
{% trans %}Secure Login • All access is logged{% endtrans %}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,221 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ current_lang }}" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}{% trans %}Edu-Space Admin{% endtrans %}{% endblock %}</title>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="{{ url_for('static', filename='img/favicon.png') }}">
|
||||
|
||||
<!-- Script Anti-Flicker para Modo Claro / Oscuro -->
|
||||
<script>
|
||||
(function() {
|
||||
let userTheme = "{{ current_user.theme_preference if (current_user and current_user.is_authenticated) else '' }}";
|
||||
let theme = userTheme;
|
||||
if (!theme || theme === 'auto') {
|
||||
const storedTheme = localStorage.getItem('theme');
|
||||
theme = storedTheme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||
}
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Bootstrap 5 CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Bootstrap Icons -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<!-- Design System & Custom Theme -->
|
||||
<link href="{{ url_for('static', filename='css/theme.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
|
||||
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body data-user-authenticated="{{ 'true' if (current_user and current_user.is_authenticated) else 'false' }}">
|
||||
{% if current_user.is_authenticated %}
|
||||
<!-- Navigation -->
|
||||
<nav class="navbar navbar-expand-lg navbar-dark navbar-app fixed-top">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand d-flex align-items-center" href="{{ url_for('main.dashboard') }}">
|
||||
<img src="{{ url_for('static', filename='img/logo_unicaba_mini.png') }}" alt="Logo UniCABA" class="navbar-logo me-2">
|
||||
<span>Edu-Space Admin</span>
|
||||
</a>
|
||||
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
<ul class="navbar-nav align-items-center">
|
||||
<!-- Theme Mode Toggle Button -->
|
||||
<li class="nav-item me-2">
|
||||
<button class="theme-toggle-btn" type="button" aria-label="Cambiar tema" title="Modo Claro/Oscuro">
|
||||
<i class="bi bi-moon-stars-fill"></i>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<!-- Language Switcher -->
|
||||
<li class="nav-item me-2">
|
||||
{% include 'partials/language_switcher.html' %}
|
||||
</li>
|
||||
|
||||
<!-- User Dropdown with Avatar -->
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}">
|
||||
<i class="bi bi-person me-2"></i> {% trans %}Profile{% endtrans %}
|
||||
</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('auth.change_password') }}">
|
||||
<i class="bi bi-key me-2"></i> {% trans %}Change Password{% endtrans %}
|
||||
</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item text-danger" href="{{ url_for('auth.logout') }}">
|
||||
<i class="bi bi-box-arrow-right me-2"></i> {% trans %}Logout{% endtrans %}
|
||||
</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
<!-- Flash Messages -->
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3" style="z-index: 1080;">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="toast align-items-center text-white bg-{{ 'danger' if category == 'error' else 'success' if category == 'success' else 'info' if category == 'info' else 'warning' }} border-0 shadow" role="alert">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
{{ message }}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
<!-- Bootstrap JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<!-- jQuery -->
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||
<!-- Theme Toggle JS -->
|
||||
<script src="{{ url_for('static', filename='js/theme-toggle.js') }}"></script>
|
||||
<!-- Custom JS -->
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
|
||||
<!-- Show flash messages -->
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
$('.toast').toast('show');
|
||||
});
|
||||
</script>
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,301 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% trans %}Dashboard - Edu-Space Admin{% endtrans %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if current_user.is_authenticated %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
{% endif %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h1 class="h3 mb-1">
|
||||
<i class="bi bi-speedometer2 text-primary me-2"></i>{% trans %}Panel de Control{% endtrans %}
|
||||
</h1>
|
||||
<p class="text-muted small mb-0">{% trans %}Resumen general de espacios, materias y reservas académicas{% endtrans %}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards with Direct Shortcuts -->
|
||||
<div class="row mb-4 g-3">
|
||||
<!-- 1. Total Aulas -->
|
||||
<div class="col-md-6 col-xl-3">
|
||||
<a href="{{ url_for('classrooms.list_classrooms') }}" class="text-decoration-none text-reset d-block h-100">
|
||||
<div class="card border-0 shadow-sm h-100 dashboard-stat-card transition-all">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="text-muted small fw-semibold text-uppercase">{% trans %}Total de Aulas{% endtrans %}</span>
|
||||
<h2 class="mb-0 fw-bold mt-1 text-primary">{{ stats.total_classrooms }}</h2>
|
||||
</div>
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle p-3 text-primary">
|
||||
<i class="bi bi-door-open fs-3"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center justify-content-between pt-3 mt-2 border-top text-primary small fw-semibold">
|
||||
<span>{% trans %}Ver todas las aulas{% endtrans %}</span>
|
||||
<i class="bi bi-arrow-right"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 2. Total Materias -->
|
||||
<div class="col-md-6 col-xl-3">
|
||||
<a href="{{ url_for('schedule.commissions_view') }}" class="text-decoration-none text-reset d-block h-100">
|
||||
<div class="card border-0 shadow-sm h-100 dashboard-stat-card transition-all">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="text-muted small fw-semibold text-uppercase">{% trans %}Total de Materias{% endtrans %}</span>
|
||||
<h2 class="mb-0 fw-bold mt-1 text-success">{{ stats.total_subjects }}</h2>
|
||||
</div>
|
||||
<div class="bg-success bg-opacity-10 rounded-circle p-3 text-success">
|
||||
<i class="bi bi-book fs-3"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center justify-content-between pt-3 mt-2 border-top text-success small fw-semibold">
|
||||
<span>{% trans %}Gestionar materias{% endtrans %}</span>
|
||||
<i class="bi bi-arrow-right"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 3. Reservas de Hoy -->
|
||||
<div class="col-md-6 col-xl-3">
|
||||
<a href="{{ url_for('schedule.today_schedule') }}" class="text-decoration-none text-reset d-block h-100">
|
||||
<div class="card border-0 shadow-sm h-100 dashboard-stat-card transition-all">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="text-muted small fw-semibold text-uppercase">{% trans %}Reservas de Hoy{% endtrans %}</span>
|
||||
<h2 class="mb-0 fw-bold mt-1 text-info">{{ stats.today_reservations }}</h2>
|
||||
</div>
|
||||
<div class="bg-info bg-opacity-10 rounded-circle p-3 text-info">
|
||||
<i class="bi bi-calendar-check fs-3"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center justify-content-between pt-3 mt-2 border-top text-info small fw-semibold">
|
||||
<span>{% trans %}Ver cartelera de hoy{% endtrans %}</span>
|
||||
<i class="bi bi-arrow-right"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 4. Esta Semana -->
|
||||
<div class="col-md-6 col-xl-3">
|
||||
<a href="{{ url_for('schedule.calendar_view') }}" class="text-decoration-none text-reset d-block h-100">
|
||||
<div class="card border-0 shadow-sm h-100 dashboard-stat-card transition-all">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="text-muted small fw-semibold text-uppercase">{% trans %}Esta Semana{% endtrans %}</span>
|
||||
<h2 class="mb-0 fw-bold mt-1 text-warning">{{ stats.week_reservations }}</h2>
|
||||
</div>
|
||||
<div class="bg-warning bg-opacity-10 rounded-circle p-3 text-warning">
|
||||
<i class="bi bi-calendar-week fs-3"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center justify-content-between pt-3 mt-2 border-top text-warning small fw-semibold">
|
||||
<span>{% trans %}Abrir calendario semanal{% endtrans %}</span>
|
||||
<i class="bi bi-arrow-right"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Monthly Chart -->
|
||||
<div class="col-md-8 mb-4">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header border-0 py-3 bg-body-tertiary">
|
||||
<h5 class="mb-0 h6 fw-bold">
|
||||
<i class="bi bi-graph-up text-primary me-2"></i>{% trans %}Reservas Mensuales{% endtrans %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="monthlyChart" height="100"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header border-0 py-3 bg-body-tertiary">
|
||||
<h5 class="mb-0 h6 fw-bold">
|
||||
<i class="bi bi-lightning text-warning me-2"></i>{% trans %}Accesos Rápidos{% endtrans %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-3">
|
||||
<div class="d-grid gap-2">
|
||||
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary text-start">
|
||||
<i class="bi bi-calendar3 me-2"></i>{% trans %}Calendario de Reservas{% endtrans %}
|
||||
</a>
|
||||
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-info text-start">
|
||||
<i class="bi bi-calendar-day me-2"></i>{% trans %}Cartelera del Día{% endtrans %}
|
||||
</a>
|
||||
{% if current_user.has_permission('optimizer', 'read') or (current_user.role and current_user.role|upper == 'ADMIN') %}
|
||||
<a href="{{ url_for('genetic_algorithm_web.genetic_optimizer') }}" class="btn btn-outline-info text-start">
|
||||
<i class="bi bi-cpu me-2"></i>{% trans %}Optimizador de Aulas (IA){% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary text-start">
|
||||
<i class="bi bi-door-open me-2"></i>{% trans %}Listado de Aulas{% endtrans %}
|
||||
</a>
|
||||
<a href="{{ url_for('buildings.list_buildings') }}" class="btn btn-outline-secondary text-start">
|
||||
<i class="bi bi-buildings me-2"></i>{% trans %}Gestión de Edificios{% endtrans %}
|
||||
</a>
|
||||
{% if current_user.has_permission('reservations', 'read_write') %}
|
||||
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-outline-success text-start">
|
||||
<i class="bi bi-calendar-plus me-2"></i>{% trans %}Nueva Reserva{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if current_user.has_permission('classrooms', 'read_write') %}
|
||||
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-outline-primary text-start">
|
||||
<i class="bi bi-plus-circle me-2"></i>{% trans %}Crear Nueva Aula{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upcoming Reservations -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header border-0 py-3 bg-body-tertiary d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0 h6 fw-bold">
|
||||
<i class="bi bi-clock-history text-info me-2"></i>{% trans %}Próximas Reservas (Próximos 7 Días){% endtrans %}
|
||||
</h5>
|
||||
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-sm btn-outline-primary">
|
||||
{% trans %}Ver en Calendario{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if upcoming_reservations %}
|
||||
<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 %}Horario{% endtrans %}</th>
|
||||
<th>{% trans %}Aula{% endtrans %}</th>
|
||||
<th>{% trans %}Asignatura / Código{% endtrans %}</th>
|
||||
<th>{% trans %}Turno / Comisión{% endtrans %}</th>
|
||||
<th>{% trans %}Estado{% endtrans %}</th>
|
||||
<th class="text-end pe-3">{% trans %}Acciones{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for reservation in upcoming_reservations %}
|
||||
<tr>
|
||||
<td class="ps-3">
|
||||
<div class="fw-bold font-monospace text-primary fs-6">
|
||||
<i class="bi bi-clock text-warning me-1"></i>{{ reservation.start_time.strftime('%H:%M') }} - {{ reservation.end_time.strftime('%H:%M') }} hs
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-secondary-subtle text-secondary border font-monospace">
|
||||
{{ reservation.classroom.code if reservation.classroom else 'Sin Aula' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-semibold text-primary">{{ reservation.subject_name }}</div>
|
||||
<small class="text-muted 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>
|
||||
{% 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>
|
||||
{% else %}
|
||||
<span class="badge bg-warning-subtle text-warning border border-warning-subtle">
|
||||
<i class="bi bi-clock me-1"></i>{% trans %}Pendiente{% endtrans %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end pe-3">
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-calendar-x text-muted fs-1 d-block mb-2"></i>
|
||||
<h6 class="fw-bold">{% trans %}No hay reservas para los próximos 7 días{% endtrans %}</h6>
|
||||
<p class="text-muted small">{% trans %}Puedes programar nuevas reservas desde el calendario.{% endtrans %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if current_user.is_authenticated %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Fetch monthly data and create chart
|
||||
$.get('/api/dashboard-stats')
|
||||
.done(function(data) {
|
||||
const ctx = document.getElementById('monthlyChart').getContext('2d');
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: data.monthly_data.map(item => item.month),
|
||||
datasets: [{
|
||||
label: 'Reservations',
|
||||
data: data.monthly_data.map(item => item.reservations),
|
||||
borderColor: '#667eea',
|
||||
backgroundColor: 'rgba(102, 126, 234, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.4
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
stepSize: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,286 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% trans %}Listado de Reservas{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<!-- Header -->
|
||||
<div class="row mb-4 align-items-center">
|
||||
<div class="col-md-7">
|
||||
<h1 class="h3 mb-1">
|
||||
<i class="bi bi-calendar-check text-primary me-2"></i>{% trans %}Listado de Reservas{% endtrans %}
|
||||
</h1>
|
||||
<p class="text-muted small mb-0">{% trans %}Registro histórico y programación completa de clases y asignación de aulas{% endtrans %}</p>
|
||||
</div>
|
||||
<div class="col-md-5 text-md-end mt-3 mt-md-0">
|
||||
{% if current_user.has_permission('reservations', 'read_write') %}
|
||||
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-success me-2">
|
||||
<i class="bi bi-plus-circle me-1"></i>{% trans %}Nueva Reserva{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary me-2">
|
||||
<i class="bi bi-calendar3 me-1"></i>{% trans %}Ver Calendario{% endtrans %}
|
||||
</a>
|
||||
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-info">
|
||||
<i class="bi bi-calendar-day me-1"></i>{% trans %}Cartelera de Hoy{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters Card -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body p-3">
|
||||
<form method="GET" action="{{ url_for('schedule.list_reservations') }}" class="row g-2 align-items-end">
|
||||
<!-- Search text -->
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<label class="form-label small fw-bold text-muted mb-1">
|
||||
<i class="bi bi-search me-1"></i>{% trans %}Buscar{% endtrans %}
|
||||
</label>
|
||||
<input type="text" name="search" class="form-control form-control-sm" placeholder="Materia, código o aula..." value="{{ search or '' }}">
|
||||
</div>
|
||||
|
||||
<!-- Status Filter -->
|
||||
<div class="col-lg-2 col-md-3">
|
||||
<label class="form-label small fw-bold text-muted mb-1">
|
||||
<i class="bi bi-shield-check me-1"></i>{% trans %}Estado{% endtrans %}
|
||||
</label>
|
||||
<select name="status" class="form-select form-select-sm">
|
||||
<option value="all" {% if status_filter == 'all' or not status_filter %}selected{% endif %}>{% trans %}Todos los estados{% endtrans %}</option>
|
||||
<option value="confirmed" {% if status_filter == 'confirmed' %}selected{% endif %}>{% trans %}Confirmadas{% endtrans %}</option>
|
||||
<option value="pending" {% if status_filter == 'pending' %}selected{% endif %}>{% trans %}Pendientes{% endtrans %}</option>
|
||||
<option value="cancelled" {% if status_filter == 'cancelled' %}selected{% endif %}>{% trans %}Canceladas{% endtrans %}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Floor Filter -->
|
||||
<div class="col-lg-2 col-md-3">
|
||||
<label class="form-label small fw-bold text-muted mb-1">
|
||||
<i class="bi bi-layers me-1"></i>{% trans %}Piso{% endtrans %}
|
||||
</label>
|
||||
<select name="floor" class="form-select form-select-sm">
|
||||
<option value="">{% trans %}Todos los pisos{% endtrans %}</option>
|
||||
{% for f in available_floors %}
|
||||
<option value="{{ f }}" {% if floor_filter == f|string %}selected{% endif %}>
|
||||
{% if f == 0 %}{% trans %}Planta Baja{% endtrans %}{% else %}{% trans %}Piso{% endtrans %} {{ f }}{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Aula Filter -->
|
||||
<div class="col-lg-3 col-md-4">
|
||||
<label class="form-label small fw-bold text-muted mb-1">
|
||||
<i class="bi bi-door-open text-primary me-1"></i>{% trans %}Aula / Espacio{% endtrans %}
|
||||
</label>
|
||||
<select name="classroom_id" class="form-select form-select-sm">
|
||||
<option value="">{% trans %}Todas las aulas{% endtrans %}</option>
|
||||
{% for c in all_classrooms %}
|
||||
<option value="{{ c.id }}" {% if classroom_id == c.id %}selected{% endif %}>{{ c.building_name }} - {{ c.code }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Shift Filter -->
|
||||
<div class="col-lg-2 col-md-3">
|
||||
<label class="form-label small fw-bold text-muted mb-1">
|
||||
<i class="bi bi-brightness-high text-warning me-1"></i>{% trans %}Turno{% endtrans %}
|
||||
</label>
|
||||
<select name="shift" class="form-select form-select-sm">
|
||||
<option value="">{% trans %}Todos los turnos{% endtrans %}</option>
|
||||
<option value="Mañana" {% if shift_filter == 'Mañana' %}selected{% endif %}>{% trans %}Turno Mañana{% endtrans %}</option>
|
||||
<option value="Tarde" {% if shift_filter == 'Tarde' %}selected{% endif %}>{% trans %}Turno Tarde{% endtrans %}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Date Range From -->
|
||||
<div class="col-lg-2 col-md-3">
|
||||
<label class="form-label small fw-bold text-muted mb-1">
|
||||
<i class="bi bi-calendar-event me-1"></i>{% trans %}Desde Fecha{% endtrans %}
|
||||
</label>
|
||||
<input type="date" name="date_from" class="form-control form-control-sm" value="{{ date_from or '' }}">
|
||||
</div>
|
||||
|
||||
<!-- Date Range To -->
|
||||
<div class="col-lg-2 col-md-3">
|
||||
<label class="form-label small fw-bold text-muted mb-1">
|
||||
<i class="bi bi-calendar-event me-1"></i>{% trans %}Hasta Fecha{% endtrans %}
|
||||
</label>
|
||||
<input type="date" name="date_to" class="form-control form-control-sm" value="{{ date_to or '' }}">
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Hide Virtual, Submit, Clear -->
|
||||
<div class="col-lg-6 col-md-6 pt-2">
|
||||
<div class="form-check form-switch pt-1">
|
||||
<input class="form-check-input" type="checkbox" role="switch" name="hide_virtual" value="1" id="hideVirtualList" {% if hide_virtual %}checked{% endif %}>
|
||||
<label class="form-check-label fw-semibold small text-dark" for="hideVirtualList">
|
||||
<i class="bi bi-laptop me-1 text-primary"></i>{% trans %}Ocultar Aulas Virtuales{% endtrans %}
|
||||
<span class="badge bg-secondary-subtle text-secondary border ms-1" style="font-size: 0.72rem;">{% trans %}Capacidad ilimitada{% endtrans %}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6 col-md-6 pt-2 text-md-end d-flex justify-content-md-end gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-sm px-3">
|
||||
<i class="bi bi-funnel me-1"></i>{% trans %}Filtrar{% endtrans %}
|
||||
</button>
|
||||
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary btn-sm px-3">
|
||||
<i class="bi bi-arrow-counterclockwise me-1"></i>{% trans %}Limpiar{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reservations Table -->
|
||||
{% 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>
|
||||
<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>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body text-center py-5">
|
||||
<i class="bi bi-calendar-x text-muted fs-1 d-block mb-3"></i>
|
||||
<h5 class="fw-bold">{% trans %}No se encontraron reservas{% endtrans %}</h5>
|
||||
<p class="text-muted small mb-3">{% trans %}No hay registros que coincidan con los criterios de búsqueda o filtros seleccionados.{% endtrans %}</p>
|
||||
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-arrow-counterclockwise me-1"></i>{% trans %}Restablecer Todos los Filtros{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,97 @@
|
||||
from flask import Flask, request, session
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
from flask_cors import CORS
|
||||
from flask_babel import Babel, gettext as _
|
||||
from config.config import Config
|
||||
|
||||
# Initialize extensions
|
||||
db = SQLAlchemy()
|
||||
migrate = Migrate()
|
||||
cors = CORS()
|
||||
babel = Babel()
|
||||
|
||||
def create_app(config_class=Config):
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(config_class)
|
||||
|
||||
# Initialize extensions with app
|
||||
db.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
cors.init_app(app)
|
||||
|
||||
# Import models to ensure they are registered
|
||||
from app.models import user
|
||||
# Register REST API (v1) Blueprints with Stateless JWT support
|
||||
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
|
||||
from app.routes.api.dashboard import api_dashboard_bp
|
||||
from app.routes.api.admin import api_admin_bp
|
||||
from app.security import SecurityFilterChain
|
||||
|
||||
# Register API v1 blueprints
|
||||
app.register_blueprint(api_auth_bp)
|
||||
app.register_blueprint(api_classrooms_bp)
|
||||
app.register_blueprint(api_reservations_bp)
|
||||
app.register_blueprint(api_optimizer_bp)
|
||||
app.register_blueprint(api_dashboard_bp)
|
||||
app.register_blueprint(api_admin_bp, url_prefix='/api/v1')
|
||||
app.register_blueprint(api_admin_bp, url_prefix='/api/v1/admin', name='api_admin_prefixed')
|
||||
|
||||
# Initialize Enterprise Security Pipeline (SecurityFilterChain)
|
||||
SecurityFilterChain(app)
|
||||
|
||||
# Language selector function
|
||||
def get_locale():
|
||||
# 1. Si el usuario está autenticado y tiene idioma configurado explícitamente en su perfil, prevalece.
|
||||
from flask_login import current_user
|
||||
if current_user.is_authenticated:
|
||||
if getattr(current_user, 'preferred_language', None) and current_user.preferred_language in app.config['LANGUAGES']:
|
||||
return current_user.preferred_language
|
||||
# El idioma por defecto siempre será español a menos que se configure a mano
|
||||
return app.config['BABEL_DEFAULT_LOCALE']
|
||||
|
||||
# 2. Parámetro explícito en la URL (?lang=es o ?lang=en) (para usuarios no autenticados o login)
|
||||
lang = request.args.get('lang')
|
||||
if lang and lang in app.config['LANGUAGES']:
|
||||
session['language'] = lang
|
||||
return lang
|
||||
|
||||
# 3. Si ya hay un idioma guardado en la sesión
|
||||
if 'language' in session and session['language'] in app.config['LANGUAGES']:
|
||||
return session['language']
|
||||
|
||||
# 4. Primera vez que se accede a la web (sin sesión): respetar el idioma del navegador
|
||||
if request.accept_languages:
|
||||
for accepted_lang, _quality in request.accept_languages:
|
||||
primary = accepted_lang.split('-')[0].lower()
|
||||
if primary in app.config['LANGUAGES']:
|
||||
session['language'] = primary # Se guarda para la sesión actual
|
||||
return primary
|
||||
|
||||
# Fallback del sistema: Español
|
||||
return app.config['BABEL_DEFAULT_LOCALE']
|
||||
|
||||
|
||||
# Babel context processor
|
||||
@app.context_processor
|
||||
def inject_conf_vars():
|
||||
return dict(
|
||||
languages=app.config['LANGUAGES'],
|
||||
current_lang=str(get_locale())
|
||||
)
|
||||
|
||||
|
||||
# Configure Babel
|
||||
babel.init_app(app, locale_selector=get_locale)
|
||||
|
||||
# Register localized date and datetime formatters for Jinja2 templates
|
||||
from flask_babel import format_date, format_datetime
|
||||
app.jinja_env.globals['format_date'] = format_date
|
||||
app.jinja_env.globals['format_datetime'] = format_datetime
|
||||
app.jinja_env.filters['format_date'] = format_date
|
||||
app.jinja_env.filters['format_datetime'] = format_datetime
|
||||
|
||||
return app
|
||||
@@ -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,9 @@ 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
|
||||
from .enrollment import StudentEnrollment
|
||||
from .setting import SystemSetting
|
||||
|
||||
__all__ = [
|
||||
'User',
|
||||
@@ -27,5 +30,8 @@ __all__ = [
|
||||
'SYSTEM_MODULES',
|
||||
'MilestoneType',
|
||||
'AcademicMilestone',
|
||||
'BASE_MILESTONE_TYPES'
|
||||
'BASE_MILESTONE_TYPES',
|
||||
'AuditLog',
|
||||
'StudentEnrollment',
|
||||
'SystemSetting'
|
||||
]
|
||||
@@ -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'}>"
|
||||
@@ -0,0 +1,49 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class StudentEnrollment(db.Model):
|
||||
"""Association table: student (User) ↔ Commission enrollment managed by Bedelía."""
|
||||
__tablename__ = 'student_enrollments'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
student_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
commission_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('commissions.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
status = db.Column(db.String(20), nullable=False, default='activo') # activo | retirado | condicional
|
||||
enrolled_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Unique constraint: a student can only be enrolled once per commission
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('student_id', 'commission_id', name='uq_student_commission'),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
student = db.relationship('User', backref=db.backref('enrollments', lazy='dynamic', cascade='all, delete-orphan'))
|
||||
commission = db.relationship('Commission', backref=db.backref('enrollments', lazy='dynamic', cascade='all, delete-orphan'))
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'student_id': self.student_id,
|
||||
'student_name': self.student.name if self.student else None,
|
||||
'student_email': self.student.email if self.student else None,
|
||||
'commission_id': self.commission_id,
|
||||
'status': self.status,
|
||||
'enrolled_at': self.enrolled_at.isoformat() if self.enrolled_at else None,
|
||||
'notes': self.notes,
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<StudentEnrollment student={self.student_id} commission={self.commission_id} status={self.status}>'
|
||||
@@ -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 = []
|
||||
@@ -95,8 +95,8 @@ class Reservation(db.Model):
|
||||
@property
|
||||
def is_virtual(self):
|
||||
"""Verifica si la reserva es virtual por aula o por contar con enlace virtual"""
|
||||
if self.classroom and self.classroom.is_virtual:
|
||||
return True
|
||||
if self.classroom:
|
||||
return bool(self.classroom.is_virtual)
|
||||
if self.effective_virtual_link:
|
||||
return True
|
||||
return False
|
||||
@@ -72,6 +72,13 @@ SYSTEM_MODULES = [
|
||||
'name_en': 'Occupancy Metrics',
|
||||
'icon': 'bi-graph-up',
|
||||
'description': 'Visualización y análisis de porcentajes de ocupación por aula (día, semana y mes).'
|
||||
},
|
||||
{
|
||||
'id': 'enrollment',
|
||||
'name': 'Inscripciones y Cursadas',
|
||||
'name_en': 'Enrollments & Courses',
|
||||
'icon': 'bi-diagram-3',
|
||||
'description': 'Gestión de inscripciones de alumnos a comisiones y asignación de docentes.'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
class SystemSetting(db.Model):
|
||||
"""
|
||||
Parámetros de configuración del sistema persistentes en base de datos.
|
||||
Permite almacenar enlaces de integración (e.g. Google Sheets), flags globales y metadatos.
|
||||
"""
|
||||
__tablename__ = 'system_settings'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
key = db.Column(db.String(100), unique=True, nullable=False, index=True)
|
||||
value = db.Column(db.Text, nullable=True)
|
||||
description = db.Column(db.String(255), nullable=True)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
@classmethod
|
||||
def get_value(cls, key, default=None):
|
||||
try:
|
||||
record = cls.query.filter_by(key=key).first()
|
||||
if record and record.value is not None and record.value.strip() != '':
|
||||
return record.value
|
||||
except Exception:
|
||||
pass
|
||||
return default
|
||||
|
||||
@classmethod
|
||||
def set_value(cls, key, value, description=None):
|
||||
record = cls.query.filter_by(key=key).first()
|
||||
if not record:
|
||||
record = cls(key=key, value=value, description=description)
|
||||
db.session.add(record)
|
||||
else:
|
||||
record.value = value
|
||||
if description:
|
||||
record.description = description
|
||||
record.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
return record
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'key': self.key,
|
||||
'value': self.value,
|
||||
'description': self.description,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<SystemSetting {self.key}={self.value}>'
|
||||
@@ -93,6 +93,7 @@ class Commission(db.Model):
|
||||
'active': self.active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
'full_code': self.get_full_code(),
|
||||
'subject': self.subject.to_dict() if self.subject else None
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
from app import db, login_manager
|
||||
from flask_login import UserMixin
|
||||
from app import db
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from datetime import datetime
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
class User(db.Model):
|
||||
__tablename__ = 'users'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
@@ -45,6 +44,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 +93,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
|
||||
@@ -105,9 +142,4 @@ class User(UserMixin, db.Model):
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<User {self.email}>'
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
"""Load user from database by ID"""
|
||||
return User.query.get(int(user_id))
|
||||
return f'<User {self.email}>'
|
||||
@@ -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()
|
||||
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
@@ -5,5 +5,6 @@ from .buildings import buildings_bp
|
||||
from .schedule import schedule_bp
|
||||
from .genetic_algorithm import genetic_bp, genetic_web_bp
|
||||
from .admin import admin_bp
|
||||
from .my_subjects import my_subjects_bp
|
||||
|
||||
__all__ = ['main_bp', 'auth_bp', 'classrooms_bp', 'buildings_bp', 'schedule_bp', 'genetic_bp', 'genetic_web_bp', 'admin_bp']
|
||||
__all__ = ['main_bp', 'auth_bp', 'classrooms_bp', 'buildings_bp', 'schedule_bp', 'genetic_bp', 'genetic_web_bp', 'admin_bp', 'my_subjects_bp']
|
||||
@@ -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,303 @@ 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
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 5. GESTION DE COMISIONES (BEDELIA / ADMIN)
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@admin_bp.route('/commissions')
|
||||
@login_required
|
||||
def commissions_list():
|
||||
"""List all commissions with filters - accessible by Bedelia and Admin"""
|
||||
if not (current_user.has_permission('enrollment', 'read_write') or current_user.is_admin() or current_user.is_bedelia()):
|
||||
abort(403)
|
||||
|
||||
from app.models.subject import Subject, Commission
|
||||
from app.models.user import User
|
||||
|
||||
search = request.args.get('search', '').strip()
|
||||
subject_filter = request.args.get('subject_id', type=int)
|
||||
teacher_filter = request.args.get('teacher_id', type=int)
|
||||
shift_filter = request.args.get('shift', '').strip()
|
||||
active_filter = request.args.get('active', 'true').strip()
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = 20
|
||||
|
||||
query = Commission.query.join(Subject, Commission.subject_id == Subject.id)
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(
|
||||
Commission.code.ilike(f'%{search}%'),
|
||||
Subject.name.ilike(f'%{search}%'),
|
||||
Subject.code.ilike(f'%{search}%'),
|
||||
)
|
||||
)
|
||||
if subject_filter:
|
||||
query = query.filter(Commission.subject_id == subject_filter)
|
||||
if teacher_filter:
|
||||
query = query.filter(Commission.teacher_id == teacher_filter)
|
||||
if shift_filter:
|
||||
query = query.filter(Commission.shift.ilike(f'%{shift_filter}%'))
|
||||
if active_filter in ['true', 'false']:
|
||||
query = query.filter(Commission.active == (active_filter == 'true'))
|
||||
|
||||
pagination = query.order_by(Subject.name.asc(), Commission.code.asc()).paginate(
|
||||
page=page, per_page=per_page, error_out=False
|
||||
)
|
||||
|
||||
subjects = Subject.query.filter_by(is_active=True).order_by(Subject.name.asc()).all()
|
||||
teachers = User.query.filter(
|
||||
or_(User.role.ilike('%DOCENTE%'), User.role.ilike('%docent%'), User.role.ilike('%ADMIN%'))
|
||||
).filter_by(is_active=True).order_by(User.name.asc()).all()
|
||||
|
||||
can_edit = (
|
||||
current_user.has_permission('academic', 'read_write') or
|
||||
current_user.is_admin() or
|
||||
current_user.is_bedelia()
|
||||
)
|
||||
|
||||
return render_template(
|
||||
'admin/commissions/list.html',
|
||||
commissions=pagination.items,
|
||||
pagination=pagination,
|
||||
subjects=subjects,
|
||||
teachers=teachers,
|
||||
search=search,
|
||||
subject_filter=subject_filter,
|
||||
teacher_filter=teacher_filter,
|
||||
shift_filter=shift_filter,
|
||||
active_filter=active_filter,
|
||||
can_edit=can_edit
|
||||
)
|
||||
|
||||
|
||||
@admin_bp.route('/commissions/<int:id>')
|
||||
@login_required
|
||||
def commission_detail(id):
|
||||
"""Detail view of a commission: enrolled students and teacher assignment"""
|
||||
if not (current_user.has_permission('enrollment', 'read_write') or current_user.is_admin() or current_user.is_bedelia()):
|
||||
abort(403)
|
||||
|
||||
from app.models.subject import Commission
|
||||
from app.models.enrollment import StudentEnrollment
|
||||
from app.models.user import User
|
||||
|
||||
commission = Commission.query.get_or_404(id)
|
||||
enrollments = (
|
||||
StudentEnrollment.query
|
||||
.filter_by(commission_id=id)
|
||||
.order_by(StudentEnrollment.enrolled_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
teachers = User.query.filter(
|
||||
or_(User.role.ilike('%DOCENTE%'), User.role.ilike('%docent%'))
|
||||
).filter_by(is_active=True).order_by(User.name.asc()).all()
|
||||
|
||||
students = User.query.filter(
|
||||
or_(User.role.ilike('%ALUMNO%'), User.role.ilike('%alumn%'), User.role.ilike('%estud%'))
|
||||
).filter_by(is_active=True).order_by(User.name.asc()).all()
|
||||
|
||||
enrolled_ids = {e.student_id for e in enrollments}
|
||||
can_edit = (
|
||||
current_user.has_permission('academic', 'read_write') or
|
||||
current_user.is_admin() or
|
||||
current_user.is_bedelia()
|
||||
)
|
||||
|
||||
return render_template(
|
||||
'admin/commissions/detail.html',
|
||||
commission=commission,
|
||||
enrollments=enrollments,
|
||||
teachers=teachers,
|
||||
students=students,
|
||||
enrolled_ids=enrolled_ids,
|
||||
can_edit=can_edit
|
||||
)
|
||||
|
||||
|
||||
@admin_bp.route('/commissions/<int:id>/assign-teacher', methods=['POST'])
|
||||
@login_required
|
||||
def commission_assign_teacher(id):
|
||||
"""Assign or change the teacher of a commission"""
|
||||
if not (
|
||||
current_user.has_permission('academic', 'read_write') or
|
||||
current_user.is_admin() or
|
||||
current_user.is_bedelia()
|
||||
):
|
||||
abort(403)
|
||||
|
||||
from app.models.subject import Commission
|
||||
from app.models.user import User
|
||||
|
||||
commission = Commission.query.get_or_404(id)
|
||||
teacher_id = request.form.get('teacher_id', type=int)
|
||||
|
||||
if teacher_id:
|
||||
teacher = User.query.get(teacher_id)
|
||||
if not teacher or not teacher.is_active:
|
||||
flash(_('El docente seleccionado no existe o esta inactivo.'), 'error')
|
||||
return redirect(url_for('admin.commission_detail', id=id))
|
||||
commission.teacher_id = teacher_id
|
||||
db.session.commit()
|
||||
flash(_('Docente asignado exitosamente a la comision.'), 'success')
|
||||
else:
|
||||
commission.teacher_id = None
|
||||
db.session.commit()
|
||||
flash(_('Se removio la asignacion de docente.'), 'warning')
|
||||
|
||||
return redirect(url_for('admin.commission_detail', id=id))
|
||||
|
||||
|
||||
@admin_bp.route('/commissions/<int:id>/enroll-student', methods=['POST'])
|
||||
@login_required
|
||||
def commission_enroll_student(id):
|
||||
"""Enroll a student into a commission"""
|
||||
if not (
|
||||
current_user.has_permission('academic', 'read_write') or
|
||||
current_user.is_admin() or
|
||||
current_user.is_bedelia()
|
||||
):
|
||||
abort(403)
|
||||
|
||||
from app.models.subject import Commission
|
||||
from app.models.enrollment import StudentEnrollment
|
||||
from app.models.user import User
|
||||
|
||||
commission = Commission.query.get_or_404(id)
|
||||
student_id = request.form.get('student_id', type=int)
|
||||
notes = request.form.get('notes', '').strip()
|
||||
|
||||
if not student_id:
|
||||
flash(_('Debes seleccionar un alumno.'), 'error')
|
||||
return redirect(url_for('admin.commission_detail', id=id))
|
||||
|
||||
student = User.query.get(student_id)
|
||||
if not student or not student.is_active:
|
||||
flash(_('El alumno seleccionado no existe o esta inactivo.'), 'error')
|
||||
return redirect(url_for('admin.commission_detail', id=id))
|
||||
|
||||
existing = StudentEnrollment.query.filter_by(student_id=student_id, commission_id=id).first()
|
||||
if existing:
|
||||
flash(_('El alumno ya esta inscripto en esta comision.'), 'warning')
|
||||
return redirect(url_for('admin.commission_detail', id=id))
|
||||
|
||||
enrollment = StudentEnrollment(
|
||||
student_id=student_id,
|
||||
commission_id=id,
|
||||
status='activo',
|
||||
notes=notes or None
|
||||
)
|
||||
db.session.add(enrollment)
|
||||
commission.current_students = (commission.current_students or 0) + 1
|
||||
db.session.commit()
|
||||
|
||||
flash(_('Alumno inscripto exitosamente en la comision.'), 'success')
|
||||
return redirect(url_for('admin.commission_detail', id=id))
|
||||
|
||||
|
||||
@admin_bp.route('/commissions/<int:id>/unenroll-student/<int:student_id>', methods=['POST'])
|
||||
@login_required
|
||||
def commission_unenroll_student(id, student_id):
|
||||
"""Remove a student from a commission"""
|
||||
if not (
|
||||
current_user.has_permission('academic', 'read_write') or
|
||||
current_user.is_admin() or
|
||||
current_user.is_bedelia()
|
||||
):
|
||||
abort(403)
|
||||
|
||||
from app.models.subject import Commission
|
||||
from app.models.enrollment import StudentEnrollment
|
||||
|
||||
commission = Commission.query.get_or_404(id)
|
||||
enrollment = StudentEnrollment.query.filter_by(
|
||||
student_id=student_id, commission_id=id
|
||||
).first_or_404()
|
||||
|
||||
db.session.delete(enrollment)
|
||||
if commission.current_students and commission.current_students > 0:
|
||||
commission.current_students -= 1
|
||||
db.session.commit()
|
||||
|
||||
flash(_('Alumno dado de baja de la comision.'), 'success')
|
||||
return redirect(url_for('admin.commission_detail', id=id))
|
||||
|
||||
|
||||
@admin_bp.route('/commissions/<int:id>/update-enrollment/<int:student_id>', methods=['POST'])
|
||||
@login_required
|
||||
def commission_update_enrollment(id, student_id):
|
||||
"""Update enrollment status for a student"""
|
||||
if not (
|
||||
current_user.has_permission('academic', 'read_write') or
|
||||
current_user.is_admin() or
|
||||
current_user.is_bedelia()
|
||||
):
|
||||
abort(403)
|
||||
|
||||
from app.models.enrollment import StudentEnrollment
|
||||
|
||||
enrollment = StudentEnrollment.query.filter_by(
|
||||
student_id=student_id, commission_id=id
|
||||
).first_or_404()
|
||||
|
||||
new_status = request.form.get('status', 'activo')
|
||||
if new_status not in ['activo', 'retirado', 'condicional']:
|
||||
new_status = 'activo'
|
||||
|
||||
enrollment.status = new_status
|
||||
enrollment.notes = request.form.get('notes', enrollment.notes or '')
|
||||
db.session.commit()
|
||||
|
||||
flash(_('Estado de inscripcion actualizado.'), 'success')
|
||||
return redirect(url_for('admin.commission_detail', id=id))
|
||||
@@ -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,240 @@
|
||||
from flask import Blueprint, jsonify, request
|
||||
from app.utils.jwt_decorators import jwt_required
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.subject import Subject
|
||||
from app.models.user import User
|
||||
from app.models.reservation import Reservation
|
||||
from app.models.audit_log import AuditLog
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import func
|
||||
from app import db
|
||||
|
||||
from app.models.milestone import AcademicMilestone
|
||||
from app.models.subject import Commission
|
||||
|
||||
api_dashboard_bp = Blueprint('api_dashboard', __name__, url_prefix='/api/v1/dashboard')
|
||||
|
||||
def serialize_reservation(r):
|
||||
subject_name = (r.commission.subject.name if (r.commission and r.commission.subject) else r.purpose) or 'Actividad Académica'
|
||||
subject_code = (r.commission.subject.code if (r.commission and r.commission.subject) else '')
|
||||
classroom_code = r.classroom.code if r.classroom else 'Sin Aula'
|
||||
is_virtual = ('virtual' in classroom_code.lower()) or (getattr(r.classroom, 'is_virtual', False) if r.classroom else False)
|
||||
return {
|
||||
'id': r.id,
|
||||
'start_time': r.start_time.strftime('%H:%M') if r.start_time else '',
|
||||
'end_time': r.end_time.strftime('%H:%M') if r.end_time else '',
|
||||
'date_display': r.start_time.strftime('%d/%m') if r.start_time else '',
|
||||
'classroom': {
|
||||
'id': r.classroom_id,
|
||||
'code': classroom_code,
|
||||
'is_virtual': is_virtual,
|
||||
'location_display': getattr(r.classroom, 'location_display', f'Sede Central · {classroom_code}') if r.classroom else 'Sin Aula'
|
||||
},
|
||||
'subject_name': subject_name,
|
||||
'subject_code': subject_code,
|
||||
'shift': r.shift or 'Mañana',
|
||||
'commission': {
|
||||
'id': r.commission_id,
|
||||
'code': r.commission.code if r.commission else 'COM-1'
|
||||
},
|
||||
'status': str(r.status.value if hasattr(r.status, 'value') else r.status),
|
||||
'user': {
|
||||
'id': r.user_id,
|
||||
'name': r.user.name if r.user else 'Docente'
|
||||
},
|
||||
'virtual_link': r.virtual_link or ('https://meet.google.com/edu-space-' + classroom_code.lower().replace(' ', '-') if is_virtual else '')
|
||||
}
|
||||
|
||||
def serialize_milestone(m):
|
||||
return {
|
||||
'id': m.id,
|
||||
'title': m.title,
|
||||
'materia_codigo': m.materia_codigo,
|
||||
'subject_name': m.subject.name if (hasattr(m, 'subject') and m.subject) else m.materia_codigo,
|
||||
'date': m.date.strftime('%d/%m/%Y') if m.date else '',
|
||||
'type_obj': {
|
||||
'name': m.type_obj.name if m.type_obj else 'Evaluación',
|
||||
'color': m.type_obj.color if m.type_obj else '#0d6efd'
|
||||
},
|
||||
'classroom': m.classroom or 'Sede Central',
|
||||
'link': m.link or '',
|
||||
'notes': m.notes or ''
|
||||
}
|
||||
|
||||
@api_dashboard_bp.route('/stats', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_stats():
|
||||
"""Retorna estadísticas clave y datos operativos para el Dashboard."""
|
||||
today = datetime.utcnow().date()
|
||||
|
||||
try:
|
||||
total_classrooms = Classroom.query.filter_by(is_active=True).count()
|
||||
total_subjects = Subject.query.filter_by(is_active=True).count()
|
||||
total_users = User.query.filter_by(is_active=True).count()
|
||||
|
||||
# Aulas presenciales y virtuales
|
||||
all_active_classrooms = Classroom.query.filter_by(is_active=True).all()
|
||||
virtual_classrooms_list = [
|
||||
c for c in all_active_classrooms
|
||||
if getattr(c, 'is_virtual', False)
|
||||
or 'virtual' in (c.building or '').lower()
|
||||
or 'virtual' in (c.code or '').lower()
|
||||
]
|
||||
virtual_classrooms = len(virtual_classrooms_list)
|
||||
|
||||
physical_classrooms_list = [
|
||||
c for c in all_active_classrooms
|
||||
if c not in virtual_classrooms_list
|
||||
]
|
||||
total_physical_count = len(physical_classrooms_list)
|
||||
physical_classrooms = total_physical_count
|
||||
physical_ids = set(c.id for c in physical_classrooms_list)
|
||||
|
||||
# 1. Asignación General del Cuatrimestre / Cronograma
|
||||
assigned_all_ids = set(
|
||||
r[0] for r in db.session.query(Reservation.classroom_id)
|
||||
.filter(Reservation.classroom_id.isnot(None)).distinct().all()
|
||||
)
|
||||
assigned_physical_ids = physical_ids.intersection(assigned_all_ids)
|
||||
assigned_classrooms = len(assigned_physical_ids)
|
||||
unassigned_classrooms = max(0, total_physical_count - assigned_classrooms)
|
||||
|
||||
assigned_pct = round((assigned_classrooms / max(1, total_physical_count)) * 100, 1) if total_physical_count > 0 else 0.0
|
||||
unassigned_pct = round(100.0 - assigned_pct, 1) if total_physical_count > 0 else 0.0
|
||||
|
||||
# 2. Asignación Operativa del Día de Hoy
|
||||
assigned_today_ids = set(
|
||||
r[0] for r in db.session.query(Reservation.classroom_id).filter(
|
||||
Reservation.classroom_id.isnot(None),
|
||||
func.date(Reservation.start_time) == today
|
||||
).distinct().all()
|
||||
)
|
||||
assigned_today_physical_ids = physical_ids.intersection(assigned_today_ids)
|
||||
assigned_today_count = len(assigned_today_physical_ids)
|
||||
unassigned_today_count = max(0, total_physical_count - assigned_today_count)
|
||||
|
||||
assigned_today_pct = round((assigned_today_count / max(1, total_physical_count)) * 100, 1) if total_physical_count > 0 else 0.0
|
||||
unassigned_today_pct = round(100.0 - assigned_today_pct, 1) if total_physical_count > 0 else 0.0
|
||||
|
||||
today_reservations_q = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) == today
|
||||
).order_by(Reservation.start_time.asc()).all()
|
||||
today_reservations = len(today_reservations_q)
|
||||
|
||||
pending_reservations_q = Reservation.query.filter(
|
||||
Reservation.status == 'PENDING'
|
||||
).order_by(Reservation.start_time.asc()).all()
|
||||
pending_count = len(pending_reservations_q)
|
||||
|
||||
upcoming_reservations_q = Reservation.query.filter(
|
||||
Reservation.start_time >= datetime.utcnow()
|
||||
).order_by(Reservation.start_time.asc()).limit(10).all()
|
||||
|
||||
milestones_q = AcademicMilestone.query.filter(
|
||||
AcademicMilestone.is_active == True
|
||||
).order_by(AcademicMilestone.date.asc()).limit(10).all()
|
||||
|
||||
recent_audits = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(6).all()
|
||||
audit_data = [{
|
||||
'created_at': a.created_at.strftime('%Y-%m-%d %H:%M') if a.created_at else None,
|
||||
'action_type': a.action_type,
|
||||
'ip_address': a.ip_address,
|
||||
'user_email': getattr(a.user, 'email', 'System') if hasattr(a, 'user') else 'System'
|
||||
} for a in recent_audits]
|
||||
|
||||
occupancy_rate = min(100, int((today_reservations / max(1, physical_classrooms * 3)) * 100)) if physical_classrooms > 0 else 0
|
||||
|
||||
pending_reservations = [serialize_reservation(r) for r in pending_reservations_q]
|
||||
today_schedule_list = [serialize_reservation(r) for r in today_reservations_q]
|
||||
upcoming_reservations = [serialize_reservation(r) for r in upcoming_reservations_q]
|
||||
milestones = [serialize_milestone(m) for m in milestones_q]
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error fetching stats: {e}")
|
||||
total_classrooms = 0
|
||||
total_subjects = 0
|
||||
total_users = 0
|
||||
today_reservations = 0
|
||||
pending_count = 0
|
||||
physical_classrooms = 0
|
||||
virtual_classrooms = 0
|
||||
occupancy_rate = 0
|
||||
assigned_classrooms = 0
|
||||
assigned_pct = 0.0
|
||||
unassigned_classrooms = 0
|
||||
unassigned_pct = 0.0
|
||||
assigned_today_count = 0
|
||||
assigned_today_pct = 0.0
|
||||
unassigned_today_count = 0
|
||||
unassigned_today_pct = 0.0
|
||||
audit_data = []
|
||||
pending_reservations = []
|
||||
today_schedule_list = []
|
||||
upcoming_reservations = []
|
||||
milestones = []
|
||||
|
||||
# Mocked monthly data for chart since complex SQL group by month might differ based on dialect (SQLite vs Postgres)
|
||||
monthly_data = [
|
||||
{"month": "Ene", "reservations": 120},
|
||||
{"month": "Feb", "reservations": 180},
|
||||
{"month": "Mar", "reservations": 250},
|
||||
{"month": "Abr", "reservations": 220},
|
||||
{"month": "May", "reservations": 300},
|
||||
{"month": "Jun", "reservations": 310},
|
||||
{"month": "Jul", "reservations": 200},
|
||||
{"month": "Ago", "reservations": 350},
|
||||
{"month": "Sep", "reservations": 420},
|
||||
{"month": "Oct", "reservations": 480},
|
||||
{"month": "Nov", "reservations": 520},
|
||||
{"month": "Dic", "reservations": 210}
|
||||
]
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'stats': {
|
||||
'total_classrooms': total_classrooms,
|
||||
'active_subjects': total_subjects,
|
||||
'total_active_users': total_users,
|
||||
'today_reservations': today_reservations,
|
||||
'pending_count': pending_count,
|
||||
'physical_classrooms': physical_classrooms,
|
||||
'virtual_classrooms': virtual_classrooms,
|
||||
'occupancy_rate': occupancy_rate,
|
||||
'assigned_classrooms': assigned_classrooms,
|
||||
'assigned_classrooms_pct': assigned_pct,
|
||||
'unassigned_classrooms': unassigned_classrooms,
|
||||
'unassigned_classrooms_pct': unassigned_pct,
|
||||
'assigned_today_classrooms': assigned_today_count,
|
||||
'assigned_today_pct': assigned_today_pct,
|
||||
'unassigned_today_classrooms': unassigned_today_count,
|
||||
'unassigned_today_pct': unassigned_today_pct
|
||||
},
|
||||
'audit_logs': audit_data,
|
||||
'monthly_data': monthly_data,
|
||||
'pending_reservations': pending_reservations,
|
||||
'today_schedule_list': today_schedule_list,
|
||||
'upcoming_reservations': upcoming_reservations,
|
||||
'milestones': milestones
|
||||
}), 200
|
||||
|
||||
@api_dashboard_bp.route('/sync-sheets', methods=['POST'])
|
||||
@jwt_required
|
||||
def sync_google_sheets():
|
||||
"""Ejecuta la sincronización real con las hojas de cálculo de Google Sheets."""
|
||||
from app.services.sheets_importer import GoogleSheetsImporter
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
custom_url = data.get('sheets_url') or data.get('base_url')
|
||||
importer = GoogleSheetsImporter(base_url=custom_url)
|
||||
stats = importer.sync()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'stats': stats,
|
||||
'source_url': importer.base_url
|
||||
}), 200
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
@@ -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,247 @@
|
||||
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
|
||||
|
||||
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
|
||||
floor = request.args.get('floor')
|
||||
shift = request.args.get('shift')
|
||||
search = request.args.get('search')
|
||||
|
||||
reservations = reservation_service.list_reservations(start_dt, end_dt, classroom_id=classroom_id)
|
||||
result = []
|
||||
for r in reservations:
|
||||
is_virt = bool(r.is_virtual or (r.classroom and r.classroom.is_virtual))
|
||||
if hide_virtual and is_virt:
|
||||
continue
|
||||
if floor and r.classroom and str(r.classroom.floor).strip().lower() != str(floor).strip().lower():
|
||||
continue
|
||||
if shift and r.shift and shift.lower() not in r.shift.lower():
|
||||
continue
|
||||
if search:
|
||||
s_lower = search.lower()
|
||||
subj_n = (r.subject_name or '').lower()
|
||||
subj_c = (r.subject_code or '').lower()
|
||||
c_name = (r.classroom.code if r.classroom else '').lower()
|
||||
u_name = (r.user.name if r.user else '').lower()
|
||||
if s_lower not in subj_n and s_lower not in subj_c and s_lower not in c_name and s_lower not in u_name:
|
||||
continue
|
||||
|
||||
result.append({
|
||||
'id': r.id,
|
||||
'classroom_id': r.classroom_id,
|
||||
'classroom_name': r.classroom.code if r.classroom else None,
|
||||
'classroom': {
|
||||
'id': r.classroom.id,
|
||||
'code': r.classroom.code,
|
||||
'code_display': getattr(r.classroom, 'code_display', r.classroom.code),
|
||||
'floor': r.classroom.floor,
|
||||
'floor_display': getattr(r.classroom, 'floor_display', f"Piso {r.classroom.floor}"),
|
||||
'building': getattr(r.classroom, 'building_name', r.classroom.building),
|
||||
'is_virtual': getattr(r.classroom, 'is_virtual', False),
|
||||
'location_display': getattr(r.classroom, 'location_display', r.classroom.code)
|
||||
} if r.classroom else None,
|
||||
'commission_id': r.commission_id,
|
||||
'commission': {
|
||||
'id': r.commission.id,
|
||||
'code': r.commission.code,
|
||||
'virtual_link': r.commission.virtual_link,
|
||||
'max_students': r.commission.max_students
|
||||
} if r.commission else None,
|
||||
'teacher_name': r.user.name if r.user else None,
|
||||
'user': {
|
||||
'id': r.user.id,
|
||||
'name': r.user.name,
|
||||
'email': r.user.email
|
||||
} if r.user else None,
|
||||
'start_time': r.start_time.isoformat(),
|
||||
'end_time': r.end_time.isoformat(),
|
||||
'purpose': r.purpose,
|
||||
'subject_name': r.subject_name,
|
||||
'subject_code': r.subject_code,
|
||||
'status': str(r.status.value if hasattr(r.status, 'value') else r.status),
|
||||
'shift': r.shift or 'Mañana',
|
||||
'virtual_link': r.virtual_link,
|
||||
'effective_virtual_link': r.effective_virtual_link,
|
||||
'is_virtual': is_virt,
|
||||
'expected_attendees': r.expected_attendees or 30,
|
||||
'notes': r.notes or ''
|
||||
})
|
||||
|
||||
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 con sus relaciones completas."""
|
||||
r = reservation_service.get_reservation(reservation_id)
|
||||
if not r:
|
||||
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
|
||||
|
||||
duration_min = int((r.end_time - r.start_time).total_seconds() / 60) if (r.start_time and r.end_time) else 120
|
||||
is_virt = bool(r.is_virtual or (r.classroom and r.classroom.is_virtual))
|
||||
|
||||
return jsonify({
|
||||
'id': r.id,
|
||||
'classroom_id': r.classroom_id,
|
||||
'classroom_name': r.classroom.code if r.classroom else None,
|
||||
'location_display': getattr(r.classroom, 'location_display', r.classroom.code if r.classroom else None),
|
||||
'classroom': {
|
||||
'id': r.classroom.id,
|
||||
'code': r.classroom.code,
|
||||
'code_display': getattr(r.classroom, 'code_display', r.classroom.code),
|
||||
'building_name': getattr(r.classroom, 'building_name', getattr(r.classroom, 'building', 'Edificio Central')),
|
||||
'floor': r.classroom.floor,
|
||||
'floor_display': getattr(r.classroom, 'floor_display', f"Piso {r.classroom.floor}"),
|
||||
'capacity': r.classroom.capacity,
|
||||
'capacity_display': getattr(r.classroom, 'capacity_display', str(r.classroom.capacity)),
|
||||
'description': getattr(r.classroom, 'description', '') or '',
|
||||
'is_virtual': getattr(r.classroom, 'is_virtual', False)
|
||||
} if r.classroom else None,
|
||||
'commission_id': r.commission_id,
|
||||
'commission': {
|
||||
'id': r.commission.id,
|
||||
'code': r.commission.code,
|
||||
'full_code': r.commission.get_full_code() if hasattr(r.commission, 'get_full_code') else r.commission.code,
|
||||
'virtual_link': r.commission.virtual_link,
|
||||
'max_students': r.commission.max_students,
|
||||
'current_students': getattr(r.commission, 'current_students', 0),
|
||||
'subject': {
|
||||
'id': r.commission.subject.id,
|
||||
'name': r.commission.subject.name,
|
||||
'code': r.commission.subject.code
|
||||
} if (r.commission and r.commission.subject) else None
|
||||
} if r.commission else None,
|
||||
'teacher_id': r.user_id,
|
||||
'teacher_name': r.user.name if r.user else None,
|
||||
'user': {
|
||||
'id': r.user.id,
|
||||
'name': r.user.name,
|
||||
'first_name': getattr(r.user, 'first_name', r.user.name.split()[0] if r.user.name else 'Usuario'),
|
||||
'last_name': getattr(r.user, 'last_name', ' '.join(r.user.name.split()[1:]) if r.user.name and len(r.user.name.split()) > 1 else ''),
|
||||
'email': r.user.email
|
||||
} if r.user else None,
|
||||
'start_time': r.start_time.isoformat(),
|
||||
'end_time': r.end_time.isoformat(),
|
||||
'duration': duration_min,
|
||||
'purpose': r.purpose,
|
||||
'status': str(r.status.value if hasattr(r.status, 'value') else r.status),
|
||||
'shift': r.shift or 'Mañana',
|
||||
'virtual_link': r.virtual_link,
|
||||
'effective_virtual_link': r.effective_virtual_link,
|
||||
'is_virtual': is_virt,
|
||||
'expected_attendees': r.expected_attendees or 30,
|
||||
'notes': r.notes or '',
|
||||
'can_be_cancelled': r.can_be_cancelled() if hasattr(r, 'can_be_cancelled') else True
|
||||
}), 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
|
||||
|
||||
@api_reservations_bp.route('/<int:reservation_id>/cancel', methods=['POST'])
|
||||
@jwt_required
|
||||
def cancel_reservation_post(reservation_id: int):
|
||||
"""Cancela una reserva (endpoint POST compatible con formularios)."""
|
||||
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
|
||||
|
||||
@api_reservations_bp.route('/<int:reservation_id>/confirm', methods=['POST'])
|
||||
@jwt_required
|
||||
def confirm_reservation(reservation_id: int):
|
||||
"""Confirma una reserva cambiando su estado a CONFIRMED."""
|
||||
confirmed = reservation_service.confirm_reservation(reservation_id)
|
||||
if not confirmed:
|
||||
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
|
||||
|
||||
return jsonify({'message': f'Reserva {reservation_id} confirmada correctamente.'}), 200
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,333 @@
|
||||
from flask import Blueprint, render_template, jsonify, redirect, url_for, session, request, flash
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.reservation import Reservation
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.subject import Subject
|
||||
from app.models.subject import Commission
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import func
|
||||
from flask_babel import gettext as _
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
@main_bp.route('/')
|
||||
@login_required
|
||||
def dashboard():
|
||||
"""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 Exception:
|
||||
total_classrooms = 0
|
||||
|
||||
try:
|
||||
total_subjects = Subject.query.filter_by(is_active=True).count()
|
||||
except Exception:
|
||||
total_subjects = 0
|
||||
|
||||
try:
|
||||
today_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) == today
|
||||
).count()
|
||||
except Exception:
|
||||
today_reservations = 0
|
||||
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
week_end = week_start + timedelta(days=6)
|
||||
try:
|
||||
week_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) >= week_start,
|
||||
func.date(Reservation.start_time) <= week_end
|
||||
).count()
|
||||
except Exception:
|
||||
week_reservations = 0
|
||||
|
||||
stats = {
|
||||
'total_classrooms': total_classrooms,
|
||||
'total_subjects': total_subjects,
|
||||
'today_reservations': today_reservations,
|
||||
'week_reservations': week_reservations
|
||||
}
|
||||
|
||||
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')
|
||||
@login_required
|
||||
def dashboard_stats_api():
|
||||
"""API endpoint for dashboard statistics"""
|
||||
# Get today's data
|
||||
today = datetime.utcnow().date()
|
||||
today_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) == today
|
||||
).count()
|
||||
|
||||
# Get this week's data
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
week_end = week_start + timedelta(days=6)
|
||||
week_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) >= week_start,
|
||||
func.date(Reservation.start_time) <= week_end
|
||||
).count()
|
||||
|
||||
# Get monthly data for chart
|
||||
current_date = datetime.utcnow()
|
||||
monthly_data = []
|
||||
|
||||
for i in range(12):
|
||||
month_date = current_date - timedelta(days=30 * i)
|
||||
month_start = month_date.replace(day=1)
|
||||
month_end = (month_start + timedelta(days=32)).replace(day=1) - timedelta(days=1)
|
||||
|
||||
monthly_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) >= month_start.date(),
|
||||
func.date(Reservation.start_time) <= month_end.date()
|
||||
).count()
|
||||
|
||||
monthly_data.append({
|
||||
'month': month_start.strftime('%Y-%m'),
|
||||
'reservations': monthly_reservations
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'today_reservations': today_reservations,
|
||||
'week_reservations': week_reservations,
|
||||
'monthly_data': list(reversed(monthly_data))
|
||||
})
|
||||
|
||||
@main_bp.route('/genetic-optimizer')
|
||||
@login_required
|
||||
def genetic_optimizer():
|
||||
"""Serve the genetic algorithm optimization interface"""
|
||||
if not (current_user.has_permission('optimizer', 'read') or (current_user.role and current_user.role.upper() == 'ADMIN')):
|
||||
flash(_('No tienes permisos para acceder a la herramienta de optimización.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
return render_template('genetic_optimizer.html')
|
||||
|
||||
@main_bp.route('/set_language/<language>')
|
||||
def set_language(language=None):
|
||||
if language not in ['en', 'es']:
|
||||
language = 'es'
|
||||
|
||||
session['language'] = language
|
||||
|
||||
# Si se selecciona el idioma desde la pantalla de login, registrar la selección explícita
|
||||
if request.referrer and 'login' in request.referrer:
|
||||
session['login_lang_explicit'] = language
|
||||
|
||||
if current_user.is_authenticated:
|
||||
current_user.preferred_language = language
|
||||
db.session.commit()
|
||||
|
||||
return redirect(request.referrer or url_for('main.dashboard'))
|
||||
|
||||
|
||||
@main_bp.route('/api/milestone-types')
|
||||
def api_milestone_types():
|
||||
"""Retorna las tipificaciones de hitos activas en JSON para consumo de frontend/mobile"""
|
||||
from app.models.milestone import MilestoneType, BASE_MILESTONE_TYPES
|
||||
if MilestoneType.query.count() == 0:
|
||||
for item in BASE_MILESTONE_TYPES:
|
||||
mt = MilestoneType(
|
||||
name=item['name'],
|
||||
code=item['code'],
|
||||
color=item['color'],
|
||||
description=item['description'],
|
||||
sort_order=item['sort_order'],
|
||||
is_active=True
|
||||
)
|
||||
db.session.add(mt)
|
||||
db.session.commit()
|
||||
|
||||
types = MilestoneType.query.filter_by(is_active=True).order_by(MilestoneType.sort_order.asc(), MilestoneType.id.asc()).all()
|
||||
return jsonify([t.to_dict() for t in types])
|
||||
@@ -0,0 +1,126 @@
|
||||
from flask import Blueprint, render_template, abort
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.subject import Commission
|
||||
from app.models.enrollment import StudentEnrollment
|
||||
from app.models.classroom import Classroom
|
||||
|
||||
my_subjects_bp = Blueprint('my_subjects', __name__, url_prefix='/mis-materias')
|
||||
|
||||
|
||||
@my_subjects_bp.route('/')
|
||||
@login_required
|
||||
def my_subjects_list():
|
||||
"""Show the commissions assigned to the current user (teacher or student)."""
|
||||
role = current_user.get_institutional_role()
|
||||
|
||||
commissions = []
|
||||
|
||||
if role in ['admin', 'bedelia']:
|
||||
# Admin/Bedelia: see all active commissions
|
||||
commissions = (
|
||||
Commission.query
|
||||
.filter_by(active=True)
|
||||
.order_by(Commission.year.desc(), Commission.semester.asc(), Commission.code.asc())
|
||||
.all()
|
||||
)
|
||||
view_mode = 'all'
|
||||
|
||||
elif role == 'docente':
|
||||
# Teacher: only commissions where they are assigned as teacher
|
||||
commissions = (
|
||||
Commission.query
|
||||
.filter_by(teacher_id=current_user.id, active=True)
|
||||
.order_by(Commission.year.desc(), Commission.semester.asc(), Commission.code.asc())
|
||||
.all()
|
||||
)
|
||||
view_mode = 'teacher'
|
||||
|
||||
elif role == 'alumno':
|
||||
# Student: only commissions where they are enrolled
|
||||
enrollments = (
|
||||
StudentEnrollment.query
|
||||
.filter_by(student_id=current_user.id, status='activo')
|
||||
.all()
|
||||
)
|
||||
commission_ids = [e.commission_id for e in enrollments]
|
||||
commissions = (
|
||||
Commission.query
|
||||
.filter(Commission.id.in_(commission_ids), Commission.active == True)
|
||||
.order_by(Commission.year.desc(), Commission.semester.asc(), Commission.code.asc())
|
||||
.all()
|
||||
)
|
||||
view_mode = 'student'
|
||||
else:
|
||||
commissions = []
|
||||
view_mode = 'none'
|
||||
|
||||
return render_template(
|
||||
'my_subjects/list.html',
|
||||
commissions=commissions,
|
||||
view_mode=view_mode,
|
||||
role=role
|
||||
)
|
||||
|
||||
|
||||
@my_subjects_bp.route('/<int:commission_id>')
|
||||
@login_required
|
||||
def my_subject_detail(commission_id):
|
||||
"""Detail view of a single commission for the current user."""
|
||||
role = current_user.get_institutional_role()
|
||||
commission = Commission.query.get_or_404(commission_id)
|
||||
|
||||
# Access control: make sure the user is allowed to see this commission
|
||||
if role in ['admin', 'bedelia']:
|
||||
pass # full access
|
||||
elif role == 'docente':
|
||||
if commission.teacher_id != current_user.id:
|
||||
abort(403)
|
||||
elif role == 'alumno':
|
||||
enrollment = StudentEnrollment.query.filter_by(
|
||||
student_id=current_user.id,
|
||||
commission_id=commission_id
|
||||
).first()
|
||||
if not enrollment:
|
||||
abort(403)
|
||||
else:
|
||||
abort(403)
|
||||
|
||||
# Get enrolled students (visible to teachers and admin/bedelia)
|
||||
enrollments = []
|
||||
if role in ['admin', 'bedelia', 'docente']:
|
||||
enrollments = (
|
||||
StudentEnrollment.query
|
||||
.filter_by(commission_id=commission_id)
|
||||
.order_by(StudentEnrollment.enrolled_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
# Try to get classroom info from reservations linked to this commission
|
||||
from app.models.reservation import Reservation
|
||||
from sqlalchemy import func
|
||||
from datetime import datetime
|
||||
|
||||
# Recent or upcoming reservation linked to this commission
|
||||
recent_reservation = (
|
||||
Reservation.query
|
||||
.filter(
|
||||
Reservation.commission_id == commission_id,
|
||||
Reservation.start_time >= datetime.utcnow()
|
||||
)
|
||||
.order_by(Reservation.start_time.asc())
|
||||
.first()
|
||||
)
|
||||
|
||||
classroom = None
|
||||
if recent_reservation and recent_reservation.classroom_id:
|
||||
classroom = Classroom.query.get(recent_reservation.classroom_id)
|
||||
|
||||
return render_template(
|
||||
'my_subjects/detail.html',
|
||||
commission=commission,
|
||||
enrollments=enrollments,
|
||||
classroom=classroom,
|
||||
recent_reservation=recent_reservation,
|
||||
role=role
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +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',
|
||||
'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(days=1)
|
||||
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,131 @@
|
||||
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
|
||||
|
||||
def confirm_reservation(self, reservation_id: int) -> bool:
|
||||
"""Confirma una reserva estableciendo su estado en CONFIRMED."""
|
||||
reservation = self.reservation_repo.get_by_id(reservation_id)
|
||||
if not reservation:
|
||||
return False
|
||||
reservation.status = ReservationStatus.CONFIRMED.value
|
||||
self.reservation_repo.save(reservation)
|
||||
return True
|
||||
@@ -2,6 +2,7 @@ import urllib.request
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
import os
|
||||
from datetime import datetime, date, time, timedelta
|
||||
from app import db
|
||||
from app.models.classroom import Classroom
|
||||
@@ -12,6 +13,34 @@ from app.models.user import User
|
||||
|
||||
BASE_CSV_URL = 'https://docs.google.com/spreadsheets/d/e/2PACX-1vSc_T_BQjbn3uPelioCgx52UM5Py-qNhJN0TYPd1kmsN5jdb3Q8rAaIvNMF_2ZTzQt6bH--yIWQKrKR/pub?single=true&output=csv'
|
||||
|
||||
def normalize_sheets_url(url):
|
||||
"""
|
||||
Normaliza enlaces de Google Sheets (compartidos, de edición o publicados)
|
||||
para obtener la URL base que acepta parámetro gid=<gid>&output=csv o format=csv.
|
||||
"""
|
||||
if not url:
|
||||
return BASE_CSV_URL
|
||||
url = url.strip()
|
||||
|
||||
# 1. Enlace publicado web (/d/e/2PACX-.../pub...)
|
||||
if '/d/e/' in url:
|
||||
base_match = re.match(r'^(https://docs\.google\.com/spreadsheets/d/e/[^/?#]+)/pub', url)
|
||||
if base_match:
|
||||
return f"{base_match.group(1)}/pub?single=true&output=csv"
|
||||
clean = url.split('&gid=')[0].split('?gid=')[0]
|
||||
sep = '&' if '?' in clean else '?'
|
||||
if 'output=csv' not in clean:
|
||||
clean = f"{clean}{sep}single=true&output=csv"
|
||||
return clean
|
||||
|
||||
# 2. Enlace de edición o visualización (/d/<SPREADSHEET_ID>/edit...)
|
||||
sheet_id_match = re.search(r'/spreadsheets/d/([a-zA-Z0-9-_]+)', url)
|
||||
if sheet_id_match:
|
||||
sheet_id = sheet_id_match.group(1)
|
||||
return f"https://docs.google.com/spreadsheets/d/{sheet_id}/export?format=csv"
|
||||
|
||||
return url
|
||||
|
||||
SHEETS_CONFIG = [
|
||||
{ 'name': 'Lunes', 'gid': '1915752353', 'day_idx': 0 },
|
||||
{ 'name': 'Martes', 'gid': '466778263', 'day_idx': 1 },
|
||||
@@ -30,16 +59,28 @@ class GoogleSheetsImporter:
|
||||
"""
|
||||
|
||||
def __init__(self, base_url=None):
|
||||
self.base_url = base_url or BASE_CSV_URL
|
||||
if base_url:
|
||||
self.base_url = normalize_sheets_url(base_url)
|
||||
else:
|
||||
configured_url = None
|
||||
try:
|
||||
from app.models.setting import SystemSetting
|
||||
configured_url = SystemSetting.get_value('google_sheets_url')
|
||||
except Exception:
|
||||
pass
|
||||
if not configured_url:
|
||||
configured_url = os.getenv('GOOGLE_SHEETS_URL')
|
||||
self.base_url = normalize_sheets_url(configured_url) if configured_url else BASE_CSV_URL
|
||||
|
||||
def fetch_sheet_csv(self, gid):
|
||||
"""Fetch CSV string for a specific sheet gid"""
|
||||
url = f"{self.base_url}&gid={gid}"
|
||||
separator = '&' if '?' in self.base_url else '?'
|
||||
url = f"{self.base_url}{separator}gid={gid}"
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) EduSpace/2.0'}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
with urllib.request.urlopen(req, timeout=25) as resp:
|
||||
return resp.read().decode('utf-8', errors='replace')
|
||||
|
||||
def parse_sheet_rows(self, csv_content, sheet_name):
|
||||
@@ -366,16 +407,36 @@ class GoogleSheetsImporter:
|
||||
# 5. Generate Calendar Reservations
|
||||
t_start, t_end = self.parse_time_range(horario_raw)
|
||||
if classroom and t_start and t_end and day_idx is not None:
|
||||
is_room_virtual = bool(classroom.is_virtual or 'VIRTUAL' in (classroom.code or '').upper())
|
||||
|
||||
# Generar enlace de videollamada para clases virtuales si no tienen uno asignado
|
||||
v_link = None
|
||||
if is_room_virtual:
|
||||
clean_subj = re.sub(r'[^a-zA-Z0-9]', '', subject.code or 'cls').lower()
|
||||
clean_comm = re.sub(r'[^a-zA-Z0-9]', '', commission.code or 'c1').lower()
|
||||
v_link = f"https://meet.google.com/edu-{clean_subj}-{clean_comm}"
|
||||
if not commission.virtual_link:
|
||||
commission.virtual_link = v_link
|
||||
|
||||
for w in range(weeks_to_generate):
|
||||
class_date = base_monday + timedelta(days=day_idx, weeks=w)
|
||||
dt_start = datetime.combine(class_date, t_start)
|
||||
dt_end = datetime.combine(class_date, t_end)
|
||||
|
||||
existing_res = Reservation.query.filter_by(
|
||||
classroom_id=classroom.id,
|
||||
start_time=dt_start,
|
||||
end_time=dt_end
|
||||
).first()
|
||||
# En aulas virtuales la capacidad es infinita y pueden cursar múltiples comisiones en paralelo
|
||||
if is_room_virtual:
|
||||
existing_res = Reservation.query.filter_by(
|
||||
classroom_id=classroom.id,
|
||||
commission_id=commission.id,
|
||||
start_time=dt_start,
|
||||
end_time=dt_end
|
||||
).first()
|
||||
else:
|
||||
existing_res = Reservation.query.filter_by(
|
||||
classroom_id=classroom.id,
|
||||
start_time=dt_start,
|
||||
end_time=dt_end
|
||||
).first()
|
||||
|
||||
if not existing_res:
|
||||
res = Reservation(
|
||||
@@ -386,8 +447,9 @@ class GoogleSheetsImporter:
|
||||
end_time=dt_end,
|
||||
purpose=f"{subject.code} - {subject.name}",
|
||||
status='CONFIRMED',
|
||||
expected_attendees=min(classroom.capacity, 30),
|
||||
expected_attendees=min(classroom.capacity, 30) if not is_room_virtual else 35,
|
||||
shift=turno,
|
||||
virtual_link=v_link,
|
||||
notes=f"Clase semanal {dia} ({turno}) - Carrera: {career.name}"
|
||||
)
|
||||
db.session.add(res)
|
||||
@@ -395,6 +457,8 @@ class GoogleSheetsImporter:
|
||||
else:
|
||||
existing_res.shift = turno
|
||||
existing_res.notes = f"Clase semanal {dia} ({turno}) - Carrera: {career.name}"
|
||||
if is_room_virtual and not existing_res.virtual_link:
|
||||
existing_res.virtual_link = v_link
|
||||
|
||||
if len(stats['sample_records']) < 10:
|
||||
stats['sample_records'].append({
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -7,7 +7,7 @@ load_dotenv(os.path.join(basedir, '.env'))
|
||||
class Config:
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
|
||||
|
||||
_db_url = os.environ.get('DATABASE_URL') or 'postgresql://postgres:password@localhost:5432/classrooms_db'
|
||||
_db_url = os.environ.get('DATABASE_URL') or 'sqlite:///app.db'
|
||||
if _db_url.startswith('postgres://'):
|
||||
_db_url = _db_url.replace('postgres://', 'postgresql://', 1)
|
||||
SQLALCHEMY_DATABASE_URI = _db_url
|
||||
@@ -0,0 +1,424 @@
|
||||
import sys
|
||||
|
||||
# Asegurar codificación UTF-8 en consola de Windows
|
||||
if hasattr(sys.stdout, 'reconfigure'):
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
if hasattr(sys.stderr, 'reconfigure'):
|
||||
sys.stderr.reconfigure(encoding='utf-8')
|
||||
|
||||
from app import create_app, db
|
||||
from app.models.user import User
|
||||
from app.models.role import Role, Permission, SYSTEM_MODULES
|
||||
from sqlalchemy import text
|
||||
|
||||
def init_database():
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
try:
|
||||
print("[*] Conectando a la base de datos y sincronizando esquema...")
|
||||
|
||||
# 1. Crear tablas que no existan
|
||||
db.create_all()
|
||||
|
||||
# 2. Migraciones incrementales seguras para bases de datos existentes
|
||||
if db.engine.url.drivername != 'sqlite':
|
||||
db.session.execute(text("""
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS preferred_language VARCHAR(10) DEFAULT NULL;
|
||||
"""))
|
||||
db.session.execute(text("""
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS theme_preference VARCHAR(10) DEFAULT 'auto' NOT NULL;
|
||||
"""))
|
||||
db.session.execute(text("""
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS role_id INTEGER REFERENCES roles(id) ON DELETE SET NULL;
|
||||
"""))
|
||||
db.session.execute(text("""
|
||||
ALTER TABLE subjects ADD COLUMN IF NOT EXISTS career_id INTEGER REFERENCES careers(id) ON DELETE SET NULL;
|
||||
"""))
|
||||
db.session.execute(text("""
|
||||
ALTER TABLE subjects ALTER COLUMN code TYPE VARCHAR(50);
|
||||
"""))
|
||||
db.session.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS buildings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) UNIQUE NOT NULL,
|
||||
code VARCHAR(50) UNIQUE,
|
||||
address VARCHAR(255),
|
||||
floors INTEGER DEFAULT 1 NOT NULL,
|
||||
description TEXT,
|
||||
is_active BOOLEAN DEFAULT TRUE NOT NULL,
|
||||
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW(),
|
||||
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;
|
||||
"""))
|
||||
db.session.execute(text("""
|
||||
ALTER TABLE commissions ADD COLUMN IF NOT EXISTS shift VARCHAR(30);
|
||||
"""))
|
||||
db.session.execute(text("""
|
||||
ALTER TABLE reservations ADD COLUMN IF NOT EXISTS shift VARCHAR(30);
|
||||
"""))
|
||||
db.session.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS student_enrollments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
student_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
commission_id INTEGER NOT NULL REFERENCES commissions(id) ON DELETE CASCADE,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'activo',
|
||||
enrolled_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW() NOT NULL,
|
||||
updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW(),
|
||||
notes TEXT,
|
||||
CONSTRAINT uq_student_commission UNIQUE (student_id, commission_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_student_enrollments_student_id ON student_enrollments (student_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_student_enrollments_commission_id ON student_enrollments (commission_id);
|
||||
"""))
|
||||
db.session.commit()
|
||||
print("[OK] Esquema y tablas actualizadas con éxito.")
|
||||
|
||||
# 3. Crear o sincronizar roles predeterminados y matriz de permisos
|
||||
print("[*] Configurando roles institucionales y permisos RBAC...")
|
||||
default_roles = [
|
||||
{
|
||||
'name': 'Admin',
|
||||
'description': 'Control total institucional, infraestructura, gestion academica y accesos RBAC',
|
||||
'is_system': True,
|
||||
'permissions': {
|
||||
'classrooms': 'read_write',
|
||||
'buildings': 'read_write',
|
||||
'reservations': 'read_write',
|
||||
'schedule': 'read_write',
|
||||
'academic': 'read_write',
|
||||
'import': 'read_write',
|
||||
'optimizer': 'read_write',
|
||||
'users': 'read_write',
|
||||
'milestone_types': 'read_write',
|
||||
'metrics': 'read_write',
|
||||
'enrollment': 'read_write'
|
||||
}
|
||||
},
|
||||
{
|
||||
'name': 'Docente',
|
||||
'description': 'Gestion de reservas de cursada, consulta de cronograma y materias',
|
||||
'is_system': False,
|
||||
'permissions': {
|
||||
'classrooms': 'read',
|
||||
'buildings': 'read',
|
||||
'reservations': 'read_write',
|
||||
'schedule': 'read_write',
|
||||
'academic': 'read',
|
||||
'import': 'none',
|
||||
'optimizer': 'read',
|
||||
'users': 'none',
|
||||
'milestone_types': 'read',
|
||||
'metrics': 'none',
|
||||
'enrollment': 'read'
|
||||
}
|
||||
},
|
||||
{
|
||||
'name': 'Bedelia',
|
||||
'description': 'Gestion operativa de aulas, sedes, reservas, comisiones, importacion y cartelera',
|
||||
'is_system': False,
|
||||
'permissions': {
|
||||
'classrooms': 'read_write',
|
||||
'buildings': 'read_write',
|
||||
'reservations': 'read_write',
|
||||
'schedule': 'read_write',
|
||||
'academic': 'read_write',
|
||||
'import': 'read_write',
|
||||
'optimizer': 'none',
|
||||
'users': 'none',
|
||||
'milestone_types': 'read_write',
|
||||
'metrics': 'read_write',
|
||||
'enrollment': 'read_write'
|
||||
}
|
||||
},
|
||||
{
|
||||
'name': 'Alumno',
|
||||
'description': 'Acceso de solo lectura al cronograma general, asignaturas y aulas',
|
||||
'is_system': False,
|
||||
'permissions': {
|
||||
'classrooms': 'read',
|
||||
'buildings': 'read',
|
||||
'reservations': 'read',
|
||||
'schedule': 'read',
|
||||
'academic': 'read',
|
||||
'import': 'none',
|
||||
'optimizer': 'none',
|
||||
'users': 'none',
|
||||
'milestone_types': 'read',
|
||||
'metrics': 'none',
|
||||
'enrollment': 'read'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
module_ids = [m['id'] for m in SYSTEM_MODULES]
|
||||
admin_role = None
|
||||
|
||||
for r_data in default_roles:
|
||||
role = Role.query.filter_by(name=r_data['name']).first()
|
||||
if not role:
|
||||
role = Role(
|
||||
name=r_data['name'],
|
||||
description=r_data['description'],
|
||||
is_system=r_data['is_system']
|
||||
)
|
||||
db.session.add(role)
|
||||
db.session.flush()
|
||||
print(f" [+] Rol '{role.name}' creado.")
|
||||
else:
|
||||
role.description = r_data['description']
|
||||
role.is_system = r_data['is_system']
|
||||
|
||||
if role.name == 'Admin':
|
||||
admin_role = role
|
||||
|
||||
# Sincronizar permisos del rol
|
||||
for mod in module_ids:
|
||||
level = r_data['permissions'].get(mod, 'none')
|
||||
role.set_permission(mod, level)
|
||||
|
||||
db.session.commit()
|
||||
print("[OK] Roles y matriz de permisos configurados correctamente.")
|
||||
|
||||
# 3b. Deduplicar rol Bedelia con acento (Bedelía) si existe
|
||||
print("[*] Verificando duplicados del rol Bedelia...")
|
||||
bedelia_accented = Role.query.filter(
|
||||
Role.name.in_(['Bedelía', 'Bedel\u00eda', 'BEDELÍA'])
|
||||
).first()
|
||||
bedelia_canonical = Role.query.filter_by(name='Bedelia').first()
|
||||
if bedelia_accented and bedelia_canonical and bedelia_accented.id != bedelia_canonical.id:
|
||||
affected = User.query.filter_by(role_id=bedelia_accented.id).all()
|
||||
for u in affected:
|
||||
u.role_id = bedelia_canonical.id
|
||||
u.role = 'BEDELIA'
|
||||
print(f" [~] Usuario {u.email} migrado de '{bedelia_accented.name}' a 'Bedelia'")
|
||||
db.session.flush()
|
||||
db.session.delete(bedelia_accented)
|
||||
db.session.commit()
|
||||
print(f" [OK] Rol duplicado '{bedelia_accented.name}' eliminado. {len(affected)} usuario(s) migrado(s).")
|
||||
else:
|
||||
print(" [OK] Sin duplicados de Bedelia.")
|
||||
|
||||
# 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...")
|
||||
from app.models.building import Building
|
||||
b_central = Building.query.filter_by(name='Edificio Central').first()
|
||||
if not b_central:
|
||||
b_central = Building(
|
||||
name='Edificio Central',
|
||||
code='CENTRAL',
|
||||
address='Tte. Gral. Juan Domingo Peron 802, CABA',
|
||||
floors=4,
|
||||
description='Sede central institucional de UniCABA. Aulas de grado, auditorio y laboratorios.'
|
||||
)
|
||||
db.session.add(b_central)
|
||||
else:
|
||||
b_central.address = 'Tte. Gral. Juan Domingo Peron 802, CABA'
|
||||
|
||||
b_virtual = Building.query.filter_by(name='Campus Virtual').first()
|
||||
if not b_virtual:
|
||||
b_virtual = Building(
|
||||
name='Campus Virtual',
|
||||
code='VIRTUAL',
|
||||
address='Entorno Digital / Microsoft Teams - Moodle',
|
||||
floors=0,
|
||||
description='Entorno de aulas virtuales y clases a distancia'
|
||||
)
|
||||
db.session.add(b_virtual)
|
||||
db.session.commit()
|
||||
print("[OK] Edificios institucionales configurados con dirección oficial.")
|
||||
|
||||
# 6. Inicializar catalogo dinamico de tipificaciones de hitos academicos
|
||||
print("[*] Verificando catalogo de tipificaciones de hitos academicos...")
|
||||
from app.models.milestone import MilestoneType, BASE_MILESTONE_TYPES
|
||||
for item in BASE_MILESTONE_TYPES:
|
||||
mt = MilestoneType.query.filter_by(code=item['code']).first()
|
||||
if not mt:
|
||||
mt = MilestoneType(
|
||||
name=item['name'],
|
||||
code=item['code'],
|
||||
color=item['color'],
|
||||
description=item['description'],
|
||||
sort_order=item['sort_order'],
|
||||
is_active=True
|
||||
)
|
||||
db.session.add(mt)
|
||||
else:
|
||||
if not mt.color:
|
||||
mt.color = item['color']
|
||||
if not mt.sort_order:
|
||||
mt.sort_order = item['sort_order']
|
||||
db.session.commit()
|
||||
print(f"[OK] Catalogo de tipificaciones inicializado ({MilestoneType.query.count()} tipos disponibles).")
|
||||
|
||||
# 7. Inicializar aulas, materias, comisiones y reservas de muestra
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.subject import Subject, Commission
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
from datetime import datetime, timedelta, date
|
||||
|
||||
if Classroom.query.count() == 0:
|
||||
print("[*] Generando catalogo de aulas...")
|
||||
for floor in ['PB', '1', '2', '3']:
|
||||
for num in range(1, 8):
|
||||
code = f'{floor}0{num}' if floor != 'PB' else f'PB-0{num}'
|
||||
c = Classroom(
|
||||
code=code,
|
||||
building=b_central.name,
|
||||
building_id=b_central.id,
|
||||
floor=floor,
|
||||
capacity=30 + (num * 5),
|
||||
description=f'Aula física estándar {code} en {b_central.name}',
|
||||
is_active=True
|
||||
)
|
||||
db.session.add(c)
|
||||
|
||||
for v in range(1, 4):
|
||||
vc = Classroom(
|
||||
code=f'VIRTUAL-0{v}',
|
||||
building=b_virtual.name,
|
||||
building_id=b_virtual.id,
|
||||
floor='0',
|
||||
capacity=100,
|
||||
description=f'Sala virtual Microsoft Teams {v}',
|
||||
is_active=True
|
||||
)
|
||||
db.session.add(vc)
|
||||
db.session.commit()
|
||||
print(f"[OK] {Classroom.query.count()} aulas creadas.")
|
||||
|
||||
# Materia y Comisión
|
||||
s = Subject.query.filter_by(code='ARQ-101').first()
|
||||
if not s:
|
||||
s = Subject(name='Arquitectura de Software', code='ARQ-101', is_active=True)
|
||||
db.session.add(s)
|
||||
db.session.commit()
|
||||
|
||||
comm = Commission.query.filter_by(code='COM-A').first()
|
||||
if not comm:
|
||||
comm = Commission(code='COM-A', subject_id=s.id, shift='Noche', semester=2, year=2026, max_students=40, is_active=True)
|
||||
db.session.add(comm)
|
||||
db.session.commit()
|
||||
|
||||
# Reservas de Hoy
|
||||
if Reservation.query.count() == 0:
|
||||
print("[*] Generando reservas de muestra...")
|
||||
today = date.today()
|
||||
classrooms = Classroom.query.all()
|
||||
docente = User.query.filter_by(email='docente@edu-space.com').first()
|
||||
for idx, cl in enumerate(classrooms[:19]):
|
||||
start_hour = 8 + (idx % 6) * 2
|
||||
r = Reservation(
|
||||
classroom_id=cl.id,
|
||||
commission_id=comm.id,
|
||||
user_id=docente.id if docente else 1,
|
||||
start_time=datetime.combine(today, datetime.min.time()) + timedelta(hours=start_hour),
|
||||
end_time=datetime.combine(today, datetime.min.time()) + timedelta(hours=start_hour + 2),
|
||||
purpose='Clase Regular',
|
||||
status=ReservationStatus.CONFIRMED.value,
|
||||
shift='Noche' if start_hour >= 18 else 'Tarde' if start_hour >= 13 else 'Mañana',
|
||||
expected_attendees=cl.capacity - 5
|
||||
)
|
||||
db.session.add(r)
|
||||
db.session.commit()
|
||||
print(f"[OK] {Reservation.query.count()} reservas creadas.")
|
||||
|
||||
print("\n[LISTO] Base de datos inicializada y lista para usar.")
|
||||
|
||||
except Exception as e:
|
||||
# Safe print to avoid UnicodeEncodeError in exception strings
|
||||
try:
|
||||
print(f"\n[ERROR] Error al inicializar la base de datos: {str(e)}")
|
||||
except UnicodeEncodeError:
|
||||
print("\n[ERROR] Error al inicializar la base de datos (se omiten caracteres no-ascii).")
|
||||
print("\nVerifica que:")
|
||||
print("1. El servicio de PostgreSQL este corriendo.")
|
||||
print("2. La base de datos 'classrooms_db' haya sido creada.")
|
||||
print("3. La variable DATABASE_URL en el archivo .env tenga el usuario y contraseña correctos.")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_database()
|
||||
@@ -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,167 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Herramienta de Limpieza y Reinicio de Datos Académicos para UniCABA / Edu-Space.
|
||||
|
||||
Permite eliminar de forma segura y en el orden estricto de claves foráneas:
|
||||
- Asignaciones de aulas / Reservas (reservations)
|
||||
- Inscripciones de alumnos (student_enrollments)
|
||||
- Comisiones de cursada (commissions)
|
||||
- Asignaturas / Materias (subjects)
|
||||
- Carreras académicas (careers)
|
||||
- Recursos de aula y Aulas físicas / virtuales (classroom_resources, classrooms)
|
||||
|
||||
PRESERVA INTOLERABLEMENTE:
|
||||
- Usuarios del sistema (Users: admin, bedelia, docentes, alumnos)
|
||||
- Roles y Permisos RBAC
|
||||
- Edificios institucionales (Buildings)
|
||||
- Registros de auditoría (AuditLogs)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import argparse
|
||||
|
||||
# Asegurar que el directorio backend está en el PYTHONPATH
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
|
||||
from app import create_app, db
|
||||
from app.models.reservation import Reservation
|
||||
from app.models.enrollment import StudentEnrollment
|
||||
from app.models.milestone import AcademicMilestone
|
||||
from app.models.subject import Commission, Subject
|
||||
from app.models.career import Career
|
||||
from app.models.classroom import Classroom, ClassroomResource
|
||||
|
||||
def execute_academic_reset(
|
||||
reset_reservations=True,
|
||||
reset_commissions=True,
|
||||
reset_subjects=True,
|
||||
reset_careers=True,
|
||||
reset_classrooms=True
|
||||
):
|
||||
"""
|
||||
Ejecuta el reinicio de las entidades académicas seleccionadas.
|
||||
Retorna un diccionario con el conteo de registros eliminados.
|
||||
"""
|
||||
# Si se reinicia una entidad superior, automáticamente deben reiniciarse sus dependientes
|
||||
if reset_careers:
|
||||
reset_subjects = True
|
||||
if reset_subjects:
|
||||
reset_commissions = True
|
||||
if reset_commissions or reset_classrooms:
|
||||
reset_reservations = True
|
||||
|
||||
deleted_counts = {
|
||||
'reservations': 0,
|
||||
'enrollments': 0,
|
||||
'commissions': 0,
|
||||
'subjects': 0,
|
||||
'careers': 0,
|
||||
'classrooms': 0,
|
||||
'classroom_resources': 0
|
||||
}
|
||||
|
||||
try:
|
||||
# 1. Eliminar Reservas (Asignaciones de aulas)
|
||||
if reset_reservations:
|
||||
deleted_counts['reservations'] = Reservation.query.delete()
|
||||
|
||||
# 2. Desvincular hitos de materias o comisiones si se van a eliminar
|
||||
if reset_subjects or reset_commissions:
|
||||
AcademicMilestone.query.update({
|
||||
AcademicMilestone.subject_id: None,
|
||||
AcademicMilestone.commission_id: None
|
||||
})
|
||||
db.session.flush()
|
||||
|
||||
# 3. Eliminar Inscripciones y Comisiones
|
||||
if reset_commissions:
|
||||
deleted_counts['enrollments'] = StudentEnrollment.query.delete()
|
||||
deleted_counts['commissions'] = Commission.query.delete()
|
||||
|
||||
# 4. Eliminar Materias / Asignaturas
|
||||
if reset_subjects:
|
||||
deleted_counts['subjects'] = Subject.query.delete()
|
||||
|
||||
# 5. Eliminar Carreras
|
||||
if reset_careers:
|
||||
deleted_counts['careers'] = Career.query.delete()
|
||||
|
||||
# 6. Eliminar Recursos y Aulas
|
||||
if reset_classrooms:
|
||||
deleted_counts['classroom_resources'] = ClassroomResource.query.delete()
|
||||
deleted_counts['classrooms'] = Classroom.query.delete()
|
||||
|
||||
db.session.commit()
|
||||
return {
|
||||
'success': True,
|
||||
'deleted': deleted_counts,
|
||||
'message': 'Datos académicos reiniciados exitosamente.'
|
||||
}
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'deleted': deleted_counts
|
||||
}
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Limpieza y purga de datos académicos para pruebas del importador.")
|
||||
parser.add_argument('--all', action='store_true', help="Eliminar todo: reservas, comisiones, asignaturas, carreras y aulas.")
|
||||
parser.add_argument('--reservations', action='store_true', help="Eliminar únicamente las asignaciones de aulas.")
|
||||
parser.add_argument('--classrooms', action='store_true', help="Eliminar aulas y sus asignaciones asociadas.")
|
||||
parser.add_argument('--careers', action='store_true', help="Eliminar carreras, asignaturas, comisiones y asignaciones.")
|
||||
parser.add_argument('--yes', '-y', action='store_true', help="Confirmar automáticamente sin preguntar.")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Si no se pasó ninguna opción específica, asumir --all si el usuario lo confirma
|
||||
if not (args.all or args.reservations or args.classrooms or args.careers):
|
||||
args.all = True
|
||||
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
# Mostrar estado actual de la BD
|
||||
print("=" * 60)
|
||||
print(" ESTADO ACTUAL DE ENTIDADES ACADÉMICAS EN BASE DE DATOS")
|
||||
print("=" * 60)
|
||||
print(f" - Asignaciones de Aulas (Reservas): {Reservation.query.count()}")
|
||||
print(f" - Comisiones: {Commission.query.count()}")
|
||||
print(f" - Asignaturas / Materias: {Subject.query.count()}")
|
||||
print(f" - Carreras: {Career.query.count()}")
|
||||
print(f" - Aulas (Físicas y Virtuales): {Classroom.query.count()}")
|
||||
print("=" * 60)
|
||||
|
||||
if not args.yes:
|
||||
confirm = input("\n¿Está SEGURO de eliminar los datos seleccionados? Escriba 'CONFIRMAR': ")
|
||||
if confirm.strip() != 'CONFIRMAR':
|
||||
print("Operación cancelada.")
|
||||
sys.exit(0)
|
||||
|
||||
r_res = args.all or args.reservations or args.classrooms or args.careers
|
||||
r_comm = args.all or args.careers
|
||||
r_subj = args.all or args.careers
|
||||
r_car = args.all or args.careers
|
||||
r_cls = args.all or args.classrooms
|
||||
|
||||
print("\nEjecutando limpieza de datos...")
|
||||
res = execute_academic_reset(
|
||||
reset_reservations=r_res,
|
||||
reset_commissions=r_comm,
|
||||
reset_subjects=r_subj,
|
||||
reset_careers=r_car,
|
||||
reset_classrooms=r_cls
|
||||
)
|
||||
|
||||
if res['success']:
|
||||
print("\n[OK] Limpieza completada con éxito:")
|
||||
for k, v in res['deleted'].items():
|
||||
print(f" - {k}: {v} registros eliminados")
|
||||
else:
|
||||
print(f"\n[ERROR] Falló la limpieza: {res['error']}")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,48 @@
|
||||
import requests
|
||||
from app import create_app
|
||||
from app.models.user import User
|
||||
from app.services.jwt_service import JWTService
|
||||
|
||||
BASE_URL = "http://localhost:3000"
|
||||
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
admin = User.query.filter_by(email='admin@edu-space.com').first()
|
||||
token = JWTService.create_access_token(admin)
|
||||
|
||||
cookies = {'auth_token': token}
|
||||
session = requests.Session()
|
||||
session.cookies.update(cookies)
|
||||
|
||||
routes_to_test = [
|
||||
("admin.google_sheets_import", "/admin/google_sheets_import", ["Google Sheets", "Official Schedule Board"]),
|
||||
("classrooms.add_classroom", "/classrooms/add_classroom", ["Add Classroom", "Classroom Information"]),
|
||||
("admin.occupancy_metrics", "/admin/occupancy_metrics", ["Ocupación", "Métricas"]),
|
||||
("buildings.list_buildings", "/buildings/list_buildings", ["Edificios", "Edificio Central"]),
|
||||
("schedule.add_reservation", "/schedule/add_reservation", ["New Reservation", "Detalles de la Reserva"])
|
||||
]
|
||||
|
||||
all_passed = True
|
||||
print("\n========================================================")
|
||||
print("Testing coupling of 5 newly added BFF functionalities:")
|
||||
print("========================================================")
|
||||
|
||||
for name, path, expected_texts in routes_to_test:
|
||||
r = session.get(f"{BASE_URL}{path}", allow_redirects=True)
|
||||
is_200 = r.status_code == 200
|
||||
texts_found = [t for t in expected_texts if t.lower() in r.text.lower()]
|
||||
all_texts_ok = len(texts_found) == len(expected_texts)
|
||||
has_fallback = "En Construcción" in r.text
|
||||
|
||||
status = "PASSED" if is_200 and all_texts_ok and not has_fallback else "FAILED"
|
||||
print(f"[{status}] {name} ({path}) -> Status: {r.status_code}, Texts match: {texts_found}/{expected_texts}, Has Fallback: {has_fallback}")
|
||||
if status == "FAILED":
|
||||
all_passed = False
|
||||
print(f"Response snippet:\n{r.text[:500]}\n---")
|
||||
|
||||
print("========================================================")
|
||||
if all_passed:
|
||||
print("SUCCESS: ALL 5 FUNCTIONALITIES ARE COUPLED TO THE NODE.JS BFF!")
|
||||
else:
|
||||
print("FAILURE: SOME FUNCTIONALITIES FAILED!")
|
||||
print("========================================================\n")
|
||||
@@ -0,0 +1,77 @@
|
||||
import requests
|
||||
from app import create_app
|
||||
from app.models.user import User
|
||||
from app.services.jwt_service import JWTService
|
||||
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
admin = User.query.filter_by(email='admin@edu-space.com').first()
|
||||
token = JWTService.create_access_token(admin)
|
||||
|
||||
cookies = {'auth_token': token}
|
||||
session = requests.Session()
|
||||
session.cookies.update(cookies)
|
||||
|
||||
tests = [
|
||||
# Dashboard y redirecciones
|
||||
('GET /main/dashboard (redirect to /dashboard)', 'http://localhost:3000/main/dashboard', 200, ['Edu-Space Admin', 'Aulas Totales']),
|
||||
('GET /dashboard', 'http://localhost:3000/dashboard', 200, ['Edu-Space Admin', 'Aulas Totales', 'Reservas de Hoy']),
|
||||
|
||||
# Classrooms
|
||||
('GET /classrooms/list_classrooms', 'http://localhost:3000/classrooms/list_classrooms', 200, ['Aulas y Espacios', 'PB-01', 'Edificio Central']),
|
||||
('GET /classrooms/list', 'http://localhost:3000/classrooms/list', 200, ['Aulas y Espacios']),
|
||||
('GET /classrooms/add_classroom (Coupled)', 'http://localhost:3000/classrooms/add_classroom', 200, ['Add Classroom', 'Classroom Information']),
|
||||
('GET /classrooms/add', 'http://localhost:3000/classrooms/add', 200, ['Add Classroom']),
|
||||
|
||||
# Schedule
|
||||
('GET /schedule/calendar_view', 'http://localhost:3000/schedule/calendar_view', 200, ['Reservations Calendar', 'calendar']),
|
||||
('GET /schedule/calendar_data (JSON Feed)', 'http://localhost:3000/schedule/calendar_data', 200, None),
|
||||
('GET /schedule/today', 'http://localhost:3000/schedule/today', 200, ['Reservas']),
|
||||
('GET /schedule/list_reservations', 'http://localhost:3000/schedule/list_reservations', 200, ['Listado de Reservas']),
|
||||
('GET /schedule/add_reservation (Coupled)', 'http://localhost:3000/schedule/add_reservation', 200, ['New Reservation', 'Detalles de la Reserva']),
|
||||
('GET /schedule/add', 'http://localhost:3000/schedule/add', 200, ['New Reservation']),
|
||||
|
||||
# Buildings
|
||||
('GET /buildings/list_buildings (Coupled)', 'http://localhost:3000/buildings/list_buildings', 200, ['Gestión de Edificios', 'Edificio Central']),
|
||||
('GET /buildings', 'http://localhost:3000/buildings', 200, ['Gestión de Edificios']),
|
||||
('GET /buildings/add_building', 'http://localhost:3000/buildings/add_building', 200, ['Nuevo Edificio']),
|
||||
|
||||
# Admin Features
|
||||
('GET /admin/google_sheets_import (Coupled)', 'http://localhost:3000/admin/google_sheets_import', 200, ['Google Sheets', 'Official Schedule Board']),
|
||||
('GET /admin/import', 'http://localhost:3000/admin/import', 200, ['Google Sheets']),
|
||||
('GET /admin/occupancy_metrics (Coupled)', 'http://localhost:3000/admin/occupancy_metrics', 200, ['Ocupación', 'Métricas']),
|
||||
('GET /admin/occupancy-metrics', 'http://localhost:3000/admin/occupancy-metrics', 200, ['Ocupación'])
|
||||
]
|
||||
|
||||
all_passed = True
|
||||
print('=== EJECUTANDO SUITE DE PRUEBAS DE INTEGRACION BFF NODE.JS ===')
|
||||
for name, url, expected_status, expected_texts in tests:
|
||||
r = session.get(url, allow_redirects=True)
|
||||
status_ok = r.status_code == expected_status
|
||||
text_ok = True
|
||||
if 'En Construcción' in r.text:
|
||||
text_ok = False
|
||||
print(f' [FAIL] Fallback "En Construcción" encountered in {url}')
|
||||
if expected_texts:
|
||||
for t in expected_texts:
|
||||
if t.lower() not in r.text.lower():
|
||||
text_ok = False
|
||||
print(f' [FAIL] Expected text "{t}" not found in {url}')
|
||||
if expected_texts is None: # JSON check
|
||||
data = r.json()
|
||||
text_ok = isinstance(data, list) and len(data) > 0
|
||||
if not text_ok:
|
||||
print(f' [FAIL] Expected non-empty JSON list in {url}')
|
||||
|
||||
if status_ok and text_ok:
|
||||
print(f'[PASS] {name} -> Status: {r.status_code} OK')
|
||||
else:
|
||||
all_passed = False
|
||||
print(f'[FAIL] {name} -> Status: {r.status_code} FAIL')
|
||||
|
||||
print('===============================================================')
|
||||
if all_passed:
|
||||
print('TODAS LAS PRUEBAS DE INTEGRACION PASARON SATISFACTORIAMENTE (100% OK)')
|
||||
else:
|
||||
print('SE ENCONTRARON FALLOS EN LA SUITE DE PRUEBAS')
|
||||
print('===============================================================')
|
||||
@@ -0,0 +1,26 @@
|
||||
import pytest
|
||||
from app import create_app, db
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
app = create_app()
|
||||
# Configure testing DB
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
|
||||
app.config['TESTING'] = True
|
||||
return app
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
@pytest.fixture
|
||||
def app_context(app):
|
||||
with app.app_context():
|
||||
yield
|
||||
|
||||
@pytest.fixture
|
||||
def init_database(app, app_context):
|
||||
db.create_all()
|
||||
yield db
|
||||
db.session.remove()
|
||||
db.drop_all()
|
||||