57 Commits
Author SHA1 Message Date
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
281 changed files with 34141 additions and 14061 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
+1 -1
View File
@@ -4,7 +4,7 @@
# ============================================================================== # ==============================================================================
# Entorno Flask # Entorno Flask
FLASK_APP=app.py FLASK_APP=wsgi.py
FLASK_ENV=production FLASK_ENV=production
# Seguridad: Clave secreta para sesiones y protección CSRF # Seguridad: Clave secreta para sesiones y protección CSRF
+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
+15
View File
@@ -57,6 +57,9 @@ cover/
*.mo *.mo
*.pot *.pot
# Examples
example/
# Django stuff: # Django stuff:
*.log *.log
local_settings.py local_settings.py
@@ -207,3 +210,15 @@ cython_debug/
marimo/_static/ marimo/_static/
marimo/_lsp/ marimo/_lsp/
__marimo__/ __marimo__/
# Node.js / Frontend
node_modules/
npm-debug.log
.env
.env.*
!.env.example
frontend/.env
legacy_admin-edu-space/
.vscode/
.schemathesis/
+188
View File
@@ -0,0 +1,188 @@
# 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 3 del Roadmap MVP)
- **Calendario de Evaluación por Comisión:** Programación de parciales, recuperatorios, entregas de trabajos prácticos y exámenes finales con cuenta regresiva.
- **Libro de Calificaciones (*Gradebook*):** Matriz de carga rápida de notas para docentes, consulta pedagógica individual para alumnos y cierre de actas para Bedelía.
- **Grilla Semanal Drag & Drop (Fase 4):** Asignación visual interactiva para la oficina de Bedelía.
---
## [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.
+52
View File
@@ -0,0 +1,52 @@
# CHANGELOG MVP — Admin Edu-Space (UniCABA)
Registro de hitos y versiones correspondientes a las Fases del [ROADMAP_MVP.md](./ROADMAP_MVP.md).
---
## [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.
-253
View File
@@ -1,253 +0,0 @@
# Resumen de Cambios - Sesión de Desarrollo (Rama `dev`)
**Fecha:** 2026-09-02 / 2026-09-03
**Proyecto:** Admin Edu-Space (Flask + PostgreSQL)
**Rama:** `dev`
---
## 1. Auditoría Git y Puesta en Producción
* **Informe Técnico Completo (`DEPLOYMENT_AUDIT.md`):**
* Análisis de ramas locales y remotas (`main`, `dev`, `origin/planning`, `origin/commissions`, `origin/spanish`).
* Manual de despliegue en producción con arquitectura WSGI (Gunicorn), Nginx como proxy inverso, Systemd unit y contenedorización Docker / Docker Compose.
* **Archivos Base:**
* Creación de `.env.example` con todas las variables requeridas (PostgreSQL, Flask, Secret Key, Babel).
* Creación y posterior actualización de `init_db.py` para bootstrap inicial seguro de base de datos, roles RBAC y usuario administrador por defecto (`admin@edu-space.com` / `admin123`).
---
## 2. Base de Datos y Modelos
* **Preferencia de Idioma Persistente:**
* Migración en PostgreSQL agregando la columna `preferred_language VARCHAR(10)` a la tabla `users`.
* Actualización del modelo `User` en `app/models/user.py` con el campo `preferred_language`.
* **Preferencia de Tema Visual Persistente:**
* Migración agregando la columna `theme_preference VARCHAR(10) DEFAULT 'auto' NOT NULL` a la tabla `users`.
* Soporte en el modelo `User` para valores `'auto'`, `'light'` y `'dark'`.
* **Control de Acceso Basado en Roles (RBAC):**
* Migración creando las tablas `roles` y `permissions`, y agregando la clave foránea `role_id` a la tabla `users`.
* Modelos `Role` y `Permission` en `app/models/role.py`.
* Métodos `has_permission(module, min_level)` y `can_manage()` en `User`.
---
## 3. Implementación de Vistas y Plantillas
* **Perfil de Usuario (`app/templates/auth/profile.html`):**
* Vista completa con datos del usuario, rol, estado, fecha de registro y reservas recientes.
* Formulario integral para configurar **Idioma de la interfaz** (*Detectar automáticamente del navegador*, *Español*, *English*) y **Tema visual** (*Automático*, *Modo Claro*, *Modo Oscuro*).
* **Cambio de Contraseña (`app/templates/auth/change_password.html`):**
* Formulario seguro para actualización de credenciales.
* **Manejo de Errores (`app/templates/auth/403.html`):**
* Pantalla de error 403 Forbidden estilizada e integrada con el sistema de diseño.
---
## 4. Correcciones de UX, Linter y Bugs de Rutas
* **Navbar Limpio (`app/templates/base.html`):**
* Eliminación del bloque duplicado del menú de usuario `System Administrator`.
* **Corrección de BuildError en Comisiones:**
* En `app/routes/classrooms.py`, se agregó el alias de ruta `@classrooms_bp.route('/', endpoint='index')` para resolver enlaces a `classrooms.index`.
* En `app/templates/schedule/commissions.html`, se reemplazó el filtro inválido de Django `truncatewords(20)` por el filtro estándar de Jinja2 `truncate(100)`.
* **Corrección Visual en `/genetic-optimizer` (`app/templates/genetic_optimizer.html`):**
* Se agregó el espaciado `pt-5 mt-4` evitando que la barra fija superior se monte sobre el contenido.
* Rediseño de tarjeta hero con contraste óptimo y reemplazo de iconos faltantes por Bootstrap Icons (`bi bi-...`).
* **Corrección de Errores del Linter en `schedule/add.html` y `schedule/view.html`:**
* En `add.html`: Desacople de iteraciones Jinja a `<script type="application/json" id="classroom-data">` y parseo con `JSON.parse()`, eliminando 19 errores sintácticos del editor.
* En `view.html`: Formateo de la propiedad inline `style` en barras de progreso para cumplir con el validador CSS.
---
## 5. Rediseño UI/UX, Assets Institucionales y Modo Claro/Oscuro
* **Exploración e Integración de Assets Gráficos:**
* Sincronización desde `app/resources` hacia `app/static/img/`:
* `logo_unicaba.png`: Logo institucional principal para el login.
* `logo_unicaba_mini.png`: Isotipo oficial para el navbar.
* `favicon.png`: Favicon vinculado en las plantillas base.
* `icons/`: Paquete de resoluciones WebApp/PWA.
* **Sistema de Diseño y Tokens de Color (`app/static/css/theme.css`):**
* Paleta institucional basada en Magenta/Uva (`#B43E8E`), acentos violetas (`#682CAD` / `#AE7FFF`) y secundarios magentas.
* Tokens CSS para `:root` (Modo Claro) y `[data-bs-theme="dark"]` (Modo Oscuro).
* Clases especializadas: `.auth-card`, `.navbar-app`, `.user-avatar-small`, `.theme-toggle-btn`, `.badge-brand`.
* **Modo Claro / Modo Oscuro Dinámico (`app/static/js/theme-toggle.js`):**
* Script anti-flickering en `<head>` para evitar parpadeos visuales al cargar la página.
* Botón toggle interactivo con persistencia en `localStorage`.
* En Modo Claro: Isotipo del navbar con filtro blanco puro (`brightness(0) invert(1)`) sobre el fondo magenta.
* Selector de idioma (`#languageDropdown`) con fondo y bordes de alto contraste y tilde de verificación blanca (`bi-check-lg`) visible **únicamente** en el idioma activo sobre gradiente magenta.
---
## 6. Internacionalización (i18n), Localización y Formateo de Fechas
* **Detección Inteligente de Subtags Regionales (`get_locale` en `app/__init__.py`):**
* Extracción del subtag primario (`es` a partir de `es-419`, `es-AR`, `es-ES`), resolviendo el fallo donde Werkzeug saltaba erróneamente a inglés.
* Configuración institucional: `BABEL_DEFAULT_LOCALE = 'es'` y `LANGUAGES = ['es', 'en']`.
* **Reglas de Idioma para la Pantalla de Login:**
* Resuelve según el navegador del visitante o recurre a español por defecto.
* Inmunidad contra cookies o sesiones residuales de otros usuarios.
* Flash messages traducidos (`¡Bienvenido de nuevo!`, `Has cerrado sesión correctamente`).
* **Formateador de Fechas Localizadas en Jinja2:**
* Registro de `format_date` y `format_datetime` de Flask-Babel.
* Fechas en español natural en `schedule/today.html` (`Jueves, 3 de septiembre de 2026`), `view.html` y `list.html`.
* **Traducción Integral de `schedule/add.html`:**
* Formulario y textos de ayuda 100% traducidos.
* Caja dinámica de resumen (JavaScript e `i18n-strings`) con cálculo de duración y validación de sobrecupo en español.
---
## 7. Módulo de Gestión de Accesos (RBAC) con Matriz FortiGate
* **Modelos y Persistencia:**
* Módulo `app/models/role.py` con `Role` y `Permission`.
* Roles iniciales sembrados: `Admin` (total, protegido), `Docente` (reservas y horarios), `Operador` (aulas y reservas), `Consulta` (solo lectura).
* Vinculación del usuario inicial `admin@edu-space.com` al rol `Admin`.
* **Matriz de Permisos Estilo FortiGate (`/admin/roles/edit/<id>`):**
* Tabla interactiva con columnas **Access Control** y **Permissions**.
* Segmented control por módulo con tres estados: `🚫 None`, `👁️ Read` y `✏️ Read/Write`.
* Color activo verde esmeralda FortiGate (`#2D9F6F`) con texto e icono en blanco puro.
* Menú desplegable masivo **Set All ▾** (*Set All to None*, *Set All to Read*, *Set All to Read/Write*).
* **Módulo de Administración (`app/routes/admin.py`):**
* `/admin/roles`: Listado de roles, conteo de miembros, visualización de permisos en badges (RW, R, -) y modal de borrado seguro.
* `/admin/users`: Listado de cuentas con filtros por nombre, rol y estado (Activo/Inactivo), paginación y switch rápido para alternar accesos.
* `/admin/users/add` y `/admin/users/edit/<id>`: Formularios de alta y edición con asignación de rol y contraseña opcional.
* **Seguridad y Decoradores (`app/utils/decorators.py`):**
* `@permission_required(module, min_level)` y `@admin_required`.
* Pestaña **Management** en el navbar visible exclusivamente para administradores.
* Botones de acción sensibles en el navbar (*Add Classroom*, *New Reservation*) condicionados a permisos de escritura.
---
## 8. Sincronización y Actualización de `init_db.py`
* Migraciones DDL automáticas (`ALTER TABLE ... ADD COLUMN IF NOT EXISTS`) para `preferred_language`, `theme_preference`, `role_id`, y para la tabla `careers` y columna `career_id` en `subjects`.
* Creación y siembra de roles institucionales y permisos en PostgreSQL.
* Configuración de salida con codificación UTF-8 segura en Windows para evitar excepciones de `cp1252`.
---
## 9. Sincronización e Importación de Datos Académicos (Google Sheets)
* **Modelo `Career` (`app/models/career.py`):**
* Entidad de carreras universitarias vinculada con `Subject` vía relación uno a muchos.
* Migración en PostgreSQL de la tabla `careers` y campo `career_id` en `subjects`.
* **Servicio de Extracción ([app/services/sheets_importer.py](file:///c:/Workspace/admin-edu-space/app/services/sheets_importer.py)):**
* Integración con la cartelera oficial UniCABA 2026 a través de 6 hojas CSV (`Lunes` a `Sábado`).
* Normalización inteligente de espacios áulicos físicos (`Aula 402`, `Aula 302`), espacios virtuales (`VIRTUAL`), auditorios y salas de reunión.
* Extracción y parseo de franjas horarias (`8:00 a 9:00hs`, `17:30 a 21:30hs`).
* Rutina de **upsert** transaccional e idempotente que previene la duplicación de datos.
* Generación de reservas/clases en el calendario para poblar instantáneamente las vistas de horario diario y semanal.
* **Interfaz de Gestión (`/admin/import`):**
* Vista interactiva con métricas en tiempo real (total de carreras, asignaturas, aulas, comisiones y clases).
* Botón de ejecución manual de sincronización con indicador visual de progreso (`spinner`).
* Reporte detallado de resultados y tabla con vista previa de asignaturas y comisiones sincronizadas.
* **Acceso Rápido en Navbar (`app/templates/base.html`):**
* Nueva opción **"Import from Google Sheets"** incorporada en el menú desplegable **Management**, restringida a roles con permisos administrativos.
---
## 10. Correcciones en la Vista de Calendario (FullCalendar) y Códigos de Asignatura
* **Carga de Eventos y Corrección de Error 500 (`/schedule/calendar-data`):**
* Se identificó y corrigió el error `AttributeError: 'str' object has no attribute 'value'` provocado por llamadas a `.status.value` sobre campos de estado textuales.
* Se implementó el TypeDecorator `StatusType` con la clase `StatusString` en `app/models/reservation.py` garantizando compatibilidad total tanto para `.status` como para `.status.value`.
* Sanitización de fechas ISO 8601 con zona horaria (`Z`, `-03:00`) enviadas por FullCalendar para comparación adecuada con campos `TIMESTAMP WITHOUT TIME ZONE` en PostgreSQL.
* Anclaje de fecha inicial con `initialDate: '{{ today_date }}'` para posicionar el calendario en la semana activa de cursada 2026.
* **Corrección del Código de Asignatura (`subject_code`):**
* Se reemplazó la concatenación de código extendido de comisión (`Commission.get_full_code()` que arrojaba cadenas distorsionadas como `INGA0003-C1-2026-22026`) por el **código de asignatura real** del Sheet (`Subject.code`, ej. `INGA0003`, `ASIG00165`).
* Propiedades de acceso directo añadidas en `Reservation`: `subject_code`, `subject_name` y `classroom_name`.
* **Jerarquía Visual y Renderizado de Tarjetas (`calendar.html`):**
* Implementación del hook `eventContent` en FullCalendar 5 para construir tarjetas personalizadas.
* **Nombre de la Asignatura** destacado como elemento principal con tipografía grande y en negrita (`font-weight: 700; font-size: 0.88rem`).
* **Código de Asignatura** y **Aula** mostrados como datos secundarios en badges de alto contraste (código con fondo oscuro translúcido y aula con isotipo y fondo blanco translúcido).
* Modal interactivo instantáneo al hacer clic en cualquier evento con todos los detalles académicos de la clase y enlace a la ficha completa.
---
## 11. Filtros por Aula y Materia en el Calendario de Reservas
* **Barra de Herramientas de Filtros Interactiva (`calendar.html`):**
* **Filtrar por Aula:** Desplegable dinámico con todas las aulas activas ordenadas por edificio y código (físicas y virtuales). Al seleccionar un aula (ej. `Aula 402`), se eliminan todas las superposiciones visuales mostrando el cronograma limpio de ese espacio.
* **Filtrar por Materia:** Desplegable ordenado alfabéticamente con formato `[Código] Nombre de la Asignatura`. Permite aislar todas las clases de una asignatura en particular.
* **Filtrar por Carrera:** Filtro adicional por plan académico/carrera.
* **Botón Limpiar Filtros:** Restablece instantáneamente todos los controles y refresca la vista.
* **Insignias de Estado:** Indicador en tiempo real de los filtros aplicados y contador de clases visibles (`X clases visibles`).
* **Soporte en Backend (`app/routes/schedule.py`):**
* `calendar_view`: Suministra las listas de `classrooms`, `subjects` y `careers` a la plantilla.
* `calendar_data`: Procesa `classroom_id`, `subject_id`, `career_id` y `search` aplicando cláusulas `filter()` con `JOIN` en SQLAlchemy.
* `refetchEvents()` de FullCalendar conectado a los eventos `change` de cada selector para recarga inmediata vía AJAX sin parpadeo de pantalla.
---
## 12. Módulo de Gestión de Edificios y Corrección de Vistas de Aulas
* **Corrección de Plantillas de Aulas Faltantes:**
* Se crearon las plantillas [app/templates/classrooms/edit.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/edit.html) y [app/templates/classrooms/view.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/view.html), solucionando el error HTTP 500 (`TemplateNotFound: classrooms/edit.html`) y la redirección 302 que ocurría al hacer clic en cualquier aula.
* La vista de ficha técnica del aula ahora muestra detalles de capacidad, piso, estado, edificio y la tabla interactiva de próximas reservas.
* **Modelo y Base de Datos de Edificios (`Building`):**
* Se creó el modelo [app/models/building.py](file:///c:/Workspace/admin-edu-space/app/models/building.py) (`id`, `name`, `code`, `address`, `floors`, `description`, `is_active`, timestamps y relación con `Classroom`).
* Migración DDL en PostgreSQL (`CREATE TABLE buildings`, `ALTER TABLE classrooms ADD COLUMN building_id`).
* Actualización de [init_db.py](file:///c:/Workspace/admin-edu-space/init_db.py) y siembra de edificios institucionales iniciales (`Edificio Central` y `Campus Virtual`) con vinculación de las 25 aulas existentes.
* **Rutas y Gestión Completa (`app/routes/buildings.py`):**
* `/buildings`: Listado con métricas de infraestructura (total de edificios, aulas activas, capacidad total y plantas/pisos), barra de búsqueda y filtros por estado.
* `/buildings/<id>`: Vista de detalle con distribución de aulas organizadas visualmente por piso (Planta Baja, Piso 1, Piso 2, etc.), capacidad por sala y acciones directas.
* `/buildings/add` y `/buildings/<id>/edit`: Formularios con validación WTForms para alta y actualización.
* `/buildings/<id>/toggle` y `/buildings/<id>/delete`: Control de activación y eliminación protegida (impide borrar edificios que contengan aulas asignadas).
* **Navegación (`app/templates/base.html`):**
* Enlace a **Edificios** (`/buildings`) incorporado en el menú **Classrooms** y en el menú administrativo **Management**.
---
## 13. Mapeo de Campos del Google Sheet y Concepto de Turno (Mañana / Tarde)
* **Uso de los 5 Campos de la Cartelera Oficial:**
* **Codigo (`Subject.code` y `Commission.code`):** Almacena el código académico real (`INGA0003`, `ASIG00165`). Se expone como badge monoespaciado en eventos de calendario, comisiones y vistas administrativas.
* **Asignatura (`Subject.name`):** Título principal de la materia destacado en texto grande y en negrita en FullCalendar y en la cartelera diaria.
* **Aula (`Classroom.code`, `Classroom.building`, `Classroom.floor`, `Classroom.capacity`):** Normalización de espacios físicos y virtuales vinculados al nuevo modelo `Building`.
* **Carrera (`Career.name` y `Subject.career_id`):** Entidad de carrera universitaria con relaciones relacionales normalizadas.
* **Horarios (`Commission.schedule`, `Reservation.start_time`, `Reservation.end_time`):** Rango horario parseado hacia objetos `datetime` nativos para el motor de reservas y el calendario.
* **Modelado y Captura de Turnos (`TURNO MAÑANA` y `TURNO VESPERTINO`):**
* Se agregó la columna `shift` en las tablas `commissions` y `reservations` de PostgreSQL y en sus respectivos modelos de SQLAlchemy.
* El parser de [app/services/sheets_importer.py](file:///c:/Workspace/admin-edu-space/app/services/sheets_importer.py) identifica las filas delimitadoras de bloque (`TURNO MAÑANA` vs `TURNO VESPERTINO / TARDE`) y corrobora la hora de inicio (< 13:00hs = Mañana, $\ge$ 14:00hs = Tarde).
* Se sincronizaron todas las comisiones y reservas de cursada: 15 comisiones y 48 reservas en `Turno Mañana`; 191 comisiones y 364 reservas en `Turno Tarde / Vespertino`.
* En el Calendario de Reservas (`/schedule/calendar`) se añadió el selector **Filtrar por Turno** (`Todos`, `Mañana`, `Tarde / Vespertino`), permitiendo a los usuarios aislar las franjas horarias y resolver la alta concentración de clases en el horario vespertino (17:30 a 21:30hs).
---
## 14. Accesos Directos en Panel, Ocultamiento de Virtuales, Filtro por Piso, Dirección Oficial y Traducción
* **Tarjetas Estadísticas como Accesos Directos Interactivos ([app/templates/dashboard.html](file:///c:/Workspace/admin-edu-space/app/templates/dashboard.html)):**
* **Total de Aulas (25):** Redirige a `/classrooms/list` con feedback visual de hover y elevación.
* **Total de Materias (206):** Redirige a `/schedule/commissions`.
* **Reservas de Hoy (18):** Redirige a `/schedule/today`.
* **Esta Semana (103):** Redirige a `/schedule/calendar`.
* Se añadieron microinteracciones CSS en [app/static/css/style.css](file:///c:/Workspace/admin-edu-space/app/static/css/style.css) (`.dashboard-stat-card`).
* **Ocultar Aulas Virtuales y Filtro por Piso:**
* **Calendario ([app/templates/schedule/calendar.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/calendar.html)):** Switch interactivo *Ocultar Aulas Virtuales* (indica capacidad ilimitada) y selector *Filtrar por Piso* (`Planta Baja`, `Piso 1`, `Piso 2`, `Piso 3`, `Piso 4`). El backend `/schedule/calendar-data` excluye las aulas virtuales o filtra por número de piso.
* **Cartelera del Día ([app/templates/schedule/today.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/today.html)):** Barra de filtros completa con soporte para ocultar virtuales, piso y turno.
* **Listado de Reservas ([app/templates/schedule/list.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/list.html)):** Endpoint `/schedule/list` totalmente operativo con paginación de 20 registros, preservación de query params y filtros de piso, turno, virtuales, estado y rango de fechas.
* **Dirección Oficial de la Sede Central:**
* Se configuró la dirección oficial de la Universidad de la Ciudad de Buenos Aires (UniCABA): **`Tte. Gral. Juan Domingo Perón 802, CABA`** en la base de datos, en [init_db.py](file:///c:/Workspace/admin-edu-space/init_db.py) y en el importador [app/services/sheets_importer.py](file:///c:/Workspace/admin-edu-space/app/services/sheets_importer.py).
* **Traducción Integral al Español y Catálogo Babel:**
* Se agregaron traducciones en [translations/es/LC_MESSAGES/messages.po](file:///c:/Workspace/admin-edu-space/translations/es/LC_MESSAGES/messages.po) y se recompiló el binario `.mo` con `pybabel`.
* Textos traducidos: `Buildings` $\rightarrow$ *Edificios*, `Management` $\rightarrow$ *Gestión*, `Timeline View` $\rightarrow$ *Vista Cronológica*, `Grid View` $\rightarrow$ *Vista en Tabla*, `Active Classrooms` $\rightarrow$ *Aulas Ocupadas*, `All Reservations` $\rightarrow$ *Todas las Reservas*, `AI Room Optimizer` $\rightarrow$ *Optimizador de Aulas*, etc.
---
## 15. Actualización Integral de Roles RBAC, Capacidad Infinita (∞) y Horarios de Próximas Clases
* **Actualización de Roles, Grupos de Acceso y Matriz de Permisos ([app/models/role.py](file:///c:/Workspace/admin-edu-space/app/models/role.py), [init_db.py](file:///c:/Workspace/admin-edu-space/init_db.py)):**
* Se amplió `SYSTEM_MODULES` para cubrir los 8 módulos del sistema:
1. `classrooms`: **Aulas y Espacios** (físicas, virtuales y capacidades).
2. `buildings`: **Edificios y Sedes** (Sede Central, Campus Virtual, plantas).
3. `reservations`: **Reservas de Aulas** (aprobación, confirmación, cancelación).
4. `schedule`: **Cartelera y Cronograma** (calendario, cartelera del día, comisiones).
5. `academic`: **Gestión Académica** (carreras, asignaturas, códigos).
6. `import`: **Sincronización Sheets** (importador Google Sheets).
7. `optimizer`: **Optimizador IA** (algoritmo genético áulico).
8. `users`: **Usuarios y Accesos RBAC** (cuentas, roles y matriz).
* Se actualizaron los perfiles estándar (`Admin`, `Docente`, `Operador`, `Consulta`) en PostgreSQL y en `init_db.py`.
* Se tradujeron completamente las plantillas [app/templates/admin/roles/list.html](file:///c:/Workspace/admin-edu-space/app/templates/admin/roles/list.html) y [app/templates/admin/roles/edit.html](file:///c:/Workspace/admin-edu-space/app/templates/admin/roles/edit.html) al español.
* **Soporte de Capacidad Infinita (∞) y Aclaración ([app/forms/classroom.py](file:///c:/Workspace/admin-edu-space/app/forms/classroom.py), [app/templates/classrooms/edit.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/edit.html)):**
* Se actualizó `ClassroomForm.capacity` con validador personalizado que admite el símbolo **`∞`**, `inf`, `0` o cualquier capacidad numérica positiva.
* Se corrigió la validación de `floor` con `InputRequired` y `NumberRange(min=0)` para aceptar Planta Baja (Piso 0).
* En la interfaz de edición y alta de aulas se incluyó el botón de acción rápida **`Ilimitada (∞)`** que rellena automáticamente el campo con el símbolo `∞`.
* Se añadió una leyenda explicativa directa: *"Para aulas virtuales o eventos sin aforo restringido, ingrese ∞ o 0 (Capacidad ilimitada)"*.
* Se tradujo el botón de guardado a *"Guardar Cambios"* (reemplazando *"Save Changes"*).
* El aula virtual institucional (`Campus Virtual - VIRTUAL`) se actualizó a capacidad `0` (`∞`), renderizando el badge `∞ Ilimitada` en listados y fichas.
* **Próximas Clases: Visualización Exclusiva del Horario ([app/templates/dashboard.html](file:///c:/Workspace/admin-edu-space/app/templates/dashboard.html), [app/templates/classrooms/view.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/view.html)):**
* En la tabla de *Próximas Reservas* del panel y en la ficha del aula se cambió la columna a **"Horario"**, removiendo la fecha del calendario y destacando únicamente el rango horario de cursada (ej. `17:30 - 21:30 hs`).
+223
View File
@@ -0,0 +1,223 @@
# Estado Actual de la Migración y Arquitectura Objetivo
## Sistema Integral de Gestión de Aulas, Cursadas y Seguimiento Académico — UniCABA
**Rol:** Arquitecto de Software y Analista Funcional Senior
**Institución:** Universidad de la Ciudad de Buenos Aires (UniCABA)
**Fecha de Actualización:** 19 de Septiembre de 2026
**Rama Activa:** `testing`
---
## 1. De Dónde Venimos: La Versión Monolítica (`legacy_admin-edu-space`)
La versión original del sistema correspondía a un monolito clásico desarrollado en Python (Flask) con renderizado del lado del servidor mediante plantillas Jinja2 y ORM SQLAlchemy:
```
[Cliente Web / Navegador]
│
▼ (HTTP / HTML)
┌────────────────────────────────────────────────────────┐
│ Monolito Legacy (legacy_admin-edu-space) │
│ - Rutas Flask con render_template (Jinja2) │
│ - Sesión en Cookies de Flask (Flask-Login con estado) │
│ - Consultas directas SQLAlchemy acopladas a vistas │
│ - Base de Datos (SQLite / PostgreSQL) │
└────────────────────────────────────────────────────────┘
```
### Fortalezas Heredadas del Monolito
1. **Modelado de Dominio Inicial:** Entidades base para Sedes (`Building`), Aulas (`Classroom`), Materias (`Subject`), Comisiones (`Commission`), Reservas (`Reservation`) y Usuarios (`User`).
2. **Importador de Planillas Oficiales:** Módulo de sincronización con Google Sheets para alimentar la cartelera con la oferta de comisiones y clases.
3. **Algoritmo Genético Experimental:** Prototipo de optimización predictiva para asignación de aulas físicas según aforo y equipamiento.
### Limitaciones Críticas que Forzaron la Migración
* **Acoplamiento Servidor-Vista:** Lógica de negocio mezclada dentro de controladores web y macros de plantillas Jinja2, impidiendo el consumo móvil (app institucional) o integraciones externas (SIU Guaraní, Moodle).
* **Fallas Graves en Virtualidad:** El importador de Google Sheets descartaba más del 75% de las clases virtuales por asumir que el aula `Campus Virtual · VIRTUAL` sufría colisiones físicas de horario ante comisiones simultáneas.
* **Falta de Perspectiva por Rol:** El dashboard era único y uniforme; no distinguía entre el trabajo administrativo de Bedelía, la consulta rápida de cursada del Alumno, o la gestión evaluativa del Docente.
* **Escalabilidad y Seguridad:** Autenticación dependiente de la sesión en memoria del servidor web, sin soporte de tokens JWT sin estado ni separación de responsabilidades (BFF pattern).
---
## 2. Dónde Estamos Actualmente: Arquitectura Desacoplada Backend-BFF
La infraestructura ha sido transformada en un modelo desacoplado y de alta disponibilidad:
```
[ Navegador Web / Cliente ]
│
│ (HTTP / HTML + Cookies HttpOnly)
▼
┌──────────────────────────────────────────────┐
│ Frontend BFF (Node.js + Express) │
│ Puerto: 3000 │
│ - Renderizado Nunjucks con herencia limpia │
│ - Orquestación y agregación de UI │
│ - Gestión de Cookie JWT y Contexto de Rol │
│ - Cliente Axios con Interceptor Bearer │
└──────────────────────┬───────────────────────┘
│
│ (REST JSON / JWT Bearer)
▼
┌──────────────────────────────────────────────┐
│ Backend Core API (Python + Flask) │
│ Puerto: 5000 │
│ - Endpoints RESTful bajo /api/v1/ │
│ - Autenticación Stateless JWT (HS256) │
│ - Pipeline Enterprise SecurityFilterChain │
│ - Capa de Servicios y DTOs (Pydantic) │
│ - Persistencia SQLAlchemy (Transaccional) │
└──────────────────────┬───────────────────────┘
│
▼
[ Base de Datos SQLite / PG ]
```
### Estado de Implementación al Día de la Fecha
| Componente / Módulo | Estado en Legacy | Estado Actual (Backend API + BFF) | Nivel de Paridad |
| :--- | :--- | :--- | :---: |
| **Autenticación & Sesión** | Flask-Login basado en sesión de servidor | Autenticación REST JWT con cookie HttpOnly segura y RBAC centralizado | **120% (Superado)** |
| **Dashboards por Rol** | Dashboard monolítico único para todos | 4 Dashboards especializados (`admin`, `bedelia`, `docente`, `alumno`) con selector dinámico | **150% (Superado)** |
| **Campus Virtual & Enlaces** | Colisión de clases virtuales; pérdida de enlaces Meet | Aulas virtuales concurrentes sin límite, auto-generación de enlaces Google Meet por comisión | **140% (Superado)** |
| **Importador Google Sheets** | 363 reservas importadas (colisiones masivas) | 643 reservas totales (388 virtuales) sin colisión horaria entre comisiones | **100% (Paridad Total)** |
| **Cartelera del Día (`today_schedule`)** | Vista Jinja con bloques rígidos | Timeline interactivo por horas (7 a 21 hs), filtros por piso/turno y switch `hide_virtual` | **110% (Superado)** |
| **Ficha de Reserva (`view_reservation`)** | Vista monolítica rígida | Acoplada al BFF con relaciones anidadas completas, edición en cascada, confirmación y cancelación | **100% (Paridad Total)** |
| **Gestión de Comisiones (`commissions_list`)** | Lista plana sin modal de creación en admin | Modal `#modalAddCommission` con vinculación obligatoria a Asignatura, docente, cupo, horarios y Meet | **125% (Superado)** |
| **Calendario FullCalendar** | Feed JSON básico | Endpoint `/api/v1/reservations` con discriminación cromática (`#6366F1` virtuales) | **100% (Paridad Total)** |
---
## 3. A Dónde Queremos Ir: El Sistema Integral UniCABA
El objetivo institucional es consolidar la plataforma como el **ERP Académico y Gestor de Espacios Oficial de UniCABA**, dando soporte a las dinámicas pedagógicas físicas y digitales de la universidad.
### A. Entorno Físico vs. Campus Virtual
1. **Edificio Físico (Central):**
- Espacios con aforo estrictamente limitado, asignación por piso/ala y equipamiento técnico (proyectores, laboratorios informáticos, acústica).
- Validación matemática de no-solapamiento temporal para evitar doble asignación de aula física.
2. **Campus Virtual:**
- Capacidad física infinita; asignación lógica de salas vinculadas a la plataforma oficial (Google Meet institucional, Zoom o Microsoft Teams).
- Control de concurrencia temporal de docentes y estudiantes (evitar que un docente o alumno deba estar en dos salas remotas al mismo tiempo).
---
## 4. Reglas de Negocio del Sistema Objetivo
### 1. Perfiles y Permisos (RBAC Avanzado)
* **Administrador General (Superadmin):**
* Gobernanza total del sistema, configuración de periodos y parametrización de reglas de conflicto.
* **Modo Impersonación / Vista Previa:** Visualización en tiempo real del sistema desde la perspectiva exacta de cualquier usuario (Bedelía, Docente, Alumno) para soporte y auditoría.
* Flexibilidad académica para autorizar excepciones de cursada simultánea y apertura de comisiones extraordinarias.
* **Bedelía / Gestión Operativa:**
* Administración integral del inventario físico (Sedes, Edificios, Pisos, Aulas) y virtual.
* Creación y parametrización de Ciclos Lectivos, Asignaturas y Comisiones.
* **Asignación Docente Co-docente:** Posibilidad de asignar más de un profesor a una misma comisión (equipo de cátedra: titulares, adjuntos, ayudantes).
* Gestión de cupos máximos, padrón de alumnos matriculados y resolución de conflictos de asignación.
* Supervisión de hitos evaluativos institucionales.
* **Docente:**
* Vista segmentada estrictamente a sus comisiones asignadas.
* Calendario de clases presenciales y accesos directos a sesiones de videoconferencia.
* Gestión del calendario de evaluación: carga de fechas de parciales, recuperatorios, entregas de trabajos prácticos y finales.
* Matriz ágil de carga y edición de calificaciones y estados de regularidad.
* **Alumno:**
* Acceso enfocado a sus materias cursadas en el periodo lectivo activo.
* Brújula de cursada diaria (*¿Dónde curso hoy?* con sede, piso y aula física o enlace virtual).
* Calendario unificado de entregas de TPs, parciales y finales con avisos preventivos.
* Consulta histórica de notas y retroalimentaciones pedagógicas.
### 2. Estructura Académica y Ciclo Lectivo
* **Ciclo Lectivo Flexible:** Estructurado por Año (ej. `2026`) y Periodos estándar (`1C`, `2C`, `Anual`), permitiendo el alta de "Cursos Cortos Intensivos" o trayectos formativos extracurriculares con fechas especiales.
* **Restricción Diaria de Cursada del Estudiante:**
* **Regla por defecto:** Un estudiante universitario no puede cursar dos asignaturas regulares el mismo día calendario.
* **Excepción parametrizable:** Se autoriza la coincidencia en el mismo día si una de las materias corresponde a un curso corto intensivo o mediante autorización explícita de Bedelía/Secretaría Académica.
### 3. Matriz de Conflictos y Validaciones de Solapamiento
```mermaid
flowchart TD
Req([Solicitud de Asignación / Reserva]) --> V1{¿Es Aula Física?}
V1 -- Sí --> C1{¿Aula ocupada en ese día y horario?}
C1 -- Sí --> Err1[BLOQUEO: Conflicto Físico Infranqueable]
C1 -- No --> C2{¿Aforo de alumnos <= Capacidad aula?}
C2 -- No --> Err2[BLOQUEO: Aforo Excedido]
C2 -- Sí --> V2
V1 -- No (Virtual) --> V2
V2{¿Docente en otra clase en el mismo horario?}
V2 -- Sí --> C3{¿Comisión cuenta con Co-Docencia?}
C3 -- No --> Err3[BLOQUEO: Conflicto Docente]
C3 -- Sí --> Warn1[ALERTA: Excepción Co-Docente Justificada]
C3 --> V3
V2 -- No --> V3
V3{¿Alumno cursa otra materia regular el mismo día?}
V3 -- Sí --> C4{¿Es Curso Corto o Autorizado?}
C4 -- No --> Err4[BLOQUEO: Regla Restricción Diaria]
C4 -- Sí --> Ok[ASIGNACIÓN EXITOSA]
V3 -- No --> Ok
```
---
## 5. Diseño Técnico: Base de Datos Relacional Normalizada
Para soportar las reglas de co-docencia, inscripciones, evaluaciones y restricciones de día, el esquema de datos evolucionará hacia la siguiente estructura:
### Entidades Principales
1. **`academic_terms` (Ciclos Lectivos):** `id`, `name`, `code` (ej. '2026-1C'), `year`, `term_type` ('1C', '2C', 'ANUAL', 'CORTO'), `start_date`, `end_date`, `is_active`.
2. **`subjects` (Materias / Asignaturas):** `id`, `code`, `name`, `career_id`, `is_short_course` (boolean), `credits`, `active`.
3. **`commissions` (Comisiones de Cursada):** `id`, `subject_id`, `term_id`, `code`, `shift`, `schedule_display`, `max_students`, `virtual_link`, `active`.
4. **`commission_teachers` (Co-docencia / Equipo Docente):** `id`, `commission_id`, `teacher_id`, `role` ('TITULAR', 'ADJUNTO', 'AYUDANTE'), `can_grade` (boolean).
5. **`commission_schedules` (Asignación Horaria y Espacial):**
* `id`, `commission_id`, `day_of_week` (1=Lunes .. 7=Domingo), `start_time`, `end_time`, `classroom_id` (física o virtual), `is_virtual`.
* *Constraint de Integridad:* `UNIQUE(classroom_id, day_of_week, start_time, end_time)` para aulas no virtuales.
6. **`enrollments` (Inscripciones de Alumnos):** `id`, `commission_id`, `student_id`, `status` ('REGULAR', 'CONDICIONAL', 'LIBRE', 'PROMOVIDO'), `enrolled_at`, `allow_same_day_exception` (boolean).
7. **`evaluation_milestones` (Hitos Evaluativos):** `id`, `commission_id`, `milestone_type_id`, `title`, `date`, `start_time`, `weight_percentage`, `is_mandatory`.
8. **`student_grades` (Calificaciones y Actas):** `id`, `milestone_id`, `student_id`, `numeric_score`, `concept_score`, `feedback`, `graded_by_teacher_id`, `graded_at`.
---
## 6. Diseño de Contratos de API RESTful (Backend Core)
### A. Módulo de Comisiones y Co-docencia
* `GET /api/v1/commissions`: Filtro por término académico, carrera, asignatura, turno y estado.
* `POST /api/v1/commissions`: Alta de comisión vinculada a una asignatura con generación automática de Meet.
* `POST /api/v1/commissions/{id}/teachers`: Asignación de docentes con rol en cátedra (co-docencia).
* `DELETE /api/v1/commissions/{id}/teachers/{teacher_id}`: Desvinculación de docente.
### B. Módulo de Matriz de Conflictos y Validación
* `POST /api/v1/schedules/validate`:
* **Payload:** `{ commission_id, classroom_id, day_of_week, start_time, end_time, teacher_ids, student_ids }`
* **Respuesta:** `{ valid: boolean, conflicts: [ { type: 'PHYSICAL'|'TEACHER'|'STUDENT_DAILY', message, details } ] }`
### C. Módulo de Hitos Evaluativos y Libro de Calificaciones
* `GET /api/v1/commissions/{id}/milestones`: Listado de exámenes, entregas y parciales.
* `POST /api/v1/commissions/{id}/milestones`: Creación de hito evaluativo.
* `GET /api/v1/commissions/{id}/gradebook`: Matriz completa de alumnos y notas de la comisión.
* `PUT /api/v1/commissions/{id}/gradebook`: Carga masiva o individual de calificaciones por docente.
### D. Modo Impersonación de Superadmin
* Cabecera HTTP: `X-Impersonate-User: {user_id}`
* Procesamiento en `authMiddleware`: Si el token emisor corresponde al rol `SUPERADMIN`, el contexto de ejecución adopta los permisos y vistas del usuario destino para auditoría, dejando registro en `audit_logs`.
---
## 7. Directrices UI/UX para el Frontend BFF
1. **Panel de Bedelía:**
- Grilla interactiva semanal (tipo TimeGrid) donde los bloques de comisiones se visualizan codificados por color según edificio, aula física o aula virtual.
- Detección inmediata de colisiones con carteles de advertencia flotantes antes de confirmar cambios.
2. **Matriz de Carga Rápida para Docentes:**
- Tabla editable en línea (*spreadsheet-like*) para volcar notas de parciales y entregas sin recargar la página, con autoguardado asíncrono.
3. **Perspectiva del Alumno:**
- Interfaz simplificada con diseño enfocado en dispositivos móviles, mostrando en la tarjeta de inicio la clase del momento con botón directo para ingresar a la videollamada o indicador de piso y aula física.
---
## 8. Conclusión del Diagnóstico
El sistema ha superado con éxito la fase de desacople inicial y estabilización de virtualidad:
- Se logró paridad total con `legacy_admin-edu-space` en importación de cronograma y cartelera.
- Se eliminaron las fallas de colisión virtual y se establecieron los 4 dashboards por rol.
- Se completó el acople de la ficha de reserva (`schedule.view_reservation`) y la creación asistida de comisiones en el BFF.
Las siguientes etapas se concentrarán en la consolidación de las reglas académicas complejas (co-docencia, restricción diaria de cursada, matriz de conflictos y libro de calificaciones) detalladas en el **`ROADMAP_MVP.md`**.
+114 -244
View File
@@ -1,249 +1,119 @@
# Edu-Space Admin Panel # Panel de Administración Edu-Space (UniCABA)
A comprehensive web-based administration interface for the Edu-Space Classroom Reservation System built with Flask. Plataforma integral de gestión áulica, asignación de espacios, control de horarios y reservas académicas para la **Universidad de la Ciudad de Buenos Aires (UniCABA)**.
## Features El proyecto se basa en una arquitectura **desacoplada (Frontend / Backend)** en formato monorepo.
### 🔐 Authentication & Security
- Secure login system with hashed passwords
- Role-based access control
- Session management with Flask-Login
- Password change functionality
### 🏫 Classroom Management
- Create, edit, and delete classrooms
- Campus and building organization
- Capacity and resource tracking
- Real-time availability checking
### 📅 Schedule Management
- Interactive calendar view (FullCalendar)
- Reservation creation and management
- Conflict detection and prevention
- Status tracking (Pending, Confirmed, Cancelled)
- Today's schedule overview
### 📊 Dashboard & Analytics
- Real-time statistics
- Monthly reservation charts
- Quick actions interface
- Upcoming reservations overview
### 🎨 Modern UI/UX
- Responsive Bootstrap 5 design
- Dark theme support
- Mobile-friendly interface
- Interactive components and charts
## Tech Stack
- **Backend**: Flask (Python)
- **Database**: SQLAlchemy (SQLite/PostgreSQL)
- **Frontend**: Bootstrap 5, jQuery, Chart.js
- **Authentication**: Flask-Login, Werkzeug
- **Forms**: Flask-WTF
- **Calendar**: FullCalendar.js
## Installation
### Prerequisites
- Python 3.8+
- pip package manager
### Setup
1. **Clone or setup the project:**
```bash
cd /Users/alejandro.v/PycharmProjects/admin-edu-space
```
2. **Create virtual environment:**
```bash
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
3. **Install dependencies:**
```bash
pip install -r requirements.txt
```
4. **Setup environment variables:**
```bash
cp .env.example .env
# Edit .env with your configuration
```
5. **Initialize database:**
```bash
python app.py
```
## Configuration
### Environment Variables (.env)
```env
FLASK_APP=app.py
FLASK_ENV=development
SECRET_KEY=your-secret-key-here-please-change-in-production
DATABASE_URL=sqlite:///edu_space.db
API_BASE_URL=http://localhost:8080/api
```
## Usage
### Start the Application
```bash
python app.py
```
The application will be available at: `http://localhost:5000`
### Default Login
- **Email**: admin@edu-space.com
- **Password**: admin123
### Main Features
1. **Dashboard**: Overview of system statistics and upcoming reservations
2. **Classrooms**: Manage classroom information, capacity, and resources
3. **Schedule**: View and manage reservations with calendar interface
4. **Profile**: User profile and password management
## API Integration
The admin panel integrates with the Spring Boot API at `http://localhost:8080/api`. Make sure the Java microservice is running for full functionality.
## Project Structure
```
admin-edu-space/
├── app/
│ ├── __init__.py # Flask app factory
│ ├── models/ # Database models
│ │ ├── user.py
│ │ ├── classroom.py
│ │ ├── subject.py
│ │ └── reservation.py
│ ├── routes/ # Application routes
│ │ ├── auth.py
│ │ ├── main.py
│ │ ├── classrooms.py
│ │ └── schedule.py
│ ├── forms/ # WTForms classes
│ │ ├── auth.py
│ │ ├── classroom.py
│ │ └── reservation.py
│ ├── templates/ # Jinja2 templates
│ ├── static/ # Static files
├── config/ # Configuration files
├── requirements.txt
├── .env
├── app.py
└── README.md
```
## Database Schema
### Users Table
- User authentication and authorization
- Role management (admin, staff)
### Classrooms Table
- Classroom information
- Capacity and location details
- Resource management
### Reservations Table
- Booking management
- Time slot scheduling
- Status tracking
### Subjects + Commissions Tables
- Academic information
- Teacher assignments
- Student enrollment tracking
## Security Features
- Password hashing with bcrypt
- Session management
- CSRF protection
- Input validation and sanitization
- SQL injection prevention with SQLAlchemy ORM
## Development
### Running in Development Mode
```bash
export FLASK_ENV=development
python app.py
```
### Adding New Features
1. Create models in `app/models/`
2. Add routes in `app/routes/`
3. Create forms if needed in `app/forms/`
4. Update templates in `app/templates/`
## Production Deployment
### Environment Setup
1. **Set production variables:**
```env
FLASK_ENV=production
SECRET_KEY=<strong-random-string>
DATABASE_URL=<production-database-url>
```
2. **Use production WSGI server:**
```bash
pip install gunicorn
gunicorn -w 4 -b 0.0.0.0:5000 app:app
```
3. **Configure reverse proxy** (nginx/Apache)
## Troubleshooting
### Common Issues
1. **Database connection errors:**
- Check DATABASE_URL string
- Ensure directory permissions
2. **Login issues:**
- Verify admin user exists
- Check password hash
3. **Static files not loading:**
- Ensure blueprint route registration
- Check file permissions
## Contributing
1. Fork the repository
2. Create feature branch
3. Make changes
4. Test thoroughly
5. Submit pull request
## License
This project is part of the Edu-Space educational management system.
## Support
For support and questions:
- Email: admin@edu-space.com
- Documentation: Check inline comments and README files
--- ---
**Note**: This admin panel is designed to work alongside the Spring Boot microservice. Ensure both services are running for complete functionality. ## 🏛️ Sede Institucional y Ubicaciones
* **Sede Central UniCABA:** Tte. Gral. Juan Domingo Perón 802, Ciudad Autónoma de Buenos Aires (CABA) — Aulas de grado, auditorio principal y laboratorios distribuidos desde Planta Baja (Piso 0) hasta el 4.° Piso.
* **Campus Virtual:** Espacio áulico digital integrado (Microsoft Teams / Moodle) configurado con **capacidad ilimitada ($\infty$)**.
---
## 🏗️ Arquitectura del Proyecto (Monorepo)
El repositorio está dividido en dos aplicaciones principales que interactúan mediante una API REST y un patrón Backend-For-Frontend (BFF).
```text
/
├── backend/ # API REST Backend (Python / Flask)
│ ├── app/ # Lógica de negocio, base de datos y endpoints
│ ├── tests/ # Pruebas unitarias e integración
│ └── requirements.txt # Dependencias de Python
│
├── frontend/ # Backend-For-Frontend (Node.js / Express)
│ ├── src/ # Controladores, middleware, servicios (Axios)
│ ├── views/ # Plantillas Nunjucks (.html)
│ ├── public/ # Archivos estáticos (CSS, JS cliente)
│ └── package.json # Dependencias de Node.js
│
├── docs/ # Historial de cambios y documentación técnica
└── unicaba_prerequitos/ # Requisitos académicos y de negocio
```
### 🐍 Backend (Python + Flask)
- Funciona exclusivamente como una **REST API** que expone endpoints bajo `/api/v1/` retornando **JSON**.
- Encargado de la persistencia de datos (SQLAlchemy / PostgreSQL), lógica de negocio dura, JWT auth service, seguridad, y algoritmos de optimización (Módulo Predictivo).
- **Prohibido** retornar HTML o utilizar Jinja2/`render_template` en este entorno.
### 🟢 Frontend BFF (Node.js + Express)
- Aplicación de servicio que renderiza las vistas para el cliente mediante **Nunjucks**.
- Gestiona la sesión segura del usuario almacenando el token JWT recibido de Flask en una **Cookie HttpOnly**.
- Actúa como proxy de conexión con el Backend de Python empleando **Axios** (inyectando el Bearer token interceptado).
- Utiliza **Chart.js** y JS vanilla en el lado del cliente para visualizaciones interactivas.
---
## 🎯 Dashboards Personalizados por Rol
La plataforma cuenta con paneles adaptados a las necesidades operativas de cada perfil institucional:
* **Administrador (`/dashboard?role=admin`):** Cockpit institucional con métricas globales, auditoría de seguridad RBAC, tendencias mensuales y panel interactivo de **Estado y Percentiles de Asignación de Aulas Físicas** (asignadas vs disponibles en número y porcentaje).
* **Bedelía (`/dashboard?role=bedelia`):** Control operativo del campus en tiempo real, balance de aulas presenciales asignadas (96.3%) y libres (3.7%), cartelera del día y bandeja de aprobación de reservas pendientes.
* **Docente / Profesor (`/dashboard?role=docente`):** Sesiones del día con accesos directos a videollamadas y gestión de comisiones.
* **Alumno / Estudiante (`/dashboard?role=alumno`):** Brújula de cursada *"¿Dónde curso hoy?"* con ubicación por piso y aula o enlace virtual, junto al cronograma de exámenes y entregas de TPs.
> **Modo Vista Previa:** Los usuarios con perfil administrador o directivo pueden previsualizar cualquier perspectiva utilizando el selector de roles en la barra superior o pasando el parámetro `?role={admin|bedelia|docente|alumno}` en la URL.
---
## ☁️ Sincronización Google Sheets, Configuración Global & Purga Académica
El sistema incluye sincronización automatizada con las planillas oficiales de cursada de UniCABA:
* **Configuración Global Dinámica:** Selección y persistencia de la URL de Google Sheets directamente desde la interfaz web (`/admin/google_sheets_import`) sin valores fijos en el código.
* **Herramienta de Purga y Reinicio (`reset_academic_data`):** Módulo CLI y web para limpiar selectivamente reservas, aulas, asignaturas, comisiones o carreras, garantizando pruebas limpias desde cero y preservando siempre usuarios y credenciales.
* **Concurrencia Virtual Ilimitada:** Reconocimiento de espacios áulicos remotos (`Campus Virtual · VIRTUAL`), permitiendo la coexistencia de múltiples comisiones en la misma franja horaria sin colisiones de agenda.
* **Catálogo Presencial con Cursadas Asignadas:** Listado de aulas físicas con cálculo de ocupación real (semana/mes), vista previa de asignaturas y enlaces directos a calendario y ficha del espacio.
* **Enlaces de Videollamada Automáticos:** Generación de enlaces seguros (Google Meet / Teams) para acceso instantáneo de docentes y estudiantes.
* **Filtros en Cartelera:** Opciones para filtrar por turno, piso u ocultar aulas virtuales mediante el switch dedicado en `/schedule/today_schedule`.
---
## 📅 Ficha de Reserva & Gestión de Comisiones
* **Ficha de Reserva Detallada (`/schedule/view_reservation?id={id}`):** Información completa del espacio físico o virtual asignado, capacidad y asistencia prevista, comisión académica vinculada, docente a cargo, botones de acción inmediata (*Modificar Reserva*, *Confirmar Reserva*, *Cancelar Reserva*) y acceso con un clic a la sala de videollamada.
* **Gestión de Comisiones y Vinculación con Asignaturas (`/admin/commissions_list`):**
- Botón y modal interactivo **Nueva Comisión** (`#modalAddCommission`) para registrar comisiones y vincularlas directamente a su **Asignatura / Materia**, indicando cuatrimestre, año, turno, cupo y docente responsable.
- Generación automática de enlace institucional de Google Meet cuando no se especifica un enlace propio.
- Tabla operativa con visualización explícita del código de comisión, nombre de materia, código de asignatura y carrera.
---
## 🚀 Instalación y Despliegue Local
### Requisitos previos
- Node.js (v18 o superior)
- Python 3.10+
- PostgreSQL / SQLite
### 1. Iniciar el Backend (Flask)
```bash
cd backend
python -m venv venv
# Activar venv (Windows: venv\Scripts\activate, Linux/Mac: source venv/bin/activate)
pip install -r requirements.txt
flask run --port=5000
```
*(El backend quedará escuchando en http://localhost:5000)*
### 2. Iniciar el Frontend (Node/Express)
```bash
cd frontend
npm install
npm run dev
# o en su defecto:
node src/app.js
```
*(El frontend quedará escuchando en http://localhost:3000)*
---
## 📚 Documentación Técnica & Changelogs
* [Estado de la Migración y Arquitectura Objetivo UniCABA](ESTADO_MIGRACION_Y_ARQUITECTURA_OBJETIVO.md)
* [Roadmap MVP: Fases y Estadios del Proyecto](ROADMAP_MVP.md)
* [Changelog Oficial del Proyecto](CHANGELOG.md)
* [Changelog Detallado: Clases Virtuales y Dashboards](docs/CHANGELOG_VIRTUAL_CLASSES_AND_DASHBOARDS.md)
* [Auditoría y Sesión de Reingeniería](docs/CHANGELOG_SESSION.md)
* [Guía de Integración de Cronograma](docs/SCHEDULE_INTEGRATION_GUIDE.md)
+131
View File
@@ -0,0 +1,131 @@
# ROADMAP MVP — Sistema Integral UniCABA
## Gestión de Espacios Áulicos, Actividad de Cursada y Seguimiento Académico
**Institución:** Universidad de la Ciudad de Buenos Aires (UniCABA)
**Versión:** 2.6.0
**Fecha de Actualización:** 19 de Septiembre de 2026
**Rama Base:** `testing`
---
## 🗺️ Visión del Proyecto
Construir y evolucionar la plataforma de gestión académica y espacial de **UniCABA** hacia un sistema integral de clase universitaria, desacoplado en una arquitectura **Backend RESTful (Python/Flask) + Frontend BFF (Node.js/Express/Nunjucks)**, capaz de gobernar tanto los espacios físicos de la sede central como la dinámica áulica del campus virtual, garantizando una experiencia fluida para **Administradores**, **Bedelía**, **Docentes** y **Estudiantes**.
---
## 📊 Estado Global de Progreso
```
Fase 0: Desacople y Arquitectura Base [████████████████████] 100% (Completado)
Fase 1: Paridad Legacy y Estabilización [████████████████████] 100% (Completado)
Fase 2: Co-docencia y Matriz Conflictos [████████████████████] 100% (Completado)
Fase 3: Hitos Evaluativos y Calificaciones[░░░░░░░░░░░░░░░░░░░░] 0% (Próxima)
Fase 4: UI/UX Drag & Drop e Impersonación[░░░░░░░░░░░░░░░░░░░░] 0% (Planificada)
Fase 5: Despliegue Producción e Integrac.[░░░░░░░░░░░░░░░░░░░░] 0% (Planificada)
```
---
## 🚀 Detalle de Fases y Estadios
### Fase 0: Desacople y Arquitectura Base ✅ *(Completada)*
* **Objetivo:** Separar el monolito Jinja2 legacy en dos servicios independientes de alto rendimiento.
* **Entregables:**
* [x] Creación del Backend Flask como API REST pura bajo `/api/v1/` retornando exclusivamente JSON.
* [x] Implementación del Frontend BFF en Node.js + Express con renderizado de plantillas mediante Nunjucks.
* [x] Autenticación desacoplada mediante JWT (`access_token`) transportado en Cookie `HttpOnly` segura.
* [x] Pipeline de seguridad `SecurityFilterChain` con cabeceras de blindaje OWASP y rate limiting dinámico.
* [x] Cliente HTTP Axios centralizado con inyección automática de Bearer token y manejo de sesiones.
---
### Fase 1: Paridad Legacy, Estabilización de Virtualidad y BFF Core ✅ *(Completada)*
* **Objetivo:** Alcanzar paridad funcional total con la versión legacy, subsanando defectos críticos de virtualidad y dotando de interfaz específica a cada rol.
* **Entregables:**
* [x] **Importador de Google Sheets Corregido:**
* Eliminación de la colisión de horarios en el aula virtual única (`Campus Virtual · VIRTUAL`).
* Incremento de reservas registradas de 363 a 643 (incorporando 280 clases virtuales que se descartaban).
* Generación y persistencia automática de enlaces de Google Meet institucional por comisión y reserva.
* [x] **Dashboards Especializados por Rol:**
* Dashboard interactivo para **Bedelía** (aulas físicas, salas virtuales, aprobaciones pendientes).
* Dashboard interactivo para **Docente** (clases del día, comisiones, enlaces directos a Meet).
* Dashboard interactivo para **Alumno** (brújula de cursada *"¿Dónde curso hoy?"*, hitos y calendario).
* Cockpit institucional para **Admin** (métricas de capacidad, auditoría RBAC, demanda mensual).
* Selector dinámico de vista previa / roles en tiempo real (`?role=...`).
* [x] **Cartelera del Día (`today_schedule`):**
* Grilla temporal por bloques horarios (07:00 a 21:00 hs) con indicadores físicos y virtuales.
* Filtro dinámico para ocultar clases virtuales (`hide_virtual=1`) y segmentación por piso o turno.
* [x] **Acople de Ficha de Reserva (`schedule.view_reservation`):**
* Ruteo en BFF (`/schedule/view_reservation?id=...` y `/view/:id`).
* Endpoint REST `/api/v1/reservations/<id>` con enriquecimiento relacional (aula, comisión, docente).
* Soporte para modificar reserva (`edit_reservation`) con selectores en cascada, confirmación y cancelación.
* [x] **Gestión de Comisiones (`admin/commissions_list`):**
* Botón y modal interactivo `+ Nueva Comisión` (`#modalAddCommission`).
* Vinculación obligatoria y explícita a la **Asignatura / Materia**, indicando cuatrimestre, año lectivo, turno, docente a cargo, cupo y enlace virtual.
* Visualización en tabla de la materia vinculada con su código y carrera asociada.
---
### Fase 2: Co-docencia, Matriz de Conflictos y Regla Diaria ✅ *(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 📅 *(Planificada)*
* **Objetivo:** Proveer al cuerpo docente y estudiantil el seguimiento completo de instancias evaluativas.
* **Estadios:**
1. **Estadio 3.1 — Calendario de Evaluación por Comisión:**
- [ ] Programación de parciales, recuperatorios, entregas de trabajos prácticos y exámenes finales.
- [ ] Notificaciones preventivas en el dashboard del alumno con cuenta regresiva para exámenes.
2. **Estadio 3.2 — Libro de Calificaciones (*Gradebook*):**
- [ ] Matriz de carga rápida de notas para docentes con autoguardado asíncrono.
- [ ] Consulta individualizada de calificaciones y retroalimentaciones pedagógicas para alumnos.
- [ ] Cierre de actas de regularidad y promoción para Bedelía.
---
### Fase 4: UI/UX Avanzada para Bedelía & Modo Impersonación 🖥️ *(Planificada)*
* **Objetivo:** Dotar a la oficina de Bedelía y a la Administración de herramientas interactivas de última generación.
* **Estadios:**
1. **Estadio 4.1 — Grilla Semanal Interactiva (Drag-and-Drop):**
- [ ] Interfaz tipo TimeGrid interactiva con arrastre de comisiones para asignación rápida de aulas y turnos.
- [ ] Detección visual en tiempo real de aulas ocupadas o incompatibles.
2. **Estadio 4.2 — Modo Impersonación del Superadmin:**
- [ ] Funcionalidad de *login as* o vista previa con cabecera `X-Impersonate-User` para replicar exactamente lo que ve cualquier docente o alumno.
- [ ] Trazabilidad obligatoria de acciones bajo impersonación en el registro de auditoría (`audit_logs`).
3. **Estadio 4.3 — Integración del Optimizador Genético:**
- [ ] Acople del motor predictivo de algoritmos genéticos a la base de datos real para sugerir redistribución óptima de aulas según aforo.
---
### Fase 5: Producción Institucional, CI/CD e Integraciones 🏢 *(Planificada)*
* **Objetivo:** Puesta en marcha definitiva en la infraestructura de servidores de UniCABA.
* **Estadios:**
1. **Estadio 5.1 — Despliegue e Infraestructura:**
- [ ] `docker-compose.yml` productivo orquestando Flask (Gunicorn), Node.js BFF (PM2) y PostgreSQL 15.
- [ ] Proxy inverso Nginx con certificados SSL/TLS y compresión Brotli/Gzip.
2. **Estadio 5.2 — Integraciones Universitarias:**
- [ ] Módulo de exportación/importación compatible con SIU Guaraní 3.
- [ ] Sincronización automática de aulas virtuales con Moodle institucional.
3. **Estadio 5.3 — Pruebas de Carga y Hardening:**
- [ ] Pruebas de estrés de concurrencia en días pico de inicio de cuatrimestre (10.000 solicitudes simultáneas).
---
## 📈 Criterios de Aceptación del MVP
1. **Zero Colisiones Físicas:** Imposibilidad absoluta de asignar dos materias simultáneas a una misma aula física.
2. **Virtualidad Fluida:** Acceso con un clic a reuniones virtuales válidas para el 100% de las comisiones remotas.
3. **Roles Seguros:** Separación total de permisos donde ningún alumno o docente acceda a configuración global ni a datos privados de otras comisiones.
4. **Respuesta Sub-segundo:** Carga de carteleras y calendarios en menos de 300 ms en el BFF Node.js.
+99
View File
@@ -0,0 +1,99 @@
# Informe de Auditoría y Seguridad Automatizada (Python & Node.js)
**Proyecto:** Edu-Space Admin Architecture
**Fecha:** Septiembre 2026
**Entorno:** Backend Flask (REST API) + Node.js BFF (Express)
**Estado General:** Aprobado ✅ (0 vulnerabilidades conocidas, 0 fallos críticos SAST/DAST)
---
## 1. Resumen Ejecutivo
Se completó la implementación del **Plan de Auditoría y Seguridad Automatizada** que cubre el ciclo de vida completo de las dos capas backend 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 (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`).
---
## 2. Fase 1: 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 ./app -ll -ii`
- **Líneas Escaneadas:** 8.456 líneas de código Python.
- **Hallazgo Inicial:** 1 advertencia de severidad Media (CWE-22 / B310 en `backend/app/services/sheets_importer.py` por uso de `urllib.request.urlopen` sin validación estricta del esquema URL).
- **Remediación Aplicada:**
- Se incorporó validación explícita de esquema (`url.startswith('https://')` o `url.startswith('http://')`) previo a la invocación de `urlopen`.
- Se colocó anotación `# nosec B310` justificando la protección criptográfica/red implementada.
- **Resultado Actual:** **0 problemas identificados** (Medium/High = 0).
### 2.2. Auditoría de Dependencias de Terceros (SCA) - `Pip-Audit`
- **Herramienta:** Pip-Audit v2.10.1 con base de datos OSV (`backend/venv/Scripts/pip-audit.exe`)
- **Comando:** `pip-audit -s osv --progress-spinner off -r requirements.txt`
- **Hallazgo Inicial:** 24 vulnerabilidades conocidas asociadas a dependencias desactualizadas (`Flask==3.0.0`, `Werkzeug==3.0.1`, `requests==2.31.0`, `bleach==6.1.0`, `python-dotenv==1.0.0`).
- **Remediaciones Aplicadas:**
- Actualización de `Flask` a `>=3.1.3`
- Actualización de `Werkzeug` a `>=3.1.6` (instalada v3.1.8)
- Actualización de `requests` a `>=2.32.4` (instalada v2.34.2)
- Actualización de `bleach` a `>=6.4.0`
- Actualización de `python-dotenv` a `>=1.2.2` (instalada v1.2.3)
- **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:** Implementada en `backend/app/routes/api/openapi.py` (`/api/v1/openapi.json`) bajo el estándar OpenAPI 3.0.3.
- **Comando:** `st run http://127.0.0.1:5000/api/v1/openapi.json --checks not_a_server_error --max-examples=10`
- **Hallazgo Inicial:** Fuzzing detectó un error 500 no controlado en `POST /api/v1/auth/login` cuando el payload enviado consistía en un arreglo JSON anómalo (`[null, null]`) en vez de un objeto/diccionario, provocando un `TypeError` en el constructor de Pydantic (`LoginDTO(**data)`).
- **Remediación Aplicada:**
- Se agregó validación de tipo `if not isinstance(data, dict): return jsonify({'error': 'BadRequest', 'message': '...'}), 400` tanto en `/login` como en `/refresh`.
- **Resultado Actual:** **114 casos de prueba generados y superados con éxito**, **0 errores 500 del servidor**.
---
## 3. Fase 2: Backend Node.js (Express BFF)
### 3.1. Análisis Estático de Código Fuente (SAST) - `njsscan`
- **Herramienta:** NodeJsScan (`backend/venv/Scripts/njsscan.exe`)
- **Comando:** `njsscan ./frontend/src`
- **Reglas Evaluadas:** Inyecciones de código, llamadas inseguras a `eval()`, omisión de headers de seguridad, credenciales hardcodeadas, CORS inseguro.
- **Resultado:** **No issues found** (0 vulnerabilidades).
### 3.2. Blindaje de Middleware (`helmet` & `express-rate-limit`)
- **Paquetes Instalados:** `helmet`, `express-rate-limit`.
- **Configuración en `frontend/src/app.js`:**
- `app.disable('x-powered-by')`: Oculta el motor Express para prevenir finger-printing de atacantes.
- `helmet`: Aplica cabeceras HTTP de protección:
- `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`:
- Endpoint `/auth/login`: Límite estricto de 30 peticiones por ventana de 15 minutos por IP para mitigar ataques de fuerza bruta de credenciales.
- Endpoints `/api`: Límite de 180 peticiones por minuto por IP para mitigar saturación y scraping.
### 3.3. Auditoría de Dependencias (SCA) - `npm audit`
- **Herramienta:** `npm audit` nativo (v11.x)
- **Comando:** `cmd.exe /c npm audit` en `frontend/`
- **Resultado:** **found 0 vulnerabilities** en 108 paquetes analizados.
---
## 4. Script de Auditoría Automatizada Unificada
Se diseñó y probó el script automatizado [security_audit.bat](file:///c:/Users/Soporte%20IT/workspace/admin-edu-space/security_audit.bat) en la raíz del repositorio, ejecutable en un solo paso:
```cmd
security_audit.bat
```
El script ejecuta secuencialmente los 5 controles de seguridad y genera un reporte en consola para desarrolladores y pipelines de integración continua.
---
## 5. Matriz de Resultados
| Control | Capa | Herramienta | Estado Previo | Estado Final |
|---|---|---|---|---|
| **SAST (Python)** | Flask API | `bandit` | 1 Advertencia Media (B310) | **0 Problemas (100% Limpio)** ✅ |
| **SCA (Python)** | Flask API | `pip-audit` | 24 Vulnerabilidades (CVEs) | **0 Vulnerabilidades** ✅ |
| **DAST (Python)** | Flask API | `schemathesis` | 1 Fallo HTTP 500 (payload fuzzing) | **0 Fallos (114/114 Pasados)** ✅ |
| **SAST (Node.js)**| Express BFF| `njsscan` | Sin controles previos | **0 Problemas (100% Limpio)** ✅ |
| **SCA (Node.js)** | Express BFF| `npm audit` | No auditado con Helmet | **0 Vulnerabilidades** ✅ |
| **Blindaje HTTP** | Express BFF| `helmet` + `rate-limit` | Cabeceras por defecto | **Protegido con HSTS, nosniff, limiters** ✅ |
-112
View File
@@ -1,112 +0,0 @@
from flask import Flask, request, session
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
from flask_cors import CORS
from flask_babel import Babel, gettext as _
from config.config import Config
# Initialize extensions
db = SQLAlchemy()
migrate = Migrate()
login_manager = LoginManager()
cors = CORS()
babel = Babel()
def create_app(config_class=Config):
app = Flask(__name__)
app.config.from_object(config_class)
# Initialize extensions with app
db.init_app(app)
migrate.init_app(app, db)
login_manager.init_app(app)
cors.init_app(app)
# Login manager configuration
login_manager.login_view = 'auth.login'
login_manager.login_message = 'Por favor inicia sesión para acceder a esta página.'
login_manager.login_message_category = 'info'
# Import models to ensure they are registered
from app.models import user
# Register blueprints
from app.routes import auth_bp, classrooms_bp, buildings_bp, main_bp, schedule_bp, genetic_bp, genetic_web_bp, admin_bp
app.register_blueprint(auth_bp, url_prefix="/")
app.register_blueprint(classrooms_bp, url_prefix="/classrooms")
app.register_blueprint(buildings_bp)
app.register_blueprint(main_bp, url_prefix="/")
app.register_blueprint(schedule_bp, url_prefix="/schedule")
app.register_blueprint(genetic_bp)
app.register_blueprint(genetic_web_bp)
app.register_blueprint(admin_bp)
# Language selector function
def get_locale():
# 1. Parámetro explícito en la URL (?lang=es o ?lang=en)
lang = request.args.get('lang')
if lang and lang in app.config['LANGUAGES']:
session['language'] = lang
return lang
# 2. Si el usuario está autenticado y tiene idioma configurado en su perfil, prevalece en toda la sesión
from flask_login import current_user
if current_user.is_authenticated and getattr(current_user, 'preferred_language', None):
if current_user.preferred_language in app.config['LANGUAGES']:
return current_user.preferred_language
# 3. En la pantalla de Login (usuario no autenticado):
# El idioma predeterminado del login debe ser español o respetar el idioma del navegador.
# No debe verse afectado por cookies o sesiones residuales de otros usuarios.
is_login = (request.endpoint == 'auth.login' or request.path.rstrip('/') == '/login')
if is_login:
# Solo si el usuario cambió el idioma explícitamente en el selector del login
if session.get('login_lang_explicit') in app.config['LANGUAGES']:
return session['login_lang_explicit']
# Detectar según el navegador del usuario (soporta es-419, es-AR, es-ES, etc.)
if request.accept_languages:
for accepted_lang, _quality in request.accept_languages:
primary = accepted_lang.split('-')[0].lower()
if primary in app.config['LANGUAGES']:
return primary
# Idioma predeterminado por defecto del login: Español
return 'es'
# 4. Para otras rutas: verificar si hay idioma en sesión (ej. selector manual)
if 'language' in session and session['language'] in app.config['LANGUAGES']:
return session['language']
# 5. Detección automática del navegador por defecto (soporta subtags regionales)
if request.accept_languages:
for accepted_lang, _quality in request.accept_languages:
primary = accepted_lang.split('-')[0].lower()
if primary in app.config['LANGUAGES']:
return primary
# Fallback del sistema: Español
return app.config['BABEL_DEFAULT_LOCALE']
# Babel context processor
@app.context_processor
def inject_conf_vars():
return dict(
languages=app.config['LANGUAGES'],
current_lang=str(get_locale())
)
# Configure Babel
babel.init_app(app, locale_selector=get_locale)
# Register localized date and datetime formatters for Jinja2 templates
from flask_babel import format_date, format_datetime
app.jinja_env.globals['format_date'] = format_date
app.jinja_env.globals['format_datetime'] = format_datetime
app.jinja_env.filters['format_date'] = format_date
app.jinja_env.filters['format_datetime'] = format_datetime
return app
-86
View File
@@ -1,86 +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)
preferred_language = db.Column(db.String(10), default=None, nullable=True)
theme_preference = db.Column(db.String(10), default='auto', nullable=False)
role_id = db.Column(db.Integer, db.ForeignKey('roles.id', ondelete='SET NULL'), nullable=True)
# Virtual fields for future compatibility
@property
def first_name(self):
return self.name.split()[0] if self.name else ''
@property
def last_name(self):
parts = self.name.split()
return ' '.join(parts[1:]) if len(parts) > 1 else ''
# Relationships
reservations = db.relationship('Reservation', backref='user', lazy=True, cascade='all, delete-orphan')
def set_password(self, password):
"""Set password hash"""
self.password_hash = generate_password_hash(password, method='pbkdf2:sha256')
def check_password(self, password):
"""Check password"""
return check_password_hash(self.password_hash, password)
def is_admin(self):
"""Check if user is admin"""
if self.role_obj and self.role_obj.name.lower() in ['admin', 'administrador']:
return True
return str(self.role).upper() in ['ADMIN', 'ADMINISTRADOR']
def 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.has_permission('users', 'read')
def update_last_login(self):
"""Update last login timestamp"""
self.last_login = datetime.utcnow()
db.session.commit()
def to_dict(self):
"""Convert user to dictionary"""
return {
'id': self.id,
'email': self.email,
'name': self.name,
'role': self.role,
'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None,
'last_login': self.last_login.isoformat() if self.last_login else None,
'preferred_language': self.preferred_language,
'theme_preference': self.theme_preference
}
def __repr__(self):
return f'<User {self.email}>'
@login_manager.user_loader
def load_user(user_id):
"""Load user from database by ID"""
return User.query.get(int(user_id))
-342
View File
@@ -1,342 +0,0 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, abort
from flask_login import login_required, current_user
from flask_babel import gettext as _
from app import db
from app.models.user import User
from app.models.role import Role, Permission, SYSTEM_MODULES, ACCESS_LEVELS
from app.utils.decorators import admin_required, permission_required
from sqlalchemy import or_
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
# ---------------------------------------------------------
# USERS MANAGEMENT
# ---------------------------------------------------------
@admin_bp.route('/users')
@admin_required
def users_list():
"""List system users with filtering and role information"""
search = request.args.get('search', '').strip()
role_filter = request.args.get('role', '').strip()
status_filter = request.args.get('status', '').strip()
page = request.args.get('page', 1, type=int)
per_page = 15
query = User.query
if search:
query = query.filter(
or_(
User.name.ilike(f'%{search}%'),
User.email.ilike(f'%{search}%')
)
)
if role_filter:
if role_filter.isdigit():
query = query.filter(User.role_id == int(role_filter))
else:
query = query.filter(User.role.ilike(role_filter))
if status_filter in ['active', 'inactive']:
query = query.filter(User.is_active == (status_filter == 'active'))
pagination = query.order_by(User.id.asc()).paginate(page=page, per_page=per_page, error_out=False)
roles = Role.query.order_by(Role.name.asc()).all()
return render_template(
'admin/users/list.html',
users=pagination.items,
pagination=pagination,
roles=roles,
search=search,
role_filter=role_filter,
status_filter=status_filter
)
@admin_bp.route('/users/add', methods=['GET', 'POST'])
@admin_required
def user_add():
"""Create a new user and assign a role"""
roles = Role.query.order_by(Role.name.asc()).all()
if request.method == 'POST':
name = request.form.get('name', '').strip()
email = request.form.get('email', '').strip().lower()
password = request.form.get('password', '')
role_id = request.form.get('role_id', type=int)
is_active = request.form.get('is_active') == 'on'
errors = []
if not name:
errors.append(_('El nombre es requerido.'))
if not email:
errors.append(_('El correo electrónico es requerido.'))
elif User.query.filter_by(email=email).first():
errors.append(_('Ya existe una cuenta con este correo electrónico.'))
if not password or len(password) < 6:
errors.append(_('La contraseña debe tener al menos 6 caracteres.'))
role_obj = Role.query.get(role_id) if role_id else None
if not role_obj:
errors.append(_('Debes seleccionar un rol válido.'))
if errors:
for err in errors:
flash(err, 'error')
return render_template('admin/users/form.html', user=None, roles=roles)
user = User(
name=name,
email=email,
role_id=role_obj.id,
role=role_obj.name,
is_active=is_active
)
user.set_password(password)
db.session.add(user)
db.session.commit()
flash(_('Usuario "%(name)s" creado exitosamente.', name=user.name), 'success')
return redirect(url_for('admin.users_list'))
return render_template('admin/users/form.html', user=None, roles=roles)
@admin_bp.route('/users/edit/<int:id>', methods=['GET', 'POST'])
@admin_required
def user_edit(id):
"""Edit user details, role and status"""
user = User.query.get_or_404(id)
roles = Role.query.order_by(Role.name.asc()).all()
if request.method == 'POST':
name = request.form.get('name', '').strip()
email = request.form.get('email', '').strip().lower()
password = request.form.get('password', '')
role_id = request.form.get('role_id', type=int)
is_active = request.form.get('is_active') == 'on'
errors = []
if not name:
errors.append(_('El nombre es requerido.'))
if not email:
errors.append(_('El correo electrónico es requerido.'))
else:
existing = User.query.filter_by(email=email).first()
if existing and existing.id != user.id:
errors.append(_('Ya existe otra cuenta con este correo electrónico.'))
role_obj = Role.query.get(role_id) if role_id else None
if not role_obj:
errors.append(_('Debes seleccionar un rol válido.'))
# Do not allow deactivating the currently logged in admin user
if user.id == current_user.id and not is_active:
errors.append(_('No puedes desactivar tu propia cuenta mientras estás en sesión.'))
if errors:
for err in errors:
flash(err, 'error')
return render_template('admin/users/form.html', user=user, roles=roles)
user.name = name
user.email = email
user.role_id = role_obj.id
user.role = role_obj.name
user.is_active = is_active
if password:
if len(password) < 6:
flash(_('La nueva contraseña debe tener al menos 6 caracteres.'), 'error')
return render_template('admin/users/form.html', user=user, roles=roles)
user.set_password(password)
db.session.commit()
flash(_('Usuario "%(name)s" actualizado correctamente.', name=user.name), 'success')
return redirect(url_for('admin.users_list'))
return render_template('admin/users/form.html', user=user, roles=roles)
@admin_bp.route('/users/toggle/<int:id>', methods=['POST'])
@admin_required
def user_toggle(id):
"""Toggle user active status"""
user = User.query.get_or_404(id)
if user.id == current_user.id:
flash(_('No puedes desactivar tu propia cuenta.'), 'error')
return redirect(url_for('admin.users_list'))
user.is_active = not user.is_active
db.session.commit()
status_str = _('activada') if user.is_active else _('desactivada')
flash(_('La cuenta de %(name)s ha sido %(status)s.', name=user.name, status=status_str), 'success')
return redirect(url_for('admin.users_list'))
# ---------------------------------------------------------
# ROLES & PERMISSIONS MANAGEMENT (FortiGate Matrix)
# ---------------------------------------------------------
@admin_bp.route('/roles')
@admin_required
def roles_list():
"""List all roles and permission summaries"""
roles = Role.query.order_by(Role.id.asc()).all()
return render_template(
'admin/roles/list.html',
roles=roles,
modules=SYSTEM_MODULES
)
@admin_bp.route('/roles/add', methods=['GET', 'POST'])
@admin_required
def role_add():
"""Create a new role with the FortiGate permission matrix"""
if request.method == 'POST':
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
if not name:
flash(_('El nombre del rol es requerido.'), 'error')
return render_template('admin/roles/edit.html', role=None, modules=SYSTEM_MODULES, current_perms={})
if Role.query.filter_by(name=name).first():
flash(_('Ya existe un rol con ese nombre.'), 'error')
return render_template('admin/roles/edit.html', role=None, modules=SYSTEM_MODULES, current_perms={})
new_role = Role(name=name, description=description, is_system=False)
db.session.add(new_role)
db.session.flush() # to obtain new_role.id
# Save permissions from matrix
for mod in SYSTEM_MODULES:
mod_id = mod['id']
level = request.form.get(f'perm_{mod_id}', 'none')
if level not in ACCESS_LEVELS:
level = 'none'
new_role.set_permission(mod_id, level)
db.session.commit()
flash(_('Rol "%(name)s" creado exitosamente con sus permisos.', name=new_role.name), 'success')
return redirect(url_for('admin.roles_list'))
return render_template('admin/roles/edit.html', role=None, modules=SYSTEM_MODULES, current_perms={})
@admin_bp.route('/roles/edit/<int:id>', methods=['GET', 'POST'])
@admin_required
def role_edit(id):
"""Edit an existing role and its FortiGate permission matrix"""
role = Role.query.get_or_404(id)
if request.method == 'POST':
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
if not name:
flash(_('El nombre del rol es requerido.'), 'error')
return render_template('admin/roles/edit.html', role=role, modules=SYSTEM_MODULES, current_perms={p.module: p.access_level for p in role.permissions})
existing = Role.query.filter_by(name=name).first()
if existing and existing.id != role.id:
flash(_('Ya existe otro rol con ese nombre.'), 'error')
return render_template('admin/roles/edit.html', role=role, modules=SYSTEM_MODULES, current_perms={p.module: p.access_level for p in role.permissions})
# If it's a system role, keep name unchanged or protected
if not role.is_system:
role.name = name
role.description = description
# Update permissions from the FortiGate matrix
for mod in SYSTEM_MODULES:
mod_id = mod['id']
level = request.form.get(f'perm_{mod_id}', 'none')
if level not in ACCESS_LEVELS:
level = 'none'
role.set_permission(mod_id, level)
db.session.commit()
flash(_('Rol "%(name)s" y permisos actualizados correctamente.', name=role.name), 'success')
return redirect(url_for('admin.roles_list'))
current_perms = {p.module: p.access_level for p in role.permissions}
return render_template(
'admin/roles/edit.html',
role=role,
modules=SYSTEM_MODULES,
current_perms=current_perms
)
@admin_bp.route('/roles/delete/<int:id>', methods=['POST'])
@admin_required
def role_delete(id):
"""Delete a non-system role if it has no assigned users"""
role = Role.query.get_or_404(id)
if role.is_system:
flash(_('No se pueden eliminar roles protegidos del sistema.'), 'error')
return redirect(url_for('admin.roles_list'))
user_count = len(role.users) if role.users else 0
if user_count > 0:
flash(_('No se puede eliminar el rol "%(name)s" porque tiene %(count)d usuario(s) asignado(s). Reasígnalos primero.', name=role.name, count=user_count), 'error')
return redirect(url_for('admin.roles_list'))
role_name = role.name
db.session.delete(role)
db.session.commit()
flash(_('El rol "%(name)s" ha sido eliminado.', name=role_name), 'success')
return redirect(url_for('admin.roles_list'))
# ---------------------------------------------------------
# GOOGLE SHEETS IMPORT & SYNCHRONIZATION
# ---------------------------------------------------------
@admin_bp.route('/import', methods=['GET', 'POST'])
@admin_required
def google_sheets_import():
"""Manual sync and status page for Google Sheets academic schedule data"""
from app.services.sheets_importer import GoogleSheetsImporter, SHEETS_CONFIG
from app.models.classroom import Classroom
from app.models.career import Career
from app.models.subject import Subject, Commission
from app.models.reservation import Reservation
stats = None
if request.method == 'POST':
try:
importer = GoogleSheetsImporter()
stats = importer.sync()
if stats['errors']:
flash(_('La sincronización se completó con algunas advertencias.'), 'warning')
else:
flash(_('Sincronización completada exitosamente. %(rows)d registros procesados.', rows=stats['total_rows_processed']), 'success')
except Exception as e:
flash(_('Error al sincronizar con Google Sheets: %(err)s', err=str(e)), 'error')
# Calculate current system metrics
metrics = {
'total_classrooms': Classroom.query.count(),
'total_careers': Career.query.count(),
'total_subjects': Subject.query.count(),
'total_commissions': Commission.query.count(),
'total_reservations': Reservation.query.count(),
'sheets': SHEETS_CONFIG
}
# Recent samples from DB if available
recent_subjects = Subject.query.order_by(Subject.id.desc()).limit(10).all()
return render_template(
'admin/import.html',
metrics=metrics,
stats=stats,
recent_subjects=recent_subjects
)
-248
View File
@@ -1,248 +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')
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 = Classroom(
# campus_id=form.campus_id.data,
building=form.building.data,
code=form.room_number.data,
floor=form.floor.data,
capacity=cap_val,
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('/', endpoint='index')
@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)
if request.method == 'GET':
if classroom.capacity == 0 or (classroom.code and 'VIRTUAL' in classroom.code.upper()):
form.capacity.data = '∞'
else:
form.capacity.data = str(classroom.capacity)
# 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')
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)
# Update classroom
classroom.building = form.building.data
classroom.code = form.room_number.data
classroom.floor = form.floor.data
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'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
-137
View File
@@ -1,137 +0,0 @@
from flask import Blueprint, render_template, jsonify, redirect, url_for, session, request, flash
from flask_login import login_required, current_user
from app import db
from app.models.reservation import Reservation
from app.models.classroom import Classroom
from app.models.subject import Subject
from app.models.subject import Commission
from datetime import datetime, timedelta
from sqlalchemy import func
from flask_babel import gettext as _
main_bp = Blueprint('main', __name__)
@main_bp.route('/')
@login_required
def dashboard():
"""Main dashboard page"""
# Get statistics
try:
total_classrooms = Classroom.query.filter_by(is_active=True).count()
except:
total_classrooms = 0
try:
total_subjects = Subject.query.filter_by(is_active=True).count()
except:
total_subjects = 0
# Get today's reservations
today = datetime.utcnow().date()
try:
today_reservations = Reservation.query.filter(
func.date(Reservation.start_time) == today
).count()
except:
today_reservations = 0
# Get this week's reservations
week_start = today - timedelta(days=today.weekday())
week_end = week_start + timedelta(days=6)
try:
week_reservations = Reservation.query.filter(
func.date(Reservation.start_time) >= week_start,
func.date(Reservation.start_time) <= week_end
).count()
except:
week_reservations = 0
# Get upcoming reservations (next 7 days)
try:
upcoming_reservations = Reservation.query.filter(
Reservation.start_time >= datetime.utcnow(),
Reservation.start_time <= datetime.utcnow() + timedelta(days=7)
).limit(10).all()
except:
upcoming_reservations = []
stats = {
'total_classrooms': total_classrooms,
'total_subjects': total_subjects,
'today_reservations': today_reservations,
'week_reservations': week_reservations
}
return render_template('dashboard.html',
stats=stats,
upcoming_reservations=upcoming_reservations)
@main_bp.route('/api/dashboard-stats')
@login_required
def dashboard_stats_api():
"""API endpoint for dashboard statistics"""
# Get today's data
today = datetime.utcnow().date()
today_reservations = Reservation.query.filter(
func.date(Reservation.start_time) == today
).count()
# Get this week's data
week_start = today - timedelta(days=today.weekday())
week_end = week_start + timedelta(days=6)
week_reservations = Reservation.query.filter(
func.date(Reservation.start_time) >= week_start,
func.date(Reservation.start_time) <= week_end
).count()
# Get monthly data for chart
current_date = datetime.utcnow()
monthly_data = []
for i in range(12):
month_date = current_date - timedelta(days=30 * i)
month_start = month_date.replace(day=1)
month_end = (month_start + timedelta(days=32)).replace(day=1) - timedelta(days=1)
monthly_reservations = Reservation.query.filter(
func.date(Reservation.start_time) >= month_start.date(),
func.date(Reservation.start_time) <= month_end.date()
).count()
monthly_data.append({
'month': month_start.strftime('%Y-%m'),
'reservations': monthly_reservations
})
return jsonify({
'today_reservations': today_reservations,
'week_reservations': week_reservations,
'monthly_data': list(reversed(monthly_data))
})
@main_bp.route('/genetic-optimizer')
@login_required
def genetic_optimizer():
"""Serve the genetic algorithm optimization interface"""
if 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 = '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'))␍
-3
View File
@@ -1,3 +0,0 @@
from .sheets_importer import GoogleSheetsImporter
__all__ = ['GoogleSheetsImporter']
-242
View File
@@ -1,242 +0,0 @@
{% extends "base.html" %}
{% block title %}{% trans %}Import from Google Sheets{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-1">
<i class="bi bi-file-earmark-spreadsheet text-success me-2"></i>{% trans %}Google Sheets Academic Sync{% endtrans %}
</h1>
<p class="text-muted small mb-0">{% trans %}Extract and synchronize classrooms, careers, subjects, and weekly schedules directly from the official Google Sheet.{% endtrans %}</p>
</div>
<div>
<a href="{{ url_for('main.dashboard') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>{% trans %}Dashboard{% endtrans %}
</a>
</div>
</div>
<!-- Metrics Cards -->
<div class="row g-3 mb-4">
<div class="col-sm-6 col-xl-2">
<div class="card border-0 shadow-sm text-center p-3 h-100">
<div class="text-primary mb-1"><i class="bi bi-mortarboard fs-3"></i></div>
<div class="h4 mb-0 fw-bold">{{ metrics.total_careers }}</div>
<small class="text-muted">{% trans %}Careers{% endtrans %}</small>
</div>
</div>
<div class="col-sm-6 col-xl-2">
<div class="card border-0 shadow-sm text-center p-3 h-100">
<div class="text-info mb-1"><i class="bi bi-book fs-3"></i></div>
<div class="h4 mb-0 fw-bold">{{ metrics.total_subjects }}</div>
<small class="text-muted">{% trans %}Subjects{% endtrans %}</small>
</div>
</div>
<div class="col-sm-6 col-xl-2">
<div class="card border-0 shadow-sm text-center p-3 h-100">
<div class="text-success mb-1"><i class="bi bi-door-open fs-3"></i></div>
<div class="h4 mb-0 fw-bold">{{ metrics.total_classrooms }}</div>
<small class="text-muted">{% trans %}Classrooms{% endtrans %}</small>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm text-center p-3 h-100">
<div class="text-warning mb-1"><i class="bi bi-people fs-3"></i></div>
<div class="h4 mb-0 fw-bold">{{ metrics.total_commissions }}</div>
<small class="text-muted">{% trans %}Commissions{% endtrans %}</small>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm text-center p-3 h-100">
<div class="text-danger mb-1"><i class="bi bi-calendar-check fs-3"></i></div>
<div class="h4 mb-0 fw-bold">{{ metrics.total_reservations }}</div>
<small class="text-muted">{% trans %}Calendar Classes{% endtrans %}</small>
</div>
</div>
</div>
<!-- Sync Action Banner -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-4">
<div class="row align-items-center">
<div class="col-lg-8 mb-3 mb-lg-0">
<div class="d-flex align-items-center mb-2">
<span class="badge bg-success-subtle text-success border border-success-subtle px-3 py-1 me-2 fw-semibold">
<i class="bi bi-cloud-check me-1"></i>Google Sheets Live Integration
</span>
<span class="badge bg-light text-muted border">UniCABA 2026</span>
</div>
<h4 class="h5 mb-2 fw-bold">{% trans %}Official Schedule Board (Cartelera de Aulas){% endtrans %}</h4>
<p class="text-muted small mb-2">
{% trans %}Reads published CSV sheets for all 6 days of the week, automatically creating or updating academic careers, classrooms, subject codes, commissions, and weekly calendar entries without duplicating records.{% endtrans %}
</p>
<div class="d-flex flex-wrap gap-1">
{% for s in metrics.sheets %}
<span class="badge bg-body-tertiary text-secondary border small">
<i class="bi bi-calendar-event me-1 text-primary"></i>{{ s.name }}
</span>
{% endfor %}
</div>
</div>
<div class="col-lg-4 text-lg-end">
<form method="POST" id="syncForm" onsubmit="startSync()">
<button type="submit" id="syncBtn" class="btn btn-success btn-lg px-4 py-2 shadow fw-semibold">
<i class="bi bi-arrow-repeat me-2"></i>{% trans %}Sync Data Now{% endtrans %}
</button>
</form>
<div id="syncSpinner" class="d-none mt-2 text-muted small">
<div class="spinner-border spinner-border-sm text-success me-1" role="status"></div>
{% trans %}Connecting to Google Sheets and processing records...{% endtrans %}
</div>
</div>
</div>
</div>
</div>
<!-- Sync Results (if just completed) -->
{% if stats %}
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-success bg-opacity-10 text-success py-3">
<h5 class="card-title h6 mb-0 fw-bold">
<i class="bi bi-check-circle-fill me-2"></i>{% trans %}Synchronization Report{% endtrans %}
</h5>
</div>
<div class="card-body">
<div class="row g-3 text-center mb-3">
<div class="col-md-2">
<div class="p-2 border rounded bg-body-tertiary">
<div class="fw-bold fs-5">{{ stats.total_rows_processed }}</div>
<small class="text-muted">{% trans %}Rows Processed{% endtrans %}</small>
</div>
</div>
<div class="col-md-2">
<div class="p-2 border rounded bg-body-tertiary">
<div class="fw-bold fs-5 text-primary">+{{ stats.careers_created }}</div>
<small class="text-muted">{% trans %}Careers Created{% endtrans %}</small>
</div>
</div>
<div class="col-md-2">
<div class="p-2 border rounded bg-body-tertiary">
<div class="fw-bold fs-5 text-success">+{{ stats.classrooms_created }}</div>
<small class="text-muted">{% trans %}Classrooms Created{% endtrans %}</small>
</div>
</div>
<div class="col-md-2">
<div class="p-2 border rounded bg-body-tertiary">
<div class="fw-bold fs-5 text-info">+{{ stats.subjects_created }}</div>
<small class="text-muted">{% trans %}Subjects Created{% endtrans %}</small>
</div>
</div>
<div class="col-md-2">
<div class="p-2 border rounded bg-body-tertiary">
<div class="fw-bold fs-5 text-warning">+{{ stats.commissions_created }}</div>
<small class="text-muted">{% trans %}Commissions Created{% endtrans %}</small>
</div>
</div>
<div class="col-md-2">
<div class="p-2 border rounded bg-body-tertiary">
<div class="fw-bold fs-5 text-danger">+{{ stats.reservations_created }}</div>
<small class="text-muted">{% trans %}Reservations Created{% endtrans %}</small>
</div>
</div>
</div>
{% if stats.errors %}
<div class="alert alert-warning mb-0 small">
<h6 class="alert-heading fw-bold mb-1">{% trans %}Warnings / Errors detected:{% endtrans %}</h6>
<ul class="mb-0 ps-3">
{% for err in stats.errors %}
<li>{{ err }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
</div>
{% endif %}
<!-- Database Preview Table -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-body-tertiary d-flex justify-content-between align-items-center py-3">
<h5 class="card-title h6 mb-0">
<i class="bi bi-table text-primary me-2"></i>{% trans %}Recently Synchronized Subjects & Comissions{% endtrans %}
</h5>
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-calendar3 me-1"></i>{% trans %}View Calendar{% endtrans %}
</a>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="bg-body-tertiary">
<tr>
<th class="ps-4">{% trans %}Code{% endtrans %}</th>
<th>{% trans %}Subject Name{% endtrans %}</th>
<th>{% trans %}Career{% endtrans %}</th>
<th>{% trans %}Commissions{% endtrans %}</th>
<th>{% trans %}Status{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for s in recent_subjects %}
<tr>
<td class="ps-4">
<span class="badge bg-secondary-subtle text-secondary font-monospace border">
{{ s.code }}
</span>
</td>
<td>
<div class="fw-semibold">{{ s.name }}</div>
</td>
<td>
<span class="badge badge-brand small">
{{ s.career_obj.name if s.career_obj else (s.department or _('General')) }}
</span>
</td>
<td>
<span class="small text-muted">
{{ s.commissions|length }} {% trans %}commission(s){% endtrans %}
</span>
</td>
<td>
{% if s.is_active %}
<span class="badge bg-success-subtle text-success border border-success-subtle small">
<i class="bi bi-check-circle me-1"></i>{% trans %}Active{% endtrans %}
</span>
{% else %}
<span class="badge bg-secondary small">{% trans %}Inactive{% endtrans %}</span>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="text-center py-5 text-muted">
<i class="bi bi-cloud-arrow-down fs-1 d-block mb-2 text-secondary"></i>
{% trans %}No subjects in database yet. Click "Sync Data Now" to import.{% endtrans %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
function startSync() {
const btn = document.getElementById('syncBtn');
const spinner = document.getElementById('syncSpinner');
if (btn) {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status"></span>Sincronizando...';
}
if (spinner) {
spinner.classList.remove('d-none');
}
}
</script>
{% endblock %}
-124
View File
@@ -1,124 +0,0 @@
{% extends "base.html" %}
{% block title %}{% trans %}Roles y Grupos de Acceso{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<div>
<h1 class="h3 mb-1">
<i class="bi bi-person-badge text-primary me-2"></i>{% trans %}Roles y Grupos de Acceso{% endtrans %}
</h1>
<p class="text-muted small mb-0">{% trans %}Administración de perfiles de usuario y matriz granular de permisos por módulo del sistema.{% endtrans %}</p>
</div>
<div>
<a href="{{ url_for('admin.role_add') }}" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i>{% trans %}Nuevo Rol{% endtrans %}
</a>
</div>
</div>
<!-- Roles Grid / Table -->
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="bg-body-tertiary">
<tr>
<th class="ps-4">{% trans %}Nombre del Rol{% endtrans %}</th>
<th>{% trans %}Descripción{% endtrans %}</th>
<th>{% trans %}Integrantes{% endtrans %}</th>
<th>{% trans %}Resumen de Permisos{% endtrans %}</th>
<th class="text-end pe-4">{% trans %}Acciones{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for role in roles %}
<tr>
<td class="ps-4">
<div class="d-flex align-items-center">
<div class="badge-brand rounded-circle p-2 me-3 d-inline-flex align-items-center justify-content-center" style="width: 38px; height: 38px;">
<i class="bi bi-shield-check fs-6 text-white"></i>
</div>
<div>
<div class="fw-bold text-primary">{{ role.name }}</div>
{% if role.is_system %}
<span class="badge bg-secondary-subtle text-secondary border small">{% trans %}Protegido del Sistema{% endtrans %}</span>
{% endif %}
</div>
</div>
</td>
<td>
<span class="text-muted small">{{ role.description or _('Sin descripción provista.') }}</span>
</td>
<td>
<span class="badge bg-primary-subtle text-primary border px-2 py-1">
<i class="bi bi-people me-1"></i>{{ role.users|length }} {% trans %}usuarios{% endtrans %}
</span>
</td>
<td>
<div class="d-flex flex-wrap gap-1">
{% for mod in modules %}
{% set p_level = role.get_permission(mod.id) %}
{% if p_level == 'read_write' %}
<span class="badge bg-success-subtle text-success border small" title="{{ mod.name }}: {% trans %}Lectura y Escritura{% endtrans %}">
<i class="bi {{ mod.icon }} me-1"></i>{{ mod.name }}: RW
</span>
{% elif p_level == 'read' %}
<span class="badge bg-info-subtle text-info border small" title="{{ mod.name }}: {% trans %}Solo Lectura{% endtrans %}">
<i class="bi {{ mod.icon }} me-1"></i>{{ mod.name }}: R
</span>
{% else %}
<span class="badge bg-light text-muted border small opacity-75" title="{{ mod.name }}: {% trans %}Sin Acceso{% endtrans %}">
<i class="bi {{ mod.icon }} me-1"></i>{{ mod.name }}: -
</span>
{% endif %}
{% endfor %}
</div>
</td>
<td class="text-end pe-4">
<div class="btn-group btn-group-sm">
<a href="{{ url_for('admin.role_edit', id=role.id) }}" class="btn btn-outline-primary" title="{% trans %}Configurar Permisos{% endtrans %}">
<i class="bi bi-pencil-square me-1"></i>{% trans %}Permisos{% endtrans %}
</a>
{% if not role.is_system %}
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#deleteModal{{ role.id }}" title="{% trans %}Eliminar Rol{% endtrans %}" {% if role.users|length > 0 %}disabled{% endif %}>
<i class="bi bi-trash"></i>
</button>
{% endif %}
</div>
{% if not role.is_system %}
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal{{ role.id }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow">
<div class="modal-header border-0">
<h5 class="modal-title text-danger">
<i class="bi bi-exclamation-triangle-fill me-2"></i>{% trans %}Confirmar Eliminación de Rol{% endtrans %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-start">
<p>{% trans %}¿Está seguro de que desea eliminar el rol{% endtrans %} <strong>{{ role.name }}</strong>?</p>
<p class="text-muted small mb-0">{% trans %}Esta acción no se puede deshacer.{% endtrans %}</p>
</div>
<div class="modal-footer border-0">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<form action="{{ url_for('admin.role_delete', id=role.id) }}" method="POST" class="d-inline">
<button type="submit" class="btn btn-danger">{% trans %}Eliminar Rol{% endtrans %}</button>
</form>
</div>
</div>
</div>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
-107
View File
@@ -1,107 +0,0 @@
{% extends "base.html" %}
{% block title %}{% if user %}{% trans %}Edit User{% endtrans %}: {{ user.name }}{% else %}{% trans %}New User{% endtrans %}{% endif %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-1">
<i class="bi bi-person-gear text-primary me-2"></i>
{% if user %}{% trans %}Edit User Account{% endtrans %}{% else %}{% trans %}Create User Account{% endtrans %}{% endif %}
</h1>
<p class="text-muted small mb-0">{% trans %}Configure user credentials, profile information, and role assignment.{% endtrans %}</p>
</div>
<div>
<a href="{{ url_for('admin.users_list') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>{% trans %}Back to Users{% endtrans %}
</a>
</div>
</div>
<div class="row">
<div class="col-lg-6 mx-auto">
<div class="card border-0 shadow-sm">
<div class="card-header bg-body-tertiary py-3">
<h5 class="card-title h6 mb-0">
<i class="bi bi-person-vcard text-primary me-2"></i>{% trans %}Account Details{% endtrans %}
</h5>
</div>
<div class="card-body p-4">
<form method="POST">
<div class="mb-3">
<label for="name" class="form-label fw-semibold">{% trans %}Full Name{% endtrans %} <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="name" name="name"
value="{{ user.name if user else '' }}" required
placeholder="{% trans %}e.g. María González{% endtrans %}">
</div>
<div class="mb-3">
<label for="email" class="form-label fw-semibold">{% trans %}Email Address{% endtrans %} <span class="text-danger">*</span></label>
<input type="email" class="form-control" id="email" name="email"
value="{{ user.email if user else '' }}" required
placeholder="{% trans %}e.g. mgonzalez@edu-space.com{% endtrans %}">
</div>
<div class="mb-3">
<label for="password" class="form-label fw-semibold">
{% trans %}Password{% endtrans %} {% if not user %}<span class="text-danger">*</span>{% endif %}
</label>
<input type="password" class="form-control" id="password" name="password"
{% if not user %}required{% endif %}
placeholder="{% if user %}{% trans %}Leave empty to keep current password{% endtrans %}{% else %}{% trans %}At least 6 characters{% endtrans %}{% endif %}">
{% if user %}
<small class="text-muted d-block mt-1">
{% trans %}Only fill this in if you want to reset this user's password.{% endtrans %}
</small>
{% endif %}
</div>
<div class="mb-3">
<label for="role_id" class="form-label fw-semibold">{% trans %}Role / Access Group{% endtrans %} <span class="text-danger">*</span></label>
<select class="form-select" id="role_id" name="role_id" required>
<option value="" disabled {% if not user or not user.role_id %}selected{% endif %}>{% trans %}Select a role...{% endtrans %}</option>
{% for r in roles %}
<option value="{{ r.id }}" {% if user and user.role_id == r.id %}selected{% endif %}>
{{ r.name }} - {{ r.description or '' }}
</option>
{% endfor %}
</select>
<small class="text-muted d-block mt-1">
<a href="{{ url_for('admin.roles_list') }}" target="_blank" class="text-decoration-none">
<i class="bi bi-box-arrow-up-right me-1"></i>{% trans %}View or manage roles and permissions{% endtrans %}
</a>
</small>
</div>
<div class="mb-4 form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="is_active" name="is_active"
{% if not user or user.is_active %}checked{% endif %}
{% if user and user.id == current_user.id %}disabled{% endif %}>
<label class="form-check-label fw-semibold" for="is_active">
{% trans %}Active Account{% endtrans %}
</label>
<small class="text-muted d-block">
{% trans %}Inactive users are prevented from signing in to the platform.{% endtrans %}
</small>
{% if user and user.id == current_user.id %}
<input type="hidden" name="is_active" value="on">
{% endif %}
</div>
<div class="d-flex justify-content-between align-items-center pt-2">
<a href="{{ url_for('admin.users_list') }}" class="btn btn-outline-secondary">
{% trans %}Cancel{% endtrans %}
</a>
<button type="submit" class="btn btn-primary px-4 fw-semibold">
<i class="bi bi-check-lg me-1"></i>{% trans %}Save User{% endtrans %}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
-182
View File
@@ -1,182 +0,0 @@
{% extends "base.html" %}
{% block title %}{% trans %}User Management{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-1">
<i class="bi bi-people text-primary me-2"></i>{% trans %}User Management{% endtrans %}
</h1>
<p class="text-muted small mb-0">{% trans %}Manage user accounts, assign roles, and control active status.{% endtrans %}</p>
</div>
<div>
<a href="{{ url_for('admin.user_add') }}" class="btn btn-primary">
<i class="bi bi-person-plus me-1"></i>{% trans %}New User{% endtrans %}
</a>
</div>
</div>
<!-- Filters & Search -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-3">
<form method="GET" class="row g-2 align-items-center">
<div class="col-md-5">
<div class="input-group input-group-sm">
<span class="input-group-text bg-body-tertiary border-end-0">
<i class="bi bi-search text-muted"></i>
</span>
<input type="text" name="search" class="form-control border-start-0"
placeholder="{% trans %}Search by name or email...{% endtrans %}"
value="{{ search }}">
</div>
</div>
<div class="col-md-3">
<select name="role" class="form-select form-select-sm">
<option value="">{% trans %}All Roles{% endtrans %}</option>
{% for r in roles %}
<option value="{{ r.id }}" {% if role_filter == (r.id|string) %}selected{% endif %}>{{ r.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<select name="status" class="form-select form-select-sm">
<option value="">{% trans %}All Statuses{% endtrans %}</option>
<option value="active" {% if status_filter == 'active' %}selected{% endif %}>{% trans %}Active{% endtrans %}</option>
<option value="inactive" {% if status_filter == 'inactive' %}selected{% endif %}>{% trans %}Inactive{% endtrans %}</option>
</select>
</div>
<div class="col-md-2 d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm w-100">
<i class="bi bi-filter me-1"></i>{% trans %}Filter{% endtrans %}
</button>
{% if search or role_filter or status_filter %}
<a href="{{ url_for('admin.users_list') }}" class="btn btn-outline-secondary btn-sm" title="{% trans %}Clear filters{% endtrans %}">
<i class="bi bi-x-lg"></i>
</a>
{% endif %}
</div>
</form>
</div>
</div>
<!-- Users Table -->
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="bg-body-tertiary">
<tr>
<th class="ps-4">{% trans %}User{% endtrans %}</th>
<th>{% trans %}Email{% endtrans %}</th>
<th>{% trans %}Role / Group{% endtrans %}</th>
<th>{% trans %}Status{% endtrans %}</th>
<th>{% trans %}Last Login{% endtrans %}</th>
<th class="text-end pe-4">{% trans %}Actions{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td class="ps-4">
<div class="d-flex align-items-center">
<div class="user-avatar-small me-2">
{{ (u.first_name[0] if u.first_name else 'U')|upper }}
</div>
<div>
<div class="fw-bold">{{ u.name }}</div>
<small class="text-muted">ID: #{{ u.id }}</small>
</div>
</div>
</td>
<td>
<span class="font-monospace small">{{ u.email }}</span>
</td>
<td>
{% if u.role_obj %}
<span class="badge badge-brand">
<i class="bi bi-shield-check me-1"></i>{{ u.role_obj.name }}
</span>
{% else %}
<span class="badge bg-secondary">
{{ u.role or _('No Role') }}
</span>
{% endif %}
</td>
<td>
{% if u.is_active %}
<span class="badge bg-success-subtle text-success border border-success-subtle px-2 py-1">
<i class="bi bi-check-circle me-1"></i>{% trans %}Active{% endtrans %}
</span>
{% else %}
<span class="badge bg-danger-subtle text-danger border border-danger-subtle px-2 py-1">
<i class="bi bi-dash-circle me-1"></i>{% trans %}Inactive{% endtrans %}
</span>
{% endif %}
</td>
<td>
<small class="text-muted">
{% if u.last_login %}
{{ format_datetime(u.last_login, format='short') }}
{% else %}
{% trans %}Never{% endtrans %}
{% endif %}
</small>
</td>
<td class="text-end pe-4">
<div class="btn-group btn-group-sm">
<a href="{{ url_for('admin.user_edit', id=u.id) }}" class="btn btn-outline-primary" title="{% trans %}Edit User{% endtrans %}">
<i class="bi bi-pencil"></i>
</a>
{% if u.id != current_user.id %}
<form action="{{ url_for('admin.user_toggle', id=u.id) }}" method="POST" class="d-inline">
<button type="submit" class="btn {% if u.is_active %}btn-outline-warning{% else %}btn-outline-success{% endif %}"
title="{% if u.is_active %}{% trans %}Deactivate User{% endtrans %}{% else %}{% trans %}Activate User{% endtrans %}{% endif %}">
<i class="bi {% if u.is_active %}bi-person-x{% else %}bi-person-check{% endif %}"></i>
</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center py-5 text-muted">
<i class="bi bi-people fs-1 d-block mb-2 text-secondary"></i>
{% trans %}No users found matching the criteria.{% endtrans %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if pagination.pages > 1 %}
<div class="card-footer bg-body-tertiary d-flex justify-content-between align-items-center py-2 px-4">
<small class="text-muted">
{% trans %}Showing page{% endtrans %} {{ pagination.page }} {% trans %}of{% endtrans %} {{ pagination.pages }} ({{ pagination.total }} {% trans %}total{% endtrans %})
</small>
<ul class="pagination pagination-sm mb-0">
{% if pagination.has_prev %}
<li class="page-item"><a class="page-link" href="{{ url_for('admin.users_list', page=pagination.prev_num, search=search, role=role_filter, status=status_filter) }}">&laquo;</a></li>
{% endif %}
{% for p in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
{% if p %}
<li class="page-item {% if p == pagination.page %}active{% endif %}">
<a class="page-link" href="{{ url_for('admin.users_list', page=p, search=search, role=role_filter, status=status_filter) }}">{{ p }}</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link">…</span></li>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<li class="page-item"><a class="page-link" href="{{ url_for('admin.users_list', page=pagination.next_num, search=search, role=role_filter, status=status_filter) }}">&raquo;</a></li>
{% endif %}
</ul>
</div>
{% endif %}
</div>
</div>
{% endblock %}
-115
View File
@@ -1,115 +0,0 @@
<!DOCTYPE html>
<html lang="{{ current_lang }}" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% trans %}Login - Edu-Space Admin{% endtrans %}</title>
<!-- Favicon -->
<link rel="icon" type="image/png" href="{{ url_for('static', filename='img/favicon.png') }}">
<!-- Script Anti-Flicker para Modo Claro / Oscuro -->
<script>
(function() {
const storedTheme = localStorage.getItem('theme');
const theme = storedTheme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-bs-theme', theme);
})();
</script>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
<!-- Design System Theme -->
<link href="{{ url_for('static', filename='css/theme.css') }}" rel="stylesheet">
</head>
<body>
<div class="auth-wrapper">
<div class="auth-card">
<!-- Header con Controles Superiores & Logo Oficial -->
<div class="auth-header">
<div class="auth-controls">
<button class="theme-toggle-btn" type="button" aria-label="Cambiar tema" title="Modo Claro/Oscuro">
<i class="bi bi-moon-stars-fill"></i>
</button>
{% include 'partials/language_switcher.html' %}
</div>
<img src="{{ url_for('static', filename='img/logo_unicaba.png') }}" alt="UniCABA" class="auth-logo">
<h4 class="fw-bold mb-1">Edu-Space Admin</h4>
<p class="text-muted small mb-0">{% trans %}Classroom Management System{% endtrans %}</p>
</div>
<div class="px-4 pb-4 pt-2">
<!-- Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show rounded-3 py-2 small" role="alert">
{{ message }}
<button type="button" class="btn-close py-2" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" novalidate>
{{ form.hidden_tag() }}
<div class="mb-3">
<label for="email" class="form-label small fw-semibold text-muted">{{ form.email.label.text }}</label>
<div class="input-group">
<span class="input-group-text bg-body-tertiary border-end-0 text-muted">
<i class="bi bi-envelope"></i>
</span>
<input type="email" class="form-control border-start-0 ps-0" id="email" name="email"
value="{{ form.email.data or '' }}" required
placeholder="{% trans %}Enter your email{% endtrans %}">
</div>
{% for error in form.email.errors %}
<div class="text-danger small mt-1">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-3">
<label for="password" class="form-label small fw-semibold text-muted">{{ form.password.label.text }}</label>
<div class="input-group">
<span class="input-group-text bg-body-tertiary border-end-0 text-muted">
<i class="bi bi-lock"></i>
</span>
<input type="password" class="form-control border-start-0 ps-0" id="password" name="password"
required placeholder="{% trans %}Enter your password{% endtrans %}">
</div>
{% for error in form.password.errors %}
<div class="text-danger small mt-1">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-4 form-check">
{{ form.remember_me(class="form-check-input") }}
<label class="form-check-label text-muted small" for="remember_me">
{{ form.remember_me.label.text }}
</label>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg fw-semibold py-2">
<i class="bi bi-box-arrow-in-right me-1"></i> {{ form.submit.label.text }}
</button>
</div>
</form>
<div class="text-center mt-4">
<small class="text-muted d-inline-flex align-items-center">
<i class="bi bi-shield-check text-primary me-1"></i>
{% trans %}Secure Login • All access is logged{% endtrans %}
</small>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ url_for('static', filename='js/theme-toggle.js') }}"></script>
</body>
</html>
-194
View File
@@ -1,194 +0,0 @@
<!DOCTYPE html>
<html lang="{{ current_lang }}" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{% trans %}Edu-Space Admin{% endtrans %}{% endblock %}</title>
<!-- Favicon -->
<link rel="icon" type="image/png" href="{{ url_for('static', filename='img/favicon.png') }}">
<!-- Script Anti-Flicker para Modo Claro / Oscuro -->
<script>
(function() {
let userTheme = "{{ current_user.theme_preference if (current_user and current_user.is_authenticated) else '' }}";
let theme = userTheme;
if (!theme || theme === 'auto') {
const storedTheme = localStorage.getItem('theme');
theme = storedTheme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
}
document.documentElement.setAttribute('data-bs-theme', theme);
})();
</script>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
<!-- Design System & Custom Theme -->
<link href="{{ url_for('static', filename='css/theme.css') }}" rel="stylesheet">
<link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
{% block extra_css %}{% endblock %}
</head>
<body data-user-authenticated="{{ 'true' if (current_user and current_user.is_authenticated) else 'false' }}">
{% if current_user.is_authenticated %}
<!-- Navigation -->
<nav class="navbar navbar-expand-lg navbar-dark navbar-app fixed-top">
<div class="container-fluid">
<a class="navbar-brand d-flex align-items-center" href="{{ url_for('main.dashboard') }}">
<img src="{{ url_for('static', filename='img/logo_unicaba_mini.png') }}" alt="Logo UniCABA" class="navbar-logo me-2">
<span>Edu-Space Admin</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.dashboard') }}">
<i class="bi bi-speedometer2"></i> {% trans %}Dashboard{% endtrans %}
</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-door-open"></i> {% trans %}Classrooms{% endtrans %}
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms') }}">
<i class="bi bi-list"></i> {% trans %}All Classrooms{% endtrans %}
</a></li>
{% if current_user.has_permission('classrooms', 'read_write') %}
<li><a class="dropdown-item" href="{{ url_for('classrooms.add_classroom') }}">
<i class="bi bi-plus-circle"></i> {% trans %}Add Classroom{% endtrans %}
</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}">
<i class="bi bi-buildings"></i> {% trans %}Buildings{% endtrans %}
</a></li>
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-calendar3"></i> {% trans %}Schedule{% endtrans %}
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ url_for('schedule.calendar_view') }}">
<i class="bi bi-calendar-week"></i> {% trans %}Calendar View{% endtrans %}
</a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}">
<i class="bi bi-list-check"></i> {% trans %}All Reservations{% endtrans %}
</a></li>
{% if current_user.has_permission('reservations', 'read_write') %}
<li><a class="dropdown-item" href="{{ url_for('schedule.add_reservation') }}">
<i class="bi bi-plus-circle"></i> {% trans %}New Reservation{% endtrans %}
</a></li>
{% endif %}
<li><a class="dropdown-item" href="{{ url_for('schedule.today_schedule') }}">
<i class="bi bi-calendar-today"></i> {% trans %}Today's Schedule{% endtrans %}
</a></li>
</ul>
</li>
{% if current_user.is_authenticated and current_user.can_manage() %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-shield-lock"></i> {% trans %}Management{% endtrans %}
</a>
<ul class="dropdown-menu shadow-sm">
<li><a class="dropdown-item" href="{{ url_for('admin.users_list') }}"><i class="bi bi-people me-2"></i>{% trans %}Users{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.roles_list') }}"><i class="bi bi-person-badge me-2"></i>{% trans %}Roles & Groups{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}"><i class="bi bi-buildings me-2"></i>{% trans %}Buildings{% endtrans %}</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('admin.google_sheets_import') }}"><i class="bi bi-file-earmark-spreadsheet me-2 text-success"></i>{% trans %}Import from Google Sheets{% endtrans %}</a></li>
</ul>
</li>
{% endif %}
</ul>
<ul class="navbar-nav align-items-center">
<!-- Theme Mode Toggle Button -->
<li class="nav-item me-2">
<button class="theme-toggle-btn" type="button" aria-label="Cambiar tema" title="Modo Claro/Oscuro">
<i class="bi bi-moon-stars-fill"></i>
</button>
</li>
<!-- Language Switcher -->
<li class="nav-item me-2">
{% include 'partials/language_switcher.html' %}
</li>
<!-- User Dropdown with Avatar -->
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle d-flex align-items-center py-1" href="#" role="button" data-bs-toggle="dropdown">
<span class="user-avatar-small">{{ current_user.name[:1]|upper if current_user.name else 'U' }}</span>
<span>{{ current_user.first_name }}</span>
</a>
<ul class="dropdown-menu dropdown-menu-end shadow-sm border-0">
<li>
<div class="px-3 py-2 border-bottom">
<small class="text-muted d-block">{% trans %}Signed in as{% endtrans %}</small>
<span class="fw-bold small">{{ current_user.email }}</span>
</div>
</li>
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}">
<i class="bi bi-person me-2"></i> {% trans %}Profile{% endtrans %}
</a></li>
<li><a class="dropdown-item" href="{{ url_for('auth.change_password') }}">
<i class="bi bi-key me-2"></i> {% trans %}Change Password{% endtrans %}
</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger" href="{{ url_for('auth.logout') }}">
<i class="bi bi-box-arrow-right me-2"></i> {% trans %}Logout{% endtrans %}
</a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
{% endif %}
<!-- Flash Messages -->
<div class="toast-container position-fixed bottom-0 end-0 p-3" style="z-index: 1080;">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="toast align-items-center text-white bg-{{ 'danger' if category == 'error' else 'success' if category == 'success' else 'info' if category == 'info' else 'warning' }} border-0 shadow" role="alert">
<div class="d-flex">
<div class="toast-body">
{{ message }}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
<!-- Main Content -->
{% block content %}{% endblock %}
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- jQuery -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<!-- Theme Toggle JS -->
<script src="{{ url_for('static', filename='js/theme-toggle.js') }}"></script>
<!-- Custom JS -->
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
<!-- Show flash messages -->
<script>
$(document).ready(function() {
$('.toast').toast('show');
});
</script>
{% block extra_js %}{% endblock %}
</body>
</html>
-218
View File
@@ -1,218 +0,0 @@
{% extends "base.html" %}
{% block title %}{% trans %}Add Classroom{% endtrans %} - 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> {% trans %}Add Classroom{% endtrans %}
</h1>
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> {% trans %}Back to Classrooms{% endtrans %}
</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-body-tertiary border-0 py-3">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-door-open text-primary me-2"></i> {% trans %}Classroom Information{% endtrans %}
</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">
<div class="d-flex justify-content-between align-items-center mb-1">
{{ form.capacity.label(class="form-label mb-0") }}
<button type="button" class="btn btn-sm btn-outline-primary py-0 px-2" id="btnInfinityAdd" style="font-size: 0.8rem;" title="{% trans %}Asignar capacidad ilimitada{% endtrans %}">
<i class="bi bi-infinity me-1"></i>{% trans %}Ilimitada (∞){% endtrans %}
</button>
</div>
<div class="input-group">
{{ form.capacity(class="form-control", id="capacity", placeholder="ej. 30 o ∞") }}
<span class="input-group-text bg-body-tertiary text-muted font-monospace"><i class="bi bi-people"></i></span>
</div>
<div class="form-text small text-muted mt-1">
<i class="bi bi-info-circle text-info me-1"></i>{% trans %}Para aulas virtuales o sin límite de aforo, ingrese <strong>∞</strong> o <strong>0</strong> (Capacidad ilimitada).{% endtrans %}
</div>
{% 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">
<label class="form-label d-block">&nbsp;</label>
<div class="form-check pt-2">
{{ form.is_active(class="form-check-input") }}
{{ form.is_active.label(class="form-check-label") }}
</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> {% trans %}Preview{% endtrans %}
</h6>
<div id="classroom-preview">
<p class="mb-1">{% trans %}Fill in the form to see a preview of the classroom code:{% endtrans %}
<span class="fw-bold" id="preview-code">-</span>
</p>
<p class="mb-0">{% trans %}Full location:{% endtrans %}
<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> {% trans %}Cancel{% endtrans %}
</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-save me-1"></i> {{ form.submit.label.text }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Resources Section -->
<div class="row mt-4 mb-4">
<div class="col-lg-8 mx-auto">
<div class="card border-0 shadow-sm">
<div class="card-header bg-body-tertiary border-0 py-3">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-tools text-warning me-2"></i> {% trans %}Resources (Optional){% endtrans %}
</h5>
</div>
<div class="card-body">
<p class="text-muted mb-0">{% trans %}You can add classroom resources after creating the classroom.{% endtrans %}</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();
const floorLabel = '{{ _("Floor") }}';
if (building && roomNumber && floor) {
$('#preview-code').text(`${building}-${roomNumber}`);
$('#preview-location').text(`${building} - ${floorLabel} ${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;
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');
}
var floorVal = $('#floor').val();
if (floorVal === '' || isNaN(floorVal)) {
$('#floor').addClass('is-invalid');
isValid = false;
} else {
$('#floor').removeClass('is-invalid');
}
var capVal = $('#capacity').val() ? $('#capacity').val().trim() : '';
if (!capVal || (capVal !== '∞' && capVal !== '0' && isNaN(capVal))) {
$('#capacity').addClass('is-invalid');
isValid = false;
} else {
$('#capacity').removeClass('is-invalid');
}
return isValid;
});
$('#btnInfinityAdd').on('click', function() {
$('#capacity').val('∞').focus();
});
});
</script>
{% endblock %}
-162
View File
@@ -1,162 +0,0 @@
{% extends "base.html" %}
{% block title %}{% trans %}Edit Classroom{% endtrans %} - {{ classroom.code_display }} - 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">
<div>
<h1 class="h3 mb-1">
<i class="bi bi-pencil-square text-primary me-2"></i>{% trans %}Edit Classroom{% endtrans %}
</h1>
<p class="text-muted small mb-0">{{ classroom.building }} — {{ classroom.code }}</p>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}" class="btn btn-outline-primary">
<i class="bi bi-eye me-1"></i>{% trans %}Ver Ficha{% endtrans %}
</a>
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>{% trans %}Back to Classrooms{% endtrans %}
</a>
</div>
</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-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-door-open text-primary me-2"></i>{% trans %}Classroom Information{% endtrans %}
</h5>
<span class="badge bg-secondary-subtle text-secondary font-monospace border">ID #{{ classroom.id }}</span>
</div>
<div class="card-body p-4">
<form method="POST" id="classroom-form" novalidate>
{{ form.hidden_tag() }}
<div class="row g-3">
<div class="col-md-4">
{{ form.building.label(class="form-label fw-semibold") }}
{{ form.building(class="form-control" + (" is-invalid" if form.building.errors else ""), placeholder="Edificio Central") }}
{% if form.building.errors %}
<div class="invalid-feedback">{{ form.building.errors[0] }}</div>
{% endif %}
</div>
<div class="col-md-4">
{{ form.room_number.label(class="form-label fw-semibold") }}
{{ form.room_number(class="form-control" + (" is-invalid" if form.room_number.errors else ""), placeholder="402") }}
{% if form.room_number.errors %}
<div class="invalid-feedback">{{ form.room_number.errors[0] }}</div>
{% endif %}
</div>
<div class="col-md-4">
{{ form.floor.label(class="form-label fw-semibold") }}
{{ form.floor(class="form-control" + (" is-invalid" if form.floor.errors else "")) }}
{% if form.floor.errors %}
<div class="invalid-feedback">{{ form.floor.errors[0] }}</div>
{% endif %}
</div>
<div class="col-md-6">
<div class="d-flex justify-content-between align-items-center mb-1">
{{ form.capacity.label(class="form-label fw-semibold mb-0") }}
<button type="button" class="btn btn-sm btn-outline-primary py-0 px-2" id="btnInfinity" style="font-size: 0.8rem;" title="{% trans %}Asignar capacidad ilimitada{% endtrans %}">
<i class="bi bi-infinity me-1"></i>{% trans %}Ilimitada (∞){% endtrans %}
</button>
</div>
<div class="input-group">
{{ form.capacity(class="form-control" + (" is-invalid" if form.capacity.errors else ""), id="capacityInput", placeholder="ej. 35 o ∞") }}
<span class="input-group-text bg-body-tertiary text-muted font-monospace"><i class="bi bi-people"></i></span>
</div>
<div class="form-text small text-muted mt-1">
<i class="bi bi-info-circle text-info me-1"></i>{% trans %}Para aulas virtuales o eventos sin aforo restringido, ingrese <strong>∞</strong> o <strong>0</strong> (Capacidad ilimitada).{% endtrans %}
</div>
{% if form.capacity.errors %}
<div class="invalid-feedback d-block">{{ form.capacity.errors[0] }}</div>
{% endif %}
</div>
<div class="col-md-6 d-flex align-items-center pt-md-4">
<div class="form-check form-switch pt-2">
{{ form.is_active(class="form-check-input", role="switch") }}
{{ form.is_active.label(class="form-check-label fw-semibold") }}
</div>
</div>
<div class="col-12">
{{ form.description.label(class="form-label fw-semibold") }}
{{ form.description(class="form-control" + (" is-invalid" if form.description.errors else ""), rows="3", placeholder="Detalles sobre equipamiento o uso preferente del aula...") }}
{% if form.description.errors %}
<div class="invalid-feedback">{{ form.description.errors[0] }}</div>
{% endif %}
</div>
</div>
<!-- Live Preview Card -->
<div class="alert alert-info border-0 mt-4 mb-0">
<h6 class="alert-heading fw-bold mb-2">
<i class="bi bi-info-circle me-1"></i>{% trans %}Vista Previa de Identificador{% endtrans %}
</h6>
<div id="classroom-preview">
<p class="mb-0">
{% trans %}Código de visualización{% endtrans %}:
<strong id="preview-code" class="text-primary font-monospace fs-6">{{ classroom.code_display }}</strong>
</p>
</div>
</div>
<div class="d-flex justify-content-end gap-2 mt-4 pt-3 border-top">
<a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}" class="btn btn-outline-secondary">
{% trans %}Cancelar{% endtrans %}
</a>
<button type="submit" class="btn btn-primary px-4">
<i class="bi bi-check-circle me-1"></i>{% trans %}Guardar Cambios{% endtrans %}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
var buildingInput = document.getElementById('building');
var roomInput = document.getElementById('room_number');
var previewCode = document.getElementById('preview-code');
var capacityInput = document.getElementById('capacityInput');
var btnInfinity = document.getElementById('btnInfinity');
function updatePreview() {
var b = buildingInput ? buildingInput.value.trim() : '';
var r = roomInput ? roomInput.value.trim() : '';
if (b && r) {
previewCode.textContent = b + '-' + r;
} else if (r) {
previewCode.textContent = r;
} else {
previewCode.textContent = '---';
}
}
if (buildingInput) buildingInput.addEventListener('input', updatePreview);
if (roomInput) roomInput.addEventListener('input', updatePreview);
if (btnInfinity && capacityInput) {
btnInfinity.addEventListener('click', function() {
capacityInput.value = '∞';
capacityInput.focus();
});
}
});
</script>
{% endblock %}
-207
View File
@@ -1,207 +0,0 @@
{% extends "base.html" %}
{% block title %}{% trans %}Classrooms{% endtrans %} - 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> {% trans %}Classrooms{% endtrans %}
</h1>
<div>
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> {% trans %}Add Classroom{% endtrans %}
</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="{% trans %}Search by building, room, description...{% endtrans %}"
value="{{ search }}">
</div>
<div class="col-md-3">
<div class="form-check pt-2">
<input class="form-check-input" type="checkbox" name="active_only"
id="activeOnly" {% if active_only %}checked{% endif %}>
<label class="form-check-label" for="activeOnly">
{% trans %}Show active classrooms only{% endtrans %}
</label>
</div>
</div>
<div class="col-md-3">
<button type="submit" class="btn btn-outline-primary">
<i class="bi bi-search"></i> {% trans %}Search{% endtrans %}
</button>
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle"></i> {% trans %}Clear{% endtrans %}
</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_display }}
</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 %}{% trans %}Active{% endtrans %}{% else %}{% trans %}Inactive{% endtrans %}{% endif %}
</span>
</div>
<div class="row small">
<div class="col-6">
<span class="text-muted">{% trans %}Capacity{% endtrans %}:</span>
<strong>
{% if classroom.is_unlimited %}
<span class="badge bg-primary-subtle text-primary border"><i class="bi bi-infinity me-1"></i>∞</span>
{% else %}
{{ classroom.capacity }}
{% endif %}
</strong>
</div>
<div class="col-6">
<span class="text-muted">{% trans %}Building{% endtrans %}:</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 }} {% trans %}resources{% endtrans %}
{% else %}
{% trans %}No resources registered{% endtrans %}
{% endif %}
</small>
</div>
</div>
<div class="card-footer bg-body-tertiary 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> {% trans %}View{% endtrans %}
</a>
<a href="{{ url_for('classrooms.edit_classroom', id=classroom.id) }}"
class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pencil"></i> {% trans %}Edit{% endtrans %}
</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> {% trans %}Deactivate{% endtrans %}
{% else %}
<i class="bi bi-play-circle"></i> {% trans %}Activate{% endtrans %}
{% 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) }}">
{% trans %}Previous{% endtrans %}
</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) }}">
{% trans %}Next{% endtrans %}
</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">{% trans %}No classrooms found{% endtrans %}</h4>
<p class="text-muted">
{% if search %}
{% trans %}No classrooms match your search criteria.{% endtrans %}
{% else %}
{% trans %}No classrooms have been registered yet.{% endtrans %}
{% endif %}
</p>
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> {% trans %}Add First Classroom{% endtrans %}
</a>
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}
-296
View File
@@ -1,296 +0,0 @@
{% extends "base.html" %}
{% block title %}{% trans %}Dashboard - Edu-Space Admin{% endtrans %}{% endblock %}
{% block content %}
{% if current_user.is_authenticated %}
<div class="container-fluid pt-5 mt-4">
{% endif %}
<div class="row mb-4">
<div class="col-12">
<h1 class="h3 mb-1">
<i class="bi bi-speedometer2 text-primary me-2"></i>{% trans %}Panel de Control{% endtrans %}
</h1>
<p class="text-muted small mb-0">{% trans %}Resumen general de espacios, materias y reservas académicas{% endtrans %}</p>
</div>
</div>
<!-- Statistics Cards with Direct Shortcuts -->
<div class="row mb-4 g-3">
<!-- 1. Total Aulas -->
<div class="col-md-6 col-xl-3">
<a href="{{ url_for('classrooms.list_classrooms') }}" class="text-decoration-none text-reset d-block h-100">
<div class="card border-0 shadow-sm h-100 dashboard-stat-card transition-all">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="text-muted small fw-semibold text-uppercase">{% trans %}Total de Aulas{% endtrans %}</span>
<h2 class="mb-0 fw-bold mt-1 text-primary">{{ stats.total_classrooms }}</h2>
</div>
<div class="bg-primary bg-opacity-10 rounded-circle p-3 text-primary">
<i class="bi bi-door-open fs-3"></i>
</div>
</div>
<div class="d-flex align-items-center justify-content-between pt-3 mt-2 border-top text-primary small fw-semibold">
<span>{% trans %}Ver todas las aulas{% endtrans %}</span>
<i class="bi bi-arrow-right"></i>
</div>
</div>
</div>
</a>
</div>
<!-- 2. Total Materias -->
<div class="col-md-6 col-xl-3">
<a href="{{ url_for('schedule.commissions_view') }}" class="text-decoration-none text-reset d-block h-100">
<div class="card border-0 shadow-sm h-100 dashboard-stat-card transition-all">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="text-muted small fw-semibold text-uppercase">{% trans %}Total de Materias{% endtrans %}</span>
<h2 class="mb-0 fw-bold mt-1 text-success">{{ stats.total_subjects }}</h2>
</div>
<div class="bg-success bg-opacity-10 rounded-circle p-3 text-success">
<i class="bi bi-book fs-3"></i>
</div>
</div>
<div class="d-flex align-items-center justify-content-between pt-3 mt-2 border-top text-success small fw-semibold">
<span>{% trans %}Gestionar materias{% endtrans %}</span>
<i class="bi bi-arrow-right"></i>
</div>
</div>
</div>
</a>
</div>
<!-- 3. Reservas de Hoy -->
<div class="col-md-6 col-xl-3">
<a href="{{ url_for('schedule.today_schedule') }}" class="text-decoration-none text-reset d-block h-100">
<div class="card border-0 shadow-sm h-100 dashboard-stat-card transition-all">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="text-muted small fw-semibold text-uppercase">{% trans %}Reservas de Hoy{% endtrans %}</span>
<h2 class="mb-0 fw-bold mt-1 text-info">{{ stats.today_reservations }}</h2>
</div>
<div class="bg-info bg-opacity-10 rounded-circle p-3 text-info">
<i class="bi bi-calendar-check fs-3"></i>
</div>
</div>
<div class="d-flex align-items-center justify-content-between pt-3 mt-2 border-top text-info small fw-semibold">
<span>{% trans %}Ver cartelera de hoy{% endtrans %}</span>
<i class="bi bi-arrow-right"></i>
</div>
</div>
</div>
</a>
</div>
<!-- 4. Esta Semana -->
<div class="col-md-6 col-xl-3">
<a href="{{ url_for('schedule.calendar_view') }}" class="text-decoration-none text-reset d-block h-100">
<div class="card border-0 shadow-sm h-100 dashboard-stat-card transition-all">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="text-muted small fw-semibold text-uppercase">{% trans %}Esta Semana{% endtrans %}</span>
<h2 class="mb-0 fw-bold mt-1 text-warning">{{ stats.week_reservations }}</h2>
</div>
<div class="bg-warning bg-opacity-10 rounded-circle p-3 text-warning">
<i class="bi bi-calendar-week fs-3"></i>
</div>
</div>
<div class="d-flex align-items-center justify-content-between pt-3 mt-2 border-top text-warning small fw-semibold">
<span>{% trans %}Abrir calendario semanal{% endtrans %}</span>
<i class="bi bi-arrow-right"></i>
</div>
</div>
</div>
</a>
</div>
</div>
<div class="row">
<!-- Monthly Chart -->
<div class="col-md-8 mb-4">
<div class="card border-0 shadow-sm">
<div class="card-header border-0 py-3 bg-body-tertiary">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-graph-up text-primary me-2"></i>{% trans %}Reservas Mensuales{% endtrans %}
</h5>
</div>
<div class="card-body">
<canvas id="monthlyChart" height="100"></canvas>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="col-md-4 mb-4">
<div class="card border-0 shadow-sm">
<div class="card-header border-0 py-3 bg-body-tertiary">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-lightning text-warning me-2"></i>{% trans %}Accesos Rápidos{% endtrans %}
</h5>
</div>
<div class="card-body p-3">
<div class="d-grid gap-2">
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary text-start">
<i class="bi bi-calendar3 me-2"></i>{% trans %}Calendario de Reservas{% endtrans %}
</a>
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-info text-start">
<i class="bi bi-calendar-day me-2"></i>{% trans %}Cartelera del Día{% endtrans %}
</a>
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary text-start">
<i class="bi bi-door-open me-2"></i>{% trans %}Listado de Aulas{% endtrans %}
</a>
<a href="{{ url_for('buildings.list_buildings') }}" class="btn btn-outline-secondary text-start">
<i class="bi bi-buildings me-2"></i>{% trans %}Gestión de Edificios{% endtrans %}
</a>
{% if current_user.has_permission('reservations', 'read_write') %}
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-outline-success text-start">
<i class="bi bi-calendar-plus me-2"></i>{% trans %}Nueva Reserva{% endtrans %}
</a>
{% endif %}
{% if current_user.has_permission('classrooms', 'read_write') %}
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-outline-primary text-start">
<i class="bi bi-plus-circle me-2"></i>{% trans %}Crear Nueva Aula{% endtrans %}
</a>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Upcoming Reservations -->
<div class="row">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-header border-0 py-3 bg-body-tertiary d-flex justify-content-between align-items-center">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-clock-history text-info me-2"></i>{% trans %}Próximas Reservas (Próximos 7 Días){% endtrans %}
</h5>
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-sm btn-outline-primary">
{% trans %}Ver en Calendario{% endtrans %}
</a>
</div>
<div class="card-body p-0">
{% if upcoming_reservations %}
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light small text-uppercase">
<tr>
<th class="ps-3">{% trans %}Horario{% endtrans %}</th>
<th>{% trans %}Aula{% endtrans %}</th>
<th>{% trans %}Asignatura / Código{% endtrans %}</th>
<th>{% trans %}Turno / Comisión{% endtrans %}</th>
<th>{% trans %}Estado{% endtrans %}</th>
<th class="text-end pe-3">{% trans %}Acciones{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for reservation in upcoming_reservations %}
<tr>
<td class="ps-3">
<div class="fw-bold font-monospace text-primary fs-6">
<i class="bi bi-clock text-warning me-1"></i>{{ reservation.start_time.strftime('%H:%M') }} - {{ reservation.end_time.strftime('%H:%M') }} hs
</div>
</td>
<td>
<span class="badge bg-secondary-subtle text-secondary border font-monospace">
{{ reservation.classroom.code if reservation.classroom else 'Sin Aula' }}
</span>
</td>
<td>
<div class="fw-semibold text-primary">{{ reservation.subject_name }}</div>
<small class="text-muted font-monospace">{{ reservation.subject_code }}</small>
</td>
<td>
<span class="badge bg-info-subtle text-info border me-1">
{{ reservation.shift or 'Tarde' }}
</span>
<small class="text-muted">{{ reservation.commission.code if reservation.commission else 'C1' }}</small>
</td>
<td>
{% if reservation.status == 'CONFIRMED' or reservation.status.value == 'CONFIRMED' %}
<span class="badge bg-success-subtle text-success border border-success-subtle">
<i class="bi bi-check-circle me-1"></i>{% trans %}Confirmada{% endtrans %}
</span>
{% else %}
<span class="badge bg-warning-subtle text-warning border border-warning-subtle">
<i class="bi bi-clock me-1"></i>{% trans %}Pendiente{% endtrans %}
</span>
{% endif %}
</td>
<td class="text-end pe-3">
<a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}"
class="btn btn-sm btn-outline-secondary py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="bi bi-calendar-x text-muted fs-1 d-block mb-2"></i>
<h6 class="fw-bold">{% trans %}No hay reservas para los próximos 7 días{% endtrans %}</h6>
<p class="text-muted small">{% trans %}Puedes programar nuevas reservas desde el calendario.{% endtrans %}</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% if current_user.is_authenticated %}
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
$(document).ready(function() {
// Fetch monthly data and create chart
$.get('/api/dashboard-stats')
.done(function(data) {
const ctx = document.getElementById('monthlyChart').getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels: data.monthly_data.map(item => item.month),
datasets: [{
label: 'Reservations',
data: data.monthly_data.map(item => item.reservations),
borderColor: '#667eea',
backgroundColor: 'rgba(102, 126, 234, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1
}
}
}
}
});
});
});
</script>
{% endblock %}
-373
View File
@@ -1,373 +0,0 @@
{% extends "base.html" %}
{% block title %}{% trans %}New Reservation{% endtrans %} - 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> {% trans %}New Reservation{% endtrans %}
</h1>
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> {% trans %}Back to Reservations{% endtrans %}
</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 border-0 py-3">
<h5 class="mb-0">
<i class="bi bi-calendar3 text-success"></i> {% trans %}Reservation Details{% endtrans %}
</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">{% trans %}Select a classroom for your reservation{% endtrans %}</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">{% trans %}Select the subject/commission{% endtrans %}</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">{% trans %}When will the class start?{% endtrans %}</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">{% trans %}When will the class end?{% endtrans %}</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">{% trans %}Number of expected attendees{% endtrans %}</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> {% trans %}Reservation Summary{% endtrans %}
</h6>
<div id="summary-content">
<p class="mb-2">
<strong>{% trans %}Selected Classroom:{% endtrans %}</strong>
<span id="summary-classroom">{% trans %}Not selected{% endtrans %}</span>
</p>
<p class="mb-2">
<strong>{% trans %}Duration:{% endtrans %}</strong>
<span id="summary-duration">{% trans %}Not calculated{% endtrans %}</span>
</p>
<p class="mb-0">
<strong>{% trans %}Commission:{% endtrans %}</strong>
<span id="summary-commission">{% trans %}Not selected{% endtrans %}</span>
</p>
</div>
</div>
<div class="d-flex justify-content-between">
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle"></i> {% trans %}Cancel{% endtrans %}
</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 border-0 py-3">
<h5 class="mb-0">
<i class="bi bi-question-circle text-info"></i> {% trans %}Reservation Guidelines{% endtrans %}
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6 class="text-primary">📅 {% trans %}Booking Rules{% endtrans %}</h6>
<ul class="small">
<li>{% trans %}Reservations can be made up to 3 months in advance{% endtrans %}</li>
<li>{% trans %}Cannot book in the past{% endtrans %}</li>
<li>{% trans %}Maximum duration: 8 hours per reservation{% endtrans %}</li>
<li>{% trans %}Cancellations must be 24 hours before start time{% endtrans %}</li>
</ul>
</div>
<div class="col-md-6">
<h6 class="text-warning">🚨 {% trans %}Important Notes{% endtrans %}</h6>
<ul class="small">
<li>{% trans %}Check classroom capacity before booking{% endtrans %}</li>
<li>{% trans %}Pending reservations require approval{% endtrans %}</li>
<li>{% trans %}Time slots must not overlap{% endtrans %}</li>
<li>{% trans %}System automatically detects conflicts{% endtrans %}</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script type="application/json" id="classroom-data">
{
{% for classroom in classroom_list %}
"{{ classroom.id }}": {
"capacity": {{ classroom.capacity }},
"code_display": {{ classroom.code_display|tojson }},
"name": {{ classroom.name|tojson }}
}{% if not loop.last %},{% endif %}
{% endfor %}
}
</script>
<script type="application/json" id="i18n-strings">
{
"not_selected": "{% trans %}Not selected{% endtrans %}",
"not_calculated": "{% trans %}Not calculated{% endtrans %}",
"invalid_time": "{% trans %}Invalid time range{% endtrans %}",
"hour": "{% trans %}hour{% endtrans %}",
"hours": "{% trans %}hours{% endtrans %}",
"minute": "{% trans %}minute{% endtrans %}",
"minutes": "{% trans %}minutes{% endtrans %}",
"capacity": "{% trans %}Capacity{% endtrans %}",
"capacity_exceeded": "{% trans %}Expected attendees exceeds classroom capacity{% endtrans %}"
}
</script>
<script>
$(document).ready(function() {
// Classroom data for capacity checking
let classroomData = {};
try {
const rawData = document.getElementById('classroom-data')?.textContent;
if (rawData) {
classroomData = JSON.parse(rawData);
}
} catch (err) {
console.error('Error parsing classroomData:', err);
}
// I18n strings
let i18n = {};
try {
const rawI18n = document.getElementById('i18n-strings')?.textContent;
if (rawI18n) {
i18n = JSON.parse(rawI18n);
}
} catch (err) {
console.error('Error parsing i18n:', err);
}
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]) {
const capLabel = i18n.capacity || 'Capacidad';
$('#summary-classroom').text(`${classroomData[classroomId].code_display} - ${classroomData[classroomId].name} (${capLabel}: ${classroomData[classroomId].capacity})`);
} else {
$('#summary-classroom').text(i18n.not_selected || 'No seleccionada');
}
// Update commission summary
if (commissionId) {
const commissionText = $('#commission-select option:selected').text();
$('#summary-commission').text(commissionText);
} else {
$('#summary-commission').text(i18n.not_selected || 'No seleccionada');
}
// 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) {
const hLabel = diffHours > 1 ? (i18n.hours || 'horas') : (i18n.hour || 'hora');
durationText += `${diffHours} ${hLabel}`;
}
if (diffMins > 0) {
if (durationText) durationText += ' ';
const mLabel = diffMins > 1 ? (i18n.minutes || 'minutos') : (i18n.minute || 'minuto');
durationText += `${diffMins} ${mLabel}`;
}
$('#summary-duration').text(durationText || '0 min');
} else {
$('#summary-duration').text(i18n.invalid_time || 'Rango horario inválido');
}
} else {
$('#summary-duration').text(i18n.not_calculated || 'No calculada');
}
}
// 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');
const msg = i18n.capacity_exceeded || 'Los asistentes esperados superan la capacidad del aula';
$('#expected_attendees').after(`
<div class="invalid-feedback">
${msg} (${attendees} > ${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 %}
-585
View File
@@ -1,585 +0,0 @@
{% extends "base.html" %}
{% block title %}{% trans %}Reservations Calendar{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<!-- Header -->
<div class="row mb-4 align-items-center">
<div class="col-md-7">
<h1 class="h3 mb-1">
<i class="bi bi-calendar3 text-primary me-2"></i>{% trans %}Reservations Calendar{% endtrans %}
</h1>
<p class="text-muted small mb-0">{% trans %}View & Manage Reservations{% endtrans %} — {% trans %}Horarios de cursada y asignación de aulas UniCABA{% endtrans %}</p>
</div>
<div class="col-md-5 text-md-end mt-3 mt-md-0">
{% if current_user.has_permission('reservations', 'read_write') %}
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-primary me-2">
<i class="bi bi-plus-circle me-1"></i>{% trans %}Add Reservation{% endtrans %}
</a>
{% endif %}
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
<i class="bi bi-list me-1"></i>{% trans %}List View{% endtrans %}
</a>
</div>
</div>
<!-- Filters Toolbar (Classroom & Subject Filters to prevent overlapping) -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-3">
<div class="row g-2 align-items-center">
<!-- Filter by Classroom -->
<div class="col-lg-3 col-md-6">
<label for="filterClassroom" class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-door-open text-success me-1"></i>{% trans %}Aula{% endtrans %}
</label>
<select id="filterClassroom" class="form-select form-select-sm">
<option value="">{% trans %}Todas las aulas{% endtrans %}</option>
{% for c in classrooms %}
<option value="{{ c.id }}">{{ c.code }} ({{ c.building }})</option>
{% endfor %}
</select>
</div>
<!-- Filter by Subject -->
<div class="col-lg-3 col-md-6">
<label for="filterSubject" class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-book text-primary me-1"></i>{% trans %}Materia{% endtrans %}
</label>
<select id="filterSubject" class="form-select form-select-sm">
<option value="">{% trans %}Todas las materias{% endtrans %}</option>
{% for s in subjects %}
<option value="{{ s.id }}">{{ s.name }} [{{ s.code }}]</option>
{% endfor %}
</select>
</div>
<!-- Filter by Career -->
<div class="col-lg-3 col-md-6">
<label for="filterCareer" class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-mortarboard text-info me-1"></i>{% trans %}Carrera{% endtrans %}
</label>
<select id="filterCareer" class="form-select form-select-sm">
<option value="">{% trans %}Todas las carreras{% endtrans %}</option>
{% for cr in careers %}
<option value="{{ cr.id }}">{{ cr.name }}</option>
{% endfor %}
</select>
</div>
<!-- Filter by Turno (Mañana / Tarde) -->
<div class="col-lg-3 col-md-6">
<label for="filterShift" class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-brightness-high text-warning me-1"></i>{% trans %}Turno{% endtrans %}
</label>
<select id="filterShift" class="form-select form-select-sm">
<option value="">{% trans %}Todos los turnos{% endtrans %}</option>
<option value="Mañana">{% trans %}Turno Mañana{% endtrans %}</option>
<option value="Tarde">{% trans %}Turno Tarde / Vespertino{% endtrans %}</option>
</select>
</div>
</div>
<!-- Second row of filters: Floor & Hide Virtual -->
<div class="row g-2 align-items-center mt-2 pt-2 border-top">
<!-- Filter by Floor -->
<div class="col-lg-3 col-md-4">
<label for="filterFloor" class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-layers text-secondary me-1"></i>{% trans %}Filtrar por Piso{% endtrans %}
</label>
<select id="filterFloor" class="form-select form-select-sm">
<option value="">{% trans %}Todos los pisos{% endtrans %}</option>
{% for f in available_floors %}
<option value="{{ f }}">{% if f == 0 %}{% trans %}Planta Baja (Piso 0){% endtrans %}{% else %}{% trans %}Piso{% endtrans %} {{ f }}{% endif %}</option>
{% endfor %}
</select>
</div>
<!-- Hide Virtual Classrooms Toggle -->
<div class="col-lg-6 col-md-5 pt-md-3">
<div class="form-check form-switch pt-1">
<input class="form-check-input" type="checkbox" role="switch" id="filterHideVirtual">
<label class="form-check-label fw-semibold small text-dark" for="filterHideVirtual">
<i class="bi bi-laptop me-1 text-primary"></i>{% trans %}Ocultar Aulas Virtuales{% endtrans %}
<span class="badge bg-secondary-subtle text-secondary border ms-1" style="font-size: 0.72rem;">{% trans %}Capacidad ilimitada{% endtrans %}</span>
</label>
</div>
</div>
<!-- Reset Filters Button -->
<div class="col-lg-3 col-md-3 text-md-end pt-md-3">
<button type="button" id="btnResetFilters" class="btn btn-outline-secondary btn-sm px-3" title="{% trans %}Restablecer filtros{% endtrans %}">
<i class="bi bi-arrow-counterclockwise me-1"></i>{% trans %}Limpiar Filtros{% endtrans %}
</button>
</div>
</div>
<!-- Active filter indicator & counter -->
<div class="d-flex align-items-center flex-wrap gap-2 mt-2 pt-2 border-top">
<small class="text-muted fw-semibold">
<i class="bi bi-funnel-fill text-secondary me-1"></i>{% trans %}Estado{% endtrans %}:
</small>
<span id="activeFilterBadge" class="badge bg-secondary-subtle text-secondary border small">
{% trans %}Mostrando todas las aulas y materias{% endtrans %}
</span>
<span class="badge badge-brand ms-auto small">
<i class="bi bi-calendar-check me-1"></i><span id="eventCountNumber">0</span> {% trans %}clases visibles{% endtrans %}
</span>
</div>
</div>
</div>
<!-- Calendar Card -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-body-tertiary border-0 py-3">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
<div class="d-flex align-items-center gap-3">
<span class="fw-bold small text-secondary text-uppercase tracking-wide">
<i class="bi bi-info-circle me-1"></i>{% trans %}Referencia{% endtrans %}:
</span>
<div class="d-flex align-items-center">
<span class="badge badge-brand me-1" style="width: 14px; height: 14px; border-radius: 4px;"></span>
<small class="text-muted">{% trans %}Clase Confirmada{% endtrans %}</small>
</div>
<div class="d-flex align-items-center">
<span class="badge bg-warning me-1" style="width: 14px; height: 14px; border-radius: 4px;"></span>
<small class="text-muted">{% trans %}Pendiente{% endtrans %}</small>
</div>
</div>
<div class="text-muted small">
<i class="bi bi-lightning-charge-fill text-warning me-1"></i>{% trans %}Haz clic en cualquier clase para ver los detalles completos{% endtrans %}
</div>
</div>
</div>
<div class="card-body p-3">
<!-- FullCalendar Container -->
<div id="calendar"></div>
</div>
</div>
<!-- Reservation Details Modal -->
<div class="modal fade" id="reservationModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content border-0 shadow-lg">
<div class="modal-header border-0 pb-0">
<div>
<span id="modalCategoryBadge" class="badge badge-brand mb-1">Clase Académica</span>
<h4 class="modal-title fw-bold text-primary" id="modalSubjectTitle">Detalle de la Clase</h4>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body pt-3" id="reservationDetails">
<div class="row g-3">
<div class="col-md-6">
<div class="p-3 rounded bg-body-tertiary h-100">
<h6 class="text-muted small text-uppercase fw-bold mb-2">
<i class="bi bi-book text-primary me-1"></i>{% trans %}Materia y Código{% endtrans %}
</h6>
<p class="h6 fw-bold mb-1" id="modalSubjectName">-</p>
<p class="mb-2">
<span class="badge bg-dark bg-opacity-75 font-monospace text-white" id="modalSubjectCode">-</span>
</p>
<p class="small text-muted mb-0" id="modalCareerWrap">
<i class="bi bi-mortarboard me-1"></i><span id="modalCareer">-</span>
</p>
</div>
</div>
<div class="col-md-6">
<div class="p-3 rounded bg-body-tertiary h-100">
<h6 class="text-muted small text-uppercase fw-bold mb-2">
<i class="bi bi-door-open text-success me-1"></i>{% trans %}Espacio Asignado{% endtrans %}
</h6>
<p class="h5 fw-bold text-success mb-1" id="modalClassroom">-</p>
<p class="small text-muted mb-0" id="modalBuilding">-</p>
</div>
</div>
<div class="col-md-6">
<div class="p-3 rounded bg-body-tertiary h-100">
<h6 class="text-muted small text-uppercase fw-bold mb-2">
<i class="bi bi-clock text-warning me-1"></i>{% trans %}Horario Programado{% endtrans %}
</h6>
<p class="h6 fw-bold mb-0" id="modalTimeRange">-</p>
</div>
</div>
<div class="col-md-6">
<div class="p-3 rounded bg-body-tertiary h-100">
<h6 class="text-muted small text-uppercase fw-bold mb-2">
<i class="bi bi-shield-check text-info me-1"></i>{% trans %}Estado y Capacidad{% endtrans %}
</h6>
<p class="mb-1">
<span class="badge bg-success-subtle text-success border border-success-subtle px-2 py-1" id="modalStatus">-</span>
</p>
<small class="text-muted" id="modalAttendees">-</small>
</div>
</div>
</div>
</div>
<div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Close{% endtrans %}</button>
<a href="#" id="modalFullLink" class="btn btn-primary">
<i class="bi bi-box-arrow-up-right me-1"></i>{% trans %}Ver Ficha Completa{% endtrans %}
</a>
</div>
</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>
/* FullCalendar Modern Overrides */
#calendar {
font-family: inherit;
min-height: 700px;
}
.fc .fc-toolbar-title {
font-size: 1.25rem !important;
font-weight: 700 !important;
color: var(--text-primary);
}
.fc .fc-button-primary {
background: var(--gradient-primary, linear-gradient(135deg, #B43E8E 0%, #9E367D 100%)) !important;
border: none !important;
border-radius: 6px !important;
font-weight: 600 !important;
font-size: 0.85rem !important;
padding: 0.35rem 0.75rem !important;
box-shadow: 0 2px 4px rgba(180, 62, 142, 0.25) !important;
}
.fc .fc-button-primary:hover {
background: linear-gradient(135deg, #9E367D 0%, #832765 100%) !important;
}
.fc .fc-button-primary:disabled {
background: #94A3B8 !important;
opacity: 0.6 !important;
}
.fc-theme-standard td, .fc-theme-standard th {
border-color: var(--card-border, #E2E8F0) !important;
}
.fc .fc-col-header-cell-cushion {
color: var(--text-primary);
font-weight: 600;
padding: 8px 4px !important;
text-decoration: none !important;
}
.fc .fc-timegrid-slot-label-cushion {
color: var(--text-secondary, #64748B);
font-size: 0.78rem;
}
/* Event Card Visual Hierarchy */
.fc-event {
cursor: pointer;
border: none !important;
border-radius: 8px !important;
overflow: hidden !important;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12) !important;
transition: transform 0.15s ease, box-shadow 0.15s ease !important;
}
.fc-event:hover {
transform: translateY(-2px) scale(1.01);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.22) !important;
z-index: 10 !important;
}
.fc-event-confirmed {
background: linear-gradient(135deg, #B43E8E 0%, #832765 100%) !important;
color: #FFFFFF !important;
}
.fc-event-pending {
background: linear-gradient(135deg, #F59E0B 0%, #D97706 100%) !important;
color: #FFFFFF !important;
}
.fc-event-custom-container {
padding: 5px 7px;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
.fc-event-time-pill {
font-size: 0.72rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.85);
margin-bottom: 2px;
display: flex;
align-items: center;
}
/* High Visual Hierarchy for Subject Name */
.fc-event-subject-title {
font-size: 0.88rem !important;
font-weight: 700 !important;
line-height: 1.22 !important;
color: #FFFFFF !important;
letter-spacing: -0.15px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
margin-bottom: 4px;
}
/* Secondary Badges */
.fc-event-badges {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 3px;
margin-top: auto;
}
.fc-badge-code {
background-color: rgba(0, 0, 0, 0.3) !important;
color: #FFFFFF !important;
font-size: 0.70rem !important;
font-weight: 600 !important;
padding: 2px 5px !important;
border-radius: 4px !important;
}
.fc-badge-room {
background-color: rgba(255, 255, 255, 0.28) !important;
color: #FFFFFF !important;
font-size: 0.70rem !important;
font-weight: 700 !important;
padding: 2px 5px !important;
border-radius: 4px !important;
display: inline-flex;
align-items: center;
}
/* Month View Optimization */
.fc-daygrid-event .fc-event-custom-container {
padding: 2px 4px;
}
.fc-daygrid-event .fc-event-subject-title {
font-size: 0.78rem !important;
-webkit-line-clamp: 1;
}
</style>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.11.0/main.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.11.0/locales-all.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var calendarEl = document.getElementById('calendar');
var currentLang = '{{ session.get("language", "es") }}';
var todayDate = '{{ today_date or "2026-09-03" }}';
var filterRoom = document.getElementById('filterClassroom');
var filterSubj = document.getElementById('filterSubject');
var filterCar = document.getElementById('filterCareer');
var filterShift = document.getElementById('filterShift');
var filterFloor = document.getElementById('filterFloor');
var filterHideVirtual = document.getElementById('filterHideVirtual');
var activeBadge = document.getElementById('activeFilterBadge');
var countEl = document.getElementById('eventCountNumber');
function updateFilterSummaryBadge() {
var parts = [];
if (filterRoom.value && filterRoom.selectedOptions[0]) {
parts.push('Aula: ' + filterRoom.selectedOptions[0].text.split('(')[0].trim());
}
if (filterSubj.value && filterSubj.selectedOptions[0]) {
parts.push('Materia: ' + filterSubj.selectedOptions[0].text.split('[')[0].trim());
}
if (filterCar.value && filterCar.selectedOptions[0]) {
parts.push('Carrera: ' + filterCar.selectedOptions[0].text.trim());
}
if (filterShift && filterShift.value) {
parts.push('Turno ' + filterShift.value);
}
if (filterFloor && filterFloor.value) {
parts.push(filterFloor.value === '0' ? 'Planta Baja' : 'Piso ' + filterFloor.value);
}
if (filterHideVirtual && filterHideVirtual.checked) {
parts.push('Sin Virtuales');
}
if (parts.length > 0) {
activeBadge.className = 'badge bg-primary text-white border small';
activeBadge.textContent = parts.join(' | ');
} else {
activeBadge.className = 'badge bg-secondary-subtle text-secondary border small';
activeBadge.textContent = 'Mostrando todas las aulas y materias';
}
}
var calendar = new FullCalendar.Calendar(calendarEl, {
locale: currentLang,
initialView: 'timeGridWeek',
initialDate: todayDate,
allDaySlot: false,
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
},
eventTimeFormat: {
hour: '2-digit',
minute: '2-digit',
hour12: false
},
slotMinTime: '07:30:00',
slotMaxTime: '22:30:00',
height: 'auto',
slotEventOverlap: true,
/* Dynamic Server-Side Filtered Events */
events: function(info, successCallback, failureCallback) {
var params = new URLSearchParams({
start: info.startStr,
end: info.endStr,
classroom_id: filterRoom.value || '',
subject_id: filterSubj.value || '',
career_id: filterCar.value || '',
shift: (filterShift ? filterShift.value : '') || '',
floor: (filterFloor ? filterFloor.value : '') || '',
hide_virtual: (filterHideVirtual && filterHideVirtual.checked) ? '1' : ''
});
updateFilterSummaryBadge();
fetch('{{ url_for("schedule.calendar_data") }}?' + params.toString())
.then(function(response) {
if (!response.ok) throw new Error('Network error');
return response.json();
})
.then(function(eventsData) {
if (countEl) {
countEl.textContent = eventsData.length;
}
successCallback(eventsData);
})
.catch(function(err) {
console.error('Error loading calendar events:', err);
failureCallback(err);
});
},
/* Custom Card Render with Visual Hierarchy */
eventContent: function(arg) {
var ext = arg.event.extendedProps || {};
var subjectName = ext.subject_name || arg.event.title || '';
var subjectCode = ext.subject_code || '';
var classroom = ext.classroom || '';
var timeText = arg.timeText || '';
var container = document.createElement('div');
container.className = 'fc-event-custom-container';
var timeHtml = timeText ? `<div class="fc-event-time-pill"><i class="bi bi-clock me-1"></i>${timeText}</div>` : '';
var titleHtml = `<div class="fc-event-subject-title" title="${subjectName}">${subjectName}</div>`;
var badgesHtml = `
<div class="fc-event-badges">
${subjectCode ? `<span class="fc-badge-code font-monospace">${subjectCode}</span>` : ''}
${classroom ? `<span class="fc-badge-room"><i class="bi bi-door-open me-1"></i>${classroom}</span>` : ''}
</div>`;
container.innerHTML = timeHtml + titleHtml + badgesHtml;
return { domNodes: [container] };
},
/* Interactive Instant Modal */
eventClick: function(info) {
info.jsEvent.preventDefault();
var ext = info.event.extendedProps || {};
var subjectName = ext.subject_name || info.event.title || '';
var subjectCode = ext.subject_code || '-';
var classroom = ext.classroom || 'Sin Aula';
var building = ext.building || 'Edificio Central';
var career = ext.career || 'General';
var status = ext.status || 'CONFIRMED';
var attendees = ext.attendees || 0;
var startStr = info.event.start ? info.event.start.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit', hour12: false}) : '';
var endStr = info.event.end ? info.event.end.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit', hour12: false}) : '';
var dateStr = info.event.start ? info.event.start.toLocaleDateString([], {weekday: 'long', day: 'numeric', month: 'long'}) : '';
document.getElementById('modalSubjectTitle').textContent = subjectName;
document.getElementById('modalSubjectName').textContent = subjectName;
document.getElementById('modalSubjectCode').textContent = subjectCode;
document.getElementById('modalCareer').textContent = career;
document.getElementById('modalClassroom').textContent = classroom;
document.getElementById('modalBuilding').textContent = building;
document.getElementById('modalTimeRange').textContent = `${dateStr} (${startStr} a ${endStr} hs)`;
document.getElementById('modalStatus').textContent = status;
document.getElementById('modalAttendees').textContent = `${attendees} estudiantes esperados`;
if (info.event.url) {
var btn = document.getElementById('modalFullLink');
btn.href = info.event.url;
btn.classList.remove('d-none');
} else {
document.getElementById('modalFullLink').classList.add('d-none');
}
var modal = new bootstrap.Modal(document.getElementById('reservationModal'));
modal.show();
}
});
calendar.render();
// Event listeners to trigger immediate calendar refetch on filter change
filterRoom.addEventListener('change', function() {
calendar.refetchEvents();
});
filterSubj.addEventListener('change', function() {
calendar.refetchEvents();
});
filterCar.addEventListener('change', function() {
calendar.refetchEvents();
});
if (filterShift) {
filterShift.addEventListener('change', function() {
calendar.refetchEvents();
});
}
if (filterFloor) {
filterFloor.addEventListener('change', function() {
calendar.refetchEvents();
});
}
if (filterHideVirtual) {
filterHideVirtual.addEventListener('change', function() {
calendar.refetchEvents();
});
}
document.getElementById('btnResetFilters').addEventListener('click', function() {
filterRoom.value = '';
filterSubj.value = '';
filterCar.value = '';
if (filterShift) filterShift.value = '';
if (filterFloor) filterFloor.value = '';
if (filterHideVirtual) filterHideVirtual.checked = false;
calendar.refetchEvents();
});
});
</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 border-0 py-3">
<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|truncate(100) }}
</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.list_classrooms') }}" class="btn btn-primary">
<i class="bi bi-door-open"></i> View Classrooms
</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 %}
-253
View File
@@ -1,253 +0,0 @@
{% extends "base.html" %}
{% block title %}{% trans %}Listado de Reservas{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<!-- Header -->
<div class="row mb-4 align-items-center">
<div class="col-md-7">
<h1 class="h3 mb-1">
<i class="bi bi-calendar-check text-primary me-2"></i>{% trans %}Listado de Reservas{% endtrans %}
</h1>
<p class="text-muted small mb-0">{% trans %}Registro histórico y programación completa de clases y asignación de aulas{% endtrans %}</p>
</div>
<div class="col-md-5 text-md-end mt-3 mt-md-0">
{% if current_user.has_permission('reservations', 'read_write') %}
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-success me-2">
<i class="bi bi-plus-circle me-1"></i>{% trans %}Nueva Reserva{% endtrans %}
</a>
{% endif %}
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary me-2">
<i class="bi bi-calendar3 me-1"></i>{% trans %}Ver Calendario{% endtrans %}
</a>
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-info">
<i class="bi bi-calendar-day me-1"></i>{% trans %}Cartelera de Hoy{% endtrans %}
</a>
</div>
</div>
<!-- Filters Card -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-3">
<form method="GET" action="{{ url_for('schedule.list_reservations') }}" class="row g-2 align-items-end">
<!-- Search text -->
<div class="col-lg-3 col-md-6">
<label class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-search me-1"></i>{% trans %}Buscar{% endtrans %}
</label>
<input type="text" name="search" class="form-control form-control-sm" placeholder="Materia, código o aula..." value="{{ search or '' }}">
</div>
<!-- Status Filter -->
<div class="col-lg-2 col-md-3">
<label class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-shield-check me-1"></i>{% trans %}Estado{% endtrans %}
</label>
<select name="status" class="form-select form-select-sm">
<option value="all" {% if status_filter == 'all' or not status_filter %}selected{% endif %}>{% trans %}Todos los estados{% endtrans %}</option>
<option value="confirmed" {% if status_filter == 'confirmed' %}selected{% endif %}>{% trans %}Confirmadas{% endtrans %}</option>
<option value="pending" {% if status_filter == 'pending' %}selected{% endif %}>{% trans %}Pendientes{% endtrans %}</option>
<option value="cancelled" {% if status_filter == 'cancelled' %}selected{% endif %}>{% trans %}Canceladas{% endtrans %}</option>
</select>
</div>
<!-- Floor Filter -->
<div class="col-lg-2 col-md-3">
<label class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-layers me-1"></i>{% trans %}Piso{% endtrans %}
</label>
<select name="floor" class="form-select form-select-sm">
<option value="">{% trans %}Todos los pisos{% endtrans %}</option>
{% for f in available_floors %}
<option value="{{ f }}" {% if floor_filter == f|string %}selected{% endif %}>
{% if f == 0 %}{% trans %}Planta Baja{% endtrans %}{% else %}{% trans %}Piso{% endtrans %} {{ f }}{% endif %}
</option>
{% endfor %}
</select>
</div>
<!-- Shift Filter -->
<div class="col-lg-2 col-md-4">
<label class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-brightness-high text-warning me-1"></i>{% trans %}Turno{% endtrans %}
</label>
<select name="shift" class="form-select form-select-sm">
<option value="">{% trans %}Todos los turnos{% endtrans %}</option>
<option value="Mañana" {% if shift_filter == 'Mañana' %}selected{% endif %}>{% trans %}Turno Mañana{% endtrans %}</option>
<option value="Tarde" {% if shift_filter == 'Tarde' %}selected{% endif %}>{% trans %}Turno Tarde{% endtrans %}</option>
</select>
</div>
<!-- Date Range -->
<div class="col-lg-3 col-md-4">
<label class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-calendar-event me-1"></i>{% trans %}Desde Fecha{% endtrans %}
</label>
<input type="date" name="date_from" class="form-control form-control-sm" value="{{ date_from or '' }}">
</div>
<!-- Row 2: Hide Virtual, Submit, Clear -->
<div class="col-lg-6 col-md-6 pt-2">
<div class="form-check form-switch pt-1">
<input class="form-check-input" type="checkbox" role="switch" name="hide_virtual" value="1" id="hideVirtualList" {% if hide_virtual %}checked{% endif %}>
<label class="form-check-label fw-semibold small text-dark" for="hideVirtualList">
<i class="bi bi-laptop me-1 text-primary"></i>{% trans %}Ocultar Aulas Virtuales{% endtrans %}
<span class="badge bg-secondary-subtle text-secondary border ms-1" style="font-size: 0.72rem;">{% trans %}Capacidad ilimitada{% endtrans %}</span>
</label>
</div>
</div>
<div class="col-lg-6 col-md-6 pt-2 text-md-end d-flex justify-content-md-end gap-2">
<button type="submit" class="btn btn-primary btn-sm px-3">
<i class="bi bi-funnel me-1"></i>{% trans %}Filtrar{% endtrans %}
</button>
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary btn-sm px-3">
<i class="bi bi-arrow-counterclockwise me-1"></i>{% trans %}Limpiar{% endtrans %}
</a>
</div>
</form>
</div>
</div>
<!-- Reservations Table -->
{% if reservations and reservations.items %}
<div class="card border-0 shadow-sm">
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-table text-primary me-2"></i>{% trans %}Registros Encontrados{% endtrans %}
<span class="badge bg-secondary-subtle text-secondary border ms-2">{{ reservations.total }} {% trans %}reservas{% endtrans %}</span>
</h5>
<small class="text-muted">{% trans %}Página{% endtrans %} {{ reservations.page }} {% trans %}de{% endtrans %} {{ reservations.pages }}</small>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light small text-uppercase">
<tr>
<th class="ps-3">{% trans %}Fecha y Horario{% endtrans %}</th>
<th>{% trans %}Aula / Piso{% endtrans %}</th>
<th>{% trans %}Asignatura / Código{% endtrans %}</th>
<th>{% trans %}Turno / Comisión{% endtrans %}</th>
<th>{% trans %}Capacidad{% endtrans %}</th>
<th>{% trans %}Estado{% endtrans %}</th>
<th class="text-end pe-3">{% trans %}Acciones{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for reservation in reservations.items %}
<tr>
<td class="ps-3">
<div class="fw-bold">{{ reservation.start_time.strftime('%d/%m/%Y') }}</div>
<small class="text-muted font-monospace">{{ reservation.start_time.strftime('%H:%M') }} - {{ reservation.end_time.strftime('%H:%M') }} hs</small>
</td>
<td>
<span class="badge bg-secondary-subtle text-secondary border font-monospace">
{{ reservation.classroom.code if reservation.classroom else 'Sin Aula' }}
</span>
<small class="text-muted d-block">
{% if reservation.classroom and reservation.classroom.floor == 0 %}PB{% elif reservation.classroom %}Piso {{ reservation.classroom.floor }}{% endif %}
{% if reservation.classroom and reservation.classroom.building %} · {{ reservation.classroom.building }}{% endif %}
</small>
</td>
<td>
<div class="fw-bold text-primary">{{ reservation.subject_name }}</div>
<small class="badge bg-secondary-subtle text-secondary font-monospace">{{ reservation.subject_code }}</small>
</td>
<td>
<span class="badge bg-info-subtle text-info border me-1">
{{ reservation.shift or 'Tarde' }}
</span>
<small class="text-muted">{{ reservation.commission.code if reservation.commission else 'C1' }}</small>
</td>
<td>
<span class="small">{{ reservation.expected_attendees }} / {{ reservation.classroom.capacity if reservation.classroom else '-' }}</span>
</td>
<td>
{% if reservation.status == 'CONFIRMED' or reservation.status.value == 'CONFIRMED' %}
<span class="badge bg-success-subtle text-success border border-success-subtle">
<i class="bi bi-check-circle me-1"></i>{% trans %}Confirmada{% endtrans %}
</span>
{% elif reservation.status == 'PENDING' or reservation.status.value == 'PENDING' %}
<span class="badge bg-warning-subtle text-warning border border-warning-subtle">
<i class="bi bi-clock me-1"></i>{% trans %}Pendiente{% endtrans %}
</span>
{% else %}
<span class="badge bg-danger-subtle text-danger border border-danger-subtle">
{% trans %}Cancelada{% endtrans %}
</span>
{% endif %}
</td>
<td class="text-end pe-3">
<a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}"
class="btn btn-sm btn-outline-secondary py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}">
<i class="bi bi-eye"></i>
</a>
{% if current_user.has_permission('reservations', 'read_write') and (reservation.status == 'PENDING' or reservation.status.value == 'PENDING') %}
<a href="{{ url_for('schedule.confirm_reservation', id=reservation.id) }}"
class="btn btn-sm btn-outline-success py-0 px-2 ms-1" title="{% trans %}Confirmar{% endtrans %}">
<i class="bi bi-check-lg"></i>
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if reservations.pages > 1 %}
<div class="card-footer bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
<small class="text-muted">{% trans %}Mostrando 20 registros por página{% endtrans %}</small>
<nav>
<ul class="pagination pagination-sm mb-0">
{% if reservations.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=reservations.prev_num, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, hide_virtual='1' if hide_virtual else '', search=search) }}">
<i class="bi bi-chevron-left"></i>
</a>
</li>
{% endif %}
{% for p in reservations.iter_pages(left_edge=2, left_current=2, right_current=2, right_edge=2) %}
{% if p %}
{% if p == reservations.page %}
<li class="page-item active"><span class="page-link">{{ p }}</span></li>
{% else %}
<li class="page-item">
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=p, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, hide_virtual='1' if hide_virtual else '', search=search) }}">{{ p }}</a>
</li>
{% endif %}
{% else %}
<li class="page-item disabled"><span class="page-link">…</span></li>
{% endif %}
{% endfor %}
{% if reservations.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=reservations.next_num, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, hide_virtual='1' if hide_virtual else '', search=search) }}">
<i class="bi bi-chevron-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
</div>
</div>
{% else %}
<div class="card border-0 shadow-sm">
<div class="card-body text-center py-5">
<i class="bi bi-calendar-x text-muted fs-1 d-block mb-3"></i>
<h5 class="fw-bold">{% trans %}No se encontraron reservas{% endtrans %}</h5>
<p class="text-muted small mb-3">{% trans %}No hay registros que coincidan con los criterios de búsqueda o filtros seleccionados.{% endtrans %}</p>
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-arrow-counterclockwise me-1"></i>{% trans %}Restablecer Todos los Filtros{% endtrans %}
</a>
</div>
</div>
{% endif %}
</div>
{% endblock %}
+101
View File
@@ -0,0 +1,101 @@
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.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_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
@@ -4,11 +4,14 @@ from wtforms.validators import DataRequired, Email, Length
from flask_babel import lazy_gettext as _l from flask_babel import lazy_gettext as _l
class LoginForm(FlaskForm): class LoginForm(FlaskForm):
email = StringField(_l('Email'), validators=[ email = StringField(_l('Email'),
DataRequired(), validators=[
Email(), DataRequired(),
Length(max=120) Email(),
]) Length(max=120)
],
filters=[lambda x: x.strip().lower() if x else '']
)
password = PasswordField(_l('Password'), validators=[DataRequired()]) password = PasswordField(_l('Password'), validators=[DataRequired()])
remember_me = BooleanField(_l('Remember me')) remember_me = BooleanField(_l('Remember me'))
submit = SubmitField(_l('Sign In')) submit = SubmitField(_l('Sign In'))
@@ -19,27 +19,25 @@ def validate_capacity(form, field):
raise ValidationError(_l('Ingrese un número entero o el símbolo ∞ para capacidad ilimitada.')) raise ValidationError(_l('Ingrese un número entero o el símbolo ∞ para capacidad ilimitada.'))
class ClassroomForm(FlaskForm): class ClassroomForm(FlaskForm):
building = StringField(_l('Building'), validators=[ building_id = SelectField(_l('Edificio'), validators=[DataRequired()])
DataRequired(), new_building_name = StringField(_l('Nombre del Nuevo Edificio'), validators=[Optional(), Length(max=100)])
Length(min=2, max=100) room_number = StringField(_l('Número / Nombre del Aula'), validators=[
])
room_number = StringField(_l('Room Number'), validators=[
DataRequired(), DataRequired(),
Length(min=1, max=50) Length(min=1, max=50)
]) ])
floor = IntegerField(_l('Floor'), validators=[ floor = StringField(_l('Piso'), validators=[
InputRequired(message=_l('El piso es obligatorio (0 para Planta Baja)')), DataRequired(message=_l('El piso es obligatorio (ej: PB, 1, 2, 4°P)')),
NumberRange(min=0, max=50, message=_l('El piso debe estar entre 0 (Planta Baja) y 50')) Length(min=1, max=50)
]) ])
capacity = StringField(_l('Capacity'), validators=[ capacity = StringField(_l('Capacidad'), validators=[
validate_capacity validate_capacity
]) ])
description = TextAreaField(_l('Description'), validators=[ description = TextAreaField(_l('Descripción'), validators=[
Optional(), Optional(),
Length(max=500) Length(max=500)
]) ])
is_active = BooleanField(_l('Active'), default=True) is_active = BooleanField(_l('Activa'), default=True)
submit = SubmitField(_l('Save Classroom')) submit = SubmitField(_l('Guardar Aula'))
class ClassroomResourceForm(FlaskForm): class ClassroomResourceForm(FlaskForm):
resource_type = SelectField(_l('Resource Type'), choices=[ resource_type = SelectField(_l('Resource Type'), choices=[
@@ -21,8 +21,16 @@ class ReservationForm(FlaskForm):
Optional(), Optional(),
Length(max=1000) Length(max=1000)
]) ])
virtual_link = StringField(_l('Virtual Classroom Link'), validators=[
Optional(),
Length(max=500)
])
submit = SubmitField(_l('Make Reservation')) submit = SubmitField(_l('Make Reservation'))
def __init__(self, *args, is_edit=False, **kwargs):
super().__init__(*args, **kwargs)
self.is_edit = is_edit
def validate_end_time(self, field): def validate_end_time(self, field):
if field.data <= self.start_time.data: if field.data <= self.start_time.data:
raise ValidationError('End time must be after start time.') raise ValidationError('End time must be after start time.')
@@ -32,8 +40,8 @@ class ReservationForm(FlaskForm):
raise ValidationError('Reservation cannot exceed 8 hours.') raise ValidationError('Reservation cannot exceed 8 hours.')
def validate_start_time(self, field): def validate_start_time(self, field):
# Don't allow reservations in the past # Don't allow new reservations in the past
if field.data < datetime.now(): if not self.is_edit and field.data < datetime.now() - timedelta(minutes=10):
raise ValidationError('Start time cannot be in the past.') raise ValidationError('Start time cannot be in the past.')
# Don't allow reservations too far in the future (max 3 months) # Don't allow reservations too far in the future (max 3 months)
@@ -2,10 +2,15 @@ from .user import User
from .building import Building from .building import Building
from .classroom import Classroom, ClassroomResource from .classroom import Classroom, ClassroomResource
from .career import Career from .career import Career
from .subject import Subject, Commission from .academic_term import AcademicTerm
from .subject import Subject, Commission, CommissionTeacher
from .reservation import Reservation, ReservationStatus from .reservation import Reservation, ReservationStatus
from .genetic_algorithm import GeneticAlgorithm, ReservationOptimizer from .genetic_algorithm import GeneticAlgorithm, ReservationOptimizer
from .role import Role, Permission, SYSTEM_MODULES from .role import Role, Permission, SYSTEM_MODULES
from .milestone import MilestoneType, AcademicMilestone, BASE_MILESTONE_TYPES
from .audit_log import AuditLog
from .enrollment import StudentEnrollment
from .setting import SystemSetting
__all__ = [ __all__ = [
'User', 'User',
@@ -13,13 +18,21 @@ __all__ = [
'Classroom', 'Classroom',
'ClassroomResource', 'ClassroomResource',
'Career', 'Career',
'AcademicTerm',
'Subject', 'Subject',
'Commission', 'Commission',
'CommissionTeacher',
'Reservation', 'Reservation',
'ReservationStatus', 'ReservationStatus',
'GeneticAlgorithm', 'GeneticAlgorithm',
'ReservationOptimizer', 'ReservationOptimizer',
'Role', 'Role',
'Permission', 'Permission',
'SYSTEM_MODULES' 'SYSTEM_MODULES',
'MilestoneType',
'AcademicMilestone',
'BASE_MILESTONE_TYPES',
'AuditLog',
'StudentEnrollment',
'SystemSetting'
] ]
+34
View File
@@ -0,0 +1,34 @@
from app import db
from datetime import datetime
class AcademicTerm(db.Model):
"""Modelo para Ciclos Lectivos y Períodos Académicos"""
__tablename__ = 'academic_terms'
id = db.Column(db.Integer, primary_key=True)
code = db.Column(db.String(50), unique=True, nullable=False, index=True) # ej. 2026-2, 2026-1, 2025-2
name = db.Column(db.String(150), nullable=False) # ej. Ciclo Lectivo 2026 - 2º Cuatrimestre
year = db.Column(db.Integer, nullable=False, default=2026, index=True)
period_type = db.Column(db.String(50), nullable=False, default='Segundo Cuatrimestre') # Primer Cuatrimestre, Segundo Cuatrimestre, Anual, Verano
start_date = db.Column(db.Date, nullable=True)
end_date = db.Column(db.Date, nullable=True)
is_current = db.Column(db.Boolean, default=False, nullable=False, index=True)
is_active = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def to_dict(self):
return {
'id': self.id,
'code': self.code,
'name': self.name,
'year': self.year,
'period_type': self.period_type,
'start_date': self.start_date.isoformat() if self.start_date else None,
'end_date': self.end_date.isoformat() if self.end_date else None,
'is_current': self.is_current,
'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None
}
def __repr__(self):
return f'<AcademicTerm {self.code} - {self.name}>'
+25
View File
@@ -0,0 +1,25 @@
from app import db
from datetime import datetime
class AuditLog(db.Model):
"""
Bitácora inmutable de auditoría para registro de eventos críticos
y trazabilidad de operaciones institucionales.
"""
__tablename__ = 'audit_logs'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
user_email = db.Column(db.String(255), nullable=True)
action = db.Column(db.String(50), nullable=False, index=True) # CREATE, UPDATE, DELETE, LOGIN, LOGOUT
module = db.Column(db.String(50), nullable=False, index=True) # classrooms, reservations, auth, users
entity_id = db.Column(db.Integer, nullable=True) # ID del registro afectado
details = db.Column(db.Text, nullable=True) # Detalles de cambios o parámetros
ip_address = db.Column(db.String(45), nullable=True) # Soporta IPv4 e IPv6
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True)
# Relación con usuario
user = db.relationship('User', foreign_keys=[user_id], backref=db.backref('audit_records', lazy='dynamic'))
def __repr__(self):
return f"<AuditLog #{self.id} {self.action} on {self.module} by {self.user_email or 'System'}>"
@@ -9,23 +9,57 @@ class Classroom(db.Model):
building_id = db.Column(db.Integer, db.ForeignKey('buildings.id'), nullable=True) building_id = db.Column(db.Integer, db.ForeignKey('buildings.id'), nullable=True)
building = db.Column(db.String(100), nullable=False) building = db.Column(db.String(100), nullable=False)
code = db.Column('room_number', db.String(50), nullable=False) # Maps to room_number column in DB code = db.Column('room_number', db.String(50), nullable=False) # Maps to room_number column in DB
floor = db.Column(db.Integer, nullable=False) floor = db.Column(db.String(50), nullable=False, default='1')
capacity = db.Column(db.Integer, nullable=False) capacity = db.Column(db.Integer, nullable=False)
description = db.Column(db.Text) description = db.Column(db.Text)
is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relación con Building (mapeada vía backref 'building_entity' en Building.classrooms)
@property
def building_rel(self):
return self.building_entity
@property
def building_name(self):
"""Nombre del edificio desacoplado"""
if self.building_rel:
return self.building_rel.name
return self.building or 'Edificio Central'
@property
def floor_display(self):
"""Formato amigable para el piso: PB, Planta Baja, Piso X"""
f = str(self.floor).strip() if self.floor is not None else ''
if f in ['0', 'PB', 'pb', 'Planta Baja', 'planta baja']:
return 'Planta Baja'
if f.lower().startswith('piso') or '°' in f:
return f
return f"Piso {f}"
@property
def is_virtual(self):
"""Indica si el aula es virtual"""
return (self.code and 'VIRTUAL' in self.code.upper()) or (self.building and 'VIRTUAL' in self.building.upper())
@property
def location_display(self):
"""Ubicación completa y limpia: Edificio · Piso · Aula"""
if self.is_virtual:
return '🌐 Campus Virtual'
return f"{self.building_name} · {self.floor_display} · {self.code}"
# Virtual properties for compatibility # Virtual properties for compatibility
@property @property
def name(self): def name(self):
"""Generate name from building and code""" """Generate name from building and code"""
return f"{self.building}-{self.code}" return f"{self.building_name}-{self.code}"
@property @property
def campus_location(self): def campus_location(self):
"""Placeholder for campus location - can be expanded when campus model is implemented""" """Placeholder for campus location"""
return None return self.building_rel.address if self.building_rel else None
# Virtual field for compatibility - combines building and code # Virtual field for compatibility - combines building and code
@property @property
@@ -39,28 +73,34 @@ class Classroom(db.Model):
# Override code property for consistency # Override code property for consistency
@property @property
def code_display(self): def code_display(self):
return f"{self.building}-{self.code}" return f"{self.building_name} - {self.code}"
@property @property
def capacity_display(self): def capacity_display(self):
"""Retorna '∞' para aulas virtuales o capacidad ilimitada, o el número entero.""" """Retorna '∞' para aulas virtuales o capacidad ilimitada, o el número entero."""
if self.capacity == 0 or (self.code and 'VIRTUAL' in self.code.upper()): if self.capacity == 0 or self.is_virtual:
return '∞' return '∞'
return str(self.capacity) return str(self.capacity)
@property @property
def is_unlimited(self): def is_unlimited(self):
return self.capacity == 0 or (self.code and 'VIRTUAL' in self.code.upper()) return self.capacity == 0 or self.is_virtual
def to_dict(self): def to_dict(self):
"""Convert classroom to dictionary""" """Convert classroom to dictionary"""
return { return {
'id': self.id, 'id': self.id,
'code': self.code_display, 'building_id': self.building_id,
'building': self.building, 'building': self.building_name,
'code': self.code,
'code_display': self.code_display,
'name': self.name, 'name': self.name,
'floor': self.floor, 'floor': self.floor,
'floor_display': self.floor_display,
'location_display': self.location_display,
'capacity': self.capacity, 'capacity': self.capacity,
'capacity_display': self.capacity_display,
'is_virtual': self.is_virtual,
'description': self.description, 'description': self.description,
'campus_location': self.campus_location, 'campus_location': self.campus_location,
'is_active': self.is_active, 'is_active': self.is_active,
@@ -71,10 +111,9 @@ class Classroom(db.Model):
def get_full_location(self): def get_full_location(self):
"""Get full classroom location""" """Get full classroom location"""
location = f"{self.building} - Floor {self.floor}" if self.is_virtual:
if self.campus_location: return 'Campus Virtual (Remoto)'
location += f" ({self.campus_location})" return f"{self.building_name} - {self.floor_display}"
return location
@staticmethod @staticmethod
def find_best_classroom(max_students, start_time, end_time, exclude_reservation_id=None): def find_best_classroom(max_students, start_time, end_time, exclude_reservation_id=None):
+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}>'
@@ -46,10 +46,31 @@ class Individual:
self.conflicts = [] self.conflicts = []
def calculate_fitness(self, classrooms: Dict[int, Classroom], requests: Dict[int, ReservationRequest]) -> float: def calculate_fitness(self, classrooms: Dict[int, Classroom], requests: Dict[int, ReservationRequest]) -> float:
"""Calculate fitness score based on multiple factors""" """Calculate fitness score based on multiple factors with fast O(K log K) interval conflict detection"""
score = 0.0 score = 0.0
self.conflicts = [] self.conflicts = []
# Pre-group genes by classroom to calculate conflicts in O(K log K)
by_classroom = {}
for g in self.genes:
by_classroom.setdefault(g.classroom_id, []).append(g)
conflict_count_by_id = {}
for cid, room_genes in by_classroom.items():
if len(room_genes) <= 1:
continue
# Sort chronologically
room_genes.sort(key=lambda x: x.start_time)
for i in range(len(room_genes)):
g_i = room_genes[i]
for j in range(i + 1, len(room_genes)):
g_j = room_genes[j]
if g_i.end_time > g_j.start_time:
conflict_count_by_id[id(g_i)] = conflict_count_by_id.get(id(g_i), 0) + 1
conflict_count_by_id[id(g_j)] = conflict_count_by_id.get(id(g_j), 0) + 1
else:
break # Gene j and subsequent ones start later than gene i ends
for gene in self.genes: for gene in self.genes:
classroom = classrooms.get(gene.classroom_id) classroom = classrooms.get(gene.classroom_id)
request = requests.get(gene.commission_id) request = requests.get(gene.commission_id)
@@ -66,20 +87,21 @@ class Individual:
score += time_score * 0.3 score += time_score * 0.3
# Factor 3: Conflict penalty (20% weight) # Factor 3: Conflict penalty (20% weight)
conflict_score = self._calculate_conflict_penalty(gene, self.genes) c_count = conflict_count_by_id.get(id(gene), 0)
if c_count > 0:
conflict_score = -c_count * 0.5
self.conflicts.append({
'gene': gene,
'conflict_type': 'time_overlap'
})
else:
conflict_score = 1.0
score += conflict_score * 0.2 score += conflict_score * 0.2
# Factor 4: Resource matching (10% weight) # Factor 4: Resource matching (10% weight)
resource_score = self._calculate_resource_score(classroom, request.subject_requirements) resource_score = self._calculate_resource_score(classroom, request.subject_requirements)
score += resource_score * 0.1 score += resource_score * 0.1
# Store conflicts for debugging
if conflict_score < 0:
self.conflicts.append({
'gene': gene,
'conflict_type': 'time_overlap'
})
self.fitness = score self.fitness = score
return score return score
@@ -89,7 +111,7 @@ class Individual:
return 0.0 # Overfilled classroom return 0.0 # Overfilled classroom
# Calculate efficiency: closer capacity match = higher score # Calculate efficiency: closer capacity match = higher score
waste_ratio = (classroom.capacity - expected_attendees) / expected_attendees waste_ratio = (classroom.capacity - expected_attendees) / max(1, expected_attendees)
if waste_ratio <= 0.1: # Less than 10% waste if waste_ratio <= 0.1: # Less than 10% waste
return 1.0 return 1.0
elif waste_ratio <= 0.3: # Less than 30% waste elif waste_ratio <= 0.3: # Less than 30% waste
@@ -107,12 +129,12 @@ class Individual:
if time_diff <= 0.5: # Within 30 minutes if time_diff <= 0.5: # Within 30 minutes
return 1.0 return 1.0
elif time_diff <= request.flexibility_hours: # Within flexible window elif time_diff <= request.flexibility_hours: # Within flexible window
return 0.8 - (time_diff / request.flexibility_hours) * 0.3 return 0.8 - (time_diff / max(1, request.flexibility_hours)) * 0.3
else: else:
return max(0.0, 0.5 - (time_diff - request.flexibility_hours) * 0.1) return max(0.0, 0.5 - (time_diff - request.flexibility_hours) * 0.1)
def _calculate_conflict_penalty(self, gene: Gene, all_genes: List[Gene]) -> float: def _calculate_conflict_penalty(self, gene: Gene, all_genes: List[Gene]) -> float:
"""Check for time conflicts in the same classroom""" """Check for time conflicts in the same classroom (kept for backward compatibility)"""
conflicts = 0 conflicts = 0
for other in all_genes: for other in all_genes:
if gene != other and gene.classroom_id == other.classroom_id: if gene != other and gene.classroom_id == other.classroom_id:
@@ -127,9 +149,6 @@ class Individual:
"""Check if classroom meets subject requirements""" """Check if classroom meets subject requirements"""
if not requirements: if not requirements:
return 1.0 return 1.0
# This would need to be implemented based on actual classroom resources
# For now, return a default score
return 0.8 return 0.8
def _times_overlap(self, start1: datetime, end1: datetime, start2: datetime, end2: datetime) -> bool: def _times_overlap(self, start1: datetime, end1: datetime, start2: datetime, end2: datetime) -> bool:
@@ -138,19 +157,24 @@ class Individual:
class GeneticAlgorithm: class GeneticAlgorithm:
"""Main genetic algorithm for room reservation optimization""" """Main genetic algorithm for room reservation optimization with concurrency and progress tracking"""
def __init__(self, population_size: int = 50, generations: int = 100, def __init__(self, population_size: int = 50, generations: int = 100,
mutation_rate: float = 0.1, crossover_rate: float = 0.8): mutation_rate: float = 0.1, crossover_rate: float = 0.8,
workers: int = 4):
self.population_size = population_size self.population_size = population_size
self.generations = generations self.generations = generations
self.mutation_rate = mutation_rate self.mutation_rate = mutation_rate
self.crossover_rate = crossover_rate self.crossover_rate = crossover_rate
self.workers = max(1, workers)
def optimize_reservations(self, requests: List[ReservationRequest], def optimize_reservations(self, requests: List[ReservationRequest],
available_classrooms: List[Classroom], available_classrooms: List[Classroom],
start_date: datetime, end_date: datetime) -> Individual: start_date: datetime, end_date: datetime,
"""Run genetic algorithm to find optimal reservation schedule""" progress_callback=None) -> Individual:
"""Run genetic algorithm with high concurrency to find optimal reservation schedule"""
import concurrent.futures
classrooms_dict = {c.id: c for c in available_classrooms} classrooms_dict = {c.id: c for c in available_classrooms}
requests_dict = {r.commission_id: r for r in requests} requests_dict = {r.commission_id: r for r in requests}
@@ -159,18 +183,29 @@ class GeneticAlgorithm:
# Evolve population # Evolve population
for generation in range(self.generations): for generation in range(self.generations):
# Calculate fitness for all individuals # Calculate fitness for all individuals concurrently when workers > 1
for individual in population: if self.workers > 1 and len(population) > 10:
individual.calculate_fitness(classrooms_dict, requests_dict) with concurrent.futures.ThreadPoolExecutor(max_workers=self.workers) as executor:
futures = [executor.submit(ind.calculate_fitness, classrooms_dict, requests_dict) for ind in population]
concurrent.futures.wait(futures)
else:
for individual in population:
individual.calculate_fitness(classrooms_dict, requests_dict)
# Sort by fitness (best first) # Sort by fitness (best first)
population.sort(key=lambda x: x.fitness, reverse=True) population.sort(key=lambda x: x.fitness, reverse=True)
# Report progress if callback is provided
if progress_callback:
pct = int(((generation + 1) / self.generations) * 100)
best_fit = population[0].fitness if population else 0.0
progress_callback(generation + 1, self.generations, pct, best_fit)
# Create new generation # Create new generation
new_population = [] new_population = []
# Elitism: keep best 10% # Elitism: keep best 10%
elite_size = int(self.population_size * 0.1) elite_size = max(2, int(self.population_size * 0.1))
new_population.extend(population[:elite_size]) new_population.extend(population[:elite_size])
# Crossover and mutation for remaining # Crossover and mutation for remaining
@@ -196,7 +231,9 @@ class GeneticAlgorithm:
population = new_population[:self.population_size] population = new_population[:self.population_size]
# Return best individual from final generation # Calculate fitness for final generation
for individual in population:
individual.calculate_fitness(classrooms_dict, requests_dict)
population.sort(key=lambda x: x.fitness, reverse=True) population.sort(key=lambda x: x.fitness, reverse=True)
return population[0] return population[0]
@@ -284,13 +321,14 @@ class GeneticAlgorithm:
class ReservationOptimizer: class ReservationOptimizer:
"""High-level interface for the reservation optimization system""" """High-level interface for the reservation optimization system"""
def __init__(self): def __init__(self, population_size: int = 50, generations: int = 100, workers: int = 4):
self.ga = GeneticAlgorithm() self.ga = GeneticAlgorithm(population_size=population_size, generations=generations, workers=workers)
def optimize_schedule(self, commission_ids: List[int], def optimize_schedule(self, commission_ids: List[int],
admin_user_id: int, admin_user_id: int,
start_date: datetime = None, start_date: datetime = None,
end_date: datetime = None) -> Dict: end_date: datetime = None,
progress_callback = None) -> Dict:
"""Optimize reservation schedule for given commissions""" """Optimize reservation schedule for given commissions"""
# Default date range if not provided # Default date range if not provided
@@ -325,7 +363,7 @@ class ReservationOptimizer:
classrooms = Classroom.query.filter_by(is_active=True).all() classrooms = Classroom.query.filter_by(is_active=True).all()
# Run genetic algorithm # Run genetic algorithm
best_individual = self.ga.optimize_reservations(requests, classrooms, start_date, end_date) best_individual = self.ga.optimize_reservations(requests, classrooms, start_date, end_date, progress_callback=progress_callback)
# Convert to reservation format # Convert to reservation format
optimized_reservations = [] optimized_reservations = []
+180
View File
@@ -0,0 +1,180 @@
from datetime import datetime
from app import db
class MilestoneType(db.Model):
"""
Catálogo dinámico y administrable de tipificaciones de hitos y evaluaciones académicas.
(Parciales, Trabajos Prácticos, Recuperatorios, Finales, etc.)
"""
__tablename__ = 'milestone_types'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False) # Ej: "Parcial 1", "Trabajo Práctico 1"
code = db.Column(db.String(50), unique=True, nullable=False, index=True) # Ej: "parcial_1", "tp_1"
color = db.Column(db.String(30), default='#0d6efd') # Hex color para badges y calendario
description = db.Column(db.String(255), nullable=True)
sort_order = db.Column(db.Integer, default=0) # Orden de aparición en selects
is_active = db.Column(db.Boolean, default=True, nullable=False) # Activo / Inactivo
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relación uno-a-muchos con AcademicMilestone
milestones = db.relationship('AcademicMilestone', backref='type_obj', lazy='dynamic', cascade='all, delete-orphan')
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'code': self.code,
'color': self.color or '#0d6efd',
'description': self.description or '',
'sort_order': self.sort_order or 0,
'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None
}
def __repr__(self):
return f'<MilestoneType {self.code}: {self.name}>'
class AcademicMilestone(db.Model):
"""
Hito o evento académico programado para una materia/comisión (Examen, TP, Entrega).
"""
__tablename__ = 'academic_milestones'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(150), nullable=True)
materia_codigo = db.Column(db.String(50), nullable=False, index=True) # Código de materia (ej: INGA0003)
subject_id = db.Column(db.Integer, db.ForeignKey('subjects.id'), nullable=True)
commission_id = db.Column(db.Integer, db.ForeignKey('commissions.id', ondelete='SET NULL'), nullable=True)
milestone_type_id = db.Column(db.Integer, db.ForeignKey('milestone_types.id'), nullable=False)
date = db.Column(db.DateTime, nullable=False) # Fecha y hora del hito
classroom = db.Column(db.String(100), nullable=True) # Aula o enlace virtual asignado
link = db.Column(db.String(255), nullable=True) # Link Teams / Meet / Zoom si aplica
notes = db.Column(db.Text, nullable=True) # Temario, consignas o notas
is_active = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relación con Asignatura y Comisión
subject = db.relationship('Subject', backref=db.backref('milestones', lazy='dynamic'))
commission = db.relationship('Commission', backref=db.backref('academic_milestones', lazy='dynamic', cascade='all, delete-orphan'))
@property
def tipo(self):
return self.type_obj.name if self.type_obj else 'Otro'
@property
def color(self):
return self.type_obj.color if self.type_obj and self.type_obj.color else '#0d6efd'
def to_dict(self):
return {
'id': self.id,
'title': self.title or self.tipo,
'materiaCodigo': self.materia_codigo,
'subject_id': self.subject_id,
'commission_id': self.commission_id,
'tipo': self.tipo,
'milestone_type_id': self.milestone_type_id,
'tipo_info': self.type_obj.to_dict() if self.type_obj else None,
'color': self.color,
'fecha': self.date.isoformat() if self.date else None,
'aula': self.classroom,
'link': self.link,
'notas': self.notes,
'is_active': self.is_active
}
def __repr__(self):
return f'<AcademicMilestone {self.materia_codigo} - {self.tipo} ({self.date})>'
# Datos semilla estándar para inicialización
BASE_MILESTONE_TYPES = [
{
'name': 'Parcial 1',
'code': 'parcial_1',
'color': '#0d6efd',
'description': 'Primer examen parcial de la cursada',
'sort_order': 1
},
{
'name': 'Parcial 2',
'code': 'parcial_2',
'color': '#0b5ed7',
'description': 'Segundo examen parcial de la cursada',
'sort_order': 2
},
{
'name': 'Trabajo Practico 1',
'code': 'tp_1',
'color': '#198754',
'description': 'Primera entrega o evaluacion practica',
'sort_order': 3
},
{
'name': 'Trabajo Practico 2',
'code': 'tp_2',
'color': '#157347',
'description': 'Segunda entrega o evaluacion practica',
'sort_order': 4
},
{
'name': 'Trabajo Practico 3',
'code': 'tp_3',
'color': '#0f5132',
'description': 'Tercera entrega o proyecto integrador',
'sort_order': 5
},
{
'name': 'Recuperatorio Parcial 1',
'code': 'recu_p1',
'color': '#fd7e14',
'description': 'Instancia de recuperacion para el Parcial 1',
'sort_order': 6
},
{
'name': 'Recuperatorio Parcial 2',
'code': 'recu_p2',
'color': '#e76b08',
'description': 'Instancia de recuperacion para el Parcial 2',
'sort_order': 7
},
{
'name': 'Recuperatorio TP 1',
'code': 'recu_tp1',
'color': '#ffc107',
'description': 'Reentrega o recuperacion de Trabajo Practico 1',
'sort_order': 8
},
{
'name': 'Recuperatorio TP 2',
'code': 'recu_tp2',
'color': '#e0a800',
'description': 'Reentrega o recuperacion de Trabajo Practico 2',
'sort_order': 9
},
{
'name': 'Recuperatorio TP 3',
'code': 'recu_tp3',
'color': '#c69500',
'description': 'Reentrega o recuperacion de Trabajo Practico 3',
'sort_order': 10
},
{
'name': 'Final',
'code': 'final',
'color': '#dc3545',
'description': 'Examen final regular o libre',
'sort_order': 11
},
{
'name': 'Otro',
'code': 'otro',
'color': '#6c757d',
'description': 'Taller, seminario, clase especial o hito no tipificado',
'sort_order': 12
}
]
@@ -47,6 +47,7 @@ class Reservation(db.Model):
status = db.Column(StatusType, default='PENDING', nullable=False) # Maps to VARCHAR(20) with StatusString status = db.Column(StatusType, default='PENDING', nullable=False) # Maps to VARCHAR(20) with StatusString
expected_attendees = db.Column(db.Integer, nullable=False) expected_attendees = db.Column(db.Integer, nullable=False)
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
virtual_link = db.Column(db.String(500), nullable=True) # Zoom, Meet, Teams, etc.
notes = db.Column(db.Text) notes = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
@@ -82,6 +83,24 @@ class Reservation(db.Model):
return self.classroom.code return self.classroom.code
return '' return ''
@property
def effective_virtual_link(self):
"""Devuelve el enlace explícito de la reserva o hereda el de la comisión"""
if self.virtual_link and self.virtual_link.strip():
return self.virtual_link.strip()
if self.commission and self.commission.virtual_link:
return self.commission.virtual_link.strip()
return None
@property
def is_virtual(self):
"""Verifica si la reserva es virtual por aula o por contar con enlace virtual"""
if self.classroom:
return bool(self.classroom.is_virtual)
if self.effective_virtual_link:
return True
return False
# Relationships # Relationships
classroom = db.relationship('Classroom', backref=db.backref('reservation_list', lazy=True, cascade='all, delete-orphan')) classroom = db.relationship('Classroom', backref=db.backref('reservation_list', lazy=True, cascade='all, delete-orphan'))
commission = db.relationship('Commission', 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'))
@@ -100,6 +119,8 @@ class Reservation(db.Model):
'status': self.status, # Already a string in DB 'status': self.status, # Already a string in DB
'expected_attendees': self.expected_attendees, 'expected_attendees': self.expected_attendees,
'notes': self.notes, 'notes': self.notes,
'virtual_link': self.effective_virtual_link,
'is_virtual': self.is_virtual,
'created_at': self.created_at.isoformat() if self.created_at else None, 'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None, 'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'classroom': self.classroom.to_dict() if self.classroom else None, 'classroom': self.classroom.to_dict() if self.classroom else None,
@@ -22,7 +22,7 @@ SYSTEM_MODULES = [
'name': 'Reservas de Aulas', 'name': 'Reservas de Aulas',
'name_en': 'Classroom Reservations', 'name_en': 'Classroom Reservations',
'icon': 'bi-calendar-check', 'icon': 'bi-calendar-check',
'description': 'Creación, aprobación, confirmación y cancelación de reservas.' 'description': 'Creación, modificación, aprobación, confirmación y cancelación de reservas.'
}, },
{ {
'id': 'schedule', 'id': 'schedule',
@@ -58,6 +58,27 @@ SYSTEM_MODULES = [
'name_en': 'Users & Access RBAC', 'name_en': 'Users & Access RBAC',
'icon': 'bi-shield-lock', 'icon': 'bi-shield-lock',
'description': 'Administración de cuentas, roles y matriz granular de permisos.' '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.'
} }
] ]
+50
View File
@@ -0,0 +1,50 @@
from app import db
from datetime import datetime
class SystemSetting(db.Model):
"""
Parámetros de configuración del sistema persistentes en base de datos.
Permite almacenar enlaces de integración (e.g. Google Sheets), flags globales y metadatos.
"""
__tablename__ = 'system_settings'
id = db.Column(db.Integer, primary_key=True)
key = db.Column(db.String(100), unique=True, nullable=False, index=True)
value = db.Column(db.Text, nullable=True)
description = db.Column(db.String(255), nullable=True)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@classmethod
def get_value(cls, key, default=None):
try:
record = cls.query.filter_by(key=key).first()
if record and record.value is not None and record.value.strip() != '':
return record.value
except Exception:
pass
return default
@classmethod
def set_value(cls, key, value, description=None):
record = cls.query.filter_by(key=key).first()
if not record:
record = cls(key=key, value=value, description=description)
db.session.add(record)
else:
record.value = value
if description:
record.description = description
record.updated_at = datetime.utcnow()
db.session.commit()
return record
def to_dict(self):
return {
'key': self.key,
'value': self.value,
'description': self.description,
'updated_at': self.updated_at.isoformat() if self.updated_at else None
}
def __repr__(self):
return f'<SystemSetting {self.key}={self.value}>'
@@ -12,6 +12,7 @@ class Subject(db.Model):
credits = db.Column(db.Integer, nullable=False, default=4) credits = db.Column(db.Integer, nullable=False, default=4)
career_id = db.Column(db.Integer, db.ForeignKey('careers.id', ondelete='SET NULL'), nullable=True) career_id = db.Column(db.Integer, db.ForeignKey('careers.id', ondelete='SET NULL'), nullable=True)
is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB
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) created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -30,6 +31,7 @@ class Subject(db.Model):
'career_id': self.career_id, 'career_id': self.career_id,
'career_name': self.career_obj.name if self.career_obj else None, 'career_name': self.career_obj.name if self.career_obj else None,
'is_active': self.is_active, '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, 'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None 'updated_at': self.updated_at.isoformat() if self.updated_at else None
} }
@@ -50,6 +52,7 @@ class Commission(db.Model):
current_students = db.Column(db.Integer, default=0) current_students = db.Column(db.Integer, default=0)
schedule = db.Column(db.String(255)) schedule = db.Column(db.String(255))
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
virtual_link = db.Column(db.String(500), nullable=True) # Link Zoom, Meet, Teams, Moodle
active = db.Column(db.Boolean, default=True) # Changed from is_active to active active = db.Column(db.Boolean, default=True) # Changed from is_active to active
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -72,6 +75,35 @@ class Commission(db.Model):
def teacher_name(self): def teacher_name(self):
return f"{self.teacher.first_name} {self.teacher.last_name}" if self.teacher else "TBD" 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 # Unique constraint
__table_args__ = (db.UniqueConstraint('subject_id', 'code', 'semester', 'year'),) __table_args__ = (db.UniqueConstraint('subject_id', 'code', 'semester', 'year'),)
@@ -83,19 +115,71 @@ class Commission(db.Model):
'code': self.code, 'code': self.code,
'semester': self.semester, 'semester': self.semester,
'year': self.year, 'year': self.year,
'teacher_id': self.teacher_id,
'teacher_name': self.teacher_name, 'teacher_name': self.teacher_name,
'teachers': self.teachers,
'teacher_names': self.teacher_names,
'max_students': self.max_students, 'max_students': self.max_students,
'current_students': self.current_students, 'current_students': self.current_students,
'schedule': self.schedule, 'schedule': self.schedule,
'shift': self.shift,
'virtual_link': self.virtual_link,
'active': self.active, 'active': self.active,
'created_at': self.created_at.isoformat() if self.created_at else None, 'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None, 'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'full_code': self.get_full_code(),
'subject': self.subject.to_dict() if self.subject else None 'subject': self.subject.to_dict() if self.subject else None
} }
def get_full_code(self): def get_full_code(self):
"""Get full commission code""" """Get full commission code"""
return f"{self.subject.code if self.subject else 'N/A'}-{self.code}-{self.semester.upper()}{self.year if self.year else ''}" subj = self.subject.code if self.subject else 'N/A'
code = self.code or ''
sem = str(self.semester or '').strip()
yr = str(self.year or '').strip()
if yr and yr in sem:
return f"{subj}-{code}-{sem}"
elif sem and yr:
return f"{subj}-{code}-{sem}{yr}"
elif sem:
return f"{subj}-{code}-{sem}"
elif yr:
return f"{subj}-{code}-{yr}"
return f"{subj}-{code}"
def __repr__(self): def __repr__(self):
return f'<Commission {self.get_full_code()}>' return f'<Commission {self.get_full_code()}>'
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}>'
+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,34 @@
from typing import Optional, List
from sqlalchemy import func
from app.models.user import User
from app.repositories.base_repository import BaseRepository
class UserRepository(BaseRepository[User]):
"""Repositorio para la gestión de usuarios, credenciales y roles."""
def __init__(self):
super().__init__(User)
def get_by_email(self, email: str) -> Optional[User]:
"""Obtiene un usuario por su dirección de email normalizada."""
if not email:
return None
return User.query.filter(func.lower(User.email) == email.strip().lower()).first()
def get_active_users(self) -> List[User]:
"""Obtiene todos los usuarios con cuenta activa."""
return User.query.filter_by(is_active=True).order_by(User.name.asc()).all()
def get_by_role(self, role_name: str) -> List[User]:
"""Obtiene usuarios por nombre de rol."""
return User.query.filter(
User.is_active == True,
func.lower(User.role) == role_name.strip().lower()
).order_by(User.name.asc()).all()
def get_teachers(self) -> List[User]:
"""Obtiene lista de docentes y profesores activos."""
return User.query.filter(
User.is_active == True,
User.role.in_(['TEACHER', 'DOCENTE', 'PROFESOR', 'teacher', 'docente'])
).order_by(User.name.asc()).all()

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

@@ -5,5 +5,6 @@ from .buildings import buildings_bp
from .schedule import schedule_bp from .schedule import schedule_bp
from .genetic_algorithm import genetic_bp, genetic_web_bp from .genetic_algorithm import genetic_bp, genetic_web_bp
from .admin import admin_bp from .admin import admin_bp
from .my_subjects import my_subjects_bp
__all__ = ['main_bp', 'auth_bp', 'classrooms_bp', 'buildings_bp', 'schedule_bp', 'genetic_bp', 'genetic_web_bp', 'admin_bp'] __all__ = ['main_bp', 'auth_bp', 'classrooms_bp', 'buildings_bp', 'schedule_bp', 'genetic_bp', 'genetic_web_bp', 'admin_bp', 'my_subjects_bp']
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
+113
View File
@@ -0,0 +1,113 @@
from flask import Blueprint, request, jsonify, g, make_response
from pydantic import ValidationError
import jwt
from app.services.user_service import UserService
from app.services.jwt_service import JWTService
from app.schemas.auth_dto import LoginDTO, RefreshTokenDTO
from app.utils.jwt_decorators import jwt_required
api_auth_bp = Blueprint('api_auth', __name__, url_prefix='/api/v1/auth')
user_service = UserService()
@api_auth_bp.route('/login', methods=['POST'])
def login():
"""
Endpoint de autenticación para obtener par de tokens (Access + Refresh).
"""
data = request.get_json(silent=True)
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
user = user_service.authenticate(dto.email, dto.password)
if not user:
return jsonify({
'error': 'Unauthorized',
'message': 'Credenciales de acceso incorrectas o cuenta inactiva.'
}), 401
tokens = JWTService.generate_tokens(user)
profile = user_service.get_profile_data(user)
response_data = {
'access_token': tokens['access_token'],
'refresh_token': tokens['refresh_token'],
'token_type': tokens['token_type'],
'expires_in': tokens['expires_in'],
'user': profile
}
resp = make_response(jsonify(response_data), 200)
# Almacenar refresh token en cookie segura HttpOnly
resp.set_cookie(
'refresh_token',
tokens['refresh_token'],
httponly=True,
samesite='Lax',
max_age=7 * 24 * 3600,
path='/api/v1/auth/refresh'
)
return resp
@api_auth_bp.route('/refresh', methods=['POST'])
def refresh():
"""
Renovación silenciosa del Access Token utilizando el Refresh Token.
"""
data = request.get_json(silent=True)
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():
"""
Cierre de sesión: revoca el token de acceso activo y limpia cookies.
"""
JWTService.revoke_token(g.jwt_token)
resp = make_response(jsonify({'message': 'Sesión finalizada y token revocado exitosamente.'}), 200)
resp.delete_cookie('refresh_token', path='/api/v1/auth/refresh')
return resp
@api_auth_bp.route('/me', methods=['GET'])
@jwt_required
def get_current_user():
"""
Retorna el perfil y los permisos del usuario autenticado vía JWT.
"""
profile = user_service.get_profile_data(g.jwt_user)
return jsonify(profile), 200
+119
View File
@@ -0,0 +1,119 @@
from flask import Blueprint, request, jsonify, g
from pydantic import ValidationError
from app.services.classroom_service import ClassroomService
from app.schemas.classroom_dto import ClassroomCreateDTO, ClassroomUpdateDTO, ClassroomResponseDTO
from app.utils.jwt_decorators import jwt_required, jwt_role_required
api_classrooms_bp = Blueprint('api_classrooms', __name__, url_prefix='/api/v1/classrooms')
classroom_service = ClassroomService()
@api_classrooms_bp.route('', methods=['GET'])
@jwt_required
def list_classrooms():
"""Lista aulas con filtros opcionales (building_id, floor, mode)."""
building_id = request.args.get('building_id', type=int)
floor = request.args.get('floor')
mode = request.args.get('mode', '').lower()
only_virtual = mode == 'virtual'
only_physical = mode == 'physical'
classrooms = classroom_service.list_classrooms(
building_id=building_id,
floor=floor,
only_virtual=only_virtual,
only_physical=only_physical
)
result = []
for c in classrooms:
result.append({
'id': c.id,
'code': c.code,
'building': c.building_name,
'building_id': c.building_id,
'floor': c.floor,
'floor_display': c.floor_display,
'capacity': c.capacity,
'is_virtual': c.is_virtual,
'location_display': c.location_display,
'description': c.description,
'is_active': c.is_active
})
return jsonify({'total': len(result), 'classrooms': result}), 200
@api_classrooms_bp.route('/<int:classroom_id>', methods=['GET'])
@jwt_required
def get_classroom(classroom_id: int):
"""Obtiene el detalle de un aula por ID."""
c = classroom_service.get_classroom(classroom_id)
if not c:
return jsonify({'error': 'NotFound', 'message': f'Aula {classroom_id} no encontrada.'}), 404
return jsonify({
'id': c.id,
'code': c.code,
'building': c.building_name,
'building_id': c.building_id,
'floor': c.floor,
'floor_display': c.floor_display,
'capacity': c.capacity,
'is_virtual': c.is_virtual,
'location_display': c.location_display,
'description': c.description,
'is_active': c.is_active
}), 200
@api_classrooms_bp.route('', methods=['POST'])
@jwt_role_required('ADMIN', 'ADMINISTRADOR', 'BEDELIA')
def create_classroom():
"""Crea una nueva aula validando el contrato con ClassroomCreateDTO."""
data = request.get_json(silent=True) or {}
try:
dto = ClassroomCreateDTO(**data)
except ValidationError as e:
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
try:
classroom = classroom_service.create_classroom(dto)
return jsonify({
'message': 'Aula creada exitosamente.',
'id': classroom.id,
'code': classroom.code,
'location_display': classroom.location_display
}), 201
except ValueError as e:
return jsonify({'error': 'ConflictOrValidation', 'message': str(e)}), 400
@api_classrooms_bp.route('/<int:classroom_id>', methods=['PUT'])
@jwt_role_required('ADMIN', 'ADMINISTRADOR', 'BEDELIA')
def update_classroom(classroom_id: int):
"""Actualiza los datos de un aula."""
data = request.get_json(silent=True) or {}
try:
dto = ClassroomUpdateDTO(**data)
except ValidationError as e:
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
try:
classroom = classroom_service.update_classroom(classroom_id, dto)
return jsonify({
'message': 'Aula actualizada exitosamente.',
'id': classroom.id,
'code': classroom.code,
'capacity': classroom.capacity
}), 200
except ValueError as e:
return jsonify({'error': 'ConflictOrValidation', 'message': str(e)}), 400
@api_classrooms_bp.route('/<int:classroom_id>', methods=['DELETE'])
@jwt_role_required('ADMIN', 'ADMINISTRADOR')
def delete_classroom(classroom_id: int):
"""Elimina o desactiva un aula."""
deleted = classroom_service.delete_classroom(classroom_id)
if not deleted:
return jsonify({'error': 'NotFound', 'message': f'Aula {classroom_id} no encontrada.'}), 404
return jsonify({'message': f'Aula {classroom_id} eliminada/desactivada correctamente.'}), 200
+338
View File
@@ -0,0 +1,338 @@
from flask import Blueprint, jsonify, request
from app.utils.jwt_decorators import jwt_required
from app.models.classroom import Classroom
from app.models.subject import Subject
from app.models.user import User
from app.models.reservation import Reservation
from app.models.audit_log import AuditLog
from datetime import datetime, timedelta
from sqlalchemy import func
from app import db
from app.models.milestone import AcademicMilestone
from app.models.subject import Commission
api_dashboard_bp = Blueprint('api_dashboard', __name__, url_prefix='/api/v1/dashboard')
def serialize_reservation(r):
subject_name = (r.commission.subject.name if (r.commission and r.commission.subject) else r.purpose) or 'Actividad Académica'
subject_code = (r.commission.subject.code if (r.commission and r.commission.subject) else '')
classroom_code = r.classroom.code if r.classroom else 'Sin Aula'
is_virtual = ('virtual' in classroom_code.lower()) or (getattr(r.classroom, 'is_virtual', False) if r.classroom else False)
return {
'id': r.id,
'start_time': r.start_time.strftime('%H:%M') if r.start_time else '',
'end_time': r.end_time.strftime('%H:%M') if r.end_time else '',
'date_display': r.start_time.strftime('%d/%m') if r.start_time else '',
'classroom': {
'id': r.classroom_id,
'code': classroom_code,
'is_virtual': is_virtual,
'location_display': getattr(r.classroom, 'location_display', f'Sede Central · {classroom_code}') if r.classroom else 'Sin Aula'
},
'subject_name': subject_name,
'subject_code': subject_code,
'shift': r.shift or 'Mañana',
'commission': {
'id': r.commission_id,
'code': r.commission.code if r.commission else 'COM-1'
},
'status': str(r.status.value if hasattr(r.status, 'value') else r.status),
'user': {
'id': r.user_id,
'name': r.user.name if r.user else 'Docente'
},
'virtual_link': r.virtual_link or ('https://meet.google.com/edu-space-' + classroom_code.lower().replace(' ', '-') if is_virtual else '')
}
def serialize_milestone(m):
return {
'id': m.id,
'title': m.title,
'materia_codigo': m.materia_codigo,
'subject_name': m.subject.name if (hasattr(m, 'subject') and m.subject) else m.materia_codigo,
'date': m.date.strftime('%d/%m/%Y') if m.date else '',
'type_obj': {
'name': m.type_obj.name if m.type_obj else 'Evaluación',
'color': m.type_obj.color if m.type_obj else '#0d6efd'
},
'classroom': m.classroom or 'Sede Central',
'link': m.link or '',
'notes': m.notes or ''
}
@api_dashboard_bp.route('/stats', methods=['GET'])
@jwt_required
def get_stats():
"""Retorna estadísticas clave y datos operativos para el Dashboard."""
today = datetime.utcnow().date()
try:
total_classrooms = Classroom.query.filter_by(is_active=True).count()
total_subjects = Subject.query.filter_by(is_active=True).count()
total_users = User.query.filter_by(is_active=True).count()
# Aulas presenciales y virtuales
all_active_classrooms = Classroom.query.filter_by(is_active=True).all()
virtual_classrooms_list = [
c for c in all_active_classrooms
if getattr(c, 'is_virtual', False)
or 'virtual' in (c.building or '').lower()
or 'virtual' in (c.code or '').lower()
]
virtual_classrooms = len(virtual_classrooms_list)
physical_classrooms_list = [
c for c in all_active_classrooms
if c not in virtual_classrooms_list
]
total_physical_count = len(physical_classrooms_list)
physical_classrooms = total_physical_count
physical_ids = set(c.id for c in physical_classrooms_list)
# 1. Asignación General del Cuatrimestre / Cronograma
assigned_all_ids = set(
r[0] for r in db.session.query(Reservation.classroom_id)
.filter(Reservation.classroom_id.isnot(None)).distinct().all()
)
assigned_physical_ids = physical_ids.intersection(assigned_all_ids)
assigned_classrooms = len(assigned_physical_ids)
unassigned_classrooms = max(0, total_physical_count - assigned_classrooms)
assigned_pct = round((assigned_classrooms / max(1, total_physical_count)) * 100, 1) if total_physical_count > 0 else 0.0
unassigned_pct = round(100.0 - assigned_pct, 1) if total_physical_count > 0 else 0.0
# 2. Asignación Operativa del Día de Hoy
assigned_today_ids = set(
r[0] for r in db.session.query(Reservation.classroom_id).filter(
Reservation.classroom_id.isnot(None),
func.date(Reservation.start_time) == today
).distinct().all()
)
assigned_today_physical_ids = physical_ids.intersection(assigned_today_ids)
assigned_today_count = len(assigned_today_physical_ids)
unassigned_today_count = max(0, total_physical_count - assigned_today_count)
assigned_today_pct = round((assigned_today_count / max(1, total_physical_count)) * 100, 1) if total_physical_count > 0 else 0.0
unassigned_today_pct = round(100.0 - assigned_today_pct, 1) if total_physical_count > 0 else 0.0
today_reservations_q = Reservation.query.filter(
func.date(Reservation.start_time) == today
).order_by(Reservation.start_time.asc()).all()
today_reservations = len(today_reservations_q)
pending_reservations_q = Reservation.query.filter(
Reservation.status == 'PENDING'
).order_by(Reservation.start_time.asc()).all()
pending_count = len(pending_reservations_q)
upcoming_reservations_q = Reservation.query.filter(
Reservation.start_time >= datetime.utcnow()
).order_by(Reservation.start_time.asc()).limit(10).all()
milestones_q = AcademicMilestone.query.filter(
AcademicMilestone.is_active == True
).order_by(AcademicMilestone.date.asc()).limit(10).all()
recent_audits = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(6).all()
audit_data = [{
'created_at': a.created_at.strftime('%Y-%m-%d %H:%M') if a.created_at else None,
'action_type': a.action_type,
'ip_address': a.ip_address,
'user_email': getattr(a.user, 'email', 'System') if hasattr(a, 'user') else 'System'
} for a in recent_audits]
occupancy_rate = min(100, int((today_reservations / max(1, physical_classrooms * 3)) * 100)) if physical_classrooms > 0 else 0
pending_reservations = [serialize_reservation(r) for r in pending_reservations_q]
today_schedule_list = [serialize_reservation(r) for r in today_reservations_q]
upcoming_reservations = [serialize_reservation(r) for r in upcoming_reservations_q]
milestones = [serialize_milestone(m) for m in milestones_q]
# ---- 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,
'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
+292
View File
@@ -0,0 +1,292 @@
from flask import Blueprint, request, jsonify, g
from datetime import datetime, timedelta, date
from pydantic import ValidationError
from app.services.reservation_service import ReservationService
from app.schemas.reservation_dto import ReservationCreateDTO, ReservationUpdateDTO
from app.utils.jwt_decorators import jwt_required, jwt_role_required
api_reservations_bp = Blueprint('api_reservations', __name__, url_prefix='/api/v1/reservations')
reservation_service = ReservationService()
@api_reservations_bp.route('', methods=['GET'])
@jwt_required
def list_reservations():
"""Consulta de reservas por rango de fechas y/o aula."""
start_str = request.args.get('start_date')
end_str = request.args.get('end_date')
classroom_id = request.args.get('classroom_id', type=int)
now = datetime.utcnow()
try:
start_dt = datetime.strptime(start_str, '%Y-%m-%d') if start_str else now.replace(hour=0, minute=0, second=0)
except ValueError:
return jsonify({'error': 'BadRequest', 'message': 'Formato inválido de start_date (debe ser YYYY-MM-DD).'}), 400
try:
end_dt = datetime.strptime(end_str, '%Y-%m-%d').replace(hour=23, minute=59, second=59) if end_str else start_dt + timedelta(days=7)
except ValueError:
return jsonify({'error': 'BadRequest', 'message': 'Formato inválido de end_date (debe ser YYYY-MM-DD).'}), 400
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
floor = request.args.get('floor')
shift = request.args.get('shift')
search = request.args.get('search')
reservations = reservation_service.list_reservations(start_dt, end_dt, classroom_id=classroom_id)
result = []
for r in reservations:
is_virt = bool(r.is_virtual or (r.classroom and r.classroom.is_virtual))
if hide_virtual and is_virt:
continue
if floor and r.classroom and str(r.classroom.floor).strip().lower() != str(floor).strip().lower():
continue
if shift and r.shift and shift.lower() not in r.shift.lower():
continue
if search:
s_lower = search.lower()
subj_n = (r.subject_name or '').lower()
subj_c = (r.subject_code or '').lower()
c_name = (r.classroom.code if r.classroom else '').lower()
u_name = (r.user.name if r.user else '').lower()
if s_lower not in subj_n and s_lower not in subj_c and s_lower not in c_name and s_lower not in u_name:
continue
result.append({
'id': r.id,
'classroom_id': r.classroom_id,
'classroom_name': r.classroom.code if r.classroom else None,
'classroom': {
'id': r.classroom.id,
'code': r.classroom.code,
'code_display': getattr(r.classroom, 'code_display', r.classroom.code),
'floor': r.classroom.floor,
'floor_display': getattr(r.classroom, 'floor_display', f"Piso {r.classroom.floor}"),
'building': getattr(r.classroom, 'building_name', r.classroom.building),
'is_virtual': getattr(r.classroom, 'is_virtual', False),
'location_display': getattr(r.classroom, 'location_display', r.classroom.code)
} if r.classroom else None,
'commission_id': r.commission_id,
'commission': {
'id': r.commission.id,
'code': r.commission.code,
'virtual_link': r.commission.virtual_link,
'max_students': r.commission.max_students
} if r.commission else None,
'teacher_name': r.user.name if r.user else None,
'user': {
'id': r.user.id,
'name': r.user.name,
'email': r.user.email
} if r.user else None,
'start_time': r.start_time.isoformat(),
'end_time': r.end_time.isoformat(),
'purpose': r.purpose,
'subject_name': r.subject_name,
'subject_code': r.subject_code,
'status': str(r.status.value if hasattr(r.status, 'value') else r.status),
'shift': r.shift or 'Mañana',
'virtual_link': r.virtual_link,
'effective_virtual_link': r.effective_virtual_link,
'is_virtual': is_virt,
'expected_attendees': r.expected_attendees or 30,
'notes': r.notes or ''
})
return jsonify({
'total': len(result),
'start_date': start_dt.strftime('%Y-%m-%d'),
'end_date': end_dt.strftime('%Y-%m-%d'),
'reservations': result
}), 200
@api_reservations_bp.route('/<int:reservation_id>', methods=['GET'])
@jwt_required
def get_reservation(reservation_id: int):
"""Obtiene el detalle de una reserva específica con sus relaciones completas."""
r = reservation_service.get_reservation(reservation_id)
if not r:
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
duration_min = int((r.end_time - r.start_time).total_seconds() / 60) if (r.start_time and r.end_time) else 120
is_virt = bool(r.is_virtual or (r.classroom and r.classroom.is_virtual))
return jsonify({
'id': r.id,
'classroom_id': r.classroom_id,
'classroom_name': r.classroom.code if r.classroom else None,
'location_display': getattr(r.classroom, 'location_display', r.classroom.code if r.classroom else None),
'classroom': {
'id': r.classroom.id,
'code': r.classroom.code,
'code_display': getattr(r.classroom, 'code_display', r.classroom.code),
'building_name': getattr(r.classroom, 'building_name', getattr(r.classroom, 'building', 'Edificio Central')),
'floor': r.classroom.floor,
'floor_display': getattr(r.classroom, 'floor_display', f"Piso {r.classroom.floor}"),
'capacity': r.classroom.capacity,
'capacity_display': getattr(r.classroom, 'capacity_display', str(r.classroom.capacity)),
'description': getattr(r.classroom, 'description', '') or '',
'is_virtual': getattr(r.classroom, 'is_virtual', False)
} if r.classroom else None,
'commission_id': r.commission_id,
'commission': {
'id': r.commission.id,
'code': r.commission.code,
'full_code': r.commission.get_full_code() if hasattr(r.commission, 'get_full_code') else r.commission.code,
'virtual_link': r.commission.virtual_link,
'max_students': r.commission.max_students,
'current_students': getattr(r.commission, 'current_students', 0),
'subject': {
'id': r.commission.subject.id,
'name': r.commission.subject.name,
'code': r.commission.subject.code
} if (r.commission and r.commission.subject) else None
} if r.commission else None,
'teacher_id': r.user_id,
'teacher_name': r.user.name if r.user else None,
'user': {
'id': r.user.id,
'name': r.user.name,
'first_name': getattr(r.user, 'first_name', r.user.name.split()[0] if r.user.name else 'Usuario'),
'last_name': getattr(r.user, 'last_name', ' '.join(r.user.name.split()[1:]) if r.user.name and len(r.user.name.split()) > 1 else ''),
'email': r.user.email
} if r.user else None,
'start_time': r.start_time.isoformat(),
'end_time': r.end_time.isoformat(),
'duration': duration_min,
'purpose': r.purpose,
'status': str(r.status.value if hasattr(r.status, 'value') else r.status),
'shift': r.shift or 'Mañana',
'virtual_link': r.virtual_link,
'effective_virtual_link': r.effective_virtual_link,
'is_virtual': is_virt,
'expected_attendees': r.expected_attendees or 30,
'notes': r.notes or '',
'can_be_cancelled': r.can_be_cancelled() if hasattr(r, 'can_be_cancelled') else True
}), 200
@api_reservations_bp.route('', methods=['POST'])
@jwt_required
def create_reservation():
"""Crea una reserva aplicando validación DTO y detección de colisiones."""
data = request.get_json(silent=True) or {}
# Inyectar usuario autenticado si no se especifica
if 'user_id' not in data:
data['user_id'] = g.jwt_user.id
try:
dto = ReservationCreateDTO(**data)
except ValidationError as e:
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
try:
reservation = reservation_service.create_reservation(dto)
return jsonify({
'message': 'Reserva creada exitosamente.',
'id': reservation.id,
'classroom_id': reservation.classroom_id,
'start_time': reservation.start_time.isoformat(),
'end_time': reservation.end_time.isoformat(),
'status': str(reservation.status)
}), 201
except ValueError as e:
return jsonify({'error': 'ConflictOrValidation', 'message': str(e)}), 409
@api_reservations_bp.route('/<int:reservation_id>', methods=['PUT'])
@jwt_required
def update_reservation(reservation_id: int):
"""Modifica una reserva existente."""
data = request.get_json(silent=True) or {}
try:
dto = ReservationUpdateDTO(**data)
except ValidationError as e:
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
try:
reservation = reservation_service.update_reservation(reservation_id, dto)
return jsonify({
'message': 'Reserva actualizada exitosamente.',
'id': reservation.id,
'start_time': reservation.start_time.isoformat(),
'end_time': reservation.end_time.isoformat(),
'status': str(reservation.status)
}), 200
except ValueError as e:
return jsonify({'error': 'ConflictOrValidation', 'message': str(e)}), 409
@api_reservations_bp.route('/<int:reservation_id>', methods=['DELETE'])
@jwt_required
def cancel_reservation(reservation_id: int):
"""Cancela una reserva."""
cancelled = reservation_service.cancel_reservation(reservation_id)
if not cancelled:
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
return jsonify({'message': f'Reserva {reservation_id} cancelada correctamente.'}), 200
@api_reservations_bp.route('/<int:reservation_id>/cancel', methods=['POST'])
@jwt_required
def cancel_reservation_post(reservation_id: int):
"""Cancela una reserva (endpoint POST compatible con formularios)."""
cancelled = reservation_service.cancel_reservation(reservation_id)
if not cancelled:
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
return jsonify({'message': f'Reserva {reservation_id} cancelada correctamente.'}), 200
@api_reservations_bp.route('/<int:reservation_id>/confirm', methods=['POST'])
@jwt_required
def confirm_reservation(reservation_id: int):
"""Confirma una reserva cambiando su estado a CONFIRMED."""
confirmed = reservation_service.confirm_reservation(reservation_id)
if not confirmed:
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
return jsonify({'message': f'Reserva {reservation_id} confirmada correctamente.'}), 200
@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
@@ -8,15 +8,18 @@ from datetime import datetime
auth_bp = Blueprint('auth', __name__) auth_bp = Blueprint('auth', __name__)
from sqlalchemy import func
@auth_bp.route('/login', methods=['GET', 'POST']) @auth_bp.route('/login', methods=['GET', 'POST'])
def login(): def login():
"""Handle user login""" """Handle user login with case-insensitive email lookup and whitespace trimming"""
if current_user.is_authenticated: if current_user.is_authenticated:
return redirect(url_for('main.dashboard')) return redirect(url_for('main.dashboard'))
form = LoginForm() form = LoginForm()
if form.validate_on_submit(): if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data).first() clean_email = (form.email.data or '').strip().lower()
user = User.query.filter(func.lower(User.email) == clean_email).first()
if user and user.check_password(form.password.data): if user and user.check_password(form.password.data):
if not user.is_active: if not user.is_active:
@@ -26,6 +29,19 @@ def login():
login_user(user, remember=form.remember_me.data) login_user(user, remember=form.remember_me.data)
user.update_last_login() 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 # 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']: if user.preferred_language and user.preferred_language in ['es', 'en']:
session['language'] = user.preferred_language session['language'] = user.preferred_language
@@ -42,6 +58,15 @@ def login():
flash(_('¡Bienvenido de nuevo, %(name)s!', name=user_full_name), 'success') flash(_('¡Bienvenido de nuevo, %(name)s!', name=user_full_name), 'success')
return redirect(next_page) return redirect(next_page)
else: 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') flash(_('Correo electrónico o contraseña inválidos.'), 'error')
return render_template('auth/login.html', form=form) return render_template('auth/login.html', form=form)
+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 datetime import datetime, timedelta
from app.models.genetic_algorithm import ReservationOptimizer from app.models.genetic_algorithm import ReservationOptimizer
from app.models.reservation import Reservation, ReservationStatus from app.models.reservation import Reservation, ReservationStatus
from app import db 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 = Blueprint('genetic_algorithm', __name__, url_prefix='/api/genetic')
@genetic_bp.route('/optimize', methods=['POST']) @genetic_bp.route('/optimize', methods=['POST'])
@login_required @jwt_required
def optimize_reservations(): def optimize_reservations():
"""Optimize reservations using genetic algorithm""" """Optimize reservations using genetic algorithm"""
try: try:
@@ -39,15 +39,15 @@ def optimize_reservations():
except ValueError: except ValueError:
return jsonify({'error': 'Invalid end_date format. Use ISO format.'}), 400 return jsonify({'error': 'Invalid end_date format. Use ISO format.'}), 400
# Check if user has permission (admin or teacher) # Check if user has permission (admin, teacher or RBAC optimizer write)
if current_user.role not in ['ADMIN', 'teacher']: if not (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 return jsonify({'error': 'Insufficient permissions to optimize reservations'}), 403
# Initialize optimizer and run optimization # Initialize optimizer and run optimization
optimizer = ReservationOptimizer() optimizer = ReservationOptimizer()
result = optimizer.optimize_schedule( result = optimizer.optimize_schedule(
commission_ids=commission_ids, commission_ids=commission_ids,
admin_user_id=current_user.id, admin_user_id=g.jwt_user.id,
start_date=start_date, start_date=start_date,
end_date=end_date end_date=end_date
) )
@@ -69,7 +69,7 @@ def optimize_reservations():
@genetic_bp.route('/apply-optimization', methods=['POST']) @genetic_bp.route('/apply-optimization', methods=['POST'])
@login_required @jwt_required
def apply_optimization(): def apply_optimization():
"""Apply optimized reservations to the database""" """Apply optimized reservations to the database"""
try: try:
@@ -84,7 +84,7 @@ def apply_optimization():
return jsonify({'error': 'reservations must be a list'}), 400 return jsonify({'error': 'reservations must be a list'}), 400
# Check if user has permission # Check if user has permission
if current_user.role not in ['ADMIN', 'teacher']: if not (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 return jsonify({'error': 'Insufficient permissions to apply reservations'}), 403
# Initialize optimizer and apply reservations # Initialize optimizer and apply reservations
@@ -106,7 +106,7 @@ def apply_optimization():
@genetic_bp.route('/preview-optimization', methods=['POST']) @genetic_bp.route('/preview-optimization', methods=['POST'])
@login_required @jwt_required
def preview_optimization(): def preview_optimization():
"""Preview optimization without applying to database""" """Preview optimization without applying to database"""
try: try:
@@ -124,14 +124,14 @@ def preview_optimization():
return jsonify({'error': 'commission_ids must be a non-empty list'}), 400 return jsonify({'error': 'commission_ids must be a non-empty list'}), 400
# Check if user has permission # 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 return jsonify({'error': 'Insufficient permissions to preview optimization'}), 403
# Initialize optimizer and run optimization # Initialize optimizer and run optimization
optimizer = ReservationOptimizer() optimizer = ReservationOptimizer()
result = optimizer.optimize_schedule( result = optimizer.optimize_schedule(
commission_ids=commission_ids, commission_ids=commission_ids,
admin_user_id=current_user.id admin_user_id=g.jwt_user.id
) )
# Add additional preview information # Add additional preview information
@@ -166,12 +166,12 @@ def preview_optimization():
@genetic_bp.route('/commissions', methods=['GET']) @genetic_bp.route('/commissions', methods=['GET'])
@login_required @jwt_required
def get_commissions(): def get_commissions():
"""Get available commissions for optimization""" """Get available commissions for optimization"""
try: try:
# Check if user has permission # 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 return jsonify({'error': 'Insufficient permissions'}), 403
from app.models.subject import Commission, Subject from app.models.subject import Commission, Subject
@@ -196,12 +196,12 @@ def get_commissions():
@genetic_bp.route('/algorithm-status', methods=['GET']) @genetic_bp.route('/algorithm-status', methods=['GET'])
@login_required @jwt_required
def get_algorithm_status(): def get_algorithm_status():
"""Get genetic algorithm configuration status""" """Get genetic algorithm configuration status"""
try: try:
# Check if user has permission # 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 return jsonify({'error': 'Insufficient permissions'}), 403
# Get system status # Get system status
@@ -246,10 +246,10 @@ genetic_web_bp = Blueprint('genetic_algorithm_web', __name__)
@genetic_web_bp.route('/genetic-optimizer') @genetic_web_bp.route('/genetic-optimizer')
@login_required @jwt_required
def genetic_optimizer(): def genetic_optimizer():
"""Render the genetic algorithm optimizer interface""" """Render the genetic algorithm optimizer interface"""
if current_user.role not in ['ADMIN']: if not (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('auth/403.html'), 403
return render_template('genetic_optimizer.html') return render_template('genetic_optimizer.html')
+333
View File
@@ -0,0 +1,333 @@
from flask import Blueprint, render_template, jsonify, redirect, url_for, session, request, flash
from flask_login import login_required, current_user
from app import db
from app.models.reservation import Reservation
from app.models.classroom import Classroom
from app.models.subject import Subject
from app.models.subject import Commission
from datetime import datetime, timedelta
from sqlalchemy import func
from flask_babel import gettext as _
main_bp = Blueprint('main', __name__)
@main_bp.route('/')
@login_required
def dashboard():
"""Role-personalized institutional dashboard page"""
from app.models.user import User
from app.models.milestone import AcademicMilestone
from app.models.audit_log import AuditLog
actual_role = current_user.get_institutional_role()
view_as = request.args.get('view_as', '').lower().strip()
# Allow admins and management roles to preview dashboard perspectives
if view_as in ['admin', 'bedelia', 'docente', 'alumno'] and (current_user.is_admin() or current_user.can_manage()):
role = view_as
else:
role = actual_role
today = datetime.utcnow().date()
# Common core statistics
try:
total_classrooms = Classroom.query.filter_by(is_active=True).count()
except Exception:
total_classrooms = 0
try:
total_subjects = Subject.query.filter_by(is_active=True).count()
except Exception:
total_subjects = 0
try:
today_reservations = Reservation.query.filter(
func.date(Reservation.start_time) == today
).count()
except Exception:
today_reservations = 0
week_start = today - timedelta(days=today.weekday())
week_end = week_start + timedelta(days=6)
try:
week_reservations = Reservation.query.filter(
func.date(Reservation.start_time) >= week_start,
func.date(Reservation.start_time) <= week_end
).count()
except Exception:
week_reservations = 0
stats = {
'total_classrooms': total_classrooms,
'total_subjects': total_subjects,
'today_reservations': today_reservations,
'week_reservations': week_reservations
}
role_data = {}
if role == 'admin':
# 1. Admin Institutional Cockpit
try:
total_users = User.query.filter_by(is_active=True).count()
except Exception:
total_users = 0
try:
pending_count = Reservation.query.filter(
Reservation.status == 'PENDING'
).count()
except Exception:
pending_count = 0
try:
recent_audits = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(6).all()
except Exception:
recent_audits = []
try:
upcoming_reservations = Reservation.query.filter(
Reservation.start_time >= datetime.utcnow(),
Reservation.start_time <= datetime.utcnow() + timedelta(days=7)
).order_by(Reservation.start_time.asc()).limit(8).all()
except Exception:
upcoming_reservations = []
stats.update({
'total_users': total_users,
'pending_reservations': pending_count
})
role_data = {
'recent_audits': recent_audits,
'upcoming_reservations': upcoming_reservations
}
elif role == 'bedelia':
# 2. Bedelía Operations & Facilities Control
try:
physical_classrooms = Classroom.query.filter_by(is_active=True).filter(
~Classroom.room_number.ilike('%VIRTUAL%') & ~Classroom.building.ilike('%VIRTUAL%')
).count()
virtual_classrooms = total_classrooms - physical_classrooms
if virtual_classrooms < 0:
virtual_classrooms = 0
except Exception:
physical_classrooms = total_classrooms
virtual_classrooms = 0
try:
pending_reservations = Reservation.query.filter(
Reservation.status == 'PENDING'
).order_by(Reservation.start_time.asc()).limit(10).all()
pending_count = len(pending_reservations)
except Exception:
pending_reservations = []
pending_count = 0
try:
today_schedule_list = Reservation.query.filter(
func.date(Reservation.start_time) == today
).order_by(Reservation.start_time.asc()).limit(12).all()
except Exception:
today_schedule_list = []
occupancy_rate = min(100, int((today_reservations / max(1, physical_classrooms * 3)) * 100)) if physical_classrooms > 0 else 0
stats.update({
'physical_classrooms': physical_classrooms,
'virtual_classrooms': virtual_classrooms,
'pending_count': pending_count,
'occupancy_rate': occupancy_rate
})
role_data = {
'pending_reservations': pending_reservations,
'today_schedule_list': today_schedule_list
}
elif role == 'docente':
# 3. Docente (Profesor) Academic & Classroom Workflow
try:
my_today_classes = Reservation.query.filter(
Reservation.user_id == current_user.id,
func.date(Reservation.start_time) == today
).order_by(Reservation.start_time.asc()).all()
except Exception:
my_today_classes = []
try:
my_upcoming_reservations = Reservation.query.filter(
Reservation.user_id == current_user.id,
Reservation.start_time >= datetime.utcnow()
).order_by(Reservation.start_time.asc()).limit(8).all()
except Exception:
my_upcoming_reservations = []
try:
my_total_reservations = Reservation.query.filter(
Reservation.user_id == current_user.id
).count()
except Exception:
my_total_reservations = 0
try:
virtual_classrooms = Classroom.query.filter_by(is_active=True).filter(
(Classroom.room_number.ilike('%VIRTUAL%')) | (Classroom.building.ilike('%VIRTUAL%'))
).limit(4).all()
except Exception:
virtual_classrooms = []
stats.update({
'my_today_count': len(my_today_classes),
'my_total_reservations': my_total_reservations,
'virtual_rooms_count': len(virtual_classrooms)
})
role_data = {
'my_today_classes': my_today_classes,
'my_upcoming_reservations': my_upcoming_reservations,
'virtual_classrooms': virtual_classrooms
}
elif role == 'alumno':
# 4. Alumno (Estudiante) Daily Guide & Timetable
try:
today_classes = Reservation.query.filter(
func.date(Reservation.start_time) == today,
Reservation.status == 'CONFIRMED'
).order_by(Reservation.start_time.asc()).limit(12).all()
except Exception:
today_classes = []
try:
upcoming_milestones = AcademicMilestone.query.filter(
AcademicMilestone.date >= datetime.utcnow(),
AcademicMilestone.is_active == True
).order_by(AcademicMilestone.date.asc()).limit(6).all()
except Exception:
upcoming_milestones = []
try:
virtual_rooms = Classroom.query.filter_by(is_active=True).filter(
(Classroom.room_number.ilike('%VIRTUAL%')) | (Classroom.building.ilike('%VIRTUAL%'))
).limit(4).all()
except Exception:
virtual_rooms = []
stats.update({
'today_classes_count': len(today_classes),
'milestones_count': len(upcoming_milestones)
})
role_data = {
'today_classes': today_classes,
'upcoming_milestones': upcoming_milestones,
'virtual_rooms': virtual_rooms
}
# Backward compatibility for upcoming_reservations
upcoming_reservations = role_data.get('upcoming_reservations', [])
if not upcoming_reservations:
try:
upcoming_reservations = Reservation.query.filter(
Reservation.start_time >= datetime.utcnow(),
Reservation.start_time <= datetime.utcnow() + timedelta(days=7)
).order_by(Reservation.start_time.asc()).limit(6).all()
except Exception:
upcoming_reservations = []
return render_template('dashboard.html',
role=role,
actual_role=actual_role,
stats=stats,
role_data=role_data,
upcoming_reservations=upcoming_reservations)
@main_bp.route('/api/dashboard-stats')
@login_required
def dashboard_stats_api():
"""API endpoint for dashboard statistics"""
# Get today's data
today = datetime.utcnow().date()
today_reservations = Reservation.query.filter(
func.date(Reservation.start_time) == today
).count()
# Get this week's data
week_start = today - timedelta(days=today.weekday())
week_end = week_start + timedelta(days=6)
week_reservations = Reservation.query.filter(
func.date(Reservation.start_time) >= week_start,
func.date(Reservation.start_time) <= week_end
).count()
# Get monthly data for chart
current_date = datetime.utcnow()
monthly_data = []
for i in range(12):
month_date = current_date - timedelta(days=30 * i)
month_start = month_date.replace(day=1)
month_end = (month_start + timedelta(days=32)).replace(day=1) - timedelta(days=1)
monthly_reservations = Reservation.query.filter(
func.date(Reservation.start_time) >= month_start.date(),
func.date(Reservation.start_time) <= month_end.date()
).count()
monthly_data.append({
'month': month_start.strftime('%Y-%m'),
'reservations': monthly_reservations
})
return jsonify({
'today_reservations': today_reservations,
'week_reservations': week_reservations,
'monthly_data': list(reversed(monthly_data))
})
@main_bp.route('/genetic-optimizer')
@login_required
def genetic_optimizer():
"""Serve the genetic algorithm optimization interface"""
if not (current_user.has_permission('optimizer', 'read') or (current_user.role and current_user.role.upper() == 'ADMIN')):
flash(_('No tienes permisos para acceder a la herramienta de optimización.'), 'danger')
return redirect(url_for('main.dashboard'))
return render_template('genetic_optimizer.html')
@main_bp.route('/set_language/<language>')
def set_language(language=None):
if language not in ['en', 'es']:
language = 'es'
session['language'] = language
# Si se selecciona el idioma desde la pantalla de login, registrar la selección explícita
if request.referrer and 'login' in request.referrer:
session['login_lang_explicit'] = language
if current_user.is_authenticated:
current_user.preferred_language = language
db.session.commit()
return redirect(request.referrer or url_for('main.dashboard'))
@main_bp.route('/api/milestone-types')
def api_milestone_types():
"""Retorna las tipificaciones de hitos activas en JSON para consumo de frontend/mobile"""
from app.models.milestone import MilestoneType, BASE_MILESTONE_TYPES
if MilestoneType.query.count() == 0:
for item in BASE_MILESTONE_TYPES:
mt = MilestoneType(
name=item['name'],
code=item['code'],
color=item['color'],
description=item['description'],
sort_order=item['sort_order'],
is_active=True
)
db.session.add(mt)
db.session.commit()
types = MilestoneType.query.filter_by(is_active=True).order_by(MilestoneType.sort_order.asc(), MilestoneType.id.asc()).all()
return jsonify([t.to_dict() for t in types])␍
+126
View File
@@ -0,0 +1,126 @@
from flask import Blueprint, render_template, abort
from flask_login import login_required, current_user
from app import db
from app.models.subject import Commission
from app.models.enrollment import StudentEnrollment
from app.models.classroom import Classroom
my_subjects_bp = Blueprint('my_subjects', __name__, url_prefix='/mis-materias')
@my_subjects_bp.route('/')
@login_required
def my_subjects_list():
"""Show the commissions assigned to the current user (teacher or student)."""
role = current_user.get_institutional_role()
commissions = []
if role in ['admin', 'bedelia']:
# Admin/Bedelia: see all active commissions
commissions = (
Commission.query
.filter_by(active=True)
.order_by(Commission.year.desc(), Commission.semester.asc(), Commission.code.asc())
.all()
)
view_mode = 'all'
elif role == 'docente':
# Teacher: only commissions where they are assigned as teacher
commissions = (
Commission.query
.filter_by(teacher_id=current_user.id, active=True)
.order_by(Commission.year.desc(), Commission.semester.asc(), Commission.code.asc())
.all()
)
view_mode = 'teacher'
elif role == 'alumno':
# Student: only commissions where they are enrolled
enrollments = (
StudentEnrollment.query
.filter_by(student_id=current_user.id, status='activo')
.all()
)
commission_ids = [e.commission_id for e in enrollments]
commissions = (
Commission.query
.filter(Commission.id.in_(commission_ids), Commission.active == True)
.order_by(Commission.year.desc(), Commission.semester.asc(), Commission.code.asc())
.all()
)
view_mode = 'student'
else:
commissions = []
view_mode = 'none'
return render_template(
'my_subjects/list.html',
commissions=commissions,
view_mode=view_mode,
role=role
)
@my_subjects_bp.route('/<int:commission_id>')
@login_required
def my_subject_detail(commission_id):
"""Detail view of a single commission for the current user."""
role = current_user.get_institutional_role()
commission = Commission.query.get_or_404(commission_id)
# Access control: make sure the user is allowed to see this commission
if role in ['admin', 'bedelia']:
pass # full access
elif role == 'docente':
if commission.teacher_id != current_user.id:
abort(403)
elif role == 'alumno':
enrollment = StudentEnrollment.query.filter_by(
student_id=current_user.id,
commission_id=commission_id
).first()
if not enrollment:
abort(403)
else:
abort(403)
# Get enrolled students (visible to teachers and admin/bedelia)
enrollments = []
if role in ['admin', 'bedelia', 'docente']:
enrollments = (
StudentEnrollment.query
.filter_by(commission_id=commission_id)
.order_by(StudentEnrollment.enrolled_at.asc())
.all()
)
# Try to get classroom info from reservations linked to this commission
from app.models.reservation import Reservation
from sqlalchemy import func
from datetime import datetime
# Recent or upcoming reservation linked to this commission
recent_reservation = (
Reservation.query
.filter(
Reservation.commission_id == commission_id,
Reservation.start_time >= datetime.utcnow()
)
.order_by(Reservation.start_time.asc())
.first()
)
classroom = None
if recent_reservation and recent_reservation.classroom_id:
classroom = Classroom.query.get(recent_reservation.classroom_id)
return render_template(
'my_subjects/detail.html',
commission=commission,
enrollments=enrollments,
classroom=classroom,
recent_reservation=recent_reservation,
role=role
)
@@ -40,8 +40,12 @@ def today_schedule():
~Classroom.building.ilike('%Virtual%') ~Classroom.building.ilike('%Virtual%')
) )
if floor_filter and floor_filter.isdigit(): if floor_filter:
query = query.filter(Classroom.floor == int(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: if shift_filter:
query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%')) query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%'))
@@ -135,14 +139,102 @@ def view_reservation(id):
flash(f'Error loading reservation details: {str(e)}', 'error') flash(f'Error loading reservation details: {str(e)}', 'error')
return redirect(url_for('schedule.today_schedule')) 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') @schedule_bp.route('/calendar')
@login_required @login_required
def calendar_view(): def calendar_view():
"""Show calendar view with classroom, subject, career, shift, floor and virtual filtering""" """Show calendar view with classroom, subject, career, shift, floor and virtual filtering"""
from app.models.career import Career 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() 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() subjects = Subject.query.filter_by(is_active=True).order_by(Subject.name.asc()).all()
careers = Career.query.order_by(Career.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() 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] available_floors = [f[0] for f in floors]
@@ -154,22 +246,246 @@ def calendar_view():
classrooms=classrooms, classrooms=classrooms,
subjects=subjects, subjects=subjects,
careers=careers, careers=careers,
buildings=buildings,
available_floors=available_floors available_floors=available_floors
) )
@schedule_bp.route('/commissions') def user_can_manage_commission_milestones(user, commission=None):
def commissions_view(): """Verifica si el usuario (Docente, Admin, Bedelía) puede gestionar hitos y links virtuales"""
subjects = Subject.query.all() 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')
# Calculamos el total de capacidad sumando todas las comisiones de todas las materias
@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_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) 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', return render_template('schedule/commissions.html',
subjects=subjects, subjects=subjects,
classrooms=classrooms,
teachers=teachers,
milestone_types=milestone_types,
total_capacity=total_capacity, total_capacity=total_capacity,
total_commissions=total_commissions) 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']) @schedule_bp.route('/add', methods=['GET', 'POST'])
@login_required @login_required
@@ -177,23 +493,23 @@ def add_reservation():
"""Add new reservation""" """Add new reservation"""
from app.models.classroom import Classroom from app.models.classroom import Classroom
from app.models.subject import Commission from app.models.subject import Commission
from app.models.building import Building
form = ReservationForm() form = ReservationForm()
# Get active classrooms for the dropdown # Get active buildings and classrooms
classrooms = Classroom.query.filter_by(is_active=True).all() 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()
# Get active commissions for the dropdown
commissions = Commission.query.filter_by(active=True).all() commissions = Commission.query.filter_by(active=True).all()
# Populate form choices # Populate form choices
form.classroom_id.choices = [(c.id, f'{c.code_display} - {c.name} (Cap: {c.capacity})') for c in classrooms] 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] 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 request.method == 'POST':
if form.validate_on_submit(): if form.validate_on_submit():
try: try:
# Check for existing reservations at the same time # Check for existing reservations at the same time
from app.models.reservation import ReservationStatus from app.models.reservation import ReservationStatus
existing_reservation = Reservation.query.filter_by( existing_reservation = Reservation.query.filter_by(
classroom_id=form.classroom_id.data, classroom_id=form.classroom_id.data,
@@ -205,7 +521,7 @@ def add_reservation():
if existing_reservation: if existing_reservation:
flash('This classroom is already booked for the selected time slot.', 'danger') 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) return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions, buildings=buildings)
# Create new reservation # Create new reservation
reservation = Reservation( reservation = Reservation(
@@ -216,6 +532,7 @@ def add_reservation():
end_time=form.end_time.data, end_time=form.end_time.data,
purpose=form.purpose.data, purpose=form.purpose.data,
notes=form.notes.data, notes=form.notes.data,
virtual_link=form.virtual_link.data.strip() if form.virtual_link.data else None,
status=ReservationStatus.PENDING.value, status=ReservationStatus.PENDING.value,
expected_attendees=form.expected_attendees.data expected_attendees=form.expected_attendees.data
) )
@@ -228,10 +545,53 @@ def add_reservation():
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
current_app.logger.error(f"Error creating reservation: {str(e)}")
flash(f'Error creating reservation: {str(e)}', 'danger') 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) 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') @schedule_bp.route('/list')
@login_required @login_required
@@ -244,6 +604,7 @@ def list_reservations():
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True'] hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
floor_filter = request.args.get('floor', '').strip() floor_filter = request.args.get('floor', '').strip()
shift_filter = request.args.get('shift', '').strip() shift_filter = request.args.get('shift', '').strip()
classroom_id = request.args.get('classroom_id', type=int)
search = request.args.get('search', '').strip() search = request.args.get('search', '').strip()
query = Reservation.query.options( query = Reservation.query.options(
@@ -252,6 +613,9 @@ def list_reservations():
joinedload(Reservation.user) joinedload(Reservation.user)
).join(Reservation.classroom).outerjoin(Reservation.commission).outerjoin(Commission.subject) ).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': if status_filter and status_filter != 'all':
query = query.filter(Reservation.status == status_filter.upper()) query = query.filter(Reservation.status == status_filter.upper())
@@ -275,8 +639,12 @@ def list_reservations():
~Classroom.building.ilike('%Virtual%') ~Classroom.building.ilike('%Virtual%')
) )
if floor_filter and floor_filter.isdigit(): if floor_filter:
query = query.filter(Classroom.floor == int(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: if shift_filter:
query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%')) query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%'))
@@ -286,8 +654,10 @@ def list_reservations():
or_( or_(
Subject.name.ilike(f'%{search}%'), Subject.name.ilike(f'%{search}%'),
Subject.code.ilike(f'%{search}%'), Subject.code.ilike(f'%{search}%'),
Commission.code.ilike(f'%{search}%'),
Classroom.code.ilike(f'%{search}%'), Classroom.code.ilike(f'%{search}%'),
Reservation.purpose.ilike(f'%{search}%') Reservation.purpose.ilike(f'%{search}%'),
Reservation.shift.ilike(f'%{search}%')
) )
) )
@@ -297,6 +667,7 @@ def list_reservations():
floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.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] 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( return render_template(
'schedule/list.html', 'schedule/list.html',
@@ -307,6 +678,8 @@ def list_reservations():
hide_virtual=hide_virtual, hide_virtual=hide_virtual,
floor_filter=floor_filter, floor_filter=floor_filter,
shift_filter=shift_filter, shift_filter=shift_filter,
classroom_id=classroom_id,
all_classrooms=all_classrooms,
search=search, search=search,
available_floors=available_floors available_floors=available_floors
) )
@@ -443,7 +816,6 @@ def calendar_data():
search = request.args.get('search', '').strip() search = request.args.get('search', '').strip()
if start and end: if start and end:
from datetime import datetime
# Clean up ISO timezone strings for PostgreSQL naive timestamp comparison # Clean up ISO timezone strings for PostgreSQL naive timestamp comparison
start_clean = start.replace('Z', '+00:00') start_clean = start.replace('Z', '+00:00')
end_clean = end.replace('Z', '+00:00') end_clean = end.replace('Z', '+00:00')
@@ -472,6 +844,16 @@ def calendar_data():
if career_id and career_id.isdigit(): if career_id and career_id.isdigit():
query = query.filter(Subject.career_id == int(career_id)) 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: if shift:
query = query.filter(Reservation.shift.ilike(f'%{shift}%')) query = query.filter(Reservation.shift.ilike(f'%{shift}%'))
@@ -481,8 +863,12 @@ def calendar_data():
~Classroom.building.ilike('%Virtual%') ~Classroom.building.ilike('%Virtual%')
) )
if floor and floor.isdigit(): if floor:
query = query.filter(Classroom.floor == int(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: if search:
query = query.filter( query = query.filter(
@@ -502,8 +888,9 @@ def calendar_data():
subject_code = subject.code if subject else (reservation.commission.code if reservation.commission else '') 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') subject_name = subject.name if subject else (reservation.purpose or 'Clase')
classroom_name = reservation.classroom.code if reservation.classroom else 'Sin Aula' classroom_name = reservation.classroom.code if reservation.classroom else 'Sin Aula'
classroom_floor = reservation.classroom.floor if reservation.classroom else None classroom_floor = reservation.classroom.floor_display if reservation.classroom else None
is_virtual = bool(reservation.classroom and ('VIRTUAL' in (reservation.classroom.code or '').upper() or 'VIRTUAL' in (reservation.classroom.building or '').upper())) 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 '') 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) status_val = str(reservation.status.value if hasattr(reservation.status, 'value') else reservation.status)
@@ -518,16 +905,20 @@ def calendar_data():
'subject_code': subject_code, 'subject_code': subject_code,
'subject_name': subject_name, 'subject_name': subject_name,
'classroom': classroom_name, 'classroom': classroom_name,
'building': reservation.classroom.building if reservation.classroom else '', 'building': classroom_building,
'floor': classroom_floor, 'floor': classroom_floor,
'is_virtual': is_virtual, 'is_virtual': is_virtual,
'location_display': reservation.classroom.location_display if reservation.classroom else '',
'career': career_name, 'career': career_name,
'commission': reservation.commission.code if reservation.commission else '', 'commission': reservation.commission.code if reservation.commission else '',
'shift': reservation.shift or '', 'shift': reservation.shift or '',
'status': status_val, 'status': status_val,
'purpose': reservation.purpose or '', 'purpose': reservation.purpose or '',
'attendees': reservation.expected_attendees or 0, 'attendees': reservation.expected_attendees or 0,
'notes': reservation.notes or '' '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)
} }
}) })
+16
View File
@@ -0,0 +1,16 @@
"""Módulo de Data Transfer Objects (DTOs) y esquemas tipados con Pydantic."""
from app.schemas.classroom_dto import ClassroomCreateDTO, ClassroomUpdateDTO, ClassroomResponseDTO
from app.schemas.reservation_dto import ReservationCreateDTO, ReservationUpdateDTO, ReservationResponseDTO
from app.schemas.auth_dto import LoginDTO, TokenResponseDTO, RefreshTokenDTO
__all__ = [
'ClassroomCreateDTO',
'ClassroomUpdateDTO',
'ClassroomResponseDTO',
'ReservationCreateDTO',
'ReservationUpdateDTO',
'ReservationResponseDTO',
'LoginDTO',
'TokenResponseDTO',
'RefreshTokenDTO'
]
+16
View File
@@ -0,0 +1,16 @@
from typing import Optional, Dict, Any, List
from pydantic import BaseModel, Field, EmailStr
class LoginDTO(BaseModel):
email: str = Field(..., description="Correo electrónico institucional")
password: str = Field(..., min_length=1, description="Contraseña de acceso")
class TokenResponseDTO(BaseModel):
access_token: str
refresh_token: str
token_type: str = "Bearer"
expires_in: int = 900 # 15 minutos en segundos
user: Dict[str, Any]
class RefreshTokenDTO(BaseModel):
refresh_token: str = Field(..., description="Token de refresco válido")
+44
View File
@@ -0,0 +1,44 @@
from typing import Optional
from pydantic import BaseModel, Field, field_validator
class ClassroomBaseDTO(BaseModel):
code: str = Field(..., min_length=1, max_length=50, description="Código o nombre del aula (ej: Aula 101, VIRTUAL-A)")
floor: str = Field(default="PB", max_length=50, description="Piso o nivel (PB, Piso 1, etc.)")
capacity: int = Field(default=30, ge=1, le=10000, description="Capacidad máxima de estudiantes")
description: Optional[str] = Field(default=None, max_length=500, description="Detalles o equipamiento")
is_active: bool = Field(default=True, description="Estado operativo del aula")
@field_validator('code')
@classmethod
def clean_code(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("El código del aula no puede estar vacío.")
return v
@field_validator('floor')
@classmethod
def clean_floor(cls, v: str) -> str:
return str(v).strip() or "PB"
class ClassroomCreateDTO(ClassroomBaseDTO):
building_id: Optional[int] = Field(default=None, description="ID del edificio asignado")
building_name: Optional[str] = Field(default=None, description="Nombre de edificio (para creación dinámica)")
class ClassroomUpdateDTO(BaseModel):
building_id: Optional[int] = None
code: Optional[str] = None
floor: Optional[str] = None
capacity: Optional[int] = None
description: Optional[str] = None
is_active: Optional[bool] = None
class ClassroomResponseDTO(ClassroomBaseDTO):
id: int
building_id: Optional[int] = None
building_name: str
is_virtual: bool
location_display: str
class Config:
from_attributes = True
+49
View File
@@ -0,0 +1,49 @@
from pydantic import BaseModel, Field, field_validator
from typing import List, Optional, Dict, Any
from datetime import datetime
class OptimizerJobRequestDTO(BaseModel):
"""Contrato de solicitud para iniciar una optimización heurística."""
commission_ids: List[int] = Field(..., min_length=1, description="Lista de IDs de comisiones a programar")
start_date: Optional[datetime] = Field(None, description="Fecha de inicio (ISO format)")
end_date: Optional[datetime] = Field(None, description="Fecha de fin (ISO format)")
generations: int = Field(default=80, ge=10, le=500, description="Cantidad de generaciones genéticas")
population_size: int = Field(default=40, ge=10, le=200, description="Tamaño de la población de cromosomas")
workers: int = Field(default=4, ge=1, le=16, description="Cantidad de hilos o workers concurrentes")
@field_validator('commission_ids')
@classmethod
def validate_ids(cls, v):
if not v or len(v) == 0:
raise ValueError("Debe suministrar al menos una comisión para optimizar.")
return list(set(v))
class SpringBootCommissionDTO(BaseModel):
"""Contrato de comisión en formato Spring Boot."""
commissionId: int
subjectCode: str
subjectName: str
expectedAttendees: int = Field(default=30, ge=1)
preferredShift: Optional[str] = "NOCHE"
preferredStart: datetime
preferredEnd: datetime
class SpringBootClassroomDTO(BaseModel):
"""Contrato de aula en formato Spring Boot."""
classroomId: int
roomNumber: str
capacity: int = Field(default=30, ge=1)
building: Optional[str] = "Edificio Central"
isVirtual: bool = False
class SpringBootSyncPayloadDTO(BaseModel):
"""Carga útil de sincronización interoperable con microservicios Spring Boot."""
requestId: str = Field(..., description="Identificador único de la petición externa")
campusCode: Optional[str] = "CENTRAL"
academicTerm: Optional[str] = "2026-1C"
commissions: List[SpringBootCommissionDTO] = Field(..., min_length=1)
classrooms: Optional[List[SpringBootClassroomDTO]] = None
options: Optional[Dict[str, Any]] = None
+44
View File
@@ -0,0 +1,44 @@
from typing import Optional
from datetime import datetime
from pydantic import BaseModel, Field, model_validator
class ReservationBaseDTO(BaseModel):
classroom_id: int = Field(..., description="ID del aula asignada")
commission_id: int = Field(..., description="ID de la comisión académica")
user_id: int = Field(..., description="ID del docente responsable")
start_time: datetime = Field(..., description="Fecha y hora de inicio")
end_time: datetime = Field(..., description="Fecha y hora de finalización")
purpose: str = Field(default="Clase regular", max_length=500, description="Motivo o título de la reserva")
expected_attendees: int = Field(default=20, ge=1, description="Cantidad estimada de asistentes")
shift: Optional[str] = Field(default=None, max_length=30, description="Turno (Mañana, Tarde, Vespertino, Noche)")
virtual_link: Optional[str] = Field(default=None, max_length=500, description="Enlace a sala virtual (Meet/Zoom)")
notes: Optional[str] = Field(default=None, description="Observaciones adicionales")
status: Optional[str] = Field(default="PENDING", description="Estado de la reserva")
@model_validator(mode='after')
def validate_time_range(self):
if self.end_time <= self.start_time:
raise ValueError("La hora de finalización debe ser posterior a la hora de inicio.")
return self
class ReservationCreateDTO(ReservationBaseDTO):
allow_co_teaching_exception: Optional[bool] = Field(default=False, description="Excepción justificada si hay co-docencia")
class ReservationUpdateDTO(BaseModel):
classroom_id: Optional[int] = None
start_time: Optional[datetime] = None
end_time: Optional[datetime] = None
purpose: Optional[str] = None
expected_attendees: Optional[int] = None
shift: Optional[str] = None
virtual_link: Optional[str] = None
notes: Optional[str] = None
status: Optional[str] = None
allow_co_teaching_exception: Optional[bool] = False
class ReservationResponseDTO(ReservationBaseDTO):
id: int
created_at: Optional[datetime] = None
class Config:
from_attributes = True
+8
View File
@@ -0,0 +1,8 @@
"""Módulo de Seguridad Empresarial inspirado en Spring Security (SecurityFilterChain, RBAC y Auditoría)."""
from app.security.filter_chain import SecurityFilterChain
from app.security.decorators import require_permission
__all__ = [
'SecurityFilterChain',
'require_permission'
]
+45
View File
@@ -0,0 +1,45 @@
from functools import wraps
from flask import request, jsonify, redirect, url_for, flash, abort, g
from flask_login import current_user
def require_permission(module: str, min_level: str = 'read'):
"""
Decorador de seguridad declarativa a nivel de método inspirado en @PreAuthorize de Spring Security.
Verifica los privilegios del usuario autenticado (tanto en sesiones web como en API JWT).
Niveles de permiso estándar:
- 'read': Permiso de lectura / consulta
- 'read_write': Permiso de modificación y creación
- 'admin': Permiso de control total
"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
user = None
# Evaluar contexto JWT primero si está presente
if hasattr(g, 'jwt_user') and g.jwt_user:
user = g.jwt_user
elif current_user and current_user.is_authenticated:
user = current_user
is_api = request.path.startswith('/api/') or request.is_json
if not user:
if is_api:
return jsonify({'error': 'Unauthorized', 'message': 'Autenticación requerida.'}), 401
flash('Debe iniciar sesión para acceder a este recurso.', 'warning')
return redirect(url_for('auth.login', next=request.url))
# Comprobar privilegios en la matriz RBAC
if not user.has_permission(module, min_level):
if is_api:
return jsonify({
'error': 'Forbidden',
'message': f'Permiso insuficiente en módulo "{module}". Nivel requerido: "{min_level}".'
}), 403
flash(f'No posee permisos suficientes ({min_level}) para gestionar {module}.', 'danger')
abort(403)
return f(*args, **kwargs)
return decorated_function
return decorator
+89
View File
@@ -0,0 +1,89 @@
from flask import request, jsonify, g, render_template, Response
from datetime import datetime, timedelta, timezone
from typing import Dict, List, Tuple
import time
class SecurityFilterChain:
"""
Pipeline centralizado de interceptación de seguridad (SecurityFilterChain)
inspirado en el patrón arquitectónico de Spring Security.
Capas de filtrado activas:
1. Security Headers Filter (Blindaje HTTP en todas las respuestas).
2. Rate Limiting Filter (Protección contra fuerza bruta y DoS).
3. Bearer Token Context Filter (Inyección de identidad contextual).
"""
def __init__(self, app=None):
# Registro de timestamps de peticiones por IP: ip -> [timestamps]
self._request_history: Dict[str, List[float]] = {}
# Límites por endpoint sensible (max_requests, window_seconds)
self._rate_limits = {
'/login': (15, 60),
'/api/v1/auth/login': (15, 60),
'/api/v1/auth/refresh': (30, 60)
}
if app:
self.init_app(app)
def init_app(self, app):
"""Registra los filtros en el ciclo de vida de la aplicación Flask."""
app.before_request(self._execute_pre_filters)
app.after_request(self._execute_post_filters)
def _get_client_ip(self) -> str:
"""Obtiene la IP real del cliente respetando proxies inversos de confianza."""
if request.headers.get('X-Forwarded-For'):
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
return request.remote_addr or '127.0.0.1'
def _execute_pre_filters(self):
"""Ejecuta los filtros previos al enrutamiento del controlador."""
path = request.path
# 1. Rate Limiting Filter (solo para peticiones POST de autenticación sensible)
if path in self._rate_limits:
# Peticiones GET para cargar la interfaz web NO consumen intentos de fuerza bruta
if request.method != 'POST':
return
max_req, window = self._rate_limits[path]
client_ip = self._get_client_ip()
now = time.time()
# Limpiar historial fuera de la ventana
timestamps = self._request_history.get(client_ip, [])
timestamps = [t for t in timestamps if now - t < window]
if len(timestamps) >= max_req:
retry_after = max(1, int(window - (now - timestamps[0])))
if path.startswith('/api/'):
return jsonify({
'error': 'TooManyRequests',
'message': f'Límite de peticiones excedido. Reintente en {retry_after} segundos.'
}), 429
# Para la web, renderizar vista o respuesta legible
return Response(
f"Demasiados intentos de autenticación desde su dirección IP. Por favor espere {retry_after} segundos antes de volver a intentar.",
status=429,
mimetype='text/plain; charset=utf-8'
)
timestamps.append(now)
self._request_history[client_ip] = timestamps
def _execute_post_filters(self, response):
"""Inyecta cabeceras de blindaje HTTP (OWASP Secure Headers)."""
# Evitar ataques de MIME-Sniffing
response.headers['X-Content-Type-Options'] = 'nosniff'
# Mitigación de Clickjacking
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
# Mitigación XSS en navegadores legacy
response.headers['X-XSS-Protection'] = '1; mode=block'
# Política de Referrer segura
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
# Inyección HSTS (si es HTTPS o entorno de producción)
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
return response
+19
View File
@@ -0,0 +1,19 @@
from .sheets_importer import GoogleSheetsImporter
from .classroom_service import ClassroomService
from .reservation_service import ReservationService
from .user_service import UserService
from .jwt_service import JWTService
from .audit_service import AuditService
from .optimizer_service import OptimizerService, OptimizerJob, OptimizerJobStatus
__all__ = [
'GoogleSheetsImporter',
'ClassroomService',
'ReservationService',
'UserService',
'JWTService',
'AuditService',
'OptimizerService',
'OptimizerJob',
'OptimizerJobStatus'
]
+76
View File
@@ -0,0 +1,76 @@
from typing import Optional, List, Dict, Any
from flask import request, g, has_request_context
from flask_login import current_user
import json
from app import db
from app.models.audit_log import AuditLog
from app.models.user import User
class AuditService:
"""Servicio para la captura y consulta de la bitácora inmutable de auditoría."""
@staticmethod
def log(action: str, module: str, entity_id: Optional[int] = None,
details: Optional[Any] = None, user: Optional[User] = None,
ip_address: Optional[str] = None) -> AuditLog:
"""
Registra un evento de auditoría de forma segura, infiriendo el usuario e IP
desde el contexto actual de la petición si no se especifican.
"""
user_id = None
user_email = None
# 1. Determinar usuario actuante
if user:
user_id = user.id
user_email = user.email
elif has_request_context():
if hasattr(g, 'jwt_user') and g.jwt_user:
user_id = g.jwt_user.id
user_email = g.jwt_user.email
elif current_user and current_user.is_authenticated:
user_id = current_user.id
user_email = current_user.email
# 2. Determinar IP de origen
if not ip_address and has_request_context():
if request.headers.get('X-Forwarded-For'):
ip_address = request.headers.get('X-Forwarded-For').split(',')[0].strip()
else:
ip_address = request.remote_addr
# 3. Serializar detalles si es estructura de datos
details_str = None
if details is not None:
if isinstance(details, (dict, list)):
try:
details_str = json.dumps(details, ensure_ascii=False)
except Exception:
details_str = str(details)
else:
details_str = str(details)
log_entry = AuditLog(
user_id=user_id,
user_email=user_email,
action=action.upper(),
module=module.lower(),
entity_id=entity_id,
details=details_str,
ip_address=ip_address
)
try:
db.session.add(log_entry)
db.session.commit()
except Exception:
db.session.rollback()
return log_entry
@staticmethod
def get_recent_logs(limit: int = 50, module: Optional[str] = None) -> List[AuditLog]:
"""Obtiene las entradas más recientes de auditoría."""
query = AuditLog.query
if module:
query = query.filter_by(module=module.lower())
return query.order_by(AuditLog.created_at.desc()).limit(limit).all()
+118
View File
@@ -0,0 +1,118 @@
from typing import Optional, List, Dict, Any
import re
from sqlalchemy import func
from app.models.classroom import Classroom
from app.models.building import Building
from app.repositories.classroom_repository import ClassroomRepository
from app.schemas.classroom_dto import ClassroomCreateDTO, ClassroomUpdateDTO
from app import db
class ClassroomService:
"""Capa de servicios para la lógica de negocio y validación de aulas."""
def __init__(self, repository: Optional[ClassroomRepository] = None):
self.repository = repository or ClassroomRepository()
def get_classroom(self, classroom_id: int) -> Optional[Classroom]:
"""Obtiene un aula por su identificador."""
return self.repository.get_by_id(classroom_id)
def list_classrooms(self, building_id: Optional[int] = None, floor: Optional[str] = None,
only_virtual: bool = False, only_physical: bool = False) -> List[Classroom]:
"""Lista aulas filtradas según criterios de ubicación o modalidad."""
return self.repository.get_active_classrooms(
building_id=building_id,
floor=floor,
only_virtual=only_virtual,
only_physical=only_physical
)
def create_classroom(self, dto: ClassroomCreateDTO) -> Classroom:
"""
Crea una nueva aula aplicando reglas de negocio:
- Resolución o creación dinámica del edificio.
- Unicidad de código por piso y edificio.
- Capacidad adaptativa para aulas virtuales.
"""
building = None
if dto.building_id:
building = Building.query.get(dto.building_id)
if not building:
raise ValueError(f"El edificio con ID {dto.building_id} no existe.")
elif dto.building_name:
b_name = dto.building_name.strip()
building = Building.query.filter(func.lower(Building.name) == func.lower(b_name)).first()
if not building:
code_slug = re.sub(r'[^a-zA-Z0-9]+', '_', b_name.upper()).strip('_')
building = Building(name=b_name, code=code_slug, is_active=True)
db.session.add(building)
db.session.flush()
else:
raise ValueError("Debe especificar un edificio válido o el nombre de un nuevo edificio.")
# Verificar unicidad
existing = self.repository.get_by_code_and_building(building.id, dto.code, dto.floor)
if existing:
raise ValueError(f"Ya existe un aula con el código '{dto.code}' en el piso {dto.floor} de {building.name}.")
capacity = dto.capacity
is_virtual = ('VIRTUAL' in dto.code.upper()) or ('VIRTUAL' in building.name.upper())
if is_virtual and capacity < 999:
capacity = 9999
classroom = Classroom(
building_id=building.id,
building=building.name,
code=dto.code,
floor=dto.floor,
capacity=capacity,
description=dto.description,
is_active=dto.is_active
)
return self.repository.save(classroom)
def update_classroom(self, classroom_id: int, dto: ClassroomUpdateDTO) -> Classroom:
"""Actualiza atributos de un aula existente."""
classroom = self.repository.get_by_id(classroom_id)
if not classroom:
raise ValueError(f"Aula con ID {classroom_id} no encontrada.")
building_id = dto.building_id if dto.building_id is not None else classroom.building_id
code = dto.code.strip() if dto.code is not None else classroom.code
floor = dto.floor.strip() if dto.floor is not None else classroom.floor
# Si cambia código, piso o edificio, validar colisión
if (building_id != classroom.building_id or code != classroom.code or floor != classroom.floor):
existing = self.repository.get_by_code_and_building(building_id, code, floor)
if existing and existing.id != classroom.id:
raise ValueError(f"Ya existe otra aula con el código '{code}' en el piso {floor}.")
if dto.building_id is not None:
building = Building.query.get(dto.building_id)
if building:
classroom.building_id = building.id
classroom.building = building.name
if dto.code is not None:
classroom.code = code
if dto.floor is not None:
classroom.floor = floor
if dto.capacity is not None:
classroom.capacity = dto.capacity
if dto.description is not None:
classroom.description = dto.description
if dto.is_active is not None:
classroom.is_active = dto.is_active
return self.repository.save(classroom)
def delete_classroom(self, classroom_id: int) -> bool:
"""Elimina o desactiva un aula verificando dependencias."""
classroom = self.repository.get_by_id(classroom_id)
if not classroom:
return False
# Si tiene reservas históricas, se desactiva lógicamente para preservar integridad
if classroom.reservations:
classroom.is_active = False
self.repository.save(classroom)
return True
return self.repository.delete(classroom)

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