55 Commits
Author SHA1 Message Date
carlostellocba 05e197e49f docs: actualizar changelog y readme con detalles de la version 2.3.0 2026-09-19 11:54:35 -03:00
carlostellocba e93d784473 docs: actualizar changelog con version 2.3.0 2026-09-19 11:42:32 -03:00
carlostellocba 620e470614 feat(dashboard): eliminar boton de reserva en panel docente y agregar monitoreo de aulas asignadas y sin asignar con percentiles en admin y bedelia 2026-09-19 11:42:02 -03:00
carlostellocba b9a11d810a fix(classrooms): corregir renderizado de aulas presenciales, metricas de ocupacion y asignacion de cursadas 2026-09-19 11:27:50 -03:00
carlostellocba 5087e22988 feat(sync): add academic data reset tool, configurable sheets URL and fix sync timeout 2026-09-19 11:13:24 -03:00
carlostellocba 42b31ec44e docs: estado de migracion, arquitectura objetivo UniCABA, roadmap MVP y changelog oficial 2026-09-19 10:54:03 -03:00
carlostellocba 500aeeb9df feat: acople de schedule.view_reservation al BFF y creacion/asociacion de comisiones a materias 2026-09-19 10:23:50 -03:00
carlostellocba 1080acd43a feat: implementacion de dashboards por rol, soporte de clases virtuales concurrentes en importador de sheets y cartelera del dia 2026-09-18 23:04:48 -03:00
carlostellocba 47b50ede7e fix: database initialization on sqlite and i18n logic 2026-09-18 14:40:34 -03:00
Carlos Tello 865538b727 fix: corrige validacion de permisos de bedelia y gestion de inscripciones 2026-09-05 13:01:39 -03:00
Carlos Tello 1fda344248 feat: gestion de materias por rol - Bedelia, Docente y Alumno
- Nuevo modelo StudentEnrollment (tabla student_enrollments)
  con inscripcion unica por alumno/comision, estado y notas

- Migracion automatica en init_db.py (CREATE TABLE IF NOT EXISTS)

- Rutas /admin/commissions/ para Bedelia y Admin:
  listado con filtros, detalle, asignar docente,
  inscribir/dar de baja alumnos, cambiar estado

- Blueprint my_subjects_bp en /mis-materias:
  Docente ve solo sus comisiones asignadas
  Alumno ve solo sus inscripciones activas
  Admin/Bedelia ven todo

- Templates: admin/commissions/list.html, detail.html
            my_subjects/list.html, detail.html

- Navbar actualizado por rol:
  Docente y Alumno: 'Mis Materias'
  Bedelia y Admin: 'Gestion de Comisiones'
