Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05e197e49f | ||
|
|
e93d784473 | ||
|
|
620e470614 | ||
|
|
b9a11d810a | ||
|
|
5087e22988 | ||
|
|
42b31ec44e | ||
|
|
500aeeb9df | ||
|
|
1080acd43a | ||
|
|
47b50ede7e | ||
|
|
865538b727 | ||
|
|
1fda344248 | ||
|
|
69d545b718 | ||
|
|
dd93b8c07a | ||
|
|
7d342bf8cc | ||
|
|
69b8b7ccef | ||
|
|
fa878cb0d5 | ||
|
|
566908f1de | ||
|
|
c748ae4075 | ||
|
|
6218657f36 | ||
|
|
747d20ed38 | ||
|
|
b64a771068 | ||
|
|
340bf3aa8c | ||
|
|
80f6018b6a | ||
|
|
4e49311208 | ||
|
|
b367b106bf | ||
|
|
0cea3dca44 | ||
|
|
eea7adb1d0 | ||
|
|
e0c58409cd | ||
|
|
9b6678724c | ||
|
|
f462d57ad9 | ||
|
|
7ef2ba8a5b | ||
|
|
169aa45746 | ||
|
|
f31e2bf760 | ||
|
|
63c9c6f935 | ||
|
|
5e91e131f0 | ||
|
|
c7ce5e4e67 | ||
|
|
957f364cc3 | ||
|
|
7cd70be274 | ||
|
|
5dcf3b75f1 | ||
|
|
7933523d7c | ||
|
|
df098e5116 | ||
|
|
bbe8e220ae | ||
|
|
adb6ba729d | ||
|
|
c9dc3d9208 | ||
|
|
cde62c9b0a | ||
|
|
5f0476be79 | ||
|
|
6ccc9e8c87 | ||
|
|
5e0b905e55 | ||
|
|
f3a4b5875e | ||
|
|
8809ff3410 | ||
|
|
291e537574 | ||
|
|
0c867e0031 | ||
|
|
9521b86399 | ||
|
|
5cc33cc7a0 | ||
|
|
d1d4be0428 |
@@ -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
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# ==============================================================================
|
||||||
|
# Edu-Space Admin - Plantilla de Variables de Entorno
|
||||||
|
# Copie este archivo como .env y complete con valores de producción seguros
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# Entorno Flask
|
||||||
|
FLASK_APP=wsgi.py
|
||||||
|
FLASK_ENV=production
|
||||||
|
|
||||||
|
# Seguridad: Clave secreta para sesiones y protección CSRF
|
||||||
|
# Generar una clave aleatoria con: python -c 'import secrets; print(secrets.token_hex(32))'
|
||||||
|
SECRET_KEY=change-this-to-a-secure-random-secret-key-in-production
|
||||||
|
|
||||||
|
# Base de Datos PostgreSQL
|
||||||
|
DATABASE_URL=postgresql://postgres:password@localhost:5432/classrooms_db
|
||||||
|
DB_USERNAME=postgres
|
||||||
|
DB_PASSWORD=password
|
||||||
|
|
||||||
|
# Integración con API externa (Microservicio Spring Boot)
|
||||||
|
API_BASE_URL=http://localhost:8080/api
|
||||||
|
|
||||||
|
# Configuración de Internacionalización (Babel)
|
||||||
|
BABEL_DEFAULT_LOCALE=es
|
||||||
|
BABEL_DEFAULT_TIMEZONE=America/Argentina/Buenos_Aires
|
||||||
@@ -57,6 +57,9 @@ cover/
|
|||||||
*.mo
|
*.mo
|
||||||
*.pot
|
*.pot
|
||||||
|
|
||||||
|
# Examples
|
||||||
|
example/
|
||||||
|
|
||||||
# Django stuff:
|
# Django stuff:
|
||||||
*.log
|
*.log
|
||||||
local_settings.py
|
local_settings.py
|
||||||
@@ -207,3 +210,14 @@ cython_debug/
|
|||||||
marimo/_static/
|
marimo/_static/
|
||||||
marimo/_lsp/
|
marimo/_lsp/
|
||||||
__marimo__/
|
__marimo__/
|
||||||
|
|
||||||
|
# Node.js / Frontend
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
frontend/.env
|
||||||
|
|
||||||
|
legacy_admin-edu-space/
|
||||||
|
.vscode/
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"python.defaultInterpreterPath": "${workspaceFolder}\\venv\\Scripts\\python.exe",
|
||||||
|
"python.analysis.extraPaths": [
|
||||||
|
"${workspaceFolder}\\venv\\Lib\\site-packages"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# Changelog Oficial — Admin Edu-Space (UniCABA)
|
||||||
|
|
||||||
|
Todas las modificaciones notables a este proyecto serán documentadas en este archivo.
|
||||||
|
El formato está basado en [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/) y este proyecto se adhiere a [Semantic Versioning](https://semver.org/lang/es/).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
### Planificado (Fase 2 del Roadmap MVP)
|
||||||
|
- **Co-docencia en Cátedras:** Asignación de múltiples profesores (titular, adjunto, ayudante) a una misma comisión.
|
||||||
|
- **Matriz de Conflictos:** Detección de solapamiento físico, docente y control de aforo en tiempo real.
|
||||||
|
- **Regla de Restricción Diaria del Alumno:** Validación para impedir cursar dos materias regulares el mismo día, con motor de excepciones para cursos cortos o autorización de Bedelía.
|
||||||
|
- **Libro de Calificaciones (*Gradebook*):** Carga rápida de notas y seguimiento de regularidad.
|
||||||
|
- **Grilla Semanal Drag & Drop:** Asignación visual interactiva para la oficina de Bedelía.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.3.0] - 2026-09-19
|
||||||
|
### Añadido
|
||||||
|
- **Monitoreo y Percentiles de Asignación de Aulas Físicas (Admin & Bedelía):**
|
||||||
|
- Panel analítico *"Estado y Percentiles de Asignación de Aulas Físicas"* incorporado en los dashboards de Administrador (`/dashboard?role=admin`) y Bedelía (`/dashboard?role=bedelia`).
|
||||||
|
- Cálculo dinámico en tiempo real de **Aulas Asignadas** en número y percentil (**26 aulas · 96.3%**) con enlace directo a cursadas activas.
|
||||||
|
- Cálculo dinámico en tiempo real de **Aulas Sin Asignar / Libres** en número y percentil (**1 aula · 3.7%**) con enlace directo al formulario de asignación de espacios.
|
||||||
|
- Barras de progreso visuales con desglose dual: **Planificación Cuatrimestral** (96.3% asignado vs 3.7% disponible) y **Ocupación Operativa del Día** (aulas con clases hoy vs aulas libres).
|
||||||
|
- Exposición de métricas de asignación y percentiles en el endpoint de la API REST `GET /api/v1/dashboard/stats`.
|
||||||
|
- **Módulo de Cursadas Asignadas y Métricas Reales en Catálogo Presencial (`/classrooms/list_classrooms?view_mode=physical`):**
|
||||||
|
- Incorporación de la sección *"Cursadas Asignadas"* dentro de cada tarjeta de aula física y columna en vista de tabla, detallando asignaturas, comisiones y turnos.
|
||||||
|
- Métricas de ocupación física real por período (Día, Semana, Mes) con porcentajes calculados dinámicamente (6%, 11%, 17%, 22% según la carga horaria del aula).
|
||||||
|
- Botones de acción directa con ID dinámico: **Ficha** (`/classrooms/view_classroom?id=...`), **Horarios en Calendario** (`/schedule/calendar_view?classroom_id=...`) y **Editar**.
|
||||||
|
- Conexión de la tabla *"Próximas Clases y Reservas"* en la ficha individual del aula (`/classrooms/view_classroom`) con las reservas reales del cuatrimestre y enlaces a cada reserva (`/schedule/view_reservation?id=...`).
|
||||||
|
|
||||||
|
### Modificado
|
||||||
|
- **Dashboard Docente (`/dashboard?role=docente`):**
|
||||||
|
- Eliminado el botón flotante *"Reservar Aula / Solicitar espacio para clase"* (`/schedule/add_reservation`) a petición de los requerimientos funcionales del cuerpo docente.
|
||||||
|
- Reorganizada la grilla superior a 3 columnas simétricas (`col-md-4`) para las tarjetas de *Mis Clases Hoy*, *Mis Reservas Activas* y *Aulas Virtuales*.
|
||||||
|
|
||||||
|
### Corregido
|
||||||
|
- **Fallo Crítico de Renderizado en Nunjucks (`memberLookup` con `.items`):**
|
||||||
|
- Solucionada la incompatibilidad de `nunjucks/src/jinja-compat` que interceptaba la propiedad `.items` convirtiendo `classrooms.items` en una función interna en lugar de un array, lo que impedía dibujar las tarjetas de aulas presenciales en `frontend/views/classrooms/list.html`.
|
||||||
|
- Implementado interceptor en `memberLookup` en `frontend/src/app.js` para preservar arrays intactos.
|
||||||
|
- **Clasificación Errónea de Reservas Físicas como Virtuales (`is_virtual`):**
|
||||||
|
- Corregida la propiedad `@property is_virtual` en `Reservation` (`backend/app/models/reservation.py`) para garantizar que si una reserva tiene aula física asignada (`self.classroom and not self.classroom.is_virtual`), siempre sea clasificada como presencial (`is_virtual = False`), evitando falsos positivos cuando la comisión posee un enlace virtual predeterminado.
|
||||||
|
- **Excepción de SQLAlchemy en Estadísticas de Dashboard:**
|
||||||
|
- Corregido el error de ejecución generado al invocar `.ilike()` sobre la propiedad Python `@property Classroom.room_number` en `backend/app/routes/api/dashboard.py`, migrando la clasificación de aulas presenciales y virtuales a una evaluación robusta en memoria Python (`Classroom.code` y `Classroom.building`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.2.0] - 2026-09-19
|
||||||
|
### Añadido
|
||||||
|
- **Herramienta de Purga y Reinicio de Datos Académicos (`reset_academic_data`):**
|
||||||
|
- Script CLI `backend/scripts/reset_academic_data.py` con argumentos de limpieza granular (`--all`, `--reservations`, `--classrooms`, `--careers`, `--yes`).
|
||||||
|
- Endpoint de API `POST /api/v1/admin/reset-academic-data` protegido por JWT (rol `admin`) con registro en `AuditLog`.
|
||||||
|
- Panel interactivo "Zona de Reinicio y Purga Académica" en `/admin/google_sheets_import` con checkboxes independientes para reservas, aulas, comisiones, asignaturas y carreras.
|
||||||
|
- Modal modal de confirmación con advertencias de seguridad para prevenir purgas accidentales.
|
||||||
|
- Preservación íntegra de usuarios del sistema, credenciales de acceso, roles RBAC y registros de auditoría.
|
||||||
|
- **Configuración Global Dinámica de Google Sheets:**
|
||||||
|
- Nuevo modelo `SystemSetting` (`backend/app/models/setting.py`) para almacenamiento persistente clave-valor en base de datos.
|
||||||
|
- Endpoints `GET /api/v1/admin/sheets-config` y `POST /api/v1/admin/sheets-config`.
|
||||||
|
- Normalizador de enlaces en `GoogleSheetsImporter` compatible con URLs compartidas de edición (`/spreadsheets/d/.../edit`) o enlaces publicados (`/pub?output=csv`).
|
||||||
|
- Interfaz web con formulario de edición de URL, botón de guardado en caliente, restauración a URL oficial predeterminada y acceso directo a Google Docs.
|
||||||
|
- **Visualización de Asignaturas Reales:**
|
||||||
|
- Tabla de asignaturas recientemente sincronizadas conectada a la base de datos real en lugar de datos de muestra (*mock*).
|
||||||
|
|
||||||
|
### Corregido
|
||||||
|
- **Fallo por Timeout en Google Sheets Academic Sync:**
|
||||||
|
- Solucionado el corte prematuro de conexión en el BFF (`frontend/src/services/apiClient.js`), aumentando el timeout base a 25s y el timeout específico de sincronización a 90s (anteriormente limitado a 5s en operaciones que toman ~6.7s).
|
||||||
|
- Eliminado el enmascaramiento silencioso de errores en el BFF, permitiendo reportar fallos reales o éxitos detallados (+carreras, +aulas, +materias, +comisiones, +reservas).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.1.0] - 2026-09-19
|
||||||
|
### Añadido
|
||||||
|
- **Ficha Detallada de Reserva (`schedule.view_reservation`):**
|
||||||
|
- Implementación en BFF Node.js de la ruta `/schedule/view_reservation` (soportando parámetros `:id`, `?id=` o selección automática del primer registro).
|
||||||
|
- Vistas y controladores para modificar reservas con selectores en cascada (`/schedule/edit_reservation`).
|
||||||
|
- Endpoints de acción rápida en backend y BFF: `POST /schedule/confirm_reservation` y `POST /schedule/cancel_reservation`.
|
||||||
|
- Actualización de enlaces en `today.html`, `list.html`, `dashboard.html` y `view.html` para navegar directamente con el identificador de la reserva (`?id=...`).
|
||||||
|
- **Creación y Asociación de Comisiones a Asignaturas (`admin/commissions_list`):**
|
||||||
|
- Botón destacado `+ Nueva Comisión` en el encabezado y en el estado vacío de la tabla.
|
||||||
|
- Modal interactivo `#modalAddCommission` con selección obligatoria y clara de la **Asignatura / Materia Asociada** (`<select name="subject_id" required>`), mostrando código oficial, nombre y carrera.
|
||||||
|
- Campos completos de cursada: cuatrimestre (1C/2C/Anual), año lectivo, turno, docente a cargo, cupo máximo y días/horarios.
|
||||||
|
- Generación y configuración automática de enlace a Google Meet institucional cuando no se ingresa un enlace personalizado.
|
||||||
|
- Alerta de confirmación visual (`?created=1`) al registrar y vincular una comisión exitosamente.
|
||||||
|
- Tabla de comisiones actualizada con visualización explícita del código de materia, nombre y badge de carrera.
|
||||||
|
- **Enriquecimiento de la API REST:**
|
||||||
|
- Endpoint `GET /api/v1/reservations/<id>` ampliado con objetos relacionales anidados: `classroom` (código, edificio, piso, capacidad, descripción, virtualidad), `commission` (código, código compuesto, materia), `user` (docente, nombre, apellido, correo), duración en minutos y enlaces virtuales.
|
||||||
|
- Endpoint `POST /api/v1/admin/commissions` con validación estricta de existencia de asignatura y resolución inteligente de unicidad.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.0.0] - 2026-09-18
|
||||||
|
### Añadido
|
||||||
|
- **Reingeniería de Arquitectura Fullstack (Desacople Monolito -> Backend + BFF):**
|
||||||
|
- Desacople completo del backend Python/Flask como una **API REST pura** bajo `/api/v1/` retornando exclusivamente JSON.
|
||||||
|
- Implementación del **Frontend BFF** en Node.js + Express con renderizado de servidor mediante Nunjucks.
|
||||||
|
- Autenticación sin estado (*Stateless*) basada en tokens JWT firmados con algoritmo HS256.
|
||||||
|
- Almacenamiento seguro del token de acceso en cookies del navegador con directivas `HttpOnly`, `SameSite=Lax` y `Secure`.
|
||||||
|
- Inyección transparente del token `Bearer` en peticiones internas mediante cliente Axios centralizado.
|
||||||
|
- **Dashboards Personalizados por Rol Institucional:**
|
||||||
|
- **Dashboard de Bedelía (`/dashboard?role=bedelia`):** Métricas operativas de campus físico y virtual, cartelera del día y panel de aprobación de solicitudes pendientes.
|
||||||
|
- **Dashboard de Docente (`/dashboard?role=docente`):** Sesiones del día con botones directos para ingresar a reuniones virtuales (Meet/Teams/Zoom) y formulario de solicitud de aulas.
|
||||||
|
- **Dashboard de Alumno (`/dashboard?role=alumno`):** Brújula de cursada *"¿Dónde curso hoy?"* con ubicación por piso/aula o enlace virtual, junto al calendario de exámenes y entregas.
|
||||||
|
- **Dashboard de Administrador (`/dashboard?role=admin`):** Cockpit institucional de capacidad, usuarios, auditoría RBAC y distribución de comisiones.
|
||||||
|
- Selector dinámico de vista previa de roles en el banner superior para administradores.
|
||||||
|
- **Cartelera del Día Interactiva (`/schedule/today_schedule`):**
|
||||||
|
- Grilla estructurada en bloques horarios (07:00 a 21:00 hs) con métricas en tiempo real de ocupación y reservas confirmadas/pendientes.
|
||||||
|
- Filtro dinámico para ocultar clases virtuales (`hide_virtual=1`) y segmentación por pisos o turnos.
|
||||||
|
- **Seguridad Enterprise (SecurityFilterChain):**
|
||||||
|
- Pipeline centralizado de cabeceras de blindaje OWASP (`nosniff`, `SAMEORIGIN`, `X-XSS-Protection`, `Strict-Transport-Security`).
|
||||||
|
- Rate limiting dinámico contra ataques de fuerza bruta en endpoints de autenticación.
|
||||||
|
|
||||||
|
### Corregido
|
||||||
|
- **Resolución de Colisión de Horarios en Clases Virtuales (Importador de Google Sheets):**
|
||||||
|
- Corrección de la búsqueda de duplicados en `sheets_importer.py` que limitaba indebidamente la unicidad a `(classroom_id, start_time, end_time)`.
|
||||||
|
- Habilitación de concurrencia ilimitada para el aula virtual (`Campus Virtual · VIRTUAL`) mediante discriminación por `commission_id`.
|
||||||
|
- El total de reservas registradas aumentó de **363 a 643** (incorporando **280 clases virtuales** que eran descartadas por falso conflicto).
|
||||||
|
- Generación y guardado automático de enlaces institucionales de Google Meet (`https://meet.google.com/edu-{materia}-{comision}`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-09-04
|
||||||
|
### Añadido
|
||||||
|
- **Versión Monolítica Base (`legacy_admin-edu-space`):**
|
||||||
|
- Aplicación monolítica en Flask con plantillas Jinja2 y persistencia SQLAlchemy (SQLite / PostgreSQL).
|
||||||
|
- Modelado de datos inicial para sedes, aulas físicas, materias, comisiones, usuarios y reservas.
|
||||||
|
- Sistema de roles inicial (Admin, Bedelía, Docente, Alumno) basado en sesión tradicional de Flask-Login.
|
||||||
|
- Calendario de reservas mensual y semanal con FullCalendar.
|
||||||
|
- Prototipo experimental de algoritmo genético para optimización de ocupación áulica.
|
||||||
|
- Módulo de sincronización inicial con planillas de Google Sheets.
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
# Estado Actual de la Migración y Arquitectura Objetivo
|
||||||
|
## Sistema Integral de Gestión de Aulas, Cursadas y Seguimiento Académico — UniCABA
|
||||||
|
|
||||||
|
**Rol:** Arquitecto de Software y Analista Funcional Senior
|
||||||
|
**Institución:** Universidad de la Ciudad de Buenos Aires (UniCABA)
|
||||||
|
**Fecha de Actualización:** 19 de Septiembre de 2026
|
||||||
|
**Rama Activa:** `testing`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. De Dónde Venimos: La Versión Monolítica (`legacy_admin-edu-space`)
|
||||||
|
|
||||||
|
La versión original del sistema correspondía a un monolito clásico desarrollado en Python (Flask) con renderizado del lado del servidor mediante plantillas Jinja2 y ORM SQLAlchemy:
|
||||||
|
|
||||||
|
```
|
||||||
|
[Cliente Web / Navegador]
|
||||||
|
│
|
||||||
|
▼ (HTTP / HTML)
|
||||||
|
┌────────────────────────────────────────────────────────┐
|
||||||
|
│ Monolito Legacy (legacy_admin-edu-space) │
|
||||||
|
│ - Rutas Flask con render_template (Jinja2) │
|
||||||
|
│ - Sesión en Cookies de Flask (Flask-Login con estado) │
|
||||||
|
│ - Consultas directas SQLAlchemy acopladas a vistas │
|
||||||
|
│ - Base de Datos (SQLite / PostgreSQL) │
|
||||||
|
└────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fortalezas Heredadas del Monolito
|
||||||
|
1. **Modelado de Dominio Inicial:** Entidades base para Sedes (`Building`), Aulas (`Classroom`), Materias (`Subject`), Comisiones (`Commission`), Reservas (`Reservation`) y Usuarios (`User`).
|
||||||
|
2. **Importador de Planillas Oficiales:** Módulo de sincronización con Google Sheets para alimentar la cartelera con la oferta de comisiones y clases.
|
||||||
|
3. **Algoritmo Genético Experimental:** Prototipo de optimización predictiva para asignación de aulas físicas según aforo y equipamiento.
|
||||||
|
|
||||||
|
### Limitaciones Críticas que Forzaron la Migración
|
||||||
|
* **Acoplamiento Servidor-Vista:** Lógica de negocio mezclada dentro de controladores web y macros de plantillas Jinja2, impidiendo el consumo móvil (app institucional) o integraciones externas (SIU Guaraní, Moodle).
|
||||||
|
* **Fallas Graves en Virtualidad:** El importador de Google Sheets descartaba más del 75% de las clases virtuales por asumir que el aula `Campus Virtual · VIRTUAL` sufría colisiones físicas de horario ante comisiones simultáneas.
|
||||||
|
* **Falta de Perspectiva por Rol:** El dashboard era único y uniforme; no distinguía entre el trabajo administrativo de Bedelía, la consulta rápida de cursada del Alumno, o la gestión evaluativa del Docente.
|
||||||
|
* **Escalabilidad y Seguridad:** Autenticación dependiente de la sesión en memoria del servidor web, sin soporte de tokens JWT sin estado ni separación de responsabilidades (BFF pattern).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Dónde Estamos Actualmente: Arquitectura Desacoplada Backend-BFF
|
||||||
|
|
||||||
|
La infraestructura ha sido transformada en un modelo desacoplado y de alta disponibilidad:
|
||||||
|
|
||||||
|
```
|
||||||
|
[ Navegador Web / Cliente ]
|
||||||
|
│
|
||||||
|
│ (HTTP / HTML + Cookies HttpOnly)
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────┐
|
||||||
|
│ Frontend BFF (Node.js + Express) │
|
||||||
|
│ Puerto: 3000 │
|
||||||
|
│ - Renderizado Nunjucks con herencia limpia │
|
||||||
|
│ - Orquestación y agregación de UI │
|
||||||
|
│ - Gestión de Cookie JWT y Contexto de Rol │
|
||||||
|
│ - Cliente Axios con Interceptor Bearer │
|
||||||
|
└──────────────────────┬───────────────────────┘
|
||||||
|
│
|
||||||
|
│ (REST JSON / JWT Bearer)
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────┐
|
||||||
|
│ Backend Core API (Python + Flask) │
|
||||||
|
│ Puerto: 5000 │
|
||||||
|
│ - Endpoints RESTful bajo /api/v1/ │
|
||||||
|
│ - Autenticación Stateless JWT (HS256) │
|
||||||
|
│ - Pipeline Enterprise SecurityFilterChain │
|
||||||
|
│ - Capa de Servicios y DTOs (Pydantic) │
|
||||||
|
│ - Persistencia SQLAlchemy (Transaccional) │
|
||||||
|
└──────────────────────┬───────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[ Base de Datos SQLite / PG ]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Estado de Implementación al Día de la Fecha
|
||||||
|
|
||||||
|
| Componente / Módulo | Estado en Legacy | Estado Actual (Backend API + BFF) | Nivel de Paridad |
|
||||||
|
| :--- | :--- | :--- | :---: |
|
||||||
|
| **Autenticación & Sesión** | Flask-Login basado en sesión de servidor | Autenticación REST JWT con cookie HttpOnly segura y RBAC centralizado | **120% (Superado)** |
|
||||||
|
| **Dashboards por Rol** | Dashboard monolítico único para todos | 4 Dashboards especializados (`admin`, `bedelia`, `docente`, `alumno`) con selector dinámico | **150% (Superado)** |
|
||||||
|
| **Campus Virtual & Enlaces** | Colisión de clases virtuales; pérdida de enlaces Meet | Aulas virtuales concurrentes sin límite, auto-generación de enlaces Google Meet por comisión | **140% (Superado)** |
|
||||||
|
| **Importador Google Sheets** | 363 reservas importadas (colisiones masivas) | 643 reservas totales (388 virtuales) sin colisión horaria entre comisiones | **100% (Paridad Total)** |
|
||||||
|
| **Cartelera del Día (`today_schedule`)** | Vista Jinja con bloques rígidos | Timeline interactivo por horas (7 a 21 hs), filtros por piso/turno y switch `hide_virtual` | **110% (Superado)** |
|
||||||
|
| **Ficha de Reserva (`view_reservation`)** | Vista monolítica rígida | Acoplada al BFF con relaciones anidadas completas, edición en cascada, confirmación y cancelación | **100% (Paridad Total)** |
|
||||||
|
| **Gestión de Comisiones (`commissions_list`)** | Lista plana sin modal de creación en admin | Modal `#modalAddCommission` con vinculación obligatoria a Asignatura, docente, cupo, horarios y Meet | **125% (Superado)** |
|
||||||
|
| **Calendario FullCalendar** | Feed JSON básico | Endpoint `/api/v1/reservations` con discriminación cromática (`#6366F1` virtuales) | **100% (Paridad Total)** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. A Dónde Queremos Ir: El Sistema Integral UniCABA
|
||||||
|
|
||||||
|
El objetivo institucional es consolidar la plataforma como el **ERP Académico y Gestor de Espacios Oficial de UniCABA**, dando soporte a las dinámicas pedagógicas físicas y digitales de la universidad.
|
||||||
|
|
||||||
|
### A. Entorno Físico vs. Campus Virtual
|
||||||
|
1. **Edificio Físico (Central):**
|
||||||
|
- Espacios con aforo estrictamente limitado, asignación por piso/ala y equipamiento técnico (proyectores, laboratorios informáticos, acústica).
|
||||||
|
- Validación matemática de no-solapamiento temporal para evitar doble asignación de aula física.
|
||||||
|
2. **Campus Virtual:**
|
||||||
|
- Capacidad física infinita; asignación lógica de salas vinculadas a la plataforma oficial (Google Meet institucional, Zoom o Microsoft Teams).
|
||||||
|
- Control de concurrencia temporal de docentes y estudiantes (evitar que un docente o alumno deba estar en dos salas remotas al mismo tiempo).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Reglas de Negocio del Sistema Objetivo
|
||||||
|
|
||||||
|
### 1. Perfiles y Permisos (RBAC Avanzado)
|
||||||
|
* **Administrador General (Superadmin):**
|
||||||
|
* Gobernanza total del sistema, configuración de periodos y parametrización de reglas de conflicto.
|
||||||
|
* **Modo Impersonación / Vista Previa:** Visualización en tiempo real del sistema desde la perspectiva exacta de cualquier usuario (Bedelía, Docente, Alumno) para soporte y auditoría.
|
||||||
|
* Flexibilidad académica para autorizar excepciones de cursada simultánea y apertura de comisiones extraordinarias.
|
||||||
|
* **Bedelía / Gestión Operativa:**
|
||||||
|
* Administración integral del inventario físico (Sedes, Edificios, Pisos, Aulas) y virtual.
|
||||||
|
* Creación y parametrización de Ciclos Lectivos, Asignaturas y Comisiones.
|
||||||
|
* **Asignación Docente Co-docente:** Posibilidad de asignar más de un profesor a una misma comisión (equipo de cátedra: titulares, adjuntos, ayudantes).
|
||||||
|
* Gestión de cupos máximos, padrón de alumnos matriculados y resolución de conflictos de asignación.
|
||||||
|
* Supervisión de hitos evaluativos institucionales.
|
||||||
|
* **Docente:**
|
||||||
|
* Vista segmentada estrictamente a sus comisiones asignadas.
|
||||||
|
* Calendario de clases presenciales y accesos directos a sesiones de videoconferencia.
|
||||||
|
* Gestión del calendario de evaluación: carga de fechas de parciales, recuperatorios, entregas de trabajos prácticos y finales.
|
||||||
|
* Matriz ágil de carga y edición de calificaciones y estados de regularidad.
|
||||||
|
* **Alumno:**
|
||||||
|
* Acceso enfocado a sus materias cursadas en el periodo lectivo activo.
|
||||||
|
* Brújula de cursada diaria (*¿Dónde curso hoy?* con sede, piso y aula física o enlace virtual).
|
||||||
|
* Calendario unificado de entregas de TPs, parciales y finales con avisos preventivos.
|
||||||
|
* Consulta histórica de notas y retroalimentaciones pedagógicas.
|
||||||
|
|
||||||
|
### 2. Estructura Académica y Ciclo Lectivo
|
||||||
|
* **Ciclo Lectivo Flexible:** Estructurado por Año (ej. `2026`) y Periodos estándar (`1C`, `2C`, `Anual`), permitiendo el alta de "Cursos Cortos Intensivos" o trayectos formativos extracurriculares con fechas especiales.
|
||||||
|
* **Restricción Diaria de Cursada del Estudiante:**
|
||||||
|
* **Regla por defecto:** Un estudiante universitario no puede cursar dos asignaturas regulares el mismo día calendario.
|
||||||
|
* **Excepción parametrizable:** Se autoriza la coincidencia en el mismo día si una de las materias corresponde a un curso corto intensivo o mediante autorización explícita de Bedelía/Secretaría Académica.
|
||||||
|
|
||||||
|
### 3. Matriz de Conflictos y Validaciones de Solapamiento
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Req([Solicitud de Asignación / Reserva]) --> V1{¿Es Aula Física?}
|
||||||
|
V1 -- Sí --> C1{¿Aula ocupada en ese día y horario?}
|
||||||
|
C1 -- Sí --> Err1[BLOQUEO: Conflicto Físico Infranqueable]
|
||||||
|
C1 -- No --> C2{¿Aforo de alumnos <= Capacidad aula?}
|
||||||
|
C2 -- No --> Err2[BLOQUEO: Aforo Excedido]
|
||||||
|
C2 -- Sí --> V2
|
||||||
|
V1 -- No (Virtual) --> V2
|
||||||
|
|
||||||
|
V2{¿Docente en otra clase en el mismo horario?}
|
||||||
|
V2 -- Sí --> C3{¿Comisión cuenta con Co-Docencia?}
|
||||||
|
C3 -- No --> Err3[BLOQUEO: Conflicto Docente]
|
||||||
|
C3 -- Sí --> Warn1[ALERTA: Excepción Co-Docente Justificada]
|
||||||
|
C3 --> V3
|
||||||
|
V2 -- No --> V3
|
||||||
|
|
||||||
|
V3{¿Alumno cursa otra materia regular el mismo día?}
|
||||||
|
V3 -- Sí --> C4{¿Es Curso Corto o Autorizado?}
|
||||||
|
C4 -- No --> Err4[BLOQUEO: Regla Restricción Diaria]
|
||||||
|
C4 -- Sí --> Ok[ASIGNACIÓN EXITOSA]
|
||||||
|
V3 -- No --> Ok
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Diseño Técnico: Base de Datos Relacional Normalizada
|
||||||
|
|
||||||
|
Para soportar las reglas de co-docencia, inscripciones, evaluaciones y restricciones de día, el esquema de datos evolucionará hacia la siguiente estructura:
|
||||||
|
|
||||||
|
### Entidades Principales
|
||||||
|
1. **`academic_terms` (Ciclos Lectivos):** `id`, `name`, `code` (ej. '2026-1C'), `year`, `term_type` ('1C', '2C', 'ANUAL', 'CORTO'), `start_date`, `end_date`, `is_active`.
|
||||||
|
2. **`subjects` (Materias / Asignaturas):** `id`, `code`, `name`, `career_id`, `is_short_course` (boolean), `credits`, `active`.
|
||||||
|
3. **`commissions` (Comisiones de Cursada):** `id`, `subject_id`, `term_id`, `code`, `shift`, `schedule_display`, `max_students`, `virtual_link`, `active`.
|
||||||
|
4. **`commission_teachers` (Co-docencia / Equipo Docente):** `id`, `commission_id`, `teacher_id`, `role` ('TITULAR', 'ADJUNTO', 'AYUDANTE'), `can_grade` (boolean).
|
||||||
|
5. **`commission_schedules` (Asignación Horaria y Espacial):**
|
||||||
|
* `id`, `commission_id`, `day_of_week` (1=Lunes .. 7=Domingo), `start_time`, `end_time`, `classroom_id` (física o virtual), `is_virtual`.
|
||||||
|
* *Constraint de Integridad:* `UNIQUE(classroom_id, day_of_week, start_time, end_time)` para aulas no virtuales.
|
||||||
|
6. **`enrollments` (Inscripciones de Alumnos):** `id`, `commission_id`, `student_id`, `status` ('REGULAR', 'CONDICIONAL', 'LIBRE', 'PROMOVIDO'), `enrolled_at`, `allow_same_day_exception` (boolean).
|
||||||
|
7. **`evaluation_milestones` (Hitos Evaluativos):** `id`, `commission_id`, `milestone_type_id`, `title`, `date`, `start_time`, `weight_percentage`, `is_mandatory`.
|
||||||
|
8. **`student_grades` (Calificaciones y Actas):** `id`, `milestone_id`, `student_id`, `numeric_score`, `concept_score`, `feedback`, `graded_by_teacher_id`, `graded_at`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Diseño de Contratos de API RESTful (Backend Core)
|
||||||
|
|
||||||
|
### A. Módulo de Comisiones y Co-docencia
|
||||||
|
* `GET /api/v1/commissions`: Filtro por término académico, carrera, asignatura, turno y estado.
|
||||||
|
* `POST /api/v1/commissions`: Alta de comisión vinculada a una asignatura con generación automática de Meet.
|
||||||
|
* `POST /api/v1/commissions/{id}/teachers`: Asignación de docentes con rol en cátedra (co-docencia).
|
||||||
|
* `DELETE /api/v1/commissions/{id}/teachers/{teacher_id}`: Desvinculación de docente.
|
||||||
|
|
||||||
|
### B. Módulo de Matriz de Conflictos y Validación
|
||||||
|
* `POST /api/v1/schedules/validate`:
|
||||||
|
* **Payload:** `{ commission_id, classroom_id, day_of_week, start_time, end_time, teacher_ids, student_ids }`
|
||||||
|
* **Respuesta:** `{ valid: boolean, conflicts: [ { type: 'PHYSICAL'|'TEACHER'|'STUDENT_DAILY', message, details } ] }`
|
||||||
|
|
||||||
|
### C. Módulo de Hitos Evaluativos y Libro de Calificaciones
|
||||||
|
* `GET /api/v1/commissions/{id}/milestones`: Listado de exámenes, entregas y parciales.
|
||||||
|
* `POST /api/v1/commissions/{id}/milestones`: Creación de hito evaluativo.
|
||||||
|
* `GET /api/v1/commissions/{id}/gradebook`: Matriz completa de alumnos y notas de la comisión.
|
||||||
|
* `PUT /api/v1/commissions/{id}/gradebook`: Carga masiva o individual de calificaciones por docente.
|
||||||
|
|
||||||
|
### D. Modo Impersonación de Superadmin
|
||||||
|
* Cabecera HTTP: `X-Impersonate-User: {user_id}`
|
||||||
|
* Procesamiento en `authMiddleware`: Si el token emisor corresponde al rol `SUPERADMIN`, el contexto de ejecución adopta los permisos y vistas del usuario destino para auditoría, dejando registro en `audit_logs`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Directrices UI/UX para el Frontend BFF
|
||||||
|
|
||||||
|
1. **Panel de Bedelía:**
|
||||||
|
- Grilla interactiva semanal (tipo TimeGrid) donde los bloques de comisiones se visualizan codificados por color según edificio, aula física o aula virtual.
|
||||||
|
- Detección inmediata de colisiones con carteles de advertencia flotantes antes de confirmar cambios.
|
||||||
|
2. **Matriz de Carga Rápida para Docentes:**
|
||||||
|
- Tabla editable en línea (*spreadsheet-like*) para volcar notas de parciales y entregas sin recargar la página, con autoguardado asíncrono.
|
||||||
|
3. **Perspectiva del Alumno:**
|
||||||
|
- Interfaz simplificada con diseño enfocado en dispositivos móviles, mostrando en la tarjeta de inicio la clase del momento con botón directo para ingresar a la videollamada o indicador de piso y aula física.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Conclusión del Diagnóstico
|
||||||
|
|
||||||
|
El sistema ha superado con éxito la fase de desacople inicial y estabilización de virtualidad:
|
||||||
|
- Se logró paridad total con `legacy_admin-edu-space` en importación de cronograma y cartelera.
|
||||||
|
- Se eliminaron las fallas de colisión virtual y se establecieron los 4 dashboards por rol.
|
||||||
|
- Se completó el acople de la ficha de reserva (`schedule.view_reservation`) y la creación asistida de comisiones en el BFF.
|
||||||
|
|
||||||
|
Las siguientes etapas se concentrarán en la consolidación de las reglas académicas complejas (co-docencia, restricción diaria de cursada, matriz de conflictos y libro de calificaciones) detalladas en el **`ROADMAP_MVP.md`**.
|
||||||
@@ -1,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)
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# ROADMAP MVP — Sistema Integral UniCABA
|
||||||
|
## Gestión de Espacios Áulicos, Actividad de Cursada y Seguimiento Académico
|
||||||
|
|
||||||
|
**Institución:** Universidad de la Ciudad de Buenos Aires (UniCABA)
|
||||||
|
**Versión:** 2.1.0
|
||||||
|
**Fecha de Actualización:** 19 de Septiembre de 2026
|
||||||
|
**Rama Base:** `testing`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗺️ Visión del Proyecto
|
||||||
|
Construir y evolucionar la plataforma de gestión académica y espacial de **UniCABA** hacia un sistema integral de clase universitaria, desacoplado en una arquitectura **Backend RESTful (Python/Flask) + Frontend BFF (Node.js/Express/Nunjucks)**, capaz de gobernar tanto los espacios físicos de la sede central como la dinámica áulica del campus virtual, garantizando una experiencia fluida para **Administradores**, **Bedelía**, **Docentes** y **Estudiantes**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Estado Global de Progreso
|
||||||
|
|
||||||
|
```
|
||||||
|
Fase 0: Desacople y Arquitectura Base [████████████████████] 100% (Completado)
|
||||||
|
Fase 1: Paridad Legacy y Estabilización [████████████████████] 100% (Completado)
|
||||||
|
Fase 2: Co-docencia y Matriz Conflictos [░░░░░░░░░░░░░░░░░░░░] 0% (Próxima)
|
||||||
|
Fase 3: Hitos Evaluativos y Calificaciones[░░░░░░░░░░░░░░░░░░░░] 0% (Planificada)
|
||||||
|
Fase 4: UI/UX Drag & Drop e Impersonación[░░░░░░░░░░░░░░░░░░░░] 0% (Planificada)
|
||||||
|
Fase 5: Despliegue Producción e Integrac.[░░░░░░░░░░░░░░░░░░░░] 0% (Planificada)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Detalle de Fases y Estadios
|
||||||
|
|
||||||
|
### Fase 0: Desacople y Arquitectura Base ✅ *(Completada)*
|
||||||
|
* **Objetivo:** Separar el monolito Jinja2 legacy en dos servicios independientes de alto rendimiento.
|
||||||
|
* **Entregables:**
|
||||||
|
* [x] Creación del Backend Flask como API REST pura bajo `/api/v1/` retornando exclusivamente JSON.
|
||||||
|
* [x] Implementación del Frontend BFF en Node.js + Express con renderizado de plantillas mediante Nunjucks.
|
||||||
|
* [x] Autenticación desacoplada mediante JWT (`access_token`) transportado en Cookie `HttpOnly` segura.
|
||||||
|
* [x] Pipeline de seguridad `SecurityFilterChain` con cabeceras de blindaje OWASP y rate limiting dinámico.
|
||||||
|
* [x] Cliente HTTP Axios centralizado con inyección automática de Bearer token y manejo de sesiones.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fase 1: Paridad Legacy, Estabilización de Virtualidad y BFF Core ✅ *(Completada)*
|
||||||
|
* **Objetivo:** Alcanzar paridad funcional total con la versión legacy, subsanando defectos críticos de virtualidad y dotando de interfaz específica a cada rol.
|
||||||
|
* **Entregables:**
|
||||||
|
* [x] **Importador de Google Sheets Corregido:**
|
||||||
|
* Eliminación de la colisión de horarios en el aula virtual única (`Campus Virtual · VIRTUAL`).
|
||||||
|
* Incremento de reservas registradas de 363 a 643 (incorporando 280 clases virtuales que se descartaban).
|
||||||
|
* Generación y persistencia automática de enlaces de Google Meet institucional por comisión y reserva.
|
||||||
|
* [x] **Dashboards Especializados por Rol:**
|
||||||
|
* Dashboard interactivo para **Bedelía** (aulas físicas, salas virtuales, aprobaciones pendientes).
|
||||||
|
* Dashboard interactivo para **Docente** (clases del día, comisiones, enlaces directos a Meet).
|
||||||
|
* Dashboard interactivo para **Alumno** (brújula de cursada *"¿Dónde curso hoy?"*, hitos y calendario).
|
||||||
|
* Cockpit institucional para **Admin** (métricas de capacidad, auditoría RBAC, demanda mensual).
|
||||||
|
* Selector dinámico de vista previa / roles en tiempo real (`?role=...`).
|
||||||
|
* [x] **Cartelera del Día (`today_schedule`):**
|
||||||
|
* Grilla temporal por bloques horarios (07:00 a 21:00 hs) con indicadores físicos y virtuales.
|
||||||
|
* Filtro dinámico para ocultar clases virtuales (`hide_virtual=1`) y segmentación por piso o turno.
|
||||||
|
* [x] **Acople de Ficha de Reserva (`schedule.view_reservation`):**
|
||||||
|
* Ruteo en BFF (`/schedule/view_reservation?id=...` y `/view/:id`).
|
||||||
|
* Endpoint REST `/api/v1/reservations/<id>` con enriquecimiento relacional (aula, comisión, docente).
|
||||||
|
* Soporte para modificar reserva (`edit_reservation`) con selectores en cascada, confirmación y cancelación.
|
||||||
|
* [x] **Gestión de Comisiones (`admin/commissions_list`):**
|
||||||
|
* Botón y modal interactivo `+ Nueva Comisión` (`#modalAddCommission`).
|
||||||
|
* Vinculación obligatoria y explícita a la **Asignatura / Materia**, indicando cuatrimestre, año lectivo, turno, docente a cargo, cupo y enlace virtual.
|
||||||
|
* Visualización en tabla de la materia vinculada con su código y carrera asociada.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fase 2: Co-docencia, Matriz de Conflictos y Regla Diaria ⏳ *(En curso / Inmediata)*
|
||||||
|
* **Objetivo:** Implementar las reglas de negocio académicas complejas para asignación de cátedras y cursada de alumnos.
|
||||||
|
* **Estadios:**
|
||||||
|
1. **Estadio 2.1 — Modelo de Co-docencia:**
|
||||||
|
- [ ] Tabla relacional `commission_teachers` para soportar múltiples docentes por comisión (titulares, adjuntos, ayudantes).
|
||||||
|
- [ ] Endpoints `/api/v1/commissions/<id>/teachers` para asociar y desvincular docentes de cátedra.
|
||||||
|
- [ ] Adaptación de vistas de Bedelía y Comisiones para visualizar el equipo docente completo.
|
||||||
|
2. **Estadio 2.2 — Matriz de Conflictos y Validación:**
|
||||||
|
- [ ] Validación estricta de conflicto físico: bloqueo de doble asignación de aula en mismo día y horario.
|
||||||
|
- [ ] Validación de conflicto docente: detección de superposición horaria del profesor, con soporte de excepción justificada cuando la comisión cuenta con co-docencia.
|
||||||
|
- [ ] Validación de aforo: bloqueo cuando la cantidad de alumnos excede la capacidad del aula física.
|
||||||
|
3. **Estadio 2.3 — Regla de Restricción Diaria del Alumno:**
|
||||||
|
- [ ] Validación al matricular: un alumno no puede cursar dos asignaturas regulares en el mismo día calendario.
|
||||||
|
- [ ] Excepción parametrizable: autorización automática si al menos una de las materias es un "curso corto" o si existe autorización manual de Bedelía (`allow_same_day_exception`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fase 3: Gestión de Hitos Evaluativos y Libro de Calificaciones 📅 *(Planificada)*
|
||||||
|
* **Objetivo:** Proveer al cuerpo docente y estudiantil el seguimiento completo de instancias evaluativas.
|
||||||
|
* **Estadios:**
|
||||||
|
1. **Estadio 3.1 — Calendario de Evaluación por Comisión:**
|
||||||
|
- [ ] Programación de parciales, recuperatorios, entregas de trabajos prácticos y exámenes finales.
|
||||||
|
- [ ] Notificaciones preventivas en el dashboard del alumno con cuenta regresiva para exámenes.
|
||||||
|
2. **Estadio 3.2 — Libro de Calificaciones (*Gradebook*):**
|
||||||
|
- [ ] Matriz de carga rápida de notas para docentes con autoguardado asíncrono.
|
||||||
|
- [ ] Consulta individualizada de calificaciones y retroalimentaciones pedagógicas para alumnos.
|
||||||
|
- [ ] Cierre de actas de regularidad y promoción para Bedelía.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fase 4: UI/UX Avanzada para Bedelía & Modo Impersonación 🖥️ *(Planificada)*
|
||||||
|
* **Objetivo:** Dotar a la oficina de Bedelía y a la Administración de herramientas interactivas de última generación.
|
||||||
|
* **Estadios:**
|
||||||
|
1. **Estadio 4.1 — Grilla Semanal Interactiva (Drag-and-Drop):**
|
||||||
|
- [ ] Interfaz tipo TimeGrid interactiva con arrastre de comisiones para asignación rápida de aulas y turnos.
|
||||||
|
- [ ] Detección visual en tiempo real de aulas ocupadas o incompatibles.
|
||||||
|
2. **Estadio 4.2 — Modo Impersonación del Superadmin:**
|
||||||
|
- [ ] Funcionalidad de *login as* o vista previa con cabecera `X-Impersonate-User` para replicar exactamente lo que ve cualquier docente o alumno.
|
||||||
|
- [ ] Trazabilidad obligatoria de acciones bajo impersonación en el registro de auditoría (`audit_logs`).
|
||||||
|
3. **Estadio 4.3 — Integración del Optimizador Genético:**
|
||||||
|
- [ ] Acople del motor predictivo de algoritmos genéticos a la base de datos real para sugerir redistribución óptima de aulas según aforo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fase 5: Producción Institucional, CI/CD e Integraciones 🏢 *(Planificada)*
|
||||||
|
* **Objetivo:** Puesta en marcha definitiva en la infraestructura de servidores de UniCABA.
|
||||||
|
* **Estadios:**
|
||||||
|
1. **Estadio 5.1 — Despliegue e Infraestructura:**
|
||||||
|
- [ ] `docker-compose.yml` productivo orquestando Flask (Gunicorn), Node.js BFF (PM2) y PostgreSQL 15.
|
||||||
|
- [ ] Proxy inverso Nginx con certificados SSL/TLS y compresión Brotli/Gzip.
|
||||||
|
2. **Estadio 5.2 — Integraciones Universitarias:**
|
||||||
|
- [ ] Módulo de exportación/importación compatible con SIU Guaraní 3.
|
||||||
|
- [ ] Sincronización automática de aulas virtuales con Moodle institucional.
|
||||||
|
3. **Estadio 5.3 — Pruebas de Carga y Hardening:**
|
||||||
|
- [ ] Pruebas de estrés de concurrencia en días pico de inicio de cuatrimestre (10.000 solicitudes simultáneas).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 Criterios de Aceptación del MVP
|
||||||
|
1. **Zero Colisiones Físicas:** Imposibilidad absoluta de asignar dos materias simultáneas a una misma aula física.
|
||||||
|
2. **Virtualidad Fluida:** Acceso con un clic a reuniones virtuales válidas para el 100% de las comisiones remotas.
|
||||||
|
3. **Roles Seguros:** Separación total de permisos donde ningún alumno o docente acceda a configuración global ni a datos privados de otras comisiones.
|
||||||
|
4. **Respuesta Sub-segundo:** Carga de carteleras y calendarios en menos de 300 ms en el BFF Node.js.
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
from flask import Flask, request, session
|
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
|
||||||
from flask_migrate import Migrate
|
|
||||||
from flask_login import LoginManager
|
|
||||||
from flask_cors import CORS
|
|
||||||
from flask_babel import Babel, gettext as _
|
|
||||||
from config.config import Config
|
|
||||||
|
|
||||||
# Initialize extensions
|
|
||||||
db = SQLAlchemy()
|
|
||||||
migrate = Migrate()
|
|
||||||
login_manager = LoginManager()
|
|
||||||
cors = CORS()
|
|
||||||
babel = Babel()
|
|
||||||
|
|
||||||
def create_app(config_class=Config):
|
|
||||||
app = Flask(__name__)
|
|
||||||
app.config.from_object(config_class)
|
|
||||||
|
|
||||||
# Initialize extensions with app
|
|
||||||
db.init_app(app)
|
|
||||||
migrate.init_app(app, db)
|
|
||||||
login_manager.init_app(app)
|
|
||||||
cors.init_app(app)
|
|
||||||
|
|
||||||
# Login manager configuration
|
|
||||||
login_manager.login_view = 'auth.login'
|
|
||||||
login_manager.login_message = 'Por favor inicia sesión para acceder a esta página.'
|
|
||||||
login_manager.login_message_category = 'info'
|
|
||||||
|
|
||||||
# Import models to ensure they are registered
|
|
||||||
from app.models import user
|
|
||||||
|
|
||||||
# Register blueprints
|
|
||||||
from app.routes import auth_bp, classrooms_bp, main_bp, schedule_bp, genetic_bp, genetic_web_bp
|
|
||||||
|
|
||||||
app.register_blueprint(auth_bp, url_prefix="/")
|
|
||||||
app.register_blueprint(classrooms_bp, url_prefix="/classrooms")
|
|
||||||
app.register_blueprint(main_bp, url_prefix="/")
|
|
||||||
app.register_blueprint(schedule_bp, url_prefix="/schedule")
|
|
||||||
app.register_blueprint(genetic_bp)
|
|
||||||
app.register_blueprint(genetic_web_bp)
|
|
||||||
|
|
||||||
# Babel language selector
|
|
||||||
@app.context_processor
|
|
||||||
def inject_conf_vars():
|
|
||||||
return dict(
|
|
||||||
languages=app.config['LANGUAGES'],
|
|
||||||
current_lang=session.get('language', request.accept_languages.best_match(app.config['LANGUAGES']) or app.config['BABEL_DEFAULT_LOCALE'])
|
|
||||||
)
|
|
||||||
|
|
||||||
# Language selector function
|
|
||||||
def get_locale():
|
|
||||||
# 1. Check if language is explicitly set in URL parameters
|
|
||||||
if request.args.get('lang'):
|
|
||||||
lang = request.args.get('lang')
|
|
||||||
if lang in app.config['LANGUAGES']:
|
|
||||||
return lang
|
|
||||||
|
|
||||||
# 2. Check if language is stored in session
|
|
||||||
if 'language' in session:
|
|
||||||
return session['language']
|
|
||||||
|
|
||||||
# 3. Check browser language preference
|
|
||||||
return request.accept_languages.best_match(app.config['LANGUAGES']) or app.config['BABEL_DEFAULT_LOCALE']
|
|
||||||
|
|
||||||
# Configure Babel
|
|
||||||
babel.init_app(app, locale_selector=get_locale)
|
|
||||||
|
|
||||||
return app
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
from flask_wtf import FlaskForm
|
|
||||||
from wtforms import StringField, PasswordField, BooleanField, SubmitField
|
|
||||||
from wtforms.validators import DataRequired, Email, Length
|
|
||||||
|
|
||||||
class LoginForm(FlaskForm):
|
|
||||||
email = StringField('Email', validators=[
|
|
||||||
DataRequired(),
|
|
||||||
Email(),
|
|
||||||
Length(max=120)
|
|
||||||
])
|
|
||||||
password = PasswordField('Password', validators=[DataRequired()])
|
|
||||||
remember_me = BooleanField('Remember me')
|
|
||||||
submit = SubmitField('Sign In')
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
from flask_wtf import FlaskForm
|
|
||||||
from wtforms import StringField, IntegerField, TextAreaField, BooleanField, SubmitField, SelectField
|
|
||||||
from wtforms.validators import DataRequired, Length, NumberRange, Optional
|
|
||||||
|
|
||||||
class ClassroomForm(FlaskForm):
|
|
||||||
# campus_id = SelectField('Campus', coerce=int, validators=[DataRequired()])
|
|
||||||
building = StringField('Building', validators=[
|
|
||||||
DataRequired(),
|
|
||||||
Length(min=2, max=100)
|
|
||||||
])
|
|
||||||
room_number = StringField('Room Number', validators=[
|
|
||||||
DataRequired(),
|
|
||||||
Length(min=1, max=50)
|
|
||||||
])
|
|
||||||
floor = IntegerField('Floor', validators=[
|
|
||||||
DataRequired(),
|
|
||||||
NumberRange(min=1, max=50)
|
|
||||||
])
|
|
||||||
capacity = IntegerField('Capacity', validators=[
|
|
||||||
DataRequired(),
|
|
||||||
NumberRange(min=1, max=500)
|
|
||||||
])
|
|
||||||
description = TextAreaField('Description', validators=[
|
|
||||||
Optional(),
|
|
||||||
Length(max=500)
|
|
||||||
])
|
|
||||||
is_active = BooleanField('Active', default=True)
|
|
||||||
submit = SubmitField('Save Classroom')
|
|
||||||
|
|
||||||
class ClassroomResourceForm(FlaskForm):
|
|
||||||
resource_type = SelectField('Resource Type', choices=[
|
|
||||||
('PROJECTOR', 'Projector'),
|
|
||||||
('WHITEBOARD', 'Whiteboard'),
|
|
||||||
('COMPUTER', 'Computer'),
|
|
||||||
('SOUND_SYSTEM', 'Sound System'),
|
|
||||||
('MICROPHONE', 'Microphone'),
|
|
||||||
('AIR_CONDITIONER', 'Air Conditioner'),
|
|
||||||
('OTHER', 'Other')
|
|
||||||
], validators=[DataRequired()])
|
|
||||||
brand = StringField('Brand', validators=[Optional(), Length(max=100)])
|
|
||||||
model = StringField('Model', validators=[Optional(), Length(max=100)])
|
|
||||||
specifications = TextAreaField('Specifications', validators=[Optional()])
|
|
||||||
functional = BooleanField('Functional', default=True)
|
|
||||||
submit = SubmitField('Save Resource')
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
from .user import User
|
|
||||||
from .classroom import Classroom, ClassroomResource
|
|
||||||
from .subject import Subject, Commission
|
|
||||||
from .reservation import Reservation, ReservationStatus
|
|
||||||
from .genetic_algorithm import GeneticAlgorithm, ReservationOptimizer
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
'User',
|
|
||||||
'Classroom',
|
|
||||||
'ClassroomResource',
|
|
||||||
'Subject',
|
|
||||||
'Commission',
|
|
||||||
'Reservation',
|
|
||||||
'ReservationStatus',
|
|
||||||
'GeneticAlgorithm',
|
|
||||||
'ReservationOptimizer'
|
|
||||||
]
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
from app import db, login_manager
|
|
||||||
from flask_login import UserMixin
|
|
||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
class User(UserMixin, db.Model):
|
|
||||||
__tablename__ = 'users'
|
|
||||||
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
|
||||||
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
|
||||||
password_hash = db.Column(db.String(255), nullable=False)
|
|
||||||
name = db.Column(db.String(100), nullable=False)
|
|
||||||
role = db.Column(db.String(50), nullable=False)
|
|
||||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
|
||||||
last_login = db.Column(db.DateTime)
|
|
||||||
|
|
||||||
# Virtual fields for future compatibility
|
|
||||||
@property
|
|
||||||
def first_name(self):
|
|
||||||
return self.name.split()[0] if self.name else ''
|
|
||||||
|
|
||||||
@property
|
|
||||||
def last_name(self):
|
|
||||||
parts = self.name.split()
|
|
||||||
return ' '.join(parts[1:]) if len(parts) > 1 else ''
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
reservations = db.relationship('Reservation', backref='user', lazy=True, cascade='all, delete-orphan')
|
|
||||||
|
|
||||||
def set_password(self, password):
|
|
||||||
"""Set password hash"""
|
|
||||||
self.password_hash = generate_password_hash(password, method='pbkdf2:sha256')
|
|
||||||
|
|
||||||
def check_password(self, password):
|
|
||||||
"""Check password"""
|
|
||||||
return check_password_hash(self.password_hash, password)
|
|
||||||
|
|
||||||
def is_admin(self):
|
|
||||||
"""Check if user is admin"""
|
|
||||||
return self.role == 'ADMIN'
|
|
||||||
|
|
||||||
def update_last_login(self):
|
|
||||||
"""Update last login timestamp"""
|
|
||||||
self.last_login = datetime.utcnow()
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
def to_dict(self):
|
|
||||||
"""Convert user to dictionary"""
|
|
||||||
return {
|
|
||||||
'id': self.id,
|
|
||||||
'email': self.email,
|
|
||||||
'name': self.name,
|
|
||||||
'role': self.role,
|
|
||||||
'is_active': self.is_active,
|
|
||||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
|
||||||
'last_login': self.last_login.isoformat() if self.last_login else None
|
|
||||||
}
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f'<User {self.email}>'
|
|
||||||
|
|
||||||
@login_manager.user_loader
|
|
||||||
def load_user(user_id):
|
|
||||||
"""Load user from database by ID"""
|
|
||||||
return User.query.get(int(user_id))
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
from .main import main_bp
|
|
||||||
from .auth import auth_bp
|
|
||||||
from .classrooms import classrooms_bp
|
|
||||||
from .schedule import schedule_bp
|
|
||||||
from .genetic_algorithm import genetic_bp, genetic_web_bp
|
|
||||||
|
|
||||||
__all__ = ['main_bp', 'auth_bp', 'classrooms_bp', 'schedule_bp', 'genetic_bp', 'genetic_web_bp']
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, session
|
|
||||||
from flask_login import login_user, logout_user, login_required, current_user
|
|
||||||
from app import db
|
|
||||||
from app.models.user import User
|
|
||||||
from app.forms.auth import LoginForm
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
auth_bp = Blueprint('auth', __name__)
|
|
||||||
|
|
||||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
|
||||||
def login():
|
|
||||||
"""Handle user login"""
|
|
||||||
if current_user.is_authenticated:
|
|
||||||
return redirect(url_for('main.dashboard'))
|
|
||||||
|
|
||||||
form = LoginForm()
|
|
||||||
if form.validate_on_submit():
|
|
||||||
user = User.query.filter_by(email=form.email.data).first()
|
|
||||||
|
|
||||||
if user and user.check_password(form.password.data):
|
|
||||||
if not user.is_active:
|
|
||||||
flash('Your account has been deactivated. Please contact administrator.', 'error')
|
|
||||||
return render_template('auth/login.html', form=form)
|
|
||||||
|
|
||||||
login_user(user, remember=form.remember_me.data)
|
|
||||||
user.update_last_login()
|
|
||||||
|
|
||||||
next_page = request.args.get('next')
|
|
||||||
if not next_page or not next_page.startswith('/'):
|
|
||||||
next_page = url_for('main.dashboard')
|
|
||||||
|
|
||||||
flash(f'Welcome back, {user.first_name} {user.last_name}!', 'success')
|
|
||||||
return redirect(next_page)
|
|
||||||
else:
|
|
||||||
flash('Invalid email or password.', 'error')
|
|
||||||
|
|
||||||
return render_template('auth/login.html', form=form)
|
|
||||||
|
|
||||||
@auth_bp.route('/logout')
|
|
||||||
@login_required
|
|
||||||
def logout():
|
|
||||||
"""Handle user logout"""
|
|
||||||
logout_user()
|
|
||||||
flash('You have been logged out successfully.', 'info')
|
|
||||||
return redirect(url_for('auth.login'))
|
|
||||||
|
|
||||||
@auth_bp.route('/profile')
|
|
||||||
@login_required
|
|
||||||
def profile():
|
|
||||||
"""User profile page"""
|
|
||||||
return render_template('auth/profile.html')
|
|
||||||
|
|
||||||
@auth_bp.route('/change_password', methods=['GET', 'POST'])
|
|
||||||
@login_required
|
|
||||||
def change_password():
|
|
||||||
"""Change user password"""
|
|
||||||
if request.method == 'POST':
|
|
||||||
current_password = request.form.get('current_password')
|
|
||||||
new_password = request.form.get('new_password')
|
|
||||||
confirm_password = request.form.get('confirm_password')
|
|
||||||
|
|
||||||
if not current_user.check_password(current_password):
|
|
||||||
flash('Current password is incorrect.', 'error')
|
|
||||||
return redirect(url_for('auth.change_password'))
|
|
||||||
|
|
||||||
if new_password != confirm_password:
|
|
||||||
flash('New passwords do not match.', 'error')
|
|
||||||
return redirect(url_for('auth.change_password'))
|
|
||||||
|
|
||||||
if len(new_password) < 6:
|
|
||||||
flash('Password must be at least 6 characters long.', 'error')
|
|
||||||
return redirect(url_for('auth.change_password'))
|
|
||||||
|
|
||||||
current_user.set_password(new_password)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
flash('Password changed successfully!', 'success')
|
|
||||||
return redirect(url_for('auth.profile'))
|
|
||||||
|
|
||||||
return render_template('auth/change_password.html')
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
|
|
||||||
from flask_login import login_required, current_user
|
|
||||||
from datetime import datetime, date
|
|
||||||
from app.models.classroom import Classroom
|
|
||||||
from app.models.user import User
|
|
||||||
from app.forms.classroom import ClassroomForm
|
|
||||||
from app import db
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
classrooms_bp = Blueprint('classrooms', __name__)
|
|
||||||
|
|
||||||
@classrooms_bp.route('/add', methods=['GET', 'POST'])
|
|
||||||
@login_required
|
|
||||||
def add_classroom():
|
|
||||||
"""Add a new classroom"""
|
|
||||||
form = ClassroomForm()
|
|
||||||
|
|
||||||
# TODO: Update when campus model is implemented
|
|
||||||
# form.campus_id.choices = [(1, 'Main Campus')]
|
|
||||||
|
|
||||||
if form.validate_on_submit():
|
|
||||||
try:
|
|
||||||
existing_classroom = Classroom.query.filter_by(
|
|
||||||
building=form.building.data,
|
|
||||||
code=form.room_number.data,
|
|
||||||
floor=form.floor.data
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if existing_classroom:
|
|
||||||
flash('Classroom with this building, room number, and floor already exists.', 'error')
|
|
||||||
return render_template('classrooms/add.html', form=form, title='Add Classroom')
|
|
||||||
|
|
||||||
classroom = Classroom(
|
|
||||||
# campus_id=form.campus_id.data,
|
|
||||||
building=form.building.data,
|
|
||||||
code=form.room_number.data,
|
|
||||||
floor=form.floor.data,
|
|
||||||
capacity=form.capacity.data,
|
|
||||||
description=form.description.data,
|
|
||||||
is_active=form.is_active.data
|
|
||||||
)
|
|
||||||
|
|
||||||
db.session.add(classroom)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
flash(f'Classroom {classroom.code_display} has been created successfully!', 'success')
|
|
||||||
return redirect(url_for('classrooms.list_classrooms'))
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
flash(f'Error creating classroom: {str(e)}', 'error')
|
|
||||||
|
|
||||||
return render_template('classrooms/add.html', form=form, title='Add Classroom')
|
|
||||||
|
|
||||||
@classrooms_bp.route('/list')
|
|
||||||
@login_required
|
|
||||||
def list_classrooms():
|
|
||||||
"""List all classrooms"""
|
|
||||||
try:
|
|
||||||
# Get filters
|
|
||||||
page = request.args.get('page', 1, type=int)
|
|
||||||
active_only = request.args.get('active_only', 'true').lower() == 'true'
|
|
||||||
search = request.args.get('search', '', type=str)
|
|
||||||
|
|
||||||
# Build query
|
|
||||||
query = Classroom.query
|
|
||||||
|
|
||||||
if active_only:
|
|
||||||
query = query.filter_by(is_active=True)
|
|
||||||
|
|
||||||
if search:
|
|
||||||
query = query.filter(
|
|
||||||
db.or_(
|
|
||||||
Classroom.building.ilike(f'%{search}%'),
|
|
||||||
Classroom.code.ilike(f'%{search}%'),
|
|
||||||
Classroom.description.ilike(f'%{search}%')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Paginate results
|
|
||||||
classrooms = query.order_by(Classroom.building, Classroom.floor, Classroom.code).paginate(
|
|
||||||
page=page, per_page=12, error_out=False
|
|
||||||
)
|
|
||||||
|
|
||||||
return render_template('classrooms/list.html',
|
|
||||||
classrooms=classrooms,
|
|
||||||
active_only=active_only,
|
|
||||||
search=search,
|
|
||||||
title='Classrooms')
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
flash(f'Error loading classrooms: {str(e)}', 'error')
|
|
||||||
return render_template('classrooms/list.html', classrooms=None, title='Classrooms')
|
|
||||||
|
|
||||||
@classrooms_bp.route('/<int:id>')
|
|
||||||
@login_required
|
|
||||||
def view_classroom(id):
|
|
||||||
"""View classroom details"""
|
|
||||||
try:
|
|
||||||
classroom = Classroom.query.get_or_404(id)
|
|
||||||
|
|
||||||
# Get upcoming reservations for this classroom
|
|
||||||
from app.models.reservation import Reservation, ReservationStatus
|
|
||||||
|
|
||||||
upcoming_reservations = Reservation.query.filter(
|
|
||||||
Reservation.classroom_id == id,
|
|
||||||
Reservation.start_time > datetime.utcnow(),
|
|
||||||
Reservation.status == ReservationStatus.CONFIRMED.value
|
|
||||||
).order_by(Reservation.start_time).limit(10).all()
|
|
||||||
|
|
||||||
return render_template('classrooms/view.html',
|
|
||||||
classroom=classroom,
|
|
||||||
upcoming_reservations=upcoming_reservations,
|
|
||||||
title=f'Classroom {classroom.code_display}')
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
flash(f'Error loading classroom: {str(e)}', 'error')
|
|
||||||
return redirect(url_for('classrooms.list_classrooms'))
|
|
||||||
|
|
||||||
@classrooms_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
|
|
||||||
@login_required
|
|
||||||
def edit_classroom(id):
|
|
||||||
"""Edit classroom"""
|
|
||||||
classroom = Classroom.query.get_or_404(id)
|
|
||||||
form = ClassroomForm(obj=classroom)
|
|
||||||
|
|
||||||
# TODO: Update when campus model is implemented
|
|
||||||
# form.campus_id.choices = [(1, 'Main Campus')]
|
|
||||||
|
|
||||||
if form.validate_on_submit():
|
|
||||||
try:
|
|
||||||
# Check for duplicates (excluding current classroom)
|
|
||||||
existing_classroom = Classroom.query.filter(
|
|
||||||
Classroom.building == form.building.data,
|
|
||||||
Classroom.code == form.room_number.data,
|
|
||||||
Classroom.floor == form.floor.data,
|
|
||||||
Classroom.id != id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if existing_classroom:
|
|
||||||
flash('Classroom with this building, room number, and floor already exists.', 'error')
|
|
||||||
return render_template('classrooms/edit.html', form=form, classroom=classroom, title='Edit Classroom')
|
|
||||||
|
|
||||||
# Update classroom
|
|
||||||
# classroom.campus_id = form.campus_id.data
|
|
||||||
classroom.building = form.building.data
|
|
||||||
classroom.code = form.room_number.data
|
|
||||||
classroom.floor = form.floor.data
|
|
||||||
classroom.capacity = form.capacity.data
|
|
||||||
classroom.description = form.description.data
|
|
||||||
classroom.is_active = form.is_active.data
|
|
||||||
classroom.updated_at = datetime.utcnow()
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
flash(f'Classroom {classroom.code_display} has been updated successfully!', 'success')
|
|
||||||
return redirect(url_for('classrooms.view_classroom', id=classroom.id))
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
flash(f'Error updating classroom: {str(e)}', 'error')
|
|
||||||
|
|
||||||
return render_template('classrooms/edit.html', form=form, classroom=classroom, title='Edit Classroom')
|
|
||||||
|
|
||||||
@classrooms_bp.route('/<int:id>/toggle', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def toggle_classroom(id):
|
|
||||||
"""Toggle classroom active status"""
|
|
||||||
classroom = Classroom.query.get_or_404(id)
|
|
||||||
|
|
||||||
try:
|
|
||||||
classroom.is_active = not classroom.is_active
|
|
||||||
classroom.updated_at = datetime.utcnow()
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
status = 'activated' if classroom.is_active else 'deactivated'
|
|
||||||
flash(f'Classroom {classroom.code_display} has been {status} successfully!', 'success')
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
flash(f'Error updating classroom status: {str(e)}', 'error')
|
|
||||||
|
|
||||||
return redirect(request.referrer or url_for('classrooms.list_classrooms'))
|
|
||||||
|
|
||||||
# API endpoints for AJAX requests
|
|
||||||
@classrooms_bp.route('/api/available')
|
|
||||||
@login_required
|
|
||||||
def get_available_classrooms():
|
|
||||||
"""Get available classrooms for a specific time slot"""
|
|
||||||
start_time = request.args.get('start_time')
|
|
||||||
end_time = request.args.get('end_time')
|
|
||||||
|
|
||||||
if not start_time or not end_time:
|
|
||||||
return jsonify({'error': 'Start time and end time are required'}), 400
|
|
||||||
|
|
||||||
try:
|
|
||||||
from datetime import datetime
|
|
||||||
from app.models.reservation import Reservation, ReservationStatus
|
|
||||||
|
|
||||||
start_dt = datetime.fromisoformat(start_time)
|
|
||||||
end_dt = datetime.fromisoformat(end_time)
|
|
||||||
|
|
||||||
# Find all classrooms
|
|
||||||
all_classrooms = Classroom.query.filter_by(is_active=True).all()
|
|
||||||
available_classrooms = []
|
|
||||||
|
|
||||||
for classroom in all_classrooms:
|
|
||||||
# Check if classroom has conflicting reservations
|
|
||||||
conflicts = Reservation.query.filter(
|
|
||||||
Reservation.classroom_id == classroom.id,
|
|
||||||
Reservation.status == ReservationStatus.CONFIRMED.value,
|
|
||||||
Reservation.start_time < end_dt,
|
|
||||||
Reservation.end_time > start_dt
|
|
||||||
).all()
|
|
||||||
|
|
||||||
if not conflicts:
|
|
||||||
available_classrooms.append({
|
|
||||||
'id': classroom.id,
|
|
||||||
'code': classroom.code,
|
|
||||||
'building': classroom.building,
|
|
||||||
'room_number': classroom.code,
|
|
||||||
'floor': classroom.floor,
|
|
||||||
'capacity': classroom.capacity,
|
|
||||||
'code_display': classroom.code_display
|
|
||||||
})
|
|
||||||
|
|
||||||
return jsonify({'classrooms': available_classrooms})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({'error': str(e)}), 500
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
from flask import Blueprint, render_template, jsonify, redirect, url_for, session, request, flash
|
|
||||||
from flask_login import login_required, current_user
|
|
||||||
from app.models.reservation import Reservation
|
|
||||||
from app.models.classroom import Classroom
|
|
||||||
from app.models.subject import Subject
|
|
||||||
from app.models.subject import Commission
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from sqlalchemy import func
|
|
||||||
from flask_babel import gettext as _
|
|
||||||
|
|
||||||
main_bp = Blueprint('main', __name__)
|
|
||||||
|
|
||||||
@main_bp.route('/')
|
|
||||||
@login_required
|
|
||||||
def dashboard():
|
|
||||||
"""Main dashboard page"""
|
|
||||||
# Get statistics
|
|
||||||
try:
|
|
||||||
total_classrooms = Classroom.query.filter_by(is_active=True).count()
|
|
||||||
except:
|
|
||||||
total_classrooms = 0
|
|
||||||
|
|
||||||
try:
|
|
||||||
total_subjects = Subject.query.filter_by(is_active=True).count()
|
|
||||||
except:
|
|
||||||
total_subjects = 0
|
|
||||||
|
|
||||||
# Get today's reservations
|
|
||||||
today = datetime.utcnow().date()
|
|
||||||
try:
|
|
||||||
today_reservations = Reservation.query.filter(
|
|
||||||
func.date(Reservation.start_time) == today
|
|
||||||
).count()
|
|
||||||
except:
|
|
||||||
today_reservations = 0
|
|
||||||
|
|
||||||
# Get this week's reservations
|
|
||||||
week_start = today - timedelta(days=today.weekday())
|
|
||||||
week_end = week_start + timedelta(days=6)
|
|
||||||
try:
|
|
||||||
week_reservations = Reservation.query.filter(
|
|
||||||
func.date(Reservation.start_time) >= week_start,
|
|
||||||
func.date(Reservation.start_time) <= week_end
|
|
||||||
).count()
|
|
||||||
except:
|
|
||||||
week_reservations = 0
|
|
||||||
|
|
||||||
# Get upcoming reservations (next 7 days)
|
|
||||||
try:
|
|
||||||
upcoming_reservations = Reservation.query.filter(
|
|
||||||
Reservation.start_time >= datetime.utcnow(),
|
|
||||||
Reservation.start_time <= datetime.utcnow() + timedelta(days=7)
|
|
||||||
).limit(10).all()
|
|
||||||
except:
|
|
||||||
upcoming_reservations = []
|
|
||||||
|
|
||||||
stats = {
|
|
||||||
'total_classrooms': total_classrooms,
|
|
||||||
'total_subjects': total_subjects,
|
|
||||||
'today_reservations': today_reservations,
|
|
||||||
'week_reservations': week_reservations
|
|
||||||
}
|
|
||||||
|
|
||||||
return render_template('dashboard.html',
|
|
||||||
stats=stats,
|
|
||||||
upcoming_reservations=upcoming_reservations)
|
|
||||||
|
|
||||||
@main_bp.route('/api/dashboard-stats')
|
|
||||||
@login_required
|
|
||||||
def dashboard_stats_api():
|
|
||||||
"""API endpoint for dashboard statistics"""
|
|
||||||
# Get today's data
|
|
||||||
today = datetime.utcnow().date()
|
|
||||||
today_reservations = Reservation.query.filter(
|
|
||||||
func.date(Reservation.start_time) == today
|
|
||||||
).count()
|
|
||||||
|
|
||||||
# Get this week's data
|
|
||||||
week_start = today - timedelta(days=today.weekday())
|
|
||||||
week_end = week_start + timedelta(days=6)
|
|
||||||
week_reservations = Reservation.query.filter(
|
|
||||||
func.date(Reservation.start_time) >= week_start,
|
|
||||||
func.date(Reservation.start_time) <= week_end
|
|
||||||
).count()
|
|
||||||
|
|
||||||
# Get monthly data for chart
|
|
||||||
current_date = datetime.utcnow()
|
|
||||||
monthly_data = []
|
|
||||||
|
|
||||||
for i in range(12):
|
|
||||||
month_date = current_date - timedelta(days=30 * i)
|
|
||||||
month_start = month_date.replace(day=1)
|
|
||||||
month_end = (month_start + timedelta(days=32)).replace(day=1) - timedelta(days=1)
|
|
||||||
|
|
||||||
monthly_reservations = Reservation.query.filter(
|
|
||||||
func.date(Reservation.start_time) >= month_start.date(),
|
|
||||||
func.date(Reservation.start_time) <= month_end.date()
|
|
||||||
).count()
|
|
||||||
|
|
||||||
monthly_data.append({
|
|
||||||
'month': month_start.strftime('%Y-%m'),
|
|
||||||
'reservations': monthly_reservations
|
|
||||||
})
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'today_reservations': today_reservations,
|
|
||||||
'week_reservations': week_reservations,
|
|
||||||
'monthly_data': list(reversed(monthly_data))
|
|
||||||
})
|
|
||||||
|
|
||||||
@main_bp.route('/genetic-optimizer')
|
|
||||||
@login_required
|
|
||||||
def genetic_optimizer():
|
|
||||||
"""Serve the genetic algorithm optimization interface"""
|
|
||||||
if current_user.role not in ['ADMIN']:
|
|
||||||
flash('You do not have permission to access the optimization tool.', 'danger')
|
|
||||||
return redirect(url_for('main.dashboard'))
|
|
||||||
|
|
||||||
return render_template('genetic_optimizer.html')
|
|
||||||
|
|
||||||
@main_bp.route('/set_language/<language>')
|
|
||||||
def set_language(language=None):
|
|
||||||
if language not in ['en', 'es']:
|
|
||||||
language = 'en'
|
|
||||||
|
|
||||||
session['language'] = language
|
|
||||||
return redirect(request.referrer or url_for('main.dashboard'))
|
|
||||||
@@ -1,409 +0,0 @@
|
|||||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
|
||||||
from flask_login import login_required, current_user
|
|
||||||
from app import db
|
|
||||||
from app.models.reservation import Reservation, ReservationStatus
|
|
||||||
from app.models.classroom import Classroom
|
|
||||||
from app.models.subject import Subject, Commission
|
|
||||||
from app.forms.reservation import ReservationForm
|
|
||||||
from datetime import datetime, timedelta, date
|
|
||||||
from sqlalchemy import func, or_
|
|
||||||
from sqlalchemy.orm import joinedload
|
|
||||||
|
|
||||||
schedule_bp = Blueprint('schedule', __name__, url_prefix='/schedule')
|
|
||||||
|
|
||||||
@schedule_bp.route('/today')
|
|
||||||
@login_required
|
|
||||||
def today_schedule():
|
|
||||||
"""Show today's schedule"""
|
|
||||||
today = datetime.utcnow().date()
|
|
||||||
today_start = datetime.combine(today, datetime.min.time())
|
|
||||||
today_end = datetime.combine(today, datetime.max.time())
|
|
||||||
|
|
||||||
# Get reservations with eager loading
|
|
||||||
reservations = Reservation.query.options(
|
|
||||||
joinedload(Reservation.classroom),
|
|
||||||
joinedload(Reservation.commission),
|
|
||||||
joinedload(Reservation.user)
|
|
||||||
).filter(
|
|
||||||
Reservation.start_time >= today_start,
|
|
||||||
Reservation.start_time <= today_end,
|
|
||||||
Reservation.status.in_([ReservationStatus.CONFIRMED.value, ReservationStatus.PENDING.value])
|
|
||||||
).order_by(Reservation.start_time).all()
|
|
||||||
|
|
||||||
# Create time blocks for timeline view
|
|
||||||
time_blocks = {}
|
|
||||||
for reservation in reservations:
|
|
||||||
start_hour = reservation.start_time.hour
|
|
||||||
end_hour = reservation.end_time.hour
|
|
||||||
|
|
||||||
for hour in range(max(7, start_hour), min(22, end_hour + 1)):
|
|
||||||
if hour not in time_blocks:
|
|
||||||
time_blocks[hour] = []
|
|
||||||
if reservation not in time_blocks[hour]:
|
|
||||||
time_blocks[hour].append({
|
|
||||||
'reservation': reservation,
|
|
||||||
'classroom': reservation.classroom,
|
|
||||||
'commission': reservation.commission,
|
|
||||||
'user': reservation.user
|
|
||||||
})
|
|
||||||
|
|
||||||
# Calculate stats
|
|
||||||
total_reservations = len(reservations)
|
|
||||||
confirmed = len([r for r in reservations if r.status == ReservationStatus.CONFIRMED.value])
|
|
||||||
pending = len([r for r in reservations if r.status == ReservationStatus.PENDING])
|
|
||||||
|
|
||||||
# Get total classrooms and calculate occupied ones
|
|
||||||
from app.models.classroom import Classroom
|
|
||||||
total_classrooms = Classroom.query.count()
|
|
||||||
occupied_classrooms = len(set(r.classroom_id for r in reservations if r.classroom_id))
|
|
||||||
|
|
||||||
stats = {
|
|
||||||
'total_reservations': total_reservations,
|
|
||||||
'confirmed': confirmed,
|
|
||||||
'pending': pending,
|
|
||||||
'occupied_classrooms': occupied_classrooms,
|
|
||||||
'total_classrooms': total_classrooms
|
|
||||||
}
|
|
||||||
|
|
||||||
return render_template('schedule/today.html',
|
|
||||||
reservations=reservations,
|
|
||||||
time_blocks=time_blocks,
|
|
||||||
stats=stats,
|
|
||||||
today=today)
|
|
||||||
|
|
||||||
@schedule_bp.route('/cancel/<int:id>', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def cancel_reservation(id):
|
|
||||||
"""Cancel a reservation"""
|
|
||||||
try:
|
|
||||||
reservation = Reservation.query.get_or_404(id)
|
|
||||||
|
|
||||||
if not reservation.can_be_cancelled():
|
|
||||||
flash('Reservations can only be cancelled at least 24 hours before start time.', 'error')
|
|
||||||
return redirect(request.referrer)
|
|
||||||
|
|
||||||
reservation.status = ReservationStatus.CANCELLED
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
flash('Reservation cancelled successfully!', 'success')
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
flash(f'Error cancelling reservation: {str(e)}', 'error')
|
|
||||||
|
|
||||||
return redirect(request.referrer)
|
|
||||||
|
|
||||||
@schedule_bp.route('/reservation/<int:id>/view')
|
|
||||||
@login_required
|
|
||||||
def view_reservation(id):
|
|
||||||
"""View reservation details"""
|
|
||||||
try:
|
|
||||||
reservation = Reservation.query.options(
|
|
||||||
joinedload(Reservation.classroom),
|
|
||||||
joinedload(Reservation.commission),
|
|
||||||
joinedload(Reservation.user)
|
|
||||||
).get_or_404(id)
|
|
||||||
|
|
||||||
return render_template('schedule/view.html',
|
|
||||||
reservation=reservation,
|
|
||||||
title='Reservation Details')
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
flash(f'Error loading reservation details: {str(e)}', 'error')
|
|
||||||
return redirect(url_for('schedule.today_schedule'))
|
|
||||||
|
|
||||||
@schedule_bp.route('/calendar')
|
|
||||||
@login_required
|
|
||||||
def calendar_view():
|
|
||||||
"""Show calendar view"""
|
|
||||||
return render_template('schedule/calendar.html', title='Calendar View')
|
|
||||||
|
|
||||||
@schedule_bp.route('/commissions')
|
|
||||||
def commissions_view():
|
|
||||||
subjects = Subject.query.all()
|
|
||||||
|
|
||||||
# Calculamos el total de capacidad sumando todas las comisiones de todas las materias
|
|
||||||
total_capacity = sum(c.max_students for s in subjects for c in s.commissions)
|
|
||||||
# Calculamos el total de comisiones
|
|
||||||
total_commissions = sum(len(s.commissions) for s in subjects)
|
|
||||||
|
|
||||||
return render_template('schedule/commissions.html',
|
|
||||||
subjects=subjects,
|
|
||||||
total_capacity=total_capacity,
|
|
||||||
total_commissions=total_commissions)
|
|
||||||
|
|
||||||
@schedule_bp.route('/add', methods=['GET', 'POST'])
|
|
||||||
@login_required
|
|
||||||
def add_reservation():
|
|
||||||
"""Add new reservation"""
|
|
||||||
from app.models.classroom import Classroom
|
|
||||||
from app.models.subject import Commission
|
|
||||||
|
|
||||||
form = ReservationForm()
|
|
||||||
|
|
||||||
# Get active classrooms for the dropdown
|
|
||||||
classrooms = Classroom.query.filter_by(is_active=True).all()
|
|
||||||
|
|
||||||
# Get active commissions for the dropdown
|
|
||||||
commissions = Commission.query.filter_by(active=True).all()
|
|
||||||
|
|
||||||
# Populate form choices
|
|
||||||
form.classroom_id.choices = [(c.id, f'{c.code_display} - {c.name} (Cap: {c.capacity})') for c in classrooms]
|
|
||||||
form.commission_id.choices = [(c.id, f'{c.get_full_code()} - {c.subject.name if c.subject else "No Subject"} (Students: {c.max_students})') for c in commissions]
|
|
||||||
|
|
||||||
if request.method == 'POST':
|
|
||||||
if form.validate_on_submit():
|
|
||||||
try:
|
|
||||||
# Check for existing reservations at the same time
|
|
||||||
from app.models.reservation import ReservationStatus
|
|
||||||
existing_reservation = Reservation.query.filter_by(
|
|
||||||
classroom_id=form.classroom_id.data,
|
|
||||||
status=ReservationStatus.CONFIRMED.value
|
|
||||||
).filter(
|
|
||||||
Reservation.start_time < form.end_time.data,
|
|
||||||
Reservation.end_time > form.start_time.data
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if existing_reservation:
|
|
||||||
flash('This classroom is already booked for the selected time slot.', 'danger')
|
|
||||||
return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions)
|
|
||||||
|
|
||||||
# Create new reservation
|
|
||||||
reservation = Reservation(
|
|
||||||
classroom_id=form.classroom_id.data,
|
|
||||||
commission_id=form.commission_id.data,
|
|
||||||
user_id=current_user.id,
|
|
||||||
start_time=form.start_time.data,
|
|
||||||
end_time=form.end_time.data,
|
|
||||||
purpose=form.purpose.data,
|
|
||||||
notes=form.notes.data,
|
|
||||||
status=ReservationStatus.PENDING.value,
|
|
||||||
expected_attendees=form.expected_attendees.data
|
|
||||||
)
|
|
||||||
|
|
||||||
db.session.add(reservation)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
flash('Reservation request submitted successfully! It will be reviewed by an administrator.', 'success')
|
|
||||||
return redirect(url_for('schedule.list_reservations'))
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
current_app.logger.error(f"Error creating reservation: {str(e)}")
|
|
||||||
flash(f'Error creating reservation: {str(e)}', 'danger')
|
|
||||||
|
|
||||||
return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions)
|
|
||||||
|
|
||||||
@schedule_bp.route('/list')
|
|
||||||
@login_required
|
|
||||||
def list_reservations():
|
|
||||||
"""List all reservations"""
|
|
||||||
return redirect(url_for('schedule.today_schedule'))
|
|
||||||
|
|
||||||
|
|
||||||
@schedule_bp.route('/reservation/<int:id>/confirm', methods=['GET', 'POST'])
|
|
||||||
@login_required
|
|
||||||
def confirm_reservation(id):
|
|
||||||
"""Confirm a reservation (Handles both GET from link and POST from form)"""
|
|
||||||
try:
|
|
||||||
reservation = Reservation.query.get_or_404(id)
|
|
||||||
|
|
||||||
# Validación de seguridad: Solo confirmar si está pendiente
|
|
||||||
if reservation.status != ReservationStatus.PENDING.value:
|
|
||||||
flash('Only pending reservations can be confirmed.', 'warning')
|
|
||||||
return redirect(url_for('schedule.today_schedule'))
|
|
||||||
|
|
||||||
reservation.status = ReservationStatus.CONFIRMED.value
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
flash(
|
|
||||||
f'Reservation for {reservation.commission.code if reservation.commission else "Class"} confirmed successfully!',
|
|
||||||
'success')
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
flash(f'Error confirming reservation: {str(e)}', 'error')
|
|
||||||
|
|
||||||
# Redirigir a la página desde la que venía el usuario o al inicio de hoy
|
|
||||||
return redirect(request.referrer or url_for('schedule.today_schedule'))
|
|
||||||
|
|
||||||
# API endpoints
|
|
||||||
@schedule_bp.route('/api/classroom-availability')
|
|
||||||
@login_required
|
|
||||||
def classroom_availability_api():
|
|
||||||
"""Get classroom availability for a specific date/time"""
|
|
||||||
try:
|
|
||||||
date_str = request.args.get('date')
|
|
||||||
start_time_str = request.args.get('start_time')
|
|
||||||
end_time_str = request.args.get('end_time')
|
|
||||||
max_students = request.args.get('max_students', type=int)
|
|
||||||
|
|
||||||
if not all([date_str, start_time_str, end_time_str]):
|
|
||||||
return jsonify({'error': 'Missing required parameters: date, start_time, end_time'}), 400
|
|
||||||
|
|
||||||
# Parse datetime strings
|
|
||||||
from datetime import datetime
|
|
||||||
start_time = datetime.strptime(f"{date_str} {start_time_str}", "%Y-%m-%d %H:%M")
|
|
||||||
end_time = datetime.strptime(f"{date_str} {end_time_str}", "%Y-%m-%d %H:%M")
|
|
||||||
|
|
||||||
# Get availability status
|
|
||||||
from app.models.classroom import Classroom
|
|
||||||
availability = Classroom.get_availability_status(start_time, end_time)
|
|
||||||
|
|
||||||
# Get best classroom recommendation if max_students provided
|
|
||||||
best_recommendation = None
|
|
||||||
if max_students:
|
|
||||||
best_classrooms = Classroom.find_best_classroom(max_students, start_time, end_time)
|
|
||||||
if best_classrooms:
|
|
||||||
best_recommendation = best_classrooms[0] # Most efficient one
|
|
||||||
|
|
||||||
# Format response
|
|
||||||
response = {
|
|
||||||
'availability': {
|
|
||||||
'available': [
|
|
||||||
{
|
|
||||||
'id': item['classroom'].id,
|
|
||||||
'code': item['classroom'].code_display,
|
|
||||||
'name': item['classroom'].name,
|
|
||||||
'capacity': item['classroom'].capacity,
|
|
||||||
'location': item['classroom'].get_full_location()
|
|
||||||
}
|
|
||||||
for item in availability['available']
|
|
||||||
],
|
|
||||||
'occupied': [
|
|
||||||
{
|
|
||||||
'id': item['classroom'].id,
|
|
||||||
'code': item['classroom'].code_display,
|
|
||||||
'name': item['classroom'].name,
|
|
||||||
'capacity': item['classroom'].capacity,
|
|
||||||
'location': item['classroom'].get_full_location(),
|
|
||||||
'conflicts': [
|
|
||||||
{
|
|
||||||
'start_time': conflict.start_time.strftime('%H:%M'),
|
|
||||||
'end_time': conflict.end_time.strftime('%H:%M'),
|
|
||||||
'commission': conflict.commission.get_full_code() if conflict.commission else 'Unknown'
|
|
||||||
}
|
|
||||||
for conflict in item['conflicts']
|
|
||||||
]
|
|
||||||
}
|
|
||||||
for item in availability['occupied']
|
|
||||||
],
|
|
||||||
'stats': {
|
|
||||||
'total_available': availability['total_available'],
|
|
||||||
'total_occupied': availability['total_occupied']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if best_recommendation:
|
|
||||||
response['recommendation'] = {
|
|
||||||
'classroom': {
|
|
||||||
'id': best_recommendation['classroom'].id,
|
|
||||||
'code': best_recommendation['classroom'].code_display,
|
|
||||||
'name': best_recommendation['classroom'].name,
|
|
||||||
'capacity': best_recommendation['classroom'].capacity,
|
|
||||||
'location': best_recommendation['classroom'].get_full_location()
|
|
||||||
},
|
|
||||||
'efficiency': best_recommendation['efficiency'],
|
|
||||||
'wasted_capacity': best_recommendation['wasted_capacity'],
|
|
||||||
'reason': 'Best match based on capacity and availability'
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonify(response)
|
|
||||||
|
|
||||||
except ValueError as e:
|
|
||||||
return jsonify({'error': f'Invalid date/time format: {str(e)}'}), 400
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({'error': str(e)}), 500
|
|
||||||
|
|
||||||
@schedule_bp.route('/calendar-data')
|
|
||||||
@login_required
|
|
||||||
def calendar_data():
|
|
||||||
"""Get calendar events data for FullCalendar"""
|
|
||||||
try:
|
|
||||||
# Get date range from query parameters
|
|
||||||
start = request.args.get('start')
|
|
||||||
end = request.args.get('end')
|
|
||||||
|
|
||||||
if start and end:
|
|
||||||
from datetime import datetime
|
|
||||||
start_date = datetime.fromisoformat(start)
|
|
||||||
end_date = datetime.fromisoformat(end)
|
|
||||||
else:
|
|
||||||
# Default to current month
|
|
||||||
today = date.today()
|
|
||||||
start_date = datetime.combine(today.replace(day=1), datetime.min.time())
|
|
||||||
next_month = today.replace(day=28) + timedelta(days=4) # Next month
|
|
||||||
end_date = datetime.combine(next_month.replace(day=1), datetime.max.time())
|
|
||||||
|
|
||||||
# Get reservations in the date range
|
|
||||||
reservations = Reservation.query.options(
|
|
||||||
joinedload(Reservation.classroom),
|
|
||||||
joinedload(Reservation.commission)
|
|
||||||
).filter(
|
|
||||||
Reservation.start_time >= start_date,
|
|
||||||
Reservation.start_time <= end_date,
|
|
||||||
Reservation.status.in_([ReservationStatus.CONFIRMED.value, ReservationStatus.PENDING.value])
|
|
||||||
).order_by(Reservation.start_time).all()
|
|
||||||
|
|
||||||
events = []
|
|
||||||
for reservation in reservations:
|
|
||||||
events.append({
|
|
||||||
'id': reservation.id,
|
|
||||||
'title': f"{reservation.commission.get_full_code() if reservation.commission else 'No Commission'} - {reservation.classroom.code_display}",
|
|
||||||
'start': reservation.start_time.isoformat(),
|
|
||||||
'end': reservation.end_time.isoformat(),
|
|
||||||
'url': url_for('schedule.view_reservation', id=reservation.id) if reservation.id else None,
|
|
||||||
'className': f'fc-event-{reservation.status.value.lower()}',
|
|
||||||
'extendedProps': {
|
|
||||||
'classroom': reservation.classroom.code_display if reservation.classroom else 'Unknown',
|
|
||||||
'commission': reservation.commission.get_full_code() if reservation.commission else 'No Commission',
|
|
||||||
'purpose': reservation.purpose or 'No purpose',
|
|
||||||
'attendees': reservation.expected_attendees,
|
|
||||||
'status': reservation.status.value,
|
|
||||||
'notes': reservation.notes or ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return jsonify(events)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({'error': str(e)}), 500
|
|
||||||
|
|
||||||
@schedule_bp.route('/api/today-events')
|
|
||||||
@login_required
|
|
||||||
def api_today_events():
|
|
||||||
"""Get today's events as JSON"""
|
|
||||||
try:
|
|
||||||
today_start = datetime.combine(date.today(), datetime.min.time())
|
|
||||||
today_end = datetime.combine(date.today(), datetime.max.time())
|
|
||||||
|
|
||||||
reservations = Reservation.query.options(
|
|
||||||
joinedload(Reservation.classroom),
|
|
||||||
joinedload(Reservation.commission)
|
|
||||||
).filter(
|
|
||||||
Reservation.start_time >= today_start,
|
|
||||||
Reservation.start_time <= today_end,
|
|
||||||
Reservation.status == ReservationStatus.CONFIRMED.value
|
|
||||||
).order_by(Reservation.start_time).all()
|
|
||||||
|
|
||||||
events = []
|
|
||||||
for reservation in reservations:
|
|
||||||
events.append({
|
|
||||||
'id': reservation.id,
|
|
||||||
'title': f"{reservation.commission.get_full_code() if reservation.commission else 'No Commission'} - {reservation.classroom.code_display}",
|
|
||||||
'start': reservation.start_time.isoformat(),
|
|
||||||
'end': reservation.end_time.isoformat(),
|
|
||||||
'url': url_for('schedule.view_reservation', id=reservation.id) if reservation.id else None,
|
|
||||||
'extendedProps': {
|
|
||||||
'classroom': reservation.classroom.code_display if reservation.classroom else 'Unknown',
|
|
||||||
'purpose': reservation.purpose or 'No purpose',
|
|
||||||
'attendees': reservation.expected_attendees,
|
|
||||||
'status': reservation.status.value
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return jsonify({'events': events})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({'error': str(e)}), 500
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
/* Custom CSS for Edu-Space Admin */
|
|
||||||
|
|
||||||
/* Dark Theme Enhancements */
|
|
||||||
body {
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Card Styles */
|
|
||||||
.card {
|
|
||||||
border-radius: 15px;
|
|
||||||
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Navigation */
|
|
||||||
.navbar {
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-brand {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Button Styles */
|
|
||||||
.btn {
|
|
||||||
border-radius: 8px;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: all 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background: linear-gradient(135deg, #5568d8 0%, #654198 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form Styles */
|
|
||||||
.form-control, .form-select {
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #444;
|
|
||||||
background-color: #2a2a2a;
|
|
||||||
color: #fff;
|
|
||||||
transition: border-color 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control:focus, .form-select:focus {
|
|
||||||
border-color: #667eea;
|
|
||||||
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
|
|
||||||
background-color: #2a2a2a;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table:hover tbody tr:hover {
|
|
||||||
background-color: rgba(102, 126, 234, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Badge Styles */
|
|
||||||
.badge {
|
|
||||||
font-size: 0.75em;
|
|
||||||
padding: 0.5em 0.8em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status Badge Colors */
|
|
||||||
.bg-confirmed-custom {
|
|
||||||
background-color: #28a745;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-pending-custom {
|
|
||||||
background-color: #ffc107;
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-cancelled-custom {
|
|
||||||
background-color: #dc3545;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading Spinner */
|
|
||||||
.loading-spinner {
|
|
||||||
display: inline-block;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
|
||||||
border-radius: 50%;
|
|
||||||
border-top-color: #667eea;
|
|
||||||
animation: spin 1s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Calendar Customizations */
|
|
||||||
.fc-event {
|
|
||||||
border-radius: 6px;
|
|
||||||
border: none !important;
|
|
||||||
padding: 4px 8px !important;
|
|
||||||
font-size: 12px !important;
|
|
||||||
margin: 1px 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fc-day-sat, .fc-day-sun {
|
|
||||||
background-color: rgba(255, 255, 255, 0.02) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dashboard Stat Cards */
|
|
||||||
.stat-card {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: white;
|
|
||||||
border-radius: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card-primary { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
|
|
||||||
.stat-card-success { background: linear-gradient(135deg, #28a745 0%, #20c997 100%); }
|
|
||||||
.stat-card-info { background: linear-gradient(135deg, #17a2b8 0%, #138496 100%); }
|
|
||||||
.stat-card-warning { background: linear-gradient(135deg, #ffc107 0%, #fd7e14 100%); }
|
|
||||||
|
|
||||||
/* Quick Action Buttons */
|
|
||||||
.quick-action-btn {
|
|
||||||
text-align: left;
|
|
||||||
padding: 12px 16px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
transition: all 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-action-btn:hover {
|
|
||||||
transform: translateX(5px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Empty State Styles */
|
|
||||||
.empty-state {
|
|
||||||
text-align: center;
|
|
||||||
padding: 3rem 1rem;
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state i {
|
|
||||||
font-size: 4rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Notification Toastenhancements */
|
|
||||||
.toast {
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pagination Styles */
|
|
||||||
.pagination .page-link {
|
|
||||||
border-radius: 8px;
|
|
||||||
margin: 0 2px;
|
|
||||||
border: 1px solid #444;
|
|
||||||
background-color: #2a2a2a;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination .page-link:hover {
|
|
||||||
background-color: #667eea;
|
|
||||||
border-color: #667eea;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination .page-item.active .page-link {
|
|
||||||
background-color: #667eea;
|
|
||||||
border-color: #667eea;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile Responsiveness */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.card {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-group {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-group .btn {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Print Styles */
|
|
||||||
@media print {
|
|
||||||
.navbar, .btn, .pagination, .dropdown {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
border: 1px solid #ddd !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
break-inside: avoid;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: white !important;
|
|
||||||
color: black !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Login - Edu-Space Admin</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.login-card {
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
border-radius: 20px;
|
|
||||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
|
||||||
max-width: 450px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.logo-section {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
border-radius: 20px 20px 0 0;
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="login-card">
|
|
||||||
<div class="logo-section">
|
|
||||||
<i class="bi bi-building" style="font-size: 3rem;"></i>
|
|
||||||
<h3 class="mt-3">Edu-Space Admin</h3>
|
|
||||||
<p class="mb-0">Classroom Management System</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4">
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
|
||||||
{% if messages %}
|
|
||||||
{% for category, message in messages %}
|
|
||||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
|
|
||||||
{{ message }}
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
<form method="POST">
|
|
||||||
{{ form.hidden_tag() }}
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="email" class="form-label">{{ form.email.label.text }}</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<span class="input-group-text"><i class="bi bi-envelope"></i></span>
|
|
||||||
{{ form.email(class="form-control", placeholder="Enter your email") }}
|
|
||||||
</div>
|
|
||||||
{% for error in form.email.errors %}
|
|
||||||
<div class="text-danger small mt-1">{{ error }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="password" class="form-label">{{ form.password.label.text }}</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<span class="input-group-text"><i class="bi bi-lock"></i></span>
|
|
||||||
{{ form.password(class="form-control", placeholder="Enter your password") }}
|
|
||||||
</div>
|
|
||||||
{% for error in form.password.errors %}
|
|
||||||
<div class="text-danger small mt-1">{{ error }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3 form-check">
|
|
||||||
{{ form.remember_me(class="form-check-input") }}
|
|
||||||
{{ form.remember_me.label(class="form-check-label") }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-grid">
|
|
||||||
{{ form.submit(class="btn btn-primary btn-lg") }}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="text-center mt-3">
|
|
||||||
<small class="text-muted">
|
|
||||||
<i class="bi bi-shield-lock"></i>
|
|
||||||
Secure Login • All access is logged
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="{{ session.language or request.accept_languages.best_match(['es', 'en']) or 'en' }}" data-bs-theme="dark">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>{% block title %}{% trans %}Edu-Space Admin{% endtrans %}{% endblock %}</title>
|
|
||||||
|
|
||||||
<!-- Bootstrap 5 CSS -->
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<!-- Bootstrap Icons -->
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
|
|
||||||
<!-- Custom CSS -->
|
|
||||||
<link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
|
|
||||||
|
|
||||||
{% block extra_css %}{% endblock %}
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
{% if current_user.is_authenticated %}
|
|
||||||
<!-- Navigation -->
|
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary fixed-top">
|
|
||||||
<div class="container-fluid">
|
|
||||||
<a class="navbar-brand" href="{{ url_for('main.dashboard') }}">
|
|
||||||
<i class="bi bi-building"></i> Edu-Space Admin
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
|
||||||
<ul class="navbar-nav me-auto">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="{{ url_for('main.dashboard') }}">
|
|
||||||
<i class="bi bi-speedometer2"></i> Dashboard
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item dropdown">
|
|
||||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
|
||||||
<i class="bi bi-door-open"></i> Classrooms
|
|
||||||
</a>
|
|
||||||
<ul class="dropdown-menu">
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms') }}">
|
|
||||||
<i class="bi bi-list"></i> All Classrooms
|
|
||||||
</a></li>
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.add_classroom') }}">
|
|
||||||
<i class="bi bi-plus-circle"></i> Add Classroom
|
|
||||||
</a></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item dropdown">
|
|
||||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
|
||||||
<i class="bi bi-calendar3"></i> Schedule
|
|
||||||
</a>
|
|
||||||
<ul class="dropdown-menu">
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('schedule.calendar_view') }}">
|
|
||||||
<i class="bi bi-calendar-week"></i> Calendar View
|
|
||||||
</a></li>
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}">
|
|
||||||
<i class="bi bi-list-check"></i> All Reservations
|
|
||||||
</a></li>
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('schedule.add_reservation') }}">
|
|
||||||
<i class="bi bi-plus-circle"></i> New Reservation
|
|
||||||
</a></li>
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('schedule.today_schedule') }}">
|
|
||||||
<i class="bi bi-calendar-today"></i> Today's Schedule
|
|
||||||
</a></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<ul class="navbar-nav">
|
|
||||||
<li class="nav-item dropdown">
|
|
||||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
|
||||||
<i class="bi bi-person-circle"></i> {{ current_user.first_name }} {{ current_user.last_name }}
|
|
||||||
</a>
|
|
||||||
<ul class="dropdown-menu dropdown-menu-end">
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}">
|
|
||||||
<i class="bi bi-person"></i> Profile
|
|
||||||
</a></li>
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('auth.change_password') }}">
|
|
||||||
<i class="bi bi-key"></i> Change Password
|
|
||||||
</a></li>
|
|
||||||
<li><hr class="dropdown-divider"></li>
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}">
|
|
||||||
<i class="bi bi-box-arrow-right"></i> Logout
|
|
||||||
</a></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<ul class="navbar-nav">
|
|
||||||
<!-- Language Switcher -->
|
|
||||||
<li class="nav-item">
|
|
||||||
{% include 'partials/language_switcher.html' %}
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li class="nav-item dropdown">
|
|
||||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
|
||||||
<i class="bi bi-person-circle"></i> {{ current_user.first_name }} {{ current_user.last_name }}
|
|
||||||
</a>
|
|
||||||
<ul class="dropdown-menu dropdown-menu-end">
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}">
|
|
||||||
<i class="bi bi-person"></i> Profile
|
|
||||||
</a></li>
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('auth.change_password') }}">
|
|
||||||
<i class="bi bi-key"></i> Change Password
|
|
||||||
</a></li>
|
|
||||||
<li><hr class="dropdown-divider"></li>
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}">
|
|
||||||
<i class="bi bi-box-arrow-right"></i> Logout
|
|
||||||
</a></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Flash Messages -->
|
|
||||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
|
||||||
{% if messages %}
|
|
||||||
{% for category, message in messages %}
|
|
||||||
<div class="toast align-items-center text-white bg-{{ 'danger' if category == 'error' else 'success' if category == 'success' else 'info' if category == 'info' else 'warning' }} border-0" role="alert">
|
|
||||||
<div class="d-flex">
|
|
||||||
<div class="toast-body">
|
|
||||||
{{ message }}
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main Content -->
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
|
|
||||||
<!-- Bootstrap JS -->
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
<!-- jQuery -->
|
|
||||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
|
||||||
<!-- Custom JS -->
|
|
||||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
|
||||||
|
|
||||||
<!-- Show flash messages -->
|
|
||||||
<script>
|
|
||||||
$(document).ready(function() {
|
|
||||||
$('.toast').toast('show');
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{% block extra_js %}{% endblock %}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Add Classroom - Edu-Space Admin{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container-fluid pt-5 mt-4">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<h1 class="h3">
|
|
||||||
<i class="bi bi-plus-circle text-primary"></i> Add Classroom
|
|
||||||
</h1>
|
|
||||||
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
|
|
||||||
<i class="bi bi-arrow-left"></i> Back to Classrooms
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-8 mx-auto">
|
|
||||||
<div class="card border-0 shadow-sm">
|
|
||||||
<div class="card-header bg-white border-0">
|
|
||||||
<h5 class="mb-0">
|
|
||||||
<i class="bi bi-door-open text-primary"></i> Classroom Information
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<form method="POST" id="classroom-form" novalidate>
|
|
||||||
{{ form.hidden_tag() }}
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-4 mb-3">
|
|
||||||
{{ form.building.label(class="form-label") }}
|
|
||||||
{{ form.building(class="form-control", placeholder="A") }}
|
|
||||||
{% if form.building.errors %}
|
|
||||||
<div class="text-danger small mt-1">
|
|
||||||
{{ form.building.errors[0] }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-4 mb-3">
|
|
||||||
{{ form.room_number.label(class="form-label") }}
|
|
||||||
{{ form.room_number(class="form-control", placeholder="101") }}
|
|
||||||
{% if form.room_number.errors %}
|
|
||||||
<div class="text-danger small mt-1">
|
|
||||||
{{ form.room_number.errors[0] }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-4 mb-3">
|
|
||||||
{{ form.floor.label(class="form-label") }}
|
|
||||||
{{ form.floor(class="form-control") }}
|
|
||||||
{% if form.floor.errors %}
|
|
||||||
<div class="text-danger small mt-1">
|
|
||||||
{{ form.floor.errors[0] }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
{{ form.capacity.label(class="form-label") }}
|
|
||||||
{{ form.capacity(class="form-control", placeholder="30") }}
|
|
||||||
{% if form.capacity.errors %}
|
|
||||||
<div class="text-danger small mt-1">
|
|
||||||
{{ form.capacity.errors[0] }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
{{ form.is_active.label(class="form-check-label") }}
|
|
||||||
<div class="form-check">
|
|
||||||
{{ form.is_active(class="form-check-input") }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
{{ form.description.label(class="form-label") }}
|
|
||||||
{{ form.description(class="form-control", rows="3", placeholder="Optional: Add any additional details about this classroom...") }}
|
|
||||||
{% if form.description.errors %}
|
|
||||||
<div class="text-danger small mt-1">
|
|
||||||
{{ form.description.errors[0] }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Preview Section -->
|
|
||||||
<div class="alert alert-info">
|
|
||||||
<h6 class="alert-heading">
|
|
||||||
<i class="bi bi-info-circle"></i> Preview
|
|
||||||
</h6>
|
|
||||||
<div id="classroom-preview">
|
|
||||||
<p class="mb-0">Fill in the form to see a preview of the classroom code:
|
|
||||||
<span class="fw-bold" id="preview-code">-</span>
|
|
||||||
</p>
|
|
||||||
<p class="mb-0">Full location:
|
|
||||||
<span class="fw-bold" id="preview-location">-</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-flex justify-content-between">
|
|
||||||
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
|
|
||||||
<i class="bi bi-x-circle"></i> Cancel
|
|
||||||
</a>
|
|
||||||
{{ form.submit(class="btn btn-primary") }}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Resources Section -->
|
|
||||||
<div class="row mt-4">
|
|
||||||
<div class="col-lg-8 mx-auto">
|
|
||||||
<div class="card border-0 shadow-sm">
|
|
||||||
<div class="card-header bg-white border-0">
|
|
||||||
<h5 class="mb-0">
|
|
||||||
<i class="bi bi-tools text-warning"></i> Resources (Optional)
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p class="text-muted">You can add classroom resources after creating the classroom.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
|
||||||
$(document).ready(function() {
|
|
||||||
function updatePreview() {
|
|
||||||
const building = $('#building').val();
|
|
||||||
const roomNumber = $('#room_number').val();
|
|
||||||
const floor = $('#floor').val();
|
|
||||||
|
|
||||||
if (building && roomNumber && floor) {
|
|
||||||
$('#preview-code').text(`${building}-${roomNumber}`);
|
|
||||||
$('#preview-location').text(`${building} - Floor ${floor}`);
|
|
||||||
} else {
|
|
||||||
$('#preview-code').text('-');
|
|
||||||
$('#preview-location').text('-');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update preview when inputs change
|
|
||||||
$('#building, #room_number, #floor').on('input', updatePreview);
|
|
||||||
|
|
||||||
// Initialize preview
|
|
||||||
updatePreview();
|
|
||||||
|
|
||||||
// Form validation
|
|
||||||
$('#classroom-form').on('submit', function(e) {
|
|
||||||
let isValid = true;
|
|
||||||
|
|
||||||
// Required fields validation
|
|
||||||
if (!$('#building').val().trim()) {
|
|
||||||
$('#building').addClass('is-invalid');
|
|
||||||
isValid = false;
|
|
||||||
} else {
|
|
||||||
$('#building').removeClass('is-invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$('#room_number').val().trim()) {
|
|
||||||
$('#room_number').addClass('is-invalid');
|
|
||||||
isValid = false;
|
|
||||||
} else {
|
|
||||||
$('#room_number').removeClass('is-invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$('#floor').val()) {
|
|
||||||
$('#floor').addClass('is-invalid');
|
|
||||||
isValid = false;
|
|
||||||
} else {
|
|
||||||
$('#floor').removeClass('is-invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$('#capacity').val() || $('#capacity').val() < 1) {
|
|
||||||
$('#capacity').addClass('is-invalid');
|
|
||||||
isValid = false;
|
|
||||||
} else {
|
|
||||||
$('#capacity').removeClass('is-invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
return isValid;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Classrooms - Edu-Space Admin{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container-fluid pt-5 mt-4">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<h1 class="h3">
|
|
||||||
<i class="bi bi-door-open text-primary"></i> Classrooms
|
|
||||||
</h1>
|
|
||||||
<div>
|
|
||||||
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary">
|
|
||||||
<i class="bi bi-plus-circle"></i> Add Classroom
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filters -->
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card border-0 shadow-sm">
|
|
||||||
<div class="card-body">
|
|
||||||
<form method="GET" class="row g-3">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<input type="text" name="search" class="form-control"
|
|
||||||
placeholder="Search by building, room, description..."
|
|
||||||
value="{{ search }}">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" name="active_only"
|
|
||||||
id="activeOnly" {% if active_only %}checked{% endif %}>
|
|
||||||
<label class="form-check-label" for="activeOnly">
|
|
||||||
Show active classrooms only
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<button type="submit" class="btn btn-outline-primary">
|
|
||||||
<i class="bi bi-search"></i> Search
|
|
||||||
</button>
|
|
||||||
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
|
|
||||||
<i class="bi bi-x-circle"></i> Clear
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Classrooms Grid -->
|
|
||||||
{% if classrooms and classrooms.items %}
|
|
||||||
<div class="row mt-4">
|
|
||||||
{% for classroom in classrooms.items %}
|
|
||||||
<div class="col-lg-4 col-md-6 mb-4">
|
|
||||||
<div class="card border-0 shadow-sm h-100">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
|
||||||
<div>
|
|
||||||
<h5 class="card-title mb-1">
|
|
||||||
{{ classroom.code }}
|
|
||||||
</h5>
|
|
||||||
<p class="text-muted small mb-2">
|
|
||||||
<i class="bi bi-geo-alt"></i> {{ classroom.get_full_location() }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span class="badge {% if classroom.is_active %}bg-success{% else %}bg-secondary{% endif %}">
|
|
||||||
{% if classroom.is_active %}Active{% else %}Inactive{% endif %}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row small">
|
|
||||||
<div class="col-6">
|
|
||||||
<span class="text-muted">Capacity:</span>
|
|
||||||
<strong>{{ classroom.capacity }}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<span class="text-muted">Building:</span>
|
|
||||||
<strong>{{ classroom.building }}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if classroom.description %}
|
|
||||||
<p class="card-text small text-muted mt-2">
|
|
||||||
{{ classroom.description[:100] }}{% if classroom.description|length > 100 %}...{% endif %}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Resources indicator -->
|
|
||||||
<div class="mt-2">
|
|
||||||
<small class="text-muted">
|
|
||||||
<i class="bi bi-tools"></i>
|
|
||||||
{% if classroom.resources %}
|
|
||||||
{{ classroom.resources|length }} resources
|
|
||||||
{% else %}
|
|
||||||
No resources registered
|
|
||||||
{% endif %}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-footer bg-white border-0">
|
|
||||||
<div class="d-flex justify-content-between">
|
|
||||||
<div>
|
|
||||||
<a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}"
|
|
||||||
class="btn btn-sm btn-outline-primary">
|
|
||||||
<i class="bi bi-eye"></i> View
|
|
||||||
</a>
|
|
||||||
<a href="{{ url_for('classrooms.edit_classroom', id=classroom.id) }}"
|
|
||||||
class="btn btn-sm btn-outline-secondary">
|
|
||||||
<i class="bi bi-pencil"></i> Edit
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<form method="POST" action="{{ url_for('classrooms.toggle_classroom', id=classroom.id) }}"
|
|
||||||
class="d-inline">
|
|
||||||
<button type="submit" class="btn btn-sm
|
|
||||||
{% if classroom.is_active %}btn-outline-warning{% else %}btn-outline-success{% endif %}">
|
|
||||||
{% if classroom.is_active %}
|
|
||||||
<i class="bi bi-pause-circle"></i> Deactivate
|
|
||||||
{% else %}
|
|
||||||
<i class="bi bi-play-circle"></i> Activate
|
|
||||||
{% endif %}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pagination -->
|
|
||||||
{% if classrooms.pages > 1 %}
|
|
||||||
<nav aria-label="Classroom pagination">
|
|
||||||
<ul class="pagination justify-content-center">
|
|
||||||
{% if classrooms.has_prev %}
|
|
||||||
<li class="page-item">
|
|
||||||
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=classrooms.prev_num, search=search, active_only=active_only) }}">
|
|
||||||
Previous
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% for page_num in classrooms.iter_pages() %}
|
|
||||||
{% if page_num %}
|
|
||||||
{% if page_num != classrooms.page %}
|
|
||||||
<li class="page-item">
|
|
||||||
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=page_num, search=search, active_only=active_only) }}">
|
|
||||||
{{ page_num }}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% else %}
|
|
||||||
<li class="page-item active">
|
|
||||||
<span class="page-link">{{ page_num }}</span>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
<li class="page-item disabled">
|
|
||||||
<span class="page-link">…</span>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
{% if classrooms.has_next %}
|
|
||||||
<li class="page-item">
|
|
||||||
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=classrooms.next_num, search=search, active_only=active_only) }}">
|
|
||||||
Next
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% else %}
|
|
||||||
<div class="row mt-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="text-center py-5">
|
|
||||||
<i class="bi bi-door-open text-muted" style="font-size: 4rem;"></i>
|
|
||||||
<h4 class="mt-3 text-muted">No classrooms found</h4>
|
|
||||||
<p class="text-muted">
|
|
||||||
{% if search %}
|
|
||||||
No classrooms match your search criteria.
|
|
||||||
{% else %}
|
|
||||||
No classrooms have been registered yet.
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary">
|
|
||||||
<i class="bi bi-plus-circle"></i> Add First Classroom
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}{% trans %}Dashboard - Edu-Space Admin{% endtrans %}{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
{% if current_user.is_authenticated %}
|
|
||||||
<div class="container-fluid pt-5 mt-4">
|
|
||||||
{% endif %}
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<h1 class="h3 mb-0">
|
|
||||||
<i class="bi bi-speedometer2 text-primary"></i> {% trans %}Dashboard{% endtrans %}
|
|
||||||
<small class="text-muted ms-2">{% trans %}Overview{% endtrans %}</small>
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Statistics Cards -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<div class="card border-0 shadow-sm">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<h6 class="text-muted mb-2">{% trans %}Total Classrooms{% endtrans %}</h6>
|
|
||||||
<h2 class="mb-0">{{ stats.total_classrooms }}</h2>
|
|
||||||
</div>
|
|
||||||
<div class="bg-primary bg-opacity-10 rounded-circle p-3">
|
|
||||||
<i class="bi bi-door-open text-primary fs-4"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<div class="card border-0 shadow-sm">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<h6 class="text-muted mb-2">{% trans %}Total Subjects{% endtrans %}</h6>
|
|
||||||
<h2 class="mb-0">{{ stats.total_subjects }}</h2>
|
|
||||||
</div>
|
|
||||||
<div class="bg-success bg-opacity-10 rounded-circle p-3">
|
|
||||||
<i class="bi bi-book text-success fs-4"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<div class="card border-0 shadow-sm">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<h6 class="text-muted mb-2">{% trans %}Today's Reservations{% endtrans %}</h6>
|
|
||||||
<h2 class="mb-0">{{ stats.today_reservations }}</h2>
|
|
||||||
</div>
|
|
||||||
<div class="bg-info bg-opacity-10 rounded-circle p-3">
|
|
||||||
<i class="bi bi-calendar-today text-info fs-4"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<div class="card border-0 shadow-sm">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<h6 class="text-muted mb-2">{% trans %}This Week{% endtrans %}</h6>
|
|
||||||
<h2 class="mb-0">{{ stats.week_reservations }}</h2>
|
|
||||||
</div>
|
|
||||||
<div class="bg-warning bg-opacity-10 rounded-circle p-3">
|
|
||||||
<i class="bi bi-calendar-week text-warning fs-4"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<!-- Monthly Chart -->
|
|
||||||
<div class="col-md-8 mb-4">
|
|
||||||
<div class="card border-0 shadow-sm">
|
|
||||||
<div class="card-header bg-white border-0">
|
|
||||||
<h5 class="mb-0">
|
|
||||||
<i class="bi bi-graph-up text-primary"></i> Monthly Reservations
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<canvas id="monthlyChart" height="100"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Quick Actions -->
|
|
||||||
<div class="col-md-4 mb-4">
|
|
||||||
<div class="card border-0 shadow-sm">
|
|
||||||
<div class="card-header bg-white border-0">
|
|
||||||
<h5 class="mb-0">
|
|
||||||
<i class="bi bi-lightning text-warning"></i> Quick Actions
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-grid gap-2">
|
|
||||||
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-outline-primary">
|
|
||||||
<i class="bi bi-plus-circle"></i> Add Classroom
|
|
||||||
</a>
|
|
||||||
<a href="{{ url_for('schedule.commissions_view') }}" class="btn btn-outline-warning">
|
|
||||||
<i class="bi bi-people"></i> Manage Commissions
|
|
||||||
</a>
|
|
||||||
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-outline-success">
|
|
||||||
<i class="bi bi-calendar-plus"></i> New Reservation
|
|
||||||
</a>
|
|
||||||
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-info">
|
|
||||||
<i class="bi bi-calendar-today"></i> Today's Schedule
|
|
||||||
</a>
|
|
||||||
<a href="{{ url_for('genetic_algorithm_web.genetic_optimizer') }}" class="btn btn-info text-white">
|
|
||||||
<i class="bi bi-cpu"></i> AI Room Optimizer
|
|
||||||
</a>
|
|
||||||
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-secondary">
|
|
||||||
<i class="bi bi-calendar3"></i> View Calendar
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Upcoming Reservations -->
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card border-0 shadow-sm">
|
|
||||||
<div class="card-header bg-white border-0">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<h5 class="mb-0">
|
|
||||||
<i class="bi bi-clock-history text-info"></i> Upcoming Reservations (Next 7 Days)
|
|
||||||
</h5>
|
|
||||||
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-sm btn-outline-primary">
|
|
||||||
View All
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
{% if upcoming_reservations %}
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Date & Time</th>
|
|
||||||
<th>Classroom</th>
|
|
||||||
<th>Subject</th>
|
|
||||||
<th>Teacher</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for reservation in upcoming_reservations %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ reservation.start_time.strftime('%Y-%m-%d %H:%M') }}</td>
|
|
||||||
<td>{{ reservation.classroom.code }}</td>
|
|
||||||
<td>{{ reservation.commission.get_full_code() }}</td>
|
|
||||||
<td>{{ reservation.commission.teacher_name }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge bg-{{ 'success' if reservation.status.value == 'CONFIRMED' else 'warning' }}">
|
|
||||||
{{ reservation.status.value }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<a href="{{ url_for('schedule.list_reservations') }}"
|
|
||||||
class="btn btn-sm btn-outline-primary">
|
|
||||||
<i class="bi bi-eye"></i> View
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="text-center py-4">
|
|
||||||
<i class="bi bi-calendar-x text-muted" style="font-size: 3rem;"></i>
|
|
||||||
<p class="text-muted mt-3">No upcoming reservations in the next 7 days</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% if current_user.is_authenticated %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
||||||
<script>
|
|
||||||
$(document).ready(function() {
|
|
||||||
// Fetch monthly data and create chart
|
|
||||||
$.get('/api/dashboard-stats')
|
|
||||||
.done(function(data) {
|
|
||||||
const ctx = document.getElementById('monthlyChart').getContext('2d');
|
|
||||||
new Chart(ctx, {
|
|
||||||
type: 'line',
|
|
||||||
data: {
|
|
||||||
labels: data.monthly_data.map(item => item.month),
|
|
||||||
datasets: [{
|
|
||||||
label: 'Reservations',
|
|
||||||
data: data.monthly_data.map(item => item.reservations),
|
|
||||||
borderColor: '#667eea',
|
|
||||||
backgroundColor: 'rgba(102, 126, 234, 0.1)',
|
|
||||||
borderWidth: 2,
|
|
||||||
fill: true,
|
|
||||||
tension: 0.4
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
display: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
y: {
|
|
||||||
beginAtZero: true,
|
|
||||||
ticks: {
|
|
||||||
stepSize: 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
{% if config.LANGUAGES|length > 1 %}
|
|
||||||
<div class="dropdown language-switcher">
|
|
||||||
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" type="button" id="languageDropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
|
||||||
<i class="bi bi-globe"></i>
|
|
||||||
{% if session.language == 'es' or (session.language is none and request.accept_languages.best_match(['es', 'en']) == 'es') %}
|
|
||||||
Spanish
|
|
||||||
{% else %}
|
|
||||||
English
|
|
||||||
{% endif %}
|
|
||||||
</button>
|
|
||||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="languageDropdown">
|
|
||||||
{% set current_lang = session.language or request.accept_languages.best_match(['es', 'en']) or 'en' %}
|
|
||||||
<li>
|
|
||||||
<a class="dropdown-item {% if current_lang == 'en' %}active{% endif %}"
|
|
||||||
href="{{ url_for('main.set_language', language='en') }}">
|
|
||||||
<i class="bi bi-check-circle-fill text-primary me-2"></i>English
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a class="dropdown-item {% if current_lang == 'es' %}active{% endif %}"
|
|
||||||
href="{{ url_for('main.set_language', language='es') }}">
|
|
||||||
<i class="bi bi-check-circle-fill text-primary me-2"></i>Español
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Add Reservation - Edu-Space Admin{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container-fluid pt-5 mt-4">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<h1 class="h3">
|
|
||||||
<i class="bi bi-calendar-plus text-success"></i> New Reservation
|
|
||||||
</h1>
|
|
||||||
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
|
|
||||||
<i class="bi bi-arrow-left"></i> Back to Reservations
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-8 mx-auto">
|
|
||||||
<div class="card border-0 shadow-sm">
|
|
||||||
<div class="card-header bg-white border-0">
|
|
||||||
<h5 class="mb-0">
|
|
||||||
<i class="bi bi-calendar3 text-success"></i> Reservation Details
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<form method="POST" id="reservation-form" novalidate>
|
|
||||||
{{ form.hidden_tag() }}
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
{{ form.classroom_id.label(class="form-label") }}
|
|
||||||
{{ form.classroom_id(class="form-select", id="classroom-select") }}
|
|
||||||
{% if form.classroom_id.errors %}
|
|
||||||
<div class="text-danger small mt-1">
|
|
||||||
{{ form.classroom_id.errors[0] }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<small class="text-muted">Select a classroom for your reservation</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
{{ form.commission_id.label(class="form-label") }}
|
|
||||||
{{ form.commission_id(class="form-select", id="commission-select") }}
|
|
||||||
{% if form.commission_id.errors %}
|
|
||||||
<div class="text-danger small mt-1">
|
|
||||||
{{ form.commission_id.errors[0] }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<small class="text-muted">Select the subject/commission</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
{{ form.start_time.label(class="form-label") }}
|
|
||||||
{{ form.start_time(class="form-control", id="start-time") }}
|
|
||||||
{% if form.start_time.errors %}
|
|
||||||
<div class="text-danger small mt-1">
|
|
||||||
{{ form.start_time.errors[0] }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<small class="text-muted">When will the class start?</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
{{ form.end_time.label(class="form-label") }}
|
|
||||||
{{ form.end_time(class="form-control", id="end-time") }}
|
|
||||||
{% if form.end_time.errors %}
|
|
||||||
<div class="text-danger small mt-1">
|
|
||||||
{{ form.end_time.errors[0] }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<small class="text-muted">When will the class end?</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-8 mb-3">
|
|
||||||
{{ form.purpose.label(class="form-label") }}
|
|
||||||
{{ form.purpose(class="form-control", placeholder="Regular class, exam, study session, etc.") }}
|
|
||||||
{% if form.purpose.errors %}
|
|
||||||
<div class="text-danger small mt-1">
|
|
||||||
{{ form.purpose.errors[0] }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-4 mb-3">
|
|
||||||
{{ form.expected_attendees.label(class="form-label") }}
|
|
||||||
{{ form.expected_attendees(class="form-control", placeholder="30") }}
|
|
||||||
{% if form.expected_attendees.errors %}
|
|
||||||
<div class="text-danger small mt-1">
|
|
||||||
{{ form.expected_attendees.errors[0] }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<small class="text-muted">Number of expected attendees</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
{{ form.notes.label(class="form-label") }}
|
|
||||||
{{ form.notes(class="form-control", rows="3", placeholder="Any additional notes or special requirements...") }}
|
|
||||||
{% if form.notes.errors %}
|
|
||||||
<div class="text-danger small mt-1">
|
|
||||||
{{ form.notes.errors[0] }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Summary Section -->
|
|
||||||
<div class="alert alert-info" id="reservation-summary">
|
|
||||||
<h6 class="alert-heading">
|
|
||||||
<i class="bi bi-info-circle"></i> Reservation Summary
|
|
||||||
</h6>
|
|
||||||
<div id="summary-content">
|
|
||||||
<p class="mb-2">
|
|
||||||
<strong>Selected Classroom:</strong>
|
|
||||||
<span id="summary-classroom">Not selected</span>
|
|
||||||
</p>
|
|
||||||
<p class="mb-2">
|
|
||||||
<strong>Duration:</strong>
|
|
||||||
<span id="summary-duration">Not calculated</span>
|
|
||||||
</p>
|
|
||||||
<p class="mb-0">
|
|
||||||
<strong>Commission:</strong>
|
|
||||||
<span id="summary-commission">Not selected</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-flex justify-content-between">
|
|
||||||
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
|
|
||||||
<i class="bi bi-x-circle"></i> Cancel
|
|
||||||
</a>
|
|
||||||
{{ form.submit(class="btn btn-success") }}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Instructions Card -->
|
|
||||||
<div class="row mt-4">
|
|
||||||
<div class="col-lg-8 mx-auto">
|
|
||||||
<div class="card border-0 shadow-sm">
|
|
||||||
<div class="card-header bg-white border-0">
|
|
||||||
<h5 class="mb-0">
|
|
||||||
<i class="bi bi-question-circle text-info"></i> Reservation Guidelines
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h6 class="text-primary">📅 Booking Rules</h6>
|
|
||||||
<ul class="small">
|
|
||||||
<li>Reservations can be made up to 3 months in advance</li>
|
|
||||||
<li>Cannot book in the past</li>
|
|
||||||
<li>Maximum duration: 8 hours per reservation</li>
|
|
||||||
<li>Cancellations must be 24 hours before start time</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h6 class="text-warning">🚨 Important Notes</h6>
|
|
||||||
<ul class="small">
|
|
||||||
<li>Check classroom capacity before booking</li>
|
|
||||||
<li>Pending reservations require approval</li>
|
|
||||||
<li>Time slots must not overlap</li>
|
|
||||||
<li>System automatically detects conflicts</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
|
||||||
$(document).ready(function() {
|
|
||||||
// Classroom data for capacity checking
|
|
||||||
const classroomData = {};
|
|
||||||
{% for classroom in classroom_list %}
|
|
||||||
classroomData[{{ classroom.id }}] = {
|
|
||||||
capacity: {{ classroom.capacity }},
|
|
||||||
code_display: "{{ classroom.code_display }}",
|
|
||||||
name: "{{ classroom.name }}"
|
|
||||||
};
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
function updateSummary() {
|
|
||||||
const classroomId = $('#classroom-select').val();
|
|
||||||
const commissionId = $('#commission-select').val();
|
|
||||||
const startTime = $('#start-time').val();
|
|
||||||
const endTime = $('#end-time').val();
|
|
||||||
|
|
||||||
// Update classroom summary
|
|
||||||
if (classroomId && classroomData[classroomId]) {
|
|
||||||
$('#summary-classroom').text(`${classroomData[classroomId].code_display} - ${classroomData[classroomId].name} (Capacity: ${classroomData[classroomId].capacity})`);
|
|
||||||
} else {
|
|
||||||
$('#summary-classroom').text('Not selected');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update commission summary
|
|
||||||
if (commissionId) {
|
|
||||||
const commissionText = $('#commission-select option:selected').text();
|
|
||||||
$('#summary-commission').text(commissionText);
|
|
||||||
} else {
|
|
||||||
$('#summary-commission').text('Not selected');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate duration
|
|
||||||
if (startTime && endTime) {
|
|
||||||
const start = new Date(startTime);
|
|
||||||
const end = new Date(endTime);
|
|
||||||
|
|
||||||
if (end > start) {
|
|
||||||
const diffMs = end - start;
|
|
||||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
|
||||||
const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
|
||||||
|
|
||||||
let durationText = '';
|
|
||||||
if (diffHours > 0) {
|
|
||||||
durationText += `${diffHours} hour${diffHours > 1 ? 's' : ''}`;
|
|
||||||
}
|
|
||||||
if (diffMins > 0) {
|
|
||||||
if (durationText) durationText += ' ';
|
|
||||||
durationText += `${diffMins} minute${diffMins > 1 ? 's' : ''}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
$('#summary-duration').text(durationText);
|
|
||||||
} else {
|
|
||||||
$('#summary-duration').text('Invalid time range');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$('#summary-duration').text('Not calculated');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check capacity vs expected attendees
|
|
||||||
function checkCapacity() {
|
|
||||||
const classroomId = $('#classroom-select').val();
|
|
||||||
const attendees = parseInt($('#expected_attendees').val()) || 0;
|
|
||||||
|
|
||||||
if (classroomId && classroomData[classroomId] && attendees > 0) {
|
|
||||||
const capacity = classroomData[classroomId].capacity;
|
|
||||||
|
|
||||||
if (attendees > capacity) {
|
|
||||||
$('#expected_attendees').addClass('is-invalid');
|
|
||||||
$('#expected_attendees').after(`
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
Expected attendees (${attendees}) exceeds classroom capacity (${capacity})
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
} else {
|
|
||||||
$('#expected_attendees').removeClass('is-invalid');
|
|
||||||
$('#expected_attendees').siblings('.invalid-feedback').remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update on change
|
|
||||||
$('#classroom-select, #commission-select, #start-time, #end-time, #expected_attendees').on('change', updateSummary);
|
|
||||||
$('#expected_attendees').on('input', checkCapacity);
|
|
||||||
|
|
||||||
// Initialize summary
|
|
||||||
updateSummary();
|
|
||||||
|
|
||||||
// Form validation
|
|
||||||
$('#reservation-form').on('submit', function(e) {
|
|
||||||
let isValid = true;
|
|
||||||
|
|
||||||
// Check required fields
|
|
||||||
if (!$('#classroom-select').val()) {
|
|
||||||
$('#classroom-select').addClass('is-invalid');
|
|
||||||
isValid = false;
|
|
||||||
} else {
|
|
||||||
$('#classroom-select').removeClass('is-invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$('#commission-select').val()) {
|
|
||||||
$('#commission-select').addClass('is-invalid');
|
|
||||||
isValid = false;
|
|
||||||
} else {
|
|
||||||
$('#commission-select').removeClass('is-invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$('#start-time').val()) {
|
|
||||||
$('#start-time').addClass('is-invalid');
|
|
||||||
isValid = false;
|
|
||||||
} else {
|
|
||||||
$('#start-time').removeClass('is-invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$('#end-time').val()) {
|
|
||||||
$('#end-time').addClass('is-invalid');
|
|
||||||
isValid = false;
|
|
||||||
} else {
|
|
||||||
$('#end-time').removeClass('is-invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate time range
|
|
||||||
const startTime = new Date($('#start-time').val());
|
|
||||||
const endTime = new Date($('#end-time').val());
|
|
||||||
|
|
||||||
if (endTime <= startTime) {
|
|
||||||
$('#end-time').addClass('is-invalid');
|
|
||||||
isValid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check capacity
|
|
||||||
checkCapacity();
|
|
||||||
if ($('#expected_attendees').hasClass('is-invalid')) {
|
|
||||||
isValid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return isValid;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set minimum datetime to current
|
|
||||||
const now = new Date();
|
|
||||||
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
|
|
||||||
$('#start-time, #end-time').attr('min', now.toISOString().slice(0, 16));
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Schedule Calendar - Edu-Space Admin{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<h1 class="h3 mb-0">
|
|
||||||
<i class="bi bi-calendar3 text-primary"></i> Schedule Calendar
|
|
||||||
<small class="text-muted ms-2">View & Manage Reservations</small>
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card border-0 shadow-sm">
|
|
||||||
<div class="card-header bg-white border-0">
|
|
||||||
<div class="row align-items-center">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h5 class="mb-0">
|
|
||||||
<i class="bi bi-calendar-week text-info"></i> Reservations Calendar
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 text-end">
|
|
||||||
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-primary">
|
|
||||||
<i class="bi bi-plus-circle"></i> Add Reservation
|
|
||||||
</a>
|
|
||||||
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
|
|
||||||
<i class="bi bi-list"></i> List View
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<!-- Legend -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<div class="d-flex gap-3 flex-wrap">
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<span class="badge bg-success me-2" style="width: 20px; height: 20px;"></span>
|
|
||||||
<small class="text-muted">Confirmed</small>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<span class="badge bg-warning me-2" style="width: 20px; height: 20px;"></span>
|
|
||||||
<small class="text-muted">Pending</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Calendar -->
|
|
||||||
<div id="calendar"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Reservation Details Modal -->
|
|
||||||
<div class="modal fade" id="reservationModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog modal-lg">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title">Reservation Details</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body" id="reservationDetails">
|
|
||||||
<!-- Content will be loaded dynamically -->
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_css %}
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@5.11.0/main.min.css" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
#calendar {
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
.fc-event {
|
|
||||||
cursor: pointer;
|
|
||||||
border: none !important;
|
|
||||||
padding: 2px 6px !important;
|
|
||||||
font-size: 12px !important;
|
|
||||||
}
|
|
||||||
.fc-event-pending {
|
|
||||||
background-color: #ffc107 !important;
|
|
||||||
}
|
|
||||||
.fc-event-confirmed {
|
|
||||||
background-color: #28a745 !important;
|
|
||||||
}
|
|
||||||
.fc-daygrid-day.fc-day-today {
|
|
||||||
background-color: rgba(102, 126, 234, 0.1) !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.11.0/main.min.js"></script>
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
var calendarEl = document.getElementById('calendar');
|
|
||||||
|
|
||||||
var calendar = new FullCalendar.Calendar(calendarEl, {
|
|
||||||
initialView: 'timeGridWeek',
|
|
||||||
headerToolbar: {
|
|
||||||
left: 'prev,next today',
|
|
||||||
center: 'title',
|
|
||||||
right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
|
|
||||||
},
|
|
||||||
eventTimeFormat: {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: false
|
|
||||||
},
|
|
||||||
slotMinTime: '07:00:00',
|
|
||||||
slotMaxTime: '22:00:00',
|
|
||||||
height: 'auto',
|
|
||||||
eventClick: function(info) {
|
|
||||||
// Prevent default link behavior
|
|
||||||
if (info.event.url) {
|
|
||||||
// Load reservation details in modal instead of navigation
|
|
||||||
loadReservationDetails(info.event.url);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
events: '{{ url_for("schedule.calendar_data") }}',
|
|
||||||
loading: function(bool) {
|
|
||||||
// Show loading indicator if needed
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
calendar.render();
|
|
||||||
|
|
||||||
function loadReservationDetails(url) {
|
|
||||||
$.get(url)
|
|
||||||
.done(function(data) {
|
|
||||||
var html = `
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h6 class="text-muted mb-2">Classroom</h6>
|
|
||||||
<p class="fw-bold">${data.classroom.code} - ${data.classroom.name}</p>
|
|
||||||
<p><i class="bi bi-building text-primary"></i> ${data.classroom.building} - Floor ${data.classroom.floor}</p>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h6 class="text-muted mb-2">Subject</h6>
|
|
||||||
<p class="fw-bold">${data.commission.subject.code} - ${data.commission.subject.name}</p>
|
|
||||||
<p><i class="bi bi-person text-info"></i> ${data.commission.teacher_name}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row mt-3">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h6 class="text-muted mb-2">Schedule</h6>
|
|
||||||
<p><i class="bi bi-clock text-warning"></i> ${new Date(data.start_time).toLocaleString()}</p>
|
|
||||||
<p><i class="bi bi-clock text-success"></i> ${new Date(data.end_time).toLocaleString()}</p>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h6 class="text-muted mb-2">Details</h6>
|
|
||||||
<p><i class="bi bi-people text-primary"></i> ${data.expected_attendees} attendees</p>
|
|
||||||
<p><span class="badge bg-${data.status === 'CONFIRMED' ? 'success' : 'warning'}">${data.status}</span></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
${data.notes ? `<div class="row mt-3"><div class="col-12"><h6 class="text-muted mb-2">Notes</h6><p>${data.notes}</p></div></div>` : ''}
|
|
||||||
`;
|
|
||||||
$('#reservationDetails').html(html);
|
|
||||||
$('#reservationModal').modal('show');
|
|
||||||
})
|
|
||||||
.fail(function() {
|
|
||||||
$('#reservationDetails').html('<p class="text-danger">Error loading reservation details.</p>');
|
|
||||||
$('#reservationModal').modal('show');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,390 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Commissions Management - Edu-Space Admin{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container-fluid pt-5 mt-4">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<h1 class="h3">
|
|
||||||
<i class="bi bi-diagram-3 text-info"></i> Commissions Management
|
|
||||||
</h1>
|
|
||||||
<div class="btn-group">
|
|
||||||
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-success">
|
|
||||||
<i class="bi bi-plus-circle"></i> New Reservation
|
|
||||||
</a>
|
|
||||||
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-secondary">
|
|
||||||
<i class="bi bi-calendar-day"></i> Today's Schedule
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Statistics Cards -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card border-0 shadow-sm">
|
|
||||||
<div class="card-body text-center">
|
|
||||||
<h2 class="text-primary">{{ subjects|length }}</h2>
|
|
||||||
<p class="mb-0">Active Subjects</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card border-0 shadow-sm">
|
|
||||||
<div class="card-body text-center">
|
|
||||||
<h2 class="text-success">{{ subjects|map(attribute='commissions')|map('length')|sum }}</h2>
|
|
||||||
<p class="mb-0">Total Commissions</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card border-0 shadow-sm">
|
|
||||||
<div class="card-body text-center">
|
|
||||||
<h2 class="text-success">{{ total_commissions }}</h2>
|
|
||||||
|
|
||||||
<h2 class="text-info">{{ total_capacity }}</h2>
|
|
||||||
<p class="mb-0">Total Student Capacity</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card border-0 shadow-sm">
|
|
||||||
<div class="card-body text-center">
|
|
||||||
<h2 class="text-warning">{{ subjects|selectattr('commissions')|list|length }}</h2>
|
|
||||||
<p class="mb-0">Subjects with Commissions</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Subjects and Commissions -->
|
|
||||||
<div class="row">
|
|
||||||
{% for subject in subjects %}
|
|
||||||
<div class="col-lg-6 mb-4">
|
|
||||||
<div class="card border-0 shadow-sm">
|
|
||||||
<div class="card-header bg-white border-0">
|
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
|
||||||
<div>
|
|
||||||
<h5 class="mb-1">
|
|
||||||
<span class="badge bg-primary me-2">{{ subject.code }}</span>
|
|
||||||
{{ subject.name }}
|
|
||||||
</h5>
|
|
||||||
<p class="text-muted mb-0 small">
|
|
||||||
<i class="bi bi-building"></i> {{ subject.department }} •
|
|
||||||
<i class="bi bi-award"></i> {{ subject.credits }} credits
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="text-end">
|
|
||||||
<span class="badge bg-info">{{ subject.commissions|length }} commission{{ 's' if subject.commissions|length != 1 else '' }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
{% if subject.commissions %}
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-sm table-hover">
|
|
||||||
<thead class="table-light">
|
|
||||||
<tr>
|
|
||||||
<th>Commission</th>
|
|
||||||
<th>Semester</th>
|
|
||||||
<th>Teacher</th>
|
|
||||||
<th>Students</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for commission in subject.commissions %}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<strong>{{ commission.code }}</strong>
|
|
||||||
{% if commission.schedule %}
|
|
||||||
<br><small class="text-muted">{{ commission.schedule }}</small>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge bg-light text-dark">{{ commission.semester }} {{ commission.year }}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% if commission.teacher %}
|
|
||||||
<small>{{ commission.teacher.first_name }} {{ commission.teacher.last_name }}</small>
|
|
||||||
{% else %}
|
|
||||||
<small class="text-muted">Not assigned</small>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<small>{{ commission.current_students }}/{{ commission.max_students }}</small>
|
|
||||||
{% set fill_percentage = (commission.current_students / commission.max_students * 100) %}
|
|
||||||
<div class="progress ms-2" style="height: 4px; width: 30px;">
|
|
||||||
<div class="progress-bar
|
|
||||||
{% if fill_percentage > 90 %}bg-danger
|
|
||||||
{% elif fill_percentage > 75 %}bg-warning
|
|
||||||
{% else %}bg-success
|
|
||||||
{% endif %}"
|
|
||||||
style="width: {{ fill_percentage }}%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% if commission.active %}
|
|
||||||
<span class="badge bg-success">Active</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge bg-secondary">Inactive</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="btn-group btn-group-sm">
|
|
||||||
<button class="btn btn-outline-primary quick-reserve-btn"
|
|
||||||
data-commission-id="{{ commission.id }}"
|
|
||||||
data-max-students="{{ commission.max_students }}"
|
|
||||||
data-commission-name="{{ commission.get_full_code() }}"
|
|
||||||
data-subject-name="{{ subject.name }}"
|
|
||||||
title="Quick Reservation">
|
|
||||||
<i class="bi bi-calendar-plus"></i>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-outline-info view-schedule-btn"
|
|
||||||
data-commission-id="{{ commission.id }}"
|
|
||||||
title="View Schedule">
|
|
||||||
<i class="bi bi-calendar-week"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="text-center text-muted py-3">
|
|
||||||
<i class="bi bi-inbox fs-2 d-block mb-2"></i>
|
|
||||||
No commissions registered for this subject
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% if subject.description %}
|
|
||||||
<div class="card-footer bg-light">
|
|
||||||
<small class="text-muted">
|
|
||||||
<i class="bi bi-info-circle"></i> {{ subject.description|truncatewords(20) }}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="text-center py-5">
|
|
||||||
<i class="bi bi-inbox fs-1 text-muted d-block mb-3"></i>
|
|
||||||
<h4 class="text-muted">No Subjects Found</h4>
|
|
||||||
<p class="text-muted">There are no active subjects in the system.</p>
|
|
||||||
<a href="{{ url_for('classrooms.index') }}" class="btn btn-primary">
|
|
||||||
<i class="bi bi-plus-circle"></i> Add New Subject
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Quick Reservation Modal -->
|
|
||||||
<div class="modal fade" id="quickReservationModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog modal-lg">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title">
|
|
||||||
<i class="bi bi-calendar-plus text-success"></i> Quick Reservation
|
|
||||||
</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<form id="quick-reservation-form">
|
|
||||||
<input type="hidden" id="quick-commission-id" name="commission_id">
|
|
||||||
|
|
||||||
<div class="alert alert-info">
|
|
||||||
<h6 class="alert-heading">
|
|
||||||
<i class="bi bi-info-circle"></i> Commission Details
|
|
||||||
</h6>
|
|
||||||
<div id="commission-details"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label for="quick-date" class="form-label">Date</label>
|
|
||||||
<input type="date" class="form-control" id="quick-date" required>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<label for="quick-start-time" class="form-label">Start Time</label>
|
|
||||||
<input type="time" class="form-control" id="quick-start-time" required>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<label for="quick-end-time" class="form-label">End Time</label>
|
|
||||||
<input type="time" class="form-control" id="quick-end-time" required>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-12 mb-3">
|
|
||||||
<label for="quick-purpose" class="form-label">Purpose</label>
|
|
||||||
<input type="text" class="form-control" id="quick-purpose" placeholder="Regular class, exam, review, etc." required>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Classroom Selection & Availability -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Select Classroom</label>
|
|
||||||
<div class="d-flex align-items-center mb-2">
|
|
||||||
<select class="form-select" id="quick-classroom-select" required>
|
|
||||||
<option value="">Choose date and time first</option>
|
|
||||||
</select>
|
|
||||||
<button type="button" class="btn btn-outline-primary ms-2" id="check-availability-btn" disabled>
|
|
||||||
<i class="bi bi-search"></i> Check
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="availability-results" class="mt-3"></div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<button type="button" class="btn btn-success" id="create-reservation-btn" disabled>
|
|
||||||
<i class="bi bi-plus-circle"></i> Create Reservation
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
|
||||||
$(document).ready(function() {
|
|
||||||
// Quick Reservation Button Click
|
|
||||||
$('.quick-reserve-btn').on('click', function() {
|
|
||||||
const commissionId = $(this).data('commission-id');
|
|
||||||
const maxStudents = $(this).data('max-students');
|
|
||||||
const commissionName = $(this).data('commission-name');
|
|
||||||
const subjectName = $(this).data('subject-name');
|
|
||||||
|
|
||||||
// Set commission details in modal
|
|
||||||
$('#quick-commission-id').val(commissionId);
|
|
||||||
$('#commission-details').html(`
|
|
||||||
<strong>${commissionName}</strong><br>
|
|
||||||
<small>Subject: ${subjectName}</small><br>
|
|
||||||
<small>Max Students: ${maxStudents}</small>
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Reset form
|
|
||||||
$('#quick-date').val('');
|
|
||||||
$('#quick-start-time').val('');
|
|
||||||
$('#quick-end-time').val('');
|
|
||||||
$('#quick-purpose').val('');
|
|
||||||
$('#quick-classroom-select').html('<option value="">Choose date and time first</option>').prop('disabled', true);
|
|
||||||
$('#check-availability-btn').prop('disabled', true);
|
|
||||||
$('#create-reservation-btn').prop('disabled', true);
|
|
||||||
$('#availability-results').empty();
|
|
||||||
|
|
||||||
// Show modal
|
|
||||||
$('#quickReservationModal').modal('show');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Enable date/time inputs to check availability
|
|
||||||
function validateDateTimeInputs() {
|
|
||||||
const date = $('#quick-date').val();
|
|
||||||
const startTime = $('#quick-start-time').val();
|
|
||||||
const endTime = $('#quick-end-time').val();
|
|
||||||
|
|
||||||
const isValid = date && startTime && endTime && startTime < endTime;
|
|
||||||
$('#check-availability-btn').prop('disabled', !isValid);
|
|
||||||
|
|
||||||
if (isValid) {
|
|
||||||
loadClassroomOptions();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$('#quick-date, #quick-start-time, #quick-end-time').on('change', validateDateTimeInputs);
|
|
||||||
|
|
||||||
function loadClassroomOptions() {
|
|
||||||
$.get('/schedule/api/classroom-availability', {
|
|
||||||
date: $('#quick-date').val(),
|
|
||||||
start_time: $('#quick-start-time').val(),
|
|
||||||
end_time: $('#quick-end-time').val(),
|
|
||||||
max_students: parseInt($('#quick-commission-id').data('max-students'))
|
|
||||||
}, function(response) {
|
|
||||||
const classroomSelect = $('#quick-classroom-select');
|
|
||||||
classroomSelect.empty().append('<option value="">Select a classroom</option>');
|
|
||||||
|
|
||||||
// Add available classrooms
|
|
||||||
response.availability.available.forEach(function(classroom) {
|
|
||||||
classroomSelect.append(`<option value="${classroom.id}">${classroom.code} - ${classroom.name} (Cap: ${classroom.capacity}) - ${classroom.location}</option>`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Enable selection
|
|
||||||
classroomSelect.prop('disabled', response.availability.available.length === 0);
|
|
||||||
|
|
||||||
// Show recommendation if available
|
|
||||||
if (response.recommendation) {
|
|
||||||
const rec = response.recommendation.classroom;
|
|
||||||
$('#availability-results').html(`
|
|
||||||
<div class="alert alert-success">
|
|
||||||
<h6><i class="bi bi-star-fill"></i> Recommended Classroom</h6>
|
|
||||||
<strong>${rec.code} - ${rec.name}</strong><br>
|
|
||||||
<small>Capacity: ${rec.capacity} | Location: ${rec.location}</small><br>
|
|
||||||
<small class="text-muted">${response.recommendation.reason}</small>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show statistics
|
|
||||||
$('#availability-results').append(`
|
|
||||||
<div class="alert alert-info mt-2">
|
|
||||||
<small>
|
|
||||||
<strong>Availability:</strong> ${response.availability.stats.total_available} available,
|
|
||||||
${response.availability.stats.total_occupied} occupied classrooms
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check availability button click
|
|
||||||
$('#check-availability-btn').on('click', loadClassroomOptions);
|
|
||||||
|
|
||||||
// Classroom selection enables create button
|
|
||||||
$('#quick-classroom-select').on('change', function() {
|
|
||||||
const isValid = $(this).val() && $('#quick-purpose').val();
|
|
||||||
$('#create-reservation-btn').prop('disabled', !isValid);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Purpose input validation
|
|
||||||
$('#quick-purpose').on('input', function() {
|
|
||||||
const isValid = $('#quick-classroom-select').val() && $(this).val();
|
|
||||||
$('#create-reservation-btn').prop('disabled', !isValid);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create reservation
|
|
||||||
$('#create-reservation-btn').on('click', function() {
|
|
||||||
const formData = {
|
|
||||||
commission_id: $('#quick-commission-id').val(),
|
|
||||||
classroom_id: $('#quick-classroom-select').val(),
|
|
||||||
purpose: $('#quick-purpose').val(),
|
|
||||||
start_time: `${$('#quick-date').val()} ${$('#quick-start-time').val()}`,
|
|
||||||
end_time: `${$('#quick-date').val()} ${$('#quick-end-time').val()}`,
|
|
||||||
expected_attendees: $('#quick-commission-id').data('max-students')
|
|
||||||
};
|
|
||||||
|
|
||||||
$.post('/schedule/reservation/add', formData)
|
|
||||||
.done(function() {
|
|
||||||
$('#quickReservationModal').modal('hide');
|
|
||||||
location.reload();
|
|
||||||
})
|
|
||||||
.fail(function(xhr) {
|
|
||||||
alert('Error creating reservation: ' + xhr.responseJSON.error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Reservations List - Edu-Space Admin{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<h1 class="h3 mb-0">
|
|
||||||
<i class="bi bi-calendar-list text-primary"></i> Reservations
|
|
||||||
<small class="text-muted ms-2">Manage & View All Reservations</small>
|
|
||||||
</h1>
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary">
|
|
||||||
<i class="bi bi-calendar3"></i> Calendar View
|
|
||||||
</a>
|
|
||||||
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-info">
|
|
||||||
<i class="bi bi-calendar-day"></i> Today
|
|
||||||
</a>
|
|
||||||
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-primary">
|
|
||||||
<i class="bi bi-plus-circle"></i> Add Reservation
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filters -->
|
|
||||||
<div class="card border-0 shadow-sm mb-4">
|
|
||||||
<div class="card-body">
|
|
||||||
<form method="GET" class="row g-3">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Status</label>
|
|
||||||
<select name="status" class="form-select">
|
|
||||||
<option value="all" {% if status_filter == 'all' %}selected{% endif %}>All Status</option>
|
|
||||||
<option value="pending" {% if status_filter == 'pending' %}selected{% endif %}>Pending</option>
|
|
||||||
<option value="confirmed" {% if status_filter == 'confirmed' %}selected{% endif %}>Confirmed</option>
|
|
||||||
<option value="cancelled" {% if status_filter == 'cancelled' %}selected{% endif %}>Cancelled</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">From Date</label>
|
|
||||||
<input type="date" name="date_from" class="form-control" value="{{ date_from or '' }}">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">To Date</label>
|
|
||||||
<input type="date" name="date_to" class="form-control" value="{{ date_to or '' }}">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label"> </label>
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<button type="submit" class="btn btn-outline-primary">
|
|
||||||
<i class="bi bi-funnel"></i> Filter
|
|
||||||
</button>
|
|
||||||
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
|
|
||||||
<i class="bi bi-x-circle"></i> Clear
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Reservations List -->
|
|
||||||
{% if reservations and reservations.items %}
|
|
||||||
<div class="card border-0 shadow-sm">
|
|
||||||
<div class="card-body p-0">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover mb-0">
|
|
||||||
<thead class="table-light">
|
|
||||||
<tr>
|
|
||||||
<th class="border-0">Classroom</th>
|
|
||||||
<th class="border-0">Commission</th>
|
|
||||||
<th class="border-0">Date & Time</th>
|
|
||||||
<th class="border-0">Purpose</th>
|
|
||||||
<th class="border-0">Attendees</th>
|
|
||||||
<th class="border-0">Status</th>
|
|
||||||
<th class="border-0">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for reservation in reservations.items %}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="avatar avatar-sm bg-primary text-white rounded-circle d-flex align-items-center justify-content-center me-2">
|
|
||||||
<i class="bi bi-door-open"></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="fw-bold">{{ reservation.classroom.code_display }}</div>
|
|
||||||
<small class="text-muted">Capacity: {{ reservation.classroom.capacity }}</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<div>
|
|
||||||
<div class="fw-bold">
|
|
||||||
{% if reservation.commission %}
|
|
||||||
{{ reservation.commission.get_full_code() or 'N/A' }}
|
|
||||||
{% else %}
|
|
||||||
No Commission
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<small class="text-muted">
|
|
||||||
{% if reservation.commission and reservation.commission.teacher_name %}
|
|
||||||
{{ reservation.commission.teacher_name }}
|
|
||||||
{% else %}
|
|
||||||
No Teacher
|
|
||||||
{% endif %}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<div>
|
|
||||||
<div class="fw-bold">{{ reservation.start_time.strftime('%b %d, %Y') }}</div>
|
|
||||||
<small class="text-muted">
|
|
||||||
{{ reservation.start_time.strftime('%I:%M %p') }} - {{ reservation.end_time.strftime('%I:%M %p') }}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<span class="text-truncate" style="max-width: 150px; display: inline-block;">
|
|
||||||
{{ reservation.purpose or 'No purpose specified' }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<i class="bi bi-people text-muted me-1"></i>
|
|
||||||
{{ reservation.expected_attendees }}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
{% if reservation.status.value == 'confirmed' %}
|
|
||||||
<span class="badge bg-success">Confirmed</span>
|
|
||||||
{% elif reservation.status.value == 'pending' %}
|
|
||||||
<span class="badge bg-warning">Pending</span>
|
|
||||||
{% elif reservation.status.value == 'cancelled' %}
|
|
||||||
<span class="badge bg-danger">Cancelled</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge bg-secondary">{{ reservation.status.value }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<div class="btn-group btn-group-sm" role="group">
|
|
||||||
{% if reservation.status.value == 'pending' %}
|
|
||||||
<form method="POST" action="{{ url_for('schedule.confirm_reservation', id=reservation.id) }}" style="display: inline;">
|
|
||||||
<button type="submit" class="btn btn-outline-success btn-sm" title="Confirm">
|
|
||||||
<i class="bi bi-check-circle"></i>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if reservation.status.value in ['pending', 'confirmed'] %}
|
|
||||||
<form method="POST" action="{{ url_for('schedule.cancel_reservation', id=reservation.id) }}" style="display: inline;">
|
|
||||||
<button type="submit" class="btn btn-outline-danger btn-sm" title="Cancel">
|
|
||||||
<i class="bi bi-x-circle"></i>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if reservation.notes %}
|
|
||||||
<button type="button" class="btn btn-outline-info btn-sm"
|
|
||||||
title="{{ reservation.notes }}" data-bs-toggle="tooltip">
|
|
||||||
<i class="bi bi-info-circle"></i>
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pagination -->
|
|
||||||
{% if reservations.pages > 1 %}
|
|
||||||
<div class="card-footer bg-white border-0">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<span class="text-muted small">
|
|
||||||
Showing {{ reservations.items|length }} of {{ reservations.total }} reservations
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<nav>
|
|
||||||
<ul class="pagination pagination-sm mb-0">
|
|
||||||
{% if reservations.has_prev %}
|
|
||||||
<li class="page-item">
|
|
||||||
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=reservations.prev_num, status=status_filter, date_from=date_from, date_to=date_to) }}">
|
|
||||||
<i class="bi bi-chevron-left"></i>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% for page_num in reservations.iter_pages() %}
|
|
||||||
{% if page_num %}
|
|
||||||
{% if page_num != reservations.page %}
|
|
||||||
<li class="page-item">
|
|
||||||
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=page_num, status=status_filter, date_from=date_from, date_to=date_to) }}">
|
|
||||||
{{ page_num }}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% else %}
|
|
||||||
<li class="page-item active">
|
|
||||||
<span class="page-link">{{ page_num }}</span>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
<li class="page-item disabled">
|
|
||||||
<span class="page-link">…</span>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
{% if reservations.has_next %}
|
|
||||||
<li class="page-item">
|
|
||||||
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=reservations.next_num, status=status_filter, date_from=date_from, date_to=date_to) }}">
|
|
||||||
<i class="bi bi-chevron-right"></i>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% else %}
|
|
||||||
<div class="card border-0 shadow-sm">
|
|
||||||
<div class="card-body text-center py-5">
|
|
||||||
<div class="mb-4">
|
|
||||||
<i class="bi bi-calendar-x display-1 text-muted"></i>
|
|
||||||
</div>
|
|
||||||
<h5 class="text-muted">No reservations found</h5>
|
|
||||||
<p class="text-muted">
|
|
||||||
{% if status_filter != 'all' or date_from or date_to %}
|
|
||||||
No reservations match your current filters.
|
|
||||||
<a href="{{ url_for('schedule.list_reservations') }}">Clear filters</a> to see all reservations.
|
|
||||||
{% else %}
|
|
||||||
There are no reservations in the system yet.
|
|
||||||
<a href="{{ url_for('schedule.add_reservation') }}">Create your first reservation</a>.
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
<div class="d-flex justify-content-center gap-2 mt-3">
|
|
||||||
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-primary">
|
|
||||||
<i class="bi bi-plus-circle"></i> Add Reservation
|
|
||||||
</a>
|
|
||||||
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-info">
|
|
||||||
<i class="bi bi-calendar-day"></i> Today's Schedule
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
|
||||||
// Initialize tooltips
|
|
||||||
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
|
|
||||||
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
|
||||||
return new bootstrap.Tooltip(tooltipTriggerEl)
|
|
||||||
});
|
|
||||||
|
|
||||||
// Form submission confirmation
|
|
||||||
document.querySelectorAll('form[action*="cancel"]').forEach(function(form) {
|
|
||||||
form.addEventListener('submit', function(e) {
|
|
||||||
if (!confirm('Are you sure you want to cancel this reservation?')) {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll('form[action*="confirm"]').forEach(function(form) {
|
|
||||||
form.addEventListener('submit', function(e) {
|
|
||||||
if (!confirm('Are you sure you want to confirm this reservation?')) {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,406 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Today's Schedule - Edu-Space Admin{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container-fluid pt-5 mt-4">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<h1 class="h3">
|
|
||||||
<i class="bi bi-calendar-today text-info"></i> Today's Schedule
|
|
||||||
<small class="text-muted ms-2">{{ today.strftime('%B %d, %Y') }}</small>
|
|
||||||
</h1>
|
|
||||||
<div>
|
|
||||||
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-success">
|
|
||||||
<i class="bi bi-plus-circle"></i> New Reservation
|
|
||||||
</a>
|
|
||||||
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-primary ms-2">
|
|
||||||
<i class="bi bi-list"></i> All Reservations
|
|
||||||
</a>
|
|
||||||
{# Access to Genetic Algorithm Optimizer - Only for admin/teacher roles #}
|
|
||||||
{% if current_user and current_user.role in ['admin', 'teacher'] %}
|
|
||||||
<a href="{{ url_for('main.genetic_optimizer') }}" class="btn btn-info text-white ms-2">
|
|
||||||
<i class="bi bi-cpu"></i> AI Optimizer
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Quick Stats -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<div class="card border-0 shadow-sm">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<h6 class="text-muted mb-2">Total Reservations</h6>
|
|
||||||
<h2 class="mb-0">{{ stats.total_reservations }}</h2>
|
|
||||||
</div>
|
|
||||||
<div class="bg-info bg-opacity-10 rounded-circle p-3">
|
|
||||||
<i class="bi bi-calendar3 text-info fs-4"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<div class="card border-0 shadow-sm">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<h6 class="text-muted mb-2">Confirmed</h6>
|
|
||||||
<h2 class="mb-0">{{ stats.confirmed }}</h2>
|
|
||||||
</div>
|
|
||||||
<div class="bg-success bg-opacity-10 rounded-circle p-3">
|
|
||||||
<i class="bi bi-check-circle text-success fs-4"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<div class="card border-0 shadow-sm">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<h6 class="text-muted mb-2">Pending</h6>
|
|
||||||
<h2 class="mb-0">{{ stats.pending }}</h2>
|
|
||||||
</div>
|
|
||||||
<div class="bg-warning bg-opacity-10 rounded-circle p-3">
|
|
||||||
<i class="bi bi-clock text-warning fs-4"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<div class="card border-0 shadow-sm">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<h6 class="text-muted mb-2">Active Classrooms</h6>
|
|
||||||
<h2 class="mb-0">{{ stats.occupied_classrooms }}/{{ stats.total_classrooms }}</h2>
|
|
||||||
</div>
|
|
||||||
<div class="bg-primary bg-opacity-10 rounded-circle p-3">
|
|
||||||
<i class="bi bi-door-open text-primary fs-4"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Today's Schedule -->
|
|
||||||
{% if time_blocks %}
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card border-0 shadow-sm">
|
|
||||||
<div class="card-header bg-white border-0">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<h5 class="mb-0">
|
|
||||||
<i class="bi bi-clock-history text-info"></i> Timeline View
|
|
||||||
</h5>
|
|
||||||
<div>
|
|
||||||
<button class="btn btn-sm btn-outline-primary" id="refresh-btn">
|
|
||||||
<i class="bi bi-arrow-clockwise"></i> Refresh
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-outline-secondary ms-1" id="toggle-view-btn">
|
|
||||||
<i class="bi bi-grid"></i> Grid View
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div id="timeline-view">
|
|
||||||
{% for hour in range(7, 22) %}
|
|
||||||
<div class="row border-bottom py-3 align-items-start">
|
|
||||||
<div class="col-md-2 col-3">
|
|
||||||
<div class="text-center">
|
|
||||||
<strong>{{ "%02d:00"|format(hour) }}</strong>
|
|
||||||
<br>
|
|
||||||
<small class="text-muted">{{ hour+1 }}:00</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-10 col-9">
|
|
||||||
{% if time_blocks[hour] %}
|
|
||||||
<div class="row">
|
|
||||||
{% for item in time_blocks[hour] %}
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<div class="card border-0 shadow-sm h-100">
|
|
||||||
<div class="card-body p-3">
|
|
||||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
|
||||||
<div>
|
|
||||||
<h6 class="card-title mb-1">
|
|
||||||
{{ item.commission.get_full_code() }}
|
|
||||||
</h6>
|
|
||||||
<p class="text-muted small mb-2 mb-md-0">
|
|
||||||
<i class="bi bi-geo-alt"></i> {{ item.classroom.code }}
|
|
||||||
<br class="d-md-none">
|
|
||||||
<i class="bi bi-person ms-md-2"></i> {{ item.user.first_name }} {{ item.user.last_name }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span class="badge bg-{% if item.reservation.status.value == 'CONFIRMED' %}success{% elif item.reservation.status.value == 'PENDING' %}warning{% else %}secondary{% endif %}">
|
|
||||||
{{ item.reservation.status.value }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row small">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<i class="bi bi-clock"></i>
|
|
||||||
{{ item.reservation.start_time.strftime('%H:%M') }} - {{ item.reservation.end_time.strftime('%H:%M') }}
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<i class="bi bi-people"></i>
|
|
||||||
{{ item.reservation.expected_attendees }} attendees
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if item.reservation.purpose %}
|
|
||||||
<p class="card-text small text-muted mt-2">
|
|
||||||
{{ item.reservation.purpose }}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="mt-2">
|
|
||||||
<a href="{{ url_for('schedule.view_reservation', id=item.reservation.id) }}" class="btn btn-sm btn-outline-primary">
|
|
||||||
<i class="bi bi-eye"></i> Details
|
|
||||||
</a>
|
|
||||||
{% if item.reservation.status.value == 'PENDING' %}
|
|
||||||
<a href="{{ url_for('schedule.confirm_reservation', id=item.reservation.id) }}"
|
|
||||||
class="btn btn-sm btn-outline-success ms-1"
|
|
||||||
onclick="return confirm('Confirm this reservation?')">
|
|
||||||
<i class="bi bi-check-circle"></i> Confirm
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="text-center text-muted py-3">
|
|
||||||
<i class="bi bi-circle"></i> No reservations scheduled
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Grid View (hidden by default) -->
|
|
||||||
<div id="grid-view" style="display: none;">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Time</th>
|
|
||||||
<th>Classroom</th>
|
|
||||||
<th>Subject</th>
|
|
||||||
<th>Teacher</th>
|
|
||||||
<th>Purpose</th>
|
|
||||||
<th>Attendees</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for reservation in reservations %}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
{{ reservation.start_time.strftime('%H:%M') }} -
|
|
||||||
{{ reservation.end_time.strftime('%H:%M') }}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<i class="bi bi-geo-alt"></i>
|
|
||||||
{{ reservation.classroom.code }}
|
|
||||||
</td>
|
|
||||||
<td>{{ reservation.commission.get_full_code() }}</td>
|
|
||||||
<td>{{ reservation.user.first_name }} {{ reservation.user.last_name }}</td>
|
|
||||||
<td>{{ reservation.purpose }}</td>
|
|
||||||
<td>{{ reservation.expected_attendees }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge bg-{% if reservation.status.value == 'CONFIRMED' %}success{% elif reservation.status.value == 'PENDING' %}warning{% else %}secondary{% endif %}">
|
|
||||||
{{ reservation.status.value }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}"
|
|
||||||
class="btn btn-sm btn-outline-primary">
|
|
||||||
<i class="bi bi-eye"></i>
|
|
||||||
</a>
|
|
||||||
{% if reservation.status.value == 'PENDING' %}
|
|
||||||
<a href="{{ url_for('schedule.confirm_reservation', id=reservation.id) }}"
|
|
||||||
class="btn btn-sm btn-outline-success ms-1"
|
|
||||||
onclick="return confirm('Confirm this reservation?')">
|
|
||||||
<i class="bi bi-check-circle"></i>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Daily Summary -->
|
|
||||||
<div class="row mt-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card border-0 shadow-sm">
|
|
||||||
<div class="card-header bg-white border-0">
|
|
||||||
<h5 class="mb-0">
|
|
||||||
<i class="bi bi-graph-up text-warning"></i> Daily Analytics
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="mb-2">
|
|
||||||
<i class="bi bi-door-open-fill text-success" style="font-size: 2rem;"></i>
|
|
||||||
</div>
|
|
||||||
<h6>Most Active Hours</h6>
|
|
||||||
<!-- Calculate peak hours from data -->
|
|
||||||
{% set peak_hours = [] %}
|
|
||||||
{% for item in reservations %}
|
|
||||||
{% set slot = item.start_time.hour %}
|
|
||||||
{% if slot not in peak_hours %}
|
|
||||||
{% set _ = peak_hours.append(slot) %}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
<p class="text-muted">
|
|
||||||
{% if peak_hours %}
|
|
||||||
{% for hour in peak_hours[:3]|sort %}
|
|
||||||
{{ "%02d:00"|format(hour) }}{% if not loop.last %}, {% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
No data available
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="mb-2">
|
|
||||||
<i class="bi bi-building text-info" style="font-size: 2rem;"></i>
|
|
||||||
</div>
|
|
||||||
<h6>Busiest Classrooms</h6>
|
|
||||||
<p class="text-muted">
|
|
||||||
{% if reservations %}
|
|
||||||
{% set classroom_codes = [] %}
|
|
||||||
{% for item in reservations %}
|
|
||||||
{% if item.classroom.code not in classroom_codes %}
|
|
||||||
{% set _ = classroom_codes.append(item.classroom.code) %}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
{% for code in classroom_codes[:3] %}
|
|
||||||
{{ code }}{% if not loop.last %}, {% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
No data available
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="mb-2">
|
|
||||||
<i class="bi bi-clock-fill text-primary" style="font-size: 2rem;"></i>
|
|
||||||
</div>
|
|
||||||
<h6>Total Hours Booked</h6>
|
|
||||||
<p class="text-muted">
|
|
||||||
{% set total_hours = 0 %}
|
|
||||||
{% for item in reservations %}
|
|
||||||
{% set duration = (item.end_time - item.start_time).total_seconds() / 3600 %}
|
|
||||||
{% set total_hours = (total_hours + duration) %}
|
|
||||||
{% endfor %}
|
|
||||||
{{ total_hours|round(1) }} hours total
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% else %}
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="text-center py-5">
|
|
||||||
<i class="bi bi-calendar-x text-muted" style="font-size: 4rem;"></i>
|
|
||||||
<h4 class="mt-3 text-muted">No reservations scheduled for today</h4>
|
|
||||||
<p class="text-muted">
|
|
||||||
There are no classroom reservations for {{ today.strftime('%B %d, %Y') }}.
|
|
||||||
</p>
|
|
||||||
<div class="mt-3">
|
|
||||||
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-success">
|
|
||||||
<i class="bi bi-plus-circle"></i> Schedule First Class
|
|
||||||
</a>
|
|
||||||
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-primary ms-2">
|
|
||||||
<i class="bi bi-calendar-week"></i>View Week Schedule
|
|
||||||
</a>
|
|
||||||
{# Access to Genetic Algorithm Optimizer - Only for admin/teacher roles #}
|
|
||||||
{% if current_user and current_user.role in ['admin', 'teacher'] %}
|
|
||||||
<a href="{{ url_for('main.genetic_optimizer') }}" class="btn btn-info text-white ms-2">
|
|
||||||
<i class="bi bi-cpu"></i> AI Room Optimizer
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
|
||||||
$(document).ready(function() {
|
|
||||||
// Toggle between timeline and grid view
|
|
||||||
$('#toggle-view-btn').on('click', function() {
|
|
||||||
const timelineView = $('#timeline-view');
|
|
||||||
const gridView = $('#grid-view');
|
|
||||||
const btn = $(this);
|
|
||||||
|
|
||||||
if (timelineView.is(':visible')) {
|
|
||||||
timelineView.hide();
|
|
||||||
gridView.show();
|
|
||||||
btn.html('<i class="bi bi-list"></i> Timeline View');
|
|
||||||
} else {
|
|
||||||
timelineView.show();
|
|
||||||
gridView.hide();
|
|
||||||
btn.html('<i class="bi bi-grid"></i> Grid View');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Refresh the page
|
|
||||||
$('#refresh-btn').on('click', function() {
|
|
||||||
location.reload();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-refresh every 5 minutes
|
|
||||||
setInterval(function() {
|
|
||||||
location.reload();
|
|
||||||
}, 300000); // 5 minutes = 300000 milliseconds
|
|
||||||
|
|
||||||
// Highlight current time
|
|
||||||
const currentHour = new Date().getHours();
|
|
||||||
if (currentHour >= 7 && currentHour < 22) {
|
|
||||||
const timeBlockIndex = currentHour - 7;
|
|
||||||
$('.row.border-bottom').eq(timeBlockIndex).addClass('bg-primary bg-opacity-10');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
[python: **.py]
|
|
||||||
[jinja2: **/templates/**.html]
|
|
||||||
extensions=jinja2.ext.i18n
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
from flask import Flask, request, session
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_migrate import Migrate
|
||||||
|
from flask_cors import CORS
|
||||||
|
from flask_babel import Babel, gettext as _
|
||||||
|
from config.config import Config
|
||||||
|
|
||||||
|
# Initialize extensions
|
||||||
|
db = SQLAlchemy()
|
||||||
|
migrate = Migrate()
|
||||||
|
cors = CORS()
|
||||||
|
babel = Babel()
|
||||||
|
|
||||||
|
def create_app(config_class=Config):
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config.from_object(config_class)
|
||||||
|
|
||||||
|
# Initialize extensions with app
|
||||||
|
db.init_app(app)
|
||||||
|
migrate.init_app(app, db)
|
||||||
|
cors.init_app(app)
|
||||||
|
|
||||||
|
# Import models to ensure they are registered
|
||||||
|
from app.models import user
|
||||||
|
# Register REST API (v1) Blueprints with Stateless JWT support
|
||||||
|
from app.routes.api.auth import api_auth_bp
|
||||||
|
from app.routes.api.classrooms import api_classrooms_bp
|
||||||
|
from app.routes.api.reservations import api_reservations_bp
|
||||||
|
from app.routes.api.optimizer import api_optimizer_bp
|
||||||
|
from app.routes.api.dashboard import api_dashboard_bp
|
||||||
|
from app.routes.api.admin import api_admin_bp
|
||||||
|
from app.security import SecurityFilterChain
|
||||||
|
|
||||||
|
# Register API v1 blueprints
|
||||||
|
app.register_blueprint(api_auth_bp)
|
||||||
|
app.register_blueprint(api_classrooms_bp)
|
||||||
|
app.register_blueprint(api_reservations_bp)
|
||||||
|
app.register_blueprint(api_optimizer_bp)
|
||||||
|
app.register_blueprint(api_dashboard_bp)
|
||||||
|
app.register_blueprint(api_admin_bp, url_prefix='/api/v1')
|
||||||
|
app.register_blueprint(api_admin_bp, url_prefix='/api/v1/admin', name='api_admin_prefixed')
|
||||||
|
|
||||||
|
# Initialize Enterprise Security Pipeline (SecurityFilterChain)
|
||||||
|
SecurityFilterChain(app)
|
||||||
|
|
||||||
|
# Language selector function
|
||||||
|
def get_locale():
|
||||||
|
# 1. Si el usuario está autenticado y tiene idioma configurado explícitamente en su perfil, prevalece.
|
||||||
|
from flask_login import current_user
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
if getattr(current_user, 'preferred_language', None) and current_user.preferred_language in app.config['LANGUAGES']:
|
||||||
|
return current_user.preferred_language
|
||||||
|
# El idioma por defecto siempre será español a menos que se configure a mano
|
||||||
|
return app.config['BABEL_DEFAULT_LOCALE']
|
||||||
|
|
||||||
|
# 2. Parámetro explícito en la URL (?lang=es o ?lang=en) (para usuarios no autenticados o login)
|
||||||
|
lang = request.args.get('lang')
|
||||||
|
if lang and lang in app.config['LANGUAGES']:
|
||||||
|
session['language'] = lang
|
||||||
|
return lang
|
||||||
|
|
||||||
|
# 3. Si ya hay un idioma guardado en la sesión
|
||||||
|
if 'language' in session and session['language'] in app.config['LANGUAGES']:
|
||||||
|
return session['language']
|
||||||
|
|
||||||
|
# 4. Primera vez que se accede a la web (sin sesión): respetar el idioma del navegador
|
||||||
|
if request.accept_languages:
|
||||||
|
for accepted_lang, _quality in request.accept_languages:
|
||||||
|
primary = accepted_lang.split('-')[0].lower()
|
||||||
|
if primary in app.config['LANGUAGES']:
|
||||||
|
session['language'] = primary # Se guarda para la sesión actual
|
||||||
|
return primary
|
||||||
|
|
||||||
|
# Fallback del sistema: Español
|
||||||
|
return app.config['BABEL_DEFAULT_LOCALE']
|
||||||
|
|
||||||
|
|
||||||
|
# Babel context processor
|
||||||
|
@app.context_processor
|
||||||
|
def inject_conf_vars():
|
||||||
|
return dict(
|
||||||
|
languages=app.config['LANGUAGES'],
|
||||||
|
current_lang=str(get_locale())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Configure Babel
|
||||||
|
babel.init_app(app, locale_selector=get_locale)
|
||||||
|
|
||||||
|
# Register localized date and datetime formatters for Jinja2 templates
|
||||||
|
from flask_babel import format_date, format_datetime
|
||||||
|
app.jinja_env.globals['format_date'] = format_date
|
||||||
|
app.jinja_env.globals['format_datetime'] = format_datetime
|
||||||
|
app.jinja_env.filters['format_date'] = format_date
|
||||||
|
app.jinja_env.filters['format_datetime'] = format_datetime
|
||||||
|
|
||||||
|
return app
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import StringField, PasswordField, BooleanField, SubmitField
|
||||||
|
from wtforms.validators import DataRequired, Email, Length
|
||||||
|
from flask_babel import lazy_gettext as _l
|
||||||
|
|
||||||
|
class LoginForm(FlaskForm):
|
||||||
|
email = StringField(_l('Email'),
|
||||||
|
validators=[
|
||||||
|
DataRequired(),
|
||||||
|
Email(),
|
||||||
|
Length(max=120)
|
||||||
|
],
|
||||||
|
filters=[lambda x: x.strip().lower() if x else '']
|
||||||
|
)
|
||||||
|
password = PasswordField(_l('Password'), validators=[DataRequired()])
|
||||||
|
remember_me = BooleanField(_l('Remember me'))
|
||||||
|
submit = SubmitField(_l('Sign In'))
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import StringField, IntegerField, TextAreaField, BooleanField, SubmitField
|
||||||
|
from wtforms.validators import DataRequired, Length, NumberRange, Optional
|
||||||
|
from flask_babel import lazy_gettext as _l
|
||||||
|
|
||||||
|
class BuildingForm(FlaskForm):
|
||||||
|
name = StringField(_l('Building Name'), validators=[
|
||||||
|
DataRequired(),
|
||||||
|
Length(min=2, max=100)
|
||||||
|
])
|
||||||
|
code = StringField(_l('Code'), validators=[
|
||||||
|
Optional(),
|
||||||
|
Length(max=50)
|
||||||
|
])
|
||||||
|
address = StringField(_l('Address'), validators=[
|
||||||
|
Optional(),
|
||||||
|
Length(max=255)
|
||||||
|
])
|
||||||
|
floors = IntegerField(_l('Number of Floors'), default=1, validators=[
|
||||||
|
DataRequired(),
|
||||||
|
NumberRange(min=1, max=100)
|
||||||
|
])
|
||||||
|
description = TextAreaField(_l('Description'), validators=[
|
||||||
|
Optional(),
|
||||||
|
Length(max=500)
|
||||||
|
])
|
||||||
|
is_active = BooleanField(_l('Active'), default=True)
|
||||||
|
submit = SubmitField(_l('Save Building'))
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import StringField, IntegerField, TextAreaField, BooleanField, SubmitField, SelectField
|
||||||
|
from wtforms.validators import DataRequired, InputRequired, Length, NumberRange, Optional, ValidationError
|
||||||
|
from flask_babel import lazy_gettext as _l
|
||||||
|
|
||||||
|
def validate_capacity(form, field):
|
||||||
|
val = str(field.data or '').strip()
|
||||||
|
if not val:
|
||||||
|
raise ValidationError(_l('La capacidad es obligatoria (ingrese un número o el símbolo ∞).'))
|
||||||
|
if val in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
num = int(val)
|
||||||
|
if num < 0:
|
||||||
|
raise ValidationError(_l('La capacidad no puede ser negativa.'))
|
||||||
|
if num > 10000:
|
||||||
|
raise ValidationError(_l('La capacidad máxima numérica es 10.000.'))
|
||||||
|
except ValueError:
|
||||||
|
raise ValidationError(_l('Ingrese un número entero o el símbolo ∞ para capacidad ilimitada.'))
|
||||||
|
|
||||||
|
class ClassroomForm(FlaskForm):
|
||||||
|
building_id = SelectField(_l('Edificio'), validators=[DataRequired()])
|
||||||
|
new_building_name = StringField(_l('Nombre del Nuevo Edificio'), validators=[Optional(), Length(max=100)])
|
||||||
|
room_number = StringField(_l('Número / Nombre del Aula'), validators=[
|
||||||
|
DataRequired(),
|
||||||
|
Length(min=1, max=50)
|
||||||
|
])
|
||||||
|
floor = StringField(_l('Piso'), validators=[
|
||||||
|
DataRequired(message=_l('El piso es obligatorio (ej: PB, 1, 2, 4°P)')),
|
||||||
|
Length(min=1, max=50)
|
||||||
|
])
|
||||||
|
capacity = StringField(_l('Capacidad'), validators=[
|
||||||
|
validate_capacity
|
||||||
|
])
|
||||||
|
description = TextAreaField(_l('Descripción'), validators=[
|
||||||
|
Optional(),
|
||||||
|
Length(max=500)
|
||||||
|
])
|
||||||
|
is_active = BooleanField(_l('Activa'), default=True)
|
||||||
|
submit = SubmitField(_l('Guardar Aula'))
|
||||||
|
|
||||||
|
class ClassroomResourceForm(FlaskForm):
|
||||||
|
resource_type = SelectField(_l('Resource Type'), choices=[
|
||||||
|
('PROJECTOR', _l('Projector')),
|
||||||
|
('WHITEBOARD', _l('Whiteboard')),
|
||||||
|
('COMPUTER', _l('Computer')),
|
||||||
|
('SOUND_SYSTEM', _l('Sound System')),
|
||||||
|
('MICROPHONE', _l('Microphone')),
|
||||||
|
('AIR_CONDITIONER', _l('Air Conditioner')),
|
||||||
|
('OTHER', _l('Other'))
|
||||||
|
], validators=[DataRequired()])
|
||||||
|
brand = StringField(_l('Brand'), validators=[Optional(), Length(max=100)])
|
||||||
|
model = StringField(_l('Model'), validators=[Optional(), Length(max=100)])
|
||||||
|
specifications = TextAreaField(_l('Specifications'), validators=[Optional()])
|
||||||
|
functional = BooleanField(_l('Functional'), default=True)
|
||||||
|
submit = SubmitField(_l('Save Resource'))
|
||||||
@@ -1,26 +1,35 @@
|
|||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import SelectField, StringField, TextAreaField, IntegerField, SubmitField, DateTimeLocalField
|
from wtforms import SelectField, StringField, TextAreaField, IntegerField, SubmitField, DateTimeLocalField
|
||||||
from wtforms.validators import DataRequired, Length, NumberRange, Optional, ValidationError
|
from wtforms.validators import DataRequired, Length, NumberRange, Optional, ValidationError
|
||||||
|
from flask_babel import lazy_gettext as _l
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
class ReservationForm(FlaskForm):
|
class ReservationForm(FlaskForm):
|
||||||
classroom_id = SelectField('Classroom', coerce=int, validators=[DataRequired()])
|
classroom_id = SelectField(_l('Classroom'), coerce=int, validators=[DataRequired()])
|
||||||
commission_id = SelectField('Commission/Subject', coerce=int, validators=[DataRequired()])
|
commission_id = SelectField(_l('Commission/Subject'), coerce=int, validators=[DataRequired()])
|
||||||
start_time = DateTimeLocalField('Start Time', format='%Y-%m-%dT%H:%M', validators=[DataRequired()])
|
start_time = DateTimeLocalField(_l('Start Time'), format='%Y-%m-%dT%H:%M', validators=[DataRequired()])
|
||||||
end_time = DateTimeLocalField('End Time', format='%Y-%m-%dT%H:%M', validators=[DataRequired()])
|
end_time = DateTimeLocalField(_l('End Time'), format='%Y-%m-%dT%H:%M', validators=[DataRequired()])
|
||||||
purpose = StringField('Purpose', validators=[
|
purpose = StringField(_l('Purpose'), validators=[
|
||||||
DataRequired(),
|
DataRequired(),
|
||||||
Length(min=3, max=500)
|
Length(min=3, max=500)
|
||||||
])
|
])
|
||||||
expected_attendees = IntegerField('Expected Attendees', validators=[
|
expected_attendees = IntegerField(_l('Expected Attendees'), validators=[
|
||||||
DataRequired(),
|
DataRequired(),
|
||||||
NumberRange(min=1, max=500)
|
NumberRange(min=1, max=500)
|
||||||
])
|
])
|
||||||
notes = TextAreaField('Notes', validators=[
|
notes = TextAreaField(_l('Notes'), validators=[
|
||||||
Optional(),
|
Optional(),
|
||||||
Length(max=1000)
|
Length(max=1000)
|
||||||
])
|
])
|
||||||
submit = SubmitField('Make Reservation')
|
virtual_link = StringField(_l('Virtual Classroom Link'), validators=[
|
||||||
|
Optional(),
|
||||||
|
Length(max=500)
|
||||||
|
])
|
||||||
|
submit = SubmitField(_l('Make Reservation'))
|
||||||
|
|
||||||
|
def __init__(self, *args, is_edit=False, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.is_edit = is_edit
|
||||||
|
|
||||||
def validate_end_time(self, field):
|
def validate_end_time(self, field):
|
||||||
if field.data <= self.start_time.data:
|
if field.data <= self.start_time.data:
|
||||||
@@ -31,8 +40,8 @@ class ReservationForm(FlaskForm):
|
|||||||
raise ValidationError('Reservation cannot exceed 8 hours.')
|
raise ValidationError('Reservation cannot exceed 8 hours.')
|
||||||
|
|
||||||
def validate_start_time(self, field):
|
def validate_start_time(self, field):
|
||||||
# Don't allow reservations in the past
|
# Don't allow new reservations in the past
|
||||||
if field.data < datetime.now():
|
if not self.is_edit and field.data < datetime.now() - timedelta(minutes=10):
|
||||||
raise ValidationError('Start time cannot be in the past.')
|
raise ValidationError('Start time cannot be in the past.')
|
||||||
|
|
||||||
# Don't allow reservations too far in the future (max 3 months)
|
# Don't allow reservations too far in the future (max 3 months)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from .user import User
|
||||||
|
from .building import Building
|
||||||
|
from .classroom import Classroom, ClassroomResource
|
||||||
|
from .career import Career
|
||||||
|
from .academic_term import AcademicTerm
|
||||||
|
from .subject import Subject, Commission
|
||||||
|
from .reservation import Reservation, ReservationStatus
|
||||||
|
from .genetic_algorithm import GeneticAlgorithm, ReservationOptimizer
|
||||||
|
from .role import Role, Permission, SYSTEM_MODULES
|
||||||
|
from .milestone import MilestoneType, AcademicMilestone, BASE_MILESTONE_TYPES
|
||||||
|
from .audit_log import AuditLog
|
||||||
|
from .enrollment import StudentEnrollment
|
||||||
|
from .setting import SystemSetting
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'User',
|
||||||
|
'Building',
|
||||||
|
'Classroom',
|
||||||
|
'ClassroomResource',
|
||||||
|
'Career',
|
||||||
|
'AcademicTerm',
|
||||||
|
'Subject',
|
||||||
|
'Commission',
|
||||||
|
'Reservation',
|
||||||
|
'ReservationStatus',
|
||||||
|
'GeneticAlgorithm',
|
||||||
|
'ReservationOptimizer',
|
||||||
|
'Role',
|
||||||
|
'Permission',
|
||||||
|
'SYSTEM_MODULES',
|
||||||
|
'MilestoneType',
|
||||||
|
'AcademicMilestone',
|
||||||
|
'BASE_MILESTONE_TYPES',
|
||||||
|
'AuditLog',
|
||||||
|
'StudentEnrollment',
|
||||||
|
'SystemSetting'
|
||||||
|
]
|
||||||
@@ -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}>'
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from app import db
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class AuditLog(db.Model):
|
||||||
|
"""
|
||||||
|
Bitácora inmutable de auditoría para registro de eventos críticos
|
||||||
|
y trazabilidad de operaciones institucionales.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'audit_logs'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||||
|
user_email = db.Column(db.String(255), nullable=True)
|
||||||
|
action = db.Column(db.String(50), nullable=False, index=True) # CREATE, UPDATE, DELETE, LOGIN, LOGOUT
|
||||||
|
module = db.Column(db.String(50), nullable=False, index=True) # classrooms, reservations, auth, users
|
||||||
|
entity_id = db.Column(db.Integer, nullable=True) # ID del registro afectado
|
||||||
|
details = db.Column(db.Text, nullable=True) # Detalles de cambios o parámetros
|
||||||
|
ip_address = db.Column(db.String(45), nullable=True) # Soporta IPv4 e IPv6
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||||
|
|
||||||
|
# Relación con usuario
|
||||||
|
user = db.relationship('User', foreign_keys=[user_id], backref=db.backref('audit_records', lazy='dynamic'))
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<AuditLog #{self.id} {self.action} on {self.module} by {self.user_email or 'System'}>"
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from app import db
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class Building(db.Model):
|
||||||
|
__tablename__ = 'buildings'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(100), unique=True, nullable=False, index=True)
|
||||||
|
code = db.Column(db.String(50), unique=True, nullable=True)
|
||||||
|
address = db.Column(db.String(255), nullable=True)
|
||||||
|
floors = db.Column(db.Integer, default=1, nullable=False)
|
||||||
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
classrooms = db.relationship('Classroom', backref='building_entity', lazy=True, foreign_keys='Classroom.building_id')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_capacity(self):
|
||||||
|
"""Total student capacity across active classrooms in this building"""
|
||||||
|
return sum(c.capacity for c in self.classrooms if c.is_active)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def classrooms_count(self):
|
||||||
|
"""Number of active classrooms"""
|
||||||
|
return len([c for c in self.classrooms if c.is_active])
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'name': self.name,
|
||||||
|
'code': self.code,
|
||||||
|
'address': self.address,
|
||||||
|
'floors': self.floors,
|
||||||
|
'description': self.description,
|
||||||
|
'is_active': self.is_active,
|
||||||
|
'classrooms_count': self.classrooms_count,
|
||||||
|
'total_capacity': self.total_capacity,
|
||||||
|
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||||
|
}
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Building {self.name}>'
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from app import db
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class Career(db.Model):
|
||||||
|
__tablename__ = 'careers'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(150), unique=True, nullable=False, index=True)
|
||||||
|
code = db.Column(db.String(50), nullable=True)
|
||||||
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
subjects = db.relationship('Subject', backref='career_obj', lazy=True)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'name': self.name,
|
||||||
|
'code': self.code,
|
||||||
|
'description': self.description,
|
||||||
|
'subjects_count': len(self.subjects) if self.subjects else 0,
|
||||||
|
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||||
|
}
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Career {self.name}>'
|
||||||
@@ -6,25 +6,60 @@ class Classroom(db.Model):
|
|||||||
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
campus_id = db.Column(db.Integer, nullable=True) # Maps to campus_id in DB
|
campus_id = db.Column(db.Integer, nullable=True) # Maps to campus_id in DB
|
||||||
|
building_id = db.Column(db.Integer, db.ForeignKey('buildings.id'), nullable=True)
|
||||||
building = db.Column(db.String(100), nullable=False)
|
building = db.Column(db.String(100), nullable=False)
|
||||||
code = db.Column('room_number', db.String(50), nullable=False) # Maps to room_number column in DB
|
code = db.Column('room_number', db.String(50), nullable=False) # Maps to room_number column in DB
|
||||||
floor = db.Column(db.Integer, nullable=False)
|
floor = db.Column(db.String(50), nullable=False, default='1')
|
||||||
capacity = db.Column(db.Integer, nullable=False)
|
capacity = db.Column(db.Integer, nullable=False)
|
||||||
description = db.Column(db.Text)
|
description = db.Column(db.Text)
|
||||||
is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB
|
is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relación con Building (mapeada vía backref 'building_entity' en Building.classrooms)
|
||||||
|
@property
|
||||||
|
def building_rel(self):
|
||||||
|
return self.building_entity
|
||||||
|
|
||||||
|
@property
|
||||||
|
def building_name(self):
|
||||||
|
"""Nombre del edificio desacoplado"""
|
||||||
|
if self.building_rel:
|
||||||
|
return self.building_rel.name
|
||||||
|
return self.building or 'Edificio Central'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def floor_display(self):
|
||||||
|
"""Formato amigable para el piso: PB, Planta Baja, Piso X"""
|
||||||
|
f = str(self.floor).strip() if self.floor is not None else ''
|
||||||
|
if f in ['0', 'PB', 'pb', 'Planta Baja', 'planta baja']:
|
||||||
|
return 'Planta Baja'
|
||||||
|
if f.lower().startswith('piso') or '°' in f:
|
||||||
|
return f
|
||||||
|
return f"Piso {f}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_virtual(self):
|
||||||
|
"""Indica si el aula es virtual"""
|
||||||
|
return (self.code and 'VIRTUAL' in self.code.upper()) or (self.building and 'VIRTUAL' in self.building.upper())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def location_display(self):
|
||||||
|
"""Ubicación completa y limpia: Edificio · Piso · Aula"""
|
||||||
|
if self.is_virtual:
|
||||||
|
return '🌐 Campus Virtual'
|
||||||
|
return f"{self.building_name} · {self.floor_display} · {self.code}"
|
||||||
|
|
||||||
# Virtual properties for compatibility
|
# Virtual properties for compatibility
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
"""Generate name from building and code"""
|
"""Generate name from building and code"""
|
||||||
return f"{self.building}-{self.code}"
|
return f"{self.building_name}-{self.code}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def campus_location(self):
|
def campus_location(self):
|
||||||
"""Placeholder for campus location - can be expanded when campus model is implemented"""
|
"""Placeholder for campus location"""
|
||||||
return None
|
return self.building_rel.address if self.building_rel else None
|
||||||
|
|
||||||
# Virtual field for compatibility - combines building and code
|
# Virtual field for compatibility - combines building and code
|
||||||
@property
|
@property
|
||||||
@@ -38,17 +73,34 @@ class Classroom(db.Model):
|
|||||||
# Override code property for consistency
|
# Override code property for consistency
|
||||||
@property
|
@property
|
||||||
def code_display(self):
|
def code_display(self):
|
||||||
return f"{self.building}-{self.code}"
|
return f"{self.building_name} - {self.code}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capacity_display(self):
|
||||||
|
"""Retorna '∞' para aulas virtuales o capacidad ilimitada, o el número entero."""
|
||||||
|
if self.capacity == 0 or self.is_virtual:
|
||||||
|
return '∞'
|
||||||
|
return str(self.capacity)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_unlimited(self):
|
||||||
|
return self.capacity == 0 or self.is_virtual
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
"""Convert classroom to dictionary"""
|
"""Convert classroom to dictionary"""
|
||||||
return {
|
return {
|
||||||
'id': self.id,
|
'id': self.id,
|
||||||
'code': self.code_display,
|
'building_id': self.building_id,
|
||||||
'building': self.building,
|
'building': self.building_name,
|
||||||
|
'code': self.code,
|
||||||
|
'code_display': self.code_display,
|
||||||
'name': self.name,
|
'name': self.name,
|
||||||
'floor': self.floor,
|
'floor': self.floor,
|
||||||
|
'floor_display': self.floor_display,
|
||||||
|
'location_display': self.location_display,
|
||||||
'capacity': self.capacity,
|
'capacity': self.capacity,
|
||||||
|
'capacity_display': self.capacity_display,
|
||||||
|
'is_virtual': self.is_virtual,
|
||||||
'description': self.description,
|
'description': self.description,
|
||||||
'campus_location': self.campus_location,
|
'campus_location': self.campus_location,
|
||||||
'is_active': self.is_active,
|
'is_active': self.is_active,
|
||||||
@@ -59,10 +111,9 @@ class Classroom(db.Model):
|
|||||||
|
|
||||||
def get_full_location(self):
|
def get_full_location(self):
|
||||||
"""Get full classroom location"""
|
"""Get full classroom location"""
|
||||||
location = f"{self.building} - Floor {self.floor}"
|
if self.is_virtual:
|
||||||
if self.campus_location:
|
return 'Campus Virtual (Remoto)'
|
||||||
location += f" ({self.campus_location})"
|
return f"{self.building_name} - {self.floor_display}"
|
||||||
return location
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def find_best_classroom(max_students, start_time, end_time, exclude_reservation_id=None):
|
def find_best_classroom(max_students, start_time, end_time, exclude_reservation_id=None):
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
from app import db
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class StudentEnrollment(db.Model):
|
||||||
|
"""Association table: student (User) ↔ Commission enrollment managed by Bedelía."""
|
||||||
|
__tablename__ = 'student_enrollments'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
student_id = db.Column(
|
||||||
|
db.Integer,
|
||||||
|
db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||||
|
nullable=False,
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
commission_id = db.Column(
|
||||||
|
db.Integer,
|
||||||
|
db.ForeignKey('commissions.id', ondelete='CASCADE'),
|
||||||
|
nullable=False,
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
status = db.Column(db.String(20), nullable=False, default='activo') # activo | retirado | condicional
|
||||||
|
enrolled_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
notes = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
# Unique constraint: a student can only be enrolled once per commission
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint('student_id', 'commission_id', name='uq_student_commission'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
student = db.relationship('User', backref=db.backref('enrollments', lazy='dynamic', cascade='all, delete-orphan'))
|
||||||
|
commission = db.relationship('Commission', backref=db.backref('enrollments', lazy='dynamic', cascade='all, delete-orphan'))
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'student_id': self.student_id,
|
||||||
|
'student_name': self.student.name if self.student else None,
|
||||||
|
'student_email': self.student.email if self.student else None,
|
||||||
|
'commission_id': self.commission_id,
|
||||||
|
'status': self.status,
|
||||||
|
'enrolled_at': self.enrolled_at.isoformat() if self.enrolled_at else None,
|
||||||
|
'notes': self.notes,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<StudentEnrollment student={self.student_id} commission={self.commission_id} status={self.status}>'
|
||||||
@@ -46,10 +46,31 @@ class Individual:
|
|||||||
self.conflicts = []
|
self.conflicts = []
|
||||||
|
|
||||||
def calculate_fitness(self, classrooms: Dict[int, Classroom], requests: Dict[int, ReservationRequest]) -> float:
|
def calculate_fitness(self, classrooms: Dict[int, Classroom], requests: Dict[int, ReservationRequest]) -> float:
|
||||||
"""Calculate fitness score based on multiple factors"""
|
"""Calculate fitness score based on multiple factors with fast O(K log K) interval conflict detection"""
|
||||||
score = 0.0
|
score = 0.0
|
||||||
self.conflicts = []
|
self.conflicts = []
|
||||||
|
|
||||||
|
# Pre-group genes by classroom to calculate conflicts in O(K log K)
|
||||||
|
by_classroom = {}
|
||||||
|
for g in self.genes:
|
||||||
|
by_classroom.setdefault(g.classroom_id, []).append(g)
|
||||||
|
|
||||||
|
conflict_count_by_id = {}
|
||||||
|
for cid, room_genes in by_classroom.items():
|
||||||
|
if len(room_genes) <= 1:
|
||||||
|
continue
|
||||||
|
# Sort chronologically
|
||||||
|
room_genes.sort(key=lambda x: x.start_time)
|
||||||
|
for i in range(len(room_genes)):
|
||||||
|
g_i = room_genes[i]
|
||||||
|
for j in range(i + 1, len(room_genes)):
|
||||||
|
g_j = room_genes[j]
|
||||||
|
if g_i.end_time > g_j.start_time:
|
||||||
|
conflict_count_by_id[id(g_i)] = conflict_count_by_id.get(id(g_i), 0) + 1
|
||||||
|
conflict_count_by_id[id(g_j)] = conflict_count_by_id.get(id(g_j), 0) + 1
|
||||||
|
else:
|
||||||
|
break # Gene j and subsequent ones start later than gene i ends
|
||||||
|
|
||||||
for gene in self.genes:
|
for gene in self.genes:
|
||||||
classroom = classrooms.get(gene.classroom_id)
|
classroom = classrooms.get(gene.classroom_id)
|
||||||
request = requests.get(gene.commission_id)
|
request = requests.get(gene.commission_id)
|
||||||
@@ -66,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 = []
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
class MilestoneType(db.Model):
|
||||||
|
"""
|
||||||
|
Catálogo dinámico y administrable de tipificaciones de hitos y evaluaciones académicas.
|
||||||
|
(Parciales, Trabajos Prácticos, Recuperatorios, Finales, etc.)
|
||||||
|
"""
|
||||||
|
__tablename__ = 'milestone_types'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(100), nullable=False) # Ej: "Parcial 1", "Trabajo Práctico 1"
|
||||||
|
code = db.Column(db.String(50), unique=True, nullable=False, index=True) # Ej: "parcial_1", "tp_1"
|
||||||
|
color = db.Column(db.String(30), default='#0d6efd') # Hex color para badges y calendario
|
||||||
|
description = db.Column(db.String(255), nullable=True)
|
||||||
|
sort_order = db.Column(db.Integer, default=0) # Orden de aparición en selects
|
||||||
|
is_active = db.Column(db.Boolean, default=True, nullable=False) # Activo / Inactivo
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relación uno-a-muchos con AcademicMilestone
|
||||||
|
milestones = db.relationship('AcademicMilestone', backref='type_obj', lazy='dynamic', cascade='all, delete-orphan')
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'name': self.name,
|
||||||
|
'code': self.code,
|
||||||
|
'color': self.color or '#0d6efd',
|
||||||
|
'description': self.description or '',
|
||||||
|
'sort_order': self.sort_order or 0,
|
||||||
|
'is_active': self.is_active,
|
||||||
|
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||||
|
}
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<MilestoneType {self.code}: {self.name}>'
|
||||||
|
|
||||||
|
|
||||||
|
class AcademicMilestone(db.Model):
|
||||||
|
"""
|
||||||
|
Hito o evento académico programado para una materia/comisión (Examen, TP, Entrega).
|
||||||
|
"""
|
||||||
|
__tablename__ = 'academic_milestones'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
title = db.Column(db.String(150), nullable=True)
|
||||||
|
materia_codigo = db.Column(db.String(50), nullable=False, index=True) # Código de materia (ej: INGA0003)
|
||||||
|
subject_id = db.Column(db.Integer, db.ForeignKey('subjects.id'), nullable=True)
|
||||||
|
commission_id = db.Column(db.Integer, db.ForeignKey('commissions.id', ondelete='SET NULL'), nullable=True)
|
||||||
|
milestone_type_id = db.Column(db.Integer, db.ForeignKey('milestone_types.id'), nullable=False)
|
||||||
|
date = db.Column(db.DateTime, nullable=False) # Fecha y hora del hito
|
||||||
|
classroom = db.Column(db.String(100), nullable=True) # Aula o enlace virtual asignado
|
||||||
|
link = db.Column(db.String(255), nullable=True) # Link Teams / Meet / Zoom si aplica
|
||||||
|
notes = db.Column(db.Text, nullable=True) # Temario, consignas o notas
|
||||||
|
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relación con Asignatura y Comisión
|
||||||
|
subject = db.relationship('Subject', backref=db.backref('milestones', lazy='dynamic'))
|
||||||
|
commission = db.relationship('Commission', backref=db.backref('academic_milestones', lazy='dynamic', cascade='all, delete-orphan'))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tipo(self):
|
||||||
|
return self.type_obj.name if self.type_obj else 'Otro'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def color(self):
|
||||||
|
return self.type_obj.color if self.type_obj and self.type_obj.color else '#0d6efd'
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'title': self.title or self.tipo,
|
||||||
|
'materiaCodigo': self.materia_codigo,
|
||||||
|
'subject_id': self.subject_id,
|
||||||
|
'commission_id': self.commission_id,
|
||||||
|
'tipo': self.tipo,
|
||||||
|
'milestone_type_id': self.milestone_type_id,
|
||||||
|
'tipo_info': self.type_obj.to_dict() if self.type_obj else None,
|
||||||
|
'color': self.color,
|
||||||
|
'fecha': self.date.isoformat() if self.date else None,
|
||||||
|
'aula': self.classroom,
|
||||||
|
'link': self.link,
|
||||||
|
'notas': self.notes,
|
||||||
|
'is_active': self.is_active
|
||||||
|
}
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<AcademicMilestone {self.materia_codigo} - {self.tipo} ({self.date})>'
|
||||||
|
|
||||||
|
|
||||||
|
# Datos semilla estándar para inicialización
|
||||||
|
BASE_MILESTONE_TYPES = [
|
||||||
|
{
|
||||||
|
'name': 'Parcial 1',
|
||||||
|
'code': 'parcial_1',
|
||||||
|
'color': '#0d6efd',
|
||||||
|
'description': 'Primer examen parcial de la cursada',
|
||||||
|
'sort_order': 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Parcial 2',
|
||||||
|
'code': 'parcial_2',
|
||||||
|
'color': '#0b5ed7',
|
||||||
|
'description': 'Segundo examen parcial de la cursada',
|
||||||
|
'sort_order': 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Trabajo Practico 1',
|
||||||
|
'code': 'tp_1',
|
||||||
|
'color': '#198754',
|
||||||
|
'description': 'Primera entrega o evaluacion practica',
|
||||||
|
'sort_order': 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Trabajo Practico 2',
|
||||||
|
'code': 'tp_2',
|
||||||
|
'color': '#157347',
|
||||||
|
'description': 'Segunda entrega o evaluacion practica',
|
||||||
|
'sort_order': 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Trabajo Practico 3',
|
||||||
|
'code': 'tp_3',
|
||||||
|
'color': '#0f5132',
|
||||||
|
'description': 'Tercera entrega o proyecto integrador',
|
||||||
|
'sort_order': 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Recuperatorio Parcial 1',
|
||||||
|
'code': 'recu_p1',
|
||||||
|
'color': '#fd7e14',
|
||||||
|
'description': 'Instancia de recuperacion para el Parcial 1',
|
||||||
|
'sort_order': 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Recuperatorio Parcial 2',
|
||||||
|
'code': 'recu_p2',
|
||||||
|
'color': '#e76b08',
|
||||||
|
'description': 'Instancia de recuperacion para el Parcial 2',
|
||||||
|
'sort_order': 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Recuperatorio TP 1',
|
||||||
|
'code': 'recu_tp1',
|
||||||
|
'color': '#ffc107',
|
||||||
|
'description': 'Reentrega o recuperacion de Trabajo Practico 1',
|
||||||
|
'sort_order': 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Recuperatorio TP 2',
|
||||||
|
'code': 'recu_tp2',
|
||||||
|
'color': '#e0a800',
|
||||||
|
'description': 'Reentrega o recuperacion de Trabajo Practico 2',
|
||||||
|
'sort_order': 9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Recuperatorio TP 3',
|
||||||
|
'code': 'recu_tp3',
|
||||||
|
'color': '#c69500',
|
||||||
|
'description': 'Reentrega o recuperacion de Trabajo Practico 3',
|
||||||
|
'sort_order': 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Final',
|
||||||
|
'code': 'final',
|
||||||
|
'color': '#dc3545',
|
||||||
|
'description': 'Examen final regular o libre',
|
||||||
|
'sort_order': 11
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Otro',
|
||||||
|
'code': 'otro',
|
||||||
|
'color': '#6c757d',
|
||||||
|
'description': 'Taller, seminario, clase especial o hito no tipificado',
|
||||||
|
'sort_order': 12
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -1,6 +1,30 @@
|
|||||||
from app import db
|
from app import db
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from sqlalchemy.types import TypeDecorator, String
|
||||||
|
|
||||||
|
class StatusString(str):
|
||||||
|
"""String wrapper providing .value property for backward compatibility with Enum usage"""
|
||||||
|
@property
|
||||||
|
def value(self):
|
||||||
|
return str(self)
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
if hasattr(other, 'value'):
|
||||||
|
return str(self) == str(other.value)
|
||||||
|
return str(self) == str(other)
|
||||||
|
|
||||||
|
class StatusType(TypeDecorator):
|
||||||
|
impl = String(20)
|
||||||
|
cache_ok = True
|
||||||
|
|
||||||
|
def process_result_value(self, value, dialect):
|
||||||
|
return StatusString(value) if value is not None else None
|
||||||
|
|
||||||
|
def process_bind_param(self, value, dialect):
|
||||||
|
if hasattr(value, 'value'):
|
||||||
|
return str(value.value)
|
||||||
|
return str(value) if value is not None else None
|
||||||
|
|
||||||
class ReservationStatus(Enum):
|
class ReservationStatus(Enum):
|
||||||
PENDING = 'PENDING'
|
PENDING = 'PENDING'
|
||||||
@@ -20,8 +44,10 @@ class Reservation(db.Model):
|
|||||||
start_time = db.Column(db.DateTime, nullable=False)
|
start_time = db.Column(db.DateTime, nullable=False)
|
||||||
end_time = db.Column(db.DateTime, nullable=False)
|
end_time = db.Column(db.DateTime, nullable=False)
|
||||||
purpose = db.Column(db.String(500), nullable=False)
|
purpose = db.Column(db.String(500), nullable=False)
|
||||||
status = db.Column(db.String(20), default='PENDING', nullable=False) # DB uses VARCHAR not ENUM
|
status = db.Column(StatusType, default='PENDING', nullable=False) # Maps to VARCHAR(20) with StatusString
|
||||||
expected_attendees = db.Column(db.Integer, nullable=False)
|
expected_attendees = db.Column(db.Integer, nullable=False)
|
||||||
|
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
|
||||||
|
virtual_link = db.Column(db.String(500), nullable=True) # Zoom, Meet, Teams, etc.
|
||||||
notes = db.Column(db.Text)
|
notes = db.Column(db.Text)
|
||||||
|
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
@@ -32,10 +58,49 @@ class Reservation(db.Model):
|
|||||||
def status_enum(self):
|
def status_enum(self):
|
||||||
"""Get status as enum"""
|
"""Get status as enum"""
|
||||||
try:
|
try:
|
||||||
return ReservationStatus(self.status)
|
return ReservationStatus(str(self.status))
|
||||||
except (ValueError, AttributeError):
|
except (ValueError, AttributeError):
|
||||||
return ReservationStatus.PENDING
|
return ReservationStatus.PENDING
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subject_code(self):
|
||||||
|
"""Get exact subject code (e.g. INGA0003, ASIG00165)"""
|
||||||
|
if self.commission and self.commission.subject:
|
||||||
|
return self.commission.subject.code
|
||||||
|
return ''
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subject_name(self):
|
||||||
|
"""Get subject name"""
|
||||||
|
if self.commission and self.commission.subject:
|
||||||
|
return self.commission.subject.name
|
||||||
|
return self.purpose or ''
|
||||||
|
|
||||||
|
@property
|
||||||
|
def classroom_name(self):
|
||||||
|
"""Get classroom code or name"""
|
||||||
|
if self.classroom:
|
||||||
|
return self.classroom.code
|
||||||
|
return ''
|
||||||
|
|
||||||
|
@property
|
||||||
|
def effective_virtual_link(self):
|
||||||
|
"""Devuelve el enlace explícito de la reserva o hereda el de la comisión"""
|
||||||
|
if self.virtual_link and self.virtual_link.strip():
|
||||||
|
return self.virtual_link.strip()
|
||||||
|
if self.commission and self.commission.virtual_link:
|
||||||
|
return self.commission.virtual_link.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_virtual(self):
|
||||||
|
"""Verifica si la reserva es virtual por aula o por contar con enlace virtual"""
|
||||||
|
if self.classroom:
|
||||||
|
return bool(self.classroom.is_virtual)
|
||||||
|
if self.effective_virtual_link:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
classroom = db.relationship('Classroom', backref=db.backref('reservation_list', lazy=True, cascade='all, delete-orphan'))
|
classroom = db.relationship('Classroom', backref=db.backref('reservation_list', lazy=True, cascade='all, delete-orphan'))
|
||||||
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'))
|
||||||
@@ -54,6 +119,8 @@ class Reservation(db.Model):
|
|||||||
'status': self.status, # Already a string in DB
|
'status': self.status, # Already a string in DB
|
||||||
'expected_attendees': self.expected_attendees,
|
'expected_attendees': self.expected_attendees,
|
||||||
'notes': self.notes,
|
'notes': self.notes,
|
||||||
|
'virtual_link': self.effective_virtual_link,
|
||||||
|
'is_virtual': self.is_virtual,
|
||||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||||
'classroom': self.classroom.to_dict() if self.classroom else None,
|
'classroom': self.classroom.to_dict() if self.classroom else None,
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
from app import db
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Definition of system modules available for RBAC permissions
|
||||||
|
SYSTEM_MODULES = [
|
||||||
|
{
|
||||||
|
'id': 'classrooms',
|
||||||
|
'name': 'Aulas y Espacios',
|
||||||
|
'name_en': 'Classrooms & Spaces',
|
||||||
|
'icon': 'bi-door-open',
|
||||||
|
'description': 'Gestión de aulas físicas, aulas virtuales (∞) y capacidades.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'buildings',
|
||||||
|
'name': 'Edificios y Sedes',
|
||||||
|
'name_en': 'Buildings & Campuses',
|
||||||
|
'icon': 'bi-buildings',
|
||||||
|
'description': 'Administración de sede central, campus virtual, pisos y ubicaciones.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'reservations',
|
||||||
|
'name': 'Reservas de Aulas',
|
||||||
|
'name_en': 'Classroom Reservations',
|
||||||
|
'icon': 'bi-calendar-check',
|
||||||
|
'description': 'Creación, modificación, aprobación, confirmación y cancelación de reservas.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'schedule',
|
||||||
|
'name': 'Cartelera y Cronograma',
|
||||||
|
'name_en': 'Schedule & Billboard',
|
||||||
|
'icon': 'bi-calendar3',
|
||||||
|
'description': 'Visualización de calendario interactivo, cartelera de hoy y comisiones.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'academic',
|
||||||
|
'name': 'Gestión Académica',
|
||||||
|
'name_en': 'Academic Management',
|
||||||
|
'icon': 'bi-book',
|
||||||
|
'description': 'Administración de carreras, asignaturas, códigos y comisiones.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'import',
|
||||||
|
'name': 'Sincronización Sheets',
|
||||||
|
'name_en': 'Sheets Sync & Import',
|
||||||
|
'icon': 'bi-file-earmark-spreadsheet',
|
||||||
|
'description': 'Importación y sincronización de datos académicos desde Google Sheets.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'optimizer',
|
||||||
|
'name': 'Optimizador IA',
|
||||||
|
'name_en': 'AI Room Optimizer',
|
||||||
|
'icon': 'bi-cpu',
|
||||||
|
'description': 'Ejecución y configuración del algoritmo genético de asignación.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'users',
|
||||||
|
'name': 'Usuarios y Accesos RBAC',
|
||||||
|
'name_en': 'Users & Access RBAC',
|
||||||
|
'icon': 'bi-shield-lock',
|
||||||
|
'description': 'Administración de cuentas, roles y matriz granular de permisos.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'milestone_types',
|
||||||
|
'name': 'Tipificaciones de Hitos',
|
||||||
|
'name_en': 'Milestone Types',
|
||||||
|
'icon': 'bi-tags',
|
||||||
|
'description': 'Catálogo dinámico de tipos de evaluaciones, parciales, TPs y eventos.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'metrics',
|
||||||
|
'name': 'Métricas de Ocupación',
|
||||||
|
'name_en': 'Occupancy Metrics',
|
||||||
|
'icon': 'bi-graph-up',
|
||||||
|
'description': 'Visualización y análisis de porcentajes de ocupación por aula (día, semana y mes).'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'enrollment',
|
||||||
|
'name': 'Inscripciones y Cursadas',
|
||||||
|
'name_en': 'Enrollments & Courses',
|
||||||
|
'icon': 'bi-diagram-3',
|
||||||
|
'description': 'Gestión de inscripciones de alumnos a comisiones y asignación de docentes.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
ACCESS_LEVELS = ['none', 'read', 'read_write']
|
||||||
|
|
||||||
|
class Role(db.Model):
|
||||||
|
__tablename__ = 'roles'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(80), unique=True, nullable=False, index=True)
|
||||||
|
description = db.Column(db.String(255))
|
||||||
|
is_system = db.Column(db.Boolean, default=False, nullable=False)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
permissions = db.relationship('Permission', backref='role', lazy='joined', cascade='all, delete-orphan')
|
||||||
|
users = db.relationship('User', backref='role_obj', lazy='select')
|
||||||
|
|
||||||
|
def get_permission(self, module):
|
||||||
|
"""Get access level string for a given module ('none', 'read', 'read_write')"""
|
||||||
|
for p in self.permissions:
|
||||||
|
if p.module == module:
|
||||||
|
return p.access_level
|
||||||
|
return 'none'
|
||||||
|
|
||||||
|
def has_permission(self, module, min_level='read'):
|
||||||
|
"""
|
||||||
|
Check if role has sufficient permission level.
|
||||||
|
Hierarchy: 'read_write' > 'read' > 'none'
|
||||||
|
"""
|
||||||
|
current_level = self.get_permission(module)
|
||||||
|
if current_level == 'none':
|
||||||
|
return False
|
||||||
|
if min_level == 'read':
|
||||||
|
return current_level in ['read', 'read_write']
|
||||||
|
if min_level == 'read_write':
|
||||||
|
return current_level == 'read_write'
|
||||||
|
return False
|
||||||
|
|
||||||
|
def set_permission(self, module, access_level):
|
||||||
|
"""Set or update permission level for a module"""
|
||||||
|
if access_level not in ACCESS_LEVELS:
|
||||||
|
access_level = 'none'
|
||||||
|
|
||||||
|
for p in self.permissions:
|
||||||
|
if p.module == module:
|
||||||
|
p.access_level = access_level
|
||||||
|
return p
|
||||||
|
|
||||||
|
new_perm = Permission(role=self, module=module, access_level=access_level)
|
||||||
|
db.session.add(new_perm)
|
||||||
|
return new_perm
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'name': self.name,
|
||||||
|
'description': self.description,
|
||||||
|
'is_system': self.is_system,
|
||||||
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||||
|
'user_count': len(self.users) if self.users else 0,
|
||||||
|
'permissions': {p.module: p.access_level for p in self.permissions}
|
||||||
|
}
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Role {self.name}>'
|
||||||
|
|
||||||
|
|
||||||
|
class Permission(db.Model):
|
||||||
|
__tablename__ = 'permissions'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
role_id = db.Column(db.Integer, db.ForeignKey('roles.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||||
|
module = db.Column(db.String(50), nullable=False)
|
||||||
|
access_level = db.Column(db.String(20), default='none', nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint('role_id', 'module', name='uq_role_module'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Permission role={self.role_id} module={self.module} level={self.access_level}>'
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from app import db
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class SystemSetting(db.Model):
|
||||||
|
"""
|
||||||
|
Parámetros de configuración del sistema persistentes en base de datos.
|
||||||
|
Permite almacenar enlaces de integración (e.g. Google Sheets), flags globales y metadatos.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'system_settings'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
key = db.Column(db.String(100), unique=True, nullable=False, index=True)
|
||||||
|
value = db.Column(db.Text, nullable=True)
|
||||||
|
description = db.Column(db.String(255), nullable=True)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_value(cls, key, default=None):
|
||||||
|
try:
|
||||||
|
record = cls.query.filter_by(key=key).first()
|
||||||
|
if record and record.value is not None and record.value.strip() != '':
|
||||||
|
return record.value
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return default
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def set_value(cls, key, value, description=None):
|
||||||
|
record = cls.query.filter_by(key=key).first()
|
||||||
|
if not record:
|
||||||
|
record = cls(key=key, value=value, description=description)
|
||||||
|
db.session.add(record)
|
||||||
|
else:
|
||||||
|
record.value = value
|
||||||
|
if description:
|
||||||
|
record.description = description
|
||||||
|
record.updated_at = datetime.utcnow()
|
||||||
|
db.session.commit()
|
||||||
|
return record
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'key': self.key,
|
||||||
|
'value': self.value,
|
||||||
|
'description': self.description,
|
||||||
|
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
||||||
|
}
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<SystemSetting {self.key}={self.value}>'
|
||||||
@@ -5,12 +5,12 @@ class Subject(db.Model):
|
|||||||
__tablename__ = 'subjects'
|
__tablename__ = 'subjects'
|
||||||
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
code = db.Column(db.String(20), unique=True, nullable=False, index=True)
|
code = db.Column(db.String(50), unique=True, nullable=False, index=True)
|
||||||
name = db.Column(db.String(200), nullable=False)
|
name = db.Column(db.String(200), nullable=False)
|
||||||
description = db.Column(db.Text)
|
description = db.Column(db.Text)
|
||||||
department = db.Column(db.String(100), nullable=False)
|
department = db.Column(db.String(100), nullable=False, default='')
|
||||||
credits = db.Column(db.Integer, nullable=False)
|
credits = db.Column(db.Integer, nullable=False, default=4)
|
||||||
# duration_in_minutes = db.Column(db.Integer, nullable=False)
|
career_id = db.Column(db.Integer, db.ForeignKey('careers.id', ondelete='SET NULL'), nullable=True)
|
||||||
is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB
|
is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
@@ -27,7 +27,8 @@ class Subject(db.Model):
|
|||||||
'description': self.description,
|
'description': self.description,
|
||||||
'department': self.department,
|
'department': self.department,
|
||||||
'credits': self.credits,
|
'credits': self.credits,
|
||||||
# 'duration_minutes': self.duration_in_minutes,
|
'career_id': self.career_id,
|
||||||
|
'career_name': self.career_obj.name if self.career_obj else None,
|
||||||
'is_active': self.is_active,
|
'is_active': self.is_active,
|
||||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
||||||
@@ -48,6 +49,8 @@ class Commission(db.Model):
|
|||||||
max_students = db.Column(db.Integer, nullable=False)
|
max_students = db.Column(db.Integer, nullable=False)
|
||||||
current_students = db.Column(db.Integer, default=0)
|
current_students = db.Column(db.Integer, default=0)
|
||||||
schedule = db.Column(db.String(255))
|
schedule = db.Column(db.String(255))
|
||||||
|
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
|
||||||
|
virtual_link = db.Column(db.String(500), nullable=True) # Link Zoom, Meet, Teams, Moodle
|
||||||
active = db.Column(db.Boolean, default=True) # Changed from is_active to active
|
active = db.Column(db.Boolean, default=True) # Changed from is_active to active
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
@@ -85,15 +88,31 @@ class Commission(db.Model):
|
|||||||
'max_students': self.max_students,
|
'max_students': self.max_students,
|
||||||
'current_students': self.current_students,
|
'current_students': self.current_students,
|
||||||
'schedule': self.schedule,
|
'schedule': self.schedule,
|
||||||
|
'shift': self.shift,
|
||||||
|
'virtual_link': self.virtual_link,
|
||||||
'active': self.active,
|
'active': self.active,
|
||||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||||
|
'full_code': self.get_full_code(),
|
||||||
'subject': self.subject.to_dict() if self.subject else None
|
'subject': self.subject.to_dict() if self.subject else None
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_full_code(self):
|
def get_full_code(self):
|
||||||
"""Get full commission code"""
|
"""Get full commission code"""
|
||||||
return f"{self.subject.code if self.subject else 'N/A'}-{self.code}-{self.semester.upper()}{self.year if self.year else ''}"
|
subj = self.subject.code if self.subject else 'N/A'
|
||||||
|
code = self.code or ''
|
||||||
|
sem = str(self.semester or '').strip()
|
||||||
|
yr = str(self.year or '').strip()
|
||||||
|
|
||||||
|
if yr and yr in sem:
|
||||||
|
return f"{subj}-{code}-{sem}"
|
||||||
|
elif sem and yr:
|
||||||
|
return f"{subj}-{code}-{sem}{yr}"
|
||||||
|
elif sem:
|
||||||
|
return f"{subj}-{code}-{sem}"
|
||||||
|
elif yr:
|
||||||
|
return f"{subj}-{code}-{yr}"
|
||||||
|
return f"{subj}-{code}"
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<Commission {self.get_full_code()}>'
|
return f'<Commission {self.get_full_code()}>'
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
from app import db
|
||||||
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class User(db.Model):
|
||||||
|
__tablename__ = 'users'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
||||||
|
password_hash = db.Column(db.String(255), nullable=False)
|
||||||
|
name = db.Column(db.String(100), nullable=False)
|
||||||
|
role = db.Column(db.String(50), nullable=False)
|
||||||
|
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
last_login = db.Column(db.DateTime)
|
||||||
|
preferred_language = db.Column(db.String(10), default=None, nullable=True)
|
||||||
|
theme_preference = db.Column(db.String(10), default='auto', nullable=False)
|
||||||
|
role_id = db.Column(db.Integer, db.ForeignKey('roles.id', ondelete='SET NULL'), nullable=True)
|
||||||
|
|
||||||
|
# Virtual fields for future compatibility
|
||||||
|
@property
|
||||||
|
def first_name(self):
|
||||||
|
return self.name.split()[0] if self.name else ''
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_name(self):
|
||||||
|
parts = self.name.split()
|
||||||
|
return ' '.join(parts[1:]) if len(parts) > 1 else ''
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
reservations = db.relationship('Reservation', backref='user', lazy=True, cascade='all, delete-orphan')
|
||||||
|
|
||||||
|
def set_password(self, password):
|
||||||
|
"""Set password hash"""
|
||||||
|
self.password_hash = generate_password_hash(password, method='pbkdf2:sha256')
|
||||||
|
|
||||||
|
def check_password(self, password):
|
||||||
|
"""Check password"""
|
||||||
|
return check_password_hash(self.password_hash, password)
|
||||||
|
|
||||||
|
def is_admin(self):
|
||||||
|
"""Check if user is admin"""
|
||||||
|
if self.role_obj and self.role_obj.name.lower() in ['admin', 'administrador']:
|
||||||
|
return True
|
||||||
|
return str(self.role).upper() in ['ADMIN', 'ADMINISTRADOR']
|
||||||
|
|
||||||
|
def get_institutional_role(self):
|
||||||
|
"""Returns normalized institutional role: 'admin', 'bedelia', 'docente', 'alumno'"""
|
||||||
|
raw = ''
|
||||||
|
if self.role_obj and self.role_obj.name:
|
||||||
|
raw = self.role_obj.name.lower().strip()
|
||||||
|
elif self.role:
|
||||||
|
raw = str(self.role).lower().strip()
|
||||||
|
|
||||||
|
if 'admin' in raw:
|
||||||
|
return 'admin'
|
||||||
|
if 'bedel' in raw:
|
||||||
|
return 'bedelia'
|
||||||
|
if 'docent' in raw or 'prof' in raw or 'teacher' in raw:
|
||||||
|
return 'docente'
|
||||||
|
if 'alumn' in raw or 'estud' in raw or 'student' in raw:
|
||||||
|
return 'alumno'
|
||||||
|
return 'docente'
|
||||||
|
|
||||||
|
def is_bedelia(self):
|
||||||
|
return self.get_institutional_role() == 'bedelia'
|
||||||
|
|
||||||
|
def is_docente(self):
|
||||||
|
return self.get_institutional_role() == 'docente'
|
||||||
|
|
||||||
|
def is_alumno(self):
|
||||||
|
return self.get_institutional_role() == 'alumno'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def role_badge_display(self):
|
||||||
|
role_map = {
|
||||||
|
'admin': {'label': 'Administrador', 'class': 'badge-admin', 'icon': 'bi-shield-check', 'color': 'primary'},
|
||||||
|
'bedelia': {'label': 'Bedelía', 'class': 'badge-bedelia', 'icon': 'bi-building-gear', 'color': 'warning'},
|
||||||
|
'docente': {'label': 'Profesor / Docente', 'class': 'badge-docente', 'icon': 'bi-person-video3', 'color': 'info'},
|
||||||
|
'alumno': {'label': 'Alumno / Estudiante', 'class': 'badge-alumno', 'icon': 'bi-mortarboard', 'color': 'success'}
|
||||||
|
}
|
||||||
|
return role_map.get(self.get_institutional_role(), {'label': self.role or 'Docente', 'class': 'bg-secondary', 'icon': 'bi-person', 'color': 'secondary'})
|
||||||
|
|
||||||
|
def has_permission(self, module, min_level='read'):
|
||||||
|
"""Check if user has granular permission on a given module"""
|
||||||
|
if self.is_admin():
|
||||||
|
return True
|
||||||
|
if self.role_obj:
|
||||||
|
return self.role_obj.has_permission(module, min_level)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def can_manage(self):
|
||||||
|
"""Check if user can access the Management / Admin section"""
|
||||||
|
return (
|
||||||
|
self.is_admin() or
|
||||||
|
self.is_bedelia() or
|
||||||
|
self.has_permission('users', 'read') or
|
||||||
|
self.has_permission('milestone_types', 'read') or
|
||||||
|
self.has_permission('import', 'read') or
|
||||||
|
self.has_permission('reservations', 'read_write') or
|
||||||
|
self.has_permission('classrooms', 'read_write') or
|
||||||
|
self.has_permission('buildings', 'read_write') or
|
||||||
|
self.has_permission('metrics', 'read')
|
||||||
|
)
|
||||||
|
|
||||||
|
def can_edit_reservation(self, reservation):
|
||||||
|
"""Check if user has permission to edit a given reservation"""
|
||||||
|
if not reservation:
|
||||||
|
return False
|
||||||
|
if self.is_admin():
|
||||||
|
return True
|
||||||
|
role_name = (self.role_obj.name if self.role_obj else self.role or '').lower()
|
||||||
|
if role_name in ['admin', 'administrador', 'bedelia', 'bedelía', 'operador']:
|
||||||
|
return True
|
||||||
|
if self.has_permission('reservations', 'read_write'):
|
||||||
|
# El docente creador puede modificar su propia reserva
|
||||||
|
if reservation.user_id == self.id:
|
||||||
|
return True
|
||||||
|
# Bedelía o roles de gestión con permiso de escritura total
|
||||||
|
if role_name in ['bedelia', 'bedelía', 'operador']:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def update_last_login(self):
|
||||||
|
"""Update last login timestamp"""
|
||||||
|
self.last_login = datetime.utcnow()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Convert user to dictionary"""
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'email': self.email,
|
||||||
|
'name': self.name,
|
||||||
|
'role': self.role,
|
||||||
|
'is_active': self.is_active,
|
||||||
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||||
|
'last_login': self.last_login.isoformat() if self.last_login else None,
|
||||||
|
'preferred_language': self.preferred_language,
|
||||||
|
'theme_preference': self.theme_preference
|
||||||
|
}
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<User {self.email}>'
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"""Módulo de Repositorios para persistencia y consultas desacopladas."""
|
||||||
|
from app.repositories.base_repository import BaseRepository
|
||||||
|
from app.repositories.classroom_repository import ClassroomRepository
|
||||||
|
from app.repositories.reservation_repository import ReservationRepository
|
||||||
|
from app.repositories.user_repository import UserRepository
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'BaseRepository',
|
||||||
|
'ClassroomRepository',
|
||||||
|
'ReservationRepository',
|
||||||
|
'UserRepository'
|
||||||
|
]
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from typing import TypeVar, Generic, Type, Optional, List
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
T = TypeVar('T')
|
||||||
|
|
||||||
|
class BaseRepository(Generic[T]):
|
||||||
|
"""Repositorio base con operaciones CRUD genéricas y seguras."""
|
||||||
|
|
||||||
|
def __init__(self, model_class: Type[T]):
|
||||||
|
self.model_class = model_class
|
||||||
|
|
||||||
|
def get_by_id(self, entity_id: int) -> Optional[T]:
|
||||||
|
"""Obtiene una entidad por su ID numérico."""
|
||||||
|
if not entity_id:
|
||||||
|
return None
|
||||||
|
return db.session.get(self.model_class, entity_id)
|
||||||
|
|
||||||
|
def get_all(self, order_by=None) -> List[T]:
|
||||||
|
"""Obtiene todas las entidades opcionalmente ordenadas."""
|
||||||
|
query = self.model_class.query
|
||||||
|
if order_by is not None:
|
||||||
|
query = query.order_by(order_by)
|
||||||
|
return query.all()
|
||||||
|
|
||||||
|
def save(self, entity: T, commit: bool = True) -> T:
|
||||||
|
"""Agrega o actualiza una entidad en la sesión."""
|
||||||
|
db.session.add(entity)
|
||||||
|
if commit:
|
||||||
|
db.session.commit()
|
||||||
|
return entity
|
||||||
|
|
||||||
|
def delete(self, entity: T, commit: bool = True) -> bool:
|
||||||
|
"""Elimina una entidad de la sesión."""
|
||||||
|
if entity:
|
||||||
|
db.session.delete(entity)
|
||||||
|
if commit:
|
||||||
|
db.session.commit()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def commit(self):
|
||||||
|
"""Confirma la transacción actual."""
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
def rollback(self):
|
||||||
|
"""Revierte la transacción actual."""
|
||||||
|
db.session.rollback()
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from typing import Optional, List
|
||||||
|
from sqlalchemy import func
|
||||||
|
from app.models.classroom import Classroom
|
||||||
|
from app.repositories.base_repository import BaseRepository
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
class ClassroomRepository(BaseRepository[Classroom]):
|
||||||
|
"""Repositorio especializado en operaciones de persistencia y filtrado de aulas."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(Classroom)
|
||||||
|
|
||||||
|
def get_by_code_and_building(self, building_id: int, code: str, floor: str) -> Optional[Classroom]:
|
||||||
|
"""Busca un aula por edificio, código y piso para verificar duplicados."""
|
||||||
|
return Classroom.query.filter_by(
|
||||||
|
building_id=building_id,
|
||||||
|
code=code.strip(),
|
||||||
|
floor=str(floor).strip()
|
||||||
|
).first()
|
||||||
|
|
||||||
|
def get_active_classrooms(self, building_id: Optional[int] = None, floor: Optional[str] = None,
|
||||||
|
only_virtual: bool = False, only_physical: bool = False) -> List[Classroom]:
|
||||||
|
"""Retorna aulas activas con filtros opcionales de edificio, piso y modalidad."""
|
||||||
|
query = Classroom.query.filter_by(is_active=True)
|
||||||
|
|
||||||
|
if building_id:
|
||||||
|
query = query.filter_by(building_id=building_id)
|
||||||
|
|
||||||
|
if floor:
|
||||||
|
query = query.filter_by(floor=str(floor).strip())
|
||||||
|
|
||||||
|
classrooms = query.order_by(Classroom.floor.asc(), Classroom.code.asc()).all()
|
||||||
|
|
||||||
|
if only_virtual:
|
||||||
|
return [c for c in classrooms if c.is_virtual]
|
||||||
|
elif only_physical:
|
||||||
|
return [c for c in classrooms if not c.is_virtual]
|
||||||
|
|
||||||
|
return classrooms
|
||||||
|
|
||||||
|
def get_virtual_classrooms(self) -> List[Classroom]:
|
||||||
|
"""Obtiene todas las salas virtuales activas."""
|
||||||
|
return [c for c in self.get_active_classrooms() if c.is_virtual]
|
||||||
|
|
||||||
|
def get_physical_classrooms(self) -> List[Classroom]:
|
||||||
|
"""Obtiene todas las aulas físicas activas."""
|
||||||
|
return [c for c in self.get_active_classrooms() if not c.is_virtual]
|
||||||
|
|
||||||
|
def search(self, term: str) -> List[Classroom]:
|
||||||
|
"""Búsqueda de aulas por código, edificio o descripción."""
|
||||||
|
if not term:
|
||||||
|
return self.get_all(order_by=Classroom.code.asc())
|
||||||
|
term_clean = f"%{term.strip()}%"
|
||||||
|
return Classroom.query.filter(
|
||||||
|
Classroom.is_active == True,
|
||||||
|
(Classroom.code.ilike(term_clean)) |
|
||||||
|
(Classroom.building.ilike(term_clean)) |
|
||||||
|
(Classroom.description.ilike(term_clean))
|
||||||
|
).order_by(Classroom.code.asc()).all()
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime, date, time
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
from sqlalchemy import and_, or_
|
||||||
|
from app.models.reservation import Reservation, ReservationStatus
|
||||||
|
from app.repositories.base_repository import BaseRepository
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
class ReservationRepository(BaseRepository[Reservation]):
|
||||||
|
"""Repositorio para la persistencia y consulta de reservas de aulas."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(Reservation)
|
||||||
|
|
||||||
|
def find_conflicts(self, classroom_id: int, start_time: datetime, end_time: datetime,
|
||||||
|
exclude_reservation_id: Optional[int] = None) -> List[Reservation]:
|
||||||
|
"""
|
||||||
|
Encuentra reservas activas que se solapan en el mismo aula y franja horaria.
|
||||||
|
Condición de solapamiento: (start_time < existing.end_time) AND (end_time > existing.start_time).
|
||||||
|
"""
|
||||||
|
query = Reservation.query.filter(
|
||||||
|
Reservation.classroom_id == classroom_id,
|
||||||
|
Reservation.status != ReservationStatus.CANCELLED.value,
|
||||||
|
Reservation.start_time < end_time,
|
||||||
|
Reservation.end_time > start_time
|
||||||
|
)
|
||||||
|
|
||||||
|
if exclude_reservation_id:
|
||||||
|
query = query.filter(Reservation.id != exclude_reservation_id)
|
||||||
|
|
||||||
|
return query.all()
|
||||||
|
|
||||||
|
def get_by_date_range(self, start_dt: datetime, end_dt: datetime,
|
||||||
|
classroom_id: Optional[int] = None) -> List[Reservation]:
|
||||||
|
"""Obtiene reservas dentro de una ventana temporal con carga ansiosa de relaciones."""
|
||||||
|
query = Reservation.query.options(
|
||||||
|
joinedload(Reservation.classroom),
|
||||||
|
joinedload(Reservation.commission),
|
||||||
|
joinedload(Reservation.user)
|
||||||
|
).filter(
|
||||||
|
Reservation.start_time >= start_dt,
|
||||||
|
Reservation.start_time <= end_dt,
|
||||||
|
Reservation.status != ReservationStatus.CANCELLED.value
|
||||||
|
)
|
||||||
|
|
||||||
|
if classroom_id:
|
||||||
|
query = query.filter(Reservation.classroom_id == classroom_id)
|
||||||
|
|
||||||
|
return query.order_by(Reservation.start_time.asc()).all()
|
||||||
|
|
||||||
|
def get_by_teacher(self, teacher_id: int) -> List[Reservation]:
|
||||||
|
"""Obtiene reservas asignadas a un docente."""
|
||||||
|
return Reservation.query.filter_by(
|
||||||
|
user_id=teacher_id
|
||||||
|
).order_by(Reservation.start_time.desc()).all()
|
||||||
|
|
||||||
|
def get_by_commission(self, commission_id: int) -> List[Reservation]:
|
||||||
|
"""Obtiene todas las reservas asociadas a una comisión."""
|
||||||
|
return Reservation.query.filter_by(
|
||||||
|
commission_id=commission_id
|
||||||
|
).order_by(Reservation.start_time.asc()).all()
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from typing import Optional, List
|
||||||
|
from sqlalchemy import func
|
||||||
|
from app.models.user import User
|
||||||
|
from app.repositories.base_repository import BaseRepository
|
||||||
|
|
||||||
|
class UserRepository(BaseRepository[User]):
|
||||||
|
"""Repositorio para la gestión de usuarios, credenciales y roles."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(User)
|
||||||
|
|
||||||
|
def get_by_email(self, email: str) -> Optional[User]:
|
||||||
|
"""Obtiene un usuario por su dirección de email normalizada."""
|
||||||
|
if not email:
|
||||||
|
return None
|
||||||
|
return User.query.filter(func.lower(User.email) == email.strip().lower()).first()
|
||||||
|
|
||||||
|
def get_active_users(self) -> List[User]:
|
||||||
|
"""Obtiene todos los usuarios con cuenta activa."""
|
||||||
|
return User.query.filter_by(is_active=True).order_by(User.name.asc()).all()
|
||||||
|
|
||||||
|
def get_by_role(self, role_name: str) -> List[User]:
|
||||||
|
"""Obtiene usuarios por nombre de rol."""
|
||||||
|
return User.query.filter(
|
||||||
|
User.is_active == True,
|
||||||
|
func.lower(User.role) == role_name.strip().lower()
|
||||||
|
).order_by(User.name.asc()).all()
|
||||||
|
|
||||||
|
def get_teachers(self) -> List[User]:
|
||||||
|
"""Obtiene lista de docentes y profesores activos."""
|
||||||
|
return User.query.filter(
|
||||||
|
User.is_active == True,
|
||||||
|
User.role.in_(['TEACHER', 'DOCENTE', 'PROFESOR', 'teacher', 'docente'])
|
||||||
|
).order_by(User.name.asc()).all()
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
from .main import main_bp
|
||||||
|
from .auth import auth_bp
|
||||||
|
from .classrooms import classrooms_bp
|
||||||
|
from .buildings import buildings_bp
|
||||||
|
from .schedule import schedule_bp
|
||||||
|
from .genetic_algorithm import genetic_bp, genetic_web_bp
|
||||||
|
from .admin import admin_bp
|
||||||
|
from .my_subjects import my_subjects_bp
|
||||||
|
|
||||||
|
__all__ = ['main_bp', 'auth_bp', 'classrooms_bp', 'buildings_bp', 'schedule_bp', 'genetic_bp', 'genetic_web_bp', 'admin_bp', 'my_subjects_bp']
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"""Paquete de rutas API RESTful (v1) con soporte Stateless JWT."""
|
||||||
|
from app.routes.api.auth import api_auth_bp
|
||||||
|
from app.routes.api.classrooms import api_classrooms_bp
|
||||||
|
from app.routes.api.reservations import api_reservations_bp
|
||||||
|
from app.routes.api.optimizer import api_optimizer_bp
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'api_auth_bp',
|
||||||
|
'api_classrooms_bp',
|
||||||
|
'api_reservations_bp',
|
||||||
|
'api_optimizer_bp'
|
||||||
|
]
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
from flask import Blueprint, request, jsonify, g, make_response
|
||||||
|
from pydantic import ValidationError
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
from app.services.user_service import UserService
|
||||||
|
from app.services.jwt_service import JWTService
|
||||||
|
from app.schemas.auth_dto import LoginDTO, RefreshTokenDTO
|
||||||
|
from app.utils.jwt_decorators import jwt_required
|
||||||
|
|
||||||
|
api_auth_bp = Blueprint('api_auth', __name__, url_prefix='/api/v1/auth')
|
||||||
|
user_service = UserService()
|
||||||
|
|
||||||
|
@api_auth_bp.route('/login', methods=['POST'])
|
||||||
|
def login():
|
||||||
|
"""
|
||||||
|
Endpoint de autenticación para obtener par de tokens (Access + Refresh).
|
||||||
|
"""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
try:
|
||||||
|
dto = LoginDTO(**data)
|
||||||
|
except ValidationError as e:
|
||||||
|
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
|
||||||
|
|
||||||
|
user = user_service.authenticate(dto.email, dto.password)
|
||||||
|
if not user:
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Unauthorized',
|
||||||
|
'message': 'Credenciales de acceso incorrectas o cuenta inactiva.'
|
||||||
|
}), 401
|
||||||
|
|
||||||
|
tokens = JWTService.generate_tokens(user)
|
||||||
|
profile = user_service.get_profile_data(user)
|
||||||
|
|
||||||
|
response_data = {
|
||||||
|
'access_token': tokens['access_token'],
|
||||||
|
'refresh_token': tokens['refresh_token'],
|
||||||
|
'token_type': tokens['token_type'],
|
||||||
|
'expires_in': tokens['expires_in'],
|
||||||
|
'user': profile
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = make_response(jsonify(response_data), 200)
|
||||||
|
# Almacenar refresh token en cookie segura HttpOnly
|
||||||
|
resp.set_cookie(
|
||||||
|
'refresh_token',
|
||||||
|
tokens['refresh_token'],
|
||||||
|
httponly=True,
|
||||||
|
samesite='Lax',
|
||||||
|
max_age=7 * 24 * 3600,
|
||||||
|
path='/api/v1/auth/refresh'
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
@api_auth_bp.route('/refresh', methods=['POST'])
|
||||||
|
def refresh():
|
||||||
|
"""
|
||||||
|
Renovación silenciosa del Access Token utilizando el Refresh Token.
|
||||||
|
"""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
refresh_token = data.get('refresh_token') or request.cookies.get('refresh_token')
|
||||||
|
|
||||||
|
if not refresh_token:
|
||||||
|
return jsonify({
|
||||||
|
'error': 'BadRequest',
|
||||||
|
'message': 'Refresh token no suministrado en el cuerpo ni en cookies.'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = JWTService.decode_token(refresh_token, expected_type='refresh')
|
||||||
|
user_id = int(payload.get('sub'))
|
||||||
|
user = user_service.get_by_id(user_id)
|
||||||
|
|
||||||
|
if not user or not user.is_active:
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Unauthorized',
|
||||||
|
'message': 'Usuario no encontrado o inactivo.'
|
||||||
|
}), 401
|
||||||
|
|
||||||
|
new_access_token = JWTService.create_access_token(user)
|
||||||
|
return jsonify({
|
||||||
|
'access_token': new_access_token,
|
||||||
|
'token_type': 'Bearer',
|
||||||
|
'expires_in': int(JWTService.ACCESS_TOKEN_EXPIRES.total_seconds())
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
return jsonify({'error': 'TokenExpired', 'message': 'El refresh token ha expirado. Reingrese credenciales.'}), 401
|
||||||
|
except jwt.InvalidTokenError as e:
|
||||||
|
return jsonify({'error': 'InvalidToken', 'message': str(e)}), 401
|
||||||
|
|
||||||
|
@api_auth_bp.route('/logout', methods=['POST'])
|
||||||
|
@jwt_required
|
||||||
|
def logout():
|
||||||
|
"""
|
||||||
|
Cierre de sesión: revoca el token de acceso activo y limpia cookies.
|
||||||
|
"""
|
||||||
|
JWTService.revoke_token(g.jwt_token)
|
||||||
|
resp = make_response(jsonify({'message': 'Sesión finalizada y token revocado exitosamente.'}), 200)
|
||||||
|
resp.delete_cookie('refresh_token', path='/api/v1/auth/refresh')
|
||||||
|
return resp
|
||||||
|
|
||||||
|
@api_auth_bp.route('/me', methods=['GET'])
|
||||||
|
@jwt_required
|
||||||
|
def get_current_user():
|
||||||
|
"""
|
||||||
|
Retorna el perfil y los permisos del usuario autenticado vía JWT.
|
||||||
|
"""
|
||||||
|
profile = user_service.get_profile_data(g.jwt_user)
|
||||||
|
return jsonify(profile), 200
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
from flask import Blueprint, request, jsonify, g
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from app.services.classroom_service import ClassroomService
|
||||||
|
from app.schemas.classroom_dto import ClassroomCreateDTO, ClassroomUpdateDTO, ClassroomResponseDTO
|
||||||
|
from app.utils.jwt_decorators import jwt_required, jwt_role_required
|
||||||
|
|
||||||
|
api_classrooms_bp = Blueprint('api_classrooms', __name__, url_prefix='/api/v1/classrooms')
|
||||||
|
classroom_service = ClassroomService()
|
||||||
|
|
||||||
|
@api_classrooms_bp.route('', methods=['GET'])
|
||||||
|
@jwt_required
|
||||||
|
def list_classrooms():
|
||||||
|
"""Lista aulas con filtros opcionales (building_id, floor, mode)."""
|
||||||
|
building_id = request.args.get('building_id', type=int)
|
||||||
|
floor = request.args.get('floor')
|
||||||
|
mode = request.args.get('mode', '').lower()
|
||||||
|
|
||||||
|
only_virtual = mode == 'virtual'
|
||||||
|
only_physical = mode == 'physical'
|
||||||
|
|
||||||
|
classrooms = classroom_service.list_classrooms(
|
||||||
|
building_id=building_id,
|
||||||
|
floor=floor,
|
||||||
|
only_virtual=only_virtual,
|
||||||
|
only_physical=only_physical
|
||||||
|
)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for c in classrooms:
|
||||||
|
result.append({
|
||||||
|
'id': c.id,
|
||||||
|
'code': c.code,
|
||||||
|
'building': c.building_name,
|
||||||
|
'building_id': c.building_id,
|
||||||
|
'floor': c.floor,
|
||||||
|
'floor_display': c.floor_display,
|
||||||
|
'capacity': c.capacity,
|
||||||
|
'is_virtual': c.is_virtual,
|
||||||
|
'location_display': c.location_display,
|
||||||
|
'description': c.description,
|
||||||
|
'is_active': c.is_active
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({'total': len(result), 'classrooms': result}), 200
|
||||||
|
|
||||||
|
@api_classrooms_bp.route('/<int:classroom_id>', methods=['GET'])
|
||||||
|
@jwt_required
|
||||||
|
def get_classroom(classroom_id: int):
|
||||||
|
"""Obtiene el detalle de un aula por ID."""
|
||||||
|
c = classroom_service.get_classroom(classroom_id)
|
||||||
|
if not c:
|
||||||
|
return jsonify({'error': 'NotFound', 'message': f'Aula {classroom_id} no encontrada.'}), 404
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'id': c.id,
|
||||||
|
'code': c.code,
|
||||||
|
'building': c.building_name,
|
||||||
|
'building_id': c.building_id,
|
||||||
|
'floor': c.floor,
|
||||||
|
'floor_display': c.floor_display,
|
||||||
|
'capacity': c.capacity,
|
||||||
|
'is_virtual': c.is_virtual,
|
||||||
|
'location_display': c.location_display,
|
||||||
|
'description': c.description,
|
||||||
|
'is_active': c.is_active
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
@api_classrooms_bp.route('', methods=['POST'])
|
||||||
|
@jwt_role_required('ADMIN', 'ADMINISTRADOR', 'BEDELIA')
|
||||||
|
def create_classroom():
|
||||||
|
"""Crea una nueva aula validando el contrato con ClassroomCreateDTO."""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
try:
|
||||||
|
dto = ClassroomCreateDTO(**data)
|
||||||
|
except ValidationError as e:
|
||||||
|
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
classroom = classroom_service.create_classroom(dto)
|
||||||
|
return jsonify({
|
||||||
|
'message': 'Aula creada exitosamente.',
|
||||||
|
'id': classroom.id,
|
||||||
|
'code': classroom.code,
|
||||||
|
'location_display': classroom.location_display
|
||||||
|
}), 201
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'error': 'ConflictOrValidation', 'message': str(e)}), 400
|
||||||
|
|
||||||
|
@api_classrooms_bp.route('/<int:classroom_id>', methods=['PUT'])
|
||||||
|
@jwt_role_required('ADMIN', 'ADMINISTRADOR', 'BEDELIA')
|
||||||
|
def update_classroom(classroom_id: int):
|
||||||
|
"""Actualiza los datos de un aula."""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
try:
|
||||||
|
dto = ClassroomUpdateDTO(**data)
|
||||||
|
except ValidationError as e:
|
||||||
|
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
classroom = classroom_service.update_classroom(classroom_id, dto)
|
||||||
|
return jsonify({
|
||||||
|
'message': 'Aula actualizada exitosamente.',
|
||||||
|
'id': classroom.id,
|
||||||
|
'code': classroom.code,
|
||||||
|
'capacity': classroom.capacity
|
||||||
|
}), 200
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'error': 'ConflictOrValidation', 'message': str(e)}), 400
|
||||||
|
|
||||||
|
@api_classrooms_bp.route('/<int:classroom_id>', methods=['DELETE'])
|
||||||
|
@jwt_role_required('ADMIN', 'ADMINISTRADOR')
|
||||||
|
def delete_classroom(classroom_id: int):
|
||||||
|
"""Elimina o desactiva un aula."""
|
||||||
|
deleted = classroom_service.delete_classroom(classroom_id)
|
||||||
|
if not deleted:
|
||||||
|
return jsonify({'error': 'NotFound', 'message': f'Aula {classroom_id} no encontrada.'}), 404
|
||||||
|
|
||||||
|
return jsonify({'message': f'Aula {classroom_id} eliminada/desactivada correctamente.'}), 200
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
from app.utils.jwt_decorators import jwt_required
|
||||||
|
from app.models.classroom import Classroom
|
||||||
|
from app.models.subject import Subject
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.reservation import Reservation
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from sqlalchemy import func
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
from app.models.milestone import AcademicMilestone
|
||||||
|
from app.models.subject import Commission
|
||||||
|
|
||||||
|
api_dashboard_bp = Blueprint('api_dashboard', __name__, url_prefix='/api/v1/dashboard')
|
||||||
|
|
||||||
|
def serialize_reservation(r):
|
||||||
|
subject_name = (r.commission.subject.name if (r.commission and r.commission.subject) else r.purpose) or 'Actividad Académica'
|
||||||
|
subject_code = (r.commission.subject.code if (r.commission and r.commission.subject) else '')
|
||||||
|
classroom_code = r.classroom.code if r.classroom else 'Sin Aula'
|
||||||
|
is_virtual = ('virtual' in classroom_code.lower()) or (getattr(r.classroom, 'is_virtual', False) if r.classroom else False)
|
||||||
|
return {
|
||||||
|
'id': r.id,
|
||||||
|
'start_time': r.start_time.strftime('%H:%M') if r.start_time else '',
|
||||||
|
'end_time': r.end_time.strftime('%H:%M') if r.end_time else '',
|
||||||
|
'date_display': r.start_time.strftime('%d/%m') if r.start_time else '',
|
||||||
|
'classroom': {
|
||||||
|
'id': r.classroom_id,
|
||||||
|
'code': classroom_code,
|
||||||
|
'is_virtual': is_virtual,
|
||||||
|
'location_display': getattr(r.classroom, 'location_display', f'Sede Central · {classroom_code}') if r.classroom else 'Sin Aula'
|
||||||
|
},
|
||||||
|
'subject_name': subject_name,
|
||||||
|
'subject_code': subject_code,
|
||||||
|
'shift': r.shift or 'Mañana',
|
||||||
|
'commission': {
|
||||||
|
'id': r.commission_id,
|
||||||
|
'code': r.commission.code if r.commission else 'COM-1'
|
||||||
|
},
|
||||||
|
'status': str(r.status.value if hasattr(r.status, 'value') else r.status),
|
||||||
|
'user': {
|
||||||
|
'id': r.user_id,
|
||||||
|
'name': r.user.name if r.user else 'Docente'
|
||||||
|
},
|
||||||
|
'virtual_link': r.virtual_link or ('https://meet.google.com/edu-space-' + classroom_code.lower().replace(' ', '-') if is_virtual else '')
|
||||||
|
}
|
||||||
|
|
||||||
|
def serialize_milestone(m):
|
||||||
|
return {
|
||||||
|
'id': m.id,
|
||||||
|
'title': m.title,
|
||||||
|
'materia_codigo': m.materia_codigo,
|
||||||
|
'subject_name': m.subject.name if (hasattr(m, 'subject') and m.subject) else m.materia_codigo,
|
||||||
|
'date': m.date.strftime('%d/%m/%Y') if m.date else '',
|
||||||
|
'type_obj': {
|
||||||
|
'name': m.type_obj.name if m.type_obj else 'Evaluación',
|
||||||
|
'color': m.type_obj.color if m.type_obj else '#0d6efd'
|
||||||
|
},
|
||||||
|
'classroom': m.classroom or 'Sede Central',
|
||||||
|
'link': m.link or '',
|
||||||
|
'notes': m.notes or ''
|
||||||
|
}
|
||||||
|
|
||||||
|
@api_dashboard_bp.route('/stats', methods=['GET'])
|
||||||
|
@jwt_required
|
||||||
|
def get_stats():
|
||||||
|
"""Retorna estadísticas clave y datos operativos para el Dashboard."""
|
||||||
|
today = datetime.utcnow().date()
|
||||||
|
|
||||||
|
try:
|
||||||
|
total_classrooms = Classroom.query.filter_by(is_active=True).count()
|
||||||
|
total_subjects = Subject.query.filter_by(is_active=True).count()
|
||||||
|
total_users = User.query.filter_by(is_active=True).count()
|
||||||
|
|
||||||
|
# Aulas presenciales y virtuales
|
||||||
|
all_active_classrooms = Classroom.query.filter_by(is_active=True).all()
|
||||||
|
virtual_classrooms_list = [
|
||||||
|
c for c in all_active_classrooms
|
||||||
|
if getattr(c, 'is_virtual', False)
|
||||||
|
or 'virtual' in (c.building or '').lower()
|
||||||
|
or 'virtual' in (c.code or '').lower()
|
||||||
|
]
|
||||||
|
virtual_classrooms = len(virtual_classrooms_list)
|
||||||
|
|
||||||
|
physical_classrooms_list = [
|
||||||
|
c for c in all_active_classrooms
|
||||||
|
if c not in virtual_classrooms_list
|
||||||
|
]
|
||||||
|
total_physical_count = len(physical_classrooms_list)
|
||||||
|
physical_classrooms = total_physical_count
|
||||||
|
physical_ids = set(c.id for c in physical_classrooms_list)
|
||||||
|
|
||||||
|
# 1. Asignación General del Cuatrimestre / Cronograma
|
||||||
|
assigned_all_ids = set(
|
||||||
|
r[0] for r in db.session.query(Reservation.classroom_id)
|
||||||
|
.filter(Reservation.classroom_id.isnot(None)).distinct().all()
|
||||||
|
)
|
||||||
|
assigned_physical_ids = physical_ids.intersection(assigned_all_ids)
|
||||||
|
assigned_classrooms = len(assigned_physical_ids)
|
||||||
|
unassigned_classrooms = max(0, total_physical_count - assigned_classrooms)
|
||||||
|
|
||||||
|
assigned_pct = round((assigned_classrooms / max(1, total_physical_count)) * 100, 1) if total_physical_count > 0 else 0.0
|
||||||
|
unassigned_pct = round(100.0 - assigned_pct, 1) if total_physical_count > 0 else 0.0
|
||||||
|
|
||||||
|
# 2. Asignación Operativa del Día de Hoy
|
||||||
|
assigned_today_ids = set(
|
||||||
|
r[0] for r in db.session.query(Reservation.classroom_id).filter(
|
||||||
|
Reservation.classroom_id.isnot(None),
|
||||||
|
func.date(Reservation.start_time) == today
|
||||||
|
).distinct().all()
|
||||||
|
)
|
||||||
|
assigned_today_physical_ids = physical_ids.intersection(assigned_today_ids)
|
||||||
|
assigned_today_count = len(assigned_today_physical_ids)
|
||||||
|
unassigned_today_count = max(0, total_physical_count - assigned_today_count)
|
||||||
|
|
||||||
|
assigned_today_pct = round((assigned_today_count / max(1, total_physical_count)) * 100, 1) if total_physical_count > 0 else 0.0
|
||||||
|
unassigned_today_pct = round(100.0 - assigned_today_pct, 1) if total_physical_count > 0 else 0.0
|
||||||
|
|
||||||
|
today_reservations_q = Reservation.query.filter(
|
||||||
|
func.date(Reservation.start_time) == today
|
||||||
|
).order_by(Reservation.start_time.asc()).all()
|
||||||
|
today_reservations = len(today_reservations_q)
|
||||||
|
|
||||||
|
pending_reservations_q = Reservation.query.filter(
|
||||||
|
Reservation.status == 'PENDING'
|
||||||
|
).order_by(Reservation.start_time.asc()).all()
|
||||||
|
pending_count = len(pending_reservations_q)
|
||||||
|
|
||||||
|
upcoming_reservations_q = Reservation.query.filter(
|
||||||
|
Reservation.start_time >= datetime.utcnow()
|
||||||
|
).order_by(Reservation.start_time.asc()).limit(10).all()
|
||||||
|
|
||||||
|
milestones_q = AcademicMilestone.query.filter(
|
||||||
|
AcademicMilestone.is_active == True
|
||||||
|
).order_by(AcademicMilestone.date.asc()).limit(10).all()
|
||||||
|
|
||||||
|
recent_audits = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(6).all()
|
||||||
|
audit_data = [{
|
||||||
|
'created_at': a.created_at.strftime('%Y-%m-%d %H:%M') if a.created_at else None,
|
||||||
|
'action_type': a.action_type,
|
||||||
|
'ip_address': a.ip_address,
|
||||||
|
'user_email': getattr(a.user, 'email', 'System') if hasattr(a, 'user') else 'System'
|
||||||
|
} for a in recent_audits]
|
||||||
|
|
||||||
|
occupancy_rate = min(100, int((today_reservations / max(1, physical_classrooms * 3)) * 100)) if physical_classrooms > 0 else 0
|
||||||
|
|
||||||
|
pending_reservations = [serialize_reservation(r) for r in pending_reservations_q]
|
||||||
|
today_schedule_list = [serialize_reservation(r) for r in today_reservations_q]
|
||||||
|
upcoming_reservations = [serialize_reservation(r) for r in upcoming_reservations_q]
|
||||||
|
milestones = [serialize_milestone(m) for m in milestones_q]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error fetching stats: {e}")
|
||||||
|
total_classrooms = 0
|
||||||
|
total_subjects = 0
|
||||||
|
total_users = 0
|
||||||
|
today_reservations = 0
|
||||||
|
pending_count = 0
|
||||||
|
physical_classrooms = 0
|
||||||
|
virtual_classrooms = 0
|
||||||
|
occupancy_rate = 0
|
||||||
|
assigned_classrooms = 0
|
||||||
|
assigned_pct = 0.0
|
||||||
|
unassigned_classrooms = 0
|
||||||
|
unassigned_pct = 0.0
|
||||||
|
assigned_today_count = 0
|
||||||
|
assigned_today_pct = 0.0
|
||||||
|
unassigned_today_count = 0
|
||||||
|
unassigned_today_pct = 0.0
|
||||||
|
audit_data = []
|
||||||
|
pending_reservations = []
|
||||||
|
today_schedule_list = []
|
||||||
|
upcoming_reservations = []
|
||||||
|
milestones = []
|
||||||
|
|
||||||
|
# Mocked monthly data for chart since complex SQL group by month might differ based on dialect (SQLite vs Postgres)
|
||||||
|
monthly_data = [
|
||||||
|
{"month": "Ene", "reservations": 120},
|
||||||
|
{"month": "Feb", "reservations": 180},
|
||||||
|
{"month": "Mar", "reservations": 250},
|
||||||
|
{"month": "Abr", "reservations": 220},
|
||||||
|
{"month": "May", "reservations": 300},
|
||||||
|
{"month": "Jun", "reservations": 310},
|
||||||
|
{"month": "Jul", "reservations": 200},
|
||||||
|
{"month": "Ago", "reservations": 350},
|
||||||
|
{"month": "Sep", "reservations": 420},
|
||||||
|
{"month": "Oct", "reservations": 480},
|
||||||
|
{"month": "Nov", "reservations": 520},
|
||||||
|
{"month": "Dic", "reservations": 210}
|
||||||
|
]
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'stats': {
|
||||||
|
'total_classrooms': total_classrooms,
|
||||||
|
'active_subjects': total_subjects,
|
||||||
|
'total_active_users': total_users,
|
||||||
|
'today_reservations': today_reservations,
|
||||||
|
'pending_count': pending_count,
|
||||||
|
'physical_classrooms': physical_classrooms,
|
||||||
|
'virtual_classrooms': virtual_classrooms,
|
||||||
|
'occupancy_rate': occupancy_rate,
|
||||||
|
'assigned_classrooms': assigned_classrooms,
|
||||||
|
'assigned_classrooms_pct': assigned_pct,
|
||||||
|
'unassigned_classrooms': unassigned_classrooms,
|
||||||
|
'unassigned_classrooms_pct': unassigned_pct,
|
||||||
|
'assigned_today_classrooms': assigned_today_count,
|
||||||
|
'assigned_today_pct': assigned_today_pct,
|
||||||
|
'unassigned_today_classrooms': unassigned_today_count,
|
||||||
|
'unassigned_today_pct': unassigned_today_pct
|
||||||
|
},
|
||||||
|
'audit_logs': audit_data,
|
||||||
|
'monthly_data': monthly_data,
|
||||||
|
'pending_reservations': pending_reservations,
|
||||||
|
'today_schedule_list': today_schedule_list,
|
||||||
|
'upcoming_reservations': upcoming_reservations,
|
||||||
|
'milestones': milestones
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
@api_dashboard_bp.route('/sync-sheets', methods=['POST'])
|
||||||
|
@jwt_required
|
||||||
|
def sync_google_sheets():
|
||||||
|
"""Ejecuta la sincronización real con las hojas de cálculo de Google Sheets."""
|
||||||
|
from app.services.sheets_importer import GoogleSheetsImporter
|
||||||
|
try:
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
custom_url = data.get('sheets_url') or data.get('base_url')
|
||||||
|
importer = GoogleSheetsImporter(base_url=custom_url)
|
||||||
|
stats = importer.sync()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'stats': stats,
|
||||||
|
'source_url': importer.base_url
|
||||||
|
}), 200
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
from flask import Blueprint, request, jsonify, g
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from app.schemas.optimizer_dto import OptimizerJobRequestDTO, SpringBootSyncPayloadDTO
|
||||||
|
from app.services.optimizer_service import OptimizerService
|
||||||
|
from app.utils.jwt_decorators import jwt_required
|
||||||
|
|
||||||
|
api_optimizer_bp = Blueprint('api_optimizer', __name__, url_prefix='/api/v1/optimizer')
|
||||||
|
|
||||||
|
|
||||||
|
@api_optimizer_bp.route('/jobs', methods=['POST'])
|
||||||
|
@jwt_required
|
||||||
|
def submit_optimization_job():
|
||||||
|
"""
|
||||||
|
Despacha un trabajo de optimización heurística asíncrono.
|
||||||
|
Retorna inmediatamente con HTTP 202 Accepted y el identificador de trabajo para sondeo.
|
||||||
|
"""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
try:
|
||||||
|
dto = OptimizerJobRequestDTO(**data)
|
||||||
|
except ValidationError as ex:
|
||||||
|
return jsonify({
|
||||||
|
'error': 'UnprocessableEntity',
|
||||||
|
'message': 'Error de validación en los parámetros del optimizador.',
|
||||||
|
'details': ex.errors()
|
||||||
|
}), 422
|
||||||
|
|
||||||
|
user_id = g.current_user.id if hasattr(g, 'current_user') and g.current_user else 1
|
||||||
|
|
||||||
|
job = OptimizerService.submit_job(
|
||||||
|
commission_ids=dto.commission_ids,
|
||||||
|
admin_user_id=user_id,
|
||||||
|
start_date=dto.start_date,
|
||||||
|
end_date=dto.end_date,
|
||||||
|
generations=dto.generations,
|
||||||
|
population_size=dto.population_size,
|
||||||
|
workers=dto.workers
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'job_id': job.job_id,
|
||||||
|
'status': job.status.value,
|
||||||
|
'poll_url': f'/api/v1/optimizer/jobs/{job.job_id}',
|
||||||
|
'message': 'Trabajo de optimización puesto en cola con éxito.'
|
||||||
|
}), 202
|
||||||
|
|
||||||
|
|
||||||
|
@api_optimizer_bp.route('/jobs/<string:job_id>', methods=['GET'])
|
||||||
|
@jwt_required
|
||||||
|
def get_optimization_job(job_id: str):
|
||||||
|
"""
|
||||||
|
Obtiene el estado, progreso y resultados de un trabajo de optimización en cola.
|
||||||
|
"""
|
||||||
|
job = OptimizerService.get_job(job_id)
|
||||||
|
if not job:
|
||||||
|
return jsonify({
|
||||||
|
'error': 'NotFound',
|
||||||
|
'message': f'Trabajo de optimización {job_id} no encontrado.'
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
return jsonify(job.to_dict()), 200
|
||||||
|
|
||||||
|
|
||||||
|
@api_optimizer_bp.route('/jobs', methods=['GET'])
|
||||||
|
@jwt_required
|
||||||
|
def list_optimization_jobs():
|
||||||
|
"""
|
||||||
|
Lista los trabajos de optimización más recientes.
|
||||||
|
"""
|
||||||
|
limit = request.args.get('limit', default=20, type=int)
|
||||||
|
jobs = OptimizerService.list_jobs(limit=limit)
|
||||||
|
return jsonify({
|
||||||
|
'total': len(jobs),
|
||||||
|
'jobs': jobs
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@api_optimizer_bp.route('/spring-boot/sync', methods=['POST'])
|
||||||
|
@jwt_required
|
||||||
|
def sync_with_spring_boot():
|
||||||
|
"""
|
||||||
|
Endpoint interoperable para la sincronización y resolución de horarios con microservicios Spring Boot.
|
||||||
|
Recibe el payload en formato Spring Boot (camelCase) y retorna el cronograma optimizado.
|
||||||
|
"""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
try:
|
||||||
|
dto = SpringBootSyncPayloadDTO(**data)
|
||||||
|
except ValidationError as ex:
|
||||||
|
return jsonify({
|
||||||
|
'error': 'UnprocessableEntity',
|
||||||
|
'message': 'Error de validación en el payload Spring Boot.',
|
||||||
|
'details': ex.errors()
|
||||||
|
}), 422
|
||||||
|
|
||||||
|
user_id = g.current_user.id if hasattr(g, 'current_user') and g.current_user else 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = OptimizerService.sync_with_spring_boot(dto, admin_user_id=user_id)
|
||||||
|
return jsonify(result), 200
|
||||||
|
except Exception as ex:
|
||||||
|
return jsonify({
|
||||||
|
'requestId': dto.requestId,
|
||||||
|
'status': 'ERROR',
|
||||||
|
'error': str(ex)
|
||||||
|
}), 500
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
from flask import Blueprint, request, jsonify, g
|
||||||
|
from datetime import datetime, timedelta, date
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from app.services.reservation_service import ReservationService
|
||||||
|
from app.schemas.reservation_dto import ReservationCreateDTO, ReservationUpdateDTO
|
||||||
|
from app.utils.jwt_decorators import jwt_required, jwt_role_required
|
||||||
|
|
||||||
|
api_reservations_bp = Blueprint('api_reservations', __name__, url_prefix='/api/v1/reservations')
|
||||||
|
reservation_service = ReservationService()
|
||||||
|
|
||||||
|
@api_reservations_bp.route('', methods=['GET'])
|
||||||
|
@jwt_required
|
||||||
|
def list_reservations():
|
||||||
|
"""Consulta de reservas por rango de fechas y/o aula."""
|
||||||
|
start_str = request.args.get('start_date')
|
||||||
|
end_str = request.args.get('end_date')
|
||||||
|
classroom_id = request.args.get('classroom_id', type=int)
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
try:
|
||||||
|
start_dt = datetime.strptime(start_str, '%Y-%m-%d') if start_str else now.replace(hour=0, minute=0, second=0)
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({'error': 'BadRequest', 'message': 'Formato inválido de start_date (debe ser YYYY-MM-DD).'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
end_dt = datetime.strptime(end_str, '%Y-%m-%d').replace(hour=23, minute=59, second=59) if end_str else start_dt + timedelta(days=7)
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({'error': 'BadRequest', 'message': 'Formato inválido de end_date (debe ser YYYY-MM-DD).'}), 400
|
||||||
|
|
||||||
|
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
|
||||||
|
floor = request.args.get('floor')
|
||||||
|
shift = request.args.get('shift')
|
||||||
|
search = request.args.get('search')
|
||||||
|
|
||||||
|
reservations = reservation_service.list_reservations(start_dt, end_dt, classroom_id=classroom_id)
|
||||||
|
result = []
|
||||||
|
for r in reservations:
|
||||||
|
is_virt = bool(r.is_virtual or (r.classroom and r.classroom.is_virtual))
|
||||||
|
if hide_virtual and is_virt:
|
||||||
|
continue
|
||||||
|
if floor and r.classroom and str(r.classroom.floor).strip().lower() != str(floor).strip().lower():
|
||||||
|
continue
|
||||||
|
if shift and r.shift and shift.lower() not in r.shift.lower():
|
||||||
|
continue
|
||||||
|
if search:
|
||||||
|
s_lower = search.lower()
|
||||||
|
subj_n = (r.subject_name or '').lower()
|
||||||
|
subj_c = (r.subject_code or '').lower()
|
||||||
|
c_name = (r.classroom.code if r.classroom else '').lower()
|
||||||
|
u_name = (r.user.name if r.user else '').lower()
|
||||||
|
if s_lower not in subj_n and s_lower not in subj_c and s_lower not in c_name and s_lower not in u_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
result.append({
|
||||||
|
'id': r.id,
|
||||||
|
'classroom_id': r.classroom_id,
|
||||||
|
'classroom_name': r.classroom.code if r.classroom else None,
|
||||||
|
'classroom': {
|
||||||
|
'id': r.classroom.id,
|
||||||
|
'code': r.classroom.code,
|
||||||
|
'code_display': getattr(r.classroom, 'code_display', r.classroom.code),
|
||||||
|
'floor': r.classroom.floor,
|
||||||
|
'floor_display': getattr(r.classroom, 'floor_display', f"Piso {r.classroom.floor}"),
|
||||||
|
'building': getattr(r.classroom, 'building_name', r.classroom.building),
|
||||||
|
'is_virtual': getattr(r.classroom, 'is_virtual', False),
|
||||||
|
'location_display': getattr(r.classroom, 'location_display', r.classroom.code)
|
||||||
|
} if r.classroom else None,
|
||||||
|
'commission_id': r.commission_id,
|
||||||
|
'commission': {
|
||||||
|
'id': r.commission.id,
|
||||||
|
'code': r.commission.code,
|
||||||
|
'virtual_link': r.commission.virtual_link,
|
||||||
|
'max_students': r.commission.max_students
|
||||||
|
} if r.commission else None,
|
||||||
|
'teacher_name': r.user.name if r.user else None,
|
||||||
|
'user': {
|
||||||
|
'id': r.user.id,
|
||||||
|
'name': r.user.name,
|
||||||
|
'email': r.user.email
|
||||||
|
} if r.user else None,
|
||||||
|
'start_time': r.start_time.isoformat(),
|
||||||
|
'end_time': r.end_time.isoformat(),
|
||||||
|
'purpose': r.purpose,
|
||||||
|
'subject_name': r.subject_name,
|
||||||
|
'subject_code': r.subject_code,
|
||||||
|
'status': str(r.status.value if hasattr(r.status, 'value') else r.status),
|
||||||
|
'shift': r.shift or 'Mañana',
|
||||||
|
'virtual_link': r.virtual_link,
|
||||||
|
'effective_virtual_link': r.effective_virtual_link,
|
||||||
|
'is_virtual': is_virt,
|
||||||
|
'expected_attendees': r.expected_attendees or 30,
|
||||||
|
'notes': r.notes or ''
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'total': len(result),
|
||||||
|
'start_date': start_dt.strftime('%Y-%m-%d'),
|
||||||
|
'end_date': end_dt.strftime('%Y-%m-%d'),
|
||||||
|
'reservations': result
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
@api_reservations_bp.route('/<int:reservation_id>', methods=['GET'])
|
||||||
|
@jwt_required
|
||||||
|
def get_reservation(reservation_id: int):
|
||||||
|
"""Obtiene el detalle de una reserva específica con sus relaciones completas."""
|
||||||
|
r = reservation_service.get_reservation(reservation_id)
|
||||||
|
if not r:
|
||||||
|
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
|
||||||
|
|
||||||
|
duration_min = int((r.end_time - r.start_time).total_seconds() / 60) if (r.start_time and r.end_time) else 120
|
||||||
|
is_virt = bool(r.is_virtual or (r.classroom and r.classroom.is_virtual))
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'id': r.id,
|
||||||
|
'classroom_id': r.classroom_id,
|
||||||
|
'classroom_name': r.classroom.code if r.classroom else None,
|
||||||
|
'location_display': getattr(r.classroom, 'location_display', r.classroom.code if r.classroom else None),
|
||||||
|
'classroom': {
|
||||||
|
'id': r.classroom.id,
|
||||||
|
'code': r.classroom.code,
|
||||||
|
'code_display': getattr(r.classroom, 'code_display', r.classroom.code),
|
||||||
|
'building_name': getattr(r.classroom, 'building_name', getattr(r.classroom, 'building', 'Edificio Central')),
|
||||||
|
'floor': r.classroom.floor,
|
||||||
|
'floor_display': getattr(r.classroom, 'floor_display', f"Piso {r.classroom.floor}"),
|
||||||
|
'capacity': r.classroom.capacity,
|
||||||
|
'capacity_display': getattr(r.classroom, 'capacity_display', str(r.classroom.capacity)),
|
||||||
|
'description': getattr(r.classroom, 'description', '') or '',
|
||||||
|
'is_virtual': getattr(r.classroom, 'is_virtual', False)
|
||||||
|
} if r.classroom else None,
|
||||||
|
'commission_id': r.commission_id,
|
||||||
|
'commission': {
|
||||||
|
'id': r.commission.id,
|
||||||
|
'code': r.commission.code,
|
||||||
|
'full_code': r.commission.get_full_code() if hasattr(r.commission, 'get_full_code') else r.commission.code,
|
||||||
|
'virtual_link': r.commission.virtual_link,
|
||||||
|
'max_students': r.commission.max_students,
|
||||||
|
'current_students': getattr(r.commission, 'current_students', 0),
|
||||||
|
'subject': {
|
||||||
|
'id': r.commission.subject.id,
|
||||||
|
'name': r.commission.subject.name,
|
||||||
|
'code': r.commission.subject.code
|
||||||
|
} if (r.commission and r.commission.subject) else None
|
||||||
|
} if r.commission else None,
|
||||||
|
'teacher_id': r.user_id,
|
||||||
|
'teacher_name': r.user.name if r.user else None,
|
||||||
|
'user': {
|
||||||
|
'id': r.user.id,
|
||||||
|
'name': r.user.name,
|
||||||
|
'first_name': getattr(r.user, 'first_name', r.user.name.split()[0] if r.user.name else 'Usuario'),
|
||||||
|
'last_name': getattr(r.user, 'last_name', ' '.join(r.user.name.split()[1:]) if r.user.name and len(r.user.name.split()) > 1 else ''),
|
||||||
|
'email': r.user.email
|
||||||
|
} if r.user else None,
|
||||||
|
'start_time': r.start_time.isoformat(),
|
||||||
|
'end_time': r.end_time.isoformat(),
|
||||||
|
'duration': duration_min,
|
||||||
|
'purpose': r.purpose,
|
||||||
|
'status': str(r.status.value if hasattr(r.status, 'value') else r.status),
|
||||||
|
'shift': r.shift or 'Mañana',
|
||||||
|
'virtual_link': r.virtual_link,
|
||||||
|
'effective_virtual_link': r.effective_virtual_link,
|
||||||
|
'is_virtual': is_virt,
|
||||||
|
'expected_attendees': r.expected_attendees or 30,
|
||||||
|
'notes': r.notes or '',
|
||||||
|
'can_be_cancelled': r.can_be_cancelled() if hasattr(r, 'can_be_cancelled') else True
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
@api_reservations_bp.route('', methods=['POST'])
|
||||||
|
@jwt_required
|
||||||
|
def create_reservation():
|
||||||
|
"""Crea una reserva aplicando validación DTO y detección de colisiones."""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
# Inyectar usuario autenticado si no se especifica
|
||||||
|
if 'user_id' not in data:
|
||||||
|
data['user_id'] = g.jwt_user.id
|
||||||
|
|
||||||
|
try:
|
||||||
|
dto = ReservationCreateDTO(**data)
|
||||||
|
except ValidationError as e:
|
||||||
|
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
reservation = reservation_service.create_reservation(dto)
|
||||||
|
return jsonify({
|
||||||
|
'message': 'Reserva creada exitosamente.',
|
||||||
|
'id': reservation.id,
|
||||||
|
'classroom_id': reservation.classroom_id,
|
||||||
|
'start_time': reservation.start_time.isoformat(),
|
||||||
|
'end_time': reservation.end_time.isoformat(),
|
||||||
|
'status': str(reservation.status)
|
||||||
|
}), 201
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'error': 'ConflictOrValidation', 'message': str(e)}), 409
|
||||||
|
|
||||||
|
@api_reservations_bp.route('/<int:reservation_id>', methods=['PUT'])
|
||||||
|
@jwt_required
|
||||||
|
def update_reservation(reservation_id: int):
|
||||||
|
"""Modifica una reserva existente."""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
try:
|
||||||
|
dto = ReservationUpdateDTO(**data)
|
||||||
|
except ValidationError as e:
|
||||||
|
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
reservation = reservation_service.update_reservation(reservation_id, dto)
|
||||||
|
return jsonify({
|
||||||
|
'message': 'Reserva actualizada exitosamente.',
|
||||||
|
'id': reservation.id,
|
||||||
|
'start_time': reservation.start_time.isoformat(),
|
||||||
|
'end_time': reservation.end_time.isoformat(),
|
||||||
|
'status': str(reservation.status)
|
||||||
|
}), 200
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'error': 'ConflictOrValidation', 'message': str(e)}), 409
|
||||||
|
|
||||||
|
@api_reservations_bp.route('/<int:reservation_id>', methods=['DELETE'])
|
||||||
|
@jwt_required
|
||||||
|
def cancel_reservation(reservation_id: int):
|
||||||
|
"""Cancela una reserva."""
|
||||||
|
cancelled = reservation_service.cancel_reservation(reservation_id)
|
||||||
|
if not cancelled:
|
||||||
|
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
|
||||||
|
|
||||||
|
return jsonify({'message': f'Reserva {reservation_id} cancelada correctamente.'}), 200
|
||||||
|
|
||||||
|
@api_reservations_bp.route('/<int:reservation_id>/cancel', methods=['POST'])
|
||||||
|
@jwt_required
|
||||||
|
def cancel_reservation_post(reservation_id: int):
|
||||||
|
"""Cancela una reserva (endpoint POST compatible con formularios)."""
|
||||||
|
cancelled = reservation_service.cancel_reservation(reservation_id)
|
||||||
|
if not cancelled:
|
||||||
|
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
|
||||||
|
|
||||||
|
return jsonify({'message': f'Reserva {reservation_id} cancelada correctamente.'}), 200
|
||||||
|
|
||||||
|
@api_reservations_bp.route('/<int:reservation_id>/confirm', methods=['POST'])
|
||||||
|
@jwt_required
|
||||||
|
def confirm_reservation(reservation_id: int):
|
||||||
|
"""Confirma una reserva cambiando su estado a CONFIRMED."""
|
||||||
|
confirmed = reservation_service.confirm_reservation(reservation_id)
|
||||||
|
if not confirmed:
|
||||||
|
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
|
||||||
|
|
||||||
|
return jsonify({'message': f'Reserva {reservation_id} confirmada correctamente.'}), 200
|
||||||
|
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, session, jsonify
|
||||||
|
from flask_login import login_user, logout_user, login_required, current_user
|
||||||
|
from flask_babel import gettext as _
|
||||||
|
from app import db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.forms.auth import LoginForm
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
auth_bp = Blueprint('auth', __name__)
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||||
|
def login():
|
||||||
|
"""Handle user login with case-insensitive email lookup and whitespace trimming"""
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
|
form = LoginForm()
|
||||||
|
if form.validate_on_submit():
|
||||||
|
clean_email = (form.email.data or '').strip().lower()
|
||||||
|
user = User.query.filter(func.lower(User.email) == clean_email).first()
|
||||||
|
|
||||||
|
if user and user.check_password(form.password.data):
|
||||||
|
if not user.is_active:
|
||||||
|
flash(_('Tu cuenta ha sido desactivada. Por favor contacta al administrador.'), 'error')
|
||||||
|
return render_template('auth/login.html', form=form)
|
||||||
|
|
||||||
|
login_user(user, remember=form.remember_me.data)
|
||||||
|
user.update_last_login()
|
||||||
|
|
||||||
|
# Registrar en bitácora de auditoría
|
||||||
|
try:
|
||||||
|
from app.services.audit_service import AuditService
|
||||||
|
AuditService.log(
|
||||||
|
action='LOGIN',
|
||||||
|
module='auth',
|
||||||
|
user_id=user.id,
|
||||||
|
user_email=user.email,
|
||||||
|
details=f"Inicio de sesión exitoso ({user.role})"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Si el usuario tiene idioma configurado en su perfil, sobrescribe el navegador en toda la sesión
|
||||||
|
if user.preferred_language and user.preferred_language in ['es', 'en']:
|
||||||
|
session['language'] = user.preferred_language
|
||||||
|
else:
|
||||||
|
session.pop('language', None)
|
||||||
|
|
||||||
|
session.pop('login_lang_explicit', None)
|
||||||
|
|
||||||
|
next_page = request.args.get('next')
|
||||||
|
if not next_page or not next_page.startswith('/'):
|
||||||
|
next_page = url_for('main.dashboard')
|
||||||
|
|
||||||
|
user_full_name = f'{user.first_name} {user.last_name}'.strip() or user.name
|
||||||
|
flash(_('¡Bienvenido de nuevo, %(name)s!', name=user_full_name), 'success')
|
||||||
|
return redirect(next_page)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
from app.services.audit_service import AuditService
|
||||||
|
AuditService.log(
|
||||||
|
action='LOGIN_FAILED',
|
||||||
|
module='auth',
|
||||||
|
details=f"Intento fallido de login con email: {clean_email}"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
flash(_('Correo electrónico o contraseña inválidos.'), 'error')
|
||||||
|
|
||||||
|
return render_template('auth/login.html', form=form)
|
||||||
|
|
||||||
|
@auth_bp.route('/logout')
|
||||||
|
@login_required
|
||||||
|
def logout():
|
||||||
|
"""Handle user logout"""
|
||||||
|
logout_user()
|
||||||
|
# Al cerrar sesión, se limpian las cookies de idioma para que el login detecte el navegador o use español
|
||||||
|
session.pop('language', None)
|
||||||
|
session.pop('login_lang_explicit', None)
|
||||||
|
flash(_('Has cerrado sesión correctamente.'), 'info')
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
@auth_bp.route('/profile', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def profile():
|
||||||
|
"""User profile page with language and theme settings"""
|
||||||
|
if request.method == 'POST':
|
||||||
|
lang = request.form.get('preferred_language')
|
||||||
|
if lang in ['es', 'en']:
|
||||||
|
current_user.preferred_language = lang
|
||||||
|
session['language'] = lang
|
||||||
|
else:
|
||||||
|
# Cadena vacía o None restablece la detección automática del navegador
|
||||||
|
current_user.preferred_language = None
|
||||||
|
session.pop('language', None)
|
||||||
|
|
||||||
|
theme = request.form.get('theme_preference')
|
||||||
|
if theme in ['auto', 'light', 'dark']:
|
||||||
|
current_user.theme_preference = theme
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
flash(_('Preferencias de usuario actualizadas correctamente.'), 'success')
|
||||||
|
return redirect(url_for('auth.profile'))
|
||||||
|
return render_template('auth/profile.html')
|
||||||
|
|
||||||
|
@auth_bp.route('/api/user/theme', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def update_theme_api():
|
||||||
|
"""Update user theme preference via fetch/AJAX from navbar toggle"""
|
||||||
|
data = request.get_json(silent=True) or request.form
|
||||||
|
theme = data.get('theme') if data else None
|
||||||
|
if theme in ['auto', 'light', 'dark']:
|
||||||
|
current_user.theme_preference = theme
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({'status': 'success', 'theme': theme})
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid theme'}), 400
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route('/change_password', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def change_password():
|
||||||
|
"""Change user password"""
|
||||||
|
if request.method == 'POST':
|
||||||
|
current_password = request.form.get('current_password')
|
||||||
|
new_password = request.form.get('new_password')
|
||||||
|
confirm_password = request.form.get('confirm_password')
|
||||||
|
|
||||||
|
if not current_user.check_password(current_password):
|
||||||
|
flash('Current password is incorrect.', 'error')
|
||||||
|
return redirect(url_for('auth.change_password'))
|
||||||
|
|
||||||
|
if new_password != confirm_password:
|
||||||
|
flash('New passwords do not match.', 'error')
|
||||||
|
return redirect(url_for('auth.change_password'))
|
||||||
|
|
||||||
|
if len(new_password) < 6:
|
||||||
|
flash('Password must be at least 6 characters long.', 'error')
|
||||||
|
return redirect(url_for('auth.change_password'))
|
||||||
|
|
||||||
|
current_user.set_password(new_password)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash('Password changed successfully!', 'success')
|
||||||
|
return redirect(url_for('auth.profile'))
|
||||||
|
|
||||||
|
return render_template('auth/change_password.html')
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from app import db
|
||||||
|
from app.models.building import Building
|
||||||
|
from app.models.classroom import Classroom
|
||||||
|
from app.forms.building import BuildingForm
|
||||||
|
from app.utils.decorators import permission_required
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
buildings_bp = Blueprint('buildings', __name__, url_prefix='/buildings')
|
||||||
|
|
||||||
|
@buildings_bp.route('/')
|
||||||
|
@login_required
|
||||||
|
def list_buildings():
|
||||||
|
"""List all buildings with statistics"""
|
||||||
|
search = request.args.get('search', '').strip()
|
||||||
|
status_filter = request.args.get('status', '')
|
||||||
|
|
||||||
|
query = Building.query
|
||||||
|
if search:
|
||||||
|
query = query.filter(
|
||||||
|
db.or_(
|
||||||
|
Building.name.ilike(f'%{search}%'),
|
||||||
|
Building.code.ilike(f'%{search}%'),
|
||||||
|
Building.address.ilike(f'%{search}%')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if status_filter == 'active':
|
||||||
|
query = query.filter(Building.is_active == True)
|
||||||
|
elif status_filter == 'inactive':
|
||||||
|
query = query.filter(Building.is_active == False)
|
||||||
|
|
||||||
|
buildings = query.order_by(Building.name.asc()).all()
|
||||||
|
|
||||||
|
# Calculate global building stats
|
||||||
|
total_classrooms = Classroom.query.filter_by(is_active=True).count()
|
||||||
|
total_capacity = sum(c.capacity for c in Classroom.query.filter_by(is_active=True).all())
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'buildings/list.html',
|
||||||
|
buildings=buildings,
|
||||||
|
total_classrooms=total_classrooms,
|
||||||
|
total_capacity=total_capacity,
|
||||||
|
search=search,
|
||||||
|
status_filter=status_filter,
|
||||||
|
title='Building Management'
|
||||||
|
)
|
||||||
|
|
||||||
|
@buildings_bp.route('/add', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
@permission_required('classrooms', 'read_write')
|
||||||
|
def add_building():
|
||||||
|
"""Add new building"""
|
||||||
|
form = BuildingForm()
|
||||||
|
|
||||||
|
if form.validate_on_submit():
|
||||||
|
try:
|
||||||
|
# Check unique name
|
||||||
|
existing = Building.query.filter(
|
||||||
|
db.func.lower(Building.name) == form.name.data.lower()
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
flash('Ya existe un edificio con este nombre.', 'danger')
|
||||||
|
return render_template('buildings/form.html', form=form, title='Add Building')
|
||||||
|
|
||||||
|
building = Building(
|
||||||
|
name=form.name.data.strip(),
|
||||||
|
code=form.code.data.strip().upper() if form.code.data else None,
|
||||||
|
address=form.address.data.strip() if form.address.data else None,
|
||||||
|
floors=form.floors.data,
|
||||||
|
description=form.description.data.strip() if form.description.data else None,
|
||||||
|
is_active=form.is_active.data
|
||||||
|
)
|
||||||
|
db.session.add(building)
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'Edificio "{building.name}" creado con éxito.', 'success')
|
||||||
|
return redirect(url_for('buildings.view_building', id=building.id))
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f'Error al crear el edificio: {str(e)}', 'danger')
|
||||||
|
|
||||||
|
return render_template('buildings/form.html', form=form, title='Add Building')
|
||||||
|
|
||||||
|
@buildings_bp.route('/<int:id>')
|
||||||
|
@login_required
|
||||||
|
def view_building(id):
|
||||||
|
"""View building details and its classrooms by floor"""
|
||||||
|
building = Building.query.get_or_404(id)
|
||||||
|
|
||||||
|
# Group active classrooms by floor
|
||||||
|
classrooms = Classroom.query.filter_by(building_id=building.id).order_by(Classroom.floor.asc(), Classroom.code.asc()).all()
|
||||||
|
# Fallback to name matching if building_id was not set
|
||||||
|
if not classrooms:
|
||||||
|
classrooms = Classroom.query.filter_by(building=building.name).order_by(Classroom.floor.asc(), Classroom.code.asc()).all()
|
||||||
|
|
||||||
|
classrooms_by_floor = {}
|
||||||
|
for c in classrooms:
|
||||||
|
floor_key = c.floor
|
||||||
|
if floor_key not in classrooms_by_floor:
|
||||||
|
classrooms_by_floor[floor_key] = []
|
||||||
|
classrooms_by_floor[floor_key].append(c)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'buildings/view.html',
|
||||||
|
building=building,
|
||||||
|
classrooms=classrooms,
|
||||||
|
classrooms_by_floor=classrooms_by_floor,
|
||||||
|
title=f'Building - {building.name}'
|
||||||
|
)
|
||||||
|
|
||||||
|
@buildings_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
@permission_required('classrooms', 'read_write')
|
||||||
|
def edit_building(id):
|
||||||
|
"""Edit building"""
|
||||||
|
building = Building.query.get_or_404(id)
|
||||||
|
form = BuildingForm(obj=building)
|
||||||
|
|
||||||
|
if form.validate_on_submit():
|
||||||
|
try:
|
||||||
|
# Check unique name excluding current
|
||||||
|
existing = Building.query.filter(
|
||||||
|
db.func.lower(Building.name) == form.name.data.lower(),
|
||||||
|
Building.id != id
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
flash('Ya existe otro edificio con este nombre.', 'danger')
|
||||||
|
return render_template('buildings/form.html', form=form, building=building, title='Edit Building')
|
||||||
|
|
||||||
|
old_name = building.name
|
||||||
|
new_name = form.name.data.strip()
|
||||||
|
|
||||||
|
building.name = new_name
|
||||||
|
building.code = form.code.data.strip().upper() if form.code.data else None
|
||||||
|
building.address = form.address.data.strip() if form.address.data else None
|
||||||
|
building.floors = form.floors.data
|
||||||
|
building.description = form.description.data.strip() if form.description.data else None
|
||||||
|
building.is_active = form.is_active.data
|
||||||
|
building.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
# If name changed, update string building on classrooms
|
||||||
|
if old_name != new_name:
|
||||||
|
Classroom.query.filter_by(building_id=building.id).update({'building': new_name})
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'Edificio "{building.name}" actualizado con éxito.', 'success')
|
||||||
|
return redirect(url_for('buildings.view_building', id=building.id))
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f'Error al actualizar el edificio: {str(e)}', 'danger')
|
||||||
|
|
||||||
|
return render_template('buildings/form.html', form=form, building=building, title='Edit Building')
|
||||||
|
|
||||||
|
@buildings_bp.route('/<int:id>/toggle', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@permission_required('classrooms', 'read_write')
|
||||||
|
def toggle_building(id):
|
||||||
|
"""Toggle building active status"""
|
||||||
|
building = Building.query.get_or_404(id)
|
||||||
|
try:
|
||||||
|
building.is_active = not building.is_active
|
||||||
|
building.updated_at = datetime.utcnow()
|
||||||
|
db.session.commit()
|
||||||
|
status_text = 'activado' if building.is_active else 'desactivado'
|
||||||
|
flash(f'Edificio "{building.name}" {status_text} con éxito.', 'success')
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f'Error al cambiar estado del edificio: {str(e)}', 'danger')
|
||||||
|
return redirect(url_for('buildings.list_buildings'))
|
||||||
|
|
||||||
|
@buildings_bp.route('/<int:id>/delete', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@permission_required('classrooms', 'read_write')
|
||||||
|
def delete_building(id):
|
||||||
|
"""Delete building if it has no classrooms assigned"""
|
||||||
|
building = Building.query.get_or_404(id)
|
||||||
|
has_classrooms = Classroom.query.filter(
|
||||||
|
db.or_(Classroom.building_id == building.id, Classroom.building == building.name)
|
||||||
|
).count()
|
||||||
|
|
||||||
|
if has_classrooms > 0:
|
||||||
|
flash(f'No se puede eliminar el edificio "{building.name}" porque tiene {has_classrooms} aulas asociadas. Primero reasigna o elimina las aulas.', 'danger')
|
||||||
|
return redirect(url_for('buildings.view_building', id=building.id))
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.session.delete(building)
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'Edificio "{building.name}" eliminado correctamente.', 'success')
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f'Error al eliminar edificio: {str(e)}', 'danger')
|
||||||
|
|
||||||
|
return redirect(url_for('buildings.list_buildings'))
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from datetime import datetime, date
|
||||||
|
from app.models.classroom import Classroom
|
||||||
|
from app.models.user import User
|
||||||
|
from app.forms.classroom import ClassroomForm
|
||||||
|
from app import db
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
classrooms_bp = Blueprint('classrooms', __name__)
|
||||||
|
|
||||||
|
@classrooms_bp.route('/add', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def add_classroom():
|
||||||
|
"""Add a new classroom with dynamic building creation and floor suggestion"""
|
||||||
|
from app.models.building import Building
|
||||||
|
import re
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
form = ClassroomForm()
|
||||||
|
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
|
||||||
|
form.building_id.choices = [(str(b.id), b.name) for b in buildings] + [('__new__', '+ Crear nuevo edificio')]
|
||||||
|
|
||||||
|
# Map of existing floors per building for datalist
|
||||||
|
building_floors = {str(b.id): list(dict.fromkeys(c.floor for c in b.classrooms if c.floor)) for b in buildings}
|
||||||
|
|
||||||
|
if form.validate_on_submit():
|
||||||
|
try:
|
||||||
|
building = None
|
||||||
|
if form.building_id.data == '__new__':
|
||||||
|
new_building_name = form.new_building_name.data.strip() if form.new_building_name.data else ''
|
||||||
|
if not new_building_name:
|
||||||
|
flash('Debe ingresar el nombre del nuevo edificio.', 'error')
|
||||||
|
return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom')
|
||||||
|
|
||||||
|
building = Building.query.filter(func.lower(Building.name) == func.lower(new_building_name)).first()
|
||||||
|
if not building:
|
||||||
|
code_slug = re.sub(r'[^a-zA-Z0-9]+', '_', new_building_name.upper()).strip('_')
|
||||||
|
building = Building(name=new_building_name, code=code_slug, is_active=True)
|
||||||
|
db.session.add(building)
|
||||||
|
db.session.commit()
|
||||||
|
else:
|
||||||
|
building = Building.query.get_or_404(int(form.building_id.data))
|
||||||
|
|
||||||
|
room_code = form.room_number.data.strip()
|
||||||
|
floor_val = str(form.floor.data).strip()
|
||||||
|
|
||||||
|
# Prevent duplicate room in same building & floor
|
||||||
|
existing_classroom = Classroom.query.filter_by(
|
||||||
|
building_id=building.id,
|
||||||
|
code=room_code,
|
||||||
|
floor=floor_val
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_classroom:
|
||||||
|
flash(f'Ya existe un aula con el nombre "{room_code}" en el piso {floor_val} de {building.name}.', 'error')
|
||||||
|
return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom')
|
||||||
|
|
||||||
|
cap_raw = str(form.capacity.data or '').strip()
|
||||||
|
if cap_raw in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']:
|
||||||
|
cap_val = 9999
|
||||||
|
else:
|
||||||
|
cap_val = int(cap_raw)
|
||||||
|
|
||||||
|
from app.services.classroom_service import ClassroomService
|
||||||
|
from app.schemas.classroom_dto import ClassroomCreateDTO
|
||||||
|
from app.services.audit_service import AuditService
|
||||||
|
|
||||||
|
dto = ClassroomCreateDTO(
|
||||||
|
building_id=building.id,
|
||||||
|
code=room_code,
|
||||||
|
floor=floor_val,
|
||||||
|
capacity=cap_val,
|
||||||
|
description=form.description.data,
|
||||||
|
is_active=form.is_active.data
|
||||||
|
)
|
||||||
|
classroom_service = ClassroomService()
|
||||||
|
classroom = classroom_service.create_classroom(dto)
|
||||||
|
|
||||||
|
# Registrar en bitácora inmutable de auditoría
|
||||||
|
AuditService.log('CREATE', 'classrooms', entity_id=classroom.id, details={'code': classroom.code, 'building': building.name})
|
||||||
|
|
||||||
|
flash(f'Aula {classroom.location_display} creada exitosamente.', 'success')
|
||||||
|
return redirect(url_for('classrooms.list_classrooms'))
|
||||||
|
|
||||||
|
except ValueError as ve:
|
||||||
|
flash(str(ve), 'error')
|
||||||
|
return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom')
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f'Error al crear el aula: {str(e)}', 'error')
|
||||||
|
|
||||||
|
return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom')
|
||||||
|
|
||||||
|
|
||||||
|
@classrooms_bp.route('/', endpoint='index')
|
||||||
|
@classrooms_bp.route('/list')
|
||||||
|
@login_required
|
||||||
|
def list_classrooms():
|
||||||
|
"""List all classrooms with separation between Physical spaces and Virtual on-demand rooms"""
|
||||||
|
try:
|
||||||
|
from app.models.building import Building
|
||||||
|
from app.models.reservation import Reservation, ReservationStatus
|
||||||
|
from app.models.subject import Commission, Subject
|
||||||
|
import calendar
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
active_only = request.args.get('active_only', 'true').lower() == 'true'
|
||||||
|
search = request.args.get('search', '', type=str).strip()
|
||||||
|
building_filter = request.args.get('building_id', '', type=str).strip()
|
||||||
|
floor_filter = request.args.get('floor', '', type=str).strip()
|
||||||
|
view_mode = request.args.get('view_mode', '').strip().lower()
|
||||||
|
|
||||||
|
# Identificar edificio virtual y aula virtual base
|
||||||
|
virtual_building = Building.query.filter(
|
||||||
|
db.or_(Building.code == 'VIRTUAL', Building.name.ilike('%Virtual%'))
|
||||||
|
).first()
|
||||||
|
virtual_bldg_id = virtual_building.id if virtual_building else None
|
||||||
|
|
||||||
|
# Si el usuario seleccionó "Campus Virtual" en el filtro de edificios, conmutar a pestaña virtual
|
||||||
|
if building_filter and str(building_filter) == str(virtual_bldg_id):
|
||||||
|
view_mode = 'virtual'
|
||||||
|
elif not view_mode:
|
||||||
|
view_mode = 'physical'
|
||||||
|
|
||||||
|
# Fechas operativas (Día, Semana, Mes)
|
||||||
|
today = datetime.utcnow().date()
|
||||||
|
today_start = datetime.combine(today, datetime.min.time())
|
||||||
|
today_end = datetime.combine(today, datetime.max.time())
|
||||||
|
|
||||||
|
week_start = datetime.combine(today - timedelta(days=today.weekday()), datetime.min.time())
|
||||||
|
week_end = datetime.combine(week_start.date() + timedelta(days=6), datetime.max.time())
|
||||||
|
|
||||||
|
_, last_day = calendar.monthrange(today.year, today.month)
|
||||||
|
month_start = datetime(today.year, today.month, 1, 0, 0, 0)
|
||||||
|
month_end = datetime(today.year, today.month, last_day, 23, 59, 59)
|
||||||
|
|
||||||
|
date_filters = {
|
||||||
|
'today': today.strftime('%Y-%m-%d'),
|
||||||
|
'week_start': week_start.strftime('%Y-%m-%d'),
|
||||||
|
'week_end': week_end.strftime('%Y-%m-%d'),
|
||||||
|
'month_start': month_start.strftime('%Y-%m-%d'),
|
||||||
|
'month_end': month_end.strftime('%Y-%m-%d')
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- AULAS FÍSICAS (Presenciales) ---
|
||||||
|
physical_query = Classroom.query
|
||||||
|
if virtual_bldg_id:
|
||||||
|
physical_query = physical_query.filter(Classroom.building_id != virtual_bldg_id)
|
||||||
|
else:
|
||||||
|
physical_query = physical_query.filter(~Classroom.code.ilike('%VIRTUAL%'))
|
||||||
|
|
||||||
|
if active_only:
|
||||||
|
physical_query = physical_query.filter_by(is_active=True)
|
||||||
|
|
||||||
|
physical_count = physical_query.count()
|
||||||
|
|
||||||
|
if building_filter and building_filter.isdigit() and str(building_filter) != str(virtual_bldg_id):
|
||||||
|
physical_query = physical_query.filter(Classroom.building_id == int(building_filter))
|
||||||
|
|
||||||
|
if floor_filter:
|
||||||
|
flr = str(floor_filter).strip()
|
||||||
|
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
|
||||||
|
physical_query = physical_query.filter(db.or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
|
||||||
|
else:
|
||||||
|
physical_query = physical_query.filter(Classroom.floor == flr)
|
||||||
|
|
||||||
|
if search and view_mode == 'physical':
|
||||||
|
physical_query = physical_query.filter(
|
||||||
|
db.or_(
|
||||||
|
Classroom.building.ilike(f'%{search}%'),
|
||||||
|
Classroom.code.ilike(f'%{search}%'),
|
||||||
|
Classroom.description.ilike(f'%{search}%')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
classrooms = physical_query.order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).paginate(
|
||||||
|
page=page, per_page=20, error_out=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Edificios y Pisos estrictamente físicos para el filtro
|
||||||
|
buildings = Building.query.filter_by(is_active=True)
|
||||||
|
if virtual_bldg_id:
|
||||||
|
buildings = buildings.filter(Building.id != virtual_bldg_id)
|
||||||
|
buildings = buildings.order_by(Building.name.asc()).all()
|
||||||
|
|
||||||
|
floors = [f[0] for f in db.session.query(Classroom.floor).filter(
|
||||||
|
Classroom.is_active == True,
|
||||||
|
Classroom.building_id != virtual_bldg_id if virtual_bldg_id else True,
|
||||||
|
Classroom.floor.isnot(None)
|
||||||
|
).distinct().order_by(Classroom.floor.asc()).all()]
|
||||||
|
|
||||||
|
# Métricas de ocupación física
|
||||||
|
visible_ids = [c.id for c in classrooms.items]
|
||||||
|
month_reservations = Reservation.query.filter(
|
||||||
|
Reservation.classroom_id.in_(visible_ids),
|
||||||
|
Reservation.start_time >= month_start,
|
||||||
|
Reservation.start_time <= month_end,
|
||||||
|
Reservation.status.in_([ReservationStatus.CONFIRMED.value, ReservationStatus.PENDING.value])
|
||||||
|
).all() if visible_ids else []
|
||||||
|
|
||||||
|
DAILY_OP_MINS = 15 * 60
|
||||||
|
WEEKLY_OP_MINS = 6 * 15 * 60
|
||||||
|
MONTHLY_OP_MINS = 24 * 15 * 60
|
||||||
|
|
||||||
|
occupancy_metrics = {}
|
||||||
|
for c in classrooms.items:
|
||||||
|
c_res = [r for r in month_reservations if r.classroom_id == c.id]
|
||||||
|
day_res = [r for r in c_res if today_start <= r.start_time <= today_end]
|
||||||
|
day_mins = sum((min(r.end_time, today_end) - max(r.start_time, today_start)).total_seconds() / 60 for r in day_res)
|
||||||
|
day_pct = min(100, int(round((day_mins / DAILY_OP_MINS) * 100)))
|
||||||
|
|
||||||
|
week_res = [r for r in c_res if week_start <= r.start_time <= week_end]
|
||||||
|
week_mins = sum((min(r.end_time, week_end) - max(r.start_time, week_start)).total_seconds() / 60 for r in week_res)
|
||||||
|
week_pct = min(100, int(round((week_mins / WEEKLY_OP_MINS) * 100)))
|
||||||
|
|
||||||
|
month_mins = sum((min(r.end_time, month_end) - max(r.start_time, month_start)).total_seconds() / 60 for r in c_res)
|
||||||
|
month_pct = min(100, int(round((month_mins / MONTHLY_OP_MINS) * 100)))
|
||||||
|
|
||||||
|
occupancy_metrics[c.id] = {
|
||||||
|
'day_pct': day_pct,
|
||||||
|
'day_count': len(day_res),
|
||||||
|
'week_pct': week_pct,
|
||||||
|
'week_count': len(week_res),
|
||||||
|
'month_pct': month_pct,
|
||||||
|
'month_count': len(c_res)
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- AULAS VIRTUALES (Campus Virtual a Demanda - Infinitas) ---
|
||||||
|
vc = Classroom.query.filter(
|
||||||
|
Classroom.building_id == virtual_bldg_id if virtual_bldg_id else Classroom.code.ilike('%VIRTUAL%')
|
||||||
|
).first()
|
||||||
|
vc_id = vc.id if vc else 1
|
||||||
|
|
||||||
|
all_vres = Reservation.query.filter(Reservation.classroom_id == vc_id).all() if vc_id else []
|
||||||
|
|
||||||
|
# Agrupar las salas virtuales por comisión
|
||||||
|
comm_dict = {}
|
||||||
|
for r in all_vres:
|
||||||
|
c = r.commission
|
||||||
|
cid = c.id if c else 0
|
||||||
|
if cid not in comm_dict:
|
||||||
|
vlink = (c.virtual_link if c and c.virtual_link else None) or r.effective_virtual_link
|
||||||
|
comm_dict[cid] = {
|
||||||
|
'commission_id': cid,
|
||||||
|
'commission_code': c.code if c else 'General',
|
||||||
|
'subject_name': c.subject.name if (c and c.subject) else (r.purpose or 'Clase Virtual'),
|
||||||
|
'subject_code': c.subject.code if (c and c.subject) else '',
|
||||||
|
'career_name': c.subject.career.name if (c and c.subject and hasattr(c.subject, 'career') and c.subject.career) else '',
|
||||||
|
'max_students': c.max_students if c else (r.expected_attendees or 0),
|
||||||
|
'virtual_link': vlink,
|
||||||
|
'reservations': []
|
||||||
|
}
|
||||||
|
comm_dict[cid]['reservations'].append(r)
|
||||||
|
if not comm_dict[cid]['virtual_link'] and r.effective_virtual_link:
|
||||||
|
comm_dict[cid]['virtual_link'] = r.effective_virtual_link
|
||||||
|
|
||||||
|
virtual_rooms = []
|
||||||
|
for cid, data in comm_dict.items():
|
||||||
|
res_list = data['reservations']
|
||||||
|
day_cnt = sum(1 for r in res_list if today_start <= r.start_time <= today_end)
|
||||||
|
week_cnt = sum(1 for r in res_list if week_start <= r.start_time <= week_end)
|
||||||
|
month_cnt = sum(1 for r in res_list if month_start <= r.start_time <= month_end)
|
||||||
|
shift = res_list[0].shift if res_list else 'A Demanda'
|
||||||
|
|
||||||
|
if search and view_mode == 'virtual':
|
||||||
|
s_low = search.lower()
|
||||||
|
matches = (
|
||||||
|
s_low in data['subject_name'].lower() or
|
||||||
|
s_low in data['commission_code'].lower() or
|
||||||
|
s_low in data['subject_code'].lower() or
|
||||||
|
s_low in data['career_name'].lower()
|
||||||
|
)
|
||||||
|
if not matches:
|
||||||
|
continue
|
||||||
|
|
||||||
|
virtual_rooms.append({
|
||||||
|
'commission_id': data['commission_id'],
|
||||||
|
'commission_code': data['commission_code'],
|
||||||
|
'subject_name': data['subject_name'],
|
||||||
|
'subject_code': data['subject_code'],
|
||||||
|
'career_name': data['career_name'],
|
||||||
|
'max_students': data['max_students'],
|
||||||
|
'virtual_link': data['virtual_link'],
|
||||||
|
'shift': shift,
|
||||||
|
'day_count': day_cnt,
|
||||||
|
'week_count': week_cnt,
|
||||||
|
'month_count': month_cnt,
|
||||||
|
'total_count': len(res_list),
|
||||||
|
'reservations': sorted(res_list, key=lambda x: x.start_time)
|
||||||
|
})
|
||||||
|
|
||||||
|
virtual_rooms.sort(key=lambda x: (x['subject_name'], x['commission_code']))
|
||||||
|
|
||||||
|
global_virtual_metrics = {
|
||||||
|
'today_count': sum(1 for r in all_vres if today_start <= r.start_time <= today_end),
|
||||||
|
'week_count': sum(1 for r in all_vres if week_start <= r.start_time <= week_end),
|
||||||
|
'month_count': sum(1 for r in all_vres if month_start <= r.start_time <= month_end),
|
||||||
|
'total_count': len(all_vres),
|
||||||
|
'commissions_count': len(comm_dict)
|
||||||
|
}
|
||||||
|
|
||||||
|
return render_template('classrooms/list.html',
|
||||||
|
classrooms=classrooms,
|
||||||
|
buildings=buildings,
|
||||||
|
floors=floors,
|
||||||
|
building_filter=building_filter,
|
||||||
|
floor_filter=floor_filter,
|
||||||
|
active_only=active_only,
|
||||||
|
search=search,
|
||||||
|
view_mode=view_mode,
|
||||||
|
physical_count=physical_count,
|
||||||
|
virtual_count=len(comm_dict),
|
||||||
|
virtual_rooms=virtual_rooms,
|
||||||
|
virtual_classroom_id=vc_id,
|
||||||
|
global_virtual_metrics=global_virtual_metrics,
|
||||||
|
occupancy_metrics=occupancy_metrics,
|
||||||
|
date_filters=date_filters,
|
||||||
|
title='Classrooms')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
flash(f'Error loading classrooms: {str(e)}', 'error')
|
||||||
|
return render_template('classrooms/list.html', classrooms=None, title='Classrooms')
|
||||||
|
|
||||||
|
|
||||||
|
@classrooms_bp.route('/virtual-room/<int:commission_id>/update-link', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def update_virtual_link(commission_id):
|
||||||
|
"""Actualizar enlace de videollamada para una comisión virtual"""
|
||||||
|
from app.models.subject import Commission
|
||||||
|
from flask_babel import gettext as _
|
||||||
|
comm = Commission.query.get_or_404(commission_id)
|
||||||
|
new_link = request.form.get('virtual_link', '').strip() or None
|
||||||
|
comm.virtual_link = new_link
|
||||||
|
db.session.commit()
|
||||||
|
flash(_('Enlace de videollamada para %(code)s actualizado correctamente.', code=comm.get_full_code()), 'success')
|
||||||
|
return redirect(request.referrer or url_for('classrooms.list_classrooms', view_mode='virtual'))
|
||||||
|
|
||||||
|
|
||||||
|
@classrooms_bp.route('/<int:id>')
|
||||||
|
@login_required
|
||||||
|
def view_classroom(id):
|
||||||
|
"""View classroom details"""
|
||||||
|
try:
|
||||||
|
classroom = Classroom.query.get_or_404(id)
|
||||||
|
from app.models.reservation import Reservation, ReservationStatus
|
||||||
|
|
||||||
|
upcoming_reservations = Reservation.query.filter(
|
||||||
|
Reservation.classroom_id == id,
|
||||||
|
Reservation.start_time > datetime.utcnow(),
|
||||||
|
Reservation.status == ReservationStatus.CONFIRMED.value
|
||||||
|
).order_by(Reservation.start_time).limit(10).all()
|
||||||
|
|
||||||
|
return render_template('classrooms/view.html',
|
||||||
|
classroom=classroom,
|
||||||
|
upcoming_reservations=upcoming_reservations,
|
||||||
|
title=f'Classroom {classroom.code_display}')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
flash(f'Error loading classroom: {str(e)}', 'error')
|
||||||
|
return redirect(url_for('classrooms.list_classrooms'))
|
||||||
|
|
||||||
|
|
||||||
|
@classrooms_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def edit_classroom(id):
|
||||||
|
"""Edit classroom with decoupled building and floor"""
|
||||||
|
from app.models.building import Building
|
||||||
|
import re
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
classroom = Classroom.query.get_or_404(id)
|
||||||
|
form = ClassroomForm(obj=classroom)
|
||||||
|
|
||||||
|
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
|
||||||
|
form.building_id.choices = [(str(b.id), b.name) for b in buildings] + [('__new__', '+ Crear nuevo edificio')]
|
||||||
|
building_floors = {str(b.id): list(dict.fromkeys(c.floor for c in b.classrooms if c.floor)) for b in buildings}
|
||||||
|
|
||||||
|
if request.method == 'GET':
|
||||||
|
form.building_id.data = str(classroom.building_id or '')
|
||||||
|
form.room_number.data = classroom.code
|
||||||
|
form.floor.data = classroom.floor
|
||||||
|
if classroom.capacity == 0 or classroom.is_virtual:
|
||||||
|
form.capacity.data = '∞'
|
||||||
|
else:
|
||||||
|
form.capacity.data = str(classroom.capacity)
|
||||||
|
|
||||||
|
if form.validate_on_submit():
|
||||||
|
try:
|
||||||
|
building = None
|
||||||
|
if form.building_id.data == '__new__':
|
||||||
|
new_building_name = form.new_building_name.data.strip() if form.new_building_name.data else ''
|
||||||
|
if not new_building_name:
|
||||||
|
flash('Debe ingresar el nombre del nuevo edificio.', 'error')
|
||||||
|
return render_template('classrooms/edit.html', form=form, classroom=classroom, building_floors=building_floors, title='Edit Classroom')
|
||||||
|
|
||||||
|
building = Building.query.filter(func.lower(Building.name) == func.lower(new_building_name)).first()
|
||||||
|
if not building:
|
||||||
|
code_slug = re.sub(r'[^a-zA-Z0-9]+', '_', new_building_name.upper()).strip('_')
|
||||||
|
building = Building(name=new_building_name, code=code_slug, is_active=True)
|
||||||
|
db.session.add(building)
|
||||||
|
db.session.commit()
|
||||||
|
else:
|
||||||
|
building = Building.query.get_or_404(int(form.building_id.data))
|
||||||
|
|
||||||
|
room_code = form.room_number.data.strip()
|
||||||
|
floor_val = str(form.floor.data).strip()
|
||||||
|
|
||||||
|
# Check duplicate excluding current
|
||||||
|
existing_classroom = Classroom.query.filter(
|
||||||
|
Classroom.building_id == building.id,
|
||||||
|
Classroom.code == room_code,
|
||||||
|
Classroom.floor == floor_val,
|
||||||
|
Classroom.id != id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_classroom:
|
||||||
|
flash(f'Ya existe otra aula con el nombre "{room_code}" en el piso {floor_val} de {building.name}.', 'error')
|
||||||
|
return render_template('classrooms/edit.html', form=form, classroom=classroom, building_floors=building_floors, title='Edit Classroom')
|
||||||
|
|
||||||
|
cap_raw = str(form.capacity.data or '').strip()
|
||||||
|
if cap_raw in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']:
|
||||||
|
cap_val = 0
|
||||||
|
else:
|
||||||
|
cap_val = int(cap_raw)
|
||||||
|
|
||||||
|
classroom.building_id = building.id
|
||||||
|
classroom.building = building.name
|
||||||
|
classroom.code = room_code
|
||||||
|
classroom.floor = floor_val
|
||||||
|
classroom.capacity = cap_val
|
||||||
|
classroom.description = form.description.data
|
||||||
|
classroom.is_active = form.is_active.data
|
||||||
|
classroom.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash(f'Aula {classroom.location_display} actualizada correctamente.', 'success')
|
||||||
|
return redirect(url_for('classrooms.view_classroom', id=classroom.id))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f'Error updating classroom: {str(e)}', 'error')
|
||||||
|
|
||||||
|
return render_template('classrooms/edit.html', form=form, classroom=classroom, building_floors=building_floors, title='Edit Classroom')
|
||||||
|
|
||||||
|
@classrooms_bp.route('/<int:id>/toggle', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def toggle_classroom(id):
|
||||||
|
"""Toggle classroom active status"""
|
||||||
|
classroom = Classroom.query.get_or_404(id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
classroom.is_active = not classroom.is_active
|
||||||
|
classroom.updated_at = datetime.utcnow()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
status = 'activated' if classroom.is_active else 'deactivated'
|
||||||
|
flash(f'Classroom {classroom.code_display} has been {status} successfully!', 'success')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f'Error updating classroom status: {str(e)}', 'error')
|
||||||
|
|
||||||
|
return redirect(request.referrer or url_for('classrooms.list_classrooms'))
|
||||||
|
|
||||||
|
# API endpoints for AJAX requests
|
||||||
|
@classrooms_bp.route('/api/available')
|
||||||
|
@login_required
|
||||||
|
def get_available_classrooms():
|
||||||
|
"""Get available classrooms for a specific time slot"""
|
||||||
|
start_time = request.args.get('start_time')
|
||||||
|
end_time = request.args.get('end_time')
|
||||||
|
|
||||||
|
if not start_time or not end_time:
|
||||||
|
return jsonify({'error': 'Start time and end time are required'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
from app.models.reservation import Reservation, ReservationStatus
|
||||||
|
|
||||||
|
start_dt = datetime.fromisoformat(start_time)
|
||||||
|
end_dt = datetime.fromisoformat(end_time)
|
||||||
|
|
||||||
|
# Find all classrooms
|
||||||
|
all_classrooms = Classroom.query.filter_by(is_active=True).all()
|
||||||
|
available_classrooms = []
|
||||||
|
|
||||||
|
for classroom in all_classrooms:
|
||||||
|
# Check if classroom has conflicting reservations
|
||||||
|
conflicts = Reservation.query.filter(
|
||||||
|
Reservation.classroom_id == classroom.id,
|
||||||
|
Reservation.status == ReservationStatus.CONFIRMED.value,
|
||||||
|
Reservation.start_time < end_dt,
|
||||||
|
Reservation.end_time > start_dt
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if not conflicts:
|
||||||
|
available_classrooms.append({
|
||||||
|
'id': classroom.id,
|
||||||
|
'code': classroom.code,
|
||||||
|
'building': classroom.building,
|
||||||
|
'room_number': classroom.code,
|
||||||
|
'floor': classroom.floor,
|
||||||
|
'capacity': classroom.capacity,
|
||||||
|
'code_display': classroom.code_display
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({'classrooms': available_classrooms})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
@@ -39,8 +39,8 @@ def optimize_reservations():
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return jsonify({'error': 'Invalid end_date format. Use ISO format.'}), 400
|
return jsonify({'error': 'Invalid end_date format. Use ISO format.'}), 400
|
||||||
|
|
||||||
# Check if user has permission (admin or teacher)
|
# Check if user has permission (admin, teacher or RBAC optimizer write)
|
||||||
if current_user.role not in ['ADMIN', 'teacher']:
|
if not (current_user.is_admin() or current_user.has_permission('optimizer', 'read_write') or current_user.role in ['ADMIN', 'teacher']):
|
||||||
return jsonify({'error': 'Insufficient permissions to optimize reservations'}), 403
|
return jsonify({'error': 'Insufficient permissions to optimize reservations'}), 403
|
||||||
|
|
||||||
# Initialize optimizer and run optimization
|
# Initialize optimizer and run optimization
|
||||||
@@ -84,7 +84,7 @@ def apply_optimization():
|
|||||||
return jsonify({'error': 'reservations must be a list'}), 400
|
return jsonify({'error': 'reservations must be a list'}), 400
|
||||||
|
|
||||||
# Check if user has permission
|
# Check if user has permission
|
||||||
if current_user.role not in ['ADMIN', 'teacher']:
|
if not (current_user.is_admin() or current_user.has_permission('optimizer', 'read_write') or current_user.role in ['ADMIN', 'teacher']):
|
||||||
return jsonify({'error': 'Insufficient permissions to apply reservations'}), 403
|
return jsonify({'error': 'Insufficient permissions to apply reservations'}), 403
|
||||||
|
|
||||||
# Initialize optimizer and apply reservations
|
# Initialize optimizer and apply reservations
|
||||||
@@ -249,7 +249,7 @@ genetic_web_bp = Blueprint('genetic_algorithm_web', __name__)
|
|||||||
@login_required
|
@login_required
|
||||||
def genetic_optimizer():
|
def genetic_optimizer():
|
||||||
"""Render the genetic algorithm optimizer interface"""
|
"""Render the genetic algorithm optimizer interface"""
|
||||||
if current_user.role not in ['ADMIN']:
|
if not (current_user.has_permission('optimizer', 'read') or (current_user.role and current_user.role.upper() == 'ADMIN')):
|
||||||
return render_template('auth/403.html'), 403
|
return render_template('auth/403.html'), 403
|
||||||
|
|
||||||
return render_template('genetic_optimizer.html')
|
return render_template('genetic_optimizer.html')
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
from flask import Blueprint, render_template, jsonify, redirect, url_for, session, request, flash
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from app import db
|
||||||
|
from app.models.reservation import Reservation
|
||||||
|
from app.models.classroom import Classroom
|
||||||
|
from app.models.subject import Subject
|
||||||
|
from app.models.subject import Commission
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from sqlalchemy import func
|
||||||
|
from flask_babel import gettext as _
|
||||||
|
|
||||||
|
main_bp = Blueprint('main', __name__)
|
||||||
|
|
||||||
|
@main_bp.route('/')
|
||||||
|
@login_required
|
||||||
|
def dashboard():
|
||||||
|
"""Role-personalized institutional dashboard page"""
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.milestone import AcademicMilestone
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
|
||||||
|
actual_role = current_user.get_institutional_role()
|
||||||
|
view_as = request.args.get('view_as', '').lower().strip()
|
||||||
|
|
||||||
|
# Allow admins and management roles to preview dashboard perspectives
|
||||||
|
if view_as in ['admin', 'bedelia', 'docente', 'alumno'] and (current_user.is_admin() or current_user.can_manage()):
|
||||||
|
role = view_as
|
||||||
|
else:
|
||||||
|
role = actual_role
|
||||||
|
|
||||||
|
today = datetime.utcnow().date()
|
||||||
|
|
||||||
|
# Common core statistics
|
||||||
|
try:
|
||||||
|
total_classrooms = Classroom.query.filter_by(is_active=True).count()
|
||||||
|
except Exception:
|
||||||
|
total_classrooms = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
total_subjects = Subject.query.filter_by(is_active=True).count()
|
||||||
|
except Exception:
|
||||||
|
total_subjects = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
today_reservations = Reservation.query.filter(
|
||||||
|
func.date(Reservation.start_time) == today
|
||||||
|
).count()
|
||||||
|
except Exception:
|
||||||
|
today_reservations = 0
|
||||||
|
|
||||||
|
week_start = today - timedelta(days=today.weekday())
|
||||||
|
week_end = week_start + timedelta(days=6)
|
||||||
|
try:
|
||||||
|
week_reservations = Reservation.query.filter(
|
||||||
|
func.date(Reservation.start_time) >= week_start,
|
||||||
|
func.date(Reservation.start_time) <= week_end
|
||||||
|
).count()
|
||||||
|
except Exception:
|
||||||
|
week_reservations = 0
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
'total_classrooms': total_classrooms,
|
||||||
|
'total_subjects': total_subjects,
|
||||||
|
'today_reservations': today_reservations,
|
||||||
|
'week_reservations': week_reservations
|
||||||
|
}
|
||||||
|
|
||||||
|
role_data = {}
|
||||||
|
|
||||||
|
if role == 'admin':
|
||||||
|
# 1. Admin Institutional Cockpit
|
||||||
|
try:
|
||||||
|
total_users = User.query.filter_by(is_active=True).count()
|
||||||
|
except Exception:
|
||||||
|
total_users = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
pending_count = Reservation.query.filter(
|
||||||
|
Reservation.status == 'PENDING'
|
||||||
|
).count()
|
||||||
|
except Exception:
|
||||||
|
pending_count = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
recent_audits = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(6).all()
|
||||||
|
except Exception:
|
||||||
|
recent_audits = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
upcoming_reservations = Reservation.query.filter(
|
||||||
|
Reservation.start_time >= datetime.utcnow(),
|
||||||
|
Reservation.start_time <= datetime.utcnow() + timedelta(days=7)
|
||||||
|
).order_by(Reservation.start_time.asc()).limit(8).all()
|
||||||
|
except Exception:
|
||||||
|
upcoming_reservations = []
|
||||||
|
|
||||||
|
stats.update({
|
||||||
|
'total_users': total_users,
|
||||||
|
'pending_reservations': pending_count
|
||||||
|
})
|
||||||
|
role_data = {
|
||||||
|
'recent_audits': recent_audits,
|
||||||
|
'upcoming_reservations': upcoming_reservations
|
||||||
|
}
|
||||||
|
|
||||||
|
elif role == 'bedelia':
|
||||||
|
# 2. Bedelía Operations & Facilities Control
|
||||||
|
try:
|
||||||
|
physical_classrooms = Classroom.query.filter_by(is_active=True).filter(
|
||||||
|
~Classroom.room_number.ilike('%VIRTUAL%') & ~Classroom.building.ilike('%VIRTUAL%')
|
||||||
|
).count()
|
||||||
|
virtual_classrooms = total_classrooms - physical_classrooms
|
||||||
|
if virtual_classrooms < 0:
|
||||||
|
virtual_classrooms = 0
|
||||||
|
except Exception:
|
||||||
|
physical_classrooms = total_classrooms
|
||||||
|
virtual_classrooms = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
pending_reservations = Reservation.query.filter(
|
||||||
|
Reservation.status == 'PENDING'
|
||||||
|
).order_by(Reservation.start_time.asc()).limit(10).all()
|
||||||
|
pending_count = len(pending_reservations)
|
||||||
|
except Exception:
|
||||||
|
pending_reservations = []
|
||||||
|
pending_count = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
today_schedule_list = Reservation.query.filter(
|
||||||
|
func.date(Reservation.start_time) == today
|
||||||
|
).order_by(Reservation.start_time.asc()).limit(12).all()
|
||||||
|
except Exception:
|
||||||
|
today_schedule_list = []
|
||||||
|
|
||||||
|
occupancy_rate = min(100, int((today_reservations / max(1, physical_classrooms * 3)) * 100)) if physical_classrooms > 0 else 0
|
||||||
|
|
||||||
|
stats.update({
|
||||||
|
'physical_classrooms': physical_classrooms,
|
||||||
|
'virtual_classrooms': virtual_classrooms,
|
||||||
|
'pending_count': pending_count,
|
||||||
|
'occupancy_rate': occupancy_rate
|
||||||
|
})
|
||||||
|
role_data = {
|
||||||
|
'pending_reservations': pending_reservations,
|
||||||
|
'today_schedule_list': today_schedule_list
|
||||||
|
}
|
||||||
|
|
||||||
|
elif role == 'docente':
|
||||||
|
# 3. Docente (Profesor) Academic & Classroom Workflow
|
||||||
|
try:
|
||||||
|
my_today_classes = Reservation.query.filter(
|
||||||
|
Reservation.user_id == current_user.id,
|
||||||
|
func.date(Reservation.start_time) == today
|
||||||
|
).order_by(Reservation.start_time.asc()).all()
|
||||||
|
except Exception:
|
||||||
|
my_today_classes = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
my_upcoming_reservations = Reservation.query.filter(
|
||||||
|
Reservation.user_id == current_user.id,
|
||||||
|
Reservation.start_time >= datetime.utcnow()
|
||||||
|
).order_by(Reservation.start_time.asc()).limit(8).all()
|
||||||
|
except Exception:
|
||||||
|
my_upcoming_reservations = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
my_total_reservations = Reservation.query.filter(
|
||||||
|
Reservation.user_id == current_user.id
|
||||||
|
).count()
|
||||||
|
except Exception:
|
||||||
|
my_total_reservations = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
virtual_classrooms = Classroom.query.filter_by(is_active=True).filter(
|
||||||
|
(Classroom.room_number.ilike('%VIRTUAL%')) | (Classroom.building.ilike('%VIRTUAL%'))
|
||||||
|
).limit(4).all()
|
||||||
|
except Exception:
|
||||||
|
virtual_classrooms = []
|
||||||
|
|
||||||
|
stats.update({
|
||||||
|
'my_today_count': len(my_today_classes),
|
||||||
|
'my_total_reservations': my_total_reservations,
|
||||||
|
'virtual_rooms_count': len(virtual_classrooms)
|
||||||
|
})
|
||||||
|
role_data = {
|
||||||
|
'my_today_classes': my_today_classes,
|
||||||
|
'my_upcoming_reservations': my_upcoming_reservations,
|
||||||
|
'virtual_classrooms': virtual_classrooms
|
||||||
|
}
|
||||||
|
|
||||||
|
elif role == 'alumno':
|
||||||
|
# 4. Alumno (Estudiante) Daily Guide & Timetable
|
||||||
|
try:
|
||||||
|
today_classes = Reservation.query.filter(
|
||||||
|
func.date(Reservation.start_time) == today,
|
||||||
|
Reservation.status == 'CONFIRMED'
|
||||||
|
).order_by(Reservation.start_time.asc()).limit(12).all()
|
||||||
|
except Exception:
|
||||||
|
today_classes = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
upcoming_milestones = AcademicMilestone.query.filter(
|
||||||
|
AcademicMilestone.date >= datetime.utcnow(),
|
||||||
|
AcademicMilestone.is_active == True
|
||||||
|
).order_by(AcademicMilestone.date.asc()).limit(6).all()
|
||||||
|
except Exception:
|
||||||
|
upcoming_milestones = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
virtual_rooms = Classroom.query.filter_by(is_active=True).filter(
|
||||||
|
(Classroom.room_number.ilike('%VIRTUAL%')) | (Classroom.building.ilike('%VIRTUAL%'))
|
||||||
|
).limit(4).all()
|
||||||
|
except Exception:
|
||||||
|
virtual_rooms = []
|
||||||
|
|
||||||
|
stats.update({
|
||||||
|
'today_classes_count': len(today_classes),
|
||||||
|
'milestones_count': len(upcoming_milestones)
|
||||||
|
})
|
||||||
|
role_data = {
|
||||||
|
'today_classes': today_classes,
|
||||||
|
'upcoming_milestones': upcoming_milestones,
|
||||||
|
'virtual_rooms': virtual_rooms
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backward compatibility for upcoming_reservations
|
||||||
|
upcoming_reservations = role_data.get('upcoming_reservations', [])
|
||||||
|
if not upcoming_reservations:
|
||||||
|
try:
|
||||||
|
upcoming_reservations = Reservation.query.filter(
|
||||||
|
Reservation.start_time >= datetime.utcnow(),
|
||||||
|
Reservation.start_time <= datetime.utcnow() + timedelta(days=7)
|
||||||
|
).order_by(Reservation.start_time.asc()).limit(6).all()
|
||||||
|
except Exception:
|
||||||
|
upcoming_reservations = []
|
||||||
|
|
||||||
|
return render_template('dashboard.html',
|
||||||
|
role=role,
|
||||||
|
actual_role=actual_role,
|
||||||
|
stats=stats,
|
||||||
|
role_data=role_data,
|
||||||
|
upcoming_reservations=upcoming_reservations)
|
||||||
|
|
||||||
|
@main_bp.route('/api/dashboard-stats')
|
||||||
|
@login_required
|
||||||
|
def dashboard_stats_api():
|
||||||
|
"""API endpoint for dashboard statistics"""
|
||||||
|
# Get today's data
|
||||||
|
today = datetime.utcnow().date()
|
||||||
|
today_reservations = Reservation.query.filter(
|
||||||
|
func.date(Reservation.start_time) == today
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# Get this week's data
|
||||||
|
week_start = today - timedelta(days=today.weekday())
|
||||||
|
week_end = week_start + timedelta(days=6)
|
||||||
|
week_reservations = Reservation.query.filter(
|
||||||
|
func.date(Reservation.start_time) >= week_start,
|
||||||
|
func.date(Reservation.start_time) <= week_end
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# Get monthly data for chart
|
||||||
|
current_date = datetime.utcnow()
|
||||||
|
monthly_data = []
|
||||||
|
|
||||||
|
for i in range(12):
|
||||||
|
month_date = current_date - timedelta(days=30 * i)
|
||||||
|
month_start = month_date.replace(day=1)
|
||||||
|
month_end = (month_start + timedelta(days=32)).replace(day=1) - timedelta(days=1)
|
||||||
|
|
||||||
|
monthly_reservations = Reservation.query.filter(
|
||||||
|
func.date(Reservation.start_time) >= month_start.date(),
|
||||||
|
func.date(Reservation.start_time) <= month_end.date()
|
||||||
|
).count()
|
||||||
|
|
||||||
|
monthly_data.append({
|
||||||
|
'month': month_start.strftime('%Y-%m'),
|
||||||
|
'reservations': monthly_reservations
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'today_reservations': today_reservations,
|
||||||
|
'week_reservations': week_reservations,
|
||||||
|
'monthly_data': list(reversed(monthly_data))
|
||||||
|
})
|
||||||
|
|
||||||
|
@main_bp.route('/genetic-optimizer')
|
||||||
|
@login_required
|
||||||
|
def genetic_optimizer():
|
||||||
|
"""Serve the genetic algorithm optimization interface"""
|
||||||
|
if not (current_user.has_permission('optimizer', 'read') or (current_user.role and current_user.role.upper() == 'ADMIN')):
|
||||||
|
flash(_('No tienes permisos para acceder a la herramienta de optimización.'), 'danger')
|
||||||
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
|
return render_template('genetic_optimizer.html')
|
||||||
|
|
||||||
|
@main_bp.route('/set_language/<language>')
|
||||||
|
def set_language(language=None):
|
||||||
|
if language not in ['en', 'es']:
|
||||||
|
language = 'es'
|
||||||
|
|
||||||
|
session['language'] = language
|
||||||
|
|
||||||
|
# Si se selecciona el idioma desde la pantalla de login, registrar la selección explícita
|
||||||
|
if request.referrer and 'login' in request.referrer:
|
||||||
|
session['login_lang_explicit'] = language
|
||||||
|
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
current_user.preferred_language = language
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return redirect(request.referrer or url_for('main.dashboard'))
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route('/api/milestone-types')
|
||||||
|
def api_milestone_types():
|
||||||
|
"""Retorna las tipificaciones de hitos activas en JSON para consumo de frontend/mobile"""
|
||||||
|
from app.models.milestone import MilestoneType, BASE_MILESTONE_TYPES
|
||||||
|
if MilestoneType.query.count() == 0:
|
||||||
|
for item in BASE_MILESTONE_TYPES:
|
||||||
|
mt = MilestoneType(
|
||||||
|
name=item['name'],
|
||||||
|
code=item['code'],
|
||||||
|
color=item['color'],
|
||||||
|
description=item['description'],
|
||||||
|
sort_order=item['sort_order'],
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
db.session.add(mt)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
types = MilestoneType.query.filter_by(is_active=True).order_by(MilestoneType.sort_order.asc(), MilestoneType.id.asc()).all()
|
||||||
|
return jsonify([t.to_dict() for t in types])␍
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
from flask import Blueprint, render_template, abort
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from app import db
|
||||||
|
from app.models.subject import Commission
|
||||||
|
from app.models.enrollment import StudentEnrollment
|
||||||
|
from app.models.classroom import Classroom
|
||||||
|
|
||||||
|
my_subjects_bp = Blueprint('my_subjects', __name__, url_prefix='/mis-materias')
|
||||||
|
|
||||||
|
|
||||||
|
@my_subjects_bp.route('/')
|
||||||
|
@login_required
|
||||||
|
def my_subjects_list():
|
||||||
|
"""Show the commissions assigned to the current user (teacher or student)."""
|
||||||
|
role = current_user.get_institutional_role()
|
||||||
|
|
||||||
|
commissions = []
|
||||||
|
|
||||||
|
if role in ['admin', 'bedelia']:
|
||||||
|
# Admin/Bedelia: see all active commissions
|
||||||
|
commissions = (
|
||||||
|
Commission.query
|
||||||
|
.filter_by(active=True)
|
||||||
|
.order_by(Commission.year.desc(), Commission.semester.asc(), Commission.code.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
view_mode = 'all'
|
||||||
|
|
||||||
|
elif role == 'docente':
|
||||||
|
# Teacher: only commissions where they are assigned as teacher
|
||||||
|
commissions = (
|
||||||
|
Commission.query
|
||||||
|
.filter_by(teacher_id=current_user.id, active=True)
|
||||||
|
.order_by(Commission.year.desc(), Commission.semester.asc(), Commission.code.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
view_mode = 'teacher'
|
||||||
|
|
||||||
|
elif role == 'alumno':
|
||||||
|
# Student: only commissions where they are enrolled
|
||||||
|
enrollments = (
|
||||||
|
StudentEnrollment.query
|
||||||
|
.filter_by(student_id=current_user.id, status='activo')
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
commission_ids = [e.commission_id for e in enrollments]
|
||||||
|
commissions = (
|
||||||
|
Commission.query
|
||||||
|
.filter(Commission.id.in_(commission_ids), Commission.active == True)
|
||||||
|
.order_by(Commission.year.desc(), Commission.semester.asc(), Commission.code.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
view_mode = 'student'
|
||||||
|
else:
|
||||||
|
commissions = []
|
||||||
|
view_mode = 'none'
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'my_subjects/list.html',
|
||||||
|
commissions=commissions,
|
||||||
|
view_mode=view_mode,
|
||||||
|
role=role
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@my_subjects_bp.route('/<int:commission_id>')
|
||||||
|
@login_required
|
||||||
|
def my_subject_detail(commission_id):
|
||||||
|
"""Detail view of a single commission for the current user."""
|
||||||
|
role = current_user.get_institutional_role()
|
||||||
|
commission = Commission.query.get_or_404(commission_id)
|
||||||
|
|
||||||
|
# Access control: make sure the user is allowed to see this commission
|
||||||
|
if role in ['admin', 'bedelia']:
|
||||||
|
pass # full access
|
||||||
|
elif role == 'docente':
|
||||||
|
if commission.teacher_id != current_user.id:
|
||||||
|
abort(403)
|
||||||
|
elif role == 'alumno':
|
||||||
|
enrollment = StudentEnrollment.query.filter_by(
|
||||||
|
student_id=current_user.id,
|
||||||
|
commission_id=commission_id
|
||||||
|
).first()
|
||||||
|
if not enrollment:
|
||||||
|
abort(403)
|
||||||
|
else:
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
# Get enrolled students (visible to teachers and admin/bedelia)
|
||||||
|
enrollments = []
|
||||||
|
if role in ['admin', 'bedelia', 'docente']:
|
||||||
|
enrollments = (
|
||||||
|
StudentEnrollment.query
|
||||||
|
.filter_by(commission_id=commission_id)
|
||||||
|
.order_by(StudentEnrollment.enrolled_at.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to get classroom info from reservations linked to this commission
|
||||||
|
from app.models.reservation import Reservation
|
||||||
|
from sqlalchemy import func
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Recent or upcoming reservation linked to this commission
|
||||||
|
recent_reservation = (
|
||||||
|
Reservation.query
|
||||||
|
.filter(
|
||||||
|
Reservation.commission_id == commission_id,
|
||||||
|
Reservation.start_time >= datetime.utcnow()
|
||||||
|
)
|
||||||
|
.order_by(Reservation.start_time.asc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
classroom = None
|
||||||
|
if recent_reservation and recent_reservation.classroom_id:
|
||||||
|
classroom = Classroom.query.get(recent_reservation.classroom_id)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'my_subjects/detail.html',
|
||||||
|
commission=commission,
|
||||||
|
enrollments=enrollments,
|
||||||
|
classroom=classroom,
|
||||||
|
recent_reservation=recent_reservation,
|
||||||
|
role=role
|
||||||
|
)
|
||||||
@@ -0,0 +1,975 @@
|
|||||||
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from app import db
|
||||||
|
from app.models.reservation import Reservation, ReservationStatus
|
||||||
|
from app.models.classroom import Classroom
|
||||||
|
from app.models.subject import Subject, Commission
|
||||||
|
from app.forms.reservation import ReservationForm
|
||||||
|
from datetime import datetime, timedelta, date
|
||||||
|
from sqlalchemy import func, or_
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
|
schedule_bp = Blueprint('schedule', __name__, url_prefix='/schedule')
|
||||||
|
|
||||||
|
@schedule_bp.route('/today')
|
||||||
|
@login_required
|
||||||
|
def today_schedule():
|
||||||
|
"""Show today's schedule with filters for virtual classrooms, floor, and shift"""
|
||||||
|
today = datetime.utcnow().date()
|
||||||
|
today_start = datetime.combine(today, datetime.min.time())
|
||||||
|
today_end = datetime.combine(today, datetime.max.time())
|
||||||
|
|
||||||
|
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
|
||||||
|
floor_filter = request.args.get('floor', '').strip()
|
||||||
|
shift_filter = request.args.get('shift', '').strip()
|
||||||
|
|
||||||
|
# Base query for today's reservations
|
||||||
|
query = Reservation.query.options(
|
||||||
|
joinedload(Reservation.classroom),
|
||||||
|
joinedload(Reservation.commission).joinedload(Commission.subject),
|
||||||
|
joinedload(Reservation.user)
|
||||||
|
).join(Reservation.classroom).filter(
|
||||||
|
Reservation.start_time >= today_start,
|
||||||
|
Reservation.start_time <= today_end,
|
||||||
|
Reservation.status.in_([ReservationStatus.CONFIRMED.value, ReservationStatus.PENDING.value])
|
||||||
|
)
|
||||||
|
|
||||||
|
if hide_virtual:
|
||||||
|
query = query.filter(
|
||||||
|
~Classroom.code.ilike('%VIRTUAL%'),
|
||||||
|
~Classroom.building.ilike('%Virtual%')
|
||||||
|
)
|
||||||
|
|
||||||
|
if floor_filter:
|
||||||
|
flr = str(floor_filter).strip()
|
||||||
|
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
|
||||||
|
query = query.filter(or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
|
||||||
|
else:
|
||||||
|
query = query.filter(Classroom.floor == flr)
|
||||||
|
|
||||||
|
if shift_filter:
|
||||||
|
query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%'))
|
||||||
|
|
||||||
|
reservations = query.order_by(Reservation.start_time).all()
|
||||||
|
|
||||||
|
# Create time blocks for timeline view
|
||||||
|
time_blocks = {}
|
||||||
|
for reservation in reservations:
|
||||||
|
start_hour = reservation.start_time.hour
|
||||||
|
end_hour = reservation.end_time.hour
|
||||||
|
|
||||||
|
for hour in range(max(7, start_hour), min(22, end_hour + 1)):
|
||||||
|
if hour not in time_blocks:
|
||||||
|
time_blocks[hour] = []
|
||||||
|
if reservation not in time_blocks[hour]:
|
||||||
|
time_blocks[hour].append({
|
||||||
|
'reservation': reservation,
|
||||||
|
'classroom': reservation.classroom,
|
||||||
|
'commission': reservation.commission,
|
||||||
|
'user': reservation.user
|
||||||
|
})
|
||||||
|
|
||||||
|
# Calculate stats
|
||||||
|
total_reservations = len(reservations)
|
||||||
|
confirmed = len([r for r in reservations if r.status == ReservationStatus.CONFIRMED.value or str(r.status) == 'CONFIRMED'])
|
||||||
|
pending = len([r for r in reservations if r.status == ReservationStatus.PENDING or str(r.status) == 'PENDING'])
|
||||||
|
|
||||||
|
total_classrooms = Classroom.query.count()
|
||||||
|
occupied_classrooms = len(set(r.classroom_id for r in reservations if r.classroom_id))
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
'total_reservations': total_reservations,
|
||||||
|
'confirmed': confirmed,
|
||||||
|
'pending': pending,
|
||||||
|
'occupied_classrooms': occupied_classrooms,
|
||||||
|
'total_classrooms': total_classrooms
|
||||||
|
}
|
||||||
|
|
||||||
|
floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()
|
||||||
|
available_floors = [f[0] for f in floors]
|
||||||
|
|
||||||
|
return render_template('schedule/today.html',
|
||||||
|
reservations=reservations,
|
||||||
|
time_blocks=time_blocks,
|
||||||
|
stats=stats,
|
||||||
|
today=today,
|
||||||
|
hide_virtual=hide_virtual,
|
||||||
|
floor_filter=floor_filter,
|
||||||
|
shift_filter=shift_filter,
|
||||||
|
available_floors=available_floors)
|
||||||
|
|
||||||
|
@schedule_bp.route('/cancel/<int:id>', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def cancel_reservation(id):
|
||||||
|
"""Cancel a reservation"""
|
||||||
|
try:
|
||||||
|
reservation = Reservation.query.get_or_404(id)
|
||||||
|
|
||||||
|
if not reservation.can_be_cancelled():
|
||||||
|
flash('Reservations can only be cancelled at least 24 hours before start time.', 'error')
|
||||||
|
return redirect(request.referrer)
|
||||||
|
|
||||||
|
reservation.status = ReservationStatus.CANCELLED
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash('Reservation cancelled successfully!', 'success')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f'Error cancelling reservation: {str(e)}', 'error')
|
||||||
|
|
||||||
|
return redirect(request.referrer)
|
||||||
|
|
||||||
|
@schedule_bp.route('/reservation/<int:id>/view')
|
||||||
|
@login_required
|
||||||
|
def view_reservation(id):
|
||||||
|
"""View reservation details"""
|
||||||
|
try:
|
||||||
|
reservation = Reservation.query.options(
|
||||||
|
joinedload(Reservation.classroom),
|
||||||
|
joinedload(Reservation.commission),
|
||||||
|
joinedload(Reservation.user)
|
||||||
|
).get_or_404(id)
|
||||||
|
|
||||||
|
return render_template('schedule/view.html',
|
||||||
|
reservation=reservation,
|
||||||
|
title='Reservation Details')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
flash(f'Error loading reservation details: {str(e)}', 'error')
|
||||||
|
return redirect(url_for('schedule.today_schedule'))
|
||||||
|
|
||||||
|
@schedule_bp.route('/reservation/<int:id>/edit', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def edit_reservation(id):
|
||||||
|
"""Edit existing reservation"""
|
||||||
|
from app.models.classroom import Classroom
|
||||||
|
from app.models.subject import Commission
|
||||||
|
from app.models.building import Building
|
||||||
|
from flask_babel import gettext as _
|
||||||
|
|
||||||
|
reservation = Reservation.query.options(
|
||||||
|
joinedload(Reservation.classroom),
|
||||||
|
joinedload(Reservation.commission),
|
||||||
|
joinedload(Reservation.user)
|
||||||
|
).get_or_404(id)
|
||||||
|
|
||||||
|
if not current_user.can_edit_reservation(reservation):
|
||||||
|
flash(_('No tienes los permisos necesarios para modificar esta reserva.'), 'error')
|
||||||
|
return redirect(url_for('schedule.view_reservation', id=reservation.id))
|
||||||
|
|
||||||
|
form = ReservationForm(is_edit=True)
|
||||||
|
|
||||||
|
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
|
||||||
|
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all()
|
||||||
|
commissions = Commission.query.filter_by(active=True).all()
|
||||||
|
|
||||||
|
form.classroom_id.choices = [(c.id, f'{c.building_name} - {c.code} (Cap: {c.capacity_display})') for c in classrooms]
|
||||||
|
form.commission_id.choices = [(c.id, f'{c.get_full_code()} - {c.subject.name if c.subject else "No Subject"} (Students: {c.max_students})') for c in commissions]
|
||||||
|
|
||||||
|
if request.method == 'GET':
|
||||||
|
form.classroom_id.data = reservation.classroom_id
|
||||||
|
form.commission_id.data = reservation.commission_id
|
||||||
|
form.start_time.data = reservation.start_time
|
||||||
|
form.end_time.data = reservation.end_time
|
||||||
|
form.purpose.data = reservation.purpose
|
||||||
|
form.expected_attendees.data = reservation.expected_attendees
|
||||||
|
form.notes.data = reservation.notes or ''
|
||||||
|
form.virtual_link.data = reservation.virtual_link or ''
|
||||||
|
|
||||||
|
elif request.method == 'POST':
|
||||||
|
if form.validate_on_submit():
|
||||||
|
try:
|
||||||
|
# Verificar colisión de horario si no es virtual
|
||||||
|
target_room = Classroom.query.get(form.classroom_id.data)
|
||||||
|
if target_room and not target_room.is_virtual:
|
||||||
|
conflict = Reservation.query.filter(
|
||||||
|
Reservation.id != reservation.id,
|
||||||
|
Reservation.classroom_id == form.classroom_id.data,
|
||||||
|
Reservation.status == ReservationStatus.CONFIRMED.value,
|
||||||
|
Reservation.start_time < form.end_time.data,
|
||||||
|
Reservation.end_time > form.start_time.data
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if conflict:
|
||||||
|
flash(_('El aula seleccionada ya cuenta con una reserva confirmada en esa franja horaria.'), 'danger')
|
||||||
|
return render_template('schedule/edit.html', form=form, reservation=reservation, classroom_list=classrooms, commission_list=commissions, buildings=buildings)
|
||||||
|
|
||||||
|
# Asignar cambios
|
||||||
|
reservation.classroom_id = form.classroom_id.data
|
||||||
|
reservation.commission_id = form.commission_id.data
|
||||||
|
reservation.start_time = form.start_time.data
|
||||||
|
reservation.end_time = form.end_time.data
|
||||||
|
reservation.purpose = form.purpose.data
|
||||||
|
reservation.expected_attendees = form.expected_attendees.data
|
||||||
|
reservation.notes = form.notes.data
|
||||||
|
reservation.virtual_link = form.virtual_link.data.strip() if form.virtual_link.data else None
|
||||||
|
|
||||||
|
# Turno
|
||||||
|
h = reservation.start_time.hour
|
||||||
|
if h < 13:
|
||||||
|
reservation.shift = 'Mañana'
|
||||||
|
elif h < 18:
|
||||||
|
reservation.shift = 'Tarde'
|
||||||
|
elif h < 21:
|
||||||
|
reservation.shift = 'Vespertino'
|
||||||
|
else:
|
||||||
|
reservation.shift = 'Noche'
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
flash(_('Reserva #%(id)d modificada exitosamente.', id=reservation.id), 'success')
|
||||||
|
return redirect(url_for('schedule.view_reservation', id=reservation.id))
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f'Error al modificar reserva: {str(e)}', 'danger')
|
||||||
|
|
||||||
|
return render_template('schedule/edit.html', form=form, reservation=reservation, classroom_list=classrooms, commission_list=commissions, buildings=buildings)
|
||||||
|
|
||||||
|
@schedule_bp.route('/calendar')
|
||||||
|
@login_required
|
||||||
|
def calendar_view():
|
||||||
|
"""Show calendar view with classroom, subject, career, shift, floor and virtual filtering"""
|
||||||
|
from app.models.career import Career
|
||||||
|
from app.models.building import Building
|
||||||
|
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building.asc(), Classroom.code.asc()).all()
|
||||||
|
subjects = Subject.query.filter_by(is_active=True).order_by(Subject.name.asc()).all()
|
||||||
|
careers = Career.query.order_by(Career.name.asc()).all()
|
||||||
|
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
|
||||||
|
|
||||||
|
floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()
|
||||||
|
available_floors = [f[0] for f in floors]
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'schedule/calendar.html',
|
||||||
|
title='Calendar View',
|
||||||
|
today_date=date.today().isoformat(),
|
||||||
|
classrooms=classrooms,
|
||||||
|
subjects=subjects,
|
||||||
|
careers=careers,
|
||||||
|
buildings=buildings,
|
||||||
|
available_floors=available_floors
|
||||||
|
)
|
||||||
|
|
||||||
|
def user_can_manage_commission_milestones(user, commission=None):
|
||||||
|
"""Verifica si el usuario (Docente, Admin, Bedelía) puede gestionar hitos y links virtuales"""
|
||||||
|
if user.is_admin():
|
||||||
|
return True
|
||||||
|
role_name = (user.role_obj.name if user.role_obj else user.role or '').lower()
|
||||||
|
if role_name in ['admin', 'bedelia', 'bedelía', 'operador']:
|
||||||
|
return True
|
||||||
|
if role_name in ['docente', 'profesor']:
|
||||||
|
return True
|
||||||
|
return user.has_permission('schedule', 'read_write')
|
||||||
|
|
||||||
|
|
||||||
|
@schedule_bp.route('/commissions')
|
||||||
|
@login_required
|
||||||
|
def commissions_view():
|
||||||
|
"""Vista y ABM de Comisiones con soporte de enlace virtual e hitos académicos"""
|
||||||
|
from app.models.subject import Commission, Subject
|
||||||
|
from app.models.classroom import Classroom
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.milestone import MilestoneType
|
||||||
|
|
||||||
|
subjects = Subject.query.order_by(Subject.code.asc()).all()
|
||||||
|
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.code.asc()).all()
|
||||||
|
teachers = User.query.filter_by(is_active=True).order_by(User.name.asc()).all()
|
||||||
|
milestone_types = MilestoneType.query.filter_by(is_active=True).order_by(MilestoneType.sort_order.asc()).all()
|
||||||
|
|
||||||
|
# Totales para indicadores superiores
|
||||||
|
total_capacity = sum(c.max_students for s in subjects for c in s.commissions)
|
||||||
|
total_commissions = sum(len(s.commissions) for s in subjects)
|
||||||
|
|
||||||
|
can_manage_milestones = user_can_manage_commission_milestones(current_user)
|
||||||
|
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
|
||||||
|
can_edit_commissions = current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')
|
||||||
|
|
||||||
|
return render_template('schedule/commissions.html',
|
||||||
|
subjects=subjects,
|
||||||
|
classrooms=classrooms,
|
||||||
|
teachers=teachers,
|
||||||
|
milestone_types=milestone_types,
|
||||||
|
total_capacity=total_capacity,
|
||||||
|
total_commissions=total_commissions,
|
||||||
|
can_manage_milestones=can_manage_milestones,
|
||||||
|
can_edit_commissions=can_edit_commissions)
|
||||||
|
|
||||||
|
|
||||||
|
@schedule_bp.route('/commissions/add', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def commission_add():
|
||||||
|
"""Crear una nueva comisión de cursada"""
|
||||||
|
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
|
||||||
|
if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')):
|
||||||
|
flash(_('No tienes permisos para crear comisiones.'), 'error')
|
||||||
|
return redirect(url_for('schedule.commissions_view'))
|
||||||
|
|
||||||
|
from app.models.subject import Commission
|
||||||
|
subject_id = request.form.get('subject_id', type=int)
|
||||||
|
code = request.form.get('code', '').strip()
|
||||||
|
semester = request.form.get('semester', '1C').strip()
|
||||||
|
year = request.form.get('year', datetime.now().year, type=int)
|
||||||
|
teacher_id = request.form.get('teacher_id', type=int) or None
|
||||||
|
max_students = request.form.get('max_students', 40, type=int)
|
||||||
|
schedule = request.form.get('schedule', '').strip()
|
||||||
|
shift = request.form.get('shift', 'Tarde').strip()
|
||||||
|
virtual_link = request.form.get('virtual_link', '').strip()
|
||||||
|
|
||||||
|
if not subject_id or not code:
|
||||||
|
flash(_('La asignatura y el código de comisión son obligatorios.'), 'error')
|
||||||
|
return redirect(url_for('schedule.commissions_view'))
|
||||||
|
|
||||||
|
# Verificar existencia duplicada
|
||||||
|
existing = Commission.query.filter_by(subject_id=subject_id, code=code, semester=semester, year=year).first()
|
||||||
|
if existing:
|
||||||
|
flash(_('Ya existe una comisión %(code)s para el ciclo %(sem)s %(yr)d.', code=code, sem=semester, yr=year), 'error')
|
||||||
|
return redirect(url_for('schedule.commissions_view'))
|
||||||
|
|
||||||
|
new_comm = Commission(
|
||||||
|
subject_id=subject_id,
|
||||||
|
code=code,
|
||||||
|
semester=semester,
|
||||||
|
year=year,
|
||||||
|
teacher_id=teacher_id,
|
||||||
|
max_students=max_students,
|
||||||
|
schedule=schedule,
|
||||||
|
shift=shift,
|
||||||
|
virtual_link=virtual_link or None,
|
||||||
|
active=True
|
||||||
|
)
|
||||||
|
db.session.add(new_comm)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash(_('Comisión %(code)s creada exitosamente.', code=new_comm.get_full_code()), 'success')
|
||||||
|
return redirect(url_for('schedule.commissions_view'))
|
||||||
|
|
||||||
|
|
||||||
|
@schedule_bp.route('/commissions/<int:id>/edit', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def commission_edit(id):
|
||||||
|
"""Editar datos de una comisión"""
|
||||||
|
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
|
||||||
|
if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')):
|
||||||
|
flash(_('No tienes permisos para modificar comisiones.'), 'error')
|
||||||
|
return redirect(url_for('schedule.commissions_view'))
|
||||||
|
|
||||||
|
from app.models.subject import Commission
|
||||||
|
commission = Commission.query.get_or_404(id)
|
||||||
|
|
||||||
|
commission.code = request.form.get('code', commission.code).strip()
|
||||||
|
commission.semester = request.form.get('semester', commission.semester).strip()
|
||||||
|
commission.year = request.form.get('year', commission.year, type=int)
|
||||||
|
commission.teacher_id = request.form.get('teacher_id', type=int) or None
|
||||||
|
commission.max_students = request.form.get('max_students', commission.max_students, type=int)
|
||||||
|
commission.schedule = request.form.get('schedule', commission.schedule).strip()
|
||||||
|
commission.shift = request.form.get('shift', commission.shift).strip()
|
||||||
|
commission.virtual_link = request.form.get('virtual_link', '').strip() or None
|
||||||
|
commission.active = True if request.form.get('active') in ['1', 'true', 'on'] else False
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
flash(_('Comisión %(code)s actualizada con éxito.', code=commission.get_full_code()), 'success')
|
||||||
|
return redirect(url_for('schedule.commissions_view'))
|
||||||
|
|
||||||
|
|
||||||
|
@schedule_bp.route('/commissions/<int:id>/delete', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def commission_delete(id):
|
||||||
|
"""Eliminar una comisión"""
|
||||||
|
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
|
||||||
|
if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')):
|
||||||
|
flash(_('No tienes permisos para eliminar comisiones.'), 'error')
|
||||||
|
return redirect(url_for('schedule.commissions_view'))
|
||||||
|
|
||||||
|
from app.models.subject import Commission
|
||||||
|
commission = Commission.query.get_or_404(id)
|
||||||
|
full_code = commission.get_full_code()
|
||||||
|
|
||||||
|
db.session.delete(commission)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash(_('Comisión %(code)s eliminada permanentemente.', code=full_code), 'success')
|
||||||
|
return redirect(url_for('schedule.commissions_view'))
|
||||||
|
|
||||||
|
|
||||||
|
@schedule_bp.route('/commissions/<int:id>/virtual-link', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def commission_virtual_link(id):
|
||||||
|
"""Actualizar enlace del aula virtual (Zoom, Meet, Teams, Moodle) - Profesores, Admin y Bedelía"""
|
||||||
|
from app.models.subject import Commission
|
||||||
|
commission = Commission.query.get_or_404(id)
|
||||||
|
|
||||||
|
if not user_can_manage_commission_milestones(current_user, commission):
|
||||||
|
flash(_('No tienes permisos para actualizar el aula virtual de esta comisión.'), 'error')
|
||||||
|
return redirect(url_for('schedule.commissions_view'))
|
||||||
|
|
||||||
|
link = request.form.get('virtual_link', '').strip()
|
||||||
|
commission.virtual_link = link or None
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash(_('Enlace del aula virtual actualizado para la comisión %(code)s.', code=commission.get_full_code()), 'success')
|
||||||
|
return redirect(url_for('schedule.commissions_view'))
|
||||||
|
|
||||||
|
|
||||||
|
@schedule_bp.route('/commissions/<int:id>/milestones/add', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def commission_milestone_add(id):
|
||||||
|
"""Agregar un hito académico (Parcial, TP, Recuperatorio) a una comisión - Profesores, Admin y Bedelía"""
|
||||||
|
from app.models.subject import Commission
|
||||||
|
from app.models.milestone import AcademicMilestone, MilestoneType
|
||||||
|
commission = Commission.query.get_or_404(id)
|
||||||
|
|
||||||
|
if not user_can_manage_commission_milestones(current_user, commission):
|
||||||
|
flash(_('No tienes permisos para agregar hitos a esta comisión.'), 'error')
|
||||||
|
return redirect(url_for('schedule.commissions_view'))
|
||||||
|
|
||||||
|
milestone_type_id = request.form.get('milestone_type_id', type=int)
|
||||||
|
title = request.form.get('title', '').strip()
|
||||||
|
date_str = request.form.get('date', '').strip()
|
||||||
|
time_str = request.form.get('time', '18:00').strip()
|
||||||
|
classroom = request.form.get('classroom', '').strip()
|
||||||
|
link = request.form.get('link', '').strip() or commission.virtual_link or ''
|
||||||
|
notes = request.form.get('notes', '').strip()
|
||||||
|
|
||||||
|
if not milestone_type_id or not date_str:
|
||||||
|
flash(_('Debes especificar el tipo de hito y la fecha.'), 'error')
|
||||||
|
return redirect(url_for('schedule.commissions_view'))
|
||||||
|
|
||||||
|
m_type = MilestoneType.query.get(milestone_type_id)
|
||||||
|
if not m_type:
|
||||||
|
flash(_('Tipo de hito inválido.'), 'error')
|
||||||
|
return redirect(url_for('schedule.commissions_view'))
|
||||||
|
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M")
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(date_str, "%Y-%m-%d")
|
||||||
|
except ValueError:
|
||||||
|
flash(_('Formato de fecha inválido.'), 'error')
|
||||||
|
return redirect(url_for('schedule.commissions_view'))
|
||||||
|
|
||||||
|
milestone = AcademicMilestone(
|
||||||
|
title=title or m_type.name,
|
||||||
|
materia_codigo=commission.subject.code if commission.subject else 'ASIG',
|
||||||
|
subject_id=commission.subject_id,
|
||||||
|
commission_id=commission.id,
|
||||||
|
milestone_type_id=m_type.id,
|
||||||
|
date=dt,
|
||||||
|
classroom=classroom or 'Campus Virtual',
|
||||||
|
link=link or None,
|
||||||
|
notes=notes or None,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
db.session.add(milestone)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash(_('Hito "%(title)s" registrado correctamente en %(comm)s.', title=milestone.title, comm=commission.get_full_code()), 'success')
|
||||||
|
return redirect(url_for('schedule.commissions_view'))
|
||||||
|
|
||||||
|
|
||||||
|
@schedule_bp.route('/commissions/<int:commission_id>/milestones/<int:milestone_id>/delete', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def commission_milestone_delete(commission_id, milestone_id):
|
||||||
|
"""Eliminar un hito académico de una comisión"""
|
||||||
|
from app.models.subject import Commission
|
||||||
|
from app.models.milestone import AcademicMilestone
|
||||||
|
commission = Commission.query.get_or_404(commission_id)
|
||||||
|
|
||||||
|
if not user_can_manage_commission_milestones(current_user, commission):
|
||||||
|
flash(_('No tienes permisos para eliminar hitos de esta comisión.'), 'error')
|
||||||
|
return redirect(url_for('schedule.commissions_view'))
|
||||||
|
|
||||||
|
milestone = AcademicMilestone.query.filter_by(id=milestone_id, commission_id=commission_id).first_or_404()
|
||||||
|
title = milestone.title
|
||||||
|
db.session.delete(milestone)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash(_('Hito "%(title)s" eliminado de la comisión.', title=title), 'success')
|
||||||
|
return redirect(url_for('schedule.commissions_view'))
|
||||||
|
|
||||||
|
@schedule_bp.route('/add', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def add_reservation():
|
||||||
|
"""Add new reservation"""
|
||||||
|
from app.models.classroom import Classroom
|
||||||
|
from app.models.subject import Commission
|
||||||
|
from app.models.building import Building
|
||||||
|
|
||||||
|
form = ReservationForm()
|
||||||
|
|
||||||
|
# Get active buildings and classrooms
|
||||||
|
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
|
||||||
|
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all()
|
||||||
|
commissions = Commission.query.filter_by(active=True).all()
|
||||||
|
|
||||||
|
# Populate form choices
|
||||||
|
form.classroom_id.choices = [(c.id, f'{c.building_name} - {c.code} (Cap: {c.capacity_display})') for c in classrooms]
|
||||||
|
form.commission_id.choices = [(c.id, f'{c.get_full_code()} - {c.subject.name if c.subject else "No Subject"} (Students: {c.max_students})') for c in commissions]
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
if form.validate_on_submit():
|
||||||
|
try:
|
||||||
|
# Check for existing reservations at the same time
|
||||||
|
from app.models.reservation import ReservationStatus
|
||||||
|
existing_reservation = Reservation.query.filter_by(
|
||||||
|
classroom_id=form.classroom_id.data,
|
||||||
|
status=ReservationStatus.CONFIRMED.value
|
||||||
|
).filter(
|
||||||
|
Reservation.start_time < form.end_time.data,
|
||||||
|
Reservation.end_time > form.start_time.data
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_reservation:
|
||||||
|
flash('This classroom is already booked for the selected time slot.', 'danger')
|
||||||
|
return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions, buildings=buildings)
|
||||||
|
|
||||||
|
# Create new reservation
|
||||||
|
reservation = Reservation(
|
||||||
|
classroom_id=form.classroom_id.data,
|
||||||
|
commission_id=form.commission_id.data,
|
||||||
|
user_id=current_user.id,
|
||||||
|
start_time=form.start_time.data,
|
||||||
|
end_time=form.end_time.data,
|
||||||
|
purpose=form.purpose.data,
|
||||||
|
notes=form.notes.data,
|
||||||
|
virtual_link=form.virtual_link.data.strip() if form.virtual_link.data else None,
|
||||||
|
status=ReservationStatus.PENDING.value,
|
||||||
|
expected_attendees=form.expected_attendees.data
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(reservation)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash('Reservation request submitted successfully! It will be reviewed by an administrator.', 'success')
|
||||||
|
return redirect(url_for('schedule.list_reservations'))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f'Error creating reservation: {str(e)}', 'danger')
|
||||||
|
|
||||||
|
return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions, buildings=buildings)
|
||||||
|
|
||||||
|
@schedule_bp.route('/reservation/add', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def quick_reservation_add():
|
||||||
|
"""Endpoint AJAX para crear reserva rápida desde el modal de comisiones"""
|
||||||
|
try:
|
||||||
|
commission_id = request.form.get('commission_id', type=int)
|
||||||
|
classroom_id = request.form.get('classroom_id', type=int)
|
||||||
|
purpose = request.form.get('purpose', '').strip() or 'Clase de cursada'
|
||||||
|
start_str = request.form.get('start_time', '').strip()
|
||||||
|
end_str = request.form.get('end_time', '').strip()
|
||||||
|
virtual_link = request.form.get('virtual_link', '').strip() or None
|
||||||
|
|
||||||
|
if not commission_id or not classroom_id or not start_str or not end_str:
|
||||||
|
return jsonify({'error': 'Faltan campos obligatorios'}), 400
|
||||||
|
|
||||||
|
start_time = datetime.strptime(start_str, '%Y-%m-%d %H:%M')
|
||||||
|
end_time = datetime.strptime(end_str, '%Y-%m-%d %H:%M')
|
||||||
|
|
||||||
|
from app.models.subject import Commission
|
||||||
|
from app.models.classroom import Classroom
|
||||||
|
comm = Commission.query.get_or_404(commission_id)
|
||||||
|
|
||||||
|
if not virtual_link and comm.virtual_link:
|
||||||
|
virtual_link = comm.virtual_link
|
||||||
|
|
||||||
|
new_res = Reservation(
|
||||||
|
classroom_id=classroom_id,
|
||||||
|
commission_id=commission_id,
|
||||||
|
user_id=comm.teacher_id or current_user.id,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
purpose=purpose,
|
||||||
|
expected_attendees=comm.max_students or 30,
|
||||||
|
shift=comm.shift or 'Tarde',
|
||||||
|
virtual_link=virtual_link,
|
||||||
|
status=ReservationStatus.CONFIRMED.value if current_user.is_admin() else ReservationStatus.PENDING.value
|
||||||
|
)
|
||||||
|
db.session.add(new_res)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({'success': True, 'id': new_res.id})
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
@schedule_bp.route('/list')
|
||||||
|
@login_required
|
||||||
|
def list_reservations():
|
||||||
|
"""List all reservations with pagination, date filters, floor, shift, and virtual toggle"""
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
status_filter = request.args.get('status', 'all').strip()
|
||||||
|
date_from = request.args.get('date_from', '').strip()
|
||||||
|
date_to = request.args.get('date_to', '').strip()
|
||||||
|
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
|
||||||
|
floor_filter = request.args.get('floor', '').strip()
|
||||||
|
shift_filter = request.args.get('shift', '').strip()
|
||||||
|
classroom_id = request.args.get('classroom_id', type=int)
|
||||||
|
search = request.args.get('search', '').strip()
|
||||||
|
|
||||||
|
query = Reservation.query.options(
|
||||||
|
joinedload(Reservation.classroom),
|
||||||
|
joinedload(Reservation.commission).joinedload(Commission.subject),
|
||||||
|
joinedload(Reservation.user)
|
||||||
|
).join(Reservation.classroom).outerjoin(Reservation.commission).outerjoin(Commission.subject)
|
||||||
|
|
||||||
|
if classroom_id:
|
||||||
|
query = query.filter(Reservation.classroom_id == classroom_id)
|
||||||
|
|
||||||
|
if status_filter and status_filter != 'all':
|
||||||
|
query = query.filter(Reservation.status == status_filter.upper())
|
||||||
|
|
||||||
|
if date_from:
|
||||||
|
try:
|
||||||
|
d_from = datetime.strptime(date_from, '%Y-%m-%d')
|
||||||
|
query = query.filter(Reservation.start_time >= d_from)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if date_to:
|
||||||
|
try:
|
||||||
|
d_to = datetime.strptime(date_to, '%Y-%m-%d') + timedelta(days=1)
|
||||||
|
query = query.filter(Reservation.start_time < d_to)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if hide_virtual:
|
||||||
|
query = query.filter(
|
||||||
|
~Classroom.code.ilike('%VIRTUAL%'),
|
||||||
|
~Classroom.building.ilike('%Virtual%')
|
||||||
|
)
|
||||||
|
|
||||||
|
if floor_filter:
|
||||||
|
flr = str(floor_filter).strip()
|
||||||
|
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
|
||||||
|
query = query.filter(or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
|
||||||
|
else:
|
||||||
|
query = query.filter(Classroom.floor == flr)
|
||||||
|
|
||||||
|
if shift_filter:
|
||||||
|
query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%'))
|
||||||
|
|
||||||
|
if search:
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
Subject.name.ilike(f'%{search}%'),
|
||||||
|
Subject.code.ilike(f'%{search}%'),
|
||||||
|
Commission.code.ilike(f'%{search}%'),
|
||||||
|
Classroom.code.ilike(f'%{search}%'),
|
||||||
|
Reservation.purpose.ilike(f'%{search}%'),
|
||||||
|
Reservation.shift.ilike(f'%{search}%')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
reservations = query.order_by(Reservation.start_time.desc()).paginate(
|
||||||
|
page=page, per_page=20, error_out=False
|
||||||
|
)
|
||||||
|
|
||||||
|
floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()
|
||||||
|
available_floors = [f[0] for f in floors]
|
||||||
|
all_classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all()
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'schedule/list.html',
|
||||||
|
reservations=reservations,
|
||||||
|
status_filter=status_filter,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
hide_virtual=hide_virtual,
|
||||||
|
floor_filter=floor_filter,
|
||||||
|
shift_filter=shift_filter,
|
||||||
|
classroom_id=classroom_id,
|
||||||
|
all_classrooms=all_classrooms,
|
||||||
|
search=search,
|
||||||
|
available_floors=available_floors
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@schedule_bp.route('/reservation/<int:id>/confirm', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def confirm_reservation(id):
|
||||||
|
"""Confirm a reservation (Handles both GET from link and POST from form)"""
|
||||||
|
try:
|
||||||
|
reservation = Reservation.query.get_or_404(id)
|
||||||
|
|
||||||
|
# Validación de seguridad: Solo confirmar si está pendiente
|
||||||
|
if reservation.status != ReservationStatus.PENDING.value:
|
||||||
|
flash('Only pending reservations can be confirmed.', 'warning')
|
||||||
|
return redirect(url_for('schedule.today_schedule'))
|
||||||
|
|
||||||
|
reservation.status = ReservationStatus.CONFIRMED.value
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash(
|
||||||
|
f'Reservation for {reservation.commission.code if reservation.commission else "Class"} confirmed successfully!',
|
||||||
|
'success')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f'Error confirming reservation: {str(e)}', 'error')
|
||||||
|
|
||||||
|
# Redirigir a la página desde la que venía el usuario o al inicio de hoy
|
||||||
|
return redirect(request.referrer or url_for('schedule.today_schedule'))
|
||||||
|
|
||||||
|
# API endpoints
|
||||||
|
@schedule_bp.route('/api/classroom-availability')
|
||||||
|
@login_required
|
||||||
|
def classroom_availability_api():
|
||||||
|
"""Get classroom availability for a specific date/time"""
|
||||||
|
try:
|
||||||
|
date_str = request.args.get('date')
|
||||||
|
start_time_str = request.args.get('start_time')
|
||||||
|
end_time_str = request.args.get('end_time')
|
||||||
|
max_students = request.args.get('max_students', type=int)
|
||||||
|
|
||||||
|
if not all([date_str, start_time_str, end_time_str]):
|
||||||
|
return jsonify({'error': 'Missing required parameters: date, start_time, end_time'}), 400
|
||||||
|
|
||||||
|
# Parse datetime strings
|
||||||
|
from datetime import datetime
|
||||||
|
start_time = datetime.strptime(f"{date_str} {start_time_str}", "%Y-%m-%d %H:%M")
|
||||||
|
end_time = datetime.strptime(f"{date_str} {end_time_str}", "%Y-%m-%d %H:%M")
|
||||||
|
|
||||||
|
# Get availability status
|
||||||
|
from app.models.classroom import Classroom
|
||||||
|
availability = Classroom.get_availability_status(start_time, end_time)
|
||||||
|
|
||||||
|
# Get best classroom recommendation if max_students provided
|
||||||
|
best_recommendation = None
|
||||||
|
if max_students:
|
||||||
|
best_classrooms = Classroom.find_best_classroom(max_students, start_time, end_time)
|
||||||
|
if best_classrooms:
|
||||||
|
best_recommendation = best_classrooms[0] # Most efficient one
|
||||||
|
|
||||||
|
# Format response
|
||||||
|
response = {
|
||||||
|
'availability': {
|
||||||
|
'available': [
|
||||||
|
{
|
||||||
|
'id': item['classroom'].id,
|
||||||
|
'code': item['classroom'].code_display,
|
||||||
|
'name': item['classroom'].name,
|
||||||
|
'capacity': item['classroom'].capacity,
|
||||||
|
'location': item['classroom'].get_full_location()
|
||||||
|
}
|
||||||
|
for item in availability['available']
|
||||||
|
],
|
||||||
|
'occupied': [
|
||||||
|
{
|
||||||
|
'id': item['classroom'].id,
|
||||||
|
'code': item['classroom'].code_display,
|
||||||
|
'name': item['classroom'].name,
|
||||||
|
'capacity': item['classroom'].capacity,
|
||||||
|
'location': item['classroom'].get_full_location(),
|
||||||
|
'conflicts': [
|
||||||
|
{
|
||||||
|
'start_time': conflict.start_time.strftime('%H:%M'),
|
||||||
|
'end_time': conflict.end_time.strftime('%H:%M'),
|
||||||
|
'commission': conflict.commission.get_full_code() if conflict.commission else 'Unknown'
|
||||||
|
}
|
||||||
|
for conflict in item['conflicts']
|
||||||
|
]
|
||||||
|
}
|
||||||
|
for item in availability['occupied']
|
||||||
|
],
|
||||||
|
'stats': {
|
||||||
|
'total_available': availability['total_available'],
|
||||||
|
'total_occupied': availability['total_occupied']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if best_recommendation:
|
||||||
|
response['recommendation'] = {
|
||||||
|
'classroom': {
|
||||||
|
'id': best_recommendation['classroom'].id,
|
||||||
|
'code': best_recommendation['classroom'].code_display,
|
||||||
|
'name': best_recommendation['classroom'].name,
|
||||||
|
'capacity': best_recommendation['classroom'].capacity,
|
||||||
|
'location': best_recommendation['classroom'].get_full_location()
|
||||||
|
},
|
||||||
|
'efficiency': best_recommendation['efficiency'],
|
||||||
|
'wasted_capacity': best_recommendation['wasted_capacity'],
|
||||||
|
'reason': 'Best match based on capacity and availability'
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify(response)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'error': f'Invalid date/time format: {str(e)}'}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
@schedule_bp.route('/calendar-data')
|
||||||
|
@login_required
|
||||||
|
def calendar_data():
|
||||||
|
"""Get calendar events data for FullCalendar"""
|
||||||
|
try:
|
||||||
|
start = request.args.get('start')
|
||||||
|
end = request.args.get('end')
|
||||||
|
classroom_id = request.args.get('classroom_id')
|
||||||
|
subject_id = request.args.get('subject_id')
|
||||||
|
career_id = request.args.get('career_id')
|
||||||
|
shift = request.args.get('shift', '').strip()
|
||||||
|
floor = request.args.get('floor', '').strip()
|
||||||
|
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
|
||||||
|
search = request.args.get('search', '').strip()
|
||||||
|
|
||||||
|
if start and end:
|
||||||
|
# Clean up ISO timezone strings for PostgreSQL naive timestamp comparison
|
||||||
|
start_clean = start.replace('Z', '+00:00')
|
||||||
|
end_clean = end.replace('Z', '+00:00')
|
||||||
|
start_date = datetime.fromisoformat(start_clean).replace(tzinfo=None)
|
||||||
|
end_date = datetime.fromisoformat(end_clean).replace(tzinfo=None)
|
||||||
|
else:
|
||||||
|
today = date.today()
|
||||||
|
start_date = datetime.combine(today.replace(day=1), datetime.min.time())
|
||||||
|
next_month = today.replace(day=28) + timedelta(days=4)
|
||||||
|
end_date = datetime.combine(next_month.replace(day=1), datetime.max.time())
|
||||||
|
|
||||||
|
query = Reservation.query.join(Reservation.commission).join(Commission.subject).outerjoin(Reservation.classroom).options(
|
||||||
|
joinedload(Reservation.classroom),
|
||||||
|
joinedload(Reservation.commission).joinedload(Commission.subject).joinedload(Subject.career_obj)
|
||||||
|
).filter(
|
||||||
|
Reservation.start_time >= start_date,
|
||||||
|
Reservation.start_time <= end_date
|
||||||
|
)
|
||||||
|
|
||||||
|
if classroom_id and classroom_id.isdigit():
|
||||||
|
query = query.filter(Reservation.classroom_id == int(classroom_id))
|
||||||
|
|
||||||
|
if subject_id and subject_id.isdigit():
|
||||||
|
query = query.filter(Commission.subject_id == int(subject_id))
|
||||||
|
|
||||||
|
if career_id and career_id.isdigit():
|
||||||
|
query = query.filter(Subject.career_id == int(career_id))
|
||||||
|
|
||||||
|
building = request.args.get('building', '').strip()
|
||||||
|
if building:
|
||||||
|
from app.models.building import Building
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
Classroom.building.ilike(f'%{building}%'),
|
||||||
|
Classroom.building_entity.has(Building.name.ilike(f'%{building}%'))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if shift:
|
||||||
|
query = query.filter(Reservation.shift.ilike(f'%{shift}%'))
|
||||||
|
|
||||||
|
if hide_virtual:
|
||||||
|
query = query.filter(
|
||||||
|
~Classroom.code.ilike('%VIRTUAL%'),
|
||||||
|
~Classroom.building.ilike('%Virtual%')
|
||||||
|
)
|
||||||
|
|
||||||
|
if floor:
|
||||||
|
flr = str(floor).strip()
|
||||||
|
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
|
||||||
|
query = query.filter(or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
|
||||||
|
else:
|
||||||
|
query = query.filter(Classroom.floor == flr)
|
||||||
|
|
||||||
|
if search:
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
Subject.name.ilike(f'%{search}%'),
|
||||||
|
Subject.code.ilike(f'%{search}%'),
|
||||||
|
Classroom.code.ilike(f'%{search}%'),
|
||||||
|
Reservation.purpose.ilike(f'%{search}%')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
reservations = query.order_by(Reservation.start_time).all()
|
||||||
|
|
||||||
|
events = []
|
||||||
|
for reservation in reservations:
|
||||||
|
subject = reservation.commission.subject if (reservation.commission and reservation.commission.subject) else None
|
||||||
|
subject_code = subject.code if subject else (reservation.commission.code if reservation.commission else '')
|
||||||
|
subject_name = subject.name if subject else (reservation.purpose or 'Clase')
|
||||||
|
classroom_name = reservation.classroom.code if reservation.classroom else 'Sin Aula'
|
||||||
|
classroom_floor = reservation.classroom.floor_display if reservation.classroom else None
|
||||||
|
classroom_building = reservation.classroom.building_name if reservation.classroom else ''
|
||||||
|
is_virtual = bool(reservation.classroom and reservation.classroom.is_virtual)
|
||||||
|
career_name = subject.career_obj.name if (subject and subject.career_obj) else (subject.department if subject else '')
|
||||||
|
status_val = str(reservation.status.value if hasattr(reservation.status, 'value') else reservation.status)
|
||||||
|
|
||||||
|
events.append({
|
||||||
|
'id': reservation.id,
|
||||||
|
'title': subject_name,
|
||||||
|
'start': reservation.start_time.isoformat(),
|
||||||
|
'end': reservation.end_time.isoformat(),
|
||||||
|
'url': url_for('schedule.view_reservation', id=reservation.id) if reservation.id else None,
|
||||||
|
'className': f'fc-event-{status_val.lower()}',
|
||||||
|
'extendedProps': {
|
||||||
|
'subject_code': subject_code,
|
||||||
|
'subject_name': subject_name,
|
||||||
|
'classroom': classroom_name,
|
||||||
|
'building': classroom_building,
|
||||||
|
'floor': classroom_floor,
|
||||||
|
'is_virtual': is_virtual,
|
||||||
|
'location_display': reservation.classroom.location_display if reservation.classroom else '',
|
||||||
|
'career': career_name,
|
||||||
|
'commission': reservation.commission.code if reservation.commission else '',
|
||||||
|
'shift': reservation.shift or '',
|
||||||
|
'status': status_val,
|
||||||
|
'purpose': reservation.purpose or '',
|
||||||
|
'attendees': reservation.expected_attendees or 0,
|
||||||
|
'notes': reservation.notes or '',
|
||||||
|
'teacher': reservation.user.name if reservation.user else '',
|
||||||
|
'virtual_link': reservation.effective_virtual_link,
|
||||||
|
'cancel_url': url_for('schedule.cancel_reservation', id=reservation.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify(events)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
@schedule_bp.route('/api/today-events')
|
||||||
|
@login_required
|
||||||
|
def api_today_events():
|
||||||
|
"""Get today's events as JSON"""
|
||||||
|
try:
|
||||||
|
today_start = datetime.combine(date.today(), datetime.min.time())
|
||||||
|
today_end = datetime.combine(date.today(), datetime.max.time())
|
||||||
|
|
||||||
|
reservations = Reservation.query.options(
|
||||||
|
joinedload(Reservation.classroom),
|
||||||
|
joinedload(Reservation.commission).joinedload(Commission.subject).joinedload(Subject.career_obj)
|
||||||
|
).filter(
|
||||||
|
Reservation.start_time >= today_start,
|
||||||
|
Reservation.start_time <= today_end
|
||||||
|
).order_by(Reservation.start_time).all()
|
||||||
|
|
||||||
|
events = []
|
||||||
|
for reservation in reservations:
|
||||||
|
subject = reservation.commission.subject if (reservation.commission and reservation.commission.subject) else None
|
||||||
|
subject_code = subject.code if subject else (reservation.commission.code if reservation.commission else '')
|
||||||
|
subject_name = subject.name if subject else (reservation.purpose or 'Clase')
|
||||||
|
classroom_name = reservation.classroom.code if reservation.classroom else 'Sin Aula'
|
||||||
|
status_val = str(reservation.status.value if hasattr(reservation.status, 'value') else reservation.status)
|
||||||
|
|
||||||
|
events.append({
|
||||||
|
'id': reservation.id,
|
||||||
|
'title': f"{subject_name} ({subject_code}) - {classroom_name}",
|
||||||
|
'start': reservation.start_time.isoformat(),
|
||||||
|
'end': reservation.end_time.isoformat(),
|
||||||
|
'url': url_for('schedule.view_reservation', id=reservation.id) if reservation.id else None,
|
||||||
|
'extendedProps': {
|
||||||
|
'subject_code': subject_code,
|
||||||
|
'subject_name': subject_name,
|
||||||
|
'classroom': classroom_name,
|
||||||
|
'purpose': reservation.purpose or 'No purpose',
|
||||||
|
'attendees': reservation.expected_attendees,
|
||||||
|
'status': status_val
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({'events': events})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""Módulo de Data Transfer Objects (DTOs) y esquemas tipados con Pydantic."""
|
||||||
|
from app.schemas.classroom_dto import ClassroomCreateDTO, ClassroomUpdateDTO, ClassroomResponseDTO
|
||||||
|
from app.schemas.reservation_dto import ReservationCreateDTO, ReservationUpdateDTO, ReservationResponseDTO
|
||||||
|
from app.schemas.auth_dto import LoginDTO, TokenResponseDTO, RefreshTokenDTO
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'ClassroomCreateDTO',
|
||||||
|
'ClassroomUpdateDTO',
|
||||||
|
'ClassroomResponseDTO',
|
||||||
|
'ReservationCreateDTO',
|
||||||
|
'ReservationUpdateDTO',
|
||||||
|
'ReservationResponseDTO',
|
||||||
|
'LoginDTO',
|
||||||
|
'TokenResponseDTO',
|
||||||
|
'RefreshTokenDTO'
|
||||||
|
]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
from pydantic import BaseModel, Field, EmailStr
|
||||||
|
|
||||||
|
class LoginDTO(BaseModel):
|
||||||
|
email: str = Field(..., description="Correo electrónico institucional")
|
||||||
|
password: str = Field(..., min_length=1, description="Contraseña de acceso")
|
||||||
|
|
||||||
|
class TokenResponseDTO(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
token_type: str = "Bearer"
|
||||||
|
expires_in: int = 900 # 15 minutos en segundos
|
||||||
|
user: Dict[str, Any]
|
||||||
|
|
||||||
|
class RefreshTokenDTO(BaseModel):
|
||||||
|
refresh_token: str = Field(..., description="Token de refresco válido")
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
class ClassroomBaseDTO(BaseModel):
|
||||||
|
code: str = Field(..., min_length=1, max_length=50, description="Código o nombre del aula (ej: Aula 101, VIRTUAL-A)")
|
||||||
|
floor: str = Field(default="PB", max_length=50, description="Piso o nivel (PB, Piso 1, etc.)")
|
||||||
|
capacity: int = Field(default=30, ge=1, le=10000, description="Capacidad máxima de estudiantes")
|
||||||
|
description: Optional[str] = Field(default=None, max_length=500, description="Detalles o equipamiento")
|
||||||
|
is_active: bool = Field(default=True, description="Estado operativo del aula")
|
||||||
|
|
||||||
|
@field_validator('code')
|
||||||
|
@classmethod
|
||||||
|
def clean_code(cls, v: str) -> str:
|
||||||
|
v = v.strip()
|
||||||
|
if not v:
|
||||||
|
raise ValueError("El código del aula no puede estar vacío.")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator('floor')
|
||||||
|
@classmethod
|
||||||
|
def clean_floor(cls, v: str) -> str:
|
||||||
|
return str(v).strip() or "PB"
|
||||||
|
|
||||||
|
class ClassroomCreateDTO(ClassroomBaseDTO):
|
||||||
|
building_id: Optional[int] = Field(default=None, description="ID del edificio asignado")
|
||||||
|
building_name: Optional[str] = Field(default=None, description="Nombre de edificio (para creación dinámica)")
|
||||||
|
|
||||||
|
class ClassroomUpdateDTO(BaseModel):
|
||||||
|
building_id: Optional[int] = None
|
||||||
|
code: Optional[str] = None
|
||||||
|
floor: Optional[str] = None
|
||||||
|
capacity: Optional[int] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
class ClassroomResponseDTO(ClassroomBaseDTO):
|
||||||
|
id: int
|
||||||
|
building_id: Optional[int] = None
|
||||||
|
building_name: str
|
||||||
|
is_virtual: bool
|
||||||
|
location_display: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class OptimizerJobRequestDTO(BaseModel):
|
||||||
|
"""Contrato de solicitud para iniciar una optimización heurística."""
|
||||||
|
commission_ids: List[int] = Field(..., min_length=1, description="Lista de IDs de comisiones a programar")
|
||||||
|
start_date: Optional[datetime] = Field(None, description="Fecha de inicio (ISO format)")
|
||||||
|
end_date: Optional[datetime] = Field(None, description="Fecha de fin (ISO format)")
|
||||||
|
generations: int = Field(default=80, ge=10, le=500, description="Cantidad de generaciones genéticas")
|
||||||
|
population_size: int = Field(default=40, ge=10, le=200, description="Tamaño de la población de cromosomas")
|
||||||
|
workers: int = Field(default=4, ge=1, le=16, description="Cantidad de hilos o workers concurrentes")
|
||||||
|
|
||||||
|
@field_validator('commission_ids')
|
||||||
|
@classmethod
|
||||||
|
def validate_ids(cls, v):
|
||||||
|
if not v or len(v) == 0:
|
||||||
|
raise ValueError("Debe suministrar al menos una comisión para optimizar.")
|
||||||
|
return list(set(v))
|
||||||
|
|
||||||
|
|
||||||
|
class SpringBootCommissionDTO(BaseModel):
|
||||||
|
"""Contrato de comisión en formato Spring Boot."""
|
||||||
|
commissionId: int
|
||||||
|
subjectCode: str
|
||||||
|
subjectName: str
|
||||||
|
expectedAttendees: int = Field(default=30, ge=1)
|
||||||
|
preferredShift: Optional[str] = "NOCHE"
|
||||||
|
preferredStart: datetime
|
||||||
|
preferredEnd: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class SpringBootClassroomDTO(BaseModel):
|
||||||
|
"""Contrato de aula en formato Spring Boot."""
|
||||||
|
classroomId: int
|
||||||
|
roomNumber: str
|
||||||
|
capacity: int = Field(default=30, ge=1)
|
||||||
|
building: Optional[str] = "Edificio Central"
|
||||||
|
isVirtual: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class SpringBootSyncPayloadDTO(BaseModel):
|
||||||
|
"""Carga útil de sincronización interoperable con microservicios Spring Boot."""
|
||||||
|
requestId: str = Field(..., description="Identificador único de la petición externa")
|
||||||
|
campusCode: Optional[str] = "CENTRAL"
|
||||||
|
academicTerm: Optional[str] = "2026-1C"
|
||||||
|
commissions: List[SpringBootCommissionDTO] = Field(..., min_length=1)
|
||||||
|
classrooms: Optional[List[SpringBootClassroomDTO]] = None
|
||||||
|
options: Optional[Dict[str, Any]] = None
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
class ReservationBaseDTO(BaseModel):
|
||||||
|
classroom_id: int = Field(..., description="ID del aula asignada")
|
||||||
|
commission_id: int = Field(..., description="ID de la comisión académica")
|
||||||
|
user_id: int = Field(..., description="ID del docente responsable")
|
||||||
|
start_time: datetime = Field(..., description="Fecha y hora de inicio")
|
||||||
|
end_time: datetime = Field(..., description="Fecha y hora de finalización")
|
||||||
|
purpose: str = Field(default="Clase regular", max_length=500, description="Motivo o título de la reserva")
|
||||||
|
expected_attendees: int = Field(default=20, ge=1, description="Cantidad estimada de asistentes")
|
||||||
|
shift: Optional[str] = Field(default=None, max_length=30, description="Turno (Mañana, Tarde, Vespertino, Noche)")
|
||||||
|
virtual_link: Optional[str] = Field(default=None, max_length=500, description="Enlace a sala virtual (Meet/Zoom)")
|
||||||
|
notes: Optional[str] = Field(default=None, description="Observaciones adicionales")
|
||||||
|
status: Optional[str] = Field(default="PENDING", description="Estado de la reserva")
|
||||||
|
|
||||||
|
@model_validator(mode='after')
|
||||||
|
def validate_time_range(self):
|
||||||
|
if self.end_time <= self.start_time:
|
||||||
|
raise ValueError("La hora de finalización debe ser posterior a la hora de inicio.")
|
||||||
|
return self
|
||||||
|
|
||||||
|
class ReservationCreateDTO(ReservationBaseDTO):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ReservationUpdateDTO(BaseModel):
|
||||||
|
classroom_id: Optional[int] = None
|
||||||
|
start_time: Optional[datetime] = None
|
||||||
|
end_time: Optional[datetime] = None
|
||||||
|
purpose: Optional[str] = None
|
||||||
|
expected_attendees: Optional[int] = None
|
||||||
|
shift: Optional[str] = None
|
||||||
|
virtual_link: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
|
||||||
|
class ReservationResponseDTO(ReservationBaseDTO):
|
||||||
|
id: int
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""Módulo de Seguridad Empresarial inspirado en Spring Security (SecurityFilterChain, RBAC y Auditoría)."""
|
||||||
|
from app.security.filter_chain import SecurityFilterChain
|
||||||
|
from app.security.decorators import require_permission
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'SecurityFilterChain',
|
||||||
|
'require_permission'
|
||||||
|
]
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from functools import wraps
|
||||||
|
from flask import request, jsonify, redirect, url_for, flash, abort, g
|
||||||
|
from flask_login import current_user
|
||||||
|
|
||||||
|
def require_permission(module: str, min_level: str = 'read'):
|
||||||
|
"""
|
||||||
|
Decorador de seguridad declarativa a nivel de método inspirado en @PreAuthorize de Spring Security.
|
||||||
|
Verifica los privilegios del usuario autenticado (tanto en sesiones web como en API JWT).
|
||||||
|
|
||||||
|
Niveles de permiso estándar:
|
||||||
|
- 'read': Permiso de lectura / consulta
|
||||||
|
- 'read_write': Permiso de modificación y creación
|
||||||
|
- 'admin': Permiso de control total
|
||||||
|
"""
|
||||||
|
def decorator(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
user = None
|
||||||
|
# Evaluar contexto JWT primero si está presente
|
||||||
|
if hasattr(g, 'jwt_user') and g.jwt_user:
|
||||||
|
user = g.jwt_user
|
||||||
|
elif current_user and current_user.is_authenticated:
|
||||||
|
user = current_user
|
||||||
|
|
||||||
|
is_api = request.path.startswith('/api/') or request.is_json
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
if is_api:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'message': 'Autenticación requerida.'}), 401
|
||||||
|
flash('Debe iniciar sesión para acceder a este recurso.', 'warning')
|
||||||
|
return redirect(url_for('auth.login', next=request.url))
|
||||||
|
|
||||||
|
# Comprobar privilegios en la matriz RBAC
|
||||||
|
if not user.has_permission(module, min_level):
|
||||||
|
if is_api:
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Forbidden',
|
||||||
|
'message': f'Permiso insuficiente en módulo "{module}". Nivel requerido: "{min_level}".'
|
||||||
|
}), 403
|
||||||
|
flash(f'No posee permisos suficientes ({min_level}) para gestionar {module}.', 'danger')
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
|
return decorator
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
from flask import request, jsonify, g, render_template, Response
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Dict, List, Tuple
|
||||||
|
import time
|
||||||
|
|
||||||
|
class SecurityFilterChain:
|
||||||
|
"""
|
||||||
|
Pipeline centralizado de interceptación de seguridad (SecurityFilterChain)
|
||||||
|
inspirado en el patrón arquitectónico de Spring Security.
|
||||||
|
|
||||||
|
Capas de filtrado activas:
|
||||||
|
1. Security Headers Filter (Blindaje HTTP en todas las respuestas).
|
||||||
|
2. Rate Limiting Filter (Protección contra fuerza bruta y DoS).
|
||||||
|
3. Bearer Token Context Filter (Inyección de identidad contextual).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app=None):
|
||||||
|
# Registro de timestamps de peticiones por IP: ip -> [timestamps]
|
||||||
|
self._request_history: Dict[str, List[float]] = {}
|
||||||
|
# Límites por endpoint sensible (max_requests, window_seconds)
|
||||||
|
self._rate_limits = {
|
||||||
|
'/login': (15, 60),
|
||||||
|
'/api/v1/auth/login': (15, 60),
|
||||||
|
'/api/v1/auth/refresh': (30, 60)
|
||||||
|
}
|
||||||
|
if app:
|
||||||
|
self.init_app(app)
|
||||||
|
|
||||||
|
def init_app(self, app):
|
||||||
|
"""Registra los filtros en el ciclo de vida de la aplicación Flask."""
|
||||||
|
app.before_request(self._execute_pre_filters)
|
||||||
|
app.after_request(self._execute_post_filters)
|
||||||
|
|
||||||
|
def _get_client_ip(self) -> str:
|
||||||
|
"""Obtiene la IP real del cliente respetando proxies inversos de confianza."""
|
||||||
|
if request.headers.get('X-Forwarded-For'):
|
||||||
|
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
||||||
|
return request.remote_addr or '127.0.0.1'
|
||||||
|
|
||||||
|
def _execute_pre_filters(self):
|
||||||
|
"""Ejecuta los filtros previos al enrutamiento del controlador."""
|
||||||
|
path = request.path
|
||||||
|
|
||||||
|
# 1. Rate Limiting Filter (solo para peticiones POST de autenticación sensible)
|
||||||
|
if path in self._rate_limits:
|
||||||
|
# Peticiones GET para cargar la interfaz web NO consumen intentos de fuerza bruta
|
||||||
|
if request.method != 'POST':
|
||||||
|
return
|
||||||
|
|
||||||
|
max_req, window = self._rate_limits[path]
|
||||||
|
client_ip = self._get_client_ip()
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
# Limpiar historial fuera de la ventana
|
||||||
|
timestamps = self._request_history.get(client_ip, [])
|
||||||
|
timestamps = [t for t in timestamps if now - t < window]
|
||||||
|
|
||||||
|
if len(timestamps) >= max_req:
|
||||||
|
retry_after = max(1, int(window - (now - timestamps[0])))
|
||||||
|
if path.startswith('/api/'):
|
||||||
|
return jsonify({
|
||||||
|
'error': 'TooManyRequests',
|
||||||
|
'message': f'Límite de peticiones excedido. Reintente en {retry_after} segundos.'
|
||||||
|
}), 429
|
||||||
|
|
||||||
|
# Para la web, renderizar vista o respuesta legible
|
||||||
|
return Response(
|
||||||
|
f"Demasiados intentos de autenticación desde su dirección IP. Por favor espere {retry_after} segundos antes de volver a intentar.",
|
||||||
|
status=429,
|
||||||
|
mimetype='text/plain; charset=utf-8'
|
||||||
|
)
|
||||||
|
|
||||||
|
timestamps.append(now)
|
||||||
|
self._request_history[client_ip] = timestamps
|
||||||
|
|
||||||
|
def _execute_post_filters(self, response):
|
||||||
|
"""Inyecta cabeceras de blindaje HTTP (OWASP Secure Headers)."""
|
||||||
|
# Evitar ataques de MIME-Sniffing
|
||||||
|
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||||
|
# Mitigación de Clickjacking
|
||||||
|
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
|
||||||
|
# Mitigación XSS en navegadores legacy
|
||||||
|
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||||
|
# Política de Referrer segura
|
||||||
|
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||||
|
# Inyección HSTS (si es HTTPS o entorno de producción)
|
||||||
|
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
|
||||||
|
|
||||||
|
return response
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from .sheets_importer import GoogleSheetsImporter
|
||||||
|
from .classroom_service import ClassroomService
|
||||||
|
from .reservation_service import ReservationService
|
||||||
|
from .user_service import UserService
|
||||||
|
from .jwt_service import JWTService
|
||||||
|
from .audit_service import AuditService
|
||||||
|
from .optimizer_service import OptimizerService, OptimizerJob, OptimizerJobStatus
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'GoogleSheetsImporter',
|
||||||
|
'ClassroomService',
|
||||||
|
'ReservationService',
|
||||||
|
'UserService',
|
||||||
|
'JWTService',
|
||||||
|
'AuditService',
|
||||||
|
'OptimizerService',
|
||||||
|
'OptimizerJob',
|
||||||
|
'OptimizerJobStatus'
|
||||||
|
]
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from flask import request, g, has_request_context
|
||||||
|
from flask_login import current_user
|
||||||
|
import json
|
||||||
|
from app import db
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
class AuditService:
|
||||||
|
"""Servicio para la captura y consulta de la bitácora inmutable de auditoría."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def log(action: str, module: str, entity_id: Optional[int] = None,
|
||||||
|
details: Optional[Any] = None, user: Optional[User] = None,
|
||||||
|
ip_address: Optional[str] = None) -> AuditLog:
|
||||||
|
"""
|
||||||
|
Registra un evento de auditoría de forma segura, infiriendo el usuario e IP
|
||||||
|
desde el contexto actual de la petición si no se especifican.
|
||||||
|
"""
|
||||||
|
user_id = None
|
||||||
|
user_email = None
|
||||||
|
|
||||||
|
# 1. Determinar usuario actuante
|
||||||
|
if user:
|
||||||
|
user_id = user.id
|
||||||
|
user_email = user.email
|
||||||
|
elif has_request_context():
|
||||||
|
if hasattr(g, 'jwt_user') and g.jwt_user:
|
||||||
|
user_id = g.jwt_user.id
|
||||||
|
user_email = g.jwt_user.email
|
||||||
|
elif current_user and current_user.is_authenticated:
|
||||||
|
user_id = current_user.id
|
||||||
|
user_email = current_user.email
|
||||||
|
|
||||||
|
# 2. Determinar IP de origen
|
||||||
|
if not ip_address and has_request_context():
|
||||||
|
if request.headers.get('X-Forwarded-For'):
|
||||||
|
ip_address = request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
||||||
|
else:
|
||||||
|
ip_address = request.remote_addr
|
||||||
|
|
||||||
|
# 3. Serializar detalles si es estructura de datos
|
||||||
|
details_str = None
|
||||||
|
if details is not None:
|
||||||
|
if isinstance(details, (dict, list)):
|
||||||
|
try:
|
||||||
|
details_str = json.dumps(details, ensure_ascii=False)
|
||||||
|
except Exception:
|
||||||
|
details_str = str(details)
|
||||||
|
else:
|
||||||
|
details_str = str(details)
|
||||||
|
|
||||||
|
log_entry = AuditLog(
|
||||||
|
user_id=user_id,
|
||||||
|
user_email=user_email,
|
||||||
|
action=action.upper(),
|
||||||
|
module=module.lower(),
|
||||||
|
entity_id=entity_id,
|
||||||
|
details=details_str,
|
||||||
|
ip_address=ip_address
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
db.session.add(log_entry)
|
||||||
|
db.session.commit()
|
||||||
|
except Exception:
|
||||||
|
db.session.rollback()
|
||||||
|
|
||||||
|
return log_entry
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_recent_logs(limit: int = 50, module: Optional[str] = None) -> List[AuditLog]:
|
||||||
|
"""Obtiene las entradas más recientes de auditoría."""
|
||||||
|
query = AuditLog.query
|
||||||
|
if module:
|
||||||
|
query = query.filter_by(module=module.lower())
|
||||||
|
return query.order_by(AuditLog.created_at.desc()).limit(limit).all()
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
import re
|
||||||
|
from sqlalchemy import func
|
||||||
|
from app.models.classroom import Classroom
|
||||||
|
from app.models.building import Building
|
||||||
|
from app.repositories.classroom_repository import ClassroomRepository
|
||||||
|
from app.schemas.classroom_dto import ClassroomCreateDTO, ClassroomUpdateDTO
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
class ClassroomService:
|
||||||
|
"""Capa de servicios para la lógica de negocio y validación de aulas."""
|
||||||
|
|
||||||
|
def __init__(self, repository: Optional[ClassroomRepository] = None):
|
||||||
|
self.repository = repository or ClassroomRepository()
|
||||||
|
|
||||||
|
def get_classroom(self, classroom_id: int) -> Optional[Classroom]:
|
||||||
|
"""Obtiene un aula por su identificador."""
|
||||||
|
return self.repository.get_by_id(classroom_id)
|
||||||
|
|
||||||
|
def list_classrooms(self, building_id: Optional[int] = None, floor: Optional[str] = None,
|
||||||
|
only_virtual: bool = False, only_physical: bool = False) -> List[Classroom]:
|
||||||
|
"""Lista aulas filtradas según criterios de ubicación o modalidad."""
|
||||||
|
return self.repository.get_active_classrooms(
|
||||||
|
building_id=building_id,
|
||||||
|
floor=floor,
|
||||||
|
only_virtual=only_virtual,
|
||||||
|
only_physical=only_physical
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_classroom(self, dto: ClassroomCreateDTO) -> Classroom:
|
||||||
|
"""
|
||||||
|
Crea una nueva aula aplicando reglas de negocio:
|
||||||
|
- Resolución o creación dinámica del edificio.
|
||||||
|
- Unicidad de código por piso y edificio.
|
||||||
|
- Capacidad adaptativa para aulas virtuales.
|
||||||
|
"""
|
||||||
|
building = None
|
||||||
|
if dto.building_id:
|
||||||
|
building = Building.query.get(dto.building_id)
|
||||||
|
if not building:
|
||||||
|
raise ValueError(f"El edificio con ID {dto.building_id} no existe.")
|
||||||
|
elif dto.building_name:
|
||||||
|
b_name = dto.building_name.strip()
|
||||||
|
building = Building.query.filter(func.lower(Building.name) == func.lower(b_name)).first()
|
||||||
|
if not building:
|
||||||
|
code_slug = re.sub(r'[^a-zA-Z0-9]+', '_', b_name.upper()).strip('_')
|
||||||
|
building = Building(name=b_name, code=code_slug, is_active=True)
|
||||||
|
db.session.add(building)
|
||||||
|
db.session.flush()
|
||||||
|
else:
|
||||||
|
raise ValueError("Debe especificar un edificio válido o el nombre de un nuevo edificio.")
|
||||||
|
|
||||||
|
# Verificar unicidad
|
||||||
|
existing = self.repository.get_by_code_and_building(building.id, dto.code, dto.floor)
|
||||||
|
if existing:
|
||||||
|
raise ValueError(f"Ya existe un aula con el código '{dto.code}' en el piso {dto.floor} de {building.name}.")
|
||||||
|
|
||||||
|
capacity = dto.capacity
|
||||||
|
is_virtual = ('VIRTUAL' in dto.code.upper()) or ('VIRTUAL' in building.name.upper())
|
||||||
|
if is_virtual and capacity < 999:
|
||||||
|
capacity = 9999
|
||||||
|
|
||||||
|
classroom = Classroom(
|
||||||
|
building_id=building.id,
|
||||||
|
building=building.name,
|
||||||
|
code=dto.code,
|
||||||
|
floor=dto.floor,
|
||||||
|
capacity=capacity,
|
||||||
|
description=dto.description,
|
||||||
|
is_active=dto.is_active
|
||||||
|
)
|
||||||
|
return self.repository.save(classroom)
|
||||||
|
|
||||||
|
def update_classroom(self, classroom_id: int, dto: ClassroomUpdateDTO) -> Classroom:
|
||||||
|
"""Actualiza atributos de un aula existente."""
|
||||||
|
classroom = self.repository.get_by_id(classroom_id)
|
||||||
|
if not classroom:
|
||||||
|
raise ValueError(f"Aula con ID {classroom_id} no encontrada.")
|
||||||
|
|
||||||
|
building_id = dto.building_id if dto.building_id is not None else classroom.building_id
|
||||||
|
code = dto.code.strip() if dto.code is not None else classroom.code
|
||||||
|
floor = dto.floor.strip() if dto.floor is not None else classroom.floor
|
||||||
|
|
||||||
|
# Si cambia código, piso o edificio, validar colisión
|
||||||
|
if (building_id != classroom.building_id or code != classroom.code or floor != classroom.floor):
|
||||||
|
existing = self.repository.get_by_code_and_building(building_id, code, floor)
|
||||||
|
if existing and existing.id != classroom.id:
|
||||||
|
raise ValueError(f"Ya existe otra aula con el código '{code}' en el piso {floor}.")
|
||||||
|
|
||||||
|
if dto.building_id is not None:
|
||||||
|
building = Building.query.get(dto.building_id)
|
||||||
|
if building:
|
||||||
|
classroom.building_id = building.id
|
||||||
|
classroom.building = building.name
|
||||||
|
if dto.code is not None:
|
||||||
|
classroom.code = code
|
||||||
|
if dto.floor is not None:
|
||||||
|
classroom.floor = floor
|
||||||
|
if dto.capacity is not None:
|
||||||
|
classroom.capacity = dto.capacity
|
||||||
|
if dto.description is not None:
|
||||||
|
classroom.description = dto.description
|
||||||
|
if dto.is_active is not None:
|
||||||
|
classroom.is_active = dto.is_active
|
||||||
|
|
||||||
|
return self.repository.save(classroom)
|
||||||
|
|
||||||
|
def delete_classroom(self, classroom_id: int) -> bool:
|
||||||
|
"""Elimina o desactiva un aula verificando dependencias."""
|
||||||
|
classroom = self.repository.get_by_id(classroom_id)
|
||||||
|
if not classroom:
|
||||||
|
return False
|
||||||
|
# Si tiene reservas históricas, se desactiva lógicamente para preservar integridad
|
||||||
|
if classroom.reservations:
|
||||||
|
classroom.is_active = False
|
||||||
|
self.repository.save(classroom)
|
||||||
|
return True
|
||||||
|
return self.repository.delete(classroom)
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import jwt
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
from flask import current_app
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
# Lista negra de tokens revocados en memoria (jti -> timestamp de expiración)
|
||||||
|
_token_blacklist: Dict[str, datetime] = {}
|
||||||
|
|
||||||
|
class JWTService:
|
||||||
|
"""Servicio de emisión, verificación y revocación de tokens JWT (Dual Token Pattern)."""
|
||||||
|
|
||||||
|
ACCESS_TOKEN_EXPIRES = timedelta(days=1)
|
||||||
|
REFRESH_TOKEN_EXPIRES = timedelta(days=7)
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _clean_expired_blacklist(cls):
|
||||||
|
"""Limpia periódicamente identificadores de tokens que ya han expirado naturalmente."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
expired_jtis = [jti for jti, exp in _token_blacklist.items() if exp < now]
|
||||||
|
for jti in expired_jtis:
|
||||||
|
_token_blacklist.pop(jti, None)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_secret(cls) -> str:
|
||||||
|
"""Obtiene la clave secreta desde la configuración de Flask."""
|
||||||
|
return current_app.config.get('SECRET_KEY', 'default-edu-space-secret-key')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_access_token(cls, user: User, expires_delta: Optional[timedelta] = None) -> str:
|
||||||
|
"""Emite un token de acceso de corta duración (15 min) con claims de rol y permisos."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
delta = expires_delta or cls.ACCESS_TOKEN_EXPIRES
|
||||||
|
role_name = user.role_obj.name if user.role_obj else user.role
|
||||||
|
|
||||||
|
# Mapeo de permisos para claims del token
|
||||||
|
permissions = {}
|
||||||
|
if user.role_obj and hasattr(user.role_obj, 'permissions'):
|
||||||
|
for p in user.role_obj.permissions:
|
||||||
|
permissions[p.module] = p.access_level
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'sub': str(user.id),
|
||||||
|
'email': user.email,
|
||||||
|
'name': user.name,
|
||||||
|
'role': role_name,
|
||||||
|
'is_admin': user.is_admin(),
|
||||||
|
'permissions': permissions,
|
||||||
|
'type': 'access',
|
||||||
|
'jti': str(uuid.uuid4()),
|
||||||
|
'iat': now,
|
||||||
|
'exp': now + delta
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, cls._get_secret(), algorithm=cls.ALGORITHM)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_refresh_token(cls, user: User, expires_delta: Optional[timedelta] = None) -> str:
|
||||||
|
"""Emite un token de refresco de larga duración (7 días) para renovación silenciosa."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
delta = expires_delta or cls.REFRESH_TOKEN_EXPIRES
|
||||||
|
payload = {
|
||||||
|
'sub': str(user.id),
|
||||||
|
'type': 'refresh',
|
||||||
|
'jti': str(uuid.uuid4()),
|
||||||
|
'iat': now,
|
||||||
|
'exp': now + delta
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, cls._get_secret(), algorithm=cls.ALGORITHM)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def generate_tokens(cls, user: User) -> Dict[str, Any]:
|
||||||
|
"""Genera el par de tokens (Access + Refresh) junto con los metadatos del usuario."""
|
||||||
|
access_token = cls.create_access_token(user)
|
||||||
|
refresh_token = cls.create_refresh_token(user)
|
||||||
|
return {
|
||||||
|
'access_token': access_token,
|
||||||
|
'refresh_token': refresh_token,
|
||||||
|
'token_type': 'Bearer',
|
||||||
|
'expires_in': int(cls.ACCESS_TOKEN_EXPIRES.total_seconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def decode_token(cls, token: str, expected_type: str = 'access') -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Decodifica y valida la firma, expiración y tipo del token.
|
||||||
|
Lanza jwt.PyJWTError si el token es inválido, expirado o fue revocado.
|
||||||
|
"""
|
||||||
|
cls._clean_expired_blacklist()
|
||||||
|
payload = jwt.decode(token, cls._get_secret(), algorithms=[cls.ALGORITHM])
|
||||||
|
|
||||||
|
jti = payload.get('jti')
|
||||||
|
if jti and jti in _token_blacklist:
|
||||||
|
raise jwt.InvalidTokenError("El token ha sido revocado.")
|
||||||
|
|
||||||
|
token_type = payload.get('type')
|
||||||
|
if token_type != expected_type:
|
||||||
|
raise jwt.InvalidTokenError(f"Tipo de token inválido. Se esperaba '{expected_type}', recibido '{token_type}'.")
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def revoke_token(cls, token: str) -> bool:
|
||||||
|
"""Agrega el identificador del token a la lista negra hasta su fecha natural de expiración."""
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, cls._get_secret(), algorithms=[cls.ALGORITHM], options={"verify_exp": False})
|
||||||
|
jti = payload.get('jti')
|
||||||
|
exp_ts = payload.get('exp')
|
||||||
|
if jti and exp_ts:
|
||||||
|
exp_dt = datetime.fromtimestamp(exp_ts, tz=timezone.utc)
|
||||||
|
_token_blacklist[jti] = exp_dt
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
import threading
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from enum import Enum
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
from app.models.genetic_algorithm import (
|
||||||
|
ReservationOptimizer,
|
||||||
|
GeneticAlgorithm,
|
||||||
|
ReservationRequest,
|
||||||
|
Classroom
|
||||||
|
)
|
||||||
|
from app.models.subject import Commission
|
||||||
|
from app.schemas.optimizer_dto import SpringBootSyncPayloadDTO
|
||||||
|
|
||||||
|
|
||||||
|
class OptimizerJobStatus(str, Enum):
|
||||||
|
PENDING = "PENDING"
|
||||||
|
RUNNING = "RUNNING"
|
||||||
|
COMPLETED = "COMPLETED"
|
||||||
|
FAILED = "FAILED"
|
||||||
|
CANCELLED = "CANCELLED"
|
||||||
|
|
||||||
|
|
||||||
|
class OptimizerJob:
|
||||||
|
"""Representa un trabajo asíncrono en la cola del optimizador heurístico."""
|
||||||
|
|
||||||
|
def __init__(self, job_id: str, params: Dict[str, Any]):
|
||||||
|
self.job_id = job_id
|
||||||
|
self.status = OptimizerJobStatus.PENDING
|
||||||
|
self.progress = 0
|
||||||
|
self.generation = 0
|
||||||
|
self.total_generations = params.get("generations", 80)
|
||||||
|
self.fitness_score = 0.0
|
||||||
|
self.total_requests = 0
|
||||||
|
self.assigned_reservations = 0
|
||||||
|
self.reservations: List[Dict[str, Any]] = []
|
||||||
|
self.conflicts: List[Dict[str, Any]] = []
|
||||||
|
self.error: Optional[str] = None
|
||||||
|
self.created_at = datetime.utcnow()
|
||||||
|
self.started_at: Optional[datetime] = None
|
||||||
|
self.completed_at: Optional[datetime] = None
|
||||||
|
self.execution_time_ms: Optional[float] = None
|
||||||
|
self.params = params
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"job_id": self.job_id,
|
||||||
|
"status": self.status.value,
|
||||||
|
"progress": self.progress,
|
||||||
|
"generation": self.generation,
|
||||||
|
"total_generations": self.total_generations,
|
||||||
|
"fitness_score": round(self.fitness_score, 2),
|
||||||
|
"total_requests": self.total_requests,
|
||||||
|
"assigned_reservations": self.assigned_reservations,
|
||||||
|
"reservations": self.reservations,
|
||||||
|
"conflict_count": len(self.conflicts),
|
||||||
|
"conflicts": self.conflicts,
|
||||||
|
"error": self.error,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
"started_at": self.started_at.isoformat() if self.started_at else None,
|
||||||
|
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
|
||||||
|
"execution_time_ms": self.execution_time_ms,
|
||||||
|
"params": {k: v for k, v in self.params.items() if k not in ("app",)}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class OptimizerService:
|
||||||
|
"""
|
||||||
|
Servicio de alto rendimiento para el Optimizador Heurístico y Cola de Trabajos Asíncronos.
|
||||||
|
Soporta evaluación concurrente multi-hilo y sincronización interoperable con Spring Boot.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_jobs: Dict[str, OptimizerJob] = {}
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def submit_job(
|
||||||
|
cls,
|
||||||
|
commission_ids: List[int],
|
||||||
|
admin_user_id: int,
|
||||||
|
start_date: Optional[datetime] = None,
|
||||||
|
end_date: Optional[datetime] = None,
|
||||||
|
generations: int = 80,
|
||||||
|
population_size: int = 40,
|
||||||
|
workers: int = 4,
|
||||||
|
app = None
|
||||||
|
) -> OptimizerJob:
|
||||||
|
"""
|
||||||
|
Registra y despacha un trabajo de optimización en segundo plano (asíncrono).
|
||||||
|
"""
|
||||||
|
job_id = str(uuid.uuid4())
|
||||||
|
params = {
|
||||||
|
"commission_ids": commission_ids,
|
||||||
|
"admin_user_id": admin_user_id,
|
||||||
|
"start_date": start_date,
|
||||||
|
"end_date": end_date,
|
||||||
|
"generations": generations,
|
||||||
|
"population_size": population_size,
|
||||||
|
"workers": workers
|
||||||
|
}
|
||||||
|
|
||||||
|
job = OptimizerJob(job_id, params)
|
||||||
|
with cls._lock:
|
||||||
|
cls._jobs[job_id] = job
|
||||||
|
|
||||||
|
# Si no se pasó app explícitamente, intentar obtener current_app
|
||||||
|
if app is None:
|
||||||
|
try:
|
||||||
|
app = current_app._get_current_object()
|
||||||
|
except Exception:
|
||||||
|
app = None
|
||||||
|
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=cls._run_job_worker,
|
||||||
|
args=(job_id, app),
|
||||||
|
daemon=True,
|
||||||
|
name=f"optimizer-worker-{job_id[:8]}"
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
return job
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _run_job_worker(cls, job_id: str, app):
|
||||||
|
"""Worker en segundo plano para ejecutar el algoritmo genético."""
|
||||||
|
job = cls.get_job(job_id)
|
||||||
|
if not job:
|
||||||
|
return
|
||||||
|
|
||||||
|
def _execute():
|
||||||
|
t_start = time.perf_counter()
|
||||||
|
with cls._lock:
|
||||||
|
job.status = OptimizerJobStatus.RUNNING
|
||||||
|
job.started_at = datetime.utcnow()
|
||||||
|
|
||||||
|
def progress_callback(gen: int, total_gen: int, pct: int, best_fit: float):
|
||||||
|
with cls._lock:
|
||||||
|
job.generation = gen
|
||||||
|
job.total_generations = total_gen
|
||||||
|
job.progress = pct
|
||||||
|
job.fitness_score = best_fit
|
||||||
|
|
||||||
|
optimizer = ReservationOptimizer(
|
||||||
|
population_size=job.params.get("population_size", 40),
|
||||||
|
generations=job.params.get("generations", 80),
|
||||||
|
workers=job.params.get("workers", 4)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = optimizer.optimize_schedule(
|
||||||
|
commission_ids=job.params["commission_ids"],
|
||||||
|
admin_user_id=job.params["admin_user_id"],
|
||||||
|
start_date=job.params.get("start_date"),
|
||||||
|
end_date=job.params.get("end_date"),
|
||||||
|
progress_callback=progress_callback
|
||||||
|
)
|
||||||
|
|
||||||
|
t_end = time.perf_counter()
|
||||||
|
exec_time_ms = round((t_end - t_start) * 1000, 2)
|
||||||
|
|
||||||
|
with cls._lock:
|
||||||
|
job.status = OptimizerJobStatus.COMPLETED
|
||||||
|
job.progress = 100
|
||||||
|
job.fitness_score = result.get("fitness_score", 0.0)
|
||||||
|
job.total_requests = result.get("total_requests", 0)
|
||||||
|
job.assigned_reservations = result.get("assigned_reservations", 0)
|
||||||
|
job.reservations = result.get("reservations", [])
|
||||||
|
job.conflicts = result.get("conflicts", [])
|
||||||
|
job.completed_at = datetime.utcnow()
|
||||||
|
job.execution_time_ms = exec_time_ms
|
||||||
|
|
||||||
|
try:
|
||||||
|
if app:
|
||||||
|
with app.app_context():
|
||||||
|
_execute()
|
||||||
|
else:
|
||||||
|
_execute()
|
||||||
|
except Exception as ex:
|
||||||
|
with cls._lock:
|
||||||
|
job.status = OptimizerJobStatus.FAILED
|
||||||
|
job.error = str(ex)
|
||||||
|
job.completed_at = datetime.utcnow()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_job(cls, job_id: str) -> Optional[OptimizerJob]:
|
||||||
|
"""Obtiene un trabajo por su identificador único."""
|
||||||
|
with cls._lock:
|
||||||
|
return cls._jobs.get(job_id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def list_jobs(cls, limit: int = 50) -> List[Dict[str, Any]]:
|
||||||
|
"""Lista los trabajos más recientes ordenados por fecha de creación desc."""
|
||||||
|
with cls._lock:
|
||||||
|
sorted_jobs = sorted(cls._jobs.values(), key=lambda j: j.created_at, reverse=True)
|
||||||
|
return [j.to_dict() for j in sorted_jobs[:limit]]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def sync_with_spring_boot(
|
||||||
|
cls,
|
||||||
|
payload: SpringBootSyncPayloadDTO,
|
||||||
|
admin_user_id: int = 1
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Procesa una carga de optimización interoperable proveniente de microservicios Spring Boot.
|
||||||
|
Garantiza respuesta conforme al contrato JSON del microservicio.
|
||||||
|
"""
|
||||||
|
t_start = time.perf_counter()
|
||||||
|
|
||||||
|
# 1. Resolver aulas disponibles
|
||||||
|
if payload.classrooms and len(payload.classrooms) > 0:
|
||||||
|
classrooms = []
|
||||||
|
for c_dto in payload.classrooms:
|
||||||
|
room = Classroom(
|
||||||
|
room_number=c_dto.roomNumber,
|
||||||
|
capacity=c_dto.capacity,
|
||||||
|
is_virtual=c_dto.isVirtual,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
room.id = c_dto.classroomId
|
||||||
|
classrooms.append(room)
|
||||||
|
else:
|
||||||
|
classrooms = Classroom.query.filter_by(is_active=True).all()
|
||||||
|
|
||||||
|
# 2. Generar ReservationRequests desde el DTO de Spring Boot
|
||||||
|
requests: List[ReservationRequest] = []
|
||||||
|
for c in payload.commissions:
|
||||||
|
req = ReservationRequest(
|
||||||
|
commission_id=c.commissionId,
|
||||||
|
expected_attendees=c.expectedAttendees,
|
||||||
|
purpose=f"Spring Boot Class: {c.subjectCode} - {c.subjectName}",
|
||||||
|
preferred_start_time=c.preferredStart,
|
||||||
|
preferred_end_time=c.preferredEnd,
|
||||||
|
priority=1,
|
||||||
|
flexibility_hours=2
|
||||||
|
)
|
||||||
|
requests.append(req)
|
||||||
|
|
||||||
|
# 3. Opciones de configuración de algoritmo
|
||||||
|
options = payload.options or {}
|
||||||
|
generations = options.get("generations", 60)
|
||||||
|
pop_size = options.get("populationSize", 30)
|
||||||
|
workers = options.get("workers", 4)
|
||||||
|
|
||||||
|
ga = GeneticAlgorithm(
|
||||||
|
population_size=pop_size,
|
||||||
|
generations=generations,
|
||||||
|
workers=workers
|
||||||
|
)
|
||||||
|
|
||||||
|
start_date = min(c.preferredStart for c in payload.commissions)
|
||||||
|
end_date = max(c.preferredEnd for c in payload.commissions)
|
||||||
|
|
||||||
|
best_individual = ga.optimize_reservations(requests, classrooms, start_date, end_date)
|
||||||
|
|
||||||
|
t_end = time.perf_counter()
|
||||||
|
exec_ms = round((t_end - t_start) * 1000, 2)
|
||||||
|
|
||||||
|
scheduled_assignments = []
|
||||||
|
for gene in best_individual.genes:
|
||||||
|
scheduled_assignments.append({
|
||||||
|
"commissionId": gene.commission_id,
|
||||||
|
"classroomId": gene.classroom_id,
|
||||||
|
"startTime": gene.start_time.isoformat() if gene.start_time else None,
|
||||||
|
"endTime": gene.end_time.isoformat() if gene.end_time else None,
|
||||||
|
"purpose": gene.purpose,
|
||||||
|
"expectedAttendees": gene.expected_attendees,
|
||||||
|
"status": "SCHEDULED"
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"requestId": payload.requestId,
|
||||||
|
"status": "SUCCESS",
|
||||||
|
"academicTerm": payload.academicTerm,
|
||||||
|
"campusCode": payload.campusCode,
|
||||||
|
"totalCommissions": len(payload.commissions),
|
||||||
|
"scheduledAssignments": scheduled_assignments,
|
||||||
|
"conflictCount": len(best_individual.conflicts),
|
||||||
|
"fitnessScore": round(best_individual.fitness, 2),
|
||||||
|
"executionTimeMs": exec_ms,
|
||||||
|
"timestamp": datetime.utcnow().isoformat() + "Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
from app.models.reservation import Reservation, ReservationStatus
|
||||||
|
from app.models.classroom import Classroom
|
||||||
|
from app.repositories.reservation_repository import ReservationRepository
|
||||||
|
from app.repositories.classroom_repository import ClassroomRepository
|
||||||
|
from app.schemas.reservation_dto import ReservationCreateDTO, ReservationUpdateDTO
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
class ReservationService:
|
||||||
|
"""Capa de servicios para la orquestación y validación de reservas y cronograma."""
|
||||||
|
|
||||||
|
def __init__(self, reservation_repo: Optional[ReservationRepository] = None,
|
||||||
|
classroom_repo: Optional[ClassroomRepository] = None):
|
||||||
|
self.reservation_repo = reservation_repo or ReservationRepository()
|
||||||
|
self.classroom_repo = classroom_repo or ClassroomRepository()
|
||||||
|
|
||||||
|
def get_reservation(self, reservation_id: int) -> Optional[Reservation]:
|
||||||
|
"""Obtiene una reserva por su ID."""
|
||||||
|
return self.reservation_repo.get_by_id(reservation_id)
|
||||||
|
|
||||||
|
def list_reservations(self, start_dt: datetime, end_dt: datetime,
|
||||||
|
classroom_id: Optional[int] = None) -> List[Reservation]:
|
||||||
|
"""Obtiene reservas dentro de un rango temporal."""
|
||||||
|
return self.reservation_repo.get_by_date_range(start_dt, end_dt, classroom_id=classroom_id)
|
||||||
|
|
||||||
|
def check_conflicts(self, classroom_id: int, start_time: datetime, end_time: datetime,
|
||||||
|
exclude_id: Optional[int] = None) -> List[Reservation]:
|
||||||
|
"""Verifica si existen solapamientos en un aula específica."""
|
||||||
|
classroom = self.classroom_repo.get_by_id(classroom_id)
|
||||||
|
# Las aulas virtuales tienen concurrencia ilimitada (sin colisiones)
|
||||||
|
if classroom and classroom.is_virtual:
|
||||||
|
return []
|
||||||
|
return self.reservation_repo.find_conflicts(
|
||||||
|
classroom_id=classroom_id,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
exclude_reservation_id=exclude_id
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_reservation(self, dto: ReservationCreateDTO) -> Reservation:
|
||||||
|
"""
|
||||||
|
Crea una nueva reserva aplicando validaciones de aforo y detección de colisiones.
|
||||||
|
"""
|
||||||
|
classroom = self.classroom_repo.get_by_id(dto.classroom_id)
|
||||||
|
if not classroom or not classroom.is_active:
|
||||||
|
raise ValueError(f"El aula con ID {dto.classroom_id} no existe o no se encuentra activa.")
|
||||||
|
|
||||||
|
# Detección de colisiones para aulas físicas
|
||||||
|
if not classroom.is_virtual:
|
||||||
|
conflicts = self.check_conflicts(dto.classroom_id, dto.start_time, dto.end_time)
|
||||||
|
if conflicts:
|
||||||
|
conflict_details = ", ".join([f"#{c.id} ({c.start_time.strftime('%H:%M')} a {c.end_time.strftime('%H:%M')})" for c in conflicts])
|
||||||
|
raise ValueError(f"Conflicto de horario en {classroom.code}: se solapa con las reservas {conflict_details}.")
|
||||||
|
|
||||||
|
if dto.expected_attendees > classroom.capacity:
|
||||||
|
raise ValueError(f"La cantidad de alumnos ({dto.expected_attendees}) supera la capacidad del aula ({classroom.capacity}).")
|
||||||
|
|
||||||
|
reservation = Reservation(
|
||||||
|
classroom_id=dto.classroom_id,
|
||||||
|
commission_id=dto.commission_id,
|
||||||
|
user_id=dto.user_id,
|
||||||
|
start_time=dto.start_time,
|
||||||
|
end_time=dto.end_time,
|
||||||
|
purpose=dto.purpose,
|
||||||
|
expected_attendees=dto.expected_attendees,
|
||||||
|
shift=dto.shift,
|
||||||
|
virtual_link=dto.virtual_link,
|
||||||
|
notes=dto.notes,
|
||||||
|
status=dto.status or 'CONFIRMED'
|
||||||
|
)
|
||||||
|
return self.reservation_repo.save(reservation)
|
||||||
|
|
||||||
|
def update_reservation(self, reservation_id: int, dto: ReservationUpdateDTO) -> Reservation:
|
||||||
|
"""Actualiza una reserva existente verificando disponibilidad horaria."""
|
||||||
|
reservation = self.reservation_repo.get_by_id(reservation_id)
|
||||||
|
if not reservation:
|
||||||
|
raise ValueError(f"Reserva con ID {reservation_id} no encontrada.")
|
||||||
|
|
||||||
|
classroom_id = dto.classroom_id or reservation.classroom_id
|
||||||
|
start_time = dto.start_time or reservation.start_time
|
||||||
|
end_time = dto.end_time or reservation.end_time
|
||||||
|
|
||||||
|
classroom = self.classroom_repo.get_by_id(classroom_id)
|
||||||
|
if not classroom or not classroom.is_active:
|
||||||
|
raise ValueError(f"El aula con ID {classroom_id} no existe o no se encuentra activa.")
|
||||||
|
|
||||||
|
if not classroom.is_virtual:
|
||||||
|
conflicts = self.check_conflicts(classroom_id, start_time, end_time, exclude_id=reservation.id)
|
||||||
|
if conflicts:
|
||||||
|
conflict_details = ", ".join([f"#{c.id} ({c.start_time.strftime('%H:%M')} - {c.end_time.strftime('%H:%M')})" for c in conflicts])
|
||||||
|
raise ValueError(f"Conflicto de horario en {classroom.code} con: {conflict_details}.")
|
||||||
|
|
||||||
|
if dto.classroom_id is not None:
|
||||||
|
reservation.classroom_id = dto.classroom_id
|
||||||
|
if dto.start_time is not None:
|
||||||
|
reservation.start_time = dto.start_time
|
||||||
|
if dto.end_time is not None:
|
||||||
|
reservation.end_time = dto.end_time
|
||||||
|
if dto.purpose is not None:
|
||||||
|
reservation.purpose = dto.purpose
|
||||||
|
if dto.expected_attendees is not None:
|
||||||
|
reservation.expected_attendees = dto.expected_attendees
|
||||||
|
if dto.shift is not None:
|
||||||
|
reservation.shift = dto.shift
|
||||||
|
if dto.virtual_link is not None:
|
||||||
|
reservation.virtual_link = dto.virtual_link
|
||||||
|
if dto.notes is not None:
|
||||||
|
reservation.notes = dto.notes
|
||||||
|
if dto.status is not None:
|
||||||
|
reservation.status = dto.status
|
||||||
|
|
||||||
|
return self.reservation_repo.save(reservation)
|
||||||
|
|
||||||
|
def cancel_reservation(self, reservation_id: int) -> bool:
|
||||||
|
"""Cancela una reserva estableciendo su estado en CANCELLED."""
|
||||||
|
reservation = self.reservation_repo.get_by_id(reservation_id)
|
||||||
|
if not reservation:
|
||||||
|
return False
|
||||||
|
reservation.status = ReservationStatus.CANCELLED.value
|
||||||
|
self.reservation_repo.save(reservation)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def confirm_reservation(self, reservation_id: int) -> bool:
|
||||||
|
"""Confirma una reserva estableciendo su estado en CONFIRMED."""
|
||||||
|
reservation = self.reservation_repo.get_by_id(reservation_id)
|
||||||
|
if not reservation:
|
||||||
|
return False
|
||||||
|
reservation.status = ReservationStatus.CONFIRMED.value
|
||||||
|
self.reservation_repo.save(reservation)
|
||||||
|
return True
|
||||||