84 Commits
Author SHA1 Message Date
carlostellocba 1e6f493358 fix(classrooms): blindar vistas presenciales y virtuales sin aulas + feat(admin): reinicializacion de sistema con clave de admin 2026-09-23 21:03:40 -03:00
carlostellocba 95d2aac323 feat(install): agregar opcion --empty-academic en install.sh e init_db.py para inicializar base de datos sin datos demo 2026-09-23 20:44:08 -03:00
carlostellocba ddd1714eb2 feat(abm): abm completo de ciclos lectivos, aulas y reservas con proteccion de integridad y pruebas automatizadas 2026-09-23 20:35:54 -03:00
carlostellocba a188c3bd8f feat(settings): agregar opcion global para habilitar o deshabilitar saludo y selector de rol en dashboard 2026-09-23 20:18:43 -03:00
carlostellocba 63511b9311 fix(bedelia): corregir purga academica con integridad referencial, circuito agil de asignacion de aulas y documentacion v3.1.0 2026-09-23 20:11:29 -03:00
carlostellocba 165a0b65b8 feat: implementar ABM completo de comisiones, carreras y asignaturas con proteccion de integridad y corregir edificios y campus virtual 2026-09-23 19:53:27 -03:00
carlostellocba 195bfd915e fix: normalizar sync de google sheets para todos los dias, reparar 500 en ciclos lectivos, selector de ciclo lectivo en comisiones y opciones --keep-db --keep-users en install.sh 2026-09-23 19:36:54 -03:00
carlostellocba 5771476ee9 fix(settings): corregir desincronizacion de metodos de autenticacion, normalizar claves de proveedores y añadir badges reactivos de estado 2026-09-23 17:52:45 -03:00
carlostellocba b20e206a76 docs: agregar reporte academico para catedra y guia operativa de bedelia con roadmap futuro 2026-09-23 17:06:49 -03:00
carlostellocba 223a129a2e fix(auth): agregar buildUserSession con can_manage y roles para evitar error 500 en dashboard de docente, bedelia y alumno 2026-09-23 17:03:50 -03:00
carlostellocba 7be57aeeb2 fix(express): reemplazar wildcard /settings/* por rutas explicitas compatibles con Express 5 2026-09-23 16:48:25 -03:00
carlostellocba 6907134c5e fix(bff): agregar endpoint /settings/demo-mode y proxy /api/v1 en Express Node.js y Flask API 2026-09-23 16:42:11 -03:00
carlostellocba dd4a4c042a fix(ui): resolver superposicion en menu colapsado, reparar edicion de ciclo lectivo, aplicar paleta UniCABA y agregar control de modo demo en config global 2026-09-23 16:26:42 -03:00
carlostellocba 122caca9ce style(ui): optimizar contrastes del modo claro para accesibilidad y confort visual 2026-09-23 16:06:09 -03:00
carlostellocba 7b6baca2b5 fix(admin): resolver pantalla en blanco al editar ficha de usuario (GET /admin/users/edit/:id) 2026-09-23 15:58:03 -03:00
carlostellocba 9ebfedd17f fix(ui): preservar color natural del logo, habilitar submenus en sidebar colapsado y corregir expansion infinita del canvas en dashboard 2026-09-23 15:50:53 -03:00
carlostellocba 6ecaef9b9d fix(auth/users): corregir edicion de contraseñas de usuarios, cambio de contraseña y evitar pantalla en blanco 2026-09-23 15:38:48 -03:00
carlostellocba b540015b7e fix(auth): flexibilizar login por email/usuario, asegurar hash admin123 y corregir cookies sobre HTTP 2026-09-23 14:59:46 -03:00
carlostellocba bddd382c96 fix(db): anadir migraciones DDL para columnas extendidas de users, subjects y commissions 2026-09-23 14:36:44 -03:00
carlostellocba a19f3911fe fix(deploy): incorporar cryptography en requirements.txt y robustecer instalador install.sh 2026-09-23 14:27:51 -03:00
carlostellocba 7a3468cd9f docs: actualizar diagrama y descripcion de arquitectura monorepo en README.md 2026-09-23 14:15:11 -03:00
carlostellocba 6bfaab634f feat(deploy): mover install.sh a la raiz con soporte unificado para backend, frontend y seleccion de base de datos postgres 2026-09-23 14:12:09 -03:00
carlostellocba 902e30a261 chore: depurar scripts obsoletos de migracion y pruebas legacy no requeridas 2026-09-23 14:05:48 -03:00
carlostellocba ed97fb5995 chore(security): adaptar security_audit.bat y actualizar reporte de auditoria a 100% PASS 2026-09-23 13:49:58 -03:00
carlostellocba 1aae432bba feat(phase5): integracion auth hibrida, email dinamico y moodle 4.1 backend con tolerancia a fallos 2026-09-23 13:34:59 -03:00
carlostellocba 7ad4e3f099 feat: completar fases 3 y 4 del roadmap mvp (gradebook, drag-and-drop, impersonacion) y actualizar README 2026-09-23 12:10:31 -03:00
carlostellocba 4ebc5df4fd fix(ui): resolucion del optimizador genetico, superposicion de tarjetas, contraste de logo y traducciones 2026-09-19 16:23:36 -03:00
carlostellocba 71fb20a271 feat: implement Bedelía dashboard panels and Phase 1 Automated Security Audit 2026-09-19 15:17:17 -03:00
carlostellocba ada39248d7 feat: completar Fase 2 del Roadmap MVP (Co-docencia, Matriz de Conflictos y Regla Diaria) y auditoria de seguridad 2026-09-19 14:05:16 -03:00
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
237 changed files with 50028 additions and 4953 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
+73
View File
@@ -0,0 +1,73 @@
name: Edu-Space Automated Security Audit (Gitea Local)
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
security-scan:
# Usamos la etiqueta estándar para contenedores Ubuntu en Gitea Runner
runs-on: ubuntu-latest
steps:
# 1. Clonar el código del repositorio local
- name: Checkout Code
uses: actions/checkout@v4
# 2. Preparar el entorno de paquetes del sistema (Linux)
- name: Install System Dependencies (Python & Node.js)
run: |
apt-get update && apt-get install -y python3 python3-pip python3-venv nodejs npm curl
python3 -m pip install --upgrade pip
# 3. Instalar librerías de Python y herramientas de seguridad
- name: Install Python Dependencies & Security Tools
run: |
pip3 install -r backend/requirements.txt
pip3 install -r backend/requirements-dev.txt
# 4. Instalar librerías de Node.js (Express BFF)
- name: Install Node.js Dependencies
run: |
cd frontend
npm ci
# --- EJECUCIÓN DE CONTROLES (SAST & SCA) ---
- name: [1/5] Python SAST (Bandit)
run: bandit -r ./backend/app -ll -ii
- name: [2/5] Python SCA (Pip-Audit)
run: pip-audit -s osv --progress-spinner off -r backend/requirements.txt
- name: [3/5] Node.js SAST (Njsscan)
run: njsscan ./frontend/src
- name: [4/5] Node.js SCA (Npm Audit)
run: |
cd frontend
npm audit --audit-level=high
# --- CONTROL DINÁMICO DAST (Schemathesis) ---
- name: [5/5] Start Flask App & Run DAST (Schemathesis)
env:
PYTHONUTF8: 1
FLASK_APP: backend/app # Ajustar a la ruta de inicio de tu Flask si varía
run: |
# Levantar Flask en segundo plano dentro del contenedor local
# Usamos 'python3 -m flask run' para asegurar el bindeo local
cd backend
python3 -m flask run --host=127.0.0.1 --port=5000 &
# Esperar 5 segundos a que la API responda
echo "Esperando a que el backend de Flask inicie en el entorno local..."
sleep 5
# Verificar con un curl rápido si el JSON de OpenAPI está disponible
curl -s http://127.0.0.1:5000/api/v1/openapi.json > /dev/null
# Ejecutar Schemathesis contra la instancia local efímera
schemathesis run http://127.0.0.1:5000/api/v1/openapi.json --checks not_a_server_error --max-examples=10
+18
View File
@@ -57,6 +57,9 @@ cover/
*.mo
*.pot
# Examples
example/
# Django stuff:
*.log
local_settings.py
@@ -207,3 +210,18 @@ cython_debug/
marimo/_static/
marimo/_lsp/
__marimo__/
# Node.js / Frontend
node_modules/
npm-debug.log
.env
.env.*
!.env.example
frontend/.env
legacy_admin-edu-space/
.vscode/
.schemathesis/
AlumnosLS
prerrequisitos
+6
View File
@@ -0,0 +1,6 @@
{
"python.defaultInterpreterPath": "${workspaceFolder}\\venv\\Scripts\\python.exe",
"python.analysis.extraPaths": [
"${workspaceFolder}\\venv\\Lib\\site-packages"
]
}
+347
View File
@@ -0,0 +1,347 @@
# 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 6 del Roadmap MVP)
- **Despliegue e Infraestructura (Fase 6):** `docker-compose.yml` productivo orquestando Flask (Gunicorn), Node.js BFF (PM2) y PostgreSQL 15, con proxy inverso Nginx, SSL/TLS y compresión.
- **Sincronización Continua Moodle (Fase 6):** Automatización de workers persistentes en background para el procesamiento continuo de la cola de Web Services Moodle 4.1.
- **Pruebas de Carga y Hardening (Fase 6):** Pruebas de estrés de concurrencia y validación final de producción.
---
## [3.2.0] - 2026-09-23
### Añadido
- **Función de Reinicialización Total del Sistema (`POST /api/v1/admin/system/reset`):**
- **Módulo de Mantenimiento en Configuración Global (`/admin/settings`):** Nueva pestaña "Mantenimiento & Reinicio" con tarjeta de "Zona de Peligro" y advertencia sobre el alcance de la purga.
- **Requisito Estricto de Seguridad Institucional:** Exige obligatoriamente la validación de la **contraseña del usuario Administrador en sesión** antes de ejecutar la acción; retorna 401 y aborta ante claves incorrectas registrando el evento `SYSTEM_RESET_FAILED`.
- **Purga Referencial Completa:** Vacía de forma segura y en orden estricto de claves foráneas: historiales previos (`AuditLog`), reservas (`Reservation`), hitos evaluativos (`AcademicMilestone`), notas (`MilestoneGrade`), matrículas (`StudentEnrollment`), asignaciones docentes (`CommissionTeacher`), comisiones (`Commission`), materias (`Subject`), carreras (`Career`), recursos de aula (`ClassroomResource`), aulas (`Classroom`), sedes/edificios (`Building`), ciclos lectivos (`AcademicTerm`) y colas de sincronización Moodle (`MoodleSyncTask`).
- **Preservación Estricta de Administración y Cuentas:** Mantiene 100% intactas todas las cuentas de usuario (`User` con hashes de contraseñas), roles RBAC (`Role`), permisos (`Permission`), tipos de hitos (`MilestoneType`), configuraciones globales (`SystemSetting`) y todos los módulos de la pestaña Administración.
- **Trazabilidad Inmutable:** Genera automáticamente un nuevo registro en `AuditLog` con la acción `SYSTEM_RESET`, asociando el correo del administrador ejecutor, su IP y el recuento de entidades purgadas.
- **Interfaz de Usuario y Modal Interactivo:** Diálogo modal `#modalResetSystem` con campo de contraseña enmascarada, switch de visibilidad, casilla de confirmación explícita y botón de confirmación con estado de carga (spinner).
### Corregido
- **Blindaje de Espacios y Sedes Vacíos (Presenciales y Virtuales):**
- **Prevención de Excepciones TypeError:** Se corrigieron accesos inseguros a propiedades nulas o no definidas (`c.code.includes`, `r.start_time.startsWith`, `localeCompare`) en `frontend/src/routes/classrooms.js`, permitiendo navegar a `/classrooms/list_classrooms?view_mode=physical` y `/classrooms/list_classrooms?view_mode=virtual` sin caídas ante bases vacías.
- **Corrección de Evaluación de Longitud en Nunjucks:** Actualizadas las directivas condicionales en `frontend/views/classrooms/list.html` a `classrooms.items|length > 0` y `virtual_rooms|length > 0`, garantizando el despliegue de los *Empty States* institucionales con botones de alta cuando no existen aulas registradas.
- **Mapeo Dinámico de Sedes:** `handleAddClassroom` y `handleClassroomsList` ahora sincronizan con el catálogo `/buildings`, ofreciendo la creación guiada de sedes y vinculación dinámica en el alta de aulas físicas.
- **Blindaje del Fallback Offline:** En caso de fallas o demoras en la API, el manejador de captura provee valores predeterminados seguros para `global_virtual_metrics` y `virtual_rooms`, impidiendo errores 500 no controlados.
---
## [3.1.0] - 2026-09-23
### Añadido
- **ABM Integral de Ciclos Lectivos (`AcademicTerm`):**
- **Gestión End-to-End de Períodos Académicos:** Implementación de ciclo de vida completo para ciclos lectivos: Alta, Edición, Establecimiento de Ciclo Actual, Toggle de Habilitación rápida y Eliminación protegida (`DELETE /api/v1/admin/academic-terms/<id>` y `POST /api/v1/admin/academic-terms/<id>/toggle`).
- **Integridad Relacional Protegida:** Bloqueo de eliminación si el ciclo es el activo corriente, y fallback seguro a desactivación (`is_active = False`) si el ciclo tiene comisiones históricas asociadas.
- **Interfaz Enriquecida en Bedelía / Admin:** Botones de acción contextuales, switches y modales interactivos de confirmación (`#modalDeleteTerm`) en `frontend/views/admin/academic_terms/list.html`.
- **ABM Completo de Espacios y Aulas (`Classroom`):**
- **Control de Estado y Borrado Seguro:** Incorporación de toggle activo/inactivo (`POST /api/v1/classrooms/<id>/toggle`) y endpoint de baja de aulas integrado con el frontend BFF (`/classrooms/toggle_classroom` y `/classrooms/delete_classroom`).
- **Acciones Rápidas en Vista Lista y Ficha:** Botones de edición, alternancia de estado y eliminación con modales de advertencia en las vistas de cuadrícula/tabla (`list.html`) y detalle (`view.html`).
- **ABM Completo de Reservas (`Reservation`):**
- **Eliminación Definitiva (Hard Delete):** Soporte en backend para purga física de reservas (`DELETE /api/v1/reservations/<id>?hard=true` y `POST /api/v1/reservations/<id>/delete`) complementando el flujo de cancelación lógica (`CANCELLED`).
- **Control Operativo en Vista Detalle:** Botón de "Eliminar Reserva Definitivamente" con diálogo modal de confirmación en `/schedule/view.html`.
- **Auditoría y Cobertura 100% de ABMs en la Plataforma:**
- Verificación y garantía de operaciones ABM completas en todos los componentes centrales: **Sedes/Edificios** (`Building`), **Aulas** (`Classroom`), **Ciclos Lectivos** (`AcademicTerm`), **Carreras** (`Career`), **Materias** (`Subject`), **Comisiones** (`Commission`) y **Reservas** (`Reservation`).
- **Circuito Integral y Simplificado de Bedelía:**
- **Flujo Operativo Ágil para Bedelía:**
- Circuito intuitivo de 3 pasos: Catálogo de Aulas (`/classrooms/list_classrooms`) -> Creación/Edición con validación física/virtual y aforo real -> Asignación de Comisiones (`/schedule/add_reservation`).
- Botón de acción rápida `Asignar Aula` (`bi-calendar-plus`) en cada fila de `/admin/commissions_list`, permitiendo asignar espacio y horario a cualquier comisión en 1 solo clic.
- Sincronización bidireccional de parámetros por URL (`?commission_id=...` y `?classroom_id=...`) precargando automáticamente en el formulario de reserva la materia, código, cupo máximo de alumnos y enlace virtual de Google Meet / Teams.
- Selectores inteligentes en cascada en `/schedule/add.html` con autoselección de Edificio, Piso y Aula al navegar desde el catálogo de espacios.
- **Conexión REST en Vivo para Reservas:**
- Sustitución de listas fijas/mockeadas en `frontend/src/routes/schedule.js` por consulta dinámica a `/commissions` en vivo, permitiendo reservar aulas para cualquiera de las comisiones reales de la plataforma.
- **Soporte Multisede Consistente en Aulas:**
- Resolución robusta de `building_id` y flag `is_virtual` en `frontend/src/routes/classrooms.js`, eliminando hardcodes y asegurando que las aulas físicas pertenezcan a la sede seleccionada.
- **Inicialización de Base de Datos Limpia sin Datos Demo (`--empty-academic` / `--no-demo`):**
- **Soporte en `install.sh` y Menú Interactivo:** Incorporación de la opción `sudo bash install.sh --empty-academic` (o `--no-demo`) y opción `[3]` en el menú interactivo para crear la base de datos limpia desde cero, omitiendo la inyección de snapshots con carreras, materias, comisiones, aulas o reservas heredadas.
- **Inicializador de Esquema `init_db.py`:** Nuevo flag `--empty-academic` que genera el esquema de tablas completo, los roles y permisos RBAC, los usuarios institucionales y las tipificaciones de hitos, purgando y omitiendo cualquier edificio, aula física o virtual, carrera, materia, comisión o reserva demo por defecto.
- **Personalización de Interfaz y Saludo en Dashboard (Configuración Global):**
- **Nueva Pestaña de Interfaz & Dashboard (`/admin/settings`):** Módulo en el panel de configuración global para personalizar elementos visuales del sistema.
- **Desactivación Opcional del Saludo y Selector de Rol:** Switch interactivo para habilitar o deshabilitar el banner superior `¡Hola, {Usuario}!` y el selector de perspectiva de roles en `dashboard.html`. Al deshabilitarlo, el usuario accede directamente a los indicadores y accesos operativos de su rol sin distracciones ni dudas de contexto.
- **Persistencia y Endpoint REST:** Parámetro `dashboard_role_banner_enabled` administrado vía `POST /api/v1/admin/settings/ui`, respaldado por `SystemSetting` y registrado en `AuditLog`.
### Corregido
- **Motor de Purga y Reinicio de Datos Académicos:**
- **Solución al botón inerte de Purga:** En `/admin/google_sheets_import`, el botón *"Ejecutar Purga de Datos Seleccionados"* llamaba a `openPurgeModal()`, el cual no ejecutaba ninguna acción por ausencia del modal `#purgeConfirmModal` en el DOM. Se incorporó el modal interactivo completo con desglose dinámico de elementos marcados para borrado, advertencia de preservación de usuarios y estado de carga (spinner).
- **Integridad Referencial en Purga Backend:** En `backend/scripts/reset_academic_data.py`, se reordenó la secuencia de borrado para respetar las restricciones de clave foránea (FK): `Reservation` -> `MilestoneGrade` / `AcademicMilestone` -> `CommissionTeacher` -> `StudentEnrollment` -> `Commission` -> `Subject` -> `Career` -> `ClassroomResource` / `Classroom`. Esto previene fallos silenciosos por FK y garantiza que las carreras, asignaturas y comisiones se eliminen de manera limpia cuando son seleccionadas.
- **Preservación de Usuarios y Roles:** La purga garantiza que ningún usuario (`User`), contraseña administrativa ni rol (`Role`) sea alterado o eliminado durante el reseteo académico.
- **Saneamiento de Métricas y Edición de Edificios:**
- Corrección de métricas de edificios en `/buildings/list_buildings`: la capacidad virtual (9999) ya no distorsiona el cómputo de bancos físicos ni los percentiles de ocupación institucional.
- Formulario de edición de sedes (`/buildings/edit/:id`) corregido y persistido adecuadamente.
- **Instalador `install.sh`:**
- Inclusión de banderas `--keep-db` y `--keep-users` para evitar sobreescritura de contraseñas y usuarios en reinstalaciones o actualizaciones.
---
## [3.0.0] - 2026-09-23
### Añadido
- **Fase 5 — Integración Auth, Email & Moodle Backend:**
- **Épica 1: Panel de Configuración Global (Perfil ADMIN):**
- Cifrado simétrico de credenciales y secretos con Fernet (`CryptoService` en `backend/app/services/crypto_service.py`).
- Migración y extensión de `SystemSetting` con columnas `category` e `is_encrypted`, junto con métodos polimórficos `get_decrypted_value()` y `get_masked_value()`.
- Endpoints REST `/api/v1/admin/settings/*` para administración dinámica de parámetros SMTP, toggles de proveedores SSO, Google OAuth y Web Services de Moodle 4.1.
- Nueva vista responsiva `frontend/views/admin/settings/global_config.html` con soporte optimizado para pantallas 720p, 1360x768 y 1080p, y enlace integrado en el menú de navegación lateral.
- **Épica 2: Infraestructura Asíncrona y Caché:**
- Servicio híbrido de caché `CacheService` con integración Redis y fallback en memoria/TTL.
- Modelo `MoodleSyncTask` y procesador asíncrono `MoodleQueueService` con Exponential Backoff y Dead Letter Queue (DLQ).
- Métricas en vivo y reintentos manuales individuales y masivos para tareas en DLQ.
- **Épica 3: Autenticación Híbrida (SSO & Local):**
- Login nativo como fallback con contingencia para cuentas con rol ADMIN.
- Integración de Google OAuth 2.0 basada en la arquitectura de AlumnosLS con filtrado estricto por dominios institucionales (`google_allowed_domains`).
- Autenticación delegada contra Moodle 4.1 vía `/login/token.php`, mapeo inicial de roles (Admin/Docente/Alumno) y gestión de roles 100% desacoplada en Edu-Space.
- **Épica 4: Integración Bidireccional Moodle 4.1:**
- Adaptador `MoodleClient` con Web Services de Moodle 4.1 (`core_user_*`, `enrol_manual_*`, `core_enrol_*`, `core_role_*`).
- Encolado no bloqueante de sincronización al crear o editar usuarios en Edu-Space.
- **Épica 5: Motor de Notificaciones (Email Server):**
- Cliente SMTP dinámico en `EmailService` con desencriptación en caliente de contraseñas.
- Plantillas HTML transaccionales para asignación de comisiones a profesores, citación a exámenes y pruebas en vivo de conectividad.
- **Pruebas y Verificación:**
- Suite de integración automatizada `backend/tests/test_phase6_integration.py` con 7 pruebas aprobadas al 100%. Regresión completa de 30 pruebas en verde en los módulos core.
---
## [2.8.0] - 2026-09-23
### Añadido
- **Fase 4 del Roadmap MVP — UI/UX Avanzada para Bedelía & Modo Impersonación:**
- **Estadio 4.1 — Grilla Semanal Interactiva (Drag-and-Drop) con Detección de Colisiones:**
- Activación de arrastre y edición interactiva en FullCalendar (`editable: true`, `droppable: true`, `eventDurationEditable: true`).
- Panel lateral integrado de comisiones sin aula asignada (`unassigned_commissions`) con arrastre fluido hacia la grilla semanal o diaria.
- Endpoint REST `POST /api/v1/reservations/drag-update` con validación previa de la matriz integral de conflictos (`ReservationService.check_full_conflicts_matrix`): colisión física de aula, colisión de horario docente y aforo de estudiantes.
- Si existe colisión, retorna HTTP 409 (`status: conflict`), revierte automáticamente la posición del evento en la grilla (`info.revert()`) y notifica al usuario con un Toast detallado del motivo del choque.
- Si el espacio está disponible, actualiza o crea la reserva en base de datos, asienta la acción en `AuditLog` y muestra confirmación visual de éxito.
- **Estadio 4.2 — Modo Impersonación del Superadmin:**
- Soporte completo de cabecera `X-Impersonate-User` en el decorador JWT del Backend Flask (`jwt_required`) para administradores.
- Endpoints administrativos `POST /api/v1/admin/impersonate` y `POST /api/v1/admin/stop-impersonating` con registro estricto en `AuditLog` bajo las acciones `IMPERSONATE_START` e `IMPERSONATE_END`.
- Inyección contextual de impersonación en el Frontend BFF (`authMiddleware` y proxy transparente `/api`) mediante cookies seguras `impersonate_user_id` e `impersonate_user_data`.
- Banner de advertencia visual permanente en la barra superior (`base.html`) indicando el usuario y rol impersonado, con botón de salida inmediata hacia la sesión del administrador.
- Botón de acceso directo para impersonar (`bi-incognito`) en cada fila de usuario activo en `/admin/users_list`.
- **Estadio 4.3 — Acople del Optimizador Genético a Datos y Turnos Reales:**
- Algoritmo genético (`ReservationOptimizer`) adaptado a las franjas horarias y turnos institucionales reales de UniCABA (`Mañana` 08:00-12:00, `Tarde` 14:00-18:00, `Vespertino`/`Noche` 18:30-22:30).
- Soporte de guardado masivo en base de datos (`POST /api/genetic/apply-optimization` y `apply_optimized_reservations`) con prevención de solapamientos físicos y conversión estricta de fechas ISO.
- **Suite de Pruebas Unitarias de Fase 4:**
- Archivo `backend/tests/test_phase4_interactive_impersonation.py` con 6 tests unitarios cubriendo los criterios de aceptación: impersonación como docente y estudiante, trazabilidad en auditoría, rechazo de impersonación para usuarios no administradores, finalización de impersonación, drag-update con bloqueo por colisión 409 y drag-update exitoso 200.
- Regresión global de 18 tests (Fases 2, 3 y 4) aprobada al 100% (18/18 OK).
---
## [2.7.0] - 2026-09-23
### Añadido
- **Fase 3 del Roadmap MVP — Gestión de Hitos Evaluativos y Libro de Calificaciones (*Gradebook*):**
- **Estadio 3.1 — Calendario de Evaluación y Notificaciones Preventivas:**
- Modelo relacional `MilestoneGrade` (`milestone_grades`) asociado a estudiantes (`User`) y reservas de tipo hito (`Reservation`).
- Servicio `GradebookService.get_student_upcoming_milestones`: consulta inteligente de exámenes parciales, recuperatorios y entregas de TP de las comisiones en las que el alumno está matriculado.
- Endpoint REST `GET /api/v1/students/my-upcoming-milestones` con cálculo de días restantes (`days_remaining`).
- Cartelera y Widget de Próximos Exámenes en el Dashboard del Alumno (`/dashboard?role=alumno`) con badges dinámicos de cuenta regresiva (*¡Hoy!*, *¡Mañana!*, *Faltan X días*).
- **Estadio 3.2 — Libro de Calificaciones (*Gradebook*) y Cierre de Actas:**
- Extensiones al modelo `Commission`: campos `grades_closed` (booleano), `grades_closed_at` (timestamp), `grades_closed_by` (ID usuario) y `acta_number` (código oficial de acta).
- Servicio académico `GradebookService` (`backend/app/services/gradebook_service.py`):
- `get_commission_gradebook`: matriz bidimensional consolidada de alumnos inscriptos y notas por hito evaluativo.
- `save_single_grade` y `bulk_save_grades`: registro y actualización de calificaciones con validación estricta de escala (1.0 a 10.0), flag de ausente (`is_absent`) y soporte de retroalimentación pedagógica (`feedback`).
- Bloqueo por inmutabilidad: rechazo de modificaciones cuando el acta se encuentra cerrada.
- `close_gradebook`: sellado oficial del acta con generación de identificador inmutable (`ACTA-YYYY-Sem-ID`), registro de auditoría en `AuditLog` y congelamiento de calificaciones.
- `reopen_gradebook`: reapertura excepcional para Bedelía/Admin con justificación administrativa obligatoria y registro en auditoría.
- `get_student_grades`: consolidado para el alumno de todas sus materias cursadas, notas por evaluación, promedio general y condición académica final (*Promocionado*, *Regular*, *Libre*).
- Endpoints REST de Calificaciones:
- `GET /api/v1/commissions/<id>/gradebook`: estructura completa para la matriz del libro de calificaciones.
- `PUT /api/v1/commissions/<id>/gradebook/grades`: guardado masivo o individual de notas vía JSON.
- `POST /api/v1/commissions/<id>/gradebook/close`: cierre formal de acta.
- `POST /api/v1/commissions/<id>/gradebook/reopen`: reapertura justificada de acta.
- `GET /api/v1/students/my-grades`: consulta individual de notas y regularidad del estudiante.
- Interfaz de Usuario y Vistas Frontend BFF:
- Vista `/schedule/gradebook/:id`: matriz interactiva con cálculo de promedio en tiempo real en el DOM, asignación de condición en vivo, autoguardado asíncrono debounced (500ms) con indicador flotante de estado (*Guardando...*, *✓ Todos los cambios guardados*), filtro de búsqueda instantánea por alumno/legajo, modal de cierre formal de acta y modal de reapertura.
- Vista `/schedule/my_grades` (`/mis-materias/mis-notas`): portal del estudiante para consultar calificaciones por hito, promedios finales, condición de cursada y actas emitidas, con soporte de impresión de certificado de cursada.
- Acceso directo integrado al Libro de Calificaciones en la tarjeta de comisión de `/admin/commission_detail`.
- Acceso directo a "Mis Calificaciones" en el launchpad de navegación del alumno en `/dashboard`.
- Script de migración de base de datos SQLite `backend/migrate_phase3_fields.py`.
- **Suite de Pruebas Unitarias de Fase 3:**
- Archivo `backend/tests/test_phase3_gradebook.py` con 6 tests unitarios cubriendo los criterios de aceptación de Fase 3: estructura de matriz, autoguardado de notas, validaciones de rango numérico, cierre formal de acta e inmutabilidad, consulta individual de notas y cuenta regresiva de próximos exámenes. 100% de tests aprobados (12/12 en suite global Fase 2 + Fase 3).
---
## [2.6.1] - 2026-09-23
### Modificado
- **Optimización de Espacio Vertical y Compatibilidad de Pantallas (720p / 1360x768 y 1080p):**
- **Navbar Compacta:** Reducción de altura a 46px eliminando desperdicio de espacio superior para maximizar el área visible de trabajo de Bedelía.
- **Botón de Toggle de Sidebar:** Reingeniería visual del botón de alternancia del menú lateral con diseño circular limpio, micro-animación en hover y centrado perfecto.
- **Soporte Multipantalla:** Media queries CSS especializadas para resoluciones compactas habituales en puestos de trabajo de administración y bedelía argentina (1360x768 / 1366x768) y escalado equilibrado a 1920x1080 Full HD.
- **Densidad de Información:** Espaciados y márgenes optimizados para evitar scrolling innecesario en formularios y catálogos de comisiones y aulas.
---
## [2.6.0] - 2026-09-19
### Añadido
- **Fase 2 del Roadmap MVP — Co-docencia, Matriz de Conflictos y Regla Diaria:**
- **Estadio 2.1 — Modelo y Gestión Integral de Co-docencia:**
- Modelo relacional `CommissionTeacher` (`commission_teachers`) con roles de cátedra (`Titular`, `Adjunto`, `JTP`, `Ayudante`) y flags de docente principal.
- Endpoints REST de cátedra: `POST /api/v1/commissions/<id>/teachers` y `DELETE /api/v1/commissions/<id>/teachers/<user_id>` con control de duplicados y asignación dinámica.
- Integración visual en el detalle de la comisión (`/admin/commission_detail`) con tarjeta interactiva para vincular/desvincular integrantes del equipo docente y badges de rol en la grilla general (`/admin/commissions_list`).
- **Estadio 2.2 — Matriz de Conflictos y Validación Integral:**
- **Conflicto Físico Estricto:** Bloqueo estricto de superposición horaria en la misma aula física (`ReservationRepository.find_overlapping` & `check_physical_conflicts`).
- **Conflicto Docente Inteligente:** Detección de doble reserva simultánea para un mismo profesor (`ReservationRepository.find_teacher_conflicts` & `check_teacher_conflicts`).
- **Excepción Justificada por Co-docencia:** Si un docente asignado presenta solapamiento pero la comisión cuenta con co-docentes registrados en `CommissionTeacher`, el motor de conflictos autoriza la reserva validando la cobertura del equipo de cátedra.
- **Conflicto de Aforo:** Bloqueo estricto cuando la cantidad de asistentes esperados (`expected_attendees`) excede la capacidad física declarada del aula (`classroom.capacity`).
- Endpoint unificado `POST /api/v1/reservations/check-conflicts` para pre-validación asíncrona de conflictos físicos, docentes y de capacidad antes del guardado.
- **Estadio 2.3 — Regla de Restricción Diaria del Alumno y Motor de Excepciones:**
- Servicio académico `EnrollmentService` (`backend/app/services/enrollment_service.py`) para control de cupos, inscripciones activas y análisis de días de cursada.
- **Regla Diaria:** Un alumno no puede matricularse en dos materias regulares programadas para el mismo día de la semana.
- **Excepción Automática por Curso Corto:** Indicador `is_short_course` en modelo `Subject`; si una de las asignaturas es un taller, seminario o curso corto, el sistema autoriza automáticamente la cursada simultánea en el mismo día.
- **Excepción Expresa de Bedelía:** Parámetros `allow_same_day_exception` y `exception_reason` en el modelo `StudentEnrollment`, permitiendo a Bedelía inscribir alumnos con justificación administrativa expresa (cambio de plan, equivalencias, etc.).
- Endpoints de gestión de matrículas por comisión: `GET /api/v1/commissions/<id>/enrollments`, `POST /api/v1/commissions/<id>/enrollments`, `DELETE /api/v1/commissions/<id>/enrollments/<student_id>` y `PUT /api/v1/commissions/<id>/enrollments/<student_id>`.
- Adaptación completa de UI en `/admin/commission_detail` con selector de estudiantes, toggle interactivo de excepción de Bedelía, campo de motivo justificado, y tabla de estudiantes matriculados con badges de excepción.
- **Suite de Pruebas Unitarias e Integrales de Reglas de Negocio:**
- Archivo `backend/tests/test_phase2_rules.py` con 6 tests completos verificando: bloqueo físico, conflicto de aforo, detección docente con bypass de co-docencia, restricción diaria de cursada, excepción por curso corto y excepción por autorización manual de Bedelía. 100% de tests aprobados.
- **Mejoras UI/UX en Dashboard de Bedelía:**
- Paneles interactivos para visualizar **Comisiones Sin Reserva de Aula** y **Aulas Disponibles** con soporte de filtrado temporal (Semana/Mes) directamente consumiendo estadísticas en tiempo real del motor backend.
- **Fase 1: Auditoría y Seguridad Automatizada:**
- **Control de Entorno Dev:** Aislamiento de herramientas de seguridad en `requirements-dev.txt`.
- **Automatización Local e Integración Continua:** Correcciones de evaluación en `security_audit.bat` para Windows y actualización de `.gitea/workflows/security-audit.yaml` para asegurar la correcta ejecución del stack de auditoría (Bandit, pip-audit, njsscan y Schemathesis).
## [2.5.0] - 2026-09-19
### Añadido
- **Auditoría y Seguridad Automatizada Completa (Python & Node.js):**
- **Python SAST (`bandit`):** Escaneo estático del backend Flask de más de 8.400 líneas. Corrección de vulnerabilidad B310 en `sheets_importer.py` mediante validación estricta de esquema HTTP/HTTPS para prevenir ataques de tipo SSRF. Resultado: 0 fallos detectados.
- **Python SCA (`pip-audit`):** Análisis de dependencias con base OSV; detección de 24 CVEs históricas en paquetes desactualizados y actualización completa a versiones seguras (`Flask>=3.1.3`, `Werkzeug>=3.1.8`, `requests>=2.34.2`, `bleach>=6.4.0`, `python-dotenv>=1.2.3`). Resultado: 0 CVEs activas.
- **Python DAST / Fuzzing (`schemathesis`):** Especificación OpenAPI 3.0.3 en `backend/app/routes/api/openapi.py` (`/api/v1/openapi.json`). Ejecución de 114 casos de prueba dinámicos con mutación de datos. Detección y corrección de caída 500 por payload de tipo JSON inválido en `/auth/login`. Resultado: 114/114 pruebas aprobadas con 0 errores de servidor.
- **Node.js SAST (`njsscan`):** Análisis de patrones de código inseguro en el BFF Express (`frontend/src`). Resultado: 0 hallazgos.
- **Node.js SCA (`npm audit`):** Auditoría continua de paquetes npm con 0 vulnerabilidades reportadas.
- **Blindaje Activo (`helmet` & `express-rate-limit`):** Incorporación de cabeceras HTTP defensivas (`HSTS`, `nosniff`, `SAMEORIGIN`, `X-Permitted-Cross-Domain-Policies`) y limitadores de tasa ante fuerza bruta en `/auth/login` (30 req / 15 min) y `/api` (180 req / min).
- **Script Unificado de Auditoría (`security_audit.bat`):** Ejecutable centralizado para correr los 5 controles en un solo comando.
- **Documento Formal de Seguridad (`SECURITY_AUDIT_REPORT.md`):** Reporte ejecutivo de auditoría y controles continuos.
---
## [2.4.0] - 2026-09-19
### Añadido
- **Perfil Extendido de Usuarios y Tipificaciones de Documentos Oficiales (Argentina y Extranjeros):**
- Incorporación en el modelo `User` de los campos: `first_name` (Nombre), `last_name` (Apellido), `email` (Email Institucional de acceso), `personal_email` (Email Personal/Particular de contacto), `phone` (Teléfono con código de área), `address` (Domicilio/Dirección), `document_type` (Tipo de documento) y `document_number` (Número de documento validado).
- Catálogo formal de reglas de negocio en `backend/app/constants/document_types.py` con tipificaciones oficiales de Argentina y extranjeros (DNI, CUIL, Pasaporte Argentino, Pasaporte Extranjero, DNI Extranjero, Cédula de Identidad PFA, Cédula Mercosur, Libreta Cívica y Libreta de Enrolamiento).
- Validaciones de formato, expresiones regulares, y verificación de unicidad de documento por tipo en endpoints `POST /api/v1/users` y `PUT /api/v1/users/<id>`.
- Búsqueda dinámica en `/admin/users_list` habilitada para filtrar por número de documento o email personal/institucional además de nombre.
- Rediseño del formulario `/admin/users/form` estructurado en secciones: Identidad y Documentación, Contacto y Domicilio (con Teléfono, Email Personal y Dirección), y Cuenta y Rol Institucional (con Email Institucional y Clave), permitiendo al Administrador General gestionar la totalidad de los datos.
- Script de migración SQLite `backend/migrate_user_profile_fields.py` para agregar las columnas e inicializar nombres desglosados de los usuarios existentes.
- **Co-Docencia y Cátedras Múltiples en Comisiones:**
- Creación del modelo relacional `CommissionTeacher` (`commission_teachers`) para asociar múltiples docentes a una misma comisión con roles académicos diferenciados (`Titular`, `Adjunto`, `JTP`, `Ayudante`).
- Nuevos endpoints en la API REST: `POST /api/v1/commissions/<id>/teachers` y `DELETE /api/v1/commissions/<id>/teachers/<user_id>`, junto con la serialización completa de cátedras en `GET /api/v1/commissions`.
- Nueva tarjeta interactiva *"Equipo Docente / Cátedra"* en el detalle de la comisión (`/admin/commission_detail?id=...`) permitiendo agregar y desvincular docentes con sus respectivos roles.
- Actualización de la grilla de comisiones (`/admin/commissions_list`) para exhibir a todos los profesores asignados con badges distintivos de rol.
- **Eliminación y Gestión Segura de Asignaturas:**
- Incorporación de botón y modal interactivo de confirmación de eliminación de asignaturas en `/admin/subjects_list`.
- Implementación de regla de negocio de integridad académica: eliminación física si la materia no tiene comisiones asociadas, o desactivación automática si posee historial o cursadas vinculadas.
### Corregido
- **Bug Visual en Modificación de Asignaturas (`/admin/subjects_list`):**
- Solucionado el problema de modales recortados o cubiertos por el fondo oscuro (`modal-backdrop`), originado por la presencia de 206 modales inline dentro de celdas `<td>` en una tabla con `overflow` (`.table-responsive`). Se refactorizó hacia un único modal global `#modalEditSubject` instanciado fuera de la tabla e inicializado dinámicamente con JavaScript.
### 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.
+156
View File
@@ -0,0 +1,156 @@
# CHANGELOG MVP — Admin Edu-Space (UniCABA)
> **Nota de Unificación:** Este archivo documenta la progresión de los hitos y entregables de las Fases del [ROADMAP_MVP.md](./ROADMAP_MVP.md). Para el historial técnico exhaustivo conforme a *Keep a Changelog* y *Semantic Versioning*, consultar el archivo oficial unificado: **[CHANGELOG.md](./CHANGELOG.md)**.
---
## [Fase 5: Integración Auth, Email & Moodle Backend] — v3.0.0 (2026-09-23)
**Estado:** ✅ 100% Completada
### Épica 1: Panel de Configuración Global (Perfil ADMIN)
* **Cifrado Simétrico Fernet:** Creación de `CryptoService` (`backend/app/services/crypto_service.py`) derivando de forma determinística una clave Fernet de 32 bytes a partir de `SECRET_KEY`.
* **Modelo `SystemSetting` Extendido:** Soporte para columnas `category` y flag `is_encrypted`. Almacenamiento seguro de secretos con métodos polimórficos `get_decrypted_value()` y enmascaramiento con `get_masked_value()`.
* **Endpoints Administrativos:**
- `GET /api/v1/admin/settings/all`: Retorna la configuración completa organizada por categorías (`smtp`, `auth_providers`, `google_oauth`, `moodle`) con secretos enmascarados.
- `POST /api/v1/admin/settings/smtp`: Configuración dinámica de Host, Puerto, Usuario, Contraseña cifrada, Protocolo de Seguridad (TLS/SSL/NONE), y Remitente.
- `POST /api/v1/admin/settings/smtp/test`: Handshake SMTP en vivo y envío opcional de correo de prueba con plantilla HTML oficial.
- `POST /api/v1/admin/settings/auth-providers`: Activación y desactivación de proveedores de acceso (`local`, `google`, `moodle`).
- `POST /api/v1/admin/settings/google-oauth`: Guardado de Client ID, Client Secret cifrado, Dominios autorizados y Callback URL.
- `POST /api/v1/admin/settings/moodle`: URL base del servidor, Token Web Services cifrado, timeout y frecuencia de sincronización.
* **Interfaz de Usuario Web:** Nueva pantalla responsiva `frontend/views/admin/settings/global_config.html` con pestañas navegables, validación en vivo, spinners y monitores de estado (100% adaptada para resoluciones desde 720p hasta 1080p).
* **Navegación:** Integración de enlace directo *"Config. Global"* en el menú lateral bajo Administración en `frontend/views/base.html`.
### Épica 2: Infraestructura Asíncrona y Caché
* **Capa de Caché Híbrida:** Creación de `CacheService` (`backend/app/services/cache_service.py`) con soporte nativo de Redis y fallback transparente en memoria con TTL para sesiones de usuario y perfiles de Moodle.
* **Cola Persistente de Sincronización:** Modelo `MoodleSyncTask` (`backend/app/models/sync_task.py`) y servicio `MoodleQueueService` (`backend/app/services/moodle_queue_service.py`) para registrar operaciones de sincronización (`CREATE_USER`, `UPDATE_USER`, `ENROL_USER`, `UNENROL_USER`, `ASSIGN_ROLE`).
* **Tolerancia a Fallos y DLQ:** Política de Exponential Backoff ($2^{\text{intentos}} \times 30$s) ante indisponibilidad de Moodle. Si se superan los intentos máximos, la tarea se traslada automáticamente a la **Dead Letter Queue (DLQ)** (`status = 'FAILED'`).
* **Endpoints de Cola & DLQ:**
- `GET /api/v1/admin/moodle/queue/stats`: Métricas en tiempo real (Pendientes, En Proceso, Reintentando, Completadas, Fallidas DLQ).
- `GET /api/v1/admin/moodle/queue/tasks`: Lista paginada con filtrado por estado.
- `POST /api/v1/admin/moodle/queue/process-now`: Disparo manual inmediato de sincronización.
- `POST /api/v1/admin/moodle/queue/tasks/<id>/retry`: Reintento de tarea individual de DLQ.
- `POST /api/v1/admin/moodle/queue/retry-all`: Reintento masivo de todas las tareas en DLQ.
### Épica 3: Sistema de Autenticación Híbrida (SSO & Local)
* **Login Local Refactorizado:** Endpoint `/api/v1/auth/login` respeta la configuración global. Si `auth_local_enabled` está desactivado, sólo admite acceso de contingencia a usuarios con rol `ADMIN`.
* **Google OAuth 2.0 (Arquitectura AlumnosLS):** Endpoint `POST /api/v1/auth/google` con validación estricta de dominios institucionales (`google_allowed_domains`), aprovisionamiento automático de perfil y emisión de tokens JWT.
* **Moodle Delegated Login:** Endpoint `POST /api/v1/auth/moodle` validando contra `/login/token.php` de Moodle 4.1 (`moodle_mobile_app`), obtención de datos de usuario con Web Services, caché en Redis y emisión de JWT.
* **Mapeo y Desacople de Roles:** Si el usuario es nuevo y posee rol de manager/admin en Moodle, se le asigna rol local `ADMIN`; en caso contrario se asigna `Docente` o `Alumno`. La administración y cambio de roles posterior permanece 100% local e independiente de Moodle.
* **Frontend Login:** Vista `frontend/views/auth/login.html` adaptada para consultar `/api/v1/auth/providers` y renderizar dinámicamente el botón de Google Workspace, el botón desplegable de Moodle y el formulario tradicional.
### Épica 4: Integración Bidireccional Moodle 4.1 (Backend)
* **Cliente REST Moodle 4.1:** `MoodleClient` (`backend/app/services/moodle_client.py`) con métodos para:
- `core_user_create_users`, `core_user_update_users`, `core_user_get_users`.
- `enrol_manual_enrol_users`, `enrol_manual_unenrol_users`, `core_enrol_get_enrolled_users`.
- `core_role_assign_roles`, `core_role_unassign_roles`.
- `test_connection()` contra `core_webservice_get_site_info`.
* **Desacople en CRUD Local:** Creación y edición de usuarios en `backend/app/routes/api/admin.py` encola automáticamente la sincronización sin bloquear las peticiones locales.
### Épica 5: Motor de Notificaciones (Email Server)
* **Cliente SMTP Dinámico:** Creación de `EmailService` (`backend/app/services/email_service.py`) que obtiene credenciales en tiempo de ejecución de la base de datos (con desencriptación de contraseña al vuelo).
* **Plantillas Transaccionales Profesionales:**
- `notify_teacher_assignment`: Notificación a profesores sobre asignación a comisiones y horarios.
- `notify_exam_schedule`: Convocatoria y citación a mesas de examen final.
- `test_smtp_connection`: Verificación de handshake y autenticación SMTP con feedback claro.
### Cobertura de Pruebas
* Suite completa en `backend/tests/test_phase6_integration.py` con 7 pruebas unitarias e integrales que validan cifrado Fernet, SystemSetting, CacheService, MoodleQueueService (Backoff & DLQ), EmailService dinámico, y autenticación híbrida (Google & Moodle). **30/30 pruebas pasando en verde** en la suite global de regresión.
---
## [Fase 4: UI/UX Avanzada para Bedelía & Modo Impersonación] — v2.8.0 (2026-09-23)
**Estado:** ✅ 100% Completada
### Estadio 4.1 — Grilla Semanal Interactiva (Drag-and-Drop) con Detección de Colisiones
* **FullCalendar Interactivo:** Habilitación de `editable: true` y `droppable: true` en el calendario general de Bedelía (`/schedule/calendar`).
* **Sidebar de Comisiones Pendientes:** Panel lateral dinámico con catálogo de comisiones que aún no tienen aula asignada (`unassigned_commissions`), permitiendo arrastrarlas directamente hacia los bloques horarios del calendario.
* **Validación de Choques en Tiempo Real:** Integración con `POST /api/v1/reservations/drag-update`. Al mover o soltar una clase, el backend somete la nueva posición a la matriz integral de conflictos (`ReservationService.check_full_conflicts_matrix`):
- Conflicto físico de aula (doble asignación simultánea).
- Conflicto docente (profesor ocupado en otra aula).
- Conflicto de aforo (capacidad de aula insuficiente).
* **Manejo de Respuestas:** Si hay colisión, devuelve HTTP 409 (`status: conflict`), FullCalendar revierte automáticamente el movimiento (`info.revert()`) y se dispara un Toast de alerta detallando el motivo del bloqueo. Si está libre, persiste la reserva y confirma con Toast de éxito.
### Estadio 4.2 — Modo Impersonación del Superadmin con Trazabilidad
* **Cabecera `X-Impersonate-User`:** Inyección y soporte en `backend/app/utils/jwt_decorators.py` dentro de `jwt_required` para usuarios administradores. Asigna `g.jwt_user` al usuario objetivo preservando `g.real_admin_user` y marcando `g.is_impersonating = True`.
* **Endpoints de Control:** `POST /api/v1/admin/impersonate` y `POST /api/v1/admin/stop-impersonating` en Backend Flask con auditoría inmutable en `AuditLog` (`IMPERSONATE_START` e `IMPERSONATE_END`).
* **Integración en BFF Node.js:** Middleware `authMiddleware.js` y proxy `/api` en `app.js` configurados para propagar cookies seguras `impersonate_user_id` e inyectar `X-Impersonate-User` en todas las peticiones a la API Flask.
* **Banner Visual Institucional:** Banner de advertencia permanente en `base.html` con fondo de alerta ámbar, spinner de estado, identificación del usuario y rol adoptado, y botón de salida inmediata.
* **Acceso de un Clic:** Botón directo de impersonación (`bi-incognito`) en cada fila de usuario activo en `/admin/users_list`.
### Estadio 4.3 — Acople del Optimizador Genético a Datos y Turnos Reales
* **Turnos Académicos Reales:** `ReservationOptimizer._calculate_preferred_time` sincronizado con las franjas horarias de UniCABA (`Mañana`: 08:00 a 12:00, `Tarde`: 14:00 a 18:00, `Vespertino`/`Noche`: 18:30 a 22:30).
* **Persistencia Segura:** Endpoint `POST /api/genetic/apply-optimization` adaptado con parseo riguroso de strings ISO y prevención de colisiones físicas en guardado masivo.
### Cobertura de Pruebas
* Suite completa en `backend/tests/test_phase4_interactive_impersonation.py` validando los 6 criterios de aceptación (impersonación de docente, impersonación de alumno, trazabilidad en auditoría, rechazo a no-admins, bloqueo 409 por colisión en drag-drop y actualización exitosa 200). 18/18 tests aprobados en suite global de Fases 2, 3 y 4.
---
## [Fase 3: Gestión de Hitos Evaluativos y Libro de Calificaciones] — v2.7.0 (2026-09-23)
**Estado:** ✅ 100% Completada
### Estadio 3.1 — Calendario de Evaluación por Comisión y Notificaciones Preventivas
* **Modelo `MilestoneGrade`:** Tabla relacional `milestone_grades` para registrar calificaciones por estudiante y reserva evaluativa, con validación de unicidad (`student_id`, `reservation_id`), notas en escala 1.0 a 10.0, estado de inasistencia (`is_absent`) y devoluciones pedagógicas (`feedback`).
* **Consulta de Hitos del Alumno:** Endpoint `GET /api/v1/students/my-upcoming-milestones` que detecta dinámicamente exámenes parciales, recuperatorios y entregas de TP de las comisiones del estudiante con cálculo automático de días restantes (`days_remaining`).
* **Dashboard del Alumno:** Widget interactivo de próximos exámenes en `/dashboard?role=alumno` con badges visuales de urgencia (*¡Hoy!*, *¡Mañana!*, *Faltan X días*) y acceso directo desde el Launchpad a *"Mis Calificaciones"*.
### Estadio 3.2 — Libro de Calificaciones (*Gradebook*) y Cierre Formal de Actas
* **Extensiones en `Commission`:** Incorporación de columnas `grades_closed` (boolean), `grades_closed_at` (datetime), `grades_closed_by` (foreign key) y `acta_number` (identificador oficial generado).
* **Servicio `GradebookService`:** Motor centralizado para matriz de notas por comisión, guardado asíncrono individual y por lote (`bulk_save_grades`), cálculo dinámico de promedios ponderados y condiciones académicas en tiempo real (*Promocionado* ≥ 7, *Regular* ≥ 4, *Libre* < 4).
* **Cierre Formal de Actas:** Sellado institucional con código único (`ACTA-YYYY-Sem-ID`), auditoría inmutable en `audit_logs` con acción `CLOSE_ACTA` y bloqueo total contra modificaciones posteriores.
* **Reapertura Excepcional:** Endpoint para Bedelía/Administración (`POST /api/v1/commissions/<id>/gradebook/reopen`) con justificación administrativa obligatoria asentada en auditoría.
* **Interfaz Docente & Bedelía (`/schedule/gradebook/:id`):** Matriz interactiva de carga rápida con autoguardado asíncrono debounced (500ms), indicador visual flotante de sincronización, recálculo instantáneo de promedios en el cliente y modales de sellado y reapertura de actas.
* **Portal del Alumno (`/schedule/my_grades` / `/mis-materias/mis-notas`):** Vista consolidada de todas las materias del estudiante, notas de parciales, condición final y número de acta emitida, con soporte de impresión de certificado de regularidad.
* **Acceso Directo en Comisión:** Botón destacado *"Libro de Calificaciones (Actas)"* y badge de acta sellada incorporados en `/admin/commission_detail`.
### Cobertura de Pruebas
* Suite completa en `backend/tests/test_phase3_gradebook.py` validando los 6 criterios de aceptación: estructura de matriz bidimensional, persistencia y autoguardado de notas, validación estricta de notas inválidas (fuera del rango 1-10), cierre formal de acta e inmutabilidad estricta ante intentos de edición, consulta integral del alumno y cuenta regresiva de próximos hitos. 6/6 tests aprobados (12/12 en conjunto con Fase 2).
---
## [Fase 2: Co-docencia, Matriz de Conflictos y Regla Diaria] — v2.6.0 (2026-09-19)
**Estado:** ✅ 100% Completada
### Estadio 2.1 — Modelo y Gestión Integral de Co-docencia
* **Modelo `CommissionTeacher`:** Soporte para múltiples docentes por comisión en base de datos (`commission_teachers`), admitiendo roles académicos diferenciados (`Titular`, `Adjunto`, `JTP`, `Ayudante`) y flag `is_primary`.
* **API REST de Cátedras:** Endpoints `POST /api/v1/commissions/<id>/teachers` y `DELETE /api/v1/commissions/<id>/teachers/<user_id>` con serialización completa de cátedra en comisiones.
* **Interfaz de Gestión:** Tarjeta interactiva *"Equipo Docente / Cátedra"* en `/admin/commission_detail` para incorporar y desvincular profesores con rol dinámico, y visualización de badges en `/admin/commissions_list`.
### Estadio 2.2 — Matriz de Conflictos y Validación Integral
* **Conflicto Físico:** Bloqueo estricto e infranqueable de superposiciones de reservas en la misma aula física (`ReservationRepository.find_overlapping`).
* **Conflicto Docente:** Detección de doble asignación simultánea para un mismo docente en diferentes aulas o formatos.
* **Excepción Justificada por Co-docencia:** Si un docente asignado presenta solapamiento pero la comisión cuenta con co-docentes registrados en `CommissionTeacher`, la matriz de validación autoriza la reserva validando la cobertura del equipo de cátedra.
* **Conflicto de Aforo:** Bloqueo estricto cuando `expected_attendees > classroom.capacity` en espacios físicos.
* **Endpoint de Pre-validación:** `POST /api/v1/reservations/check-conflicts` para validación asíncrona de conflictos físicos, docentes y de aforo antes del commit.
### Estadio 2.3 — Regla de Restricción Diaria del Alumno y Motor de Excepciones
* **Servicio `EnrollmentService`:** Lógica de matriculación académica con análisis de días de cursada por comisión y verificación de cupos.
* **Regla Restrictiva:** Un alumno no puede cursar dos asignaturas regulares el mismo día de la semana.
* **Excepción Automática por Curso Corto:** Atributo `is_short_course` en modelo `Subject`; si una de las materias es un taller o curso corto, el sistema autoriza la cursada simultánea en el mismo día.
* **Excepción Expresa de Bedelía:** Campos `allow_same_day_exception` y `exception_reason` en `StudentEnrollment`. La oficina de Bedelía puede autorizar la matriculación simultánea mediante switch justificado en la interfaz.
* **Endpoints de Matrícula:** `GET/POST /api/v1/commissions/<id>/enrollments`, `DELETE/PUT /api/v1/commissions/<id>/enrollments/<student_id>`.
* **UI en Detalle de Comisión:** Formulario de matriculación con toggle de autorización de Bedelía, motivo de excepción y tabla de inscriptos con badges de estado.
### Cobertura de Pruebas
* Suite completa en `backend/tests/test_phase2_rules.py` validando los 6 criterios de aceptación (conflicto físico, aforo, co-docencia, restricción diaria, bypass de curso corto y bypass de Bedelía). 6/6 tests aprobados con 100% de éxito.
### Complementos y Seguridad Transversal
* **Dashboard de Bedelía:** Integración de paneles en tiempo real para análisis de **Comisiones Sin Reserva** y grilla de **Aulas Disponibles** (Filtros Semana/Mes) conectando frontend UI con el servicio analítico backend.
* **Auditoría Automatizada (Fase 1):** Implantación de un pipeline de seguridad local/Gitea que aísla dependencias de desarrollo (`requirements-dev.txt`) y ejecuta controles SAST, DAST y SCA (Bandit, pip-audit, njsscan y Schemathesis) sin vulnerabilidades detectadas.
* **Optimizador Genético de Reservas:** Reparación de enrutamiento 404, migración a JWT (`@jwt_required`), corrección de UI de métricas dinámicas de algoritmo, y refactorización de layout para superposición de herramientas.
* **UI/UX General:** Ajustes de CSS en modo claro (soporte dinámico de contraste de isologotipo blanco a rosa institucional mediante filtros) y traducciones de menús de usuario en `base.html`.
---
## [Fase 1: Paridad Legacy y Estabilización] — v2.5.0 / v2.4.0 (2026-09-19)
**Estado:** ✅ 100% Completada
* Seguridad automatizada SAST/DAST/SCA (Bandit, pip-audit, schemathesis, njsscan, npm audit).
* Tipificaciones de documentos argentinos y extranjeros, perfiles extendidos de usuarios con email personal e institucional.
* Gestión completa de aulas físicas vs virtuales con percentiles de asignación y métricas de ocupación real.
* Reingeniería del modal de edición y eliminación segura de asignaturas.
---
## [Fase 0: Desacople y Arquitectura Base] — v2.0.0 (2026-09-18)
**Estado:** ✅ 100% Completada
* Desacople arquitectónico completo: Backend Flask RESTful (`/api/v1/`) + Frontend BFF Node.js/Express con Nunjucks.
* Autenticación basada en tokens JWT con sincronización de estado de sesión.
+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`**.
+267 -243
View File
@@ -1,249 +1,273 @@
# 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, seguimiento académico y reservas 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
**Versión Actual:** `3.1.0` (Fases 0 a 5 completadas, Fase 5.1 Bedelía UX & ABMs finalizada)
**Institución:** Universidad de la Ciudad de Buenos Aires (UniCABA)
---
**Note**: This admin panel is designed to work alongside the Spring Boot microservice. Ensure both services are running for complete functionality.
## 🗺️ Estado del Roadmap MVP
```
Fase 0: Desacople y Arquitectura Base [████████████████████] 100% (Completado)
Fase 1: Paridad Legacy y Estabilización [████████████████████] 100% (Completado)
Fase 2: Co-docencia y Matriz Conflictos [████████████████████] 100% (Completado)
Fase 3: Hitos Evaluativos y Calificaciones[████████████████████] 100% (Completado)
Fase 4: UI/UX Drag & Drop e Impersonación[████████████████████] 100% (Completado)
Fase 5: Integración Auth, Email & Moodle [████████████████████] 100% (Completado)
Fase 5.1: Bedelía UX, ABMs y Purga Datos [████████████████████] 100% (Completado)
Fase 6: Despliegue Producción e Infraest.[███████░░░░░░░░░░░░░] 35% (En Curso)
```
---
## 🏛️ 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 implementa un patrón desacoplado en formato monorepo compuesto por la **Core API (Backend)**, la capa de mediación y vistas **BFF (Frontend)**, herramientas unificadas de despliegue, auditoría automatizada y flujos de CI/CD:
```text
/
├── backend/ # Capa Core API (Python 3.10+ / Flask / SQLAlchemy)
│ ├── app/ # Lógica de negocio y arquitectura desacoplada
│ │ ├── models/ # Modelos ORM (User, Role, Classroom, Reservation, etc.)
│ │ ├── routes/api/ # Endpoints REST JSON v1 (/auth, /classrooms, /admin, etc.)
│ │ ├── schemas/ # DTOs y validación de contratos (Pydantic v2)
│ │ ├── services/ # Servicios de dominio (JWT, Email SMTP, Moodle, Queue)
│ │ └── security/ # Pipeline de filtros y validación stateless de JWT
│ ├── tests/ # Suite activa de pruebas unitarias y de integración (pytest)
│ ├── init_db.py # Inicializador idempotente de esquema, roles RBAC y semillas
│ ├── backup_db.sh # Generador automatizado de backups para PostgreSQL
│ ├── classrooms_db.sql # Snapshot integral de base de datos para despliegue
│ ├── wsgi.py # Entrada WSGI para ejecución con Gunicorn
│ └── requirements.txt # Dependencias Python auditadas (0 vulnerabilidades / CVEs)
│
├── frontend/ # Backend-For-Frontend BFF (Node.js 20 LTS / Express)
│ ├── src/ # Capa servidora de mediación web
│ │ ├── app.js # Servidor Express, seguridad Helmet, rate-limiting
│ │ ├── controllers/ # Controladores de orquestación y mediación
│ │ ├── middlewares/ # Sesión por Cookie HttpOnly, JWT injection y RBAC
│ │ ├── routes/ # Enrutamiento web (auth, schedule, classrooms, admin)
│ │ └── services/ # Cliente Axios centralizado hacia Flask API
│ ├── views/ # Plantillas y componentes dinámicos en Nunjucks (.html)
│ │ ├── admin/ # Paneles de gestión institucional y configuración
│ │ ├── classrooms/ # Catálogo de aulas y visualización de sedes
│ │ ├── schedule/ # Cronograma de comisiones, reservas y calendario
│ │ ├── my_subjects/ # Libreta de calificaciones, hitos evaluativos y cursada
│ │ └── partials/ # Componentes reutilizables, modales y navegación
│ ├── public/ # Assets estáticos (CSS responsive 720p/1080p, JS cliente)
│ └── package.json # Dependencias y manifiesto de Node.js
│
├── install.sh # Instalador nativo unificado para Linux / Proxmox LXC
├── security_audit.bat # Suite automatizada de auditoría (SAST, SCA, DAST)
├── .gitea/workflows/ # Pipelines de integración continua (CI/CD en Gitea)
├── docs/ # Especificaciones técnicas, auditorías y changelogs
├── ROADMAP_MVP.md # Plan de evolución, estadios y fases del proyecto
├── SECURITY_AUDIT_REPORT.md # Informe formal de auditoría y hardening
└── README.md # Documentación general y guía operativa
```
### 🐍 Backend Core API (Python + Flask)
- Funciona exclusivamente como una **REST API** que expone endpoints bajo `/api/v1/` retornando **JSON**.
- Encargado de la persistencia de datos (SQLAlchemy / PostgreSQL o SQLite), lógica de negocio, servicio de tokens JWT (Access & Refresh), matriz RBAC, y algoritmos de optimización heurística (Módulo Predictivo).
- **Prohibido** retornar HTML o utilizar Jinja2/`render_template` en este entorno.
### 🟢 Frontend BFF (Node.js + Express)
- Aplicación Backend-For-Frontend 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).
- Blindado con **Helmet** (cabeceras de seguridad HTTP) y **express-rate-limit** para mitigar ataques de fuerza bruta.
- Interfaz adaptativa y responsive diseñada para pantallas estándar argentinas (720p, 1366x768 y 1080p) con gráficos dinámicos (**Chart.js**).
---
## 🎯 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.
---
## 🏛️ Circuito Operativo de Bedelía (UX & Flujo Simplificado)
Para garantizar una experiencia de usuario rápida y sin fricción para el personal de Bedelía, el circuito de gestión áulica se divide en 3 etapas directas:
```
[ 1. Creación / Edición de Aula ] ──► [ 2. Asignación de Comisión al Aula ] ──► [ 3. Cartelera y Grilla ]
- Sede Central o Campus Virtual - Desde Comisiones (1 clic) - Detección de colisiones
- Piso y Aforo real - O desde Ficha de Aula - Drag & Drop semanal
- Tipo (Teórica / Laboratorio) - Autocompletado de cupo y Meet - Cartelera del día en vivo
```
### 1. Creación y Mantenimiento de Aulas (`/classrooms/list_classrooms`)
* Acceso directo a **"Nueva Aula"**: se selecciona la sede (Edificio Central o Campus Virtual), piso (`PB`, `1`, `2`, `3`, `4`) y tipo de espacio (Teórica, Laboratorio de Informática, etc.).
* Las aulas virtuales se configuran con capacidad infinita ($\infty$) y no distorsionan la capacidad de bancos físicos del edificio central.
* En el catálogo de aulas, cada tarjeta muestra el aforo, piso, equipamiento y el botón de acción rápida **"Asignar Comisión"**, que abre el formulario de reserva con el aula ya preseleccionada.
### 2. Asignación de Comisión al Aula (`/schedule/add_reservation`)
La asignación puede realizarse desde dos puntos de entrada según la necesidad operativa:
* **Desde el Catálogo de Comisiones (`/admin/commissions_list`):**
- Cada fila cuenta con el botón de acceso rápido **"Asignar Aula / Horario"** (`bi-calendar-plus`).
- Al hacer clic, redirige a `/schedule/add_reservation?commission_id={id}` precargando de forma automática:
- Nombre de la asignatura y código de comisión.
- Cupo esperado de alumnos (`expected_attendees = max_students`).
- Enlace de videollamada preexistente (Google Meet / Teams).
* **Desde el Catálogo de Aulas (`/classrooms/list_classrooms`):**
- Al pulsar **"Asignar Comisión"**, redirige a `/schedule/add_reservation?classroom_id={id}`.
- El selector en cascada (**1. Edificio ➔ 2. Piso ➔ 3. Aula**) se auto-posiciona en la sede, planta y aula elegidas.
* **Validación en Vivo:** El sistema alerta inmediatamente si la cantidad de alumnos de la comisión excede la capacidad física del aula seleccionada.
### 3. Visualización y Control de Ocupación
* **Cartelera del Día (`/schedule/today_schedule`):** Bloques horarios de 07:00 a 21:00 hs con filtros por piso, turno y switch para ocultar aulas virtuales.
* **Grilla Semanal Interactiva (`/schedule/calendar`):** Arrastre de comisiones sin aula asignada desde el panel lateral con detección de colisiones físicas, solapamientos docentes y aforo en tiempo real (HTTP 409).
---
## 🧹 Módulo de Purga y Sincronización Académica
* **Purga Segura y Selectiva (`/admin/google_sheets_import`):**
- Permite restablecer limpiamente reservas, comisiones, asignaturas, carreras y/o aulas para comenzar nuevos ciclos lectivos o pruebas de carga.
- **Confirmación Interactiva:** Cuenta con un modal de confirmación (`#purgeConfirmModal`) que lista las entidades específicas a eliminar y previene clics accidentales.
- **Integridad Referencial Estricta:** Ejecuta la eliminación en orden topológico inverso de claves foráneas (`Reservation` ➔ `MilestoneGrade` ➔ `CommissionTeacher` ➔ `StudentEnrollment` ➔ `Commission` ➔ `Subject` ➔ `Career` ➔ `Classroom`), garantizando que la base de datos no rechace el borrado por restricciones relacionales.
- **Preservación Total de Identidades:** Los usuarios (`User`), contraseñas y roles (`Role`) están estrictamente blindados y nunca son afectados por la purga de datos académicos.
* **Importación desde Google Sheets:** Carga y sincronización dinámica de todas las materias y franjas horarias directamente desde la hoja institucional publicada.
---
## ⚡ Capacidades Avanzadas de Gestión (Fases 2, 3 y 4 del MVP)
### 1. Grilla Semanal Interactiva (Drag & Drop) y Validación de Colisiones en Tiempo Real
- **FullCalendar Interactivo:** Arrastre y edición de horarios directo en el calendario general de Bedelía (`/schedule/calendar`).
- **Sidebar de Comisiones por Asignar:** Panel lateral colapsable con las comisiones sin aula, con arrastre fluido hacia la grilla semanal o diaria.
- **Detección Automática de Choques (HTTP 409):** Validación previa ante cada movimiento (`POST /api/v1/reservations/drag-update`) verificando colisiones físicas de aula, superposición docente y límite de aforo. Si hay conflicto, el evento se revierte automáticamente (`info.revert()`) y se notifica al usuario con un Toast explicativo.
### 2. Modo Impersonación del Superadmin ("Login as")
- Permite al Administrador navegar la plataforma adoptando exactamente la perspectiva y permisos de cualquier docente o estudiante.
- Activación con un clic (`bi-incognito`) desde el catálogo de usuarios (`/admin/users_list`).
- Banner superior permanente de advertencia visual en color ámbar con identificación del usuario adoptado y botón de salida inmediata.
- Trazabilidad y auditoría obligatoria inmutable en `audit_logs` (`IMPERSONATE_START` e `IMPERSONATE_END`).
### 3. Libro de Calificaciones (*Gradebook*) & Cierre Formal de Actas
- Matriz bidimensional interactiva de calificaciones por comisión (`/schedule/gradebook/:id`) con autoguardado asíncrono debounced (500 ms).
- Cálculo en vivo de promedios ponderados y condiciones académicas (*Promocionado*, *Regular*, *Libre*).
- Cierre formal de actas con generación de código inmutable (`ACTA-YYYY-Sem-ID`), auditoría en `audit_logs` y congelamiento de modificaciones.
- Soporte para reapertura excepcional justificada por Bedelía.
- Portal del Estudiante (`/schedule/my_grades` / `/mis-materias/mis-notas`) para consulta de calificaciones y comprobantes.
### 4. Co-docencia, Matriz de Conflictos y Regla Diaria
- Soporte de cátedras con múltiples docentes por comisión (`CommissionTeacher`).
- Excepción por co-docencia: autoriza superposiciones de horario docente si la cátedra cuenta con un co-docente registrado disponible.
- Regla restrictiva de cursada diaria: un alumno no puede cursar dos asignaturas regulares el mismo día, con excepciones automáticas para talleres/cursos cortos (`is_short_course`) o autorizaciones especiales emitidas por Bedelía.
---
## 📅 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+
- SQLite / PostgreSQL
### 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)*
---
## 📦 Despliegue Automatizado en Producción (Linux / Proxmox LXC)
Para servidores Debian 12 / Ubuntu en Proxmox LXC o VPS, el repositorio incluye el instalador unificado [install.sh](install.sh) en la raíz:
# Despliegue interactivo (pregunta si mantener BD, restaurar snapshot o crear base limpia):
sudo bash install.sh
# Despliegue desatendido manteniendo la base de datos existente:
sudo bash install.sh --keep-db
# Despliegue desatendido preservando la base de datos y los usuarios/credenciales configurados:
sudo bash install.sh --keep-db --keep-users
# Despliegue desatendido con base de datos limpia sin carreras, materias, ciclos lectivos, aulas ni edificios demo:
sudo bash install.sh --empty-academic
# Despliegue desatendido recreando la base de datos desde cero con snapshot de muestra (DROP y restore classrooms_db.sql):
sudo bash install.sh --fresh-db
```
También es posible inicializar directamente la base de datos limpia desde Python en cualquier entorno:
```bash
# Inicialización estándar con datos de muestra:
python backend/init_db.py
# Inicialización limpia (sin carreras, materias, ciclos lectivos, aulas, edificios ni reservas):
python backend/init_db.py --empty-academic
```
El script configura automáticamente:
1. Entorno Python 3.10+ y virtualenv en `backend/venv` con Gunicorn y servicio `admin-edu-space-backend.service` (puerto 5000).
2. Entorno Node.js 20 LTS en `frontend/` con servicio `admin-edu-space-frontend.service` (puerto 3000).
3. Motor PostgreSQL local con configuración de usuario y opciones seguras para preservar o restaurar la base `classrooms_db`.
4. Servicio maestro unificado `admin-edu-space.service` gestionable vía `systemctl {status|restart|stop} admin-edu-space`.
---
## 🧪 Pruebas Automatizadas
Para ejecutar la suite completa de 47 pruebas unitarias y de integración (JWT, ABMs, Reglas, Optimizador, Calificaciones y Moodle):
```bash
cd backend
$env:PYTHONPATH="backend"
.\venv\Scripts\python.exe -m pytest tests -v
```
---
## 📚 Documentación Técnica & Changelogs
* [Roadmap MVP: Fases y Estadios del Proyecto](ROADMAP_MVP.md)
* [Changelog Oficial del Proyecto (v2.8.0)](CHANGELOG.md)
* [Changelog MVP Consolidado](CHANGELOG_MVP.md)
* [Estado de la Migración y Arquitectura Objetivo UniCABA](ESTADO_MIGRACION_Y_ARQUITECTURA_OBJETIVO.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)
+188
View File
@@ -0,0 +1,188 @@
# 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:** 3.1.0
**Fecha de Actualización:** 23 de Septiembre de 2026
**Rama Base:** `mvp`
---
## 🗺️ 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 [████████████████████] 100% (Completado)
Fase 3: Hitos Evaluativos y Calificaciones[████████████████████] 100% (Completado)
Fase 4: UI/UX Drag & Drop e Impersonación[████████████████████] 100% (Completado)
Fase 5: Integración Auth, Email & Moodle [████████████████████] 100% (Completado)
Fase 5.1: Bedelía UX, ABMs y Purga Datos [████████████████████] 100% (Completado)
Fase 6: Despliegue Producción e Infraest.[███████░░░░░░░░░░░░░] 35% (En Curso)
```
---
## 🚀 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 ✅ *(100% Completado)*
* **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:**
- [x] Tabla relacional `commission_teachers` para soportar múltiples docentes por comisión (titulares, adjuntos, ayudantes).
- [x] Endpoints `/api/v1/commissions/<id>/teachers` para asociar y desvincular docentes de cátedra.
- [x] 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:**
- [x] Validación estricta de conflicto físico: bloqueo de doble asignación de aula en mismo día y horario.
- [x] 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.
- [x] 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:**
- [x] Validación al matricular: un alumno no puede cursar dos asignaturas regulares en el mismo día calendario.
- [x] Excepción parametrizable: autorización automática si al menos una de las materias es un "curso corto" (`is_short_course`) o si existe autorización manual de Bedelía (`allow_same_day_exception`).
---
### Fase 3: Gestión de Hitos Evaluativos y Libro de Calificaciones 📅 *(Completada)*
* **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:**
- [x] Programación de parciales, recuperatorios, entregas de trabajos prácticos y exámenes finales.
- [x] Notificaciones preventivas en el dashboard del alumno con cuenta regresiva para exámenes.
2. **Estadio 3.2 — Libro de Calificaciones (*Gradebook*):**
- [x] Matriz de carga rápida de notas para docentes con autoguardado asíncrono.
- [x] Consulta individualizada de calificaciones y retroalimentaciones pedagógicas para alumnos.
- [x] Cierre de actas de regularidad y promoción para Bedelía.
---
### Fase 4: UI/UX Avanzada para Bedelía & Modo Impersonación 🖥️ ✅ *(100% Completado)*
* **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):**
- [x] Interfaz tipo TimeGrid interactiva con arrastre de comisiones para asignación rápida de aulas y turnos (`FullCalendar` `editable: true` + `droppable: true`).
- [x] Panel lateral de comisiones pendientes de aula (`unassigned_commissions`) con arrastre directo hacia la grilla.
- [x] Detección visual en tiempo real de aulas ocupadas, colisiones horarias, aforo insuficiente o choques de docentes con reversión automática y mensajes de alerta descriptivos vía `POST /api/v1/reservations/drag-update`.
2. **Estadio 4.2 — Modo Impersonación del Superadmin:**
- [x] Funcionalidad de *login as* con soporte para cabecera `X-Impersonate-User` en Backend Flask (`jwt_required`) y Frontend BFF (`authMiddleware`).
- [x] Botón directo de impersonación en la lista administrativa de usuarios (`admin/users/list.html`).
- [x] Banner de advertencia visual permanente en la barra superior con indicador de usuario impersonado y botón de salida inmediata hacia la sesión administrativa.
- [x] Trazabilidad obligatoria y estricta en el registro de auditoría (`audit_logs`) con eventos `IMPERSONATE_START` e `IMPERSONATE_END`.
3. **Estadio 4.3 — Integración del Optimizador Genético:**
- [x] Acople del motor heurístico de algoritmos genéticos a la base de datos real con cálculo de turnos reales (Mañana, Tarde, Vespertino).
- [x] Prevención de choques en guardado masivo con persistencia directa en base de datos (`POST /api/genetic/apply-optimization`).
---
### Fase 5: Integración Auth, Email & Moodle Backend 🛡️ ✉️ 🎓 ✅ *(100% Completado)*
* **Objetivo:** Centralizar la gestión de credenciales, autenticación híbrida, motor transaccional de correo y sincronización bidireccional asíncrona con Moodle 4.1 con tolerancia total a fallos.
* **Épicas Entregadas:**
1. **Épica 1 — Panel de Configuración Global (Perfil ADMIN):**
- [x] Módulo SMTP: UI/API dinámica con host, puerto, usuario, seguridad (TLS/SSL/NONE) y contraseña cifrada con Fernet (`/admin/settings`).
- [x] Módulo SSO: Toggles para habilitar/deshabilitar Login Local, Google OAuth 2.0 y Moodle SSO en base de datos.
- [x] Credenciales OAuth & Moodle: Client ID/Secret de Google y URL de Servidor / Token Web Services de Moodle 4.1.
2. **Épica 2 — Infraestructura Asíncrona y Caché:**
- [x] Capa de Caché Híbrida: `CacheService` con integración Redis y fallback en memoria/TTL.
- [x] Cola Persistente `MoodleSyncTask` con reintentos exponenciales y Dead Letter Queue (DLQ).
- [x] Panel de monitorización de cola con métricas en tiempo real, botón de "Forzar Sincronización Inmediata" y reintento masivo de tareas fallidas.
3. **Épica 3 — Autenticación Híbrida (SSO & Local Tolerante a Fallos):**
- [x] Login Local: Fallback con contraseña nativa y contingencia para ADMIN cuando está desactivado para usuarios generales.
- [x] Google OAuth 2.0: Restricción arquitectónica de dominios (`@unicaba.edu.ar`, `@lasalle.edu.ar`) inspirada en AlumnosLS.
- [x] Moodle Delegated Login: Autenticación contra `/login/token.php` de Moodle 4.1 con mapeo inicial de roles (Admin/Docente/Alumno) y desacople local para cambios posteriores por Bedelía/Admin.
4. **Épica 4 — Adaptadores Moodle 4.1 (Bidireccional):**
- [x] Cliente REST Moodle 4.1 (`moodle_client`) con soporte para `core_user_create_users`, `core_user_update_users`, `core_user_get_users`, `enrol_manual_enrol_users`, `enrol_manual_unenrol_users`, `core_enrol_get_enrolled_users`, `core_role_assign_roles`, `core_role_unassign_roles`.
- [x] Encolado automático y no bloqueante al crear o editar usuarios en Edu-Space.
5. **Épica 5 — Motor de Notificaciones (Email Dinámico):**
- [x] Transporte SMTP dinámico (`email_service`) instanciado en tiempo de ejecución leyendo credenciales descifradas de la BD.
- [x] Notificaciones transaccionales automáticas para profesores (asignación de comisiones, cronogramas de exámenes y alertas de sistema).
---
### Fase 5.1: Bedelía UX, Cobertura Integral de ABMs y Purga Segura de Datos 🏛️ ⚡ ✅ *(100% Completado)*
* **Objetivo:** Optimizar de punta a punta la experiencia de usuario de Bedelía, dotar de ABMs completos a todas las entidades de Espacios, Académica y Reservas, y asegurar la purga de datos sin comprometer identidades ni claves foráneas.
* **Entregables:**
1. **Circuito Ágil de Bedelía:**
- [x] Flujo de asignación ágil: de lista de comisiones (`/admin/commissions_list`) a reserva de aula (`/schedule/add_reservation?commission_id=...`) con 1 clic.
- [x] Sincronización en cascada de selectores (Edificio -> Piso -> Aula) con detección de modalidad física/virtual y aforo.
- [x] Conexión de reservas con comisiones activas en vivo vía API REST desacoplada (fin de mocks estáticos).
- [x] Creación de aulas con vinculación consistente de sede y tipología.
2. **Cobertura ABM 100% (Espacios, Académica y Reservas):**
- [x] **Ciclos Lectivos (`AcademicTerm`):** ABM completo (Crear, Editar, Activar como Ciclo Actual, Toggle Activo/Inactivo rápido y Borrado protegido con soft-deactivate ante comisiones asociadas). Endpoints `/api/v1/admin/academic-terms` y vistas en `/admin/academic_terms`.
- [x] **Aulas y Espacios Físicos/Virtuales (`Classroom`):** ABM completo (Alta, Edición de aforo y piso, Toggle rápido activo/inactivo vía `/api/v1/classrooms/<id>/toggle`, y Eliminación con validación de reservas previas). Botones y modales en lista y detalle.
- [x] **Sedes y Edificios (`Building`):** ABM completo (Crear, Editar, Toggle y Eliminar sedes físicas e institucionales).
- [x] **Carreras (`Career`):** ABM completo (`/admin/careers_list`) con protección ante asignaturas existentes (soft-deactivation).
- [x] **Asignaturas (`Subject`):** ABM completo (`/admin/subjects_list`) con vinculación por carrera y mitigación de desvinculación forzada.
- [x] **Comisiones (`Commission`):** ABM completo (`/admin/commissions_list`) con asignación directa de profesores, turnos y cupos.
- [x] **Reservas (`Reservation`):** ABM completo (Creación, Edición, Confirmación, Cancelación lógica y Eliminación definitiva / Hard Delete con `/api/v1/reservations/<id>?hard=true`).
3. **Purga Segura de Datos Académicos:**
- [x] Modal de confirmación interactivo en frontend (`#purgeConfirmModal`) con feedback visual y prevención de borrado accidental.
- [x] Secuencia de purga backend en estricto orden de integridad referencial (respetando Foreign Keys).
- [x] Preservación garantizada de usuarios y roles RBAC ante reseteos de datos de cursada.
4. **Configuración y Personalización UI:**
- [x] Panel de configuración global (`/admin/settings`) con opción para habilitar o deshabilitar el saludo superior y banner de rol en el dashboard.
5. **Inicialización y Despliegue con Base Limpia:**
- [x] Soporte `--empty-academic` / `--no-demo` en `install.sh` e `init_db.py` para inicializar el sistema con roles y usuarios intactos pero con tablas académicas y de espacios totalmente vacías para configuración desde cero.
---
### Fase 6: Producción Institucional, CI/CD y Despliegue 🏢 *(En curso)*
* **Objetivo:** Puesta en marcha definitiva en la infraestructura de servidores de UniCABA.
* **Estadios:**
1. **Estadio 6.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 6.2 — Sincronización Continua Moodle:**
- [x] Sincronización automática de aulas virtuales y usuarios con Moodle 4.1 institucional (`10.0.0.207`).
- [ ] Automatización de workers persistentes (systemd / supervisor) para el procesamiento continuo de la cola.
3. **Estadio 6.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.
+112
View File
@@ -0,0 +1,112 @@
# Informe de Auditoría y Seguridad Automatizada (Python & Node.js)
**Proyecto:** Edu-Space Admin Architecture
**Fecha:** Septiembre 2026 (Actualización Post Fase 5)
**Entorno:** Backend Flask (REST API) + Node.js BFF (Express) + SQLite / PostgreSQL
**Estado General:** Aprobado ✅ (`[PASS]` en los 5 controles automatizados, 0 vulnerabilidades conocidas, 0 fallos críticos SAST/DAST)
---
## 1. Resumen Ejecutivo
Se ejecutó y validó la suite completa del **Plan de Auditoría y Seguridad Automatizada** que cubre el ciclo de vida completo de las dos capas de la plataforma:
- **Capa Core API (Python / Flask)**: Análisis Estático (SAST), Análisis de Dependencias de Terceros (SCA), Pruebas Dinámicas / Fuzzing contra especificación OpenAPI 3.0.3 (DAST).
- **Capa BFF / Frontend Server (Node.js / Express)**: Análisis Estático (SAST), Análisis de Dependencias (SCA) y Blindaje Activo con cabeceras HTTP (`helmet`) y limitador de tasa (`express-rate-limit`).
Todos los controles fueron unificados en el script ejecutable [security_audit.bat](file:///c:/Users/Soporte%20IT/workspace/admin-edu-space/security_audit.bat), compatible con ejecuciones interactivas en Windows y modos desatendidos (`--no-pause`) para integración continua.
---
## 2. Capa Backend Python (Flask API)
### 2.1. Análisis Estático de Código Fuente (SAST) - `Bandit`
- **Herramienta:** Bandit v1.8.3 (`backend/venv/Scripts/bandit.exe`)
- **Comando:** `bandit -r backend/app -ll -ii`
- **Líneas Escaneadas:** 11.321 líneas de código Python analizadas en toda la aplicación.
- **Hallazgos y Remediaciones:**
1. *CWE-22 / B310 en `sheets_importer.py`:* Uso de `urllib.request.urlopen` sin validación estricta de esquema. Remediado validando `url.startswith('https://')` o `http://` y documentando la verificación con `# nosec B310`.
2. *CWE-829 / B113 en `genetic_algorithm.py`:* Falso positivo producido por el parámetro de dominio `requests` (lista de solicitudes de aula `ReservationRequest`) que colisionaba con el analizador de llamadas HTTP sin timeout. Remediado renombrando el parámetro a `reservation_requests` con tipado estricto.
- **Resultado Actual:** **0 problemas identificados** (Medium = 0, High = 0).
### 2.2. Auditoría de Dependencias de Terceros (SCA) - `Pip-Audit`
- **Herramienta:** Pip-Audit v2.10.1 (`backend/venv/Scripts/pip-audit.exe`)
- **Comando:** `pip-audit -s osv --progress-spinner off -r backend/requirements.txt` (con fallback de resiliencia automática a servicio PyPI ante indisponibilidad de red).
- **Librerías Core Aseguradas:**
- `Flask>=3.1.3`
- `Werkzeug>=3.1.6` (v3.1.8 instalada)
- `requests>=2.32.4` (v2.34.2 instalada)
- `bleach>=6.4.0`
- `python-dotenv>=1.2.2` (v1.2.3 instalada)
- `cryptography>=44.0.0`
- **Resultado Actual:** **No known vulnerabilities found** (0 CVEs pendientes).
### 2.3. Pruebas Dinámicas y Fuzzing de API REST (DAST) - `Schemathesis`
- **Herramienta:** Schemathesis v4.27.4 (`backend/venv/Scripts/st.exe`)
- **Especificación OpenAPI:** OpenAPI 3.0.3 en `/api/v1/openapi.json`.
- **Comando:** `st run http://127.0.0.1:5000/api/v1/openapi.json --checks not_a_server_error --max-examples=10 --no-color`
- **Fuzzing & Robustez de Endpoints:**
- `POST /api/v1/auth/login`: Blindado contra payloads no estructurados (JSON arrays anómalos o tipos no diccionario devuelven `400 Bad Request` antes de invocar DTOs). Manejo seguro de base de datos garantizando códigos `401 Unauthorized` ante credenciales erróneas o usuarios no existentes sin generar fallos 500 no capturados.
- `GET /api/v1/admin/subjects`, `GET /api/v1/admin/users`, `GET /api/v1/auth/me`, `GET /api/v1/classrooms`: Rechazan peticiones sin token JWT con código `401 Unauthorized`.
- **Resultado Actual:** **114 casos de prueba generados y superados con éxito**, **0 errores 500 del servidor** (100% PASS).
---
## 3. Capa Backend Node.js (Express BFF)
### 3.1. Análisis Estático de Código Fuente (SAST) - `njsscan`
- **Herramienta:** NodeJsScan v1.0.0 (`backend/venv/Scripts/njsscan.exe`)
- **Comando:** `njsscan frontend/src`
- **Reglas Evaluadas:** Inyecciones de código, llamadas a `eval()`, exposición de credenciales hardcodeadas, omisión de headers de seguridad, CORS inseguro.
- **Resultado:** **No issues found** (0 problemas de seguridad).
### 3.2. Blindaje de Middleware (`helmet` & `express-rate-limit`)
- **Paquetes:** `helmet`, `express-rate-limit` en `frontend/src/app.js`.
- **Protecciones Activas:**
- `app.disable('x-powered-by')`: Oculta el motor Express.
- `helmet`:
- `X-Content-Type-Options: nosniff`
- `X-Frame-Options: SAMEORIGIN` (prevención de Clickjacking)
- `Strict-Transport-Security: max-age=31536000; includeSubDomains` (HSTS)
- `Cross-Origin-Opener-Policy: same-origin`
- `Cross-Origin-Resource-Policy: same-origin`
- `express-rate-limit`:
- `/auth/login`: Límite estricto de 30 peticiones por ventana de 15 minutos por IP (mitigación de ataques de fuerza bruta).
- Endpoints `/api`: Límite de 180 peticiones por minuto por IP (mitigación de scraping y saturación).
### 3.3. Auditoría de Dependencias (SCA) - `npm audit`
- **Herramienta:** `npm audit` nativo en `frontend/`
- **Comando:** `cmd.exe /c npm audit`
- **Resultado:** **found 0 vulnerabilities** en 108 dependencias evaluadas.
---
## 4. Automatización de la Suite de Seguridad
El archivo [security_audit.bat](file:///c:/Users/Soporte%20IT/workspace/admin-edu-space/security_audit.bat) provee la suite de ejecución automatizada:
```cmd
:: Ejecución interactiva con consola Windows:
security_audit.bat
:: Ejecución desatendida / CI:
security_audit.bat --no-pause
```
### Características de la suite adaptada:
1. **Página de códigos UTF-8 (`chcp 65001`) y variables de entorno Python (`PYTHONUTF8=1`):** Evita fallos de codificación `charmap` en terminales Windows tradicionales.
2. **Fallback resiliente en `pip-audit`:** Conmutación automática a base PyPI en caso de indisponibilidad temporal de la API OSV.
3. **Comprobación de conectividad al backend:** Antes de lanzar Schemathesis DAST, verifica la respuesta HTTP de `/api/v1/openapi.json`. Si el backend está inactivo, emite una advertencia sin abortar los controles estáticos previos.
4. **Bandera `--no-pause`:** Permite integración fluida en pipelines de CI/CD conservando la pausa para desarrolladores que ejecuten por doble click.
---
## 5. Matriz de Resultados Finales
| Control | Capa | Herramienta | Métricas Escaneadas | Estado Final |
|---|---|---|---|:---:|
| **SAST (Python)** | Flask API | `bandit` | 11.321 líneas de código | **0 Issues (PASS)** ✅ |
| **SCA (Python)** | Flask API | `pip-audit` | 42 librerías en requirements.txt | **0 CVEs (PASS)** ✅ |
| **SAST (Node.js)**| Express BFF| `njsscan` | Archivos en `frontend/src` | **0 Issues (PASS)** ✅ |
| **SCA (Node.js)** | Express BFF| `npm audit` | 108 paquetes npm | **0 Vulnerabilidades (PASS)** ✅ |
| **DAST (Python)** | Flask API | `schemathesis` | 114 casos de prueba generados | **114/114 Pasados - 0 Error 500 (PASS)** ✅ |
| **Protección HTTP** | Express BFF| `helmet` + `rate-limit` | HSTS, nosniff, limiters activos | **Activo y Blindado** ✅ |
**Dictamen:** **SUITE DE SEGURIDAD APROBADA AL 100% [PASS]**.
-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'
]
-99
View File
@@ -1,99 +0,0 @@
from app import db
from datetime import datetime
class Subject(db.Model):
__tablename__ = 'subjects'
id = db.Column(db.Integer, primary_key=True)
code = db.Column(db.String(20), unique=True, nullable=False, index=True)
name = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text)
department = db.Column(db.String(100), nullable=False)
credits = db.Column(db.Integer, nullable=False)
# duration_in_minutes = db.Column(db.Integer, nullable=False)
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)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
commissions = db.relationship('Commission', backref='subject', lazy=True, cascade='all, delete-orphan')
def to_dict(self):
"""Convert subject to dictionary"""
return {
'id': self.id,
'code': self.code,
'name': self.name,
'description': self.description,
'department': self.department,
'credits': self.credits,
# 'duration_minutes': self.duration_in_minutes,
'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None
}
def __repr__(self):
return f'<Subject {self.code}>'
class Commission(db.Model):
__tablename__ = 'commissions'
id = db.Column(db.Integer, primary_key=True)
subject_id = db.Column(db.Integer, db.ForeignKey('subjects.id'), nullable=False)
code = db.Column(db.String(50), nullable=False)
semester = db.Column(db.String(20), nullable=False)
year = db.Column(db.Integer, nullable=False)
teacher_id = db.Column(db.Integer, db.ForeignKey('users.id'))
max_students = db.Column(db.Integer, nullable=False)
current_students = db.Column(db.Integer, default=0)
schedule = db.Column(db.String(255))
active = db.Column(db.Boolean, default=True) # Changed from is_active to active
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Virtual property for compatibility
@property
def is_active(self):
return self.active
@is_active.setter
def is_active(self, value):
self.active = value
# Relationships
pass # Reservation already defines the relationship back to commission
teacher = db.relationship('User', backref='teaching_commissions', foreign_keys=[teacher_id])
# Virtual field for teacher_name
@property
def teacher_name(self):
return f"{self.teacher.first_name} {self.teacher.last_name}" if self.teacher else "TBD"
# Unique constraint
__table_args__ = (db.UniqueConstraint('subject_id', 'code', 'semester', 'year'),)
def to_dict(self):
"""Convert commission to dictionary"""
return {
'id': self.id,
'subject_id': self.subject_id,
'code': self.code,
'semester': self.semester,
'year': self.year,
'teacher_name': self.teacher_name,
'max_students': self.max_students,
'current_students': self.current_students,
'schedule': self.schedule,
'active': self.active,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'subject': self.subject.to_dict() if self.subject else None
}
def get_full_code(self):
"""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 ''}"
def __repr__(self):
return f'<Commission {self.get_full_code()}>'
-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 %}
-235
View File
@@ -1,235 +0,0 @@
{% extends "base.html" %}
{% block title %}Reservation Details - 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-check text-info"></i> Reservation Details
</h1>
<div>
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to List
</a>
</div>
</div>
</div>
</div>
<div class="row">
<!-- Main Reservation Info -->
<div class="col-lg-8 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-center">
<h5 class="mb-0">
<i class="bi bi-info-circle text-primary"></i> Reservation Information
</h5>
<span class="badge bg-{% if reservation.status.value == 'CONFIRMED' %}success{% elif reservation.status.value == 'PENDING' %}warning{% else %}secondary{% endif %}">
{{ reservation.status.value }}
</span>
</div>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6 mb-3">
<label class="text-muted small">Reservation ID</label>
<p class="mb-0"><strong>#{{ reservation.id }}</strong></p>
</div>
<div class="col-md-6 mb-3">
<label class="text-muted small">Created by</label>
<p class="mb-0">
<i class="bi bi-person"></i>
{% if reservation.user %}
{{ reservation.user.first_name }} {{ reservation.user.last_name }}
{% else %}
Unknown
{% endif %}
</p>
</div>
<div class="col-md-6 mb-3">
<label class="text-muted small">Date & Time</label>
<p class="mb-0">
<i class="bi bi-clock"></i>
{{ reservation.start_time.strftime('%B %d, %Y') }}<br>
{{ reservation.start_time.strftime('%H:%M') }} - {{ reservation.end_time.strftime('%H:%M') }}
</p>
</div>
<div class="col-md-6 mb-3">
<label class="text-muted small">Duration</label>
<p class="mb-0">
<i class="bi bi-hourglass"></i>
{% set duration = (reservation.end_time - reservation.start_time).total_seconds() / 60 %}
{{ (duration / 60) | round(1) }} hours
</p>
</div>
</div>
{% if reservation.purpose %}
<div class="mb-3">
<label class="text-muted small">Purpose</label>
<p class="mb-0">{{ reservation.purpose }}</p>
</div>
{% endif %}
{% if reservation.notes %}
<div class="mb-3">
<label class="text-muted small">Notes</label>
<p class="mb-0">{{ reservation.notes }}</p>
</div>
{% endif %}
</div>
</div>
<!-- Classroom Details -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0">
<h5 class="mb-0">
<i class="bi bi-building text-info"></i> Classroom Information
</h5>
</div>
<div class="card-body">
{% if reservation.classroom %}
<div class="row">
<div class="col-md-4 mb-3">
<label class="text-muted small">Classroom</label>
<p class="mb-0"><strong>{{ reservation.classroom.code_display }}</strong></p>
</div>
<div class="col-md-4 mb-3">
<label class="text-muted small">Building</label>
<p class="mb-0">{{ reservation.classroom.building }}</p>
</div>
<div class="col-md-4 mb-3">
<label class="text-muted small">Capacity</label>
<p class="mb-0">{{ reservation.classroom.capacity }} people</p>
</div>
{% if reservation.classroom.description %}
<div class="col-12">
<label class="text-muted small">Description</label>
<p class="mb-0">{{ reservation.classroom.description }}</p>
</div>
{% endif %}
</div>
{% else %}
<p class="text-muted mb-0">Classroom information not available</p>
{% endif %}
</div>
</div>
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<!-- Subject & Commission -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0">
<h5 class="mb-0">
<i class="bi bi-book text-warning"></i> Subject & Commission
</h5>
</div>
<div class="card-body">
{% if reservation.commission %}
<div class="mb-3">
<label class="text-muted small">Commission Code</label>
<p class="mb-0"><strong>{{ reservation.commission.get_full_code() }}</strong></p>
</div>
{% if reservation.commission.subject %}
<div class="mb-3">
<label class="text-muted small">Subject</label>
<p class="mb-0">{{ reservation.commission.subject.name }}</p>
</div>
{% endif %}
{% else %}
<p class="text-muted mb-0">No commission assigned</p>
{% endif %}
</div>
</div>
<!-- Attendees -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0">
<h5 class="mb-0">
<i class="bi bi-people text-success"></i> Attendance
</h5>
</div>
<div class="card-body">
<div class="text-center">
<h3 class="mb-1">{{ reservation.expected_attendees }}</h3>
<p class="text-muted small">Expected Attendees</p>
{% if reservation.classroom %}
<div class="progress mt-3" style="height: 8px;">
{% set percentage = (reservation.expected_attendees / reservation.classroom.capacity * 100) %}
<div class="progress-bar bg-{% if percentage > 90 %}danger{% elif percentage > 75 %}warning{% else %}success{% endif %}"
style="width: {{ percentage }}%"></div>
</div>
<small class="text-muted">
{{ reservation.expected_attendees }} / {{ reservation.classroom.capacity }} capacity
</small>
{% endif %}
</div>
</div>
</div>
<!-- Actions -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0">
<h5 class="mb-0">
<i class="bi bi-gear text-secondary"></i> Actions
</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
{% if reservation.status.value == 'PENDING' %}
<form method="POST" action="{{ url_for('schedule.confirm_reservation', id=reservation.id) }}">
<button type="submit" class="btn btn-success" onclick="return confirm('Confirm this reservation?')">
<i class="bi bi-check-circle"></i> Confirm Reservation
</button>
</form>
{% endif %}
{% if reservation.can_be_cancelled() %}
<form method="POST" action="{{ url_for('schedule.cancel_reservation', id=reservation.id) }}">
<button type="submit" class="btn btn-danger" onclick="return confirm('Cancel this reservation?')">
<i class="bi bi-x-circle"></i> Cancel Reservation
</button>
</form>
{% endif %}
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
<i class="bi bi-list"></i> View All Reservations
</a>
</div>
</div>
</div>
</div>
</div>
<!-- Timestamps -->
<div class="row mt-4">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="row text-muted small">
<div class="col-md-4">
<i class="bi bi-clock-history"></i>
Created: {{ reservation.created_at.strftime('%Y-%m-%d %H:%M:%S') }}
</div>
{% if reservation.updated_at %}
<div class="col-md-4">
<i class="bi bi-arrow-repeat"></i>
Updated: {{ reservation.updated_at.strftime('%Y-%m-%d %H:%M:%S') }}
</div>
{% endif %}
<div class="col-md-4">
<i class="bi bi-info-square"></i>
Reservation ID: #{{ reservation.id }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
-3
View File
@@ -1,3 +0,0 @@
[python: **.py]
[jinja2: **/templates/**.html]
extensions=jinja2.ext.i18n
+103
View File
@@ -0,0 +1,103 @@
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.routes.api.openapi import api_openapi_bp
from app.routes.api.students import api_students_bp
from app.routes.genetic_algorithm import genetic_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_openapi_bp)
app.register_blueprint(genetic_bp)
app.register_blueprint(api_students_bp, url_prefix='/api/v1')
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
+1
View File
@@ -0,0 +1 @@
from .document_types import DOCUMENT_TYPES, DOCUMENT_TYPE_MAP, get_document_type, validate_document, format_document
+160
View File
@@ -0,0 +1,160 @@
import re
DOCUMENT_TYPES = [
{
'code': 'DNI',
'name': 'Documento Nacional de Identidad',
'short_name': 'DNI',
'category': 'Nacional',
'description': 'Documento oficial para ciudadanos argentinos nativos, naturalizados y residentes permanentes.',
'regex': r'^\d{7,8}$',
'placeholder': 'Ej: 38123456 (7 u 8 dígitos sin puntos)',
'mask_example': '38.123.456'
},
{
'code': 'CUIL',
'name': 'Código Único de Identificación Laboral (CUIL/CUIT)',
'short_name': 'CUIL',
'category': 'Nacional / Laboral',
'description': 'Identificador fiscal y laboral en Argentina (11 dígitos).',
'regex': r'^\d{11}$|^\d{2}-\d{8}-\d{1}$',
'placeholder': 'Ej: 20381234567 o 20-38123456-7',
'mask_example': '20-38123456-7'
},
{
'code': 'PAS',
'name': 'Pasaporte Argentino',
'short_name': 'Pasaporte ARG',
'category': 'Internacional',
'description': 'Pasaporte emitido por la República Argentina para viajes y acreditación internacional.',
'regex': r'^[a-zA-Z0-9]{6,12}$',
'placeholder': 'Ej: AAB123456',
'mask_example': 'PAS AAB123456'
},
{
'code': 'PASEXT',
'name': 'Pasaporte Extranjero',
'short_name': 'Pasaporte Extranjero',
'category': 'Internacional / Extranjero',
'description': 'Pasaporte oficial emitido por país de origen (docentes invitados, alumnos de intercambio).',
'regex': r'^[a-zA-Z0-9\-\.]{4,20}$',
'placeholder': 'Ej: US987654321 / B1234567',
'mask_example': 'PASEXT US987654321'
},
{
'code': 'DNIEXT',
'name': 'DNI para Extranjeros (Residente)',
'short_name': 'DNI Extranjero',
'category': 'Extranjero / Residente',
'description': 'DNI argentino otorgado a ciudadanos extranjeros con radicación o residencia temporaria.',
'regex': r'^[a-zA-Z0-9]{7,10}$',
'placeholder': 'Ej: 94123456 o E94123456',
'mask_example': 'DNI-EXT 94.123.456'
},
{
'code': 'CI',
'name': 'Cédula de Identidad (PFA / Policía Provincial)',
'short_name': 'Cédula Identidad',
'category': 'Nacional (Histórico)',
'description': 'Cédula de Identidad emitida por Policía Federal Argentina o Policías Provinciales.',
'regex': r'^\d{6,9}$',
'placeholder': 'Ej: 6123456',
'mask_example': 'CI 6.123.456'
},
{
'code': 'CIEXT',
'name': 'Cédula de Identidad Extranjera (Mercosur)',
'short_name': 'Cédula Mercosur',
'category': 'Regional / Mercosur',
'description': 'Cédula de Identidad emitida por países miembros o asociados al Mercosur (Brasil, Uruguay, etc.).',
'regex': r'^[a-zA-Z0-9\-\.]{5,18}$',
'placeholder': 'Ej: RG12345678 (Brasil) o 4.123.456-7 (Uruguay)',
'mask_example': 'CI-EXT 12345678'
},
{
'code': 'LC',
'name': 'Libreta Cívica',
'short_name': 'Libreta Cívica',
'category': 'Histórico Nacional',
'description': 'Documento histórico nacional para ciudadanas mujeres argentinas.',
'regex': r'^\d{6,8}$',
'placeholder': 'Ej: 5123456',
'mask_example': 'LC 5.123.456'
},
{
'code': 'LE',
'name': 'Libreta de Enrolamiento',
'short_name': 'Libreta Enrolamiento',
'category': 'Histórico Nacional',
'description': 'Documento histórico nacional para ciudadanos varones argentinos.',
'regex': r'^\d{6,8}$',
'placeholder': 'Ej: 4123456',
'mask_example': 'LE 4.123.456'
}
]
DOCUMENT_TYPE_MAP = {doc['code']: doc for doc in DOCUMENT_TYPES}
def get_document_type(code):
"""Devuelve la especificación de un tipo de documento o None."""
if not code:
return None
return DOCUMENT_TYPE_MAP.get(str(code).strip().upper())
def validate_document(doc_type, doc_number):
"""
Valida un número de documento según las reglas de negocio de su tipificación.
Retorna (is_valid: bool, cleaned_number: str, error_message: str|None).
"""
if not doc_type:
return False, '', 'Debe especificar el tipo de documento.'
spec = get_document_type(doc_type)
if not spec:
return False, '', f"El tipo de documento '{doc_type}' no está registrado en el catálogo oficial."
if not doc_number:
return True, '', None # Document number may be optional if pending registration
cleaned = str(doc_number).strip().upper()
# Si es DNI, LC, LE, CI podemos remover puntos y guiones intermedios para validar y almacenar de forma uniforme
if spec['code'] in ['DNI', 'LC', 'LE', 'CI', 'DNIEXT']:
numeric_only = re.sub(r'[\.\-\s]', '', cleaned)
if re.match(spec['regex'], numeric_only):
return True, numeric_only, None
return False, cleaned, f"Formato inválido para {spec['name']}. Debe contener {spec['placeholder']}."
elif spec['code'] == 'CUIL':
numeric_only = re.sub(r'[\.\-\s]', '', cleaned)
if re.match(r'^\d{11}$', numeric_only):
return True, numeric_only, None
return False, cleaned, "El CUIL debe contener 11 dígitos numéricos (ej. 20-38123456-7)."
else:
# Pasaportes o Cédulas Extranjeras
if re.match(spec['regex'], cleaned):
return True, cleaned, None
return False, cleaned, f"Formato inválido para {spec['name']} ({spec['placeholder']})."
def format_document(doc_type, doc_number):
"""
Formatea visualmente un número de documento con puntos/guiones estándar.
"""
if not doc_number:
return ''
raw = str(doc_number).strip().upper()
dtype = (doc_type or 'DNI').upper()
if dtype in ['DNI', 'LC', 'LE', 'CI']:
clean = re.sub(r'\D', '', raw)
if len(clean) == 8:
return f"{clean[:2]}.{clean[2:5]}.{clean[5:]}"
elif len(clean) == 7:
return f"{clean[:1]}.{clean[1:4]}.{clean[4:]}"
return clean
elif dtype == 'CUIL':
clean = re.sub(r'\D', '', raw)
if len(clean) == 11:
return f"{clean[:2]}-{clean[2:10]}-{clean[10:]}"
return clean
return raw
+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 wtforms import SelectField, StringField, TextAreaField, IntegerField, SubmitField, DateTimeLocalField
from wtforms.validators import DataRequired, Length, NumberRange, Optional, ValidationError
from flask_babel import lazy_gettext as _l
from datetime import datetime, timedelta
class ReservationForm(FlaskForm):
classroom_id = SelectField('Classroom', coerce=int, validators=[DataRequired()])
commission_id = SelectField('Commission/Subject', coerce=int, validators=[DataRequired()])
start_time = DateTimeLocalField('Start Time', format='%Y-%m-%dT%H:%M', validators=[DataRequired()])
end_time = DateTimeLocalField('End Time', format='%Y-%m-%dT%H:%M', validators=[DataRequired()])
purpose = StringField('Purpose', validators=[
classroom_id = SelectField(_l('Classroom'), coerce=int, validators=[DataRequired()])
commission_id = SelectField(_l('Commission/Subject'), coerce=int, validators=[DataRequired()])
start_time = DateTimeLocalField(_l('Start 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(_l('Purpose'), validators=[
DataRequired(),
Length(min=3, max=500)
])
expected_attendees = IntegerField('Expected Attendees', validators=[
expected_attendees = IntegerField(_l('Expected Attendees'), validators=[
DataRequired(),
NumberRange(min=1, max=500)
])
notes = TextAreaField('Notes', validators=[
notes = TextAreaField(_l('Notes'), validators=[
Optional(),
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):
if field.data <= self.start_time.data:
@@ -31,8 +40,8 @@ class ReservationForm(FlaskForm):
raise ValidationError('Reservation cannot exceed 8 hours.')
def validate_start_time(self, field):
# Don't allow reservations in the past
if field.data < datetime.now():
# Don't allow new reservations in the past
if not self.is_edit and field.data < datetime.now() - timedelta(minutes=10):
raise ValidationError('Start time cannot be in the past.')
# Don't allow reservations too far in the future (max 3 months)
+42
View File
@@ -0,0 +1,42 @@
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, CommissionTeacher
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
from .grade import MilestoneGrade
from .sync_task import MoodleSyncTask
__all__ = [
'User',
'Building',
'Classroom',
'ClassroomResource',
'Career',
'AcademicTerm',
'Subject',
'Commission',
'CommissionTeacher',
'Reservation',
'ReservationStatus',
'GeneticAlgorithm',
'ReservationOptimizer',
'Role',
'Permission',
'SYSTEM_MODULES',
'MilestoneType',
'AcademicMilestone',
'BASE_MILESTONE_TYPES',
'AuditLog',
'StudentEnrollment',
'SystemSetting',
'MilestoneGrade',
'MoodleSyncTask'
]
+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'}>"
+79
View File
@@ -0,0 +1,79 @@
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 is_virtual(self):
"""Identifica si la sede corresponde al Campus Virtual"""
c = (self.code or '').upper()
n = (self.name or '').upper()
return 'VIRTUAL' in c or 'VIRTUAL' in n
@property
def physical_capacity(self):
"""Capacidad física instalada real (excluye aulas virtuales con capacidad 9999 o infinitas)"""
if self.is_virtual:
return 0
return sum(c.capacity for c in self.classrooms if c.is_active and (c.capacity or 0) < 999 and not getattr(c, 'is_virtual', False))
@property
def total_capacity(self):
"""Capacidad para compatibilidad histórica"""
if self.is_virtual:
return 0
return self.physical_capacity
@property
def capacity_display(self):
"""Representación textual clara para UI/UX"""
if self.is_virtual:
return "Ilimitada (Virtual)"
return str(self.physical_capacity)
@property
def floors_display(self):
"""Representación de pisos"""
if self.is_virtual:
return "Digital"
return str(self.floors)
@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,
'floors_display': self.floors_display,
'description': self.description,
'is_active': self.is_active,
'is_virtual': self.is_virtual,
'classrooms_count': self.classrooms_count,
'physical_capacity': self.physical_capacity,
'total_capacity': self.physical_capacity if not self.is_virtual else 0,
'capacity_display': self.capacity_display,
'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)
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)
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)
description = db.Column(db.Text)
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)
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
@property
def name(self):
"""Generate name from building and code"""
return f"{self.building}-{self.code}"
return f"{self.building_name}-{self.code}"
@property
def campus_location(self):
"""Placeholder for campus location - can be expanded when campus model is implemented"""
return None
"""Placeholder for campus location"""
return self.building_rel.address if self.building_rel else None
# Virtual field for compatibility - combines building and code
@property
@@ -38,17 +73,34 @@ class Classroom(db.Model):
# Override code property for consistency
@property
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):
"""Convert classroom to dictionary"""
return {
'id': self.id,
'code': self.code_display,
'building': self.building,
'building_id': self.building_id,
'building': self.building_name,
'code': self.code,
'code_display': self.code_display,
'name': self.name,
'floor': self.floor,
'floor_display': self.floor_display,
'location_display': self.location_display,
'capacity': self.capacity,
'capacity_display': self.capacity_display,
'is_virtual': self.is_virtual,
'description': self.description,
'campus_location': self.campus_location,
'is_active': self.is_active,
@@ -59,10 +111,9 @@ class Classroom(db.Model):
def get_full_location(self):
"""Get full classroom location"""
location = f"{self.building} - Floor {self.floor}"
if self.campus_location:
location += f" ({self.campus_location})"
return location
if self.is_virtual:
return 'Campus Virtual (Remoto)'
return f"{self.building_name} - {self.floor_display}"
@staticmethod
def find_best_classroom(max_students, start_time, end_time, exclude_reservation_id=None):
+53
View File
@@ -0,0 +1,53 @@
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)
allow_same_day_exception = db.Column(db.Boolean, default=False, nullable=True)
exception_reason = db.Column(db.String(255), 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,
'allow_same_day_exception': bool(self.allow_same_day_exception),
'exception_reason': self.exception_reason
}
def __repr__(self):
return f'<StudentEnrollment student={self.student_id} commission={self.commission_id} status={self.status}>'
@@ -45,16 +45,37 @@ class Individual:
self.fitness = 0.0
self.conflicts = []
def calculate_fitness(self, classrooms: Dict[int, Classroom], requests: Dict[int, ReservationRequest]) -> float:
"""Calculate fitness score based on multiple factors"""
def calculate_fitness(self, classrooms: Dict[int, Classroom], reservation_requests: Dict[int, ReservationRequest]) -> float:
"""Calculate fitness score based on multiple factors with fast O(K log K) interval conflict detection"""
score = 0.0
self.conflicts = []
# Pre-group genes by classroom to calculate conflicts in O(K log K)
by_classroom = {}
for g in self.genes:
by_classroom.setdefault(g.classroom_id, []).append(g)
conflict_count_by_id = {}
for cid, room_genes in by_classroom.items():
if len(room_genes) <= 1:
continue
# Sort chronologically
room_genes.sort(key=lambda x: x.start_time)
for i in range(len(room_genes)):
g_i = room_genes[i]
for j in range(i + 1, len(room_genes)):
g_j = room_genes[j]
if g_i.end_time > g_j.start_time:
conflict_count_by_id[id(g_i)] = conflict_count_by_id.get(id(g_i), 0) + 1
conflict_count_by_id[id(g_j)] = conflict_count_by_id.get(id(g_j), 0) + 1
else:
break # Gene j and subsequent ones start later than gene i ends
for gene in self.genes:
classroom = classrooms.get(gene.classroom_id)
request = requests.get(gene.commission_id)
res_req = reservation_requests.get(gene.commission_id)
if not classroom or not request:
if not classroom or not res_req:
continue
# Factor 1: Capacity efficiency (40% weight)
@@ -62,23 +83,24 @@ class Individual:
score += capacity_score * 0.4
# Factor 2: Time preference satisfaction (30% weight)
time_score = self._calculate_time_score(gene, request)
time_score = self._calculate_time_score(gene, res_req)
score += time_score * 0.3
# Factor 3: Conflict penalty (20% weight)
conflict_score = self._calculate_conflict_penalty(gene, self.genes)
score += conflict_score * 0.2
# Factor 4: Resource matching (10% weight)
resource_score = self._calculate_resource_score(classroom, request.subject_requirements)
score += resource_score * 0.1
# Store conflicts for debugging
if conflict_score < 0:
c_count = conflict_count_by_id.get(id(gene), 0)
if c_count > 0:
conflict_score = -c_count * 0.5
self.conflicts.append({
'gene': gene,
'conflict_type': 'time_overlap'
})
else:
conflict_score = 1.0
score += conflict_score * 0.2
# Factor 4: Resource matching (10% weight)
resource_score = self._calculate_resource_score(classroom, res_req.subject_requirements)
score += resource_score * 0.1
self.fitness = score
return score
@@ -89,7 +111,7 @@ class Individual:
return 0.0 # Overfilled classroom
# Calculate efficiency: closer capacity match = higher score
waste_ratio = (classroom.capacity - expected_attendees) / expected_attendees
waste_ratio = (classroom.capacity - expected_attendees) / max(1, expected_attendees)
if waste_ratio <= 0.1: # Less than 10% waste
return 1.0
elif waste_ratio <= 0.3: # Less than 30% waste
@@ -107,12 +129,12 @@ class Individual:
if time_diff <= 0.5: # Within 30 minutes
return 1.0
elif time_diff <= request.flexibility_hours: # Within flexible window
return 0.8 - (time_diff / request.flexibility_hours) * 0.3
return 0.8 - (time_diff / max(1, request.flexibility_hours)) * 0.3
else:
return max(0.0, 0.5 - (time_diff - request.flexibility_hours) * 0.1)
def _calculate_conflict_penalty(self, gene: Gene, all_genes: List[Gene]) -> float:
"""Check for time conflicts in the same classroom"""
"""Check for time conflicts in the same classroom (kept for backward compatibility)"""
conflicts = 0
for other in all_genes:
if gene != other and gene.classroom_id == other.classroom_id:
@@ -127,9 +149,6 @@ class Individual:
"""Check if classroom meets subject requirements"""
if not requirements:
return 1.0
# This would need to be implemented based on actual classroom resources
# For now, return a default score
return 0.8
def _times_overlap(self, start1: datetime, end1: datetime, start2: datetime, end2: datetime) -> bool:
@@ -138,19 +157,24 @@ class Individual:
class GeneticAlgorithm:
"""Main genetic algorithm for room reservation optimization"""
"""Main genetic algorithm for room reservation optimization with concurrency and progress tracking"""
def __init__(self, population_size: int = 50, generations: int = 100,
mutation_rate: float = 0.1, crossover_rate: float = 0.8):
mutation_rate: float = 0.1, crossover_rate: float = 0.8,
workers: int = 4):
self.population_size = population_size
self.generations = generations
self.mutation_rate = mutation_rate
self.crossover_rate = crossover_rate
self.workers = max(1, workers)
def optimize_reservations(self, requests: List[ReservationRequest],
available_classrooms: List[Classroom],
start_date: datetime, end_date: datetime) -> Individual:
"""Run genetic algorithm to find optimal reservation schedule"""
start_date: datetime, end_date: datetime,
progress_callback=None) -> Individual:
"""Run genetic algorithm with high concurrency to find optimal reservation schedule"""
import concurrent.futures
classrooms_dict = {c.id: c for c in available_classrooms}
requests_dict = {r.commission_id: r for r in requests}
@@ -159,18 +183,29 @@ class GeneticAlgorithm:
# Evolve population
for generation in range(self.generations):
# Calculate fitness for all individuals
# Calculate fitness for all individuals concurrently when workers > 1
if self.workers > 1 and len(population) > 10:
with concurrent.futures.ThreadPoolExecutor(max_workers=self.workers) as executor:
futures = [executor.submit(ind.calculate_fitness, classrooms_dict, requests_dict) for ind in population]
concurrent.futures.wait(futures)
else:
for individual in population:
individual.calculate_fitness(classrooms_dict, requests_dict)
# Sort by fitness (best first)
population.sort(key=lambda x: x.fitness, reverse=True)
# Report progress if callback is provided
if progress_callback:
pct = int(((generation + 1) / self.generations) * 100)
best_fit = population[0].fitness if population else 0.0
progress_callback(generation + 1, self.generations, pct, best_fit)
# Create new generation
new_population = []
# Elitism: keep best 10%
elite_size = int(self.population_size * 0.1)
elite_size = max(2, int(self.population_size * 0.1))
new_population.extend(population[:elite_size])
# Crossover and mutation for remaining
@@ -196,7 +231,9 @@ class GeneticAlgorithm:
population = new_population[:self.population_size]
# Return best individual from final generation
# Calculate fitness for final generation
for individual in population:
individual.calculate_fitness(classrooms_dict, requests_dict)
population.sort(key=lambda x: x.fitness, reverse=True)
return population[0]
@@ -284,13 +321,14 @@ class GeneticAlgorithm:
class ReservationOptimizer:
"""High-level interface for the reservation optimization system"""
def __init__(self):
self.ga = GeneticAlgorithm()
def __init__(self, population_size: int = 50, generations: int = 100, workers: int = 4):
self.ga = GeneticAlgorithm(population_size=population_size, generations=generations, workers=workers)
def optimize_schedule(self, commission_ids: List[int],
admin_user_id: int,
start_date: datetime = None,
end_date: datetime = None) -> Dict:
end_date: datetime = None,
progress_callback = None) -> Dict:
"""Optimize reservation schedule for given commissions"""
# Default date range if not provided
@@ -325,7 +363,7 @@ class ReservationOptimizer:
classrooms = Classroom.query.filter_by(is_active=True).all()
# Run genetic algorithm
best_individual = self.ga.optimize_reservations(requests, classrooms, start_date, end_date)
best_individual = self.ga.optimize_reservations(requests, classrooms, start_date, end_date, progress_callback=progress_callback)
# Convert to reservation format
optimized_reservations = []
@@ -352,28 +390,61 @@ class ReservationOptimizer:
}
def _calculate_preferred_time(self, commission: Commission) -> Dict:
"""Calculate preferred time slots for a commission"""
# This is a simplified version - would need to be based on actual semester schedule
"""Calculate preferred time slots for a commission based on shift and schedule"""
current_date = datetime.utcnow()
# Default to weekday mornings for regular classes
days_ahead = (0 - current_date.weekday()) % 7 # Next Monday
if days_ahead == 0: # If today is Monday, use next week
# Determine day of week from commission schedule or default to Monday
target_weekday = 0 # Monday
sched = (commission.schedule or '').lower()
if 'martes' in sched:
target_weekday = 1
elif 'miercoles' in sched or 'miércoles' in sched:
target_weekday = 2
elif 'jueves' in sched:
target_weekday = 3
elif 'viernes' in sched:
target_weekday = 4
elif 'sabado' in sched or 'sábado' in sched:
target_weekday = 5
days_ahead = (target_weekday - current_date.weekday()) % 7
if days_ahead == 0:
days_ahead = 7
preferred_date = current_date + timedelta(days=days_ahead)
# Determine hours based on shift
shift = (commission.shift or '').strip().lower()
if 'mañana' in shift or 'manana' in shift:
start_h, start_m = 8, 0
end_h, end_m = 12, 0
elif 'tarde' in shift:
start_h, start_m = 14, 0
end_h, end_m = 18, 0
elif 'noche' in shift or 'vespertino' in shift:
start_h, start_m = 18, 30
end_h, end_m = 22, 30
else:
start_h, start_m = 9, 0
end_h, end_m = 13, 0
return {
'start': preferred_date.replace(hour=9, minute=0, second=0, microsecond=0),
'end': preferred_date.replace(hour=11, minute=0, second=0, microsecond=0)
'start': preferred_date.replace(hour=start_h, minute=start_m, second=0, microsecond=0),
'end': preferred_date.replace(hour=end_h, minute=end_m, second=0, microsecond=0)
}
def apply_optimized_reservations(self, reservation_data: List[Dict]) -> List[Reservation]:
"""Apply the optimized reservations to the database"""
"""Apply the optimized reservations to the database with ISO string parsing and conflict avoidance"""
applied_reservations = []
for data in reservation_data:
# Check for conflicts before creating
for item in reservation_data:
data = dict(item)
if isinstance(data.get('start_time'), str):
data['start_time'] = datetime.fromisoformat(data['start_time'].replace('Z', '+00:00'))
if isinstance(data.get('end_time'), str):
data['end_time'] = datetime.fromisoformat(data['end_time'].replace('Z', '+00:00'))
# Check for physical room conflicts before creating
conflicts = Reservation.find_conflicts(
data['classroom_id'],
data['start_time'],
@@ -381,12 +452,17 @@ class ReservationOptimizer:
)
if not conflicts:
# Ensure status enum value compatibility
if 'status' in data and isinstance(data['status'], str):
try:
data['status'] = ReservationStatus(data['status']).value
except ValueError:
data['status'] = ReservationStatus.CONFIRMED.value
reservation = Reservation(**data)
db.session.add(reservation)
applied_reservations.append(reservation)
else:
# Log conflict - could be handled differently
print(f"Conflict detected for reservation data: {data}")
current_app.logger.warning(f"Conflict detected for reservation during genetic apply: {data}")
try:
db.session.commit()
+92
View File
@@ -0,0 +1,92 @@
from datetime import datetime
from app import db
class MilestoneGrade(db.Model):
"""
Calificación y seguimiento pedagógico individual de un alumno en un hito evaluativo (Fase 3 MVP).
Vincula un AcademicMilestone con un estudiante matriculado (StudentEnrollment / User).
"""
__tablename__ = 'milestone_grades'
id = db.Column(db.Integer, primary_key=True)
milestone_id = db.Column(
db.Integer,
db.ForeignKey('academic_milestones.id', ondelete='CASCADE'),
nullable=False,
index=True
)
enrollment_id = db.Column(
db.Integer,
db.ForeignKey('student_enrollments.id', ondelete='CASCADE'),
nullable=False,
index=True
)
student_id = db.Column(
db.Integer,
db.ForeignKey('users.id', ondelete='CASCADE'),
nullable=False,
index=True
)
score = db.Column(db.Float, nullable=True) # Calificación numérica de 1.0 a 10.0
is_absent = db.Column(db.Boolean, default=False) # Flag de Ausente
feedback = db.Column(db.Text, nullable=True) # Devolución formativa/pedagógica
graded_by_user_id = db.Column(
db.Integer,
db.ForeignKey('users.id', ondelete='SET NULL'),
nullable=True
)
graded_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Restricción de unicidad: un estudiante tiene una única nota por hito
__table_args__ = (
db.UniqueConstraint('milestone_id', 'student_id', name='uq_milestone_student'),
)
# Relaciones
milestone = db.relationship('AcademicMilestone', backref=db.backref('grades', lazy='dynamic', cascade='all, delete-orphan'))
enrollment = db.relationship('StudentEnrollment', backref=db.backref('milestone_grades', lazy='dynamic', cascade='all, delete-orphan'))
student = db.relationship('User', foreign_keys=[student_id], backref=db.backref('received_grades', lazy='dynamic'))
graded_by = db.relationship('User', foreign_keys=[graded_by_user_id])
@property
def is_passed(self) -> bool:
"""Determina si la nota está aprobada (>= 4.00 y no ausente)."""
if self.is_absent or self.score is None:
return False
return self.score >= 4.0
@property
def display_score(self) -> str:
"""Representación textual estándar: 'Ausente', '7.5' o '-'."""
if self.is_absent:
return 'Ausente'
if self.score is not None:
return f'{self.score:.1f}' if self.score % 1 != 0 else f'{int(self.score)}'
return '-'
def to_dict(self):
return {
'id': self.id,
'milestone_id': self.milestone_id,
'milestone_title': self.milestone.title if self.milestone else None,
'milestone_type': self.milestone.tipo if self.milestone else None,
'milestone_color': self.milestone.color if self.milestone else None,
'enrollment_id': self.enrollment_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,
'score': self.score,
'display_score': self.display_score,
'is_absent': bool(self.is_absent),
'is_passed': self.is_passed,
'feedback': self.feedback or '',
'graded_by_user_id': self.graded_by_user_id,
'graded_by_name': self.graded_by.name if self.graded_by else None,
'graded_at': self.graded_at.isoformat() if self.graded_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
def __repr__(self):
return f'<MilestoneGrade milestone={self.milestone_id} student={self.student_id} score={self.display_score}>'
+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 datetime import datetime, timedelta
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):
PENDING = 'PENDING'
@@ -20,8 +44,10 @@ class Reservation(db.Model):
start_time = db.Column(db.DateTime, nullable=False)
end_time = db.Column(db.DateTime, 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)
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)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
@@ -32,10 +58,49 @@ class Reservation(db.Model):
def status_enum(self):
"""Get status as enum"""
try:
return ReservationStatus(self.status)
return ReservationStatus(str(self.status))
except (ValueError, AttributeError):
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
classroom = db.relationship('Classroom', backref=db.backref('reservation_list', lazy=True, cascade='all, delete-orphan'))
commission = db.relationship('Commission', 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
'expected_attendees': self.expected_attendees,
'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,
'updated_at': self.updated_at.isoformat() if self.updated_at 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}>'
+131
View File
@@ -0,0 +1,131 @@
from app import db
from datetime import datetime
from app.services.crypto_service import CryptoService
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, Moodle),
flags globales, credenciales SMTP encriptadas y secretos OAuth.
"""
__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)
category = db.Column(db.String(50), default='system', nullable=True)
is_encrypted = db.Column(db.Boolean, default=False, nullable=True)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@classmethod
def get_value(cls, key, default=None):
"""Retorna el valor tal como está almacenado en BD."""
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
def get_decrypted_value(self_or_cls, key_or_default=None, default=None):
"""Retorna el valor desencriptado. Compatible como método de clase o de instancia."""
if isinstance(self_or_cls, type):
# Llamado como clase: SystemSetting.get_decrypted_value('key', default)
key = key_or_default
try:
record = self_or_cls.query.filter_by(key=key).first()
if record:
return record.get_decrypted_value(default)
except Exception:
pass
return default
else:
# Llamado como instancia: setting.get_decrypted_value(default)
actual_default = key_or_default
try:
if self_or_cls.value is not None and self_or_cls.value.strip() != '':
if self_or_cls.is_encrypted:
return CryptoService.decrypt(self_or_cls.value)
return self_or_cls.value
except Exception:
pass
return actual_default
def get_masked_value(self_or_cls, key_or_default=None, default=''):
"""Retorna una versión enmascarada si es un secreto o contraseña. Compatible como clase o instancia."""
if isinstance(self_or_cls, type):
# Llamado como clase: SystemSetting.get_masked_value('key', default)
key = key_or_default
try:
record = self_or_cls.query.filter_by(key=key).first()
if record:
return record.get_masked_value(default)
except Exception:
pass
return default
else:
# Llamado como instancia: setting.get_masked_value(default)
actual_default = key_or_default or ''
try:
if self_or_cls.value and self_or_cls.value.strip() != '':
if self_or_cls.is_encrypted or 'secret' in self_or_cls.key.lower() or 'password' in self_or_cls.key.lower() or 'token' in self_or_cls.key.lower():
return '••••••••••••'
return self_or_cls.value
except Exception:
pass
return actual_default
@classmethod
def set_value(cls, key, value, description=None, category='system', is_encrypted=False):
"""Guarda o actualiza un parámetro de configuración."""
record = cls.query.filter_by(key=key).first()
final_value = value
if is_encrypted and value:
# Si el valor ya viene cifrado o es un placeholder de no-cambio '••••••••••••', no recifrar
if value != '••••••••••••':
final_value = CryptoService.encrypt(value)
if not record:
record = cls(
key=key,
value=final_value,
description=description,
category=category,
is_encrypted=is_encrypted
)
db.session.add(record)
else:
# Si el valor ingresado es el placeholder, no sobrescribir la contraseña existente
if not (is_encrypted and value == '••••••••••••'):
record.value = final_value
if description:
record.description = description
if category:
record.category = category
record.is_encrypted = is_encrypted
record.updated_at = datetime.utcnow()
db.session.commit()
return record
@classmethod
def set_encrypted_value(cls, key, value, description=None, category='system'):
"""Helper para guardar directamente valores sensibles cifrados."""
return cls.set_value(key, value, description=description, category=category, is_encrypted=True)
def to_dict(self, mask_secrets=True):
val = self.value
if mask_secrets and (self.is_encrypted or 'password' in self.key.lower() or 'secret' in self.key.lower() or 'token' in self.key.lower()):
val = '••••••••••••' if val else ''
return {
'key': self.key,
'value': val,
'description': self.description,
'category': self.category or 'system',
'is_encrypted': bool(self.is_encrypted),
'updated_at': self.updated_at.isoformat() if self.updated_at else None
}
def __repr__(self):
return f'<SystemSetting {self.key}>'
+203
View File
@@ -0,0 +1,203 @@
from app import db
from datetime import datetime
class Subject(db.Model):
__tablename__ = 'subjects'
id = db.Column(db.Integer, primary_key=True)
code = db.Column(db.String(50), unique=True, nullable=False, index=True)
name = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text)
department = db.Column(db.String(100), nullable=False, default='')
credits = db.Column(db.Integer, nullable=False, default=4)
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_short_course = db.Column(db.Boolean, default=False, nullable=True) # True para talleres/cursos cortos
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
commissions = db.relationship('Commission', backref='subject', lazy=True, cascade='all, delete-orphan')
def to_dict(self):
"""Convert subject to dictionary"""
return {
'id': self.id,
'code': self.code,
'name': self.name,
'description': self.description,
'department': self.department,
'credits': self.credits,
'career_id': self.career_id,
'career_name': self.career_obj.name if self.career_obj else None,
'is_active': self.is_active,
'is_short_course': bool(self.is_short_course),
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None
}
def __repr__(self):
return f'<Subject {self.code}>'
class Commission(db.Model):
__tablename__ = 'commissions'
id = db.Column(db.Integer, primary_key=True)
subject_id = db.Column(db.Integer, db.ForeignKey('subjects.id'), nullable=False)
code = db.Column(db.String(50), nullable=False)
semester = db.Column(db.String(20), nullable=False)
year = db.Column(db.Integer, nullable=False)
teacher_id = db.Column(db.Integer, db.ForeignKey('users.id'))
max_students = db.Column(db.Integer, nullable=False)
current_students = db.Column(db.Integer, default=0)
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
# Cierre de Actas y Libro de Calificaciones (Fase 3 MVP)
grades_closed = db.Column(db.Boolean, default=False, nullable=True)
grades_closed_at = db.Column(db.DateTime, nullable=True)
grades_closed_by = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
acta_number = db.Column(db.String(50), nullable=True)
# Vinculación con Ciclo Lectivo / Período Académico
academic_term_id = db.Column(db.Integer, db.ForeignKey('academic_terms.id', ondelete='SET NULL'), nullable=True)
academic_term = db.relationship('AcademicTerm', foreign_keys=[academic_term_id])
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Virtual property for compatibility
@property
def is_active(self):
return self.active
@is_active.setter
def is_active(self, value):
self.active = value
# Relationships
pass # Reservation already defines the relationship back to commission
teacher = db.relationship('User', backref='teaching_commissions', foreign_keys=[teacher_id])
closed_by_user = db.relationship('User', foreign_keys=[grades_closed_by])
# Virtual field for teacher_name
@property
def teacher_name(self):
return f"{self.teacher.first_name} {self.teacher.last_name}" if self.teacher else "TBD"
@property
def teachers(self):
"""Retorna la lista de docentes asignados.
Si teachers_association tiene registros, los retorna ordenados (Titular primero).
Si está vacía pero teacher_id está configurado, devuelve el docente titular.
"""
if hasattr(self, 'teachers_association') and self.teachers_association and len(self.teachers_association) > 0:
res = [ta.to_dict() for ta in self.teachers_association]
res.sort(key=lambda x: (not x.get('is_primary', False), x.get('role', '') != 'Titular'))
return res
if self.teacher:
return [{
'id': 0,
'commission_id': self.id,
'user_id': self.teacher.id,
'name': self.teacher.name,
'email': self.teacher.email,
'role': 'Titular',
'is_primary': True
}]
return []
@property
def teacher_names(self):
t_list = self.teachers
if not t_list:
return "Sin asignar"
return ", ".join([f"{t['name']} ({t['role']})" for t in t_list])
# Unique constraint
__table_args__ = (db.UniqueConstraint('subject_id', 'code', 'semester', 'year'),)
def to_dict(self):
"""Convert commission to dictionary"""
return {
'id': self.id,
'subject_id': self.subject_id,
'code': self.code,
'semester': self.semester,
'year': self.year,
'teacher_id': self.teacher_id,
'teacher_name': self.teacher_name,
'teachers': self.teachers,
'teacher_names': self.teacher_names,
'max_students': self.max_students,
'current_students': self.current_students,
'schedule': self.schedule,
'shift': self.shift,
'virtual_link': self.virtual_link,
'active': self.active,
'grades_closed': bool(self.grades_closed),
'grades_closed_at': self.grades_closed_at.isoformat() if self.grades_closed_at else None,
'grades_closed_by': self.grades_closed_by,
'closed_by_name': self.closed_by_user.name if self.closed_by_user else None,
'acta_number': self.acta_number,
'academic_term_id': self.academic_term_id,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'full_code': self.get_full_code(),
'subject': self.subject.to_dict() if self.subject else None
}
def get_full_code(self):
"""Get full commission code"""
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):
return f'<Commission {self.get_full_code()}>'
class CommissionTeacher(db.Model):
"""Modelo de asignación de múltiples docentes a una comisión (Co-Docencia / Cátedra)."""
__tablename__ = 'commission_teachers'
id = db.Column(db.Integer, primary_key=True)
commission_id = db.Column(db.Integer, db.ForeignKey('commissions.id', ondelete='CASCADE'), nullable=False, index=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True)
role = db.Column(db.String(50), default='Titular', nullable=False) # Titular, Adjunto, JTP, Ayudante
is_primary = db.Column(db.Boolean, default=False, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Relationships
commission = db.relationship('Commission', backref=db.backref('teachers_association', cascade='all, delete-orphan', lazy='joined'))
teacher = db.relationship('User', backref=db.backref('commission_assignments', cascade='all, delete-orphan', lazy='joined'))
__table_args__ = (db.UniqueConstraint('commission_id', 'user_id'),)
def to_dict(self):
return {
'id': self.id,
'commission_id': self.commission_id,
'user_id': self.user_id,
'name': self.teacher.name if self.teacher else 'Docente',
'email': self.teacher.email if self.teacher else '',
'role': self.role or 'Titular',
'is_primary': self.is_primary,
'created_at': self.created_at.isoformat() if self.created_at else None
}
def __repr__(self):
return f'<CommissionTeacher Commission:{self.commission_id} User:{self.user_id} Role:{self.role}>'␍
+69
View File
@@ -0,0 +1,69 @@
from app import db
from datetime import datetime
import json
class MoodleSyncTask(db.Model):
"""
Cola persistente de eventos y tareas de sincronización asíncrona hacia Moodle 4.1.
Garantiza tolerancia a fallos ante caídas o saturación del servidor Moodle,
incorporando reintentos exponenciales y Dead Letter Queue (DLQ).
"""
__tablename__ = 'moodle_sync_tasks'
id = db.Column(db.Integer, primary_key=True)
action = db.Column(db.String(50), nullable=False, index=True) # CREATE_USER, UPDATE_USER, ENROL_USER, UNENROL_USER, ASSIGN_ROLE
entity_type = db.Column(db.String(50), nullable=False) # user, commission, enrollment
entity_id = db.Column(db.String(100), nullable=True)
_payload = db.Column('payload', db.Text, nullable=False) # JSON serializado en Text
status = db.Column(db.String(20), default='PENDING', index=True) # PENDING, PROCESSING, RETRYING, COMPLETED, FAILED
attempts = db.Column(db.Integer, default=0)
max_attempts = db.Column(db.Integer, default=5)
error_message = db.Column(db.Text, nullable=True)
next_retry_at = db.Column(db.DateTime, default=datetime.utcnow, index=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __init__(self, **kwargs):
if 'payload' in kwargs:
raw_payload = kwargs.pop('payload')
if isinstance(raw_payload, (dict, list)):
kwargs['_payload'] = json.dumps(raw_payload)
else:
kwargs['_payload'] = str(raw_payload or '{}')
super().__init__(**kwargs)
@property
def payload(self):
try:
return json.loads(self._payload) if self._payload else {}
except Exception:
return {}
@payload.setter
def payload(self, val):
if isinstance(val, (dict, list)):
self._payload = json.dumps(val)
else:
self._payload = str(val or '{}')
def get_payload_dict(self):
return self.payload
def to_dict(self):
return {
'id': self.id,
'action': self.action,
'entity_type': self.entity_type,
'entity_id': self.entity_id,
'payload': self.payload,
'status': self.status,
'attempts': self.attempts,
'max_attempts': self.max_attempts,
'error_message': self.error_message,
'next_retry_at': self.next_retry_at.isoformat() if self.next_retry_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
}
def __repr__(self):
return f'<MoodleSyncTask #{self.id} {self.action} [{self.status}]>'
+169
View File
@@ -0,0 +1,169 @@
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)
# Extended personal, contact and identity document fields
first_name = db.Column(db.String(100), nullable=True)
last_name = db.Column(db.String(100), nullable=True)
phone = db.Column(db.String(50), nullable=True)
personal_email = db.Column(db.String(255), nullable=True)
address = db.Column(db.String(255), nullable=True)
document_type = db.Column(db.String(20), default='DNI', nullable=True)
document_number = db.Column(db.String(30), nullable=True, index=True)
def get_first_name(self):
if self.first_name:
return self.first_name
return self.name.split()[0] if self.name else ''
def get_last_name(self):
if self.last_name:
return self.last_name
parts = self.name.split() if self.name else []
return ' '.join(parts[1:]) if len(parts) > 1 else ''
def get_formatted_document(self):
from app.constants.document_types import format_document
return format_document(self.document_type, self.document_number)
# 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,
'institutional_email': self.email,
'personal_email': self.personal_email or '',
'name': self.name,
'first_name': self.get_first_name(),
'last_name': self.get_last_name(),
'phone': self.phone or '',
'address': self.address or '',
'document_type': self.document_type or 'DNI',
'document_number': self.document_number or '',
'document_formatted': self.get_formatted_document(),
'role': self.role,
'role_id': self.role_id,
'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,80 @@
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()
def find_teacher_conflicts(self, teacher_id: int, start_time: datetime, end_time: datetime,
exclude_reservation_id: Optional[int] = None) -> List[Reservation]:
"""
Encuentra reservas activas donde el docente ya está asignado en la misma franja horaria.
Condición de solapamiento: (start_time < existing.end_time) AND (end_time > existing.start_time).
"""
query = Reservation.query.options(
joinedload(Reservation.classroom),
joinedload(Reservation.commission)
).filter(
Reservation.user_id == teacher_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()
@@ -0,0 +1,49 @@
from typing import Optional, List
from sqlalchemy import func, or_
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 o alias de usuario."""
if not email:
return None
clean = email.strip().lower()
user = User.query.filter(func.lower(User.email) == clean).first()
if user:
return user
# Si el usuario ingresó solo el nombre de cuenta (ej. 'admin' o 'bedelia')
if '@' not in clean:
user = User.query.filter(
or_(
func.lower(User.email) == f"{clean}@edu-space.com",
func.lower(User.name) == clean,
func.lower(User.role) == clean.upper()
)
).first()
if user:
return user
return None
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
+401
View File
@@ -0,0 +1,401 @@
"""
Authentication Routes (admin-edu-space)
Implements Hybrid Authentication:
- Local Login with admin fallback
- Google OAuth 2.0 (inspired by AlumnosLS domain & email validation)
- Moodle Delegated Authentication with dynamic role mapping
- Token Refresh & Session Management (Redis / JWT)
"""
from flask import Blueprint, request, jsonify, g, make_response
from pydantic import ValidationError
import jwt
import requests
import logging
from app import db
from app.models.user import User
from app.models.role import Role
from app.models.setting import SystemSetting
from app.services.user_service import UserService
from app.services.jwt_service import JWTService
from app.services.cache_service import cache_service
from app.services.moodle_client import moodle_client
from app.schemas.auth_dto import LoginDTO, RefreshTokenDTO
from app.utils.jwt_decorators import jwt_required
logger = logging.getLogger(__name__)
api_auth_bp = Blueprint('api_auth', __name__, url_prefix='/api/v1/auth')
user_service = UserService()
@api_auth_bp.route('/providers', methods=['GET'])
def get_auth_providers():
"""
Public endpoint returning active authentication methods and Google client ID for the UI.
"""
local_val = SystemSetting.get_value('auth_local_enabled', 'true').lower() in ('true', '1')
google_val = SystemSetting.get_value('auth_google_enabled', 'false').lower() in ('true', '1')
moodle_val = SystemSetting.get_value('auth_moodle_enabled', 'false').lower() in ('true', '1')
google_client_id = SystemSetting.get_value('google_client_id', '')
demo_access_enabled = SystemSetting.get_value('demo_access_enabled', 'true').lower() in ('true', '1')
return jsonify({
'status': 'success',
'providers': {
'local': local_val,
'google': google_val,
'moodle': moodle_val
},
'google_client_id': google_client_id,
'demo_access_enabled': demo_access_enabled
}), 200
@api_auth_bp.route('/login', methods=['POST'])
def login():
"""
Native local authentication endpoint (Access + Refresh tokens).
Respects global auth_local_enabled setting, allowing emergency ADMIN access if disabled.
"""
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({'error': 'BadRequest', 'message': 'El cuerpo de la solicitud debe ser un objeto JSON.'}), 400
try:
dto = LoginDTO(**data)
except ValidationError as e:
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
local_enabled = SystemSetting.get_value('auth_local_enabled', 'true').lower() in ('true', '1')
user = user_service.authenticate(dto.email, dto.password)
if not user:
return jsonify({
'error': 'Unauthorized',
'message': 'Credenciales de acceso incorrectas o cuenta inactiva.'
}), 401
is_admin = user.is_admin() or getattr(user, 'role', '').upper() == 'ADMIN'
if not local_enabled and not is_admin:
return jsonify({
'error': 'Forbidden',
'message': 'El acceso local con contraseña está deshabilitado por el administrador. Inicie sesión mediante Google OAuth o Moodle.'
}), 403
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)
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('/google', methods=['POST'])
def google_auth():
"""
Google OAuth 2.0 authentication endpoint.
Receives Google profile / token data, verifies domain whitelist (AlumnosLS architecture),
creates/updates local user and issues JWT.
"""
google_enabled = SystemSetting.get_value('auth_google_enabled', 'false').lower() in ('true', '1')
if not google_enabled:
return jsonify({'error': 'Forbidden', 'message': 'El inicio de sesión con Google no está habilitado.'}), 403
data = request.get_json(silent=True) or {}
email = data.get('email', '').strip().lower()
name = data.get('name', '').strip()
domain = data.get('domain') or (email.split('@')[1] if '@' in email else '')
photo = data.get('photo', '')
if not email:
return jsonify({'error': 'BadRequest', 'message': 'El email de Google es obligatorio.'}), 400
# Domain restriction check
allowed_domains_str = SystemSetting.get_value('google_allowed_domains', 'unicaba.edu.ar')
allowed_domains = [d.strip().lower() for d in allowed_domains_str.split(',') if d.strip()]
if allowed_domains and domain.lower() not in allowed_domains:
logger.warning(f"[GoogleAuth] Access denied for {email}: domain {domain} not in {allowed_domains}")
return jsonify({
'error': 'Forbidden',
'message': f'Acceso denegado. El dominio @{domain} no está autorizado en esta institución.'
}), 403
user = User.query.filter(User.email.ilike(email)).first()
if not user:
# Create local user on first Google login
parts = name.split()
first_name = parts[0] if parts else 'Usuario'
last_name = ' '.join(parts[1:]) if len(parts) > 1 else 'Google'
# Default role: Docente
docente_role = Role.query.filter(Role.name.ilike('Docente')).first()
user = User(
email=email,
name=name or f"{first_name} {last_name}",
first_name=first_name,
last_name=last_name,
role='Docente' if not docente_role else docente_role.name,
role_id=docente_role.id if docente_role else None,
is_active=True
)
user.set_password(f"GoogleSSO_{email}")
db.session.add(user)
db.session.commit()
logger.info(f"[GoogleAuth] Created new local user for {email} with role Docente.")
if not user.is_active:
return jsonify({'error': 'Unauthorized', 'message': 'Su cuenta se encuentra inactiva. Contacte a Bedelía.'}), 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)
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('/moodle', methods=['POST'])
def moodle_auth():
"""
Moodle Delegated Authentication endpoint.
Validates user credentials against Moodle server (/login/token.php),
fetches Moodle profile, implements initial role mapping (if new user),
and caches profile in Redis.
"""
moodle_enabled = SystemSetting.get_value('auth_moodle_enabled', 'false').lower() in ('true', '1')
if not moodle_enabled:
return jsonify({'error': 'Forbidden', 'message': 'El inicio de sesión con Moodle no está habilitado.'}), 403
data = request.get_json(silent=True) or {}
username = data.get('username', '').strip()
password = data.get('password', '').strip()
if not username or not password:
return jsonify({'error': 'BadRequest', 'message': 'Usuario y contraseña de Moodle son obligatorios.'}), 400
server_url = SystemSetting.get_value('moodle_server_url', 'http://10.0.0.207/moodle').rstrip('/')
# Authenticate against Moodle login/token.php
token_url = f"{server_url}/login/token.php"
try:
resp = requests.post(token_url, data={
'username': username,
'password': password,
'service': 'moodle_mobile_app'
}, timeout=10)
res_data = resp.json()
except Exception as e:
logger.error(f"[MoodleAuth] Connection error to Moodle server: {e}")
return jsonify({'error': 'ServiceUnavailable', 'message': 'No se pudo conectar con el servidor de Moodle. Intente más tarde.'}), 503
if 'error' in res_data or 'token' not in res_data:
err_msg = res_data.get('error', 'Credenciales inválidas en Moodle.')
return jsonify({'error': 'Unauthorized', 'message': f'Moodle: {err_msg}'}), 401
moodle_user_token = res_data['token']
# Retrieve Moodle user profile via Web Services
moodle_profile = None
cache_key = f"moodle_user:{username.lower()}"
cached = cache_service.get(cache_key)
if cached:
moodle_profile = cached
else:
try:
m_users = moodle_client.get_users([{'key': 'username', 'value': username.lower()}])
if m_users and isinstance(m_users, list) and len(m_users) > 0:
moodle_profile = m_users[0]
cache_service.set(cache_key, moodle_profile, ttl_seconds=3600)
except Exception as e:
logger.warning(f"[MoodleAuth] Could not fetch extended Moodle profile: {e}")
email = moodle_profile.get('email') if moodle_profile else f"{username}@unicaba.edu.ar"
firstname = moodle_profile.get('firstname', username) if moodle_profile else username
lastname = moodle_profile.get('lastname', 'Moodle') if moodle_profile else 'Moodle'
# Local user lookup or creation
user = User.query.filter((User.email.ilike(email)) | (User.email.ilike(f"{username}@%"))).first()
if not user:
# Determine initial role: if username is admin or has manager role -> ADMIN, else Docente
role_name = 'ADMIN' if username.lower() in ('admin', 'manager') else 'Docente'
role_obj = Role.query.filter(Role.name.ilike(role_name)).first()
user = User(
email=email,
name=f"{firstname} {lastname}".strip(),
first_name=firstname,
last_name=lastname,
role=role_obj.name if role_obj else role_name,
role_id=role_obj.id if role_obj else None,
is_active=True
)
user.set_password(f"MoodleLinked_{username}")
db.session.add(user)
db.session.commit()
logger.info(f"[MoodleAuth] Created new local user for Moodle username '{username}' with role '{role_name}'.")
if not user.is_active:
return jsonify({'error': 'Unauthorized', 'message': 'Su cuenta en Edu-Space está desactivada.'}), 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)
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():
"""
Silent Access Token renewal using Refresh Token.
"""
data = request.get_json(silent=True)
if not isinstance(data, dict):
data = {}
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():
"""
Session logout: revokes active access token and clears 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('/change_password', methods=['POST'])
@api_auth_bp.route('/change-password', methods=['POST'])
@jwt_required
def change_password():
"""
Permite al usuario autenticado cambiar su propia contraseña institucional.
Valida la contraseña actual y la confirmación de la nueva clave.
"""
data = request.get_json(silent=True) or request.form.to_dict() or {}
current_password = data.get('current_password', '').strip()
new_password = data.get('new_password', '').strip()
confirm_password = data.get('confirm_password', '').strip()
if not current_password or not new_password:
return jsonify({
'error': 'BadRequest',
'message': 'Debe ingresar la contraseña actual y la nueva contraseña.'
}), 400
if new_password != confirm_password:
return jsonify({
'error': 'BadRequest',
'message': 'La nueva contraseña y su confirmación no coinciden.'
}), 400
if len(new_password) < 6:
return jsonify({
'error': 'BadRequest',
'message': 'La nueva contraseña debe tener al menos 6 caracteres.'
}), 400
user = g.jwt_user
if not user.check_password(current_password):
return jsonify({
'error': 'Unauthorized',
'message': 'La contraseña actual ingresada es incorrecta.'
}), 401
user.set_password(new_password)
db.session.commit()
logger.info(f"[Auth] Password successfully updated for user {user.email}")
return jsonify({
'status': 'success',
'message': 'Contraseña actualizada correctamente.'
}), 200
@api_auth_bp.route('/me', methods=['GET'])
@jwt_required
def get_current_user():
"""
Returns current authenticated profile and permissions.
"""
profile = user_service.get_profile_data(g.jwt_user)
return jsonify(profile), 200
+135
View File
@@ -0,0 +1,135 @@
from flask import Blueprint, request, jsonify, g
from pydantic import ValidationError
from app import db
from app.models.classroom import Classroom
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
@api_classrooms_bp.route('/<int:classroom_id>/toggle', methods=['POST'])
@jwt_role_required('ADMIN', 'ADMINISTRADOR', 'BEDELIA')
def toggle_classroom(classroom_id: int):
"""Alterna el estado activo/inactivo de un aula."""
classroom = Classroom.query.get_or_404(classroom_id)
classroom.is_active = not classroom.is_active
db.session.commit()
return jsonify({
'status': 'success',
'id': classroom.id,
'is_active': classroom.is_active,
'message': f'Aula {"activada" if classroom.is_active else "desactivada"} correctamente.'
}), 200
+342
View File
@@ -0,0 +1,342 @@
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
from app.models.setting import SystemSetting
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': getattr(a, 'action', getattr(a, 'action_type', 'AUDIT')),
'ip_address': a.ip_address,
'user_email': getattr(a, 'user_email', None) or (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]
# ---- Bedelía: Comisiones sin aula asignada (semana y mes) ----
import calendar
# Calcular rango de semana actual (lunes a domingo)
today_dt = datetime.utcnow()
week_start = today_dt - timedelta(days=today_dt.weekday()) # lunes
week_start = week_start.replace(hour=0, minute=0, second=0, microsecond=0)
week_end = week_start + timedelta(days=6, hours=23, minutes=59, seconds=59)
# Calcular rango del mes actual
month_start = today_dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
last_day = calendar.monthrange(today_dt.year, today_dt.month)[1]
month_end = today_dt.replace(day=last_day, hour=23, minute=59, second=59, microsecond=0)
# Comisiones activas totales
all_active_commissions = Commission.query.filter_by(active=True).all()
total_active_commissions = len(all_active_commissions)
# IDs de comisiones que SÍ tienen reserva en la semana
comm_ids_with_res_week = set(
r[0] for r in db.session.query(Reservation.commission_id).filter(
Reservation.commission_id.isnot(None),
Reservation.start_time >= week_start,
Reservation.start_time <= week_end
).distinct().all()
)
# IDs de comisiones que SÍ tienen reserva en el mes
comm_ids_with_res_month = set(
r[0] for r in db.session.query(Reservation.commission_id).filter(
Reservation.commission_id.isnot(None),
Reservation.start_time >= month_start,
Reservation.start_time <= month_end
).distinct().all()
)
unassigned_commissions_week = []
unassigned_commissions_month = []
for comm in all_active_commissions:
comm_data = {
'id': comm.id,
'code': comm.code,
'full_code': comm.get_full_code(),
'subject_name': comm.subject.name if comm.subject else 'Sin materia',
'subject_code': comm.subject.code if comm.subject else '',
'teacher_name': comm.teacher_name,
'schedule': comm.schedule or 'Sin horario',
'shift': comm.shift or 'Sin turno',
'max_students': comm.max_students,
'current_students': comm.current_students
}
if comm.id not in comm_ids_with_res_week:
unassigned_commissions_week.append(comm_data)
if comm.id not in comm_ids_with_res_month:
unassigned_commissions_month.append(comm_data)
# ---- Bedelía: Aulas disponibles (sin reservas) en la semana y mes ----
classroom_ids_used_week = set(
r[0] for r in db.session.query(Reservation.classroom_id).filter(
Reservation.classroom_id.isnot(None),
Reservation.start_time >= week_start,
Reservation.start_time <= week_end
).distinct().all()
)
classroom_ids_used_month = set(
r[0] for r in db.session.query(Reservation.classroom_id).filter(
Reservation.classroom_id.isnot(None),
Reservation.start_time >= month_start,
Reservation.start_time <= month_end
).distinct().all()
)
available_classrooms_week = []
available_classrooms_month = []
for c in all_active_classrooms:
c_data = {
'id': c.id,
'code': c.code,
'building': c.building or 'Sin edificio',
'floor': c.floor or '',
'capacity': c.capacity,
'is_virtual': getattr(c, 'is_virtual', False) or 'virtual' in (c.building or '').lower() or 'virtual' in (c.code or '').lower()
}
if c.id not in classroom_ids_used_week:
available_classrooms_week.append(c_data)
if c.id not in classroom_ids_used_month:
available_classrooms_month.append(c_data)
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 = []
unassigned_commissions_week = []
unassigned_commissions_month = []
available_classrooms_week = []
available_classrooms_month = []
total_active_commissions = 0
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,
'total_active_commissions': total_active_commissions
},
'audit_logs': audit_data,
'monthly_data': monthly_data,
'pending_reservations': pending_reservations,
'today_schedule_list': today_schedule_list,
'upcoming_reservations': upcoming_reservations,
'milestones': milestones,
'ui': {
'show_role_banner': SystemSetting.get_value('dashboard_role_banner_enabled', 'true') in ['true', 'True', '1', True]
},
'bedelia': {
'unassigned_commissions_week': unassigned_commissions_week,
'unassigned_commissions_month': unassigned_commissions_month,
'available_classrooms_week': available_classrooms_week,
'available_classrooms_month': available_classrooms_month
}
}), 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
+242
View File
@@ -0,0 +1,242 @@
from flask import Blueprint, jsonify
api_openapi_bp = Blueprint('api_openapi', __name__, url_prefix='/api/v1')
OPENAPI_SPEC = {
"openapi": "3.0.3",
"info": {
"title": "Edu-Space REST API",
"description": "Enterprise Academic & Physical Space Management REST API with JWT Authentication and Role-Based Access Control.",
"version": "1.0.0"
},
"servers": [
{
"url": "http://127.0.0.1:5000",
"description": "Local Flask Backend Server"
}
],
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
},
"schemas": {
"LoginRequest": {
"type": "object",
"required": ["email", "password"],
"properties": {
"email": {
"type": "string",
"format": "email",
"example": "admin@eduspace.com"
},
"password": {
"type": "string",
"format": "password",
"example": "Admin123!"
}
}
},
"LoginResponse": {
"type": "object",
"properties": {
"access_token": {"type": "string"},
"refresh_token": {"type": "string"},
"token_type": {"type": "string", "example": "Bearer"},
"expires_in": {"type": "integer"},
"user": {"type": "object"}
}
},
"ErrorResponse": {
"type": "object",
"properties": {
"error": {"type": "string"},
"message": {"type": "string"}
}
},
"UserResponse": {
"type": "object",
"properties": {
"id": {"type": "integer"},
"email": {"type": "string"},
"first_name": {"type": "string"},
"last_name": {"type": "string"},
"role": {"type": "string"},
"is_active": {"type": "boolean"}
}
},
"SubjectResponse": {
"type": "object",
"properties": {
"id": {"type": "integer"},
"name": {"type": "string"},
"code": {"type": "string"},
"career": {"type": "string"}
}
},
"ClassroomResponse": {
"type": "object",
"properties": {
"id": {"type": "integer"},
"name": {"type": "string"},
"capacity": {"type": "integer"},
"building": {"type": "string"},
"floor": {"type": "string"}
}
}
}
},
"paths": {
"/api/v1/auth/login": {
"post": {
"summary": "Authenticate user and issue JWT tokens",
"tags": ["Authentication"],
"requestBody": {
"required": True,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LoginRequest"
}
}
}
},
"responses": {
"200": {
"description": "Authentication successful",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LoginResponse"
}
}
}
},
"400": {
"description": "Validation error",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/ErrorResponse"}
}
}
},
"401": {
"description": "Unauthorized / Bad credentials",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/ErrorResponse"}
}
}
}
}
}
},
"/api/v1/auth/me": {
"get": {
"summary": "Get authenticated user profile",
"tags": ["Authentication"],
"security": [{"bearerAuth": []}],
"responses": {
"200": {
"description": "Current user profile",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/UserResponse"}
}
}
},
"401": {
"description": "Missing or invalid token",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/ErrorResponse"}
}
}
}
}
}
},
"/api/v1/classrooms": {
"get": {
"summary": "List all physical classrooms and facilities",
"tags": ["Classrooms"],
"security": [{"bearerAuth": []}],
"responses": {
"200": {
"description": "List of classrooms",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {"$ref": "#/components/schemas/ClassroomResponse"}
}
}
}
}
}
}
},
"/api/v1/admin/users": {
"get": {
"summary": "List all users (Admin only)",
"tags": ["Admin - Users"],
"security": [{"bearerAuth": []}],
"responses": {
"200": {
"description": "List of users",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {"$ref": "#/components/schemas/UserResponse"}
}
}
}
},
"403": {
"description": "Forbidden - Requires admin role"
}
}
}
},
"/api/v1/admin/subjects": {
"get": {
"summary": "List all academic subjects (Admin only)",
"tags": ["Admin - Academic"],
"security": [{"bearerAuth": []}],
"responses": {
"200": {
"description": "List of subjects",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {"$ref": "#/components/schemas/SubjectResponse"}
}
}
}
}
}
}
},
"/api/v1/openapi.json": {
"get": {
"summary": "Get OpenAPI 3.0.3 specification JSON",
"tags": ["Documentation"],
"responses": {
"200": {
"description": "OpenAPI Specification"
}
}
}
}
}
}
@api_openapi_bp.route('/openapi.json', methods=['GET'])
def get_openapi_spec():
"""Serves the OpenAPI 3.0 specification for Schemathesis / DAST and Swagger UI."""
return jsonify(OPENAPI_SPEC)
+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
+314
View File
@@ -0,0 +1,314 @@
from flask import Blueprint, request, jsonify, g
from datetime import datetime, timedelta, date
from pydantic import ValidationError
from app import db
from app.models.reservation import Reservation
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 o elimina una reserva según el parámetro hard."""
hard_delete = request.args.get('hard', '').lower() in ['true', '1']
if hard_delete:
res = Reservation.query.get(reservation_id)
if not res:
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
db.session.delete(res)
db.session.commit()
return jsonify({'message': f'Reserva {reservation_id} eliminada permanentemente.'}), 200
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>/delete', methods=['POST'])
@jwt_required
def delete_reservation_post(reservation_id: int):
"""Elimina permanentemente una reserva."""
res = Reservation.query.get(reservation_id)
if not res:
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
db.session.delete(res)
db.session.commit()
return jsonify({'message': f'Reserva {reservation_id} eliminada permanentemente.'}), 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
@api_reservations_bp.route('/check-conflicts', methods=['POST'])
@jwt_required
def check_conflicts_endpoint():
"""
Evalúa la matriz completa de conflictos (físico, docente y aforo) en tiempo real.
"""
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({'error': 'BadRequest', 'message': 'El cuerpo de la solicitud debe ser un objeto JSON.'}), 400
classroom_id = data.get('classroom_id')
start_str = data.get('start_time')
end_str = data.get('end_time')
if not classroom_id or not start_str or not end_str:
return jsonify({'error': 'BadRequest', 'message': 'classroom_id, start_time y end_time son obligatorios.'}), 400
try:
start_time = datetime.fromisoformat(str(start_str).replace('Z', '+00:00'))
end_time = datetime.fromisoformat(str(end_str).replace('Z', '+00:00'))
except ValueError:
return jsonify({'error': 'BadRequest', 'message': 'Formato inválido de fecha/hora (ISO 8601 esperado).'}), 400
teacher_id = data.get('teacher_id')
commission_id = data.get('commission_id')
expected_attendees = data.get('expected_attendees')
allow_co_teaching = bool(data.get('allow_co_teaching_exception', False))
exclude_id = data.get('exclude_id')
try:
matrix = reservation_service.check_full_conflicts_matrix(
classroom_id=int(classroom_id),
start_time=start_time,
end_time=end_time,
teacher_id=int(teacher_id) if teacher_id else None,
commission_id=int(commission_id) if commission_id else None,
expected_attendees=int(expected_attendees) if expected_attendees is not None else None,
allow_co_teaching_exception=allow_co_teaching,
exclude_id=int(exclude_id) if exclude_id else None
)
return jsonify(matrix), 200
except ValueError as e:
return jsonify({'error': 'ValidationError', 'message': str(e)}), 400
+43
View File
@@ -0,0 +1,43 @@
from flask import Blueprint, jsonify, request, g
from app.utils.jwt_decorators import jwt_required
from app.services.gradebook_service import GradebookService
api_students_bp = Blueprint('api_students', __name__)
@api_students_bp.route('/students/my-grades', methods=['GET'])
@jwt_required
def get_my_grades():
"""Consulta individual de calificaciones y estado académico del alumno logueado."""
user = getattr(g, 'jwt_user', None)
user_id = user.id if user else getattr(request, 'current_user_id', None)
if not user_id:
return jsonify({'error': 'Unauthorized', 'message': 'Usuario no autenticado.'}), 401
try:
grades = GradebookService.get_student_grades(user_id)
return jsonify({
'status': 'success',
'student_id': user_id,
'is_impersonating': getattr(g, 'is_impersonating', False),
'data': grades
}), 200
except Exception as e:
return jsonify({'error': 'ServerError', 'message': str(e)}), 500
@api_students_bp.route('/students/my-upcoming-milestones', methods=['GET'])
@jwt_required
def get_my_upcoming_milestones():
"""Próximas evaluaciones e hitos académicos con cuenta regresiva para el dashboard."""
user = getattr(g, 'jwt_user', None)
user_id = user.id if user else getattr(request, 'current_user_id', None)
if not user_id:
return jsonify({'error': 'Unauthorized', 'message': 'Usuario no autenticado.'}), 401
limit = request.args.get('limit', default=5, type=int)
try:
milestones = GradebookService.get_student_upcoming_milestones(user_id, limit=limit)
return jsonify({'status': 'success', 'data': milestones}), 200
except Exception as e:
return jsonify({'error': 'ServerError', 'message': str(e)}), 500
+152
View File
@@ -0,0 +1,152 @@
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'))
from app.models.setting import SystemSetting
demo_access_enabled = SystemSetting.get_value('demo_access_enabled', 'true') in ['true', 'True', '1', True]
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, demo_access_enabled=demo_access_enabled)
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, demo_access_enabled=demo_access_enabled)
@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')
+196
View File
@@ -0,0 +1,196 @@
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 (excluye aulas virtuales con 9999)
total_classrooms = Classroom.query.filter_by(is_active=True).count()
physical_classrooms = [c for c in Classroom.query.filter_by(is_active=True).all() if not getattr(c, 'is_virtual', False) and (c.capacity or 0) < 999 and 'VIRTUAL' not in (c.code or '').upper()]
total_physical_capacity = sum(c.capacity for c in physical_classrooms)
total_capacity = total_physical_capacity
return render_template(
'buildings/list.html',
buildings=buildings,
total_classrooms=total_classrooms,
total_capacity=total_capacity,
total_physical_capacity=total_physical_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
@@ -1,15 +1,15 @@
from flask import Blueprint, request, jsonify, current_app, render_template
from flask import Blueprint, request, jsonify, current_app, render_template, g
from datetime import datetime, timedelta
from app.models.genetic_algorithm import ReservationOptimizer
from app.models.reservation import Reservation, ReservationStatus
from app import db
from flask_login import login_required, current_user
from app.utils.jwt_decorators import jwt_required
genetic_bp = Blueprint('genetic_algorithm', __name__, url_prefix='/api/genetic')
@genetic_bp.route('/optimize', methods=['POST'])
@login_required
@jwt_required
def optimize_reservations():
"""Optimize reservations using genetic algorithm"""
try:
@@ -39,15 +39,15 @@ def optimize_reservations():
except ValueError:
return jsonify({'error': 'Invalid end_date format. Use ISO format.'}), 400
# Check if user has permission (admin or teacher)
if current_user.role not in ['ADMIN', 'teacher']:
# Check if user has permission (admin, teacher or RBAC optimizer write)
if not (g.jwt_user.is_admin() or g.jwt_user.has_permission('optimizer', 'read_write') or g.jwt_user.role in ['ADMIN', 'teacher']):
return jsonify({'error': 'Insufficient permissions to optimize reservations'}), 403
# Initialize optimizer and run optimization
optimizer = ReservationOptimizer()
result = optimizer.optimize_schedule(
commission_ids=commission_ids,
admin_user_id=current_user.id,
admin_user_id=g.jwt_user.id,
start_date=start_date,
end_date=end_date
)
@@ -69,7 +69,7 @@ def optimize_reservations():
@genetic_bp.route('/apply-optimization', methods=['POST'])
@login_required
@jwt_required
def apply_optimization():
"""Apply optimized reservations to the database"""
try:
@@ -84,7 +84,7 @@ def apply_optimization():
return jsonify({'error': 'reservations must be a list'}), 400
# Check if user has permission
if current_user.role not in ['ADMIN', 'teacher']:
if not (g.jwt_user.is_admin() or g.jwt_user.has_permission('optimizer', 'read_write') or g.jwt_user.role in ['ADMIN', 'teacher']):
return jsonify({'error': 'Insufficient permissions to apply reservations'}), 403
# Initialize optimizer and apply reservations
@@ -106,7 +106,7 @@ def apply_optimization():
@genetic_bp.route('/preview-optimization', methods=['POST'])
@login_required
@jwt_required
def preview_optimization():
"""Preview optimization without applying to database"""
try:
@@ -124,14 +124,14 @@ def preview_optimization():
return jsonify({'error': 'commission_ids must be a non-empty list'}), 400
# Check if user has permission
if current_user.role not in ['ADMIN', 'teacher']:
if g.jwt_user.role not in ['ADMIN', 'teacher']:
return jsonify({'error': 'Insufficient permissions to preview optimization'}), 403
# Initialize optimizer and run optimization
optimizer = ReservationOptimizer()
result = optimizer.optimize_schedule(
commission_ids=commission_ids,
admin_user_id=current_user.id
admin_user_id=g.jwt_user.id
)
# Add additional preview information
@@ -166,12 +166,12 @@ def preview_optimization():
@genetic_bp.route('/commissions', methods=['GET'])
@login_required
@jwt_required
def get_commissions():
"""Get available commissions for optimization"""
try:
# Check if user has permission
if current_user.role not in ['ADMIN', 'teacher']:
if g.jwt_user.role not in ['ADMIN', 'teacher']:
return jsonify({'error': 'Insufficient permissions'}), 403
from app.models.subject import Commission, Subject
@@ -196,12 +196,12 @@ def get_commissions():
@genetic_bp.route('/algorithm-status', methods=['GET'])
@login_required
@jwt_required
def get_algorithm_status():
"""Get genetic algorithm configuration status"""
try:
# Check if user has permission
if current_user.role not in ['ADMIN']:
if g.jwt_user.role not in ['ADMIN']:
return jsonify({'error': 'Insufficient permissions'}), 403
# Get system status
@@ -246,10 +246,10 @@ genetic_web_bp = Blueprint('genetic_algorithm_web', __name__)
@genetic_web_bp.route('/genetic-optimizer')
@login_required
@jwt_required
def genetic_optimizer():
"""Render the genetic algorithm optimizer interface"""
if current_user.role not in ['ADMIN']:
if not (g.jwt_user.has_permission('optimizer', 'read') or (g.jwt_user.role and g.jwt_user.role.upper() == 'ADMIN')):
return render_template('auth/403.html'), 403
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

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