2026-09-05 12:24:04 -03:00
Carlos Tello 69d545b718 feat(sprint4): completar optimizador heuristico concurrente, integracion Spring Boot, correccion de login y fixes visuales 2026-09-05 02:20:47 -03:00
Carlos Tello dd93b8c07a feat(ui/rbac): ordenar navegacion segun estandares de la industria y personalizar dashboards por rol (Admin, Bedelia, Docente, Alumno) 2026-09-05 02:03:57 -03:00
Carlos Tello 7d342bf8cc feat(ui): implementar selector de vista universal (tabla/grilla vs tarjetas/mosaico) con persistencia 2026-09-05 01:54:55 -03:00
Carlos Tello 69b8b7ccef feat(roadmap): implementar Sprints 1, 2 y 3 (MVC, Stateless JWT API y Spring Security) 2026-09-05 01:46:57 -03:00
Carlos Tello fa878cb0d5 docs: registrar creacion de rama rc y release v1.0.1 en changelog 2026-09-05 01:34:37 -03:00
Carlos Tello 566908f1de docs: documentar roadmap academico, propuesta mvc/jwt/security y actualizar changelog de sesion 2026-09-05 01:07:15 -03:00
Carlos Tello c748ae4075 feat(lxc): verificar e instalar git automáticamente al inicio de install.sh si no existe 2026-09-05 00:33:49 -03:00
Carlos Tello 6218657f36 fix(lxc): instalar sudo e implementar helper pg_cmd universal (sudo/runuser/su) para PostgreSQL 2026-09-04 23:56:37 -03:00
Carlos Tello 747d20ed38 fix(lxc): preservar directorio origen y configurar safe.directory global 2026-09-04 23:52:23 -03:00
Carlos Tello b64a771068 docs: registrar seccion 24 de auto-recuperacion de PostgreSQL en CHANGELOG_SESSION 2026-09-04 23:50:47 -03:00
Carlos Tello 340bf3aa8c fix(lxc): asegurar creación de cluster e inicialización robusta de PostgreSQL 15 en Debian 12 2026-09-04 23:50:20 -03:00
Carlos Tello 80f6018b6a fix(lxc): agregar safe.directory en install.sh para evitar error de git dubious ownership 2026-09-04 23:24:36 -03:00
Carlos Tello 4e49311208 fix(lxc): resolver 500 propagando permisos PostgreSQL universales y logging en journald 2026-09-04 23:15:02 -03:00
Carlos Tello b367b106bf fix: corregir traslado automatico de directorio a /opt con cp -a evitando Directory not empty 2026-09-04 23:05:33 -03:00
Carlos Tello 0cea3dca44 feat: hacer install.sh 100% automatico y desatendido usando config activa de PostgreSQL (.env) 2026-09-04 23:02:02 -03:00
Carlos Tello eea7adb1d0 feat: actualizar install.sh para Proxmox LXC con venv, snapshot classrooms_db.sql, backup_db.sh y fix linter en occupancy 2026-09-04 22:56:53 -03:00
carlostellocba e0c58409cd fix: resolve gunicorn app import and systemd permission issues 2026-09-04 22:20:34 -03:00
carlostellocba 9b6678724c chore: eliminar .env del control de versiones para evitar sobrescribir configuraciones locales 2026-09-04 22:11:54 -03:00
carlostellocba f462d57ad9 fix: sanitizar tildes en prints de init_db.py para consola LXC ASCII 2026-09-04 21:54:04 -03:00
carlostellocba 7ef2ba8a5b feat: soportar restauracion automatica desde classrooms_db.sql si existe en el directorio 2026-09-04 21:49:25 -03:00
carlostellocba 169aa45746 fix: sanitizar tildes en milestone.py para evitar errores UnicodeEncodeError 2026-09-04 21:43:44 -03:00
carlostellocba f31e2bf760 fix: sanitizar tildes y acentos en init_db.py para evitar errores UnicodeEncodeError al inicializar la base de datos 2026-09-04 21:41:57 -03:00
carlostellocba 63c9c6f935 fix: update SQLAlchemy for Python 3.13 compatibility 2026-09-04 21:30:12 -03:00
carlostellocba 5e91e131f0 fix: update psycopg2-binary to support Python 3.13 compilation 2026-09-04 21:25:57 -03:00
carlostellocba c7ce5e4e67 fix: rewrite install.sh con deteccion de Python y soporte Pillow en Python 3.13 2026-09-04 21:15:18 -03:00
carlostellocba 957f364cc3 Se corrigio install que fallaba al instalar python 2026-09-04 21:07:45 -03:00
carlostellocba 7cd70be274 Se actualizo README.md con informacion para el deploy 2026-09-04 21:03:02 -03:00
carlostellocba 5dcf3b75f1 Se crear el archivo install.sh para realizar el deploy de la app 2026-09-04 20:56:50 -03:00
Carlos Tello 7933523d7c feat: vista previa y modificacion de clases en salas virtuales y busqueda por codigo de materia 2026-09-03 20:36:20 -03:00
Carlos Tello df098e5116 docs: actualizar estructura del proyecto y modulos de gestion en README.md 2026-09-03 12:25:16 -03:00
Carlos Tello bbe8e220ae feat: modulo integral de gestion (8 pilares) y limpieza de breadcrumbs redundantes 2026-09-03 11:59:47 -03:00
Carlos Tello adb6ba729d feat: separacion de aulas fisicas y campus virtual a demanda con criterio cloud de concurrencia ilimitada por comision 2026-09-03 11:33:10 -03:00
Carlos Tello c9dc3d9208 feat: optimizacion zocalos aulas, metricas ocupacion dia/semana/mes, modificacion de reservas y permisos RBAC en gestion 2026-09-03 11:16:07 -03:00
Carlos Tello cde62c9b0a feat: fecha y horario en aula, fix division by zero, soporte enlace virtual y mejora contrastes comisiones 2026-09-03 10:12:25 -03:00
Carlos Tello 5f0476be79 fix(schedule): corregir casteo de filtro de piso para compatibilidad con VARCHAR en PostgreSQL 2026-09-03 05:28:10 -03:00
Carlos Tello 6ccc9e8c87 feat: refactorizar espacios fisicos desacoplando Edificio, Piso y Aula con selectores en cascada y filtros 2026-09-03 05:18:01 -03:00
Carlos Tello 5e0b905e55 fix: corregir repeticion del nombre de aula en detalles de reserva y selector 2026-09-03 04:42:29 -03:00
Carlos Tello f3a4b5875e feat: abm comisiones, hitos, links virtuales, eliminacion usuarios y roles bedelia y alumno 2026-09-03 04:34:54 -03:00
Carlos Tello 8809ff3410 feat: rediseno de calendario estilo Google Calendar y correccion de modal de roles 2026-09-03 04:10:36 -03:00
Carlos Tello 291e537574 feat: catalogo dinamico de tipificaciones de hitos, matriz RBAC y desacoplamiento en modal de eventos 2026-09-03 03:48:49 -03:00
Carlos Tello 0c867e0031 fix: restaurar acceso a Optimizador de Aulas (IA) en Accesos Rapidos y menu de cronograma 2026-09-03 03:17:15 -03:00
Carlos Tello 9521b86399 docs: actualizar README.md en espanol con documentacion completa de UniCABA 2026-09-03 03:12:32 -03:00
Carlos Tello 5cc33cc7a0 feat: gestion RBAC granular, importacion sheets, filtrado de aulas virtuales y pisos, capacidad infinita y traduccion 2026-09-03 03:08:49 -03:00
Carlos Tello d1d4be0428 feat(i18n & ux): auditoria, traduccion completa, correcciones ux y gestion de idioma en perfil 2026-09-02 23:45:13 -03:00
212 changed files with 36454 additions and 4574 deletions
-7
View File
@@ -1,7 +0,0 @@
FLASK_APP=app.py
FLASK_ENV=development
SECRET_KEY=your-secret-key-here-please-change-in-production
DATABASE_URL=postgresql://postgres:password@localhost:5432/classrooms_db
DB_USERNAME=postgres
DB_PASSWORD=password
API_BASE_URL=http://localhost:8080/api
+24
View File
@@ -0,0 +1,24 @@
# ==============================================================================
# Edu-Space Admin - Plantilla de Variables de Entorno
# Copie este archivo como .env y complete con valores de producción seguros
# ==============================================================================
# Entorno Flask
FLASK_APP=wsgi.py
FLASK_ENV=production
# Seguridad: Clave secreta para sesiones y protección CSRF
# Generar una clave aleatoria con: python -c 'import secrets; print(secrets.token_hex(32))'
SECRET_KEY=change-this-to-a-secure-random-secret-key-in-production
# Base de Datos PostgreSQL
DATABASE_URL=postgresql://postgres:password@localhost:5432/classrooms_db
DB_USERNAME=postgres
DB_PASSWORD=password
# Integración con API externa (Microservicio Spring Boot)
API_BASE_URL=http://localhost:8080/api
# Configuración de Internacionalización (Babel)
BABEL_DEFAULT_LOCALE=es
BABEL_DEFAULT_TIMEZONE=America/Argentina/Buenos_Aires
+14
View File
@@ -57,6 +57,9 @@ cover/
*.mo *.mo
*.pot *.pot
# Examples
example/
# Django stuff: # Django stuff:
*.log *.log
local_settings.py local_settings.py
@@ -207,3 +210,14 @@ cython_debug/
marimo/_static/ marimo/_static/
marimo/_lsp/ marimo/_lsp/
__marimo__/ __marimo__/
# Node.js / Frontend
node_modules/
npm-debug.log
.env
.env.*
!.env.example
frontend/.env
legacy_admin-edu-space/
.vscode/
+6
View File
@@ -0,0 +1,6 @@
{
"python.defaultInterpreterPath": "${workspaceFolder}\\venv\\Scripts\\python.exe",
"python.analysis.extraPaths": [
"${workspaceFolder}\\venv\\Lib\\site-packages"
]
}
+129
View File
@@ -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.
+223
View File
@@ -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`**.
+114 -244
View File
@@ -1,249 +1,119 @@
# Edu-Space Admin Panel # Panel de Administración Edu-Space (UniCABA)
A comprehensive web-based administration interface for the Edu-Space Classroom Reservation System built with Flask. 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)**.
## Features El proyecto se basa en una arquitectura **desacoplada (Frontend / Backend)** en formato monorepo.
### 🔐 Authentication & Security
- Secure login system with hashed passwords
- Role-based access control
- Session management with Flask-Login
- Password change functionality
### 🏫 Classroom Management
- Create, edit, and delete classrooms
- Campus and building organization
- Capacity and resource tracking
- Real-time availability checking
### 📅 Schedule Management
- Interactive calendar view (FullCalendar)
- Reservation creation and management
- Conflict detection and prevention
- Status tracking (Pending, Confirmed, Cancelled)
- Today's schedule overview
### 📊 Dashboard & Analytics
- Real-time statistics
- Monthly reservation charts
- Quick actions interface
- Upcoming reservations overview
### 🎨 Modern UI/UX
- Responsive Bootstrap 5 design
- Dark theme support
- Mobile-friendly interface
- Interactive components and charts
## Tech Stack
- **Backend**: Flask (Python)
- **Database**: SQLAlchemy (SQLite/PostgreSQL)
- **Frontend**: Bootstrap 5, jQuery, Chart.js
- **Authentication**: Flask-Login, Werkzeug
- **Forms**: Flask-WTF
- **Calendar**: FullCalendar.js
## Installation
### Prerequisites
- Python 3.8+
- pip package manager
### Setup
1. **Clone or setup the project:**
```bash
cd /Users/alejandro.v/PycharmProjects/admin-edu-space
```
2. **Create virtual environment:**
```bash
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
3. **Install dependencies:**
```bash
pip install -r requirements.txt
```
4. **Setup environment variables:**
```bash
cp .env.example .env
# Edit .env with your configuration
```
5. **Initialize database:**
```bash
python app.py
```
## Configuration
### Environment Variables (.env)
```env
FLASK_APP=app.py
FLASK_ENV=development
SECRET_KEY=your-secret-key-here-please-change-in-production
DATABASE_URL=sqlite:///edu_space.db
API_BASE_URL=http://localhost:8080/api
```
## Usage
### Start the Application
```bash
python app.py
```
The application will be available at: `http://localhost:5000`
### Default Login
- **Email**: admin@edu-space.com
- **Password**: admin123
### Main Features
1. **Dashboard**: Overview of system statistics and upcoming reservations
2. **Classrooms**: Manage classroom information, capacity, and resources
3. **Schedule**: View and manage reservations with calendar interface
4. **Profile**: User profile and password management
## API Integration
The admin panel integrates with the Spring Boot API at `http://localhost:8080/api`. Make sure the Java microservice is running for full functionality.
## Project Structure
```
admin-edu-space/
├── app/
│ ├── __init__.py # Flask app factory
│ ├── models/ # Database models
│ │ ├── user.py
│ │ ├── classroom.py
│ │ ├── subject.py
│ │ └── reservation.py
│ ├── routes/ # Application routes
│ │ ├── auth.py
│ │ ├── main.py
│ │ ├── classrooms.py
│ │ └── schedule.py
│ ├── forms/ # WTForms classes
│ │ ├── auth.py
│ │ ├── classroom.py
│ │ └── reservation.py
│ ├── templates/ # Jinja2 templates
│ ├── static/ # Static files
├── config/ # Configuration files
├── requirements.txt
├── .env
├── app.py
└── README.md
```
## Database Schema
### Users Table
- User authentication and authorization
- Role management (admin, staff)
### Classrooms Table
- Classroom information
- Capacity and location details
- Resource management
### Reservations Table
- Booking management
- Time slot scheduling
- Status tracking
### Subjects + Commissions Tables
- Academic information
- Teacher assignments
- Student enrollment tracking
## Security Features
- Password hashing with bcrypt
- Session management
- CSRF protection
- Input validation and sanitization
- SQL injection prevention with SQLAlchemy ORM
## Development
### Running in Development Mode
```bash
export FLASK_ENV=development
python app.py
```
### Adding New Features
1. Create models in `app/models/`
2. Add routes in `app/routes/`
3. Create forms if needed in `app/forms/`
4. Update templates in `app/templates/`
## Production Deployment
### Environment Setup
1. **Set production variables:**
```env
FLASK_ENV=production
SECRET_KEY=<strong-random-string>
DATABASE_URL=<production-database-url>
```
2. **Use production WSGI server:**
```bash
pip install gunicorn
gunicorn -w 4 -b 0.0.0.0:5000 app:app
```
3. **Configure reverse proxy** (nginx/Apache)
## Troubleshooting
### Common Issues
1. **Database connection errors:**
- Check DATABASE_URL string
- Ensure directory permissions
2. **Login issues:**
- Verify admin user exists
- Check password hash
3. **Static files not loading:**
- Ensure blueprint route registration
- Check file permissions
## Contributing
1. Fork the repository
2. Create feature branch
3. Make changes
4. Test thoroughly
5. Submit pull request
## License
This project is part of the Edu-Space educational management system.
## Support
For support and questions:
- Email: admin@edu-space.com
- Documentation: Check inline comments and README files
--- ---
**Note**: This admin panel is designed to work alongside the Spring Boot microservice. Ensure both services are running for complete functionality. ## 🏛️ 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)
+131
View File
@@ -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.
-70
View File
@@ -1,70 +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, main_bp, schedule_bp, genetic_bp, genetic_web_bp
app.register_blueprint(auth_bp, url_prefix="/")
app.register_blueprint(classrooms_bp, url_prefix="/classrooms")
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)
# Babel language selector
@app.context_processor
def inject_conf_vars():
return dict(
languages=app.config['LANGUAGES'],
current_lang=session.get('language', request.accept_languages.best_match(app.config['LANGUAGES']) or app.config['BABEL_DEFAULT_LOCALE'])
)
# Language selector function
def get_locale():
# 1. Check if language is explicitly set in URL parameters
if request.args.get('lang'):
lang = request.args.get('lang')
if lang in app.config['LANGUAGES']:
return lang
# 2. Check if language is stored in session
if 'language' in session:
return session['language']
# 3. Check browser language preference
return request.accept_languages.best_match(app.config['LANGUAGES']) or app.config['BABEL_DEFAULT_LOCALE']
# Configure Babel
babel.init_app(app, locale_selector=get_locale)
return app
-13
View File
@@ -1,13 +0,0 @@
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Email, Length
class LoginForm(FlaskForm):
email = StringField('Email', validators=[
DataRequired(),
Email(),
Length(max=120)
])
password = PasswordField('Password', validators=[DataRequired()])
remember_me = BooleanField('Remember me')
submit = SubmitField('Sign In')
-44
View File
@@ -1,44 +0,0 @@
from flask_wtf import FlaskForm
from wtforms import StringField, IntegerField, TextAreaField, BooleanField, SubmitField, SelectField
from wtforms.validators import DataRequired, Length, NumberRange, Optional
class ClassroomForm(FlaskForm):
# campus_id = SelectField('Campus', coerce=int, validators=[DataRequired()])
building = StringField('Building', validators=[
DataRequired(),
Length(min=2, max=100)
])
room_number = StringField('Room Number', validators=[
DataRequired(),
Length(min=1, max=50)
])
floor = IntegerField('Floor', validators=[
DataRequired(),
NumberRange(min=1, max=50)
])
capacity = IntegerField('Capacity', validators=[
DataRequired(),
NumberRange(min=1, max=500)
])
description = TextAreaField('Description', validators=[
Optional(),
Length(max=500)
])
is_active = BooleanField('Active', default=True)
submit = SubmitField('Save Classroom')
class ClassroomResourceForm(FlaskForm):
resource_type = SelectField('Resource Type', choices=[
('PROJECTOR', 'Projector'),
('WHITEBOARD', 'Whiteboard'),
('COMPUTER', 'Computer'),
('SOUND_SYSTEM', 'Sound System'),
('MICROPHONE', 'Microphone'),
('AIR_CONDITIONER', 'Air Conditioner'),
('OTHER', 'Other')
], validators=[DataRequired()])
brand = StringField('Brand', validators=[Optional(), Length(max=100)])
model = StringField('Model', validators=[Optional(), Length(max=100)])
specifications = TextAreaField('Specifications', validators=[Optional()])
functional = BooleanField('Functional', default=True)
submit = SubmitField('Save Resource')
-17
View File
@@ -1,17 +0,0 @@
from .user import User
from .classroom import Classroom, ClassroomResource
from .subject import Subject, Commission
from .reservation import Reservation, ReservationStatus
from .genetic_algorithm import GeneticAlgorithm, ReservationOptimizer
__all__ = [
'User',
'Classroom',
'ClassroomResource',
'Subject',
'Commission',
'Reservation',
'ReservationStatus',
'GeneticAlgorithm',
'ReservationOptimizer'
]
-66
View File
@@ -1,66 +0,0 @@
from app import db, login_manager
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime
class User(UserMixin, db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False)
name = db.Column(db.String(100), nullable=False)
role = db.Column(db.String(50), nullable=False)
is_active = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
last_login = db.Column(db.DateTime)
# Virtual fields for future compatibility
@property
def first_name(self):
return self.name.split()[0] if self.name else ''
@property
def last_name(self):
parts = self.name.split()
return ' '.join(parts[1:]) if len(parts) > 1 else ''
# Relationships
reservations = db.relationship('Reservation', backref='user', lazy=True, cascade='all, delete-orphan')
def set_password(self, password):
"""Set password hash"""
self.password_hash = generate_password_hash(password, method='pbkdf2:sha256')
def check_password(self, password):
"""Check password"""
return check_password_hash(self.password_hash, password)
def is_admin(self):
"""Check if user is admin"""
return self.role == 'ADMIN'
def update_last_login(self):
"""Update last login timestamp"""
self.last_login = datetime.utcnow()
db.session.commit()
def to_dict(self):
"""Convert user to dictionary"""
return {
'id': self.id,
'email': self.email,
'name': self.name,
'role': self.role,
'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None,
'last_login': self.last_login.isoformat() if self.last_login else None
}
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))
-7
View File
@@ -1,7 +0,0 @@
from .main import main_bp
from .auth import auth_bp
from .classrooms import classrooms_bp
from .schedule import schedule_bp
from .genetic_algorithm import genetic_bp, genetic_web_bp
__all__ = ['main_bp', 'auth_bp', 'classrooms_bp', 'schedule_bp', 'genetic_bp', 'genetic_web_bp']
-80
View File
@@ -1,80 +0,0 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, session
from flask_login import login_user, logout_user, login_required, current_user
from app import db
from app.models.user import User
from app.forms.auth import LoginForm
from datetime import datetime
auth_bp = Blueprint('auth', __name__)
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
"""Handle user login"""
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()
if user and user.check_password(form.password.data):
if not user.is_active:
flash('Your account has been deactivated. Please contact administrator.', 'error')
return render_template('auth/login.html', form=form)
login_user(user, remember=form.remember_me.data)
user.update_last_login()
next_page = request.args.get('next')
if not next_page or not next_page.startswith('/'):
next_page = url_for('main.dashboard')
flash(f'Welcome back, {user.first_name} {user.last_name}!', 'success')
return redirect(next_page)
else:
flash('Invalid email or password.', 'error')
return render_template('auth/login.html', form=form)
@auth_bp.route('/logout')
@login_required
def logout():
"""Handle user logout"""
logout_user()
flash('You have been logged out successfully.', 'info')
return redirect(url_for('auth.login'))
@auth_bp.route('/profile')
@login_required
def profile():
"""User profile page"""
return render_template('auth/profile.html')
@auth_bp.route('/change_password', methods=['GET', 'POST'])
@login_required
def change_password():
"""Change user password"""
if request.method == 'POST':
current_password = request.form.get('current_password')
new_password = request.form.get('new_password')
confirm_password = request.form.get('confirm_password')
if not current_user.check_password(current_password):
flash('Current password is incorrect.', 'error')
return redirect(url_for('auth.change_password'))
if new_password != confirm_password:
flash('New passwords do not match.', 'error')
return redirect(url_for('auth.change_password'))
if len(new_password) < 6:
flash('Password must be at least 6 characters long.', 'error')
return redirect(url_for('auth.change_password'))
current_user.set_password(new_password)
db.session.commit()
flash('Password changed successfully!', 'success')
return redirect(url_for('auth.profile'))
return render_template('auth/change_password.html')
-230
View File
@@ -1,230 +0,0 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
from flask_login import login_required, current_user
from datetime import datetime, date
from app.models.classroom import Classroom
from app.models.user import User
from app.forms.classroom import ClassroomForm
from app import db
from sqlalchemy import text
classrooms_bp = Blueprint('classrooms', __name__)
@classrooms_bp.route('/add', methods=['GET', 'POST'])
@login_required
def add_classroom():
"""Add a new classroom"""
form = ClassroomForm()
# TODO: Update when campus model is implemented
# form.campus_id.choices = [(1, 'Main Campus')]
if form.validate_on_submit():
try:
existing_classroom = Classroom.query.filter_by(
building=form.building.data,
code=form.room_number.data,
floor=form.floor.data
).first()
if existing_classroom:
flash('Classroom with this building, room number, and floor already exists.', 'error')
return render_template('classrooms/add.html', form=form, title='Add Classroom')
classroom = Classroom(
# campus_id=form.campus_id.data,
building=form.building.data,
code=form.room_number.data,
floor=form.floor.data,
capacity=form.capacity.data,
description=form.description.data,
is_active=form.is_active.data
)
db.session.add(classroom)
db.session.commit()
flash(f'Classroom {classroom.code_display} has been created successfully!', 'success')
return redirect(url_for('classrooms.list_classrooms'))
except Exception as e:
db.session.rollback()
flash(f'Error creating classroom: {str(e)}', 'error')
return render_template('classrooms/add.html', form=form, title='Add Classroom')
@classrooms_bp.route('/list')
@login_required
def list_classrooms():
"""List all classrooms"""
try:
# Get filters
page = request.args.get('page', 1, type=int)
active_only = request.args.get('active_only', 'true').lower() == 'true'
search = request.args.get('search', '', type=str)
# Build query
query = Classroom.query
if active_only:
query = query.filter_by(is_active=True)
if search:
query = query.filter(
db.or_(
Classroom.building.ilike(f'%{search}%'),
Classroom.code.ilike(f'%{search}%'),
Classroom.description.ilike(f'%{search}%')
)
)
# Paginate results
classrooms = query.order_by(Classroom.building, Classroom.floor, Classroom.code).paginate(
page=page, per_page=12, error_out=False
)
return render_template('classrooms/list.html',
classrooms=classrooms,
active_only=active_only,
search=search,
title='Classrooms')
except Exception as e:
flash(f'Error loading classrooms: {str(e)}', 'error')
return render_template('classrooms/list.html', classrooms=None, title='Classrooms')
@classrooms_bp.route('/<int:id>')
@login_required
def view_classroom(id):
"""View classroom details"""
try:
classroom = Classroom.query.get_or_404(id)
# Get upcoming reservations for this classroom
from app.models.reservation import Reservation, ReservationStatus
upcoming_reservations = Reservation.query.filter(
Reservation.classroom_id == id,
Reservation.start_time > datetime.utcnow(),
Reservation.status == ReservationStatus.CONFIRMED.value
).order_by(Reservation.start_time).limit(10).all()
return render_template('classrooms/view.html',
classroom=classroom,
upcoming_reservations=upcoming_reservations,
title=f'Classroom {classroom.code_display}')
except Exception as e:
flash(f'Error loading classroom: {str(e)}', 'error')
return redirect(url_for('classrooms.list_classrooms'))
@classrooms_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def edit_classroom(id):
"""Edit classroom"""
classroom = Classroom.query.get_or_404(id)
form = ClassroomForm(obj=classroom)
# TODO: Update when campus model is implemented
# form.campus_id.choices = [(1, 'Main Campus')]
if form.validate_on_submit():
try:
# Check for duplicates (excluding current classroom)
existing_classroom = Classroom.query.filter(
Classroom.building == form.building.data,
Classroom.code == form.room_number.data,
Classroom.floor == form.floor.data,
Classroom.id != id
).first()
if existing_classroom:
flash('Classroom with this building, room number, and floor already exists.', 'error')
return render_template('classrooms/edit.html', form=form, classroom=classroom, title='Edit Classroom')
# Update classroom
# classroom.campus_id = form.campus_id.data
classroom.building = form.building.data
classroom.code = form.room_number.data
classroom.floor = form.floor.data
classroom.capacity = form.capacity.data
classroom.description = form.description.data
classroom.is_active = form.is_active.data
classroom.updated_at = datetime.utcnow()
db.session.commit()
flash(f'Classroom {classroom.code_display} has been updated successfully!', 'success')
return redirect(url_for('classrooms.view_classroom', id=classroom.id))
except Exception as e:
db.session.rollback()
flash(f'Error updating classroom: {str(e)}', 'error')
return render_template('classrooms/edit.html', form=form, classroom=classroom, title='Edit Classroom')
@classrooms_bp.route('/<int:id>/toggle', methods=['POST'])
@login_required
def toggle_classroom(id):
"""Toggle classroom active status"""
classroom = Classroom.query.get_or_404(id)
try:
classroom.is_active = not classroom.is_active
classroom.updated_at = datetime.utcnow()
db.session.commit()
status = 'activated' if classroom.is_active else 'deactivated'
flash(f'Classroom {classroom.code_display} has been {status} successfully!', 'success')
except Exception as e:
db.session.rollback()
flash(f'Error updating classroom status: {str(e)}', 'error')
return redirect(request.referrer or url_for('classrooms.list_classrooms'))
# API endpoints for AJAX requests
@classrooms_bp.route('/api/available')
@login_required
def get_available_classrooms():
"""Get available classrooms for a specific time slot"""
start_time = request.args.get('start_time')
end_time = request.args.get('end_time')
if not start_time or not end_time:
return jsonify({'error': 'Start time and end time are required'}), 400
try:
from datetime import datetime
from app.models.reservation import Reservation, ReservationStatus
start_dt = datetime.fromisoformat(start_time)
end_dt = datetime.fromisoformat(end_time)
# Find all classrooms
all_classrooms = Classroom.query.filter_by(is_active=True).all()
available_classrooms = []
for classroom in all_classrooms:
# Check if classroom has conflicting reservations
conflicts = Reservation.query.filter(
Reservation.classroom_id == classroom.id,
Reservation.status == ReservationStatus.CONFIRMED.value,
Reservation.start_time < end_dt,
Reservation.end_time > start_dt
).all()
if not conflicts:
available_classrooms.append({
'id': classroom.id,
'code': classroom.code,
'building': classroom.building,
'room_number': classroom.code,
'floor': classroom.floor,
'capacity': classroom.capacity,
'code_display': classroom.code_display
})
return jsonify({'classrooms': available_classrooms})
except Exception as e:
return jsonify({'error': str(e)}), 500
-127
View File
@@ -1,127 +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.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 current_user.role not in ['ADMIN']:
flash('You do not have permission to access the optimization tool.', '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 = 'en'
session['language'] = language
return redirect(request.referrer or url_for('main.dashboard'))
-409
View File
@@ -1,409 +0,0 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required, current_user
from app import db
from app.models.reservation import Reservation, ReservationStatus
from app.models.classroom import Classroom
from app.models.subject import Subject, Commission
from app.forms.reservation import ReservationForm
from datetime import datetime, timedelta, date
from sqlalchemy import func, or_
from sqlalchemy.orm import joinedload
schedule_bp = Blueprint('schedule', __name__, url_prefix='/schedule')
@schedule_bp.route('/today')
@login_required
def today_schedule():
"""Show today's schedule"""
today = datetime.utcnow().date()
today_start = datetime.combine(today, datetime.min.time())
today_end = datetime.combine(today, datetime.max.time())
# Get reservations with eager loading
reservations = Reservation.query.options(
joinedload(Reservation.classroom),
joinedload(Reservation.commission),
joinedload(Reservation.user)
).filter(
Reservation.start_time >= today_start,
Reservation.start_time <= today_end,
Reservation.status.in_([ReservationStatus.CONFIRMED.value, ReservationStatus.PENDING.value])
).order_by(Reservation.start_time).all()
# Create time blocks for timeline view
time_blocks = {}
for reservation in reservations:
start_hour = reservation.start_time.hour
end_hour = reservation.end_time.hour
for hour in range(max(7, start_hour), min(22, end_hour + 1)):
if hour not in time_blocks:
time_blocks[hour] = []
if reservation not in time_blocks[hour]:
time_blocks[hour].append({
'reservation': reservation,
'classroom': reservation.classroom,
'commission': reservation.commission,
'user': reservation.user
})
# Calculate stats
total_reservations = len(reservations)
confirmed = len([r for r in reservations if r.status == ReservationStatus.CONFIRMED.value])
pending = len([r for r in reservations if r.status == ReservationStatus.PENDING])
# Get total classrooms and calculate occupied ones
from app.models.classroom import Classroom
total_classrooms = Classroom.query.count()
occupied_classrooms = len(set(r.classroom_id for r in reservations if r.classroom_id))
stats = {
'total_reservations': total_reservations,
'confirmed': confirmed,
'pending': pending,
'occupied_classrooms': occupied_classrooms,
'total_classrooms': total_classrooms
}
return render_template('schedule/today.html',
reservations=reservations,
time_blocks=time_blocks,
stats=stats,
today=today)
@schedule_bp.route('/cancel/<int:id>', methods=['POST'])
@login_required
def cancel_reservation(id):
"""Cancel a reservation"""
try:
reservation = Reservation.query.get_or_404(id)
if not reservation.can_be_cancelled():
flash('Reservations can only be cancelled at least 24 hours before start time.', 'error')
return redirect(request.referrer)
reservation.status = ReservationStatus.CANCELLED
db.session.commit()
flash('Reservation cancelled successfully!', 'success')
except Exception as e:
db.session.rollback()
flash(f'Error cancelling reservation: {str(e)}', 'error')
return redirect(request.referrer)
@schedule_bp.route('/reservation/<int:id>/view')
@login_required
def view_reservation(id):
"""View reservation details"""
try:
reservation = Reservation.query.options(
joinedload(Reservation.classroom),
joinedload(Reservation.commission),
joinedload(Reservation.user)
).get_or_404(id)
return render_template('schedule/view.html',
reservation=reservation,
title='Reservation Details')
except Exception as e:
flash(f'Error loading reservation details: {str(e)}', 'error')
return redirect(url_for('schedule.today_schedule'))
@schedule_bp.route('/calendar')
@login_required
def calendar_view():
"""Show calendar view"""
return render_template('schedule/calendar.html', title='Calendar View')
@schedule_bp.route('/commissions')
def commissions_view():
subjects = Subject.query.all()
# Calculamos el total de capacidad sumando todas las comisiones de todas las materias
total_capacity = sum(c.max_students for s in subjects for c in s.commissions)
# Calculamos el total de comisiones
total_commissions = sum(len(s.commissions) for s in subjects)
return render_template('schedule/commissions.html',
subjects=subjects,
total_capacity=total_capacity,
total_commissions=total_commissions)
@schedule_bp.route('/add', methods=['GET', 'POST'])
@login_required
def add_reservation():
"""Add new reservation"""
from app.models.classroom import Classroom
from app.models.subject import Commission
form = ReservationForm()
# Get active classrooms for the dropdown
classrooms = Classroom.query.filter_by(is_active=True).all()
# Get active commissions for the dropdown
commissions = Commission.query.filter_by(active=True).all()
# Populate form choices
form.classroom_id.choices = [(c.id, f'{c.code_display} - {c.name} (Cap: {c.capacity})') for c in classrooms]
form.commission_id.choices = [(c.id, f'{c.get_full_code()} - {c.subject.name if c.subject else "No Subject"} (Students: {c.max_students})') for c in commissions]
if request.method == 'POST':
if form.validate_on_submit():
try:
# Check for existing reservations at the same time
from app.models.reservation import ReservationStatus
existing_reservation = Reservation.query.filter_by(
classroom_id=form.classroom_id.data,
status=ReservationStatus.CONFIRMED.value
).filter(
Reservation.start_time < form.end_time.data,
Reservation.end_time > form.start_time.data
).first()
if existing_reservation:
flash('This classroom is already booked for the selected time slot.', 'danger')
return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions)
# Create new reservation
reservation = Reservation(
classroom_id=form.classroom_id.data,
commission_id=form.commission_id.data,
user_id=current_user.id,
start_time=form.start_time.data,
end_time=form.end_time.data,
purpose=form.purpose.data,
notes=form.notes.data,
status=ReservationStatus.PENDING.value,
expected_attendees=form.expected_attendees.data
)
db.session.add(reservation)
db.session.commit()
flash('Reservation request submitted successfully! It will be reviewed by an administrator.', 'success')
return redirect(url_for('schedule.list_reservations'))
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error creating reservation: {str(e)}")
flash(f'Error creating reservation: {str(e)}', 'danger')
return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions)
@schedule_bp.route('/list')
@login_required
def list_reservations():
"""List all reservations"""
return redirect(url_for('schedule.today_schedule'))
@schedule_bp.route('/reservation/<int:id>/confirm', methods=['GET', 'POST'])
@login_required
def confirm_reservation(id):
"""Confirm a reservation (Handles both GET from link and POST from form)"""
try:
reservation = Reservation.query.get_or_404(id)
# Validación de seguridad: Solo confirmar si está pendiente
if reservation.status != ReservationStatus.PENDING.value:
flash('Only pending reservations can be confirmed.', 'warning')
return redirect(url_for('schedule.today_schedule'))
reservation.status = ReservationStatus.CONFIRMED.value
db.session.commit()
flash(
f'Reservation for {reservation.commission.code if reservation.commission else "Class"} confirmed successfully!',
'success')
except Exception as e:
db.session.rollback()
flash(f'Error confirming reservation: {str(e)}', 'error')
# Redirigir a la página desde la que venía el usuario o al inicio de hoy
return redirect(request.referrer or url_for('schedule.today_schedule'))
# API endpoints
@schedule_bp.route('/api/classroom-availability')
@login_required
def classroom_availability_api():
"""Get classroom availability for a specific date/time"""
try:
date_str = request.args.get('date')
start_time_str = request.args.get('start_time')
end_time_str = request.args.get('end_time')
max_students = request.args.get('max_students', type=int)
if not all([date_str, start_time_str, end_time_str]):
return jsonify({'error': 'Missing required parameters: date, start_time, end_time'}), 400
# Parse datetime strings
from datetime import datetime
start_time = datetime.strptime(f"{date_str} {start_time_str}", "%Y-%m-%d %H:%M")
end_time = datetime.strptime(f"{date_str} {end_time_str}", "%Y-%m-%d %H:%M")
# Get availability status
from app.models.classroom import Classroom
availability = Classroom.get_availability_status(start_time, end_time)
# Get best classroom recommendation if max_students provided
best_recommendation = None
if max_students:
best_classrooms = Classroom.find_best_classroom(max_students, start_time, end_time)
if best_classrooms:
best_recommendation = best_classrooms[0] # Most efficient one
# Format response
response = {
'availability': {
'available': [
{
'id': item['classroom'].id,
'code': item['classroom'].code_display,
'name': item['classroom'].name,
'capacity': item['classroom'].capacity,
'location': item['classroom'].get_full_location()
}
for item in availability['available']
],
'occupied': [
{
'id': item['classroom'].id,
'code': item['classroom'].code_display,
'name': item['classroom'].name,
'capacity': item['classroom'].capacity,
'location': item['classroom'].get_full_location(),
'conflicts': [
{
'start_time': conflict.start_time.strftime('%H:%M'),
'end_time': conflict.end_time.strftime('%H:%M'),
'commission': conflict.commission.get_full_code() if conflict.commission else 'Unknown'
}
for conflict in item['conflicts']
]
}
for item in availability['occupied']
],
'stats': {
'total_available': availability['total_available'],
'total_occupied': availability['total_occupied']
}
}
}
if best_recommendation:
response['recommendation'] = {
'classroom': {
'id': best_recommendation['classroom'].id,
'code': best_recommendation['classroom'].code_display,
'name': best_recommendation['classroom'].name,
'capacity': best_recommendation['classroom'].capacity,
'location': best_recommendation['classroom'].get_full_location()
},
'efficiency': best_recommendation['efficiency'],
'wasted_capacity': best_recommendation['wasted_capacity'],
'reason': 'Best match based on capacity and availability'
}
return jsonify(response)
except ValueError as e:
return jsonify({'error': f'Invalid date/time format: {str(e)}'}), 400
except Exception as e:
return jsonify({'error': str(e)}), 500
@schedule_bp.route('/calendar-data')
@login_required
def calendar_data():
"""Get calendar events data for FullCalendar"""
try:
# Get date range from query parameters
start = request.args.get('start')
end = request.args.get('end')
if start and end:
from datetime import datetime
start_date = datetime.fromisoformat(start)
end_date = datetime.fromisoformat(end)
else:
# Default to current month
today = date.today()
start_date = datetime.combine(today.replace(day=1), datetime.min.time())
next_month = today.replace(day=28) + timedelta(days=4) # Next month
end_date = datetime.combine(next_month.replace(day=1), datetime.max.time())
# Get reservations in the date range
reservations = Reservation.query.options(
joinedload(Reservation.classroom),
joinedload(Reservation.commission)
).filter(
Reservation.start_time >= start_date,
Reservation.start_time <= end_date,
Reservation.status.in_([ReservationStatus.CONFIRMED.value, ReservationStatus.PENDING.value])
).order_by(Reservation.start_time).all()
events = []
for reservation in reservations:
events.append({
'id': reservation.id,
'title': f"{reservation.commission.get_full_code() if reservation.commission else 'No Commission'} - {reservation.classroom.code_display}",
'start': reservation.start_time.isoformat(),
'end': reservation.end_time.isoformat(),
'url': url_for('schedule.view_reservation', id=reservation.id) if reservation.id else None,
'className': f'fc-event-{reservation.status.value.lower()}',
'extendedProps': {
'classroom': reservation.classroom.code_display if reservation.classroom else 'Unknown',
'commission': reservation.commission.get_full_code() if reservation.commission else 'No Commission',
'purpose': reservation.purpose or 'No purpose',
'attendees': reservation.expected_attendees,
'status': reservation.status.value,
'notes': reservation.notes or ''
}
})
return jsonify(events)
except Exception as e:
return jsonify({'error': str(e)}), 500
@schedule_bp.route('/api/today-events')
@login_required
def api_today_events():
"""Get today's events as JSON"""
try:
today_start = datetime.combine(date.today(), datetime.min.time())
today_end = datetime.combine(date.today(), datetime.max.time())
reservations = Reservation.query.options(
joinedload(Reservation.classroom),
joinedload(Reservation.commission)
).filter(
Reservation.start_time >= today_start,
Reservation.start_time <= today_end,
Reservation.status == ReservationStatus.CONFIRMED.value
).order_by(Reservation.start_time).all()
events = []
for reservation in reservations:
events.append({
'id': reservation.id,
'title': f"{reservation.commission.get_full_code() if reservation.commission else 'No Commission'} - {reservation.classroom.code_display}",
'start': reservation.start_time.isoformat(),
'end': reservation.end_time.isoformat(),
'url': url_for('schedule.view_reservation', id=reservation.id) if reservation.id else None,
'extendedProps': {
'classroom': reservation.classroom.code_display if reservation.classroom else 'Unknown',
'purpose': reservation.purpose or 'No purpose',
'attendees': reservation.expected_attendees,
'status': reservation.status.value
}
})
return jsonify({'events': events})
except Exception as e:
return jsonify({'error': str(e)}), 500
-220
View File
@@ -1,220 +0,0 @@
/* Custom CSS for Edu-Space Admin */
/* Dark Theme Enhancements */
body {
background-color: #1a1a1a;
}
/* Card Styles */
.card {
border-radius: 15px;
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
/* Navigation */
.navbar {
backdrop-filter: blur(10px);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.navbar-brand {
font-weight: 600;
}
/* Button Styles */
.btn {
border-radius: 8px;
font-weight: 500;
transition: all 0.2s ease-in-out;
}
.btn:hover {
transform: translateY(-1px);
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
}
.btn-primary:hover {
background: linear-gradient(135deg, #5568d8 0%, #654198 100%);
}
/* Form Styles */
.form-control, .form-select {
border-radius: 8px;
border: 1px solid #444;
background-color: #2a2a2a;
color: #fff;
transition: border-color 0.2s ease-in-out;
}
.form-control:focus, .form-select:focus {
border-color: #667eea;
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
background-color: #2a2a2a;
color: #fff;
}
.table {
background-color: transparent;
}
.table {
background-color: transparent;
}
.table:hover tbody tr:hover {
background-color: rgba(102, 126, 234, 0.05);
}
/* Badge Styles */
.badge {
font-size: 0.75em;
padding: 0.5em 0.8em;
}
/* Status Badge Colors */
.bg-confirmed-custom {
background-color: #28a745;
}
.bg-pending-custom {
background-color: #ffc107;
color: #000;
}
.bg-cancelled-custom {
background-color: #dc3545;
}
/* Loading Spinner */
.loading-spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: #667eea;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Calendar Customizations */
.fc-event {
border-radius: 6px;
border: none !important;
padding: 4px 8px !important;
font-size: 12px !important;
margin: 1px 0 !important;
}
.fc-day-sat, .fc-day-sun {
background-color: rgba(255, 255, 255, 0.02) !important;
}
/* Dashboard Stat Cards */
.stat-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 15px;
}
.stat-card-primary { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
.stat-card-success { background: linear-gradient(135deg, #28a745 0%, #20c997 100%); }
.stat-card-info { background: linear-gradient(135deg, #17a2b8 0%, #138496 100%); }
.stat-card-warning { background: linear-gradient(135deg, #ffc107 0%, #fd7e14 100%); }
/* Quick Action Buttons */
.quick-action-btn {
text-align: left;
padding: 12px 16px;
margin-bottom: 8px;
border-radius: 10px;
transition: all 0.2s ease-in-out;
}
.quick-action-btn:hover {
transform: translateX(5px);
}
/* Empty State Styles */
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: #6c757d;
}
.empty-state i {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
/* Notification Toastenhancements */
.toast {
backdrop-filter: blur(10px);
border-radius: 10px;
}
/* Pagination Styles */
.pagination .page-link {
border-radius: 8px;
margin: 0 2px;
border: 1px solid #444;
background-color: #2a2a2a;
color: #fff;
}
.pagination .page-link:hover {
background-color: #667eea;
border-color: #667eea;
color: white;
}
.pagination .page-item.active .page-link {
background-color: #667eea;
border-color: #667eea;
}
/* Mobile Responsiveness */
@media (max-width: 768px) {
.card {
margin-bottom: 1rem;
}
.btn-group {
flex-direction: column;
}
.btn-group .btn {
margin-bottom: 0.5rem;
}
}
/* Print Styles */
@media print {
.navbar, .btn, .pagination, .dropdown {
display: none !important;
}
.card {
border: 1px solid #ddd !important;
box-shadow: none !important;
break-inside: avoid;
}
body {
background-color: white !important;
color: black !important;
}
}
-100
View File
@@ -1,100 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - Edu-Space Admin</title>
<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">
<style>
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
max-width: 450px;
width: 100%;
}
.logo-section {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 20px 20px 0 0;
padding: 2rem;
text-align: center;
color: white;
}
</style>
</head>
<body>
<div class="login-card">
<div class="logo-section">
<i class="bi bi-building" style="font-size: 3rem;"></i>
<h3 class="mt-3">Edu-Space Admin</h3>
<p class="mb-0">Classroom Management System</p>
</div>
<div class="p-4">
{% 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" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST">
{{ form.hidden_tag() }}
<div class="mb-3">
<label for="email" class="form-label">{{ form.email.label.text }}</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-envelope"></i></span>
{{ form.email(class="form-control", placeholder="Enter your email") }}
</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">{{ form.password.label.text }}</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-lock"></i></span>
{{ form.password(class="form-control", placeholder="Enter your password") }}
</div>
{% for error in form.password.errors %}
<div class="text-danger small mt-1">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-3 form-check">
{{ form.remember_me(class="form-check-input") }}
{{ form.remember_me.label(class="form-check-label") }}
</div>
<div class="d-grid">
{{ form.submit(class="btn btn-primary btn-lg") }}
</div>
</form>
<div class="text-center mt-3">
<small class="text-muted">
<i class="bi bi-shield-lock"></i>
Secure Login • All access is logged
</small>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
-157
View File
@@ -1,157 +0,0 @@
<!DOCTYPE html>
<html lang="{{ session.language or request.accept_languages.best_match(['es', 'en']) or 'en' }}" 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>
<!-- 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">
<!-- Custom CSS -->
<link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
{% block extra_css %}{% endblock %}
</head>
<body>
{% if current_user.is_authenticated %}
<!-- Navigation -->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary fixed-top">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('main.dashboard') }}">
<i class="bi bi-building"></i> Edu-Space Admin
</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> Dashboard
</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> Classrooms
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms') }}">
<i class="bi bi-list"></i> All Classrooms
</a></li>
<li><a class="dropdown-item" href="{{ url_for('classrooms.add_classroom') }}">
<i class="bi bi-plus-circle"></i> Add Classroom
</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> Schedule
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ url_for('schedule.calendar_view') }}">
<i class="bi bi-calendar-week"></i> Calendar View
</a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}">
<i class="bi bi-list-check"></i> All Reservations
</a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.add_reservation') }}">
<i class="bi bi-plus-circle"></i> New Reservation
</a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.today_schedule') }}">
<i class="bi bi-calendar-today"></i> Today's Schedule
</a></li>
</ul>
</li>
</ul>
<ul class="navbar-nav">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-person-circle"></i> {{ current_user.first_name }} {{ current_user.last_name }}
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}">
<i class="bi bi-person"></i> Profile
</a></li>
<li><a class="dropdown-item" href="{{ url_for('auth.change_password') }}">
<i class="bi bi-key"></i> Change Password
</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}">
<i class="bi bi-box-arrow-right"></i> Logout
</a></li>
</ul>
</li>
</ul>
<ul class="navbar-nav">
<!-- Language Switcher -->
<li class="nav-item">
{% include 'partials/language_switcher.html' %}
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-person-circle"></i> {{ current_user.first_name }} {{ current_user.last_name }}
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}">
<i class="bi bi-person"></i> Profile
</a></li>
<li><a class="dropdown-item" href="{{ url_for('auth.change_password') }}">
<i class="bi bi-key"></i> Change Password
</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}">
<i class="bi bi-box-arrow-right"></i> Logout
</a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
{% endif %}
<!-- Flash Messages -->
<div class="toast-container position-fixed bottom-0 end-0 p-3">
{% 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" 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>
<!-- 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>
-198
View File
@@ -1,198 +0,0 @@
{% extends "base.html" %}
{% block title %}Add Classroom - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3">
<i class="bi bi-plus-circle text-primary"></i> Add Classroom
</h1>
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Classrooms
</a>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-8 mx-auto">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0">
<h5 class="mb-0">
<i class="bi bi-door-open text-primary"></i> Classroom Information
</h5>
</div>
<div class="card-body">
<form method="POST" id="classroom-form" novalidate>
{{ form.hidden_tag() }}
<div class="row">
<div class="col-md-4 mb-3">
{{ form.building.label(class="form-label") }}
{{ form.building(class="form-control", placeholder="A") }}
{% if form.building.errors %}
<div class="text-danger small mt-1">
{{ form.building.errors[0] }}
</div>
{% endif %}
</div>
<div class="col-md-4 mb-3">
{{ form.room_number.label(class="form-label") }}
{{ form.room_number(class="form-control", placeholder="101") }}
{% if form.room_number.errors %}
<div class="text-danger small mt-1">
{{ form.room_number.errors[0] }}
</div>
{% endif %}
</div>
<div class="col-md-4 mb-3">
{{ form.floor.label(class="form-label") }}
{{ form.floor(class="form-control") }}
{% if form.floor.errors %}
<div class="text-danger small mt-1">
{{ form.floor.errors[0] }}
</div>
{% endif %}
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
{{ form.capacity.label(class="form-label") }}
{{ form.capacity(class="form-control", placeholder="30") }}
{% if form.capacity.errors %}
<div class="text-danger small mt-1">
{{ form.capacity.errors[0] }}
</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
{{ form.is_active.label(class="form-check-label") }}
<div class="form-check">
{{ form.is_active(class="form-check-input") }}
</div>
</div>
</div>
<div class="mb-4">
{{ form.description.label(class="form-label") }}
{{ form.description(class="form-control", rows="3", placeholder="Optional: Add any additional details about this classroom...") }}
{% if form.description.errors %}
<div class="text-danger small mt-1">
{{ form.description.errors[0] }}
</div>
{% endif %}
</div>
<!-- Preview Section -->
<div class="alert alert-info">
<h6 class="alert-heading">
<i class="bi bi-info-circle"></i> Preview
</h6>
<div id="classroom-preview">
<p class="mb-0">Fill in the form to see a preview of the classroom code:
<span class="fw-bold" id="preview-code">-</span>
</p>
<p class="mb-0">Full location:
<span class="fw-bold" id="preview-location">-</span>
</p>
</div>
</div>
<div class="d-flex justify-content-between">
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle"></i> Cancel
</a>
{{ form.submit(class="btn btn-primary") }}
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Resources Section -->
<div class="row mt-4">
<div class="col-lg-8 mx-auto">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0">
<h5 class="mb-0">
<i class="bi bi-tools text-warning"></i> Resources (Optional)
</h5>
</div>
<div class="card-body">
<p class="text-muted">You can add classroom resources after creating the classroom.</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
$(document).ready(function() {
function updatePreview() {
const building = $('#building').val();
const roomNumber = $('#room_number').val();
const floor = $('#floor').val();
if (building && roomNumber && floor) {
$('#preview-code').text(`${building}-${roomNumber}`);
$('#preview-location').text(`${building} - Floor ${floor}`);
} else {
$('#preview-code').text('-');
$('#preview-location').text('-');
}
}
// Update preview when inputs change
$('#building, #room_number, #floor').on('input', updatePreview);
// Initialize preview
updatePreview();
// Form validation
$('#classroom-form').on('submit', function(e) {
let isValid = true;
// Required fields validation
if (!$('#building').val().trim()) {
$('#building').addClass('is-invalid');
isValid = false;
} else {
$('#building').removeClass('is-invalid');
}
if (!$('#room_number').val().trim()) {
$('#room_number').addClass('is-invalid');
isValid = false;
} else {
$('#room_number').removeClass('is-invalid');
}
if (!$('#floor').val()) {
$('#floor').addClass('is-invalid');
isValid = false;
} else {
$('#floor').removeClass('is-invalid');
}
if (!$('#capacity').val() || $('#capacity').val() < 1) {
$('#capacity').addClass('is-invalid');
isValid = false;
} else {
$('#capacity').removeClass('is-invalid');
}
return isValid;
});
});
</script>
{% endblock %}
-201
View File
@@ -1,201 +0,0 @@
{% extends "base.html" %}
{% block title %}Classrooms - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3">
<i class="bi bi-door-open text-primary"></i> Classrooms
</h1>
<div>
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add Classroom
</a>
</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="row">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-body">
<form method="GET" class="row g-3">
<div class="col-md-4">
<input type="text" name="search" class="form-control"
placeholder="Search by building, room, description..."
value="{{ search }}">
</div>
<div class="col-md-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="active_only"
id="activeOnly" {% if active_only %}checked{% endif %}>
<label class="form-check-label" for="activeOnly">
Show active classrooms only
</label>
</div>
</div>
<div class="col-md-3">
<button type="submit" class="btn btn-outline-primary">
<i class="bi bi-search"></i> Search
</button>
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle"></i> Clear
</a>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Classrooms Grid -->
{% if classrooms and classrooms.items %}
<div class="row mt-4">
{% for classroom in classrooms.items %}
<div class="col-lg-4 col-md-6 mb-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h5 class="card-title mb-1">
{{ classroom.code }}
</h5>
<p class="text-muted small mb-2">
<i class="bi bi-geo-alt"></i> {{ classroom.get_full_location() }}
</p>
</div>
<span class="badge {% if classroom.is_active %}bg-success{% else %}bg-secondary{% endif %}">
{% if classroom.is_active %}Active{% else %}Inactive{% endif %}
</span>
</div>
<div class="row small">
<div class="col-6">
<span class="text-muted">Capacity:</span>
<strong>{{ classroom.capacity }}</strong>
</div>
<div class="col-6">
<span class="text-muted">Building:</span>
<strong>{{ classroom.building }}</strong>
</div>
</div>
{% if classroom.description %}
<p class="card-text small text-muted mt-2">
{{ classroom.description[:100] }}{% if classroom.description|length > 100 %}...{% endif %}
</p>
{% endif %}
<!-- Resources indicator -->
<div class="mt-2">
<small class="text-muted">
<i class="bi bi-tools"></i>
{% if classroom.resources %}
{{ classroom.resources|length }} resources
{% else %}
No resources registered
{% endif %}
</small>
</div>
</div>
<div class="card-footer bg-white border-0">
<div class="d-flex justify-content-between">
<div>
<a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}"
class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> View
</a>
<a href="{{ url_for('classrooms.edit_classroom', id=classroom.id) }}"
class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pencil"></i> Edit
</a>
</div>
<form method="POST" action="{{ url_for('classrooms.toggle_classroom', id=classroom.id) }}"
class="d-inline">
<button type="submit" class="btn btn-sm
{% if classroom.is_active %}btn-outline-warning{% else %}btn-outline-success{% endif %}">
{% if classroom.is_active %}
<i class="bi bi-pause-circle"></i> Deactivate
{% else %}
<i class="bi bi-play-circle"></i> Activate
{% endif %}
</button>
</form>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Pagination -->
{% if classrooms.pages > 1 %}
<nav aria-label="Classroom pagination">
<ul class="pagination justify-content-center">
{% if classrooms.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=classrooms.prev_num, search=search, active_only=active_only) }}">
Previous
</a>
</li>
{% endif %}
{% for page_num in classrooms.iter_pages() %}
{% if page_num %}
{% if page_num != classrooms.page %}
<li class="page-item">
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=page_num, search=search, active_only=active_only) }}">
{{ page_num }}
</a>
</li>
{% else %}
<li class="page-item active">
<span class="page-link">{{ page_num }}</span>
</li>
{% endif %}
{% else %}
<li class="page-item disabled">
<span class="page-link">…</span>
</li>
{% endif %}
{% endfor %}
{% if classrooms.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=classrooms.next_num, search=search, active_only=active_only) }}">
Next
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="row mt-4">
<div class="col-12">
<div class="text-center py-5">
<i class="bi bi-door-open text-muted" style="font-size: 4rem;"></i>
<h4 class="mt-3 text-muted">No classrooms found</h4>
<p class="text-muted">
{% if search %}
No classrooms match your search criteria.
{% else %}
No classrooms have been registered yet.
{% endif %}
</p>
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add First Classroom
</a>
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}
-243
View File
@@ -1,243 +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-0">
<i class="bi bi-speedometer2 text-primary"></i> {% trans %}Dashboard{% endtrans %}
<small class="text-muted ms-2">{% trans %}Overview{% endtrans %}</small>
</h1>
</div>
</div>
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-md-3 mb-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-2">{% trans %}Total Classrooms{% endtrans %}</h6>
<h2 class="mb-0">{{ stats.total_classrooms }}</h2>
</div>
<div class="bg-primary bg-opacity-10 rounded-circle p-3">
<i class="bi bi-door-open text-primary fs-4"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-2">{% trans %}Total Subjects{% endtrans %}</h6>
<h2 class="mb-0">{{ stats.total_subjects }}</h2>
</div>
<div class="bg-success bg-opacity-10 rounded-circle p-3">
<i class="bi bi-book text-success fs-4"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-2">{% trans %}Today's Reservations{% endtrans %}</h6>
<h2 class="mb-0">{{ stats.today_reservations }}</h2>
</div>
<div class="bg-info bg-opacity-10 rounded-circle p-3">
<i class="bi bi-calendar-today text-info fs-4"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-2">{% trans %}This Week{% endtrans %}</h6>
<h2 class="mb-0">{{ stats.week_reservations }}</h2>
</div>
<div class="bg-warning bg-opacity-10 rounded-circle p-3">
<i class="bi bi-calendar-week text-warning fs-4"></i>
</div>
</div>
</div>
</div>
</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 bg-white border-0">
<h5 class="mb-0">
<i class="bi bi-graph-up text-primary"></i> Monthly Reservations
</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 bg-white border-0">
<h5 class="mb-0">
<i class="bi bi-lightning text-warning"></i> Quick Actions
</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-outline-primary">
<i class="bi bi-plus-circle"></i> Add Classroom
</a>
<a href="{{ url_for('schedule.commissions_view') }}" class="btn btn-outline-warning">
<i class="bi bi-people"></i> Manage Commissions
</a>
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-outline-success">
<i class="bi bi-calendar-plus"></i> New Reservation
</a>
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-info">
<i class="bi bi-calendar-today"></i> Today's Schedule
</a>
<a href="{{ url_for('genetic_algorithm_web.genetic_optimizer') }}" class="btn btn-info text-white">
<i class="bi bi-cpu"></i> AI Room Optimizer
</a>
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-secondary">
<i class="bi bi-calendar3"></i> View Calendar
</a>
</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 bg-white border-0">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="bi bi-clock-history text-info"></i> Upcoming Reservations (Next 7 Days)
</h5>
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-sm btn-outline-primary">
View All
</a>
</div>
</div>
<div class="card-body">
{% if upcoming_reservations %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Date & Time</th>
<th>Classroom</th>
<th>Subject</th>
<th>Teacher</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for reservation in upcoming_reservations %}
<tr>
<td>{{ reservation.start_time.strftime('%Y-%m-%d %H:%M') }}</td>
<td>{{ reservation.classroom.code }}</td>
<td>{{ reservation.commission.get_full_code() }}</td>
<td>{{ reservation.commission.teacher_name }}</td>
<td>
<span class="badge bg-{{ 'success' if reservation.status.value == 'CONFIRMED' else 'warning' }}">
{{ reservation.status.value }}
</span>
</td>
<td>
<a href="{{ url_for('schedule.list_reservations') }}"
class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> View
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-4">
<i class="bi bi-calendar-x text-muted" style="font-size: 3rem;"></i>
<p class="text-muted mt-3">No upcoming reservations in the next 7 days</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,27 +0,0 @@
{% if config.LANGUAGES|length > 1 %}
<div class="dropdown language-switcher">
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" type="button" id="languageDropdown" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-globe"></i>
{% if session.language == 'es' or (session.language is none and request.accept_languages.best_match(['es', 'en']) == 'es') %}
Spanish
{% else %}
English
{% endif %}
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="languageDropdown">
{% set current_lang = session.language or request.accept_languages.best_match(['es', 'en']) or 'en' %}
<li>
<a class="dropdown-item {% if current_lang == 'en' %}active{% endif %}"
href="{{ url_for('main.set_language', language='en') }}">
<i class="bi bi-check-circle-fill text-primary me-2"></i>English
</a>
</li>
<li>
<a class="dropdown-item {% if current_lang == 'es' %}active{% endif %}"
href="{{ url_for('main.set_language', language='es') }}">
<i class="bi bi-check-circle-fill text-primary me-2"></i>Español
</a>
</li>
</ul>
</div>
{% endif %}
-331
View File
@@ -1,331 +0,0 @@
{% extends "base.html" %}
{% block title %}Add Reservation - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3">
<i class="bi bi-calendar-plus text-success"></i> New Reservation
</h1>
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Reservations
</a>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-8 mx-auto">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0">
<h5 class="mb-0">
<i class="bi bi-calendar3 text-success"></i> Reservation Details
</h5>
</div>
<div class="card-body">
<form method="POST" id="reservation-form" novalidate>
{{ form.hidden_tag() }}
<div class="row">
<div class="col-md-6 mb-3">
{{ form.classroom_id.label(class="form-label") }}
{{ form.classroom_id(class="form-select", id="classroom-select") }}
{% if form.classroom_id.errors %}
<div class="text-danger small mt-1">
{{ form.classroom_id.errors[0] }}
</div>
{% endif %}
<small class="text-muted">Select a classroom for your reservation</small>
</div>
<div class="col-md-6 mb-3">
{{ form.commission_id.label(class="form-label") }}
{{ form.commission_id(class="form-select", id="commission-select") }}
{% if form.commission_id.errors %}
<div class="text-danger small mt-1">
{{ form.commission_id.errors[0] }}
</div>
{% endif %}
<small class="text-muted">Select the subject/commission</small>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
{{ form.start_time.label(class="form-label") }}
{{ form.start_time(class="form-control", id="start-time") }}
{% if form.start_time.errors %}
<div class="text-danger small mt-1">
{{ form.start_time.errors[0] }}
</div>
{% endif %}
<small class="text-muted">When will the class start?</small>
</div>
<div class="col-md-6 mb-3">
{{ form.end_time.label(class="form-label") }}
{{ form.end_time(class="form-control", id="end-time") }}
{% if form.end_time.errors %}
<div class="text-danger small mt-1">
{{ form.end_time.errors[0] }}
</div>
{% endif %}
<small class="text-muted">When will the class end?</small>
</div>
</div>
<div class="row">
<div class="col-md-8 mb-3">
{{ form.purpose.label(class="form-label") }}
{{ form.purpose(class="form-control", placeholder="Regular class, exam, study session, etc.") }}
{% if form.purpose.errors %}
<div class="text-danger small mt-1">
{{ form.purpose.errors[0] }}
</div>
{% endif %}
</div>
<div class="col-md-4 mb-3">
{{ form.expected_attendees.label(class="form-label") }}
{{ form.expected_attendees(class="form-control", placeholder="30") }}
{% if form.expected_attendees.errors %}
<div class="text-danger small mt-1">
{{ form.expected_attendees.errors[0] }}
</div>
{% endif %}
<small class="text-muted">Number of expected attendees</small>
</div>
</div>
<div class="mb-4">
{{ form.notes.label(class="form-label") }}
{{ form.notes(class="form-control", rows="3", placeholder="Any additional notes or special requirements...") }}
{% if form.notes.errors %}
<div class="text-danger small mt-1">
{{ form.notes.errors[0] }}
</div>
{% endif %}
</div>
<!-- Summary Section -->
<div class="alert alert-info" id="reservation-summary">
<h6 class="alert-heading">
<i class="bi bi-info-circle"></i> Reservation Summary
</h6>
<div id="summary-content">
<p class="mb-2">
<strong>Selected Classroom:</strong>
<span id="summary-classroom">Not selected</span>
</p>
<p class="mb-2">
<strong>Duration:</strong>
<span id="summary-duration">Not calculated</span>
</p>
<p class="mb-0">
<strong>Commission:</strong>
<span id="summary-commission">Not selected</span>
</p>
</div>
</div>
<div class="d-flex justify-content-between">
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle"></i> Cancel
</a>
{{ form.submit(class="btn btn-success") }}
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Instructions Card -->
<div class="row mt-4">
<div class="col-lg-8 mx-auto">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0">
<h5 class="mb-0">
<i class="bi bi-question-circle text-info"></i> Reservation Guidelines
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6 class="text-primary">📅 Booking Rules</h6>
<ul class="small">
<li>Reservations can be made up to 3 months in advance</li>
<li>Cannot book in the past</li>
<li>Maximum duration: 8 hours per reservation</li>
<li>Cancellations must be 24 hours before start time</li>
</ul>
</div>
<div class="col-md-6">
<h6 class="text-warning">🚨 Important Notes</h6>
<ul class="small">
<li>Check classroom capacity before booking</li>
<li>Pending reservations require approval</li>
<li>Time slots must not overlap</li>
<li>System automatically detects conflicts</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
$(document).ready(function() {
// Classroom data for capacity checking
const classroomData = {};
{% for classroom in classroom_list %}
classroomData[{{ classroom.id }}] = {
capacity: {{ classroom.capacity }},
code_display: "{{ classroom.code_display }}",
name: "{{ classroom.name }}"
};
{% endfor %}
function updateSummary() {
const classroomId = $('#classroom-select').val();
const commissionId = $('#commission-select').val();
const startTime = $('#start-time').val();
const endTime = $('#end-time').val();
// Update classroom summary
if (classroomId && classroomData[classroomId]) {
$('#summary-classroom').text(`${classroomData[classroomId].code_display} - ${classroomData[classroomId].name} (Capacity: ${classroomData[classroomId].capacity})`);
} else {
$('#summary-classroom').text('Not selected');
}
// Update commission summary
if (commissionId) {
const commissionText = $('#commission-select option:selected').text();
$('#summary-commission').text(commissionText);
} else {
$('#summary-commission').text('Not selected');
}
// Calculate duration
if (startTime && endTime) {
const start = new Date(startTime);
const end = new Date(endTime);
if (end > start) {
const diffMs = end - start;
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
let durationText = '';
if (diffHours > 0) {
durationText += `${diffHours} hour${diffHours > 1 ? 's' : ''}`;
}
if (diffMins > 0) {
if (durationText) durationText += ' ';
durationText += `${diffMins} minute${diffMins > 1 ? 's' : ''}`;
}
$('#summary-duration').text(durationText);
} else {
$('#summary-duration').text('Invalid time range');
}
} else {
$('#summary-duration').text('Not calculated');
}
}
// Check capacity vs expected attendees
function checkCapacity() {
const classroomId = $('#classroom-select').val();
const attendees = parseInt($('#expected_attendees').val()) || 0;
if (classroomId && classroomData[classroomId] && attendees > 0) {
const capacity = classroomData[classroomId].capacity;
if (attendees > capacity) {
$('#expected_attendees').addClass('is-invalid');
$('#expected_attendees').after(`
<div class="invalid-feedback">
Expected attendees (${attendees}) exceeds classroom capacity (${capacity})
</div>
`);
} else {
$('#expected_attendees').removeClass('is-invalid');
$('#expected_attendees').siblings('.invalid-feedback').remove();
}
}
}
// Update on change
$('#classroom-select, #commission-select, #start-time, #end-time, #expected_attendees').on('change', updateSummary);
$('#expected_attendees').on('input', checkCapacity);
// Initialize summary
updateSummary();
// Form validation
$('#reservation-form').on('submit', function(e) {
let isValid = true;
// Check required fields
if (!$('#classroom-select').val()) {
$('#classroom-select').addClass('is-invalid');
isValid = false;
} else {
$('#classroom-select').removeClass('is-invalid');
}
if (!$('#commission-select').val()) {
$('#commission-select').addClass('is-invalid');
isValid = false;
} else {
$('#commission-select').removeClass('is-invalid');
}
if (!$('#start-time').val()) {
$('#start-time').addClass('is-invalid');
isValid = false;
} else {
$('#start-time').removeClass('is-invalid');
}
if (!$('#end-time').val()) {
$('#end-time').addClass('is-invalid');
isValid = false;
} else {
$('#end-time').removeClass('is-invalid');
}
// Validate time range
const startTime = new Date($('#start-time').val());
const endTime = new Date($('#end-time').val());
if (endTime <= startTime) {
$('#end-time').addClass('is-invalid');
isValid = false;
}
// Check capacity
checkCapacity();
if ($('#expected_attendees').hasClass('is-invalid')) {
isValid = false;
}
return isValid;
});
// Set minimum datetime to current
const now = new Date();
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
$('#start-time, #end-time').attr('min', now.toISOString().slice(0, 16));
});
</script>
{% endblock %}
-173
View File
@@ -1,173 +0,0 @@
{% extends "base.html" %}
{% block title %}Schedule Calendar - Edu-Space Admin{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col-12">
<h1 class="h3 mb-0">
<i class="bi bi-calendar3 text-primary"></i> Schedule Calendar
<small class="text-muted ms-2">View & Manage Reservations</small>
</h1>
</div>
</div>
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0">
<div class="row align-items-center">
<div class="col-md-6">
<h5 class="mb-0">
<i class="bi bi-calendar-week text-info"></i> Reservations Calendar
</h5>
</div>
<div class="col-md-6 text-end">
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add Reservation
</a>
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
<i class="bi bi-list"></i> List View
</a>
</div>
</div>
</div>
<div class="card-body">
<!-- Legend -->
<div class="mb-3">
<div class="d-flex gap-3 flex-wrap">
<div class="d-flex align-items-center">
<span class="badge bg-success me-2" style="width: 20px; height: 20px;"></span>
<small class="text-muted">Confirmed</small>
</div>
<div class="d-flex align-items-center">
<span class="badge bg-warning me-2" style="width: 20px; height: 20px;"></span>
<small class="text-muted">Pending</small>
</div>
</div>
</div>
<!-- Calendar -->
<div id="calendar"></div>
</div>
</div>
<!-- Reservation Details Modal -->
<div class="modal fade" id="reservationModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Reservation Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="reservationDetails">
<!-- Content will be loaded dynamically -->
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_css %}
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@5.11.0/main.min.css" rel="stylesheet">
<style>
#calendar {
margin-top: 20px;
}
.fc-event {
cursor: pointer;
border: none !important;
padding: 2px 6px !important;
font-size: 12px !important;
}
.fc-event-pending {
background-color: #ffc107 !important;
}
.fc-event-confirmed {
background-color: #28a745 !important;
}
.fc-daygrid-day.fc-day-today {
background-color: rgba(102, 126, 234, 0.1) !important;
}
</style>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.11.0/main.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var calendarEl = document.getElementById('calendar');
var calendar = new FullCalendar.Calendar(calendarEl, {
initialView: 'timeGridWeek',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
},
eventTimeFormat: {
hour: '2-digit',
minute: '2-digit',
hour12: false
},
slotMinTime: '07:00:00',
slotMaxTime: '22:00:00',
height: 'auto',
eventClick: function(info) {
// Prevent default link behavior
if (info.event.url) {
// Load reservation details in modal instead of navigation
loadReservationDetails(info.event.url);
return false;
}
},
events: '{{ url_for("schedule.calendar_data") }}',
loading: function(bool) {
// Show loading indicator if needed
}
});
calendar.render();
function loadReservationDetails(url) {
$.get(url)
.done(function(data) {
var html = `
<div class="row">
<div class="col-md-6">
<h6 class="text-muted mb-2">Classroom</h6>
<p class="fw-bold">${data.classroom.code} - ${data.classroom.name}</p>
<p><i class="bi bi-building text-primary"></i> ${data.classroom.building} - Floor ${data.classroom.floor}</p>
</div>
<div class="col-md-6">
<h6 class="text-muted mb-2">Subject</h6>
<p class="fw-bold">${data.commission.subject.code} - ${data.commission.subject.name}</p>
<p><i class="bi bi-person text-info"></i> ${data.commission.teacher_name}</p>
</div>
</div>
<div class="row mt-3">
<div class="col-md-6">
<h6 class="text-muted mb-2">Schedule</h6>
<p><i class="bi bi-clock text-warning"></i> ${new Date(data.start_time).toLocaleString()}</p>
<p><i class="bi bi-clock text-success"></i> ${new Date(data.end_time).toLocaleString()}</p>
</div>
<div class="col-md-6">
<h6 class="text-muted mb-2">Details</h6>
<p><i class="bi bi-people text-primary"></i> ${data.expected_attendees} attendees</p>
<p><span class="badge bg-${data.status === 'CONFIRMED' ? 'success' : 'warning'}">${data.status}</span></p>
</div>
</div>
${data.notes ? `<div class="row mt-3"><div class="col-12"><h6 class="text-muted mb-2">Notes</h6><p>${data.notes}</p></div></div>` : ''}
`;
$('#reservationDetails').html(html);
$('#reservationModal').modal('show');
})
.fail(function() {
$('#reservationDetails').html('<p class="text-danger">Error loading reservation details.</p>');
$('#reservationModal').modal('show');
});
}
});
</script>
{% endblock %}
-390
View File
@@ -1,390 +0,0 @@
{% extends "base.html" %}
{% block title %}Commissions Management - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3">
<i class="bi bi-diagram-3 text-info"></i> Commissions Management
</h1>
<div class="btn-group">
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-success">
<i class="bi bi-plus-circle"></i> New Reservation
</a>
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-secondary">
<i class="bi bi-calendar-day"></i> Today's Schedule
</a>
</div>
</div>
</div>
</div>
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body text-center">
<h2 class="text-primary">{{ subjects|length }}</h2>
<p class="mb-0">Active Subjects</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body text-center">
<h2 class="text-success">{{ subjects|map(attribute='commissions')|map('length')|sum }}</h2>
<p class="mb-0">Total Commissions</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body text-center">
<h2 class="text-success">{{ total_commissions }}</h2>
<h2 class="text-info">{{ total_capacity }}</h2>
<p class="mb-0">Total Student Capacity</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body text-center">
<h2 class="text-warning">{{ subjects|selectattr('commissions')|list|length }}</h2>
<p class="mb-0">Subjects with Commissions</p>
</div>
</div>
</div>
</div>
<!-- Subjects and Commissions -->
<div class="row">
{% for subject in subjects %}
<div class="col-lg-6 mb-4">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0">
<div class="d-flex justify-content-between align-items-start">
<div>
<h5 class="mb-1">
<span class="badge bg-primary me-2">{{ subject.code }}</span>
{{ subject.name }}
</h5>
<p class="text-muted mb-0 small">
<i class="bi bi-building"></i> {{ subject.department }} •
<i class="bi bi-award"></i> {{ subject.credits }} credits
</p>
</div>
<div class="text-end">
<span class="badge bg-info">{{ subject.commissions|length }} commission{{ 's' if subject.commissions|length != 1 else '' }}</span>
</div>
</div>
</div>
<div class="card-body">
{% if subject.commissions %}
<div class="table-responsive">
<table class="table table-sm table-hover">
<thead class="table-light">
<tr>
<th>Commission</th>
<th>Semester</th>
<th>Teacher</th>
<th>Students</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for commission in subject.commissions %}
<tr>
<td>
<strong>{{ commission.code }}</strong>
{% if commission.schedule %}
<br><small class="text-muted">{{ commission.schedule }}</small>
{% endif %}
</td>
<td>
<span class="badge bg-light text-dark">{{ commission.semester }} {{ commission.year }}</span>
</td>
<td>
{% if commission.teacher %}
<small>{{ commission.teacher.first_name }} {{ commission.teacher.last_name }}</small>
{% else %}
<small class="text-muted">Not assigned</small>
{% endif %}
</td>
<td>
<div class="d-flex align-items-center">
<small>{{ commission.current_students }}/{{ commission.max_students }}</small>
{% set fill_percentage = (commission.current_students / commission.max_students * 100) %}
<div class="progress ms-2" style="height: 4px; width: 30px;">
<div class="progress-bar
{% if fill_percentage > 90 %}bg-danger
{% elif fill_percentage > 75 %}bg-warning
{% else %}bg-success
{% endif %}"
style="width: {{ fill_percentage }}%"></div>
</div>
</div>
</td>
<td>
{% if commission.active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Inactive</span>
{% endif %}
</td>
<td>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-primary quick-reserve-btn"
data-commission-id="{{ commission.id }}"
data-max-students="{{ commission.max_students }}"
data-commission-name="{{ commission.get_full_code() }}"
data-subject-name="{{ subject.name }}"
title="Quick Reservation">
<i class="bi bi-calendar-plus"></i>
</button>
<button class="btn btn-outline-info view-schedule-btn"
data-commission-id="{{ commission.id }}"
title="View Schedule">
<i class="bi bi-calendar-week"></i>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center text-muted py-3">
<i class="bi bi-inbox fs-2 d-block mb-2"></i>
No commissions registered for this subject
</div>
{% endif %}
</div>
{% if subject.description %}
<div class="card-footer bg-light">
<small class="text-muted">
<i class="bi bi-info-circle"></i> {{ subject.description|truncatewords(20) }}
</small>
</div>
{% endif %}
</div>
</div>
{% else %}
<div class="col-12">
<div class="text-center py-5">
<i class="bi bi-inbox fs-1 text-muted d-block mb-3"></i>
<h4 class="text-muted">No Subjects Found</h4>
<p class="text-muted">There are no active subjects in the system.</p>
<a href="{{ url_for('classrooms.index') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add New Subject
</a>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- Quick Reservation Modal -->
<div class="modal fade" id="quickReservationModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-calendar-plus text-success"></i> Quick Reservation
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="quick-reservation-form">
<input type="hidden" id="quick-commission-id" name="commission_id">
<div class="alert alert-info">
<h6 class="alert-heading">
<i class="bi bi-info-circle"></i> Commission Details
</h6>
<div id="commission-details"></div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="quick-date" class="form-label">Date</label>
<input type="date" class="form-control" id="quick-date" required>
</div>
<div class="col-md-3 mb-3">
<label for="quick-start-time" class="form-label">Start Time</label>
<input type="time" class="form-control" id="quick-start-time" required>
</div>
<div class="col-md-3 mb-3">
<label for="quick-end-time" class="form-label">End Time</label>
<input type="time" class="form-control" id="quick-end-time" required>
</div>
</div>
<div class="row">
<div class="col-md-12 mb-3">
<label for="quick-purpose" class="form-label">Purpose</label>
<input type="text" class="form-control" id="quick-purpose" placeholder="Regular class, exam, review, etc." required>
</div>
</div>
<!-- Classroom Selection & Availability -->
<div class="mb-3">
<label class="form-label">Select Classroom</label>
<div class="d-flex align-items-center mb-2">
<select class="form-select" id="quick-classroom-select" required>
<option value="">Choose date and time first</option>
</select>
<button type="button" class="btn btn-outline-primary ms-2" id="check-availability-btn" disabled>
<i class="bi bi-search"></i> Check
</button>
</div>
<div id="availability-results" class="mt-3"></div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" id="create-reservation-btn" disabled>
<i class="bi bi-plus-circle"></i> Create Reservation
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
$(document).ready(function() {
// Quick Reservation Button Click
$('.quick-reserve-btn').on('click', function() {
const commissionId = $(this).data('commission-id');
const maxStudents = $(this).data('max-students');
const commissionName = $(this).data('commission-name');
const subjectName = $(this).data('subject-name');
// Set commission details in modal
$('#quick-commission-id').val(commissionId);
$('#commission-details').html(`
<strong>${commissionName}</strong><br>
<small>Subject: ${subjectName}</small><br>
<small>Max Students: ${maxStudents}</small>
`);
// Reset form
$('#quick-date').val('');
$('#quick-start-time').val('');
$('#quick-end-time').val('');
$('#quick-purpose').val('');
$('#quick-classroom-select').html('<option value="">Choose date and time first</option>').prop('disabled', true);
$('#check-availability-btn').prop('disabled', true);
$('#create-reservation-btn').prop('disabled', true);
$('#availability-results').empty();
// Show modal
$('#quickReservationModal').modal('show');
});
// Enable date/time inputs to check availability
function validateDateTimeInputs() {
const date = $('#quick-date').val();
const startTime = $('#quick-start-time').val();
const endTime = $('#quick-end-time').val();
const isValid = date && startTime && endTime && startTime < endTime;
$('#check-availability-btn').prop('disabled', !isValid);
if (isValid) {
loadClassroomOptions();
}
}
$('#quick-date, #quick-start-time, #quick-end-time').on('change', validateDateTimeInputs);
function loadClassroomOptions() {
$.get('/schedule/api/classroom-availability', {
date: $('#quick-date').val(),
start_time: $('#quick-start-time').val(),
end_time: $('#quick-end-time').val(),
max_students: parseInt($('#quick-commission-id').data('max-students'))
}, function(response) {
const classroomSelect = $('#quick-classroom-select');
classroomSelect.empty().append('<option value="">Select a classroom</option>');
// Add available classrooms
response.availability.available.forEach(function(classroom) {
classroomSelect.append(`<option value="${classroom.id}">${classroom.code} - ${classroom.name} (Cap: ${classroom.capacity}) - ${classroom.location}</option>`);
});
// Enable selection
classroomSelect.prop('disabled', response.availability.available.length === 0);
// Show recommendation if available
if (response.recommendation) {
const rec = response.recommendation.classroom;
$('#availability-results').html(`
<div class="alert alert-success">
<h6><i class="bi bi-star-fill"></i> Recommended Classroom</h6>
<strong>${rec.code} - ${rec.name}</strong><br>
<small>Capacity: ${rec.capacity} | Location: ${rec.location}</small><br>
<small class="text-muted">${response.recommendation.reason}</small>
</div>
`);
}
// Show statistics
$('#availability-results').append(`
<div class="alert alert-info mt-2">
<small>
<strong>Availability:</strong> ${response.availability.stats.total_available} available,
${response.availability.stats.total_occupied} occupied classrooms
</small>
</div>
`);
});
});
}
// Check availability button click
$('#check-availability-btn').on('click', loadClassroomOptions);
// Classroom selection enables create button
$('#quick-classroom-select').on('change', function() {
const isValid = $(this).val() && $('#quick-purpose').val();
$('#create-reservation-btn').prop('disabled', !isValid);
});
// Purpose input validation
$('#quick-purpose').on('input', function() {
const isValid = $('#quick-classroom-select').val() && $(this).val();
$('#create-reservation-btn').prop('disabled', !isValid);
});
// Create reservation
$('#create-reservation-btn').on('click', function() {
const formData = {
commission_id: $('#quick-commission-id').val(),
classroom_id: $('#quick-classroom-select').val(),
purpose: $('#quick-purpose').val(),
start_time: `${$('#quick-date').val()} ${$('#quick-start-time').val()}`,
end_time: `${$('#quick-date').val()} ${$('#quick-end-time').val()}`,
expected_attendees: $('#quick-commission-id').data('max-students')
};
$.post('/schedule/reservation/add', formData)
.done(function() {
$('#quickReservationModal').modal('hide');
location.reload();
})
.fail(function(xhr) {
alert('Error creating reservation: ' + xhr.responseJSON.error);
});
});
});
</script>
{% endblock %}
-291
View File
@@ -1,291 +0,0 @@
{% extends "base.html" %}
{% block title %}Reservations List - Edu-Space Admin{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<h1 class="h3 mb-0">
<i class="bi bi-calendar-list text-primary"></i> Reservations
<small class="text-muted ms-2">Manage & View All Reservations</small>
</h1>
<div class="d-flex gap-2">
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary">
<i class="bi bi-calendar3"></i> Calendar View
</a>
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-info">
<i class="bi bi-calendar-day"></i> Today
</a>
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add Reservation
</a>
</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<form method="GET" class="row g-3">
<div class="col-md-3">
<label class="form-label">Status</label>
<select name="status" class="form-select">
<option value="all" {% if status_filter == 'all' %}selected{% endif %}>All Status</option>
<option value="pending" {% if status_filter == 'pending' %}selected{% endif %}>Pending</option>
<option value="confirmed" {% if status_filter == 'confirmed' %}selected{% endif %}>Confirmed</option>
<option value="cancelled" {% if status_filter == 'cancelled' %}selected{% endif %}>Cancelled</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">From Date</label>
<input type="date" name="date_from" class="form-control" value="{{ date_from or '' }}">
</div>
<div class="col-md-3">
<label class="form-label">To Date</label>
<input type="date" name="date_to" class="form-control" value="{{ date_to or '' }}">
</div>
<div class="col-md-3">
<label class="form-label">&nbsp;</label>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-outline-primary">
<i class="bi bi-funnel"></i> Filter
</button>
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle"></i> Clear
</a>
</div>
</div>
</form>
</div>
</div>
<!-- Reservations List -->
{% if reservations and reservations.items %}
<div class="card border-0 shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th class="border-0">Classroom</th>
<th class="border-0">Commission</th>
<th class="border-0">Date & Time</th>
<th class="border-0">Purpose</th>
<th class="border-0">Attendees</th>
<th class="border-0">Status</th>
<th class="border-0">Actions</th>
</tr>
</thead>
<tbody>
{% for reservation in reservations.items %}
<tr>
<td>
<div class="d-flex align-items-center">
<div class="avatar avatar-sm bg-primary text-white rounded-circle d-flex align-items-center justify-content-center me-2">
<i class="bi bi-door-open"></i>
</div>
<div>
<div class="fw-bold">{{ reservation.classroom.code_display }}</div>
<small class="text-muted">Capacity: {{ reservation.classroom.capacity }}</small>
</div>
</div>
</td>
<td>
<div>
<div class="fw-bold">
{% if reservation.commission %}
{{ reservation.commission.get_full_code() or 'N/A' }}
{% else %}
No Commission
{% endif %}
</div>
<small class="text-muted">
{% if reservation.commission and reservation.commission.teacher_name %}
{{ reservation.commission.teacher_name }}
{% else %}
No Teacher
{% endif %}
</small>
</div>
</td>
<td>
<div>
<div class="fw-bold">{{ reservation.start_time.strftime('%b %d, %Y') }}</div>
<small class="text-muted">
{{ reservation.start_time.strftime('%I:%M %p') }} - {{ reservation.end_time.strftime('%I:%M %p') }}
</small>
</div>
</td>
<td>
<span class="text-truncate" style="max-width: 150px; display: inline-block;">
{{ reservation.purpose or 'No purpose specified' }}
</span>
</td>
<td>
<div class="d-flex align-items-center">
<i class="bi bi-people text-muted me-1"></i>
{{ reservation.expected_attendees }}
</div>
</td>
<td>
{% if reservation.status.value == 'confirmed' %}
<span class="badge bg-success">Confirmed</span>
{% elif reservation.status.value == 'pending' %}
<span class="badge bg-warning">Pending</span>
{% elif reservation.status.value == 'cancelled' %}
<span class="badge bg-danger">Cancelled</span>
{% else %}
<span class="badge bg-secondary">{{ reservation.status.value }}</span>
{% endif %}
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
{% if reservation.status.value == 'pending' %}
<form method="POST" action="{{ url_for('schedule.confirm_reservation', id=reservation.id) }}" style="display: inline;">
<button type="submit" class="btn btn-outline-success btn-sm" title="Confirm">
<i class="bi bi-check-circle"></i>
</button>
</form>
{% endif %}
{% if reservation.status.value in ['pending', 'confirmed'] %}
<form method="POST" action="{{ url_for('schedule.cancel_reservation', id=reservation.id) }}" style="display: inline;">
<button type="submit" class="btn btn-outline-danger btn-sm" title="Cancel">
<i class="bi bi-x-circle"></i>
</button>
</form>
{% endif %}
{% if reservation.notes %}
<button type="button" class="btn btn-outline-info btn-sm"
title="{{ reservation.notes }}" data-bs-toggle="tooltip">
<i class="bi bi-info-circle"></i>
</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Pagination -->
{% if reservations.pages > 1 %}
<div class="card-footer bg-white border-0">
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted small">
Showing {{ reservations.items|length }} of {{ reservations.total }} reservations
</span>
<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) }}">
<i class="bi bi-chevron-left"></i>
</a>
</li>
{% endif %}
{% for page_num in reservations.iter_pages() %}
{% if page_num %}
{% if page_num != reservations.page %}
<li class="page-item">
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=page_num, status=status_filter, date_from=date_from, date_to=date_to) }}">
{{ page_num }}
</a>
</li>
{% else %}
<li class="page-item active">
<span class="page-link">{{ page_num }}</span>
</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) }}">
<i class="bi bi-chevron-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
</div>
</div>
{% endif %}
</div>
{% else %}
<div class="card border-0 shadow-sm">
<div class="card-body text-center py-5">
<div class="mb-4">
<i class="bi bi-calendar-x display-1 text-muted"></i>
</div>
<h5 class="text-muted">No reservations found</h5>
<p class="text-muted">
{% if status_filter != 'all' or date_from or date_to %}
No reservations match your current filters.
<a href="{{ url_for('schedule.list_reservations') }}">Clear filters</a> to see all reservations.
{% else %}
There are no reservations in the system yet.
<a href="{{ url_for('schedule.add_reservation') }}">Create your first reservation</a>.
{% endif %}
</p>
<div class="d-flex justify-content-center gap-2 mt-3">
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add Reservation
</a>
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-info">
<i class="bi bi-calendar-day"></i> Today's Schedule
</a>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
// Initialize tooltips
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl)
});
// Form submission confirmation
document.querySelectorAll('form[action*="cancel"]').forEach(function(form) {
form.addEventListener('submit', function(e) {
if (!confirm('Are you sure you want to cancel this reservation?')) {
e.preventDefault();
}
});
});
document.querySelectorAll('form[action*="confirm"]').forEach(function(form) {
form.addEventListener('submit', function(e) {
if (!confirm('Are you sure you want to confirm this reservation?')) {
e.preventDefault();
}
});
});
</script>
{% endblock %}
-406
View File
@@ -1,406 +0,0 @@
{% extends "base.html" %}
{% block title %}Today's Schedule - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3">
<i class="bi bi-calendar-today text-info"></i> Today's Schedule
<small class="text-muted ms-2">{{ today.strftime('%B %d, %Y') }}</small>
</h1>
<div>
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-success">
<i class="bi bi-plus-circle"></i> New Reservation
</a>
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-primary ms-2">
<i class="bi bi-list"></i> All Reservations
</a>
{# Access to Genetic Algorithm Optimizer - Only for admin/teacher roles #}
{% if current_user and current_user.role in ['admin', 'teacher'] %}
<a href="{{ url_for('main.genetic_optimizer') }}" class="btn btn-info text-white ms-2">
<i class="bi bi-cpu"></i> AI Optimizer
</a>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Quick Stats -->
<div class="row mb-4">
<div class="col-md-3 mb-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-2">Total Reservations</h6>
<h2 class="mb-0">{{ stats.total_reservations }}</h2>
</div>
<div class="bg-info bg-opacity-10 rounded-circle p-3">
<i class="bi bi-calendar3 text-info fs-4"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-2">Confirmed</h6>
<h2 class="mb-0">{{ stats.confirmed }}</h2>
</div>
<div class="bg-success bg-opacity-10 rounded-circle p-3">
<i class="bi bi-check-circle text-success fs-4"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-2">Pending</h6>
<h2 class="mb-0">{{ stats.pending }}</h2>
</div>
<div class="bg-warning bg-opacity-10 rounded-circle p-3">
<i class="bi bi-clock text-warning fs-4"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-2">Active Classrooms</h6>
<h2 class="mb-0">{{ stats.occupied_classrooms }}/{{ stats.total_classrooms }}</h2>
</div>
<div class="bg-primary bg-opacity-10 rounded-circle p-3">
<i class="bi bi-door-open text-primary fs-4"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Today's Schedule -->
{% if time_blocks %}
<div class="row">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="bi bi-clock-history text-info"></i> Timeline View
</h5>
<div>
<button class="btn btn-sm btn-outline-primary" id="refresh-btn">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
<button class="btn btn-sm btn-outline-secondary ms-1" id="toggle-view-btn">
<i class="bi bi-grid"></i> Grid View
</button>
</div>
</div>
</div>
<div class="card-body">
<div id="timeline-view">
{% for hour in range(7, 22) %}
<div class="row border-bottom py-3 align-items-start">
<div class="col-md-2 col-3">
<div class="text-center">
<strong>{{ "%02d:00"|format(hour) }}</strong>
<br>
<small class="text-muted">{{ hour+1 }}:00</small>
</div>
</div>
<div class="col-md-10 col-9">
{% if time_blocks[hour] %}
<div class="row">
{% for item in time_blocks[hour] %}
<div class="col-md-6 mb-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<h6 class="card-title mb-1">
{{ item.commission.get_full_code() }}
</h6>
<p class="text-muted small mb-2 mb-md-0">
<i class="bi bi-geo-alt"></i> {{ item.classroom.code }}
<br class="d-md-none">
<i class="bi bi-person ms-md-2"></i> {{ item.user.first_name }} {{ item.user.last_name }}
</p>
</div>
<span class="badge bg-{% if item.reservation.status.value == 'CONFIRMED' %}success{% elif item.reservation.status.value == 'PENDING' %}warning{% else %}secondary{% endif %}">
{{ item.reservation.status.value }}
</span>
</div>
<div class="row small">
<div class="col-md-6">
<i class="bi bi-clock"></i>
{{ item.reservation.start_time.strftime('%H:%M') }} - {{ item.reservation.end_time.strftime('%H:%M') }}
</div>
<div class="col-md-6">
<i class="bi bi-people"></i>
{{ item.reservation.expected_attendees }} attendees
</div>
</div>
{% if item.reservation.purpose %}
<p class="card-text small text-muted mt-2">
{{ item.reservation.purpose }}
</p>
{% endif %}
<div class="mt-2">
<a href="{{ url_for('schedule.view_reservation', id=item.reservation.id) }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> Details
</a>
{% if item.reservation.status.value == 'PENDING' %}
<a href="{{ url_for('schedule.confirm_reservation', id=item.reservation.id) }}"
class="btn btn-sm btn-outline-success ms-1"
onclick="return confirm('Confirm this reservation?')">
<i class="bi bi-check-circle"></i> Confirm
</a>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center text-muted py-3">
<i class="bi bi-circle"></i> No reservations scheduled
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
<!-- Grid View (hidden by default) -->
<div id="grid-view" style="display: none;">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Time</th>
<th>Classroom</th>
<th>Subject</th>
<th>Teacher</th>
<th>Purpose</th>
<th>Attendees</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for reservation in reservations %}
<tr>
<td>
{{ reservation.start_time.strftime('%H:%M') }} -
{{ reservation.end_time.strftime('%H:%M') }}
</td>
<td>
<i class="bi bi-geo-alt"></i>
{{ reservation.classroom.code }}
</td>
<td>{{ reservation.commission.get_full_code() }}</td>
<td>{{ reservation.user.first_name }} {{ reservation.user.last_name }}</td>
<td>{{ reservation.purpose }}</td>
<td>{{ reservation.expected_attendees }}</td>
<td>
<span class="badge bg-{% if reservation.status.value == 'CONFIRMED' %}success{% elif reservation.status.value == 'PENDING' %}warning{% else %}secondary{% endif %}">
{{ reservation.status.value }}
</span>
</td>
<td>
<a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}"
class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i>
</a>
{% if reservation.status.value == 'PENDING' %}
<a href="{{ url_for('schedule.confirm_reservation', id=reservation.id) }}"
class="btn btn-sm btn-outline-success ms-1"
onclick="return confirm('Confirm this reservation?')">
<i class="bi bi-check-circle"></i>
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Daily Summary -->
<div class="row mt-4">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0">
<h5 class="mb-0">
<i class="bi bi-graph-up text-warning"></i> Daily Analytics
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4">
<div class="text-center">
<div class="mb-2">
<i class="bi bi-door-open-fill text-success" style="font-size: 2rem;"></i>
</div>
<h6>Most Active Hours</h6>
<!-- Calculate peak hours from data -->
{% set peak_hours = [] %}
{% for item in reservations %}
{% set slot = item.start_time.hour %}
{% if slot not in peak_hours %}
{% set _ = peak_hours.append(slot) %}
{% endif %}
{% endfor %}
<p class="text-muted">
{% if peak_hours %}
{% for hour in peak_hours[:3]|sort %}
{{ "%02d:00"|format(hour) }}{% if not loop.last %}, {% endif %}
{% endfor %}
{% else %}
No data available
{% endif %}
</p>
</div>
</div>
<div class="col-md-4">
<div class="text-center">
<div class="mb-2">
<i class="bi bi-building text-info" style="font-size: 2rem;"></i>
</div>
<h6>Busiest Classrooms</h6>
<p class="text-muted">
{% if reservations %}
{% set classroom_codes = [] %}
{% for item in reservations %}
{% if item.classroom.code not in classroom_codes %}
{% set _ = classroom_codes.append(item.classroom.code) %}
{% endif %}
{% endfor %}
{% for code in classroom_codes[:3] %}
{{ code }}{% if not loop.last %}, {% endif %}
{% endfor %}
{% else %}
No data available
{% endif %}
</p>
</div>
</div>
<div class="col-md-4">
<div class="text-center">
<div class="mb-2">
<i class="bi bi-clock-fill text-primary" style="font-size: 2rem;"></i>
</div>
<h6>Total Hours Booked</h6>
<p class="text-muted">
{% set total_hours = 0 %}
{% for item in reservations %}
{% set duration = (item.end_time - item.start_time).total_seconds() / 3600 %}
{% set total_hours = (total_hours + duration) %}
{% endfor %}
{{ total_hours|round(1) }} hours total
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% else %}
<div class="row">
<div class="col-12">
<div class="text-center py-5">
<i class="bi bi-calendar-x text-muted" style="font-size: 4rem;"></i>
<h4 class="mt-3 text-muted">No reservations scheduled for today</h4>
<p class="text-muted">
There are no classroom reservations for {{ today.strftime('%B %d, %Y') }}.
</p>
<div class="mt-3">
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-success">
<i class="bi bi-plus-circle"></i> Schedule First Class
</a>
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-primary ms-2">
<i class="bi bi-calendar-week"></i>View Week Schedule
</a>
{# Access to Genetic Algorithm Optimizer - Only for admin/teacher roles #}
{% if current_user and current_user.role in ['admin', 'teacher'] %}
<a href="{{ url_for('main.genetic_optimizer') }}" class="btn btn-info text-white ms-2">
<i class="bi bi-cpu"></i> AI Room Optimizer
</a>
{% endif %}
</div>
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
<script>
$(document).ready(function() {
// Toggle between timeline and grid view
$('#toggle-view-btn').on('click', function() {
const timelineView = $('#timeline-view');
const gridView = $('#grid-view');
const btn = $(this);
if (timelineView.is(':visible')) {
timelineView.hide();
gridView.show();
btn.html('<i class="bi bi-list"></i> Timeline View');
} else {
timelineView.show();
gridView.hide();
btn.html('<i class="bi bi-grid"></i> Grid View');
}
});
// Refresh the page
$('#refresh-btn').on('click', function() {
location.reload();
});
// Auto-refresh every 5 minutes
setInterval(function() {
location.reload();
}, 300000); // 5 minutes = 300000 milliseconds
// Highlight current time
const currentHour = new Date().getHours();
if (currentHour >= 7 && currentHour < 22) {
const timeBlockIndex = currentHour - 7;
$('.row.border-bottom').eq(timeBlockIndex).addClass('bg-primary bg-opacity-10');
}
});
</script>
{% endblock %}
-3
View File
@@ -1,3 +0,0 @@
[python: **.py]
[jinja2: **/templates/**.html]
extensions=jinja2.ext.i18n
+97
View File
@@ -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
+17
View File
@@ -0,0 +1,17 @@
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
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)
],
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'))
+28
View File
@@ -0,0 +1,28 @@
from flask_wtf import FlaskForm
from wtforms import StringField, IntegerField, TextAreaField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Length, NumberRange, Optional
from flask_babel import lazy_gettext as _l
class BuildingForm(FlaskForm):
name = StringField(_l('Building Name'), validators=[
DataRequired(),
Length(min=2, max=100)
])
code = StringField(_l('Code'), validators=[
Optional(),
Length(max=50)
])
address = StringField(_l('Address'), validators=[
Optional(),
Length(max=255)
])
floors = IntegerField(_l('Number of Floors'), default=1, validators=[
DataRequired(),
NumberRange(min=1, max=100)
])
description = TextAreaField(_l('Description'), validators=[
Optional(),
Length(max=500)
])
is_active = BooleanField(_l('Active'), default=True)
submit = SubmitField(_l('Save Building'))
+56
View File
@@ -0,0 +1,56 @@
from flask_wtf import FlaskForm
from wtforms import StringField, IntegerField, TextAreaField, BooleanField, SubmitField, SelectField
from wtforms.validators import DataRequired, InputRequired, Length, NumberRange, Optional, ValidationError
from flask_babel import lazy_gettext as _l
def validate_capacity(form, field):
val = str(field.data or '').strip()
if not val:
raise ValidationError(_l('La capacidad es obligatoria (ingrese un número o el símbolo ∞).'))
if val in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']:
return
try:
num = int(val)
if num < 0:
raise ValidationError(_l('La capacidad no puede ser negativa.'))
if num > 10000:
raise ValidationError(_l('La capacidad máxima numérica es 10.000.'))
except ValueError:
raise ValidationError(_l('Ingrese un número entero o el símbolo ∞ para capacidad ilimitada.'))
class ClassroomForm(FlaskForm):
building_id = SelectField(_l('Edificio'), validators=[DataRequired()])
new_building_name = StringField(_l('Nombre del Nuevo Edificio'), validators=[Optional(), Length(max=100)])
room_number = StringField(_l('Número / Nombre del Aula'), validators=[
DataRequired(),
Length(min=1, max=50)
])
floor = StringField(_l('Piso'), validators=[
DataRequired(message=_l('El piso es obligatorio (ej: PB, 1, 2, 4°P)')),
Length(min=1, max=50)
])
capacity = StringField(_l('Capacidad'), validators=[
validate_capacity
])
description = TextAreaField(_l('Descripción'), validators=[
Optional(),
Length(max=500)
])
is_active = BooleanField(_l('Activa'), default=True)
submit = SubmitField(_l('Guardar Aula'))
class ClassroomResourceForm(FlaskForm):
resource_type = SelectField(_l('Resource Type'), choices=[
('PROJECTOR', _l('Projector')),
('WHITEBOARD', _l('Whiteboard')),
('COMPUTER', _l('Computer')),
('SOUND_SYSTEM', _l('Sound System')),
('MICROPHONE', _l('Microphone')),
('AIR_CONDITIONER', _l('Air Conditioner')),
('OTHER', _l('Other'))
], validators=[DataRequired()])
brand = StringField(_l('Brand'), validators=[Optional(), Length(max=100)])
model = StringField(_l('Model'), validators=[Optional(), Length(max=100)])
specifications = TextAreaField(_l('Specifications'), validators=[Optional()])
functional = BooleanField(_l('Functional'), default=True)
submit = SubmitField(_l('Save Resource'))
@@ -1,26 +1,35 @@
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import SelectField, StringField, TextAreaField, IntegerField, SubmitField, DateTimeLocalField from wtforms import SelectField, StringField, TextAreaField, IntegerField, SubmitField, DateTimeLocalField
from wtforms.validators import DataRequired, Length, NumberRange, Optional, ValidationError from wtforms.validators import DataRequired, Length, NumberRange, Optional, ValidationError
from flask_babel import lazy_gettext as _l
from datetime import datetime, timedelta from datetime import datetime, timedelta
class ReservationForm(FlaskForm): class ReservationForm(FlaskForm):
classroom_id = SelectField('Classroom', coerce=int, validators=[DataRequired()]) classroom_id = SelectField(_l('Classroom'), coerce=int, validators=[DataRequired()])
commission_id = SelectField('Commission/Subject', coerce=int, validators=[DataRequired()]) commission_id = SelectField(_l('Commission/Subject'), coerce=int, validators=[DataRequired()])
start_time = DateTimeLocalField('Start Time', format='%Y-%m-%dT%H:%M', validators=[DataRequired()]) start_time = DateTimeLocalField(_l('Start Time'), format='%Y-%m-%dT%H:%M', validators=[DataRequired()])
end_time = DateTimeLocalField('End Time', format='%Y-%m-%dT%H:%M', validators=[DataRequired()]) end_time = DateTimeLocalField(_l('End Time'), format='%Y-%m-%dT%H:%M', validators=[DataRequired()])
purpose = StringField('Purpose', validators=[ purpose = StringField(_l('Purpose'), validators=[
DataRequired(), DataRequired(),
Length(min=3, max=500) Length(min=3, max=500)
]) ])
expected_attendees = IntegerField('Expected Attendees', validators=[ expected_attendees = IntegerField(_l('Expected Attendees'), validators=[
DataRequired(), DataRequired(),
NumberRange(min=1, max=500) NumberRange(min=1, max=500)
]) ])
notes = TextAreaField('Notes', validators=[ notes = TextAreaField(_l('Notes'), validators=[
Optional(), Optional(),
Length(max=1000) Length(max=1000)
]) ])
submit = SubmitField('Make Reservation') virtual_link = StringField(_l('Virtual Classroom Link'), validators=[
Optional(),
Length(max=500)
])
submit = SubmitField(_l('Make Reservation'))
def __init__(self, *args, is_edit=False, **kwargs):
super().__init__(*args, **kwargs)
self.is_edit = is_edit
def validate_end_time(self, field): def validate_end_time(self, field):
if field.data <= self.start_time.data: if field.data <= self.start_time.data:
@@ -31,8 +40,8 @@ class ReservationForm(FlaskForm):
raise ValidationError('Reservation cannot exceed 8 hours.') raise ValidationError('Reservation cannot exceed 8 hours.')
def validate_start_time(self, field): def validate_start_time(self, field):
# Don't allow reservations in the past # Don't allow new reservations in the past
if field.data < datetime.now(): if not self.is_edit and field.data < datetime.now() - timedelta(minutes=10):
raise ValidationError('Start time cannot be in the past.') raise ValidationError('Start time cannot be in the past.')
# Don't allow reservations too far in the future (max 3 months) # Don't allow reservations too far in the future (max 3 months)
+37
View File
@@ -0,0 +1,37 @@
from .user import User
from .building import Building
from .classroom import Classroom, ClassroomResource
from .career import Career
from .academic_term import AcademicTerm
from .subject import Subject, Commission
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',
'Building',
'Classroom',
'ClassroomResource',
'Career',
'AcademicTerm',
'Subject',
'Commission',
'Reservation',
'ReservationStatus',
'GeneticAlgorithm',
'ReservationOptimizer',
'Role',
'Permission',
'SYSTEM_MODULES',
'MilestoneType',
'AcademicMilestone',
'BASE_MILESTONE_TYPES',
'AuditLog',
'StudentEnrollment',
'SystemSetting'
]
+34
View File
@@ -0,0 +1,34 @@
from app import db
from datetime import datetime
class AcademicTerm(db.Model):
"""Modelo para Ciclos Lectivos y Períodos Académicos"""
__tablename__ = 'academic_terms'
id = db.Column(db.Integer, primary_key=True)
code = db.Column(db.String(50), unique=True, nullable=False, index=True) # ej. 2026-2, 2026-1, 2025-2
name = db.Column(db.String(150), nullable=False) # ej. Ciclo Lectivo 2026 - 2º Cuatrimestre
year = db.Column(db.Integer, nullable=False, default=2026, index=True)
period_type = db.Column(db.String(50), nullable=False, default='Segundo Cuatrimestre') # Primer Cuatrimestre, Segundo Cuatrimestre, Anual, Verano
start_date = db.Column(db.Date, nullable=True)
end_date = db.Column(db.Date, nullable=True)
is_current = db.Column(db.Boolean, default=False, nullable=False, index=True)
is_active = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def to_dict(self):
return {
'id': self.id,
'code': self.code,
'name': self.name,
'year': self.year,
'period_type': self.period_type,
'start_date': self.start_date.isoformat() if self.start_date else None,
'end_date': self.end_date.isoformat() if self.end_date else None,
'is_current': self.is_current,
'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None
}
def __repr__(self):
return f'<AcademicTerm {self.code} - {self.name}>'
+25
View File
@@ -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'}>"
+45
View File
@@ -0,0 +1,45 @@
from app import db
from datetime import datetime
class Building(db.Model):
__tablename__ = 'buildings'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), unique=True, nullable=False, index=True)
code = db.Column(db.String(50), unique=True, nullable=True)
address = db.Column(db.String(255), nullable=True)
floors = db.Column(db.Integer, default=1, nullable=False)
description = db.Column(db.Text, nullable=True)
is_active = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
classrooms = db.relationship('Classroom', backref='building_entity', lazy=True, foreign_keys='Classroom.building_id')
@property
def total_capacity(self):
"""Total student capacity across active classrooms in this building"""
return sum(c.capacity for c in self.classrooms if c.is_active)
@property
def classrooms_count(self):
"""Number of active classrooms"""
return len([c for c in self.classrooms if c.is_active])
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'code': self.code,
'address': self.address,
'floors': self.floors,
'description': self.description,
'is_active': self.is_active,
'classrooms_count': self.classrooms_count,
'total_capacity': self.total_capacity,
'created_at': self.created_at.isoformat() if self.created_at else None
}
def __repr__(self):
return f'<Building {self.name}>'
+27
View File
@@ -0,0 +1,27 @@
from app import db
from datetime import datetime
class Career(db.Model):
__tablename__ = 'careers'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(150), unique=True, nullable=False, index=True)
code = db.Column(db.String(50), nullable=True)
description = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Relationships
subjects = db.relationship('Subject', backref='career_obj', lazy=True)
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'code': self.code,
'description': self.description,
'subjects_count': len(self.subjects) if self.subjects else 0,
'created_at': self.created_at.isoformat() if self.created_at else None
}
def __repr__(self):
return f'<Career {self.name}>'
@@ -6,25 +6,60 @@ class Classroom(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
campus_id = db.Column(db.Integer, nullable=True) # Maps to campus_id in DB campus_id = db.Column(db.Integer, nullable=True) # Maps to campus_id in DB
building_id = db.Column(db.Integer, db.ForeignKey('buildings.id'), nullable=True)
building = db.Column(db.String(100), nullable=False) building = db.Column(db.String(100), nullable=False)
code = db.Column('room_number', db.String(50), nullable=False) # Maps to room_number column in DB code = db.Column('room_number', db.String(50), nullable=False) # Maps to room_number column in DB
floor = db.Column(db.Integer, nullable=False) floor = db.Column(db.String(50), nullable=False, default='1')
capacity = db.Column(db.Integer, nullable=False) capacity = db.Column(db.Integer, nullable=False)
description = db.Column(db.Text) description = db.Column(db.Text)
is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relación con Building (mapeada vía backref 'building_entity' en Building.classrooms)
@property
def building_rel(self):
return self.building_entity
@property
def building_name(self):
"""Nombre del edificio desacoplado"""
if self.building_rel:
return self.building_rel.name
return self.building or 'Edificio Central'
@property
def floor_display(self):
"""Formato amigable para el piso: PB, Planta Baja, Piso X"""
f = str(self.floor).strip() if self.floor is not None else ''
if f in ['0', 'PB', 'pb', 'Planta Baja', 'planta baja']:
return 'Planta Baja'
if f.lower().startswith('piso') or '°' in f:
return f
return f"Piso {f}"
@property
def is_virtual(self):
"""Indica si el aula es virtual"""
return (self.code and 'VIRTUAL' in self.code.upper()) or (self.building and 'VIRTUAL' in self.building.upper())
@property
def location_display(self):
"""Ubicación completa y limpia: Edificio · Piso · Aula"""
if self.is_virtual:
return '🌐 Campus Virtual'
return f"{self.building_name} · {self.floor_display} · {self.code}"
# Virtual properties for compatibility # Virtual properties for compatibility
@property @property
def name(self): def name(self):
"""Generate name from building and code""" """Generate name from building and code"""
return f"{self.building}-{self.code}" return f"{self.building_name}-{self.code}"
@property @property
def campus_location(self): def campus_location(self):
"""Placeholder for campus location - can be expanded when campus model is implemented""" """Placeholder for campus location"""
return None return self.building_rel.address if self.building_rel else None
# Virtual field for compatibility - combines building and code # Virtual field for compatibility - combines building and code
@property @property
@@ -38,17 +73,34 @@ class Classroom(db.Model):
# Override code property for consistency # Override code property for consistency
@property @property
def code_display(self): def code_display(self):
return f"{self.building}-{self.code}" return f"{self.building_name} - {self.code}"
@property
def capacity_display(self):
"""Retorna '∞' para aulas virtuales o capacidad ilimitada, o el número entero."""
if self.capacity == 0 or self.is_virtual:
return '∞'
return str(self.capacity)
@property
def is_unlimited(self):
return self.capacity == 0 or self.is_virtual
def to_dict(self): def to_dict(self):
"""Convert classroom to dictionary""" """Convert classroom to dictionary"""
return { return {
'id': self.id, 'id': self.id,
'code': self.code_display, 'building_id': self.building_id,
'building': self.building, 'building': self.building_name,
'code': self.code,
'code_display': self.code_display,
'name': self.name, 'name': self.name,
'floor': self.floor, 'floor': self.floor,
'floor_display': self.floor_display,
'location_display': self.location_display,
'capacity': self.capacity, 'capacity': self.capacity,
'capacity_display': self.capacity_display,
'is_virtual': self.is_virtual,
'description': self.description, 'description': self.description,
'campus_location': self.campus_location, 'campus_location': self.campus_location,
'is_active': self.is_active, 'is_active': self.is_active,
@@ -59,10 +111,9 @@ class Classroom(db.Model):
def get_full_location(self): def get_full_location(self):
"""Get full classroom location""" """Get full classroom location"""
location = f"{self.building} - Floor {self.floor}" if self.is_virtual:
if self.campus_location: return 'Campus Virtual (Remoto)'
location += f" ({self.campus_location})" return f"{self.building_name} - {self.floor_display}"
return location
@staticmethod @staticmethod
def find_best_classroom(max_students, start_time, end_time, exclude_reservation_id=None): def find_best_classroom(max_students, start_time, end_time, exclude_reservation_id=None):
+49
View File
@@ -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 = [] self.conflicts = []
def calculate_fitness(self, classrooms: Dict[int, Classroom], requests: Dict[int, ReservationRequest]) -> float: 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 score = 0.0
self.conflicts = [] 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: for gene in self.genes:
classroom = classrooms.get(gene.classroom_id) classroom = classrooms.get(gene.classroom_id)
request = requests.get(gene.commission_id) request = requests.get(gene.commission_id)
@@ -66,19 +87,20 @@ class Individual:
score += time_score * 0.3 score += time_score * 0.3
# Factor 3: Conflict penalty (20% weight) # 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 score += conflict_score * 0.2
# Factor 4: Resource matching (10% weight) # Factor 4: Resource matching (10% weight)
resource_score = self._calculate_resource_score(classroom, request.subject_requirements) resource_score = self._calculate_resource_score(classroom, request.subject_requirements)
score += resource_score * 0.1 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 self.fitness = score
return score return score
@@ -89,7 +111,7 @@ class Individual:
return 0.0 # Overfilled classroom return 0.0 # Overfilled classroom
# Calculate efficiency: closer capacity match = higher score # 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 if waste_ratio <= 0.1: # Less than 10% waste
return 1.0 return 1.0
elif waste_ratio <= 0.3: # Less than 30% waste elif waste_ratio <= 0.3: # Less than 30% waste
@@ -107,12 +129,12 @@ class Individual:
if time_diff <= 0.5: # Within 30 minutes if time_diff <= 0.5: # Within 30 minutes
return 1.0 return 1.0
elif time_diff <= request.flexibility_hours: # Within flexible window 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: else:
return max(0.0, 0.5 - (time_diff - request.flexibility_hours) * 0.1) 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: 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 conflicts = 0
for other in all_genes: for other in all_genes:
if gene != other and gene.classroom_id == other.classroom_id: if gene != other and gene.classroom_id == other.classroom_id:
@@ -127,9 +149,6 @@ class Individual:
"""Check if classroom meets subject requirements""" """Check if classroom meets subject requirements"""
if not requirements: if not requirements:
return 1.0 return 1.0
# This would need to be implemented based on actual classroom resources
# For now, return a default score
return 0.8 return 0.8
def _times_overlap(self, start1: datetime, end1: datetime, start2: datetime, end2: datetime) -> bool: def _times_overlap(self, start1: datetime, end1: datetime, start2: datetime, end2: datetime) -> bool:
@@ -138,19 +157,24 @@ class Individual:
class GeneticAlgorithm: 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, 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.population_size = population_size
self.generations = generations self.generations = generations
self.mutation_rate = mutation_rate self.mutation_rate = mutation_rate
self.crossover_rate = crossover_rate self.crossover_rate = crossover_rate
self.workers = max(1, workers)
def optimize_reservations(self, requests: List[ReservationRequest], def optimize_reservations(self, requests: List[ReservationRequest],
available_classrooms: List[Classroom], available_classrooms: List[Classroom],
start_date: datetime, end_date: datetime) -> Individual: start_date: datetime, end_date: datetime,
"""Run genetic algorithm to find optimal reservation schedule""" 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} classrooms_dict = {c.id: c for c in available_classrooms}
requests_dict = {r.commission_id: r for r in requests} requests_dict = {r.commission_id: r for r in requests}
@@ -159,18 +183,29 @@ class GeneticAlgorithm:
# Evolve population # Evolve population
for generation in range(self.generations): for generation in range(self.generations):
# Calculate fitness for all individuals # Calculate fitness for all individuals concurrently when workers > 1
for individual in population: if self.workers > 1 and len(population) > 10:
individual.calculate_fitness(classrooms_dict, requests_dict) 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) # Sort by fitness (best first)
population.sort(key=lambda x: x.fitness, reverse=True) 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 # Create new generation
new_population = [] new_population = []
# Elitism: keep best 10% # 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]) new_population.extend(population[:elite_size])
# Crossover and mutation for remaining # Crossover and mutation for remaining
@@ -196,7 +231,9 @@ class GeneticAlgorithm:
population = new_population[:self.population_size] 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) population.sort(key=lambda x: x.fitness, reverse=True)
return population[0] return population[0]
@@ -284,13 +321,14 @@ class GeneticAlgorithm:
class ReservationOptimizer: class ReservationOptimizer:
"""High-level interface for the reservation optimization system""" """High-level interface for the reservation optimization system"""
def __init__(self): def __init__(self, population_size: int = 50, generations: int = 100, workers: int = 4):
self.ga = GeneticAlgorithm() self.ga = GeneticAlgorithm(population_size=population_size, generations=generations, workers=workers)
def optimize_schedule(self, commission_ids: List[int], def optimize_schedule(self, commission_ids: List[int],
admin_user_id: int, admin_user_id: int,
start_date: datetime = None, start_date: datetime = None,
end_date: datetime = None) -> Dict: end_date: datetime = None,
progress_callback = None) -> Dict:
"""Optimize reservation schedule for given commissions""" """Optimize reservation schedule for given commissions"""
# Default date range if not provided # Default date range if not provided
@@ -325,7 +363,7 @@ class ReservationOptimizer:
classrooms = Classroom.query.filter_by(is_active=True).all() classrooms = Classroom.query.filter_by(is_active=True).all()
# Run genetic algorithm # 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 # Convert to reservation format
optimized_reservations = [] optimized_reservations = []
+180
View File
@@ -0,0 +1,180 @@
from datetime import datetime
from app import db
class MilestoneType(db.Model):
"""
Catálogo dinámico y administrable de tipificaciones de hitos y evaluaciones académicas.
(Parciales, Trabajos Prácticos, Recuperatorios, Finales, etc.)
"""
__tablename__ = 'milestone_types'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False) # Ej: "Parcial 1", "Trabajo Práctico 1"
code = db.Column(db.String(50), unique=True, nullable=False, index=True) # Ej: "parcial_1", "tp_1"
color = db.Column(db.String(30), default='#0d6efd') # Hex color para badges y calendario
description = db.Column(db.String(255), nullable=True)
sort_order = db.Column(db.Integer, default=0) # Orden de aparición en selects
is_active = db.Column(db.Boolean, default=True, nullable=False) # Activo / Inactivo
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relación uno-a-muchos con AcademicMilestone
milestones = db.relationship('AcademicMilestone', backref='type_obj', lazy='dynamic', cascade='all, delete-orphan')
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'code': self.code,
'color': self.color or '#0d6efd',
'description': self.description or '',
'sort_order': self.sort_order or 0,
'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None
}
def __repr__(self):
return f'<MilestoneType {self.code}: {self.name}>'
class AcademicMilestone(db.Model):
"""
Hito o evento académico programado para una materia/comisión (Examen, TP, Entrega).
"""
__tablename__ = 'academic_milestones'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(150), nullable=True)
materia_codigo = db.Column(db.String(50), nullable=False, index=True) # Código de materia (ej: INGA0003)
subject_id = db.Column(db.Integer, db.ForeignKey('subjects.id'), nullable=True)
commission_id = db.Column(db.Integer, db.ForeignKey('commissions.id', ondelete='SET NULL'), nullable=True)
milestone_type_id = db.Column(db.Integer, db.ForeignKey('milestone_types.id'), nullable=False)
date = db.Column(db.DateTime, nullable=False) # Fecha y hora del hito
classroom = db.Column(db.String(100), nullable=True) # Aula o enlace virtual asignado
link = db.Column(db.String(255), nullable=True) # Link Teams / Meet / Zoom si aplica
notes = db.Column(db.Text, nullable=True) # Temario, consignas o notas
is_active = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relación con Asignatura y Comisión
subject = db.relationship('Subject', backref=db.backref('milestones', lazy='dynamic'))
commission = db.relationship('Commission', backref=db.backref('academic_milestones', lazy='dynamic', cascade='all, delete-orphan'))
@property
def tipo(self):
return self.type_obj.name if self.type_obj else 'Otro'
@property
def color(self):
return self.type_obj.color if self.type_obj and self.type_obj.color else '#0d6efd'
def to_dict(self):
return {
'id': self.id,
'title': self.title or self.tipo,
'materiaCodigo': self.materia_codigo,
'subject_id': self.subject_id,
'commission_id': self.commission_id,
'tipo': self.tipo,
'milestone_type_id': self.milestone_type_id,
'tipo_info': self.type_obj.to_dict() if self.type_obj else None,
'color': self.color,
'fecha': self.date.isoformat() if self.date else None,
'aula': self.classroom,
'link': self.link,
'notas': self.notes,
'is_active': self.is_active
}
def __repr__(self):
return f'<AcademicMilestone {self.materia_codigo} - {self.tipo} ({self.date})>'
# Datos semilla estándar para inicialización
BASE_MILESTONE_TYPES = [
{
'name': 'Parcial 1',
'code': 'parcial_1',
'color': '#0d6efd',
'description': 'Primer examen parcial de la cursada',
'sort_order': 1
},
{
'name': 'Parcial 2',
'code': 'parcial_2',
'color': '#0b5ed7',
'description': 'Segundo examen parcial de la cursada',
'sort_order': 2
},
{
'name': 'Trabajo Practico 1',
'code': 'tp_1',
'color': '#198754',
'description': 'Primera entrega o evaluacion practica',
'sort_order': 3
},
{
'name': 'Trabajo Practico 2',
'code': 'tp_2',
'color': '#157347',
'description': 'Segunda entrega o evaluacion practica',
'sort_order': 4
},
{
'name': 'Trabajo Practico 3',
'code': 'tp_3',
'color': '#0f5132',
'description': 'Tercera entrega o proyecto integrador',
'sort_order': 5
},
{
'name': 'Recuperatorio Parcial 1',
'code': 'recu_p1',
'color': '#fd7e14',
'description': 'Instancia de recuperacion para el Parcial 1',
'sort_order': 6
},
{
'name': 'Recuperatorio Parcial 2',
'code': 'recu_p2',
'color': '#e76b08',
'description': 'Instancia de recuperacion para el Parcial 2',
'sort_order': 7
},
{
'name': 'Recuperatorio TP 1',
'code': 'recu_tp1',
'color': '#ffc107',
'description': 'Reentrega o recuperacion de Trabajo Practico 1',
'sort_order': 8
},
{
'name': 'Recuperatorio TP 2',
'code': 'recu_tp2',
'color': '#e0a800',
'description': 'Reentrega o recuperacion de Trabajo Practico 2',
'sort_order': 9
},
{
'name': 'Recuperatorio TP 3',
'code': 'recu_tp3',
'color': '#c69500',
'description': 'Reentrega o recuperacion de Trabajo Practico 3',
'sort_order': 10
},
{
'name': 'Final',
'code': 'final',
'color': '#dc3545',
'description': 'Examen final regular o libre',
'sort_order': 11
},
{
'name': 'Otro',
'code': 'otro',
'color': '#6c757d',
'description': 'Taller, seminario, clase especial o hito no tipificado',
'sort_order': 12
}
]
@@ -1,6 +1,30 @@
from app import db from app import db
from datetime import datetime, timedelta from datetime import datetime, timedelta
from enum import Enum from enum import Enum
from sqlalchemy.types import TypeDecorator, String
class StatusString(str):
"""String wrapper providing .value property for backward compatibility with Enum usage"""
@property
def value(self):
return str(self)
def __eq__(self, other):
if hasattr(other, 'value'):
return str(self) == str(other.value)
return str(self) == str(other)
class StatusType(TypeDecorator):
impl = String(20)
cache_ok = True
def process_result_value(self, value, dialect):
return StatusString(value) if value is not None else None
def process_bind_param(self, value, dialect):
if hasattr(value, 'value'):
return str(value.value)
return str(value) if value is not None else None
class ReservationStatus(Enum): class ReservationStatus(Enum):
PENDING = 'PENDING' PENDING = 'PENDING'
@@ -20,8 +44,10 @@ class Reservation(db.Model):
start_time = db.Column(db.DateTime, nullable=False) start_time = db.Column(db.DateTime, nullable=False)
end_time = db.Column(db.DateTime, nullable=False) end_time = db.Column(db.DateTime, nullable=False)
purpose = db.Column(db.String(500), nullable=False) purpose = db.Column(db.String(500), nullable=False)
status = db.Column(db.String(20), default='PENDING', nullable=False) # DB uses VARCHAR not ENUM status = db.Column(StatusType, default='PENDING', nullable=False) # Maps to VARCHAR(20) with StatusString
expected_attendees = db.Column(db.Integer, nullable=False) expected_attendees = db.Column(db.Integer, nullable=False)
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
virtual_link = db.Column(db.String(500), nullable=True) # Zoom, Meet, Teams, etc.
notes = db.Column(db.Text) notes = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
@@ -32,9 +58,48 @@ class Reservation(db.Model):
def status_enum(self): def status_enum(self):
"""Get status as enum""" """Get status as enum"""
try: try:
return ReservationStatus(self.status) return ReservationStatus(str(self.status))
except (ValueError, AttributeError): except (ValueError, AttributeError):
return ReservationStatus.PENDING return ReservationStatus.PENDING
@property
def subject_code(self):
"""Get exact subject code (e.g. INGA0003, ASIG00165)"""
if self.commission and self.commission.subject:
return self.commission.subject.code
return ''
@property
def subject_name(self):
"""Get subject name"""
if self.commission and self.commission.subject:
return self.commission.subject.name
return self.purpose or ''
@property
def classroom_name(self):
"""Get classroom code or name"""
if self.classroom:
return self.classroom.code
return ''
@property
def effective_virtual_link(self):
"""Devuelve el enlace explícito de la reserva o hereda el de la comisión"""
if self.virtual_link and self.virtual_link.strip():
return self.virtual_link.strip()
if self.commission and self.commission.virtual_link:
return self.commission.virtual_link.strip()
return None
@property
def is_virtual(self):
"""Verifica si la reserva es virtual por aula o por contar con enlace virtual"""
if self.classroom:
return bool(self.classroom.is_virtual)
if self.effective_virtual_link:
return True
return False
# Relationships # Relationships
classroom = db.relationship('Classroom', backref=db.backref('reservation_list', lazy=True, cascade='all, delete-orphan')) classroom = db.relationship('Classroom', backref=db.backref('reservation_list', lazy=True, cascade='all, delete-orphan'))
@@ -54,6 +119,8 @@ class Reservation(db.Model):
'status': self.status, # Already a string in DB 'status': self.status, # Already a string in DB
'expected_attendees': self.expected_attendees, 'expected_attendees': self.expected_attendees,
'notes': self.notes, 'notes': self.notes,
'virtual_link': self.effective_virtual_link,
'is_virtual': self.is_virtual,
'created_at': self.created_at.isoformat() if self.created_at else None, 'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None, 'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'classroom': self.classroom.to_dict() if self.classroom else None, 'classroom': self.classroom.to_dict() if self.classroom else None,
+163
View File
@@ -0,0 +1,163 @@
from app import db
from datetime import datetime
# Definition of system modules available for RBAC permissions
SYSTEM_MODULES = [
{
'id': 'classrooms',
'name': 'Aulas y Espacios',
'name_en': 'Classrooms & Spaces',
'icon': 'bi-door-open',
'description': 'Gestión de aulas físicas, aulas virtuales (∞) y capacidades.'
},
{
'id': 'buildings',
'name': 'Edificios y Sedes',
'name_en': 'Buildings & Campuses',
'icon': 'bi-buildings',
'description': 'Administración de sede central, campus virtual, pisos y ubicaciones.'
},
{
'id': 'reservations',
'name': 'Reservas de Aulas',
'name_en': 'Classroom Reservations',
'icon': 'bi-calendar-check',
'description': 'Creación, modificación, aprobación, confirmación y cancelación de reservas.'
},
{
'id': 'schedule',
'name': 'Cartelera y Cronograma',
'name_en': 'Schedule & Billboard',
'icon': 'bi-calendar3',
'description': 'Visualización de calendario interactivo, cartelera de hoy y comisiones.'
},
{
'id': 'academic',
'name': 'Gestión Académica',
'name_en': 'Academic Management',
'icon': 'bi-book',
'description': 'Administración de carreras, asignaturas, códigos y comisiones.'
},
{
'id': 'import',
'name': 'Sincronización Sheets',
'name_en': 'Sheets Sync & Import',
'icon': 'bi-file-earmark-spreadsheet',
'description': 'Importación y sincronización de datos académicos desde Google Sheets.'
},
{
'id': 'optimizer',
'name': 'Optimizador IA',
'name_en': 'AI Room Optimizer',
'icon': 'bi-cpu',
'description': 'Ejecución y configuración del algoritmo genético de asignación.'
},
{
'id': 'users',
'name': 'Usuarios y Accesos RBAC',
'name_en': 'Users & Access RBAC',
'icon': 'bi-shield-lock',
'description': 'Administración de cuentas, roles y matriz granular de permisos.'
},
{
'id': 'milestone_types',
'name': 'Tipificaciones de Hitos',
'name_en': 'Milestone Types',
'icon': 'bi-tags',
'description': 'Catálogo dinámico de tipos de evaluaciones, parciales, TPs y eventos.'
},
{
'id': 'metrics',
'name': 'Métricas de Ocupación',
'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.'
}
]
ACCESS_LEVELS = ['none', 'read', 'read_write']
class Role(db.Model):
__tablename__ = 'roles'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(80), unique=True, nullable=False, index=True)
description = db.Column(db.String(255))
is_system = db.Column(db.Boolean, default=False, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Relationships
permissions = db.relationship('Permission', backref='role', lazy='joined', cascade='all, delete-orphan')
users = db.relationship('User', backref='role_obj', lazy='select')
def get_permission(self, module):
"""Get access level string for a given module ('none', 'read', 'read_write')"""
for p in self.permissions:
if p.module == module:
return p.access_level
return 'none'
def has_permission(self, module, min_level='read'):
"""
Check if role has sufficient permission level.
Hierarchy: 'read_write' > 'read' > 'none'
"""
current_level = self.get_permission(module)
if current_level == 'none':
return False
if min_level == 'read':
return current_level in ['read', 'read_write']
if min_level == 'read_write':
return current_level == 'read_write'
return False
def set_permission(self, module, access_level):
"""Set or update permission level for a module"""
if access_level not in ACCESS_LEVELS:
access_level = 'none'
for p in self.permissions:
if p.module == module:
p.access_level = access_level
return p
new_perm = Permission(role=self, module=module, access_level=access_level)
db.session.add(new_perm)
return new_perm
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'description': self.description,
'is_system': self.is_system,
'created_at': self.created_at.isoformat() if self.created_at else None,
'user_count': len(self.users) if self.users else 0,
'permissions': {p.module: p.access_level for p in self.permissions}
}
def __repr__(self):
return f'<Role {self.name}>'
class Permission(db.Model):
__tablename__ = 'permissions'
id = db.Column(db.Integer, primary_key=True)
role_id = db.Column(db.Integer, db.ForeignKey('roles.id', ondelete='CASCADE'), nullable=False, index=True)
module = db.Column(db.String(50), nullable=False)
access_level = db.Column(db.String(20), default='none', nullable=False)
__table_args__ = (
db.UniqueConstraint('role_id', 'module', name='uq_role_module'),
)
def __repr__(self):
return f'<Permission role={self.role_id} module={self.module} level={self.access_level}>'
+50
View File
@@ -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}>'
@@ -5,12 +5,12 @@ class Subject(db.Model):
__tablename__ = 'subjects' __tablename__ = 'subjects'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
code = db.Column(db.String(20), unique=True, nullable=False, index=True) code = db.Column(db.String(50), unique=True, nullable=False, index=True)
name = db.Column(db.String(200), nullable=False) name = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text) description = db.Column(db.Text)
department = db.Column(db.String(100), nullable=False) department = db.Column(db.String(100), nullable=False, default='')
credits = db.Column(db.Integer, nullable=False) credits = db.Column(db.Integer, nullable=False, default=4)
# duration_in_minutes = db.Column(db.Integer, nullable=False) career_id = db.Column(db.Integer, db.ForeignKey('careers.id', ondelete='SET NULL'), nullable=True)
is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -27,7 +27,8 @@ class Subject(db.Model):
'description': self.description, 'description': self.description,
'department': self.department, 'department': self.department,
'credits': self.credits, 'credits': self.credits,
# 'duration_minutes': self.duration_in_minutes, 'career_id': self.career_id,
'career_name': self.career_obj.name if self.career_obj else None,
'is_active': self.is_active, 'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None, 'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None 'updated_at': self.updated_at.isoformat() if self.updated_at else None
@@ -48,6 +49,8 @@ class Commission(db.Model):
max_students = db.Column(db.Integer, nullable=False) max_students = db.Column(db.Integer, nullable=False)
current_students = db.Column(db.Integer, default=0) current_students = db.Column(db.Integer, default=0)
schedule = db.Column(db.String(255)) schedule = db.Column(db.String(255))
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
virtual_link = db.Column(db.String(500), nullable=True) # Link Zoom, Meet, Teams, Moodle
active = db.Column(db.Boolean, default=True) # Changed from is_active to active active = db.Column(db.Boolean, default=True) # Changed from is_active to active
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -85,15 +88,31 @@ class Commission(db.Model):
'max_students': self.max_students, 'max_students': self.max_students,
'current_students': self.current_students, 'current_students': self.current_students,
'schedule': self.schedule, 'schedule': self.schedule,
'shift': self.shift,
'virtual_link': self.virtual_link,
'active': self.active, 'active': self.active,
'created_at': self.created_at.isoformat() if self.created_at else None, 'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_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 'subject': self.subject.to_dict() if self.subject else None
} }
def get_full_code(self): def get_full_code(self):
"""Get full commission code""" """Get full commission code"""
return f"{self.subject.code if self.subject else 'N/A'}-{self.code}-{self.semester.upper()}{self.year if self.year else ''}" subj = self.subject.code if self.subject else 'N/A'
code = self.code or ''
sem = str(self.semester or '').strip()
yr = str(self.year or '').strip()
if yr and yr in sem:
return f"{subj}-{code}-{sem}"
elif sem and yr:
return f"{subj}-{code}-{sem}{yr}"
elif sem:
return f"{subj}-{code}-{sem}"
elif yr:
return f"{subj}-{code}-{yr}"
return f"{subj}-{code}"
def __repr__(self): def __repr__(self):
return f'<Commission {self.get_full_code()}>' return f'<Commission {self.get_full_code()}>'
+145
View File
@@ -0,0 +1,145 @@
from app import db
from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime
class User(db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False)
name = db.Column(db.String(100), nullable=False)
role = db.Column(db.String(50), nullable=False)
is_active = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
last_login = db.Column(db.DateTime)
preferred_language = db.Column(db.String(10), default=None, nullable=True)
theme_preference = db.Column(db.String(10), default='auto', nullable=False)
role_id = db.Column(db.Integer, db.ForeignKey('roles.id', ondelete='SET NULL'), nullable=True)
# Virtual fields for future compatibility
@property
def first_name(self):
return self.name.split()[0] if self.name else ''
@property
def last_name(self):
parts = self.name.split()
return ' '.join(parts[1:]) if len(parts) > 1 else ''
# Relationships
reservations = db.relationship('Reservation', backref='user', lazy=True, cascade='all, delete-orphan')
def set_password(self, password):
"""Set password hash"""
self.password_hash = generate_password_hash(password, method='pbkdf2:sha256')
def check_password(self, password):
"""Check password"""
return check_password_hash(self.password_hash, password)
def is_admin(self):
"""Check if user is admin"""
if self.role_obj and self.role_obj.name.lower() in ['admin', 'administrador']:
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():
return True
if self.role_obj:
return self.role_obj.has_permission(module, min_level)
return False
def can_manage(self):
"""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
self.has_permission('reservations', 'read_write') or
self.has_permission('classrooms', 'read_write') or
self.has_permission('buildings', 'read_write') or
self.has_permission('metrics', 'read')
)
def can_edit_reservation(self, reservation):
"""Check if user has permission to edit a given reservation"""
if not reservation:
return False
if self.is_admin():
return True
role_name = (self.role_obj.name if self.role_obj else self.role or '').lower()
if role_name in ['admin', 'administrador', 'bedelia', 'bedelía', 'operador']:
return True
if self.has_permission('reservations', 'read_write'):
# El docente creador puede modificar su propia reserva
if reservation.user_id == self.id:
return True
# Bedelía o roles de gestión con permiso de escritura total
if role_name in ['bedelia', 'bedelía', 'operador']:
return True
return False
def update_last_login(self):
"""Update last login timestamp"""
self.last_login = datetime.utcnow()
db.session.commit()
def to_dict(self):
"""Convert user to dictionary"""
return {
'id': self.id,
'email': self.email,
'name': self.name,
'role': self.role,
'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None,
'last_login': self.last_login.isoformat() if self.last_login else None,
'preferred_language': self.preferred_language,
'theme_preference': self.theme_preference
}
def __repr__(self):
return f'<User {self.email}>'
+12
View File
@@ -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()
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+10
View File
@@ -0,0 +1,10 @@
from .main import main_bp
from .auth import auth_bp
from .classrooms import classrooms_bp
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', 'my_subjects_bp']
File diff suppressed because it is too large Load Diff
+12
View File
@@ -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'
]
File diff suppressed because it is too large Load Diff
+109
View File
@@ -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
+119
View File
@@ -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
+240
View File
@@ -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
+105
View File
@@ -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
+247
View File
@@ -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
+149
View File
@@ -0,0 +1,149 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, session, jsonify
from flask_login import login_user, logout_user, login_required, current_user
from flask_babel import gettext as _
from app import db
from app.models.user import User
from app.forms.auth import LoginForm
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 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():
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:
flash(_('Tu cuenta ha sido desactivada. Por favor contacta al administrador.'), 'error')
return render_template('auth/login.html', form=form)
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']:
session['language'] = user.preferred_language
else:
session.pop('language', None)
session.pop('login_lang_explicit', None)
next_page = request.args.get('next')
if not next_page or not next_page.startswith('/'):
next_page = url_for('main.dashboard')
user_full_name = f'{user.first_name} {user.last_name}'.strip() or user.name
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)
@auth_bp.route('/logout')
@login_required
def logout():
"""Handle user logout"""
logout_user()
# Al cerrar sesión, se limpian las cookies de idioma para que el login detecte el navegador o use español
session.pop('language', None)
session.pop('login_lang_explicit', None)
flash(_('Has cerrado sesión correctamente.'), 'info')
return redirect(url_for('auth.login'))
@auth_bp.route('/profile', methods=['GET', 'POST'])
@login_required
def profile():
"""User profile page with language and theme settings"""
if request.method == 'POST':
lang = request.form.get('preferred_language')
if lang in ['es', 'en']:
current_user.preferred_language = lang
session['language'] = lang
else:
# Cadena vacía o None restablece la detección automática del navegador
current_user.preferred_language = None
session.pop('language', None)
theme = request.form.get('theme_preference')
if theme in ['auto', 'light', 'dark']:
current_user.theme_preference = theme
db.session.commit()
flash(_('Preferencias de usuario actualizadas correctamente.'), 'success')
return redirect(url_for('auth.profile'))
return render_template('auth/profile.html')
@auth_bp.route('/api/user/theme', methods=['POST'])
@login_required
def update_theme_api():
"""Update user theme preference via fetch/AJAX from navbar toggle"""
data = request.get_json(silent=True) or request.form
theme = data.get('theme') if data else None
if theme in ['auto', 'light', 'dark']:
current_user.theme_preference = theme
db.session.commit()
return jsonify({'status': 'success', 'theme': theme})
return jsonify({'status': 'error', 'message': 'Invalid theme'}), 400
@auth_bp.route('/change_password', methods=['GET', 'POST'])
@login_required
def change_password():
"""Change user password"""
if request.method == 'POST':
current_password = request.form.get('current_password')
new_password = request.form.get('new_password')
confirm_password = request.form.get('confirm_password')
if not current_user.check_password(current_password):
flash('Current password is incorrect.', 'error')
return redirect(url_for('auth.change_password'))
if new_password != confirm_password:
flash('New passwords do not match.', 'error')
return redirect(url_for('auth.change_password'))
if len(new_password) < 6:
flash('Password must be at least 6 characters long.', 'error')
return redirect(url_for('auth.change_password'))
current_user.set_password(new_password)
db.session.commit()
flash('Password changed successfully!', 'success')
return redirect(url_for('auth.profile'))
return render_template('auth/change_password.html')
+193
View File
@@ -0,0 +1,193 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user
from app import db
from app.models.building import Building
from app.models.classroom import Classroom
from app.forms.building import BuildingForm
from app.utils.decorators import permission_required
from datetime import datetime
buildings_bp = Blueprint('buildings', __name__, url_prefix='/buildings')
@buildings_bp.route('/')
@login_required
def list_buildings():
"""List all buildings with statistics"""
search = request.args.get('search', '').strip()
status_filter = request.args.get('status', '')
query = Building.query
if search:
query = query.filter(
db.or_(
Building.name.ilike(f'%{search}%'),
Building.code.ilike(f'%{search}%'),
Building.address.ilike(f'%{search}%')
)
)
if status_filter == 'active':
query = query.filter(Building.is_active == True)
elif status_filter == 'inactive':
query = query.filter(Building.is_active == False)
buildings = query.order_by(Building.name.asc()).all()
# Calculate global building stats
total_classrooms = Classroom.query.filter_by(is_active=True).count()
total_capacity = sum(c.capacity for c in Classroom.query.filter_by(is_active=True).all())
return render_template(
'buildings/list.html',
buildings=buildings,
total_classrooms=total_classrooms,
total_capacity=total_capacity,
search=search,
status_filter=status_filter,
title='Building Management'
)
@buildings_bp.route('/add', methods=['GET', 'POST'])
@login_required
@permission_required('classrooms', 'read_write')
def add_building():
"""Add new building"""
form = BuildingForm()
if form.validate_on_submit():
try:
# Check unique name
existing = Building.query.filter(
db.func.lower(Building.name) == form.name.data.lower()
).first()
if existing:
flash('Ya existe un edificio con este nombre.', 'danger')
return render_template('buildings/form.html', form=form, title='Add Building')
building = Building(
name=form.name.data.strip(),
code=form.code.data.strip().upper() if form.code.data else None,
address=form.address.data.strip() if form.address.data else None,
floors=form.floors.data,
description=form.description.data.strip() if form.description.data else None,
is_active=form.is_active.data
)
db.session.add(building)
db.session.commit()
flash(f'Edificio "{building.name}" creado con éxito.', 'success')
return redirect(url_for('buildings.view_building', id=building.id))
except Exception as e:
db.session.rollback()
flash(f'Error al crear el edificio: {str(e)}', 'danger')
return render_template('buildings/form.html', form=form, title='Add Building')
@buildings_bp.route('/<int:id>')
@login_required
def view_building(id):
"""View building details and its classrooms by floor"""
building = Building.query.get_or_404(id)
# Group active classrooms by floor
classrooms = Classroom.query.filter_by(building_id=building.id).order_by(Classroom.floor.asc(), Classroom.code.asc()).all()
# Fallback to name matching if building_id was not set
if not classrooms:
classrooms = Classroom.query.filter_by(building=building.name).order_by(Classroom.floor.asc(), Classroom.code.asc()).all()
classrooms_by_floor = {}
for c in classrooms:
floor_key = c.floor
if floor_key not in classrooms_by_floor:
classrooms_by_floor[floor_key] = []
classrooms_by_floor[floor_key].append(c)
return render_template(
'buildings/view.html',
building=building,
classrooms=classrooms,
classrooms_by_floor=classrooms_by_floor,
title=f'Building - {building.name}'
)
@buildings_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
@login_required
@permission_required('classrooms', 'read_write')
def edit_building(id):
"""Edit building"""
building = Building.query.get_or_404(id)
form = BuildingForm(obj=building)
if form.validate_on_submit():
try:
# Check unique name excluding current
existing = Building.query.filter(
db.func.lower(Building.name) == form.name.data.lower(),
Building.id != id
).first()
if existing:
flash('Ya existe otro edificio con este nombre.', 'danger')
return render_template('buildings/form.html', form=form, building=building, title='Edit Building')
old_name = building.name
new_name = form.name.data.strip()
building.name = new_name
building.code = form.code.data.strip().upper() if form.code.data else None
building.address = form.address.data.strip() if form.address.data else None
building.floors = form.floors.data
building.description = form.description.data.strip() if form.description.data else None
building.is_active = form.is_active.data
building.updated_at = datetime.utcnow()
# If name changed, update string building on classrooms
if old_name != new_name:
Classroom.query.filter_by(building_id=building.id).update({'building': new_name})
db.session.commit()
flash(f'Edificio "{building.name}" actualizado con éxito.', 'success')
return redirect(url_for('buildings.view_building', id=building.id))
except Exception as e:
db.session.rollback()
flash(f'Error al actualizar el edificio: {str(e)}', 'danger')
return render_template('buildings/form.html', form=form, building=building, title='Edit Building')
@buildings_bp.route('/<int:id>/toggle', methods=['POST'])
@login_required
@permission_required('classrooms', 'read_write')
def toggle_building(id):
"""Toggle building active status"""
building = Building.query.get_or_404(id)
try:
building.is_active = not building.is_active
building.updated_at = datetime.utcnow()
db.session.commit()
status_text = 'activado' if building.is_active else 'desactivado'
flash(f'Edificio "{building.name}" {status_text} con éxito.', 'success')
except Exception as e:
db.session.rollback()
flash(f'Error al cambiar estado del edificio: {str(e)}', 'danger')
return redirect(url_for('buildings.list_buildings'))
@buildings_bp.route('/<int:id>/delete', methods=['POST'])
@login_required
@permission_required('classrooms', 'read_write')
def delete_building(id):
"""Delete building if it has no classrooms assigned"""
building = Building.query.get_or_404(id)
has_classrooms = Classroom.query.filter(
db.or_(Classroom.building_id == building.id, Classroom.building == building.name)
).count()
if has_classrooms > 0:
flash(f'No se puede eliminar el edificio "{building.name}" porque tiene {has_classrooms} aulas asociadas. Primero reasigna o elimina las aulas.', 'danger')
return redirect(url_for('buildings.view_building', id=building.id))
try:
db.session.delete(building)
db.session.commit()
flash(f'Edificio "{building.name}" eliminado correctamente.', 'success')
except Exception as e:
db.session.rollback()
flash(f'Error al eliminar edificio: {str(e)}', 'danger')
return redirect(url_for('buildings.list_buildings'))
+513
View File
@@ -0,0 +1,513 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
from flask_login import login_required, current_user
from datetime import datetime, date
from app.models.classroom import Classroom
from app.models.user import User
from app.forms.classroom import ClassroomForm
from app import db
from sqlalchemy import text
classrooms_bp = Blueprint('classrooms', __name__)
@classrooms_bp.route('/add', methods=['GET', 'POST'])
@login_required
def add_classroom():
"""Add a new classroom with dynamic building creation and floor suggestion"""
from app.models.building import Building
import re
from sqlalchemy import func
form = ClassroomForm()
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
form.building_id.choices = [(str(b.id), b.name) for b in buildings] + [('__new__', '+ Crear nuevo edificio')]
# Map of existing floors per building for datalist
building_floors = {str(b.id): list(dict.fromkeys(c.floor for c in b.classrooms if c.floor)) for b in buildings}
if form.validate_on_submit():
try:
building = None
if form.building_id.data == '__new__':
new_building_name = form.new_building_name.data.strip() if form.new_building_name.data else ''
if not new_building_name:
flash('Debe ingresar el nombre del nuevo edificio.', 'error')
return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom')
building = Building.query.filter(func.lower(Building.name) == func.lower(new_building_name)).first()
if not building:
code_slug = re.sub(r'[^a-zA-Z0-9]+', '_', new_building_name.upper()).strip('_')
building = Building(name=new_building_name, code=code_slug, is_active=True)
db.session.add(building)
db.session.commit()
else:
building = Building.query.get_or_404(int(form.building_id.data))
room_code = form.room_number.data.strip()
floor_val = str(form.floor.data).strip()
# Prevent duplicate room in same building & floor
existing_classroom = Classroom.query.filter_by(
building_id=building.id,
code=room_code,
floor=floor_val
).first()
if existing_classroom:
flash(f'Ya existe un aula con el nombre "{room_code}" en el piso {floor_val} de {building.name}.', 'error')
return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='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 = 9999
else:
cap_val = int(cap_raw)
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,
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)
# 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 al crear el aula: {str(e)}', 'error')
return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom')
@classrooms_bp.route('/', endpoint='index')
@classrooms_bp.route('/list')
@login_required
def list_classrooms():
"""List all classrooms with separation between Physical spaces and Virtual on-demand rooms"""
try:
from app.models.building import Building
from app.models.reservation import Reservation, ReservationStatus
from app.models.subject import Commission, Subject
import calendar
from datetime import date, timedelta
page = request.args.get('page', 1, type=int)
active_only = request.args.get('active_only', 'true').lower() == 'true'
search = request.args.get('search', '', type=str).strip()
building_filter = request.args.get('building_id', '', type=str).strip()
floor_filter = request.args.get('floor', '', type=str).strip()
view_mode = request.args.get('view_mode', '').strip().lower()
# Identificar edificio virtual y aula virtual base
virtual_building = Building.query.filter(
db.or_(Building.code == 'VIRTUAL', Building.name.ilike('%Virtual%'))
).first()
virtual_bldg_id = virtual_building.id if virtual_building else None
# Si el usuario seleccionó "Campus Virtual" en el filtro de edificios, conmutar a pestaña virtual
if building_filter and str(building_filter) == str(virtual_bldg_id):
view_mode = 'virtual'
elif not view_mode:
view_mode = 'physical'
# Fechas operativas (Día, Semana, Mes)
today = datetime.utcnow().date()
today_start = datetime.combine(today, datetime.min.time())
today_end = datetime.combine(today, datetime.max.time())
week_start = datetime.combine(today - timedelta(days=today.weekday()), datetime.min.time())
week_end = datetime.combine(week_start.date() + timedelta(days=6), datetime.max.time())
_, last_day = calendar.monthrange(today.year, today.month)
month_start = datetime(today.year, today.month, 1, 0, 0, 0)
month_end = datetime(today.year, today.month, last_day, 23, 59, 59)
date_filters = {
'today': today.strftime('%Y-%m-%d'),
'week_start': week_start.strftime('%Y-%m-%d'),
'week_end': week_end.strftime('%Y-%m-%d'),
'month_start': month_start.strftime('%Y-%m-%d'),
'month_end': month_end.strftime('%Y-%m-%d')
}
# --- AULAS FÍSICAS (Presenciales) ---
physical_query = Classroom.query
if virtual_bldg_id:
physical_query = physical_query.filter(Classroom.building_id != virtual_bldg_id)
else:
physical_query = physical_query.filter(~Classroom.code.ilike('%VIRTUAL%'))
if active_only:
physical_query = physical_query.filter_by(is_active=True)
physical_count = physical_query.count()
if building_filter and building_filter.isdigit() and str(building_filter) != str(virtual_bldg_id):
physical_query = physical_query.filter(Classroom.building_id == int(building_filter))
if floor_filter:
flr = str(floor_filter).strip()
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
physical_query = physical_query.filter(db.or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
else:
physical_query = physical_query.filter(Classroom.floor == flr)
if search and view_mode == 'physical':
physical_query = physical_query.filter(
db.or_(
Classroom.building.ilike(f'%{search}%'),
Classroom.code.ilike(f'%{search}%'),
Classroom.description.ilike(f'%{search}%')
)
)
classrooms = physical_query.order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).paginate(
page=page, per_page=20, error_out=False
)
# Edificios y Pisos estrictamente físicos para el filtro
buildings = Building.query.filter_by(is_active=True)
if virtual_bldg_id:
buildings = buildings.filter(Building.id != virtual_bldg_id)
buildings = buildings.order_by(Building.name.asc()).all()
floors = [f[0] for f in db.session.query(Classroom.floor).filter(
Classroom.is_active == True,
Classroom.building_id != virtual_bldg_id if virtual_bldg_id else True,
Classroom.floor.isnot(None)
).distinct().order_by(Classroom.floor.asc()).all()]
# Métricas de ocupación física
visible_ids = [c.id for c in classrooms.items]
month_reservations = Reservation.query.filter(
Reservation.classroom_id.in_(visible_ids),
Reservation.start_time >= month_start,
Reservation.start_time <= month_end,
Reservation.status.in_([ReservationStatus.CONFIRMED.value, ReservationStatus.PENDING.value])
).all() if visible_ids else []
DAILY_OP_MINS = 15 * 60
WEEKLY_OP_MINS = 6 * 15 * 60
MONTHLY_OP_MINS = 24 * 15 * 60
occupancy_metrics = {}
for c in classrooms.items:
c_res = [r for r in month_reservations if r.classroom_id == c.id]
day_res = [r for r in c_res if today_start <= r.start_time <= today_end]
day_mins = sum((min(r.end_time, today_end) - max(r.start_time, today_start)).total_seconds() / 60 for r in day_res)
day_pct = min(100, int(round((day_mins / DAILY_OP_MINS) * 100)))
week_res = [r for r in c_res if week_start <= r.start_time <= week_end]
week_mins = sum((min(r.end_time, week_end) - max(r.start_time, week_start)).total_seconds() / 60 for r in week_res)
week_pct = min(100, int(round((week_mins / WEEKLY_OP_MINS) * 100)))
month_mins = sum((min(r.end_time, month_end) - max(r.start_time, month_start)).total_seconds() / 60 for r in c_res)
month_pct = min(100, int(round((month_mins / MONTHLY_OP_MINS) * 100)))
occupancy_metrics[c.id] = {
'day_pct': day_pct,
'day_count': len(day_res),
'week_pct': week_pct,
'week_count': len(week_res),
'month_pct': month_pct,
'month_count': len(c_res)
}
# --- AULAS VIRTUALES (Campus Virtual a Demanda - Infinitas) ---
vc = Classroom.query.filter(
Classroom.building_id == virtual_bldg_id if virtual_bldg_id else Classroom.code.ilike('%VIRTUAL%')
).first()
vc_id = vc.id if vc else 1
all_vres = Reservation.query.filter(Reservation.classroom_id == vc_id).all() if vc_id else []
# Agrupar las salas virtuales por comisión
comm_dict = {}
for r in all_vres:
c = r.commission
cid = c.id if c else 0
if cid not in comm_dict:
vlink = (c.virtual_link if c and c.virtual_link else None) or r.effective_virtual_link
comm_dict[cid] = {
'commission_id': cid,
'commission_code': c.code if c else 'General',
'subject_name': c.subject.name if (c and c.subject) else (r.purpose or 'Clase Virtual'),
'subject_code': c.subject.code if (c and c.subject) else '',
'career_name': c.subject.career.name if (c and c.subject and hasattr(c.subject, 'career') and c.subject.career) else '',
'max_students': c.max_students if c else (r.expected_attendees or 0),
'virtual_link': vlink,
'reservations': []
}
comm_dict[cid]['reservations'].append(r)
if not comm_dict[cid]['virtual_link'] and r.effective_virtual_link:
comm_dict[cid]['virtual_link'] = r.effective_virtual_link
virtual_rooms = []
for cid, data in comm_dict.items():
res_list = data['reservations']
day_cnt = sum(1 for r in res_list if today_start <= r.start_time <= today_end)
week_cnt = sum(1 for r in res_list if week_start <= r.start_time <= week_end)
month_cnt = sum(1 for r in res_list if month_start <= r.start_time <= month_end)
shift = res_list[0].shift if res_list else 'A Demanda'
if search and view_mode == 'virtual':
s_low = search.lower()
matches = (
s_low in data['subject_name'].lower() or
s_low in data['commission_code'].lower() or
s_low in data['subject_code'].lower() or
s_low in data['career_name'].lower()
)
if not matches:
continue
virtual_rooms.append({
'commission_id': data['commission_id'],
'commission_code': data['commission_code'],
'subject_name': data['subject_name'],
'subject_code': data['subject_code'],
'career_name': data['career_name'],
'max_students': data['max_students'],
'virtual_link': data['virtual_link'],
'shift': shift,
'day_count': day_cnt,
'week_count': week_cnt,
'month_count': month_cnt,
'total_count': len(res_list),
'reservations': sorted(res_list, key=lambda x: x.start_time)
})
virtual_rooms.sort(key=lambda x: (x['subject_name'], x['commission_code']))
global_virtual_metrics = {
'today_count': sum(1 for r in all_vres if today_start <= r.start_time <= today_end),
'week_count': sum(1 for r in all_vres if week_start <= r.start_time <= week_end),
'month_count': sum(1 for r in all_vres if month_start <= r.start_time <= month_end),
'total_count': len(all_vres),
'commissions_count': len(comm_dict)
}
return render_template('classrooms/list.html',
classrooms=classrooms,
buildings=buildings,
floors=floors,
building_filter=building_filter,
floor_filter=floor_filter,
active_only=active_only,
search=search,
view_mode=view_mode,
physical_count=physical_count,
virtual_count=len(comm_dict),
virtual_rooms=virtual_rooms,
virtual_classroom_id=vc_id,
global_virtual_metrics=global_virtual_metrics,
occupancy_metrics=occupancy_metrics,
date_filters=date_filters,
title='Classrooms')
except Exception as e:
flash(f'Error loading classrooms: {str(e)}', 'error')
return render_template('classrooms/list.html', classrooms=None, title='Classrooms')
@classrooms_bp.route('/virtual-room/<int:commission_id>/update-link', methods=['POST'])
@login_required
def update_virtual_link(commission_id):
"""Actualizar enlace de videollamada para una comisión virtual"""
from app.models.subject import Commission
from flask_babel import gettext as _
comm = Commission.query.get_or_404(commission_id)
new_link = request.form.get('virtual_link', '').strip() or None
comm.virtual_link = new_link
db.session.commit()
flash(_('Enlace de videollamada para %(code)s actualizado correctamente.', code=comm.get_full_code()), 'success')
return redirect(request.referrer or url_for('classrooms.list_classrooms', view_mode='virtual'))
@classrooms_bp.route('/<int:id>')
@login_required
def view_classroom(id):
"""View classroom details"""
try:
classroom = Classroom.query.get_or_404(id)
from app.models.reservation import Reservation, ReservationStatus
upcoming_reservations = Reservation.query.filter(
Reservation.classroom_id == id,
Reservation.start_time > datetime.utcnow(),
Reservation.status == ReservationStatus.CONFIRMED.value
).order_by(Reservation.start_time).limit(10).all()
return render_template('classrooms/view.html',
classroom=classroom,
upcoming_reservations=upcoming_reservations,
title=f'Classroom {classroom.code_display}')
except Exception as e:
flash(f'Error loading classroom: {str(e)}', 'error')
return redirect(url_for('classrooms.list_classrooms'))
@classrooms_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def edit_classroom(id):
"""Edit classroom with decoupled building and floor"""
from app.models.building import Building
import re
from sqlalchemy import func
classroom = Classroom.query.get_or_404(id)
form = ClassroomForm(obj=classroom)
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
form.building_id.choices = [(str(b.id), b.name) for b in buildings] + [('__new__', '+ Crear nuevo edificio')]
building_floors = {str(b.id): list(dict.fromkeys(c.floor for c in b.classrooms if c.floor)) for b in buildings}
if request.method == 'GET':
form.building_id.data = str(classroom.building_id or '')
form.room_number.data = classroom.code
form.floor.data = classroom.floor
if classroom.capacity == 0 or classroom.is_virtual:
form.capacity.data = '∞'
else:
form.capacity.data = str(classroom.capacity)
if form.validate_on_submit():
try:
building = None
if form.building_id.data == '__new__':
new_building_name = form.new_building_name.data.strip() if form.new_building_name.data else ''
if not new_building_name:
flash('Debe ingresar el nombre del nuevo edificio.', 'error')
return render_template('classrooms/edit.html', form=form, classroom=classroom, building_floors=building_floors, title='Edit Classroom')
building = Building.query.filter(func.lower(Building.name) == func.lower(new_building_name)).first()
if not building:
code_slug = re.sub(r'[^a-zA-Z0-9]+', '_', new_building_name.upper()).strip('_')
building = Building(name=new_building_name, code=code_slug, is_active=True)
db.session.add(building)
db.session.commit()
else:
building = Building.query.get_or_404(int(form.building_id.data))
room_code = form.room_number.data.strip()
floor_val = str(form.floor.data).strip()
# Check duplicate excluding current
existing_classroom = Classroom.query.filter(
Classroom.building_id == building.id,
Classroom.code == room_code,
Classroom.floor == floor_val,
Classroom.id != id
).first()
if existing_classroom:
flash(f'Ya existe otra aula con el nombre "{room_code}" en el piso {floor_val} de {building.name}.', 'error')
return render_template('classrooms/edit.html', form=form, classroom=classroom, building_floors=building_floors, title='Edit 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
else:
cap_val = int(cap_raw)
classroom.building_id = building.id
classroom.building = building.name
classroom.code = room_code
classroom.floor = floor_val
classroom.capacity = cap_val
classroom.description = form.description.data
classroom.is_active = form.is_active.data
classroom.updated_at = datetime.utcnow()
db.session.commit()
flash(f'Aula {classroom.location_display} actualizada correctamente.', 'success')
return redirect(url_for('classrooms.view_classroom', id=classroom.id))
except Exception as e:
db.session.rollback()
flash(f'Error updating classroom: {str(e)}', 'error')
return render_template('classrooms/edit.html', form=form, classroom=classroom, building_floors=building_floors, title='Edit Classroom')
@classrooms_bp.route('/<int:id>/toggle', methods=['POST'])
@login_required
def toggle_classroom(id):
"""Toggle classroom active status"""
classroom = Classroom.query.get_or_404(id)
try:
classroom.is_active = not classroom.is_active
classroom.updated_at = datetime.utcnow()
db.session.commit()
status = 'activated' if classroom.is_active else 'deactivated'
flash(f'Classroom {classroom.code_display} has been {status} successfully!', 'success')
except Exception as e:
db.session.rollback()
flash(f'Error updating classroom status: {str(e)}', 'error')
return redirect(request.referrer or url_for('classrooms.list_classrooms'))
# API endpoints for AJAX requests
@classrooms_bp.route('/api/available')
@login_required
def get_available_classrooms():
"""Get available classrooms for a specific time slot"""
start_time = request.args.get('start_time')
end_time = request.args.get('end_time')
if not start_time or not end_time:
return jsonify({'error': 'Start time and end time are required'}), 400
try:
from datetime import datetime
from app.models.reservation import Reservation, ReservationStatus
start_dt = datetime.fromisoformat(start_time)
end_dt = datetime.fromisoformat(end_time)
# Find all classrooms
all_classrooms = Classroom.query.filter_by(is_active=True).all()
available_classrooms = []
for classroom in all_classrooms:
# Check if classroom has conflicting reservations
conflicts = Reservation.query.filter(
Reservation.classroom_id == classroom.id,
Reservation.status == ReservationStatus.CONFIRMED.value,
Reservation.start_time < end_dt,
Reservation.end_time > start_dt
).all()
if not conflicts:
available_classrooms.append({
'id': classroom.id,
'code': classroom.code,
'building': classroom.building,
'room_number': classroom.code,
'floor': classroom.floor,
'capacity': classroom.capacity,
'code_display': classroom.code_display
})
return jsonify({'classrooms': available_classrooms})
except Exception as e:
return jsonify({'error': str(e)}), 500
@@ -39,8 +39,8 @@ def optimize_reservations():
except ValueError: except ValueError:
return jsonify({'error': 'Invalid end_date format. Use ISO format.'}), 400 return jsonify({'error': 'Invalid end_date format. Use ISO format.'}), 400
# Check if user has permission (admin or teacher) # Check if user has permission (admin, teacher or RBAC optimizer write)
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 optimize reservations'}), 403 return jsonify({'error': 'Insufficient permissions to optimize reservations'}), 403
# Initialize optimizer and run optimization # Initialize optimizer and run optimization
@@ -84,7 +84,7 @@ def apply_optimization():
return jsonify({'error': 'reservations must be a list'}), 400 return jsonify({'error': 'reservations must be a list'}), 400
# Check if user has permission # 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 return jsonify({'error': 'Insufficient permissions to apply reservations'}), 403
# Initialize optimizer and apply reservations # Initialize optimizer and apply reservations
@@ -249,7 +249,7 @@ genetic_web_bp = Blueprint('genetic_algorithm_web', __name__)
@login_required @login_required
def genetic_optimizer(): def genetic_optimizer():
"""Render the genetic algorithm optimizer interface""" """Render the genetic algorithm optimizer interface"""
if current_user.role not in ['ADMIN']: if not (current_user.has_permission('optimizer', 'read') or (current_user.role and current_user.role.upper() == 'ADMIN')):
return render_template('auth/403.html'), 403 return render_template('auth/403.html'), 403
return render_template('genetic_optimizer.html') return render_template('genetic_optimizer.html')
+333
View File
@@ -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])␍
+126
View File
@@ -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
)
+975
View File
@@ -0,0 +1,975 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required, current_user
from app import db
from app.models.reservation import Reservation, ReservationStatus
from app.models.classroom import Classroom
from app.models.subject import Subject, Commission
from app.forms.reservation import ReservationForm
from datetime import datetime, timedelta, date
from sqlalchemy import func, or_
from sqlalchemy.orm import joinedload
schedule_bp = Blueprint('schedule', __name__, url_prefix='/schedule')
@schedule_bp.route('/today')
@login_required
def today_schedule():
"""Show today's schedule with filters for virtual classrooms, floor, and shift"""
today = datetime.utcnow().date()
today_start = datetime.combine(today, datetime.min.time())
today_end = datetime.combine(today, datetime.max.time())
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
floor_filter = request.args.get('floor', '').strip()
shift_filter = request.args.get('shift', '').strip()
# Base query for today's reservations
query = Reservation.query.options(
joinedload(Reservation.classroom),
joinedload(Reservation.commission).joinedload(Commission.subject),
joinedload(Reservation.user)
).join(Reservation.classroom).filter(
Reservation.start_time >= today_start,
Reservation.start_time <= today_end,
Reservation.status.in_([ReservationStatus.CONFIRMED.value, ReservationStatus.PENDING.value])
)
if hide_virtual:
query = query.filter(
~Classroom.code.ilike('%VIRTUAL%'),
~Classroom.building.ilike('%Virtual%')
)
if floor_filter:
flr = str(floor_filter).strip()
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
query = query.filter(or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
else:
query = query.filter(Classroom.floor == flr)
if shift_filter:
query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%'))
reservations = query.order_by(Reservation.start_time).all()
# Create time blocks for timeline view
time_blocks = {}
for reservation in reservations:
start_hour = reservation.start_time.hour
end_hour = reservation.end_time.hour
for hour in range(max(7, start_hour), min(22, end_hour + 1)):
if hour not in time_blocks:
time_blocks[hour] = []
if reservation not in time_blocks[hour]:
time_blocks[hour].append({
'reservation': reservation,
'classroom': reservation.classroom,
'commission': reservation.commission,
'user': reservation.user
})
# Calculate stats
total_reservations = len(reservations)
confirmed = len([r for r in reservations if r.status == ReservationStatus.CONFIRMED.value or str(r.status) == 'CONFIRMED'])
pending = len([r for r in reservations if r.status == ReservationStatus.PENDING or str(r.status) == 'PENDING'])
total_classrooms = Classroom.query.count()
occupied_classrooms = len(set(r.classroom_id for r in reservations if r.classroom_id))
stats = {
'total_reservations': total_reservations,
'confirmed': confirmed,
'pending': pending,
'occupied_classrooms': occupied_classrooms,
'total_classrooms': total_classrooms
}
floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()
available_floors = [f[0] for f in floors]
return render_template('schedule/today.html',
reservations=reservations,
time_blocks=time_blocks,
stats=stats,
today=today,
hide_virtual=hide_virtual,
floor_filter=floor_filter,
shift_filter=shift_filter,
available_floors=available_floors)
@schedule_bp.route('/cancel/<int:id>', methods=['POST'])
@login_required
def cancel_reservation(id):
"""Cancel a reservation"""
try:
reservation = Reservation.query.get_or_404(id)
if not reservation.can_be_cancelled():
flash('Reservations can only be cancelled at least 24 hours before start time.', 'error')
return redirect(request.referrer)
reservation.status = ReservationStatus.CANCELLED
db.session.commit()
flash('Reservation cancelled successfully!', 'success')
except Exception as e:
db.session.rollback()
flash(f'Error cancelling reservation: {str(e)}', 'error')
return redirect(request.referrer)
@schedule_bp.route('/reservation/<int:id>/view')
@login_required
def view_reservation(id):
"""View reservation details"""
try:
reservation = Reservation.query.options(
joinedload(Reservation.classroom),
joinedload(Reservation.commission),
joinedload(Reservation.user)
).get_or_404(id)
return render_template('schedule/view.html',
reservation=reservation,
title='Reservation Details')
except Exception as e:
flash(f'Error loading reservation details: {str(e)}', 'error')
return redirect(url_for('schedule.today_schedule'))
@schedule_bp.route('/reservation/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def edit_reservation(id):
"""Edit existing reservation"""
from app.models.classroom import Classroom
from app.models.subject import Commission
from app.models.building import Building
from flask_babel import gettext as _
reservation = Reservation.query.options(
joinedload(Reservation.classroom),
joinedload(Reservation.commission),
joinedload(Reservation.user)
).get_or_404(id)
if not current_user.can_edit_reservation(reservation):
flash(_('No tienes los permisos necesarios para modificar esta reserva.'), 'error')
return redirect(url_for('schedule.view_reservation', id=reservation.id))
form = ReservationForm(is_edit=True)
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all()
commissions = Commission.query.filter_by(active=True).all()
form.classroom_id.choices = [(c.id, f'{c.building_name} - {c.code} (Cap: {c.capacity_display})') for c in classrooms]
form.commission_id.choices = [(c.id, f'{c.get_full_code()} - {c.subject.name if c.subject else "No Subject"} (Students: {c.max_students})') for c in commissions]
if request.method == 'GET':
form.classroom_id.data = reservation.classroom_id
form.commission_id.data = reservation.commission_id
form.start_time.data = reservation.start_time
form.end_time.data = reservation.end_time
form.purpose.data = reservation.purpose
form.expected_attendees.data = reservation.expected_attendees
form.notes.data = reservation.notes or ''
form.virtual_link.data = reservation.virtual_link or ''
elif request.method == 'POST':
if form.validate_on_submit():
try:
# Verificar colisión de horario si no es virtual
target_room = Classroom.query.get(form.classroom_id.data)
if target_room and not target_room.is_virtual:
conflict = Reservation.query.filter(
Reservation.id != reservation.id,
Reservation.classroom_id == form.classroom_id.data,
Reservation.status == ReservationStatus.CONFIRMED.value,
Reservation.start_time < form.end_time.data,
Reservation.end_time > form.start_time.data
).first()
if conflict:
flash(_('El aula seleccionada ya cuenta con una reserva confirmada en esa franja horaria.'), 'danger')
return render_template('schedule/edit.html', form=form, reservation=reservation, classroom_list=classrooms, commission_list=commissions, buildings=buildings)
# Asignar cambios
reservation.classroom_id = form.classroom_id.data
reservation.commission_id = form.commission_id.data
reservation.start_time = form.start_time.data
reservation.end_time = form.end_time.data
reservation.purpose = form.purpose.data
reservation.expected_attendees = form.expected_attendees.data
reservation.notes = form.notes.data
reservation.virtual_link = form.virtual_link.data.strip() if form.virtual_link.data else None
# Turno
h = reservation.start_time.hour
if h < 13:
reservation.shift = 'Mañana'
elif h < 18:
reservation.shift = 'Tarde'
elif h < 21:
reservation.shift = 'Vespertino'
else:
reservation.shift = 'Noche'
db.session.commit()
flash(_('Reserva #%(id)d modificada exitosamente.', id=reservation.id), 'success')
return redirect(url_for('schedule.view_reservation', id=reservation.id))
except Exception as e:
db.session.rollback()
flash(f'Error al modificar reserva: {str(e)}', 'danger')
return render_template('schedule/edit.html', form=form, reservation=reservation, classroom_list=classrooms, commission_list=commissions, buildings=buildings)
@schedule_bp.route('/calendar')
@login_required
def calendar_view():
"""Show calendar view with classroom, subject, career, shift, floor and virtual filtering"""
from app.models.career import Career
from app.models.building import Building
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building.asc(), Classroom.code.asc()).all()
subjects = Subject.query.filter_by(is_active=True).order_by(Subject.name.asc()).all()
careers = Career.query.order_by(Career.name.asc()).all()
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()
available_floors = [f[0] for f in floors]
return render_template(
'schedule/calendar.html',
title='Calendar View',
today_date=date.today().isoformat(),
classrooms=classrooms,
subjects=subjects,
careers=careers,
buildings=buildings,
available_floors=available_floors
)
def user_can_manage_commission_milestones(user, commission=None):
"""Verifica si el usuario (Docente, Admin, Bedelía) puede gestionar hitos y links virtuales"""
if user.is_admin():
return True
role_name = (user.role_obj.name if user.role_obj else user.role or '').lower()
if role_name in ['admin', 'bedelia', 'bedelía', 'operador']:
return True
if role_name in ['docente', 'profesor']:
return True
return user.has_permission('schedule', 'read_write')
@schedule_bp.route('/commissions')
@login_required
def commissions_view():
"""Vista y ABM de Comisiones con soporte de enlace virtual e hitos académicos"""
from app.models.subject import Commission, Subject
from app.models.classroom import Classroom
from app.models.user import User
from app.models.milestone import MilestoneType
subjects = Subject.query.order_by(Subject.code.asc()).all()
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.code.asc()).all()
teachers = User.query.filter_by(is_active=True).order_by(User.name.asc()).all()
milestone_types = MilestoneType.query.filter_by(is_active=True).order_by(MilestoneType.sort_order.asc()).all()
# Totales para indicadores superiores
total_capacity = sum(c.max_students for s in subjects for c in s.commissions)
total_commissions = sum(len(s.commissions) for s in subjects)
can_manage_milestones = user_can_manage_commission_milestones(current_user)
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
can_edit_commissions = current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')
return render_template('schedule/commissions.html',
subjects=subjects,
classrooms=classrooms,
teachers=teachers,
milestone_types=milestone_types,
total_capacity=total_capacity,
total_commissions=total_commissions,
can_manage_milestones=can_manage_milestones,
can_edit_commissions=can_edit_commissions)
@schedule_bp.route('/commissions/add', methods=['POST'])
@login_required
def commission_add():
"""Crear una nueva comisión de cursada"""
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')):
flash(_('No tienes permisos para crear comisiones.'), 'error')
return redirect(url_for('schedule.commissions_view'))
from app.models.subject import Commission
subject_id = request.form.get('subject_id', type=int)
code = request.form.get('code', '').strip()
semester = request.form.get('semester', '1C').strip()
year = request.form.get('year', datetime.now().year, type=int)
teacher_id = request.form.get('teacher_id', type=int) or None
max_students = request.form.get('max_students', 40, type=int)
schedule = request.form.get('schedule', '').strip()
shift = request.form.get('shift', 'Tarde').strip()
virtual_link = request.form.get('virtual_link', '').strip()
if not subject_id or not code:
flash(_('La asignatura y el código de comisión son obligatorios.'), 'error')
return redirect(url_for('schedule.commissions_view'))
# Verificar existencia duplicada
existing = Commission.query.filter_by(subject_id=subject_id, code=code, semester=semester, year=year).first()
if existing:
flash(_('Ya existe una comisión %(code)s para el ciclo %(sem)s %(yr)d.', code=code, sem=semester, yr=year), 'error')
return redirect(url_for('schedule.commissions_view'))
new_comm = Commission(
subject_id=subject_id,
code=code,
semester=semester,
year=year,
teacher_id=teacher_id,
max_students=max_students,
schedule=schedule,
shift=shift,
virtual_link=virtual_link or None,
active=True
)
db.session.add(new_comm)
db.session.commit()
flash(_('Comisión %(code)s creada exitosamente.', code=new_comm.get_full_code()), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/commissions/<int:id>/edit', methods=['POST'])
@login_required
def commission_edit(id):
"""Editar datos de una comisión"""
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')):
flash(_('No tienes permisos para modificar comisiones.'), 'error')
return redirect(url_for('schedule.commissions_view'))
from app.models.subject import Commission
commission = Commission.query.get_or_404(id)
commission.code = request.form.get('code', commission.code).strip()
commission.semester = request.form.get('semester', commission.semester).strip()
commission.year = request.form.get('year', commission.year, type=int)
commission.teacher_id = request.form.get('teacher_id', type=int) or None
commission.max_students = request.form.get('max_students', commission.max_students, type=int)
commission.schedule = request.form.get('schedule', commission.schedule).strip()
commission.shift = request.form.get('shift', commission.shift).strip()
commission.virtual_link = request.form.get('virtual_link', '').strip() or None
commission.active = True if request.form.get('active') in ['1', 'true', 'on'] else False
db.session.commit()
flash(_('Comisión %(code)s actualizada con éxito.', code=commission.get_full_code()), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/commissions/<int:id>/delete', methods=['POST'])
@login_required
def commission_delete(id):
"""Eliminar una comisión"""
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')):
flash(_('No tienes permisos para eliminar comisiones.'), 'error')
return redirect(url_for('schedule.commissions_view'))
from app.models.subject import Commission
commission = Commission.query.get_or_404(id)
full_code = commission.get_full_code()
db.session.delete(commission)
db.session.commit()
flash(_('Comisión %(code)s eliminada permanentemente.', code=full_code), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/commissions/<int:id>/virtual-link', methods=['POST'])
@login_required
def commission_virtual_link(id):
"""Actualizar enlace del aula virtual (Zoom, Meet, Teams, Moodle) - Profesores, Admin y Bedelía"""
from app.models.subject import Commission
commission = Commission.query.get_or_404(id)
if not user_can_manage_commission_milestones(current_user, commission):
flash(_('No tienes permisos para actualizar el aula virtual de esta comisión.'), 'error')
return redirect(url_for('schedule.commissions_view'))
link = request.form.get('virtual_link', '').strip()
commission.virtual_link = link or None
db.session.commit()
flash(_('Enlace del aula virtual actualizado para la comisión %(code)s.', code=commission.get_full_code()), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/commissions/<int:id>/milestones/add', methods=['POST'])
@login_required
def commission_milestone_add(id):
"""Agregar un hito académico (Parcial, TP, Recuperatorio) a una comisión - Profesores, Admin y Bedelía"""
from app.models.subject import Commission
from app.models.milestone import AcademicMilestone, MilestoneType
commission = Commission.query.get_or_404(id)
if not user_can_manage_commission_milestones(current_user, commission):
flash(_('No tienes permisos para agregar hitos a esta comisión.'), 'error')
return redirect(url_for('schedule.commissions_view'))
milestone_type_id = request.form.get('milestone_type_id', type=int)
title = request.form.get('title', '').strip()
date_str = request.form.get('date', '').strip()
time_str = request.form.get('time', '18:00').strip()
classroom = request.form.get('classroom', '').strip()
link = request.form.get('link', '').strip() or commission.virtual_link or ''
notes = request.form.get('notes', '').strip()
if not milestone_type_id or not date_str:
flash(_('Debes especificar el tipo de hito y la fecha.'), 'error')
return redirect(url_for('schedule.commissions_view'))
m_type = MilestoneType.query.get(milestone_type_id)
if not m_type:
flash(_('Tipo de hito inválido.'), 'error')
return redirect(url_for('schedule.commissions_view'))
try:
dt = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M")
except ValueError:
try:
dt = datetime.strptime(date_str, "%Y-%m-%d")
except ValueError:
flash(_('Formato de fecha inválido.'), 'error')
return redirect(url_for('schedule.commissions_view'))
milestone = AcademicMilestone(
title=title or m_type.name,
materia_codigo=commission.subject.code if commission.subject else 'ASIG',
subject_id=commission.subject_id,
commission_id=commission.id,
milestone_type_id=m_type.id,
date=dt,
classroom=classroom or 'Campus Virtual',
link=link or None,
notes=notes or None,
is_active=True
)
db.session.add(milestone)
db.session.commit()
flash(_('Hito "%(title)s" registrado correctamente en %(comm)s.', title=milestone.title, comm=commission.get_full_code()), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/commissions/<int:commission_id>/milestones/<int:milestone_id>/delete', methods=['POST'])
@login_required
def commission_milestone_delete(commission_id, milestone_id):
"""Eliminar un hito académico de una comisión"""
from app.models.subject import Commission
from app.models.milestone import AcademicMilestone
commission = Commission.query.get_or_404(commission_id)
if not user_can_manage_commission_milestones(current_user, commission):
flash(_('No tienes permisos para eliminar hitos de esta comisión.'), 'error')
return redirect(url_for('schedule.commissions_view'))
milestone = AcademicMilestone.query.filter_by(id=milestone_id, commission_id=commission_id).first_or_404()
title = milestone.title
db.session.delete(milestone)
db.session.commit()
flash(_('Hito "%(title)s" eliminado de la comisión.', title=title), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/add', methods=['GET', 'POST'])
@login_required
def add_reservation():
"""Add new reservation"""
from app.models.classroom import Classroom
from app.models.subject import Commission
from app.models.building import Building
form = ReservationForm()
# Get active buildings and classrooms
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all()
commissions = Commission.query.filter_by(active=True).all()
# Populate form choices
form.classroom_id.choices = [(c.id, f'{c.building_name} - {c.code} (Cap: {c.capacity_display})') for c in classrooms]
form.commission_id.choices = [(c.id, f'{c.get_full_code()} - {c.subject.name if c.subject else "No Subject"} (Students: {c.max_students})') for c in commissions]
if request.method == 'POST':
if form.validate_on_submit():
try:
# Check for existing reservations at the same time
from app.models.reservation import ReservationStatus
existing_reservation = Reservation.query.filter_by(
classroom_id=form.classroom_id.data,
status=ReservationStatus.CONFIRMED.value
).filter(
Reservation.start_time < form.end_time.data,
Reservation.end_time > form.start_time.data
).first()
if existing_reservation:
flash('This classroom is already booked for the selected time slot.', 'danger')
return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions, buildings=buildings)
# Create new reservation
reservation = Reservation(
classroom_id=form.classroom_id.data,
commission_id=form.commission_id.data,
user_id=current_user.id,
start_time=form.start_time.data,
end_time=form.end_time.data,
purpose=form.purpose.data,
notes=form.notes.data,
virtual_link=form.virtual_link.data.strip() if form.virtual_link.data else None,
status=ReservationStatus.PENDING.value,
expected_attendees=form.expected_attendees.data
)
db.session.add(reservation)
db.session.commit()
flash('Reservation request submitted successfully! It will be reviewed by an administrator.', 'success')
return redirect(url_for('schedule.list_reservations'))
except Exception as e:
db.session.rollback()
flash(f'Error creating reservation: {str(e)}', 'danger')
return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions, buildings=buildings)
@schedule_bp.route('/reservation/add', methods=['POST'])
@login_required
def quick_reservation_add():
"""Endpoint AJAX para crear reserva rápida desde el modal de comisiones"""
try:
commission_id = request.form.get('commission_id', type=int)
classroom_id = request.form.get('classroom_id', type=int)
purpose = request.form.get('purpose', '').strip() or 'Clase de cursada'
start_str = request.form.get('start_time', '').strip()
end_str = request.form.get('end_time', '').strip()
virtual_link = request.form.get('virtual_link', '').strip() or None
if not commission_id or not classroom_id or not start_str or not end_str:
return jsonify({'error': 'Faltan campos obligatorios'}), 400
start_time = datetime.strptime(start_str, '%Y-%m-%d %H:%M')
end_time = datetime.strptime(end_str, '%Y-%m-%d %H:%M')
from app.models.subject import Commission
from app.models.classroom import Classroom
comm = Commission.query.get_or_404(commission_id)
if not virtual_link and comm.virtual_link:
virtual_link = comm.virtual_link
new_res = Reservation(
classroom_id=classroom_id,
commission_id=commission_id,
user_id=comm.teacher_id or current_user.id,
start_time=start_time,
end_time=end_time,
purpose=purpose,
expected_attendees=comm.max_students or 30,
shift=comm.shift or 'Tarde',
virtual_link=virtual_link,
status=ReservationStatus.CONFIRMED.value if current_user.is_admin() else ReservationStatus.PENDING.value
)
db.session.add(new_res)
db.session.commit()
return jsonify({'success': True, 'id': new_res.id})
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 500
@schedule_bp.route('/list')
@login_required
def list_reservations():
"""List all reservations with pagination, date filters, floor, shift, and virtual toggle"""
page = request.args.get('page', 1, type=int)
status_filter = request.args.get('status', 'all').strip()
date_from = request.args.get('date_from', '').strip()
date_to = request.args.get('date_to', '').strip()
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
floor_filter = request.args.get('floor', '').strip()
shift_filter = request.args.get('shift', '').strip()
classroom_id = request.args.get('classroom_id', type=int)
search = request.args.get('search', '').strip()
query = Reservation.query.options(
joinedload(Reservation.classroom),
joinedload(Reservation.commission).joinedload(Commission.subject),
joinedload(Reservation.user)
).join(Reservation.classroom).outerjoin(Reservation.commission).outerjoin(Commission.subject)
if classroom_id:
query = query.filter(Reservation.classroom_id == classroom_id)
if status_filter and status_filter != 'all':
query = query.filter(Reservation.status == status_filter.upper())
if date_from:
try:
d_from = datetime.strptime(date_from, '%Y-%m-%d')
query = query.filter(Reservation.start_time >= d_from)
except ValueError:
pass
if date_to:
try:
d_to = datetime.strptime(date_to, '%Y-%m-%d') + timedelta(days=1)
query = query.filter(Reservation.start_time < d_to)
except ValueError:
pass
if hide_virtual:
query = query.filter(
~Classroom.code.ilike('%VIRTUAL%'),
~Classroom.building.ilike('%Virtual%')
)
if floor_filter:
flr = str(floor_filter).strip()
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
query = query.filter(or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
else:
query = query.filter(Classroom.floor == flr)
if shift_filter:
query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%'))
if search:
query = query.filter(
or_(
Subject.name.ilike(f'%{search}%'),
Subject.code.ilike(f'%{search}%'),
Commission.code.ilike(f'%{search}%'),
Classroom.code.ilike(f'%{search}%'),
Reservation.purpose.ilike(f'%{search}%'),
Reservation.shift.ilike(f'%{search}%')
)
)
reservations = query.order_by(Reservation.start_time.desc()).paginate(
page=page, per_page=20, error_out=False
)
floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()
available_floors = [f[0] for f in floors]
all_classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all()
return render_template(
'schedule/list.html',
reservations=reservations,
status_filter=status_filter,
date_from=date_from,
date_to=date_to,
hide_virtual=hide_virtual,
floor_filter=floor_filter,
shift_filter=shift_filter,
classroom_id=classroom_id,
all_classrooms=all_classrooms,
search=search,
available_floors=available_floors
)
@schedule_bp.route('/reservation/<int:id>/confirm', methods=['GET', 'POST'])
@login_required
def confirm_reservation(id):
"""Confirm a reservation (Handles both GET from link and POST from form)"""
try:
reservation = Reservation.query.get_or_404(id)
# Validación de seguridad: Solo confirmar si está pendiente
if reservation.status != ReservationStatus.PENDING.value:
flash('Only pending reservations can be confirmed.', 'warning')
return redirect(url_for('schedule.today_schedule'))
reservation.status = ReservationStatus.CONFIRMED.value
db.session.commit()
flash(
f'Reservation for {reservation.commission.code if reservation.commission else "Class"} confirmed successfully!',
'success')
except Exception as e:
db.session.rollback()
flash(f'Error confirming reservation: {str(e)}', 'error')
# Redirigir a la página desde la que venía el usuario o al inicio de hoy
return redirect(request.referrer or url_for('schedule.today_schedule'))
# API endpoints
@schedule_bp.route('/api/classroom-availability')
@login_required
def classroom_availability_api():
"""Get classroom availability for a specific date/time"""
try:
date_str = request.args.get('date')
start_time_str = request.args.get('start_time')
end_time_str = request.args.get('end_time')
max_students = request.args.get('max_students', type=int)
if not all([date_str, start_time_str, end_time_str]):
return jsonify({'error': 'Missing required parameters: date, start_time, end_time'}), 400
# Parse datetime strings
from datetime import datetime
start_time = datetime.strptime(f"{date_str} {start_time_str}", "%Y-%m-%d %H:%M")
end_time = datetime.strptime(f"{date_str} {end_time_str}", "%Y-%m-%d %H:%M")
# Get availability status
from app.models.classroom import Classroom
availability = Classroom.get_availability_status(start_time, end_time)
# Get best classroom recommendation if max_students provided
best_recommendation = None
if max_students:
best_classrooms = Classroom.find_best_classroom(max_students, start_time, end_time)
if best_classrooms:
best_recommendation = best_classrooms[0] # Most efficient one
# Format response
response = {
'availability': {
'available': [
{
'id': item['classroom'].id,
'code': item['classroom'].code_display,
'name': item['classroom'].name,
'capacity': item['classroom'].capacity,
'location': item['classroom'].get_full_location()
}
for item in availability['available']
],
'occupied': [
{
'id': item['classroom'].id,
'code': item['classroom'].code_display,
'name': item['classroom'].name,
'capacity': item['classroom'].capacity,
'location': item['classroom'].get_full_location(),
'conflicts': [
{
'start_time': conflict.start_time.strftime('%H:%M'),
'end_time': conflict.end_time.strftime('%H:%M'),
'commission': conflict.commission.get_full_code() if conflict.commission else 'Unknown'
}
for conflict in item['conflicts']
]
}
for item in availability['occupied']
],
'stats': {
'total_available': availability['total_available'],
'total_occupied': availability['total_occupied']
}
}
}
if best_recommendation:
response['recommendation'] = {
'classroom': {
'id': best_recommendation['classroom'].id,
'code': best_recommendation['classroom'].code_display,
'name': best_recommendation['classroom'].name,
'capacity': best_recommendation['classroom'].capacity,
'location': best_recommendation['classroom'].get_full_location()
},
'efficiency': best_recommendation['efficiency'],
'wasted_capacity': best_recommendation['wasted_capacity'],
'reason': 'Best match based on capacity and availability'
}
return jsonify(response)
except ValueError as e:
return jsonify({'error': f'Invalid date/time format: {str(e)}'}), 400
except Exception as e:
return jsonify({'error': str(e)}), 500
@schedule_bp.route('/calendar-data')
@login_required
def calendar_data():
"""Get calendar events data for FullCalendar"""
try:
start = request.args.get('start')
end = request.args.get('end')
classroom_id = request.args.get('classroom_id')
subject_id = request.args.get('subject_id')
career_id = request.args.get('career_id')
shift = request.args.get('shift', '').strip()
floor = request.args.get('floor', '').strip()
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
search = request.args.get('search', '').strip()
if start and end:
# Clean up ISO timezone strings for PostgreSQL naive timestamp comparison
start_clean = start.replace('Z', '+00:00')
end_clean = end.replace('Z', '+00:00')
start_date = datetime.fromisoformat(start_clean).replace(tzinfo=None)
end_date = datetime.fromisoformat(end_clean).replace(tzinfo=None)
else:
today = date.today()
start_date = datetime.combine(today.replace(day=1), datetime.min.time())
next_month = today.replace(day=28) + timedelta(days=4)
end_date = datetime.combine(next_month.replace(day=1), datetime.max.time())
query = Reservation.query.join(Reservation.commission).join(Commission.subject).outerjoin(Reservation.classroom).options(
joinedload(Reservation.classroom),
joinedload(Reservation.commission).joinedload(Commission.subject).joinedload(Subject.career_obj)
).filter(
Reservation.start_time >= start_date,
Reservation.start_time <= end_date
)
if classroom_id and classroom_id.isdigit():
query = query.filter(Reservation.classroom_id == int(classroom_id))
if subject_id and subject_id.isdigit():
query = query.filter(Commission.subject_id == int(subject_id))
if career_id and career_id.isdigit():
query = query.filter(Subject.career_id == int(career_id))
building = request.args.get('building', '').strip()
if building:
from app.models.building import Building
query = query.filter(
or_(
Classroom.building.ilike(f'%{building}%'),
Classroom.building_entity.has(Building.name.ilike(f'%{building}%'))
)
)
if shift:
query = query.filter(Reservation.shift.ilike(f'%{shift}%'))
if hide_virtual:
query = query.filter(
~Classroom.code.ilike('%VIRTUAL%'),
~Classroom.building.ilike('%Virtual%')
)
if floor:
flr = str(floor).strip()
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
query = query.filter(or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
else:
query = query.filter(Classroom.floor == flr)
if search:
query = query.filter(
or_(
Subject.name.ilike(f'%{search}%'),
Subject.code.ilike(f'%{search}%'),
Classroom.code.ilike(f'%{search}%'),
Reservation.purpose.ilike(f'%{search}%')
)
)
reservations = query.order_by(Reservation.start_time).all()
events = []
for reservation in reservations:
subject = reservation.commission.subject if (reservation.commission and reservation.commission.subject) else None
subject_code = subject.code if subject else (reservation.commission.code if reservation.commission else '')
subject_name = subject.name if subject else (reservation.purpose or 'Clase')
classroom_name = reservation.classroom.code if reservation.classroom else 'Sin Aula'
classroom_floor = reservation.classroom.floor_display if reservation.classroom else None
classroom_building = reservation.classroom.building_name if reservation.classroom else ''
is_virtual = bool(reservation.classroom and reservation.classroom.is_virtual)
career_name = subject.career_obj.name if (subject and subject.career_obj) else (subject.department if subject else '')
status_val = str(reservation.status.value if hasattr(reservation.status, 'value') else reservation.status)
events.append({
'id': reservation.id,
'title': subject_name,
'start': reservation.start_time.isoformat(),
'end': reservation.end_time.isoformat(),
'url': url_for('schedule.view_reservation', id=reservation.id) if reservation.id else None,
'className': f'fc-event-{status_val.lower()}',
'extendedProps': {
'subject_code': subject_code,
'subject_name': subject_name,
'classroom': classroom_name,
'building': classroom_building,
'floor': classroom_floor,
'is_virtual': is_virtual,
'location_display': reservation.classroom.location_display if reservation.classroom else '',
'career': career_name,
'commission': reservation.commission.code if reservation.commission else '',
'shift': reservation.shift or '',
'status': status_val,
'purpose': reservation.purpose or '',
'attendees': reservation.expected_attendees or 0,
'notes': reservation.notes or '',
'teacher': reservation.user.name if reservation.user else '',
'virtual_link': reservation.effective_virtual_link,
'cancel_url': url_for('schedule.cancel_reservation', id=reservation.id)
}
})
return jsonify(events)
except Exception as e:
import traceback
traceback.print_exc()
return jsonify({'error': str(e)}), 500
@schedule_bp.route('/api/today-events')
@login_required
def api_today_events():
"""Get today's events as JSON"""
try:
today_start = datetime.combine(date.today(), datetime.min.time())
today_end = datetime.combine(date.today(), datetime.max.time())
reservations = Reservation.query.options(
joinedload(Reservation.classroom),
joinedload(Reservation.commission).joinedload(Commission.subject).joinedload(Subject.career_obj)
).filter(
Reservation.start_time >= today_start,
Reservation.start_time <= today_end
).order_by(Reservation.start_time).all()
events = []
for reservation in reservations:
subject = reservation.commission.subject if (reservation.commission and reservation.commission.subject) else None
subject_code = subject.code if subject else (reservation.commission.code if reservation.commission else '')
subject_name = subject.name if subject else (reservation.purpose or 'Clase')
classroom_name = reservation.classroom.code if reservation.classroom else 'Sin Aula'
status_val = str(reservation.status.value if hasattr(reservation.status, 'value') else reservation.status)
events.append({
'id': reservation.id,
'title': f"{subject_name} ({subject_code}) - {classroom_name}",
'start': reservation.start_time.isoformat(),
'end': reservation.end_time.isoformat(),
'url': url_for('schedule.view_reservation', id=reservation.id) if reservation.id else None,
'extendedProps': {
'subject_code': subject_code,
'subject_name': subject_name,
'classroom': classroom_name,
'purpose': reservation.purpose or 'No purpose',
'attendees': reservation.expected_attendees,
'status': status_val
}
})
return jsonify({'events': events})
except Exception as e:
return jsonify({'error': str(e)}), 500
+16
View File
@@ -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'
]
+16
View File
@@ -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")
+44
View File
@@ -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
+49
View File
@@ -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
+43
View File
@@ -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
+8
View File
@@ -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'
]
+45
View File
@@ -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
+89
View File
@@ -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
+19
View File
@@ -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'
]
+76
View File
@@ -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()
+118
View File
@@ -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)
+116
View File
@@ -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
+282
View File
@@ -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"
}
+131
View File
@@ -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

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