Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59fc2b9e45 |
@@ -0,0 +1,7 @@
|
|||||||
|
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
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|
||||||
# Entorno Flask
|
# Entorno Flask
|
||||||
FLASK_APP=wsgi.py
|
FLASK_APP=app.py
|
||||||
FLASK_ENV=production
|
FLASK_ENV=production
|
||||||
|
|
||||||
# Seguridad: Clave secreta para sesiones y protección CSRF
|
# Seguridad: Clave secreta para sesiones y protección CSRF
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
name: Edu-Space Automated Security Audit (Gitea Local)
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ main, develop ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ main, develop ]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
security-scan:
|
|
||||||
# Usamos la etiqueta estándar para contenedores Ubuntu en Gitea Runner
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
# 1. Clonar el código del repositorio local
|
|
||||||
- name: Checkout Code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
# 2. Preparar el entorno de paquetes del sistema (Linux)
|
|
||||||
- name: Install System Dependencies (Python & Node.js)
|
|
||||||
run: |
|
|
||||||
apt-get update && apt-get install -y python3 python3-pip python3-venv nodejs npm curl
|
|
||||||
python3 -m pip install --upgrade pip
|
|
||||||
|
|
||||||
# 3. Instalar librerías de Python y herramientas de seguridad
|
|
||||||
- name: Install Python Dependencies & Security Tools
|
|
||||||
run: |
|
|
||||||
pip3 install -r backend/requirements.txt
|
|
||||||
pip3 install -r backend/requirements-dev.txt
|
|
||||||
|
|
||||||
# 4. Instalar librerías de Node.js (Express BFF)
|
|
||||||
- name: Install Node.js Dependencies
|
|
||||||
run: |
|
|
||||||
cd frontend
|
|
||||||
npm ci
|
|
||||||
|
|
||||||
# --- EJECUCIÓN DE CONTROLES (SAST & SCA) ---
|
|
||||||
|
|
||||||
- name: [1/5] Python SAST (Bandit)
|
|
||||||
run: bandit -r ./backend/app -ll -ii
|
|
||||||
|
|
||||||
- name: [2/5] Python SCA (Pip-Audit)
|
|
||||||
run: pip-audit -s osv --progress-spinner off -r backend/requirements.txt
|
|
||||||
|
|
||||||
- name: [3/5] Node.js SAST (Njsscan)
|
|
||||||
run: njsscan ./frontend/src
|
|
||||||
|
|
||||||
- name: [4/5] Node.js SCA (Npm Audit)
|
|
||||||
run: |
|
|
||||||
cd frontend
|
|
||||||
npm audit --audit-level=high
|
|
||||||
|
|
||||||
# --- CONTROL DINÁMICO DAST (Schemathesis) ---
|
|
||||||
|
|
||||||
- name: [5/5] Start Flask App & Run DAST (Schemathesis)
|
|
||||||
env:
|
|
||||||
PYTHONUTF8: 1
|
|
||||||
FLASK_APP: backend/app # Ajustar a la ruta de inicio de tu Flask si varía
|
|
||||||
run: |
|
|
||||||
# Levantar Flask en segundo plano dentro del contenedor local
|
|
||||||
# Usamos 'python3 -m flask run' para asegurar el bindeo local
|
|
||||||
cd backend
|
|
||||||
python3 -m flask run --host=127.0.0.1 --port=5000 &
|
|
||||||
|
|
||||||
# Esperar 5 segundos a que la API responda
|
|
||||||
echo "Esperando a que el backend de Flask inicie en el entorno local..."
|
|
||||||
sleep 5
|
|
||||||
|
|
||||||
# Verificar con un curl rápido si el JSON de OpenAPI está disponible
|
|
||||||
curl -s http://127.0.0.1:5000/api/v1/openapi.json > /dev/null
|
|
||||||
|
|
||||||
# Ejecutar Schemathesis contra la instancia local efímera
|
|
||||||
schemathesis run http://127.0.0.1:5000/api/v1/openapi.json --checks not_a_server_error --max-examples=10
|
|
||||||
@@ -57,9 +57,6 @@ cover/
|
|||||||
*.mo
|
*.mo
|
||||||
*.pot
|
*.pot
|
||||||
|
|
||||||
# Examples
|
|
||||||
example/
|
|
||||||
|
|
||||||
# Django stuff:
|
# Django stuff:
|
||||||
*.log
|
*.log
|
||||||
local_settings.py
|
local_settings.py
|
||||||
@@ -210,18 +207,3 @@ cython_debug/
|
|||||||
marimo/_static/
|
marimo/_static/
|
||||||
marimo/_lsp/
|
marimo/_lsp/
|
||||||
__marimo__/
|
__marimo__/
|
||||||
|
|
||||||
# Node.js / Frontend
|
|
||||||
node_modules/
|
|
||||||
npm-debug.log
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
frontend/.env
|
|
||||||
|
|
||||||
legacy_admin-edu-space/
|
|
||||||
.vscode/
|
|
||||||
.schemathesis/
|
|
||||||
|
|
||||||
AlumnosLS
|
|
||||||
prerrequisitos
|
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
# Changelog Oficial — Admin Edu-Space (UniCABA)
|
|
||||||
|
|
||||||
Todas las modificaciones notables a este proyecto serán documentadas en este archivo.
|
|
||||||
El formato está basado en [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/) y este proyecto se adhiere a [Semantic Versioning](https://semver.org/lang/es/).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [Unreleased]
|
|
||||||
### Planificado (Fase 6 del Roadmap MVP)
|
|
||||||
- **Despliegue e Infraestructura (Fase 6):** `docker-compose.yml` productivo orquestando Flask (Gunicorn), Node.js BFF (PM2) y PostgreSQL 15, con proxy inverso Nginx, SSL/TLS y compresión.
|
|
||||||
- **Sincronización Continua Moodle (Fase 6):** Automatización de workers persistentes en background para el procesamiento continuo de la cola de Web Services Moodle 4.1.
|
|
||||||
- **Pruebas de Carga y Hardening (Fase 6):** Pruebas de estrés de concurrencia y validación final de producción.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [3.2.0] - 2026-09-23
|
|
||||||
### Añadido
|
|
||||||
- **Función de Reinicialización Total del Sistema (`POST /api/v1/admin/system/reset`):**
|
|
||||||
- **Módulo de Mantenimiento en Configuración Global (`/admin/settings`):** Nueva pestaña "Mantenimiento & Reinicio" con tarjeta de "Zona de Peligro" y advertencia sobre el alcance de la purga.
|
|
||||||
- **Requisito Estricto de Seguridad Institucional:** Exige obligatoriamente la validación de la **contraseña del usuario Administrador en sesión** antes de ejecutar la acción; retorna 401 y aborta ante claves incorrectas registrando el evento `SYSTEM_RESET_FAILED`.
|
|
||||||
- **Purga Referencial Completa:** Vacía de forma segura y en orden estricto de claves foráneas: historiales previos (`AuditLog`), reservas (`Reservation`), hitos evaluativos (`AcademicMilestone`), notas (`MilestoneGrade`), matrículas (`StudentEnrollment`), asignaciones docentes (`CommissionTeacher`), comisiones (`Commission`), materias (`Subject`), carreras (`Career`), recursos de aula (`ClassroomResource`), aulas (`Classroom`), sedes/edificios (`Building`), ciclos lectivos (`AcademicTerm`) y colas de sincronización Moodle (`MoodleSyncTask`).
|
|
||||||
- **Preservación Estricta de Administración y Cuentas:** Mantiene 100% intactas todas las cuentas de usuario (`User` con hashes de contraseñas), roles RBAC (`Role`), permisos (`Permission`), tipos de hitos (`MilestoneType`), configuraciones globales (`SystemSetting`) y todos los módulos de la pestaña Administración.
|
|
||||||
- **Trazabilidad Inmutable:** Genera automáticamente un nuevo registro en `AuditLog` con la acción `SYSTEM_RESET`, asociando el correo del administrador ejecutor, su IP y el recuento de entidades purgadas.
|
|
||||||
- **Interfaz de Usuario y Modal Interactivo:** Diálogo modal `#modalResetSystem` con campo de contraseña enmascarada, switch de visibilidad, casilla de confirmación explícita y botón de confirmación con estado de carga (spinner).
|
|
||||||
|
|
||||||
### Corregido
|
|
||||||
- **Blindaje de Espacios y Sedes Vacíos (Presenciales y Virtuales):**
|
|
||||||
- **Prevención de Excepciones TypeError:** Se corrigieron accesos inseguros a propiedades nulas o no definidas (`c.code.includes`, `r.start_time.startsWith`, `localeCompare`) en `frontend/src/routes/classrooms.js`, permitiendo navegar a `/classrooms/list_classrooms?view_mode=physical` y `/classrooms/list_classrooms?view_mode=virtual` sin caídas ante bases vacías.
|
|
||||||
- **Corrección de Evaluación de Longitud en Nunjucks:** Actualizadas las directivas condicionales en `frontend/views/classrooms/list.html` a `classrooms.items|length > 0` y `virtual_rooms|length > 0`, garantizando el despliegue de los *Empty States* institucionales con botones de alta cuando no existen aulas registradas.
|
|
||||||
- **Mapeo Dinámico de Sedes:** `handleAddClassroom` y `handleClassroomsList` ahora sincronizan con el catálogo `/buildings`, ofreciendo la creación guiada de sedes y vinculación dinámica en el alta de aulas físicas.
|
|
||||||
- **Blindaje del Fallback Offline:** En caso de fallas o demoras en la API, el manejador de captura provee valores predeterminados seguros para `global_virtual_metrics` y `virtual_rooms`, impidiendo errores 500 no controlados.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [3.1.0] - 2026-09-23
|
|
||||||
### Añadido
|
|
||||||
- **ABM Integral de Ciclos Lectivos (`AcademicTerm`):**
|
|
||||||
- **Gestión End-to-End de Períodos Académicos:** Implementación de ciclo de vida completo para ciclos lectivos: Alta, Edición, Establecimiento de Ciclo Actual, Toggle de Habilitación rápida y Eliminación protegida (`DELETE /api/v1/admin/academic-terms/<id>` y `POST /api/v1/admin/academic-terms/<id>/toggle`).
|
|
||||||
- **Integridad Relacional Protegida:** Bloqueo de eliminación si el ciclo es el activo corriente, y fallback seguro a desactivación (`is_active = False`) si el ciclo tiene comisiones históricas asociadas.
|
|
||||||
- **Interfaz Enriquecida en Bedelía / Admin:** Botones de acción contextuales, switches y modales interactivos de confirmación (`#modalDeleteTerm`) en `frontend/views/admin/academic_terms/list.html`.
|
|
||||||
- **ABM Completo de Espacios y Aulas (`Classroom`):**
|
|
||||||
- **Control de Estado y Borrado Seguro:** Incorporación de toggle activo/inactivo (`POST /api/v1/classrooms/<id>/toggle`) y endpoint de baja de aulas integrado con el frontend BFF (`/classrooms/toggle_classroom` y `/classrooms/delete_classroom`).
|
|
||||||
- **Acciones Rápidas en Vista Lista y Ficha:** Botones de edición, alternancia de estado y eliminación con modales de advertencia en las vistas de cuadrícula/tabla (`list.html`) y detalle (`view.html`).
|
|
||||||
- **ABM Completo de Reservas (`Reservation`):**
|
|
||||||
- **Eliminación Definitiva (Hard Delete):** Soporte en backend para purga física de reservas (`DELETE /api/v1/reservations/<id>?hard=true` y `POST /api/v1/reservations/<id>/delete`) complementando el flujo de cancelación lógica (`CANCELLED`).
|
|
||||||
- **Control Operativo en Vista Detalle:** Botón de "Eliminar Reserva Definitivamente" con diálogo modal de confirmación en `/schedule/view.html`.
|
|
||||||
- **Auditoría y Cobertura 100% de ABMs en la Plataforma:**
|
|
||||||
- Verificación y garantía de operaciones ABM completas en todos los componentes centrales: **Sedes/Edificios** (`Building`), **Aulas** (`Classroom`), **Ciclos Lectivos** (`AcademicTerm`), **Carreras** (`Career`), **Materias** (`Subject`), **Comisiones** (`Commission`) y **Reservas** (`Reservation`).
|
|
||||||
- **Circuito Integral y Simplificado de Bedelía:**
|
|
||||||
- **Flujo Operativo Ágil para Bedelía:**
|
|
||||||
- Circuito intuitivo de 3 pasos: Catálogo de Aulas (`/classrooms/list_classrooms`) -> Creación/Edición con validación física/virtual y aforo real -> Asignación de Comisiones (`/schedule/add_reservation`).
|
|
||||||
- Botón de acción rápida `Asignar Aula` (`bi-calendar-plus`) en cada fila de `/admin/commissions_list`, permitiendo asignar espacio y horario a cualquier comisión en 1 solo clic.
|
|
||||||
- Sincronización bidireccional de parámetros por URL (`?commission_id=...` y `?classroom_id=...`) precargando automáticamente en el formulario de reserva la materia, código, cupo máximo de alumnos y enlace virtual de Google Meet / Teams.
|
|
||||||
- Selectores inteligentes en cascada en `/schedule/add.html` con autoselección de Edificio, Piso y Aula al navegar desde el catálogo de espacios.
|
|
||||||
- **Conexión REST en Vivo para Reservas:**
|
|
||||||
- Sustitución de listas fijas/mockeadas en `frontend/src/routes/schedule.js` por consulta dinámica a `/commissions` en vivo, permitiendo reservar aulas para cualquiera de las comisiones reales de la plataforma.
|
|
||||||
- **Soporte Multisede Consistente en Aulas:**
|
|
||||||
- Resolución robusta de `building_id` y flag `is_virtual` en `frontend/src/routes/classrooms.js`, eliminando hardcodes y asegurando que las aulas físicas pertenezcan a la sede seleccionada.
|
|
||||||
- **Inicialización de Base de Datos Limpia sin Datos Demo (`--empty-academic` / `--no-demo`):**
|
|
||||||
- **Soporte en `install.sh` y Menú Interactivo:** Incorporación de la opción `sudo bash install.sh --empty-academic` (o `--no-demo`) y opción `[3]` en el menú interactivo para crear la base de datos limpia desde cero, omitiendo la inyección de snapshots con carreras, materias, comisiones, aulas o reservas heredadas.
|
|
||||||
- **Inicializador de Esquema `init_db.py`:** Nuevo flag `--empty-academic` que genera el esquema de tablas completo, los roles y permisos RBAC, los usuarios institucionales y las tipificaciones de hitos, purgando y omitiendo cualquier edificio, aula física o virtual, carrera, materia, comisión o reserva demo por defecto.
|
|
||||||
- **Personalización de Interfaz y Saludo en Dashboard (Configuración Global):**
|
|
||||||
- **Nueva Pestaña de Interfaz & Dashboard (`/admin/settings`):** Módulo en el panel de configuración global para personalizar elementos visuales del sistema.
|
|
||||||
- **Desactivación Opcional del Saludo y Selector de Rol:** Switch interactivo para habilitar o deshabilitar el banner superior `¡Hola, {Usuario}!` y el selector de perspectiva de roles en `dashboard.html`. Al deshabilitarlo, el usuario accede directamente a los indicadores y accesos operativos de su rol sin distracciones ni dudas de contexto.
|
|
||||||
- **Persistencia y Endpoint REST:** Parámetro `dashboard_role_banner_enabled` administrado vía `POST /api/v1/admin/settings/ui`, respaldado por `SystemSetting` y registrado en `AuditLog`.
|
|
||||||
|
|
||||||
### Corregido
|
|
||||||
- **Motor de Purga y Reinicio de Datos Académicos:**
|
|
||||||
- **Solución al botón inerte de Purga:** En `/admin/google_sheets_import`, el botón *"Ejecutar Purga de Datos Seleccionados"* llamaba a `openPurgeModal()`, el cual no ejecutaba ninguna acción por ausencia del modal `#purgeConfirmModal` en el DOM. Se incorporó el modal interactivo completo con desglose dinámico de elementos marcados para borrado, advertencia de preservación de usuarios y estado de carga (spinner).
|
|
||||||
- **Integridad Referencial en Purga Backend:** En `backend/scripts/reset_academic_data.py`, se reordenó la secuencia de borrado para respetar las restricciones de clave foránea (FK): `Reservation` -> `MilestoneGrade` / `AcademicMilestone` -> `CommissionTeacher` -> `StudentEnrollment` -> `Commission` -> `Subject` -> `Career` -> `ClassroomResource` / `Classroom`. Esto previene fallos silenciosos por FK y garantiza que las carreras, asignaturas y comisiones se eliminen de manera limpia cuando son seleccionadas.
|
|
||||||
- **Preservación de Usuarios y Roles:** La purga garantiza que ningún usuario (`User`), contraseña administrativa ni rol (`Role`) sea alterado o eliminado durante el reseteo académico.
|
|
||||||
- **Saneamiento de Métricas y Edición de Edificios:**
|
|
||||||
- Corrección de métricas de edificios en `/buildings/list_buildings`: la capacidad virtual (9999) ya no distorsiona el cómputo de bancos físicos ni los percentiles de ocupación institucional.
|
|
||||||
- Formulario de edición de sedes (`/buildings/edit/:id`) corregido y persistido adecuadamente.
|
|
||||||
- **Instalador `install.sh`:**
|
|
||||||
- Inclusión de banderas `--keep-db` y `--keep-users` para evitar sobreescritura de contraseñas y usuarios en reinstalaciones o actualizaciones.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [3.0.0] - 2026-09-23
|
|
||||||
### Añadido
|
|
||||||
- **Fase 5 — Integración Auth, Email & Moodle Backend:**
|
|
||||||
- **Épica 1: Panel de Configuración Global (Perfil ADMIN):**
|
|
||||||
- Cifrado simétrico de credenciales y secretos con Fernet (`CryptoService` en `backend/app/services/crypto_service.py`).
|
|
||||||
- Migración y extensión de `SystemSetting` con columnas `category` e `is_encrypted`, junto con métodos polimórficos `get_decrypted_value()` y `get_masked_value()`.
|
|
||||||
- Endpoints REST `/api/v1/admin/settings/*` para administración dinámica de parámetros SMTP, toggles de proveedores SSO, Google OAuth y Web Services de Moodle 4.1.
|
|
||||||
- Nueva vista responsiva `frontend/views/admin/settings/global_config.html` con soporte optimizado para pantallas 720p, 1360x768 y 1080p, y enlace integrado en el menú de navegación lateral.
|
|
||||||
- **Épica 2: Infraestructura Asíncrona y Caché:**
|
|
||||||
- Servicio híbrido de caché `CacheService` con integración Redis y fallback en memoria/TTL.
|
|
||||||
- Modelo `MoodleSyncTask` y procesador asíncrono `MoodleQueueService` con Exponential Backoff y Dead Letter Queue (DLQ).
|
|
||||||
- Métricas en vivo y reintentos manuales individuales y masivos para tareas en DLQ.
|
|
||||||
- **Épica 3: Autenticación Híbrida (SSO & Local):**
|
|
||||||
- Login nativo como fallback con contingencia para cuentas con rol ADMIN.
|
|
||||||
- Integración de Google OAuth 2.0 basada en la arquitectura de AlumnosLS con filtrado estricto por dominios institucionales (`google_allowed_domains`).
|
|
||||||
- Autenticación delegada contra Moodle 4.1 vía `/login/token.php`, mapeo inicial de roles (Admin/Docente/Alumno) y gestión de roles 100% desacoplada en Edu-Space.
|
|
||||||
- **Épica 4: Integración Bidireccional Moodle 4.1:**
|
|
||||||
- Adaptador `MoodleClient` con Web Services de Moodle 4.1 (`core_user_*`, `enrol_manual_*`, `core_enrol_*`, `core_role_*`).
|
|
||||||
- Encolado no bloqueante de sincronización al crear o editar usuarios en Edu-Space.
|
|
||||||
- **Épica 5: Motor de Notificaciones (Email Server):**
|
|
||||||
- Cliente SMTP dinámico en `EmailService` con desencriptación en caliente de contraseñas.
|
|
||||||
- Plantillas HTML transaccionales para asignación de comisiones a profesores, citación a exámenes y pruebas en vivo de conectividad.
|
|
||||||
- **Pruebas y Verificación:**
|
|
||||||
- Suite de integración automatizada `backend/tests/test_phase6_integration.py` con 7 pruebas aprobadas al 100%. Regresión completa de 30 pruebas en verde en los módulos core.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [2.8.0] - 2026-09-23
|
|
||||||
### Añadido
|
|
||||||
- **Fase 4 del Roadmap MVP — UI/UX Avanzada para Bedelía & Modo Impersonación:**
|
|
||||||
- **Estadio 4.1 — Grilla Semanal Interactiva (Drag-and-Drop) con Detección de Colisiones:**
|
|
||||||
- Activación de arrastre y edición interactiva en FullCalendar (`editable: true`, `droppable: true`, `eventDurationEditable: true`).
|
|
||||||
- Panel lateral integrado de comisiones sin aula asignada (`unassigned_commissions`) con arrastre fluido hacia la grilla semanal o diaria.
|
|
||||||
- Endpoint REST `POST /api/v1/reservations/drag-update` con validación previa de la matriz integral de conflictos (`ReservationService.check_full_conflicts_matrix`): colisión física de aula, colisión de horario docente y aforo de estudiantes.
|
|
||||||
- Si existe colisión, retorna HTTP 409 (`status: conflict`), revierte automáticamente la posición del evento en la grilla (`info.revert()`) y notifica al usuario con un Toast detallado del motivo del choque.
|
|
||||||
- Si el espacio está disponible, actualiza o crea la reserva en base de datos, asienta la acción en `AuditLog` y muestra confirmación visual de éxito.
|
|
||||||
- **Estadio 4.2 — Modo Impersonación del Superadmin:**
|
|
||||||
- Soporte completo de cabecera `X-Impersonate-User` en el decorador JWT del Backend Flask (`jwt_required`) para administradores.
|
|
||||||
- Endpoints administrativos `POST /api/v1/admin/impersonate` y `POST /api/v1/admin/stop-impersonating` con registro estricto en `AuditLog` bajo las acciones `IMPERSONATE_START` e `IMPERSONATE_END`.
|
|
||||||
- Inyección contextual de impersonación en el Frontend BFF (`authMiddleware` y proxy transparente `/api`) mediante cookies seguras `impersonate_user_id` e `impersonate_user_data`.
|
|
||||||
- Banner de advertencia visual permanente en la barra superior (`base.html`) indicando el usuario y rol impersonado, con botón de salida inmediata hacia la sesión del administrador.
|
|
||||||
- Botón de acceso directo para impersonar (`bi-incognito`) en cada fila de usuario activo en `/admin/users_list`.
|
|
||||||
- **Estadio 4.3 — Acople del Optimizador Genético a Datos y Turnos Reales:**
|
|
||||||
- Algoritmo genético (`ReservationOptimizer`) adaptado a las franjas horarias y turnos institucionales reales de UniCABA (`Mañana` 08:00-12:00, `Tarde` 14:00-18:00, `Vespertino`/`Noche` 18:30-22:30).
|
|
||||||
- Soporte de guardado masivo en base de datos (`POST /api/genetic/apply-optimization` y `apply_optimized_reservations`) con prevención de solapamientos físicos y conversión estricta de fechas ISO.
|
|
||||||
- **Suite de Pruebas Unitarias de Fase 4:**
|
|
||||||
- Archivo `backend/tests/test_phase4_interactive_impersonation.py` con 6 tests unitarios cubriendo los criterios de aceptación: impersonación como docente y estudiante, trazabilidad en auditoría, rechazo de impersonación para usuarios no administradores, finalización de impersonación, drag-update con bloqueo por colisión 409 y drag-update exitoso 200.
|
|
||||||
- Regresión global de 18 tests (Fases 2, 3 y 4) aprobada al 100% (18/18 OK).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [2.7.0] - 2026-09-23
|
|
||||||
### Añadido
|
|
||||||
- **Fase 3 del Roadmap MVP — Gestión de Hitos Evaluativos y Libro de Calificaciones (*Gradebook*):**
|
|
||||||
- **Estadio 3.1 — Calendario de Evaluación y Notificaciones Preventivas:**
|
|
||||||
- Modelo relacional `MilestoneGrade` (`milestone_grades`) asociado a estudiantes (`User`) y reservas de tipo hito (`Reservation`).
|
|
||||||
- Servicio `GradebookService.get_student_upcoming_milestones`: consulta inteligente de exámenes parciales, recuperatorios y entregas de TP de las comisiones en las que el alumno está matriculado.
|
|
||||||
- Endpoint REST `GET /api/v1/students/my-upcoming-milestones` con cálculo de días restantes (`days_remaining`).
|
|
||||||
- Cartelera y Widget de Próximos Exámenes en el Dashboard del Alumno (`/dashboard?role=alumno`) con badges dinámicos de cuenta regresiva (*¡Hoy!*, *¡Mañana!*, *Faltan X días*).
|
|
||||||
- **Estadio 3.2 — Libro de Calificaciones (*Gradebook*) y Cierre de Actas:**
|
|
||||||
- Extensiones al modelo `Commission`: campos `grades_closed` (booleano), `grades_closed_at` (timestamp), `grades_closed_by` (ID usuario) y `acta_number` (código oficial de acta).
|
|
||||||
- Servicio académico `GradebookService` (`backend/app/services/gradebook_service.py`):
|
|
||||||
- `get_commission_gradebook`: matriz bidimensional consolidada de alumnos inscriptos y notas por hito evaluativo.
|
|
||||||
- `save_single_grade` y `bulk_save_grades`: registro y actualización de calificaciones con validación estricta de escala (1.0 a 10.0), flag de ausente (`is_absent`) y soporte de retroalimentación pedagógica (`feedback`).
|
|
||||||
- Bloqueo por inmutabilidad: rechazo de modificaciones cuando el acta se encuentra cerrada.
|
|
||||||
- `close_gradebook`: sellado oficial del acta con generación de identificador inmutable (`ACTA-YYYY-Sem-ID`), registro de auditoría en `AuditLog` y congelamiento de calificaciones.
|
|
||||||
- `reopen_gradebook`: reapertura excepcional para Bedelía/Admin con justificación administrativa obligatoria y registro en auditoría.
|
|
||||||
- `get_student_grades`: consolidado para el alumno de todas sus materias cursadas, notas por evaluación, promedio general y condición académica final (*Promocionado*, *Regular*, *Libre*).
|
|
||||||
- Endpoints REST de Calificaciones:
|
|
||||||
- `GET /api/v1/commissions/<id>/gradebook`: estructura completa para la matriz del libro de calificaciones.
|
|
||||||
- `PUT /api/v1/commissions/<id>/gradebook/grades`: guardado masivo o individual de notas vía JSON.
|
|
||||||
- `POST /api/v1/commissions/<id>/gradebook/close`: cierre formal de acta.
|
|
||||||
- `POST /api/v1/commissions/<id>/gradebook/reopen`: reapertura justificada de acta.
|
|
||||||
- `GET /api/v1/students/my-grades`: consulta individual de notas y regularidad del estudiante.
|
|
||||||
- Interfaz de Usuario y Vistas Frontend BFF:
|
|
||||||
- Vista `/schedule/gradebook/:id`: matriz interactiva con cálculo de promedio en tiempo real en el DOM, asignación de condición en vivo, autoguardado asíncrono debounced (500ms) con indicador flotante de estado (*Guardando...*, *✓ Todos los cambios guardados*), filtro de búsqueda instantánea por alumno/legajo, modal de cierre formal de acta y modal de reapertura.
|
|
||||||
- Vista `/schedule/my_grades` (`/mis-materias/mis-notas`): portal del estudiante para consultar calificaciones por hito, promedios finales, condición de cursada y actas emitidas, con soporte de impresión de certificado de cursada.
|
|
||||||
- Acceso directo integrado al Libro de Calificaciones en la tarjeta de comisión de `/admin/commission_detail`.
|
|
||||||
- Acceso directo a "Mis Calificaciones" en el launchpad de navegación del alumno en `/dashboard`.
|
|
||||||
- Script de migración de base de datos SQLite `backend/migrate_phase3_fields.py`.
|
|
||||||
- **Suite de Pruebas Unitarias de Fase 3:**
|
|
||||||
- Archivo `backend/tests/test_phase3_gradebook.py` con 6 tests unitarios cubriendo los criterios de aceptación de Fase 3: estructura de matriz, autoguardado de notas, validaciones de rango numérico, cierre formal de acta e inmutabilidad, consulta individual de notas y cuenta regresiva de próximos exámenes. 100% de tests aprobados (12/12 en suite global Fase 2 + Fase 3).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [2.6.1] - 2026-09-23
|
|
||||||
### Modificado
|
|
||||||
- **Optimización de Espacio Vertical y Compatibilidad de Pantallas (720p / 1360x768 y 1080p):**
|
|
||||||
- **Navbar Compacta:** Reducción de altura a 46px eliminando desperdicio de espacio superior para maximizar el área visible de trabajo de Bedelía.
|
|
||||||
- **Botón de Toggle de Sidebar:** Reingeniería visual del botón de alternancia del menú lateral con diseño circular limpio, micro-animación en hover y centrado perfecto.
|
|
||||||
- **Soporte Multipantalla:** Media queries CSS especializadas para resoluciones compactas habituales en puestos de trabajo de administración y bedelía argentina (1360x768 / 1366x768) y escalado equilibrado a 1920x1080 Full HD.
|
|
||||||
- **Densidad de Información:** Espaciados y márgenes optimizados para evitar scrolling innecesario en formularios y catálogos de comisiones y aulas.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [2.6.0] - 2026-09-19
|
|
||||||
### Añadido
|
|
||||||
- **Fase 2 del Roadmap MVP — Co-docencia, Matriz de Conflictos y Regla Diaria:**
|
|
||||||
- **Estadio 2.1 — Modelo y Gestión Integral de Co-docencia:**
|
|
||||||
- Modelo relacional `CommissionTeacher` (`commission_teachers`) con roles de cátedra (`Titular`, `Adjunto`, `JTP`, `Ayudante`) y flags de docente principal.
|
|
||||||
- Endpoints REST de cátedra: `POST /api/v1/commissions/<id>/teachers` y `DELETE /api/v1/commissions/<id>/teachers/<user_id>` con control de duplicados y asignación dinámica.
|
|
||||||
- Integración visual en el detalle de la comisión (`/admin/commission_detail`) con tarjeta interactiva para vincular/desvincular integrantes del equipo docente y badges de rol en la grilla general (`/admin/commissions_list`).
|
|
||||||
- **Estadio 2.2 — Matriz de Conflictos y Validación Integral:**
|
|
||||||
- **Conflicto Físico Estricto:** Bloqueo estricto de superposición horaria en la misma aula física (`ReservationRepository.find_overlapping` & `check_physical_conflicts`).
|
|
||||||
- **Conflicto Docente Inteligente:** Detección de doble reserva simultánea para un mismo profesor (`ReservationRepository.find_teacher_conflicts` & `check_teacher_conflicts`).
|
|
||||||
- **Excepción Justificada por Co-docencia:** Si un docente asignado presenta solapamiento pero la comisión cuenta con co-docentes registrados en `CommissionTeacher`, el motor de conflictos autoriza la reserva validando la cobertura del equipo de cátedra.
|
|
||||||
- **Conflicto de Aforo:** Bloqueo estricto cuando la cantidad de asistentes esperados (`expected_attendees`) excede la capacidad física declarada del aula (`classroom.capacity`).
|
|
||||||
- Endpoint unificado `POST /api/v1/reservations/check-conflicts` para pre-validación asíncrona de conflictos físicos, docentes y de capacidad antes del guardado.
|
|
||||||
- **Estadio 2.3 — Regla de Restricción Diaria del Alumno y Motor de Excepciones:**
|
|
||||||
- Servicio académico `EnrollmentService` (`backend/app/services/enrollment_service.py`) para control de cupos, inscripciones activas y análisis de días de cursada.
|
|
||||||
- **Regla Diaria:** Un alumno no puede matricularse en dos materias regulares programadas para el mismo día de la semana.
|
|
||||||
- **Excepción Automática por Curso Corto:** Indicador `is_short_course` en modelo `Subject`; si una de las asignaturas es un taller, seminario o curso corto, el sistema autoriza automáticamente la cursada simultánea en el mismo día.
|
|
||||||
- **Excepción Expresa de Bedelía:** Parámetros `allow_same_day_exception` y `exception_reason` en el modelo `StudentEnrollment`, permitiendo a Bedelía inscribir alumnos con justificación administrativa expresa (cambio de plan, equivalencias, etc.).
|
|
||||||
- Endpoints de gestión de matrículas por comisión: `GET /api/v1/commissions/<id>/enrollments`, `POST /api/v1/commissions/<id>/enrollments`, `DELETE /api/v1/commissions/<id>/enrollments/<student_id>` y `PUT /api/v1/commissions/<id>/enrollments/<student_id>`.
|
|
||||||
- Adaptación completa de UI en `/admin/commission_detail` con selector de estudiantes, toggle interactivo de excepción de Bedelía, campo de motivo justificado, y tabla de estudiantes matriculados con badges de excepción.
|
|
||||||
- **Suite de Pruebas Unitarias e Integrales de Reglas de Negocio:**
|
|
||||||
- Archivo `backend/tests/test_phase2_rules.py` con 6 tests completos verificando: bloqueo físico, conflicto de aforo, detección docente con bypass de co-docencia, restricción diaria de cursada, excepción por curso corto y excepción por autorización manual de Bedelía. 100% de tests aprobados.
|
|
||||||
- **Mejoras UI/UX en Dashboard de Bedelía:**
|
|
||||||
- Paneles interactivos para visualizar **Comisiones Sin Reserva de Aula** y **Aulas Disponibles** con soporte de filtrado temporal (Semana/Mes) directamente consumiendo estadísticas en tiempo real del motor backend.
|
|
||||||
- **Fase 1: Auditoría y Seguridad Automatizada:**
|
|
||||||
- **Control de Entorno Dev:** Aislamiento de herramientas de seguridad en `requirements-dev.txt`.
|
|
||||||
- **Automatización Local e Integración Continua:** Correcciones de evaluación en `security_audit.bat` para Windows y actualización de `.gitea/workflows/security-audit.yaml` para asegurar la correcta ejecución del stack de auditoría (Bandit, pip-audit, njsscan y Schemathesis).
|
|
||||||
## [2.5.0] - 2026-09-19
|
|
||||||
### Añadido
|
|
||||||
- **Auditoría y Seguridad Automatizada Completa (Python & Node.js):**
|
|
||||||
- **Python SAST (`bandit`):** Escaneo estático del backend Flask de más de 8.400 líneas. Corrección de vulnerabilidad B310 en `sheets_importer.py` mediante validación estricta de esquema HTTP/HTTPS para prevenir ataques de tipo SSRF. Resultado: 0 fallos detectados.
|
|
||||||
- **Python SCA (`pip-audit`):** Análisis de dependencias con base OSV; detección de 24 CVEs históricas en paquetes desactualizados y actualización completa a versiones seguras (`Flask>=3.1.3`, `Werkzeug>=3.1.8`, `requests>=2.34.2`, `bleach>=6.4.0`, `python-dotenv>=1.2.3`). Resultado: 0 CVEs activas.
|
|
||||||
- **Python DAST / Fuzzing (`schemathesis`):** Especificación OpenAPI 3.0.3 en `backend/app/routes/api/openapi.py` (`/api/v1/openapi.json`). Ejecución de 114 casos de prueba dinámicos con mutación de datos. Detección y corrección de caída 500 por payload de tipo JSON inválido en `/auth/login`. Resultado: 114/114 pruebas aprobadas con 0 errores de servidor.
|
|
||||||
- **Node.js SAST (`njsscan`):** Análisis de patrones de código inseguro en el BFF Express (`frontend/src`). Resultado: 0 hallazgos.
|
|
||||||
- **Node.js SCA (`npm audit`):** Auditoría continua de paquetes npm con 0 vulnerabilidades reportadas.
|
|
||||||
- **Blindaje Activo (`helmet` & `express-rate-limit`):** Incorporación de cabeceras HTTP defensivas (`HSTS`, `nosniff`, `SAMEORIGIN`, `X-Permitted-Cross-Domain-Policies`) y limitadores de tasa ante fuerza bruta en `/auth/login` (30 req / 15 min) y `/api` (180 req / min).
|
|
||||||
- **Script Unificado de Auditoría (`security_audit.bat`):** Ejecutable centralizado para correr los 5 controles en un solo comando.
|
|
||||||
- **Documento Formal de Seguridad (`SECURITY_AUDIT_REPORT.md`):** Reporte ejecutivo de auditoría y controles continuos.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [2.4.0] - 2026-09-19
|
|
||||||
### Añadido
|
|
||||||
- **Perfil Extendido de Usuarios y Tipificaciones de Documentos Oficiales (Argentina y Extranjeros):**
|
|
||||||
- Incorporación en el modelo `User` de los campos: `first_name` (Nombre), `last_name` (Apellido), `email` (Email Institucional de acceso), `personal_email` (Email Personal/Particular de contacto), `phone` (Teléfono con código de área), `address` (Domicilio/Dirección), `document_type` (Tipo de documento) y `document_number` (Número de documento validado).
|
|
||||||
- Catálogo formal de reglas de negocio en `backend/app/constants/document_types.py` con tipificaciones oficiales de Argentina y extranjeros (DNI, CUIL, Pasaporte Argentino, Pasaporte Extranjero, DNI Extranjero, Cédula de Identidad PFA, Cédula Mercosur, Libreta Cívica y Libreta de Enrolamiento).
|
|
||||||
- Validaciones de formato, expresiones regulares, y verificación de unicidad de documento por tipo en endpoints `POST /api/v1/users` y `PUT /api/v1/users/<id>`.
|
|
||||||
- Búsqueda dinámica en `/admin/users_list` habilitada para filtrar por número de documento o email personal/institucional además de nombre.
|
|
||||||
- Rediseño del formulario `/admin/users/form` estructurado en secciones: Identidad y Documentación, Contacto y Domicilio (con Teléfono, Email Personal y Dirección), y Cuenta y Rol Institucional (con Email Institucional y Clave), permitiendo al Administrador General gestionar la totalidad de los datos.
|
|
||||||
- Script de migración SQLite `backend/migrate_user_profile_fields.py` para agregar las columnas e inicializar nombres desglosados de los usuarios existentes.
|
|
||||||
- **Co-Docencia y Cátedras Múltiples en Comisiones:**
|
|
||||||
- Creación del modelo relacional `CommissionTeacher` (`commission_teachers`) para asociar múltiples docentes a una misma comisión con roles académicos diferenciados (`Titular`, `Adjunto`, `JTP`, `Ayudante`).
|
|
||||||
- Nuevos endpoints en la API REST: `POST /api/v1/commissions/<id>/teachers` y `DELETE /api/v1/commissions/<id>/teachers/<user_id>`, junto con la serialización completa de cátedras en `GET /api/v1/commissions`.
|
|
||||||
- Nueva tarjeta interactiva *"Equipo Docente / Cátedra"* en el detalle de la comisión (`/admin/commission_detail?id=...`) permitiendo agregar y desvincular docentes con sus respectivos roles.
|
|
||||||
- Actualización de la grilla de comisiones (`/admin/commissions_list`) para exhibir a todos los profesores asignados con badges distintivos de rol.
|
|
||||||
- **Eliminación y Gestión Segura de Asignaturas:**
|
|
||||||
- Incorporación de botón y modal interactivo de confirmación de eliminación de asignaturas en `/admin/subjects_list`.
|
|
||||||
- Implementación de regla de negocio de integridad académica: eliminación física si la materia no tiene comisiones asociadas, o desactivación automática si posee historial o cursadas vinculadas.
|
|
||||||
|
|
||||||
### Corregido
|
|
||||||
- **Bug Visual en Modificación de Asignaturas (`/admin/subjects_list`):**
|
|
||||||
- Solucionado el problema de modales recortados o cubiertos por el fondo oscuro (`modal-backdrop`), originado por la presencia de 206 modales inline dentro de celdas `<td>` en una tabla con `overflow` (`.table-responsive`). Se refactorizó hacia un único modal global `#modalEditSubject` instanciado fuera de la tabla e inicializado dinámicamente con JavaScript.
|
|
||||||
### Añadido
|
|
||||||
- **Monitoreo y Percentiles de Asignación de Aulas Físicas (Admin & Bedelía):**
|
|
||||||
- Panel analítico *"Estado y Percentiles de Asignación de Aulas Físicas"* incorporado en los dashboards de Administrador (`/dashboard?role=admin`) y Bedelía (`/dashboard?role=bedelia`).
|
|
||||||
- Cálculo dinámico en tiempo real de **Aulas Asignadas** en número y percentil (**26 aulas · 96.3%**) con enlace directo a cursadas activas.
|
|
||||||
- Cálculo dinámico en tiempo real de **Aulas Sin Asignar / Libres** en número y percentil (**1 aula · 3.7%**) con enlace directo al formulario de asignación de espacios.
|
|
||||||
- Barras de progreso visuales con desglose dual: **Planificación Cuatrimestral** (96.3% asignado vs 3.7% disponible) y **Ocupación Operativa del Día** (aulas con clases hoy vs aulas libres).
|
|
||||||
- Exposición de métricas de asignación y percentiles en el endpoint de la API REST `GET /api/v1/dashboard/stats`.
|
|
||||||
- **Módulo de Cursadas Asignadas y Métricas Reales en Catálogo Presencial (`/classrooms/list_classrooms?view_mode=physical`):**
|
|
||||||
- Incorporación de la sección *"Cursadas Asignadas"* dentro de cada tarjeta de aula física y columna en vista de tabla, detallando asignaturas, comisiones y turnos.
|
|
||||||
- Métricas de ocupación física real por período (Día, Semana, Mes) con porcentajes calculados dinámicamente (6%, 11%, 17%, 22% según la carga horaria del aula).
|
|
||||||
- Botones de acción directa con ID dinámico: **Ficha** (`/classrooms/view_classroom?id=...`), **Horarios en Calendario** (`/schedule/calendar_view?classroom_id=...`) y **Editar**.
|
|
||||||
- Conexión de la tabla *"Próximas Clases y Reservas"* en la ficha individual del aula (`/classrooms/view_classroom`) con las reservas reales del cuatrimestre y enlaces a cada reserva (`/schedule/view_reservation?id=...`).
|
|
||||||
|
|
||||||
### Modificado
|
|
||||||
- **Dashboard Docente (`/dashboard?role=docente`):**
|
|
||||||
- Eliminado el botón flotante *"Reservar Aula / Solicitar espacio para clase"* (`/schedule/add_reservation`) a petición de los requerimientos funcionales del cuerpo docente.
|
|
||||||
- Reorganizada la grilla superior a 3 columnas simétricas (`col-md-4`) para las tarjetas de *Mis Clases Hoy*, *Mis Reservas Activas* y *Aulas Virtuales*.
|
|
||||||
|
|
||||||
### Corregido
|
|
||||||
- **Fallo Crítico de Renderizado en Nunjucks (`memberLookup` con `.items`):**
|
|
||||||
- Solucionada la incompatibilidad de `nunjucks/src/jinja-compat` que interceptaba la propiedad `.items` convirtiendo `classrooms.items` en una función interna en lugar de un array, lo que impedía dibujar las tarjetas de aulas presenciales en `frontend/views/classrooms/list.html`.
|
|
||||||
- Implementado interceptor en `memberLookup` en `frontend/src/app.js` para preservar arrays intactos.
|
|
||||||
- **Clasificación Errónea de Reservas Físicas como Virtuales (`is_virtual`):**
|
|
||||||
- Corregida la propiedad `@property is_virtual` en `Reservation` (`backend/app/models/reservation.py`) para garantizar que si una reserva tiene aula física asignada (`self.classroom and not self.classroom.is_virtual`), siempre sea clasificada como presencial (`is_virtual = False`), evitando falsos positivos cuando la comisión posee un enlace virtual predeterminado.
|
|
||||||
- **Excepción de SQLAlchemy en Estadísticas de Dashboard:**
|
|
||||||
- Corregido el error de ejecución generado al invocar `.ilike()` sobre la propiedad Python `@property Classroom.room_number` en `backend/app/routes/api/dashboard.py`, migrando la clasificación de aulas presenciales y virtuales a una evaluación robusta en memoria Python (`Classroom.code` y `Classroom.building`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [2.2.0] - 2026-09-19
|
|
||||||
### Añadido
|
|
||||||
- **Herramienta de Purga y Reinicio de Datos Académicos (`reset_academic_data`):**
|
|
||||||
- Script CLI `backend/scripts/reset_academic_data.py` con argumentos de limpieza granular (`--all`, `--reservations`, `--classrooms`, `--careers`, `--yes`).
|
|
||||||
- Endpoint de API `POST /api/v1/admin/reset-academic-data` protegido por JWT (rol `admin`) con registro en `AuditLog`.
|
|
||||||
- Panel interactivo "Zona de Reinicio y Purga Académica" en `/admin/google_sheets_import` con checkboxes independientes para reservas, aulas, comisiones, asignaturas y carreras.
|
|
||||||
- Modal modal de confirmación con advertencias de seguridad para prevenir purgas accidentales.
|
|
||||||
- Preservación íntegra de usuarios del sistema, credenciales de acceso, roles RBAC y registros de auditoría.
|
|
||||||
- **Configuración Global Dinámica de Google Sheets:**
|
|
||||||
- Nuevo modelo `SystemSetting` (`backend/app/models/setting.py`) para almacenamiento persistente clave-valor en base de datos.
|
|
||||||
- Endpoints `GET /api/v1/admin/sheets-config` y `POST /api/v1/admin/sheets-config`.
|
|
||||||
- Normalizador de enlaces en `GoogleSheetsImporter` compatible con URLs compartidas de edición (`/spreadsheets/d/.../edit`) o enlaces publicados (`/pub?output=csv`).
|
|
||||||
- Interfaz web con formulario de edición de URL, botón de guardado en caliente, restauración a URL oficial predeterminada y acceso directo a Google Docs.
|
|
||||||
- **Visualización de Asignaturas Reales:**
|
|
||||||
- Tabla de asignaturas recientemente sincronizadas conectada a la base de datos real en lugar de datos de muestra (*mock*).
|
|
||||||
|
|
||||||
### Corregido
|
|
||||||
- **Fallo por Timeout en Google Sheets Academic Sync:**
|
|
||||||
- Solucionado el corte prematuro de conexión en el BFF (`frontend/src/services/apiClient.js`), aumentando el timeout base a 25s y el timeout específico de sincronización a 90s (anteriormente limitado a 5s en operaciones que toman ~6.7s).
|
|
||||||
- Eliminado el enmascaramiento silencioso de errores en el BFF, permitiendo reportar fallos reales o éxitos detallados (+carreras, +aulas, +materias, +comisiones, +reservas).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [2.1.0] - 2026-09-19
|
|
||||||
### Añadido
|
|
||||||
- **Ficha Detallada de Reserva (`schedule.view_reservation`):**
|
|
||||||
- Implementación en BFF Node.js de la ruta `/schedule/view_reservation` (soportando parámetros `:id`, `?id=` o selección automática del primer registro).
|
|
||||||
- Vistas y controladores para modificar reservas con selectores en cascada (`/schedule/edit_reservation`).
|
|
||||||
- Endpoints de acción rápida en backend y BFF: `POST /schedule/confirm_reservation` y `POST /schedule/cancel_reservation`.
|
|
||||||
- Actualización de enlaces en `today.html`, `list.html`, `dashboard.html` y `view.html` para navegar directamente con el identificador de la reserva (`?id=...`).
|
|
||||||
- **Creación y Asociación de Comisiones a Asignaturas (`admin/commissions_list`):**
|
|
||||||
- Botón destacado `+ Nueva Comisión` en el encabezado y en el estado vacío de la tabla.
|
|
||||||
- Modal interactivo `#modalAddCommission` con selección obligatoria y clara de la **Asignatura / Materia Asociada** (`<select name="subject_id" required>`), mostrando código oficial, nombre y carrera.
|
|
||||||
- Campos completos de cursada: cuatrimestre (1C/2C/Anual), año lectivo, turno, docente a cargo, cupo máximo y días/horarios.
|
|
||||||
- Generación y configuración automática de enlace a Google Meet institucional cuando no se ingresa un enlace personalizado.
|
|
||||||
- Alerta de confirmación visual (`?created=1`) al registrar y vincular una comisión exitosamente.
|
|
||||||
- Tabla de comisiones actualizada con visualización explícita del código de materia, nombre y badge de carrera.
|
|
||||||
- **Enriquecimiento de la API REST:**
|
|
||||||
- Endpoint `GET /api/v1/reservations/<id>` ampliado con objetos relacionales anidados: `classroom` (código, edificio, piso, capacidad, descripción, virtualidad), `commission` (código, código compuesto, materia), `user` (docente, nombre, apellido, correo), duración en minutos y enlaces virtuales.
|
|
||||||
- Endpoint `POST /api/v1/admin/commissions` con validación estricta de existencia de asignatura y resolución inteligente de unicidad.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [2.0.0] - 2026-09-18
|
|
||||||
### Añadido
|
|
||||||
- **Reingeniería de Arquitectura Fullstack (Desacople Monolito -> Backend + BFF):**
|
|
||||||
- Desacople completo del backend Python/Flask como una **API REST pura** bajo `/api/v1/` retornando exclusivamente JSON.
|
|
||||||
- Implementación del **Frontend BFF** en Node.js + Express con renderizado de servidor mediante Nunjucks.
|
|
||||||
- Autenticación sin estado (*Stateless*) basada en tokens JWT firmados con algoritmo HS256.
|
|
||||||
- Almacenamiento seguro del token de acceso en cookies del navegador con directivas `HttpOnly`, `SameSite=Lax` y `Secure`.
|
|
||||||
- Inyección transparente del token `Bearer` en peticiones internas mediante cliente Axios centralizado.
|
|
||||||
- **Dashboards Personalizados por Rol Institucional:**
|
|
||||||
- **Dashboard de Bedelía (`/dashboard?role=bedelia`):** Métricas operativas de campus físico y virtual, cartelera del día y panel de aprobación de solicitudes pendientes.
|
|
||||||
- **Dashboard de Docente (`/dashboard?role=docente`):** Sesiones del día con botones directos para ingresar a reuniones virtuales (Meet/Teams/Zoom) y formulario de solicitud de aulas.
|
|
||||||
- **Dashboard de Alumno (`/dashboard?role=alumno`):** Brújula de cursada *"¿Dónde curso hoy?"* con ubicación por piso/aula o enlace virtual, junto al calendario de exámenes y entregas.
|
|
||||||
- **Dashboard de Administrador (`/dashboard?role=admin`):** Cockpit institucional de capacidad, usuarios, auditoría RBAC y distribución de comisiones.
|
|
||||||
- Selector dinámico de vista previa de roles en el banner superior para administradores.
|
|
||||||
- **Cartelera del Día Interactiva (`/schedule/today_schedule`):**
|
|
||||||
- Grilla estructurada en bloques horarios (07:00 a 21:00 hs) con métricas en tiempo real de ocupación y reservas confirmadas/pendientes.
|
|
||||||
- Filtro dinámico para ocultar clases virtuales (`hide_virtual=1`) y segmentación por pisos o turnos.
|
|
||||||
- **Seguridad Enterprise (SecurityFilterChain):**
|
|
||||||
- Pipeline centralizado de cabeceras de blindaje OWASP (`nosniff`, `SAMEORIGIN`, `X-XSS-Protection`, `Strict-Transport-Security`).
|
|
||||||
- Rate limiting dinámico contra ataques de fuerza bruta en endpoints de autenticación.
|
|
||||||
|
|
||||||
### Corregido
|
|
||||||
- **Resolución de Colisión de Horarios en Clases Virtuales (Importador de Google Sheets):**
|
|
||||||
- Corrección de la búsqueda de duplicados en `sheets_importer.py` que limitaba indebidamente la unicidad a `(classroom_id, start_time, end_time)`.
|
|
||||||
- Habilitación de concurrencia ilimitada para el aula virtual (`Campus Virtual · VIRTUAL`) mediante discriminación por `commission_id`.
|
|
||||||
- El total de reservas registradas aumentó de **363 a 643** (incorporando **280 clases virtuales** que eran descartadas por falso conflicto).
|
|
||||||
- Generación y guardado automático de enlaces institucionales de Google Meet (`https://meet.google.com/edu-{materia}-{comision}`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [1.0.0] - 2026-09-04
|
|
||||||
### Añadido
|
|
||||||
- **Versión Monolítica Base (`legacy_admin-edu-space`):**
|
|
||||||
- Aplicación monolítica en Flask con plantillas Jinja2 y persistencia SQLAlchemy (SQLite / PostgreSQL).
|
|
||||||
- Modelado de datos inicial para sedes, aulas físicas, materias, comisiones, usuarios y reservas.
|
|
||||||
- Sistema de roles inicial (Admin, Bedelía, Docente, Alumno) basado en sesión tradicional de Flask-Login.
|
|
||||||
- Calendario de reservas mensual y semanal con FullCalendar.
|
|
||||||
- Prototipo experimental de algoritmo genético para optimización de ocupación áulica.
|
|
||||||
- Módulo de sincronización inicial con planillas de Google Sheets.
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
# CHANGELOG MVP — Admin Edu-Space (UniCABA)
|
|
||||||
|
|
||||||
> **Nota de Unificación:** Este archivo documenta la progresión de los hitos y entregables de las Fases del [ROADMAP_MVP.md](./ROADMAP_MVP.md). Para el historial técnico exhaustivo conforme a *Keep a Changelog* y *Semantic Versioning*, consultar el archivo oficial unificado: **[CHANGELOG.md](./CHANGELOG.md)**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [Fase 5: Integración Auth, Email & Moodle Backend] — v3.0.0 (2026-09-23)
|
|
||||||
**Estado:** ✅ 100% Completada
|
|
||||||
|
|
||||||
### Épica 1: Panel de Configuración Global (Perfil ADMIN)
|
|
||||||
* **Cifrado Simétrico Fernet:** Creación de `CryptoService` (`backend/app/services/crypto_service.py`) derivando de forma determinística una clave Fernet de 32 bytes a partir de `SECRET_KEY`.
|
|
||||||
* **Modelo `SystemSetting` Extendido:** Soporte para columnas `category` y flag `is_encrypted`. Almacenamiento seguro de secretos con métodos polimórficos `get_decrypted_value()` y enmascaramiento con `get_masked_value()`.
|
|
||||||
* **Endpoints Administrativos:**
|
|
||||||
- `GET /api/v1/admin/settings/all`: Retorna la configuración completa organizada por categorías (`smtp`, `auth_providers`, `google_oauth`, `moodle`) con secretos enmascarados.
|
|
||||||
- `POST /api/v1/admin/settings/smtp`: Configuración dinámica de Host, Puerto, Usuario, Contraseña cifrada, Protocolo de Seguridad (TLS/SSL/NONE), y Remitente.
|
|
||||||
- `POST /api/v1/admin/settings/smtp/test`: Handshake SMTP en vivo y envío opcional de correo de prueba con plantilla HTML oficial.
|
|
||||||
- `POST /api/v1/admin/settings/auth-providers`: Activación y desactivación de proveedores de acceso (`local`, `google`, `moodle`).
|
|
||||||
- `POST /api/v1/admin/settings/google-oauth`: Guardado de Client ID, Client Secret cifrado, Dominios autorizados y Callback URL.
|
|
||||||
- `POST /api/v1/admin/settings/moodle`: URL base del servidor, Token Web Services cifrado, timeout y frecuencia de sincronización.
|
|
||||||
* **Interfaz de Usuario Web:** Nueva pantalla responsiva `frontend/views/admin/settings/global_config.html` con pestañas navegables, validación en vivo, spinners y monitores de estado (100% adaptada para resoluciones desde 720p hasta 1080p).
|
|
||||||
* **Navegación:** Integración de enlace directo *"Config. Global"* en el menú lateral bajo Administración en `frontend/views/base.html`.
|
|
||||||
|
|
||||||
### Épica 2: Infraestructura Asíncrona y Caché
|
|
||||||
* **Capa de Caché Híbrida:** Creación de `CacheService` (`backend/app/services/cache_service.py`) con soporte nativo de Redis y fallback transparente en memoria con TTL para sesiones de usuario y perfiles de Moodle.
|
|
||||||
* **Cola Persistente de Sincronización:** Modelo `MoodleSyncTask` (`backend/app/models/sync_task.py`) y servicio `MoodleQueueService` (`backend/app/services/moodle_queue_service.py`) para registrar operaciones de sincronización (`CREATE_USER`, `UPDATE_USER`, `ENROL_USER`, `UNENROL_USER`, `ASSIGN_ROLE`).
|
|
||||||
* **Tolerancia a Fallos y DLQ:** Política de Exponential Backoff ($2^{\text{intentos}} \times 30$s) ante indisponibilidad de Moodle. Si se superan los intentos máximos, la tarea se traslada automáticamente a la **Dead Letter Queue (DLQ)** (`status = 'FAILED'`).
|
|
||||||
* **Endpoints de Cola & DLQ:**
|
|
||||||
- `GET /api/v1/admin/moodle/queue/stats`: Métricas en tiempo real (Pendientes, En Proceso, Reintentando, Completadas, Fallidas DLQ).
|
|
||||||
- `GET /api/v1/admin/moodle/queue/tasks`: Lista paginada con filtrado por estado.
|
|
||||||
- `POST /api/v1/admin/moodle/queue/process-now`: Disparo manual inmediato de sincronización.
|
|
||||||
- `POST /api/v1/admin/moodle/queue/tasks/<id>/retry`: Reintento de tarea individual de DLQ.
|
|
||||||
- `POST /api/v1/admin/moodle/queue/retry-all`: Reintento masivo de todas las tareas en DLQ.
|
|
||||||
|
|
||||||
### Épica 3: Sistema de Autenticación Híbrida (SSO & Local)
|
|
||||||
* **Login Local Refactorizado:** Endpoint `/api/v1/auth/login` respeta la configuración global. Si `auth_local_enabled` está desactivado, sólo admite acceso de contingencia a usuarios con rol `ADMIN`.
|
|
||||||
* **Google OAuth 2.0 (Arquitectura AlumnosLS):** Endpoint `POST /api/v1/auth/google` con validación estricta de dominios institucionales (`google_allowed_domains`), aprovisionamiento automático de perfil y emisión de tokens JWT.
|
|
||||||
* **Moodle Delegated Login:** Endpoint `POST /api/v1/auth/moodle` validando contra `/login/token.php` de Moodle 4.1 (`moodle_mobile_app`), obtención de datos de usuario con Web Services, caché en Redis y emisión de JWT.
|
|
||||||
* **Mapeo y Desacople de Roles:** Si el usuario es nuevo y posee rol de manager/admin en Moodle, se le asigna rol local `ADMIN`; en caso contrario se asigna `Docente` o `Alumno`. La administración y cambio de roles posterior permanece 100% local e independiente de Moodle.
|
|
||||||
* **Frontend Login:** Vista `frontend/views/auth/login.html` adaptada para consultar `/api/v1/auth/providers` y renderizar dinámicamente el botón de Google Workspace, el botón desplegable de Moodle y el formulario tradicional.
|
|
||||||
|
|
||||||
### Épica 4: Integración Bidireccional Moodle 4.1 (Backend)
|
|
||||||
* **Cliente REST Moodle 4.1:** `MoodleClient` (`backend/app/services/moodle_client.py`) con métodos para:
|
|
||||||
- `core_user_create_users`, `core_user_update_users`, `core_user_get_users`.
|
|
||||||
- `enrol_manual_enrol_users`, `enrol_manual_unenrol_users`, `core_enrol_get_enrolled_users`.
|
|
||||||
- `core_role_assign_roles`, `core_role_unassign_roles`.
|
|
||||||
- `test_connection()` contra `core_webservice_get_site_info`.
|
|
||||||
* **Desacople en CRUD Local:** Creación y edición de usuarios en `backend/app/routes/api/admin.py` encola automáticamente la sincronización sin bloquear las peticiones locales.
|
|
||||||
|
|
||||||
### Épica 5: Motor de Notificaciones (Email Server)
|
|
||||||
* **Cliente SMTP Dinámico:** Creación de `EmailService` (`backend/app/services/email_service.py`) que obtiene credenciales en tiempo de ejecución de la base de datos (con desencriptación de contraseña al vuelo).
|
|
||||||
* **Plantillas Transaccionales Profesionales:**
|
|
||||||
- `notify_teacher_assignment`: Notificación a profesores sobre asignación a comisiones y horarios.
|
|
||||||
- `notify_exam_schedule`: Convocatoria y citación a mesas de examen final.
|
|
||||||
- `test_smtp_connection`: Verificación de handshake y autenticación SMTP con feedback claro.
|
|
||||||
|
|
||||||
### Cobertura de Pruebas
|
|
||||||
* Suite completa en `backend/tests/test_phase6_integration.py` con 7 pruebas unitarias e integrales que validan cifrado Fernet, SystemSetting, CacheService, MoodleQueueService (Backoff & DLQ), EmailService dinámico, y autenticación híbrida (Google & Moodle). **30/30 pruebas pasando en verde** en la suite global de regresión.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [Fase 4: UI/UX Avanzada para Bedelía & Modo Impersonación] — v2.8.0 (2026-09-23)
|
|
||||||
**Estado:** ✅ 100% Completada
|
|
||||||
|
|
||||||
### Estadio 4.1 — Grilla Semanal Interactiva (Drag-and-Drop) con Detección de Colisiones
|
|
||||||
* **FullCalendar Interactivo:** Habilitación de `editable: true` y `droppable: true` en el calendario general de Bedelía (`/schedule/calendar`).
|
|
||||||
* **Sidebar de Comisiones Pendientes:** Panel lateral dinámico con catálogo de comisiones que aún no tienen aula asignada (`unassigned_commissions`), permitiendo arrastrarlas directamente hacia los bloques horarios del calendario.
|
|
||||||
* **Validación de Choques en Tiempo Real:** Integración con `POST /api/v1/reservations/drag-update`. Al mover o soltar una clase, el backend somete la nueva posición a la matriz integral de conflictos (`ReservationService.check_full_conflicts_matrix`):
|
|
||||||
- Conflicto físico de aula (doble asignación simultánea).
|
|
||||||
- Conflicto docente (profesor ocupado en otra aula).
|
|
||||||
- Conflicto de aforo (capacidad de aula insuficiente).
|
|
||||||
* **Manejo de Respuestas:** Si hay colisión, devuelve HTTP 409 (`status: conflict`), FullCalendar revierte automáticamente el movimiento (`info.revert()`) y se dispara un Toast de alerta detallando el motivo del bloqueo. Si está libre, persiste la reserva y confirma con Toast de éxito.
|
|
||||||
|
|
||||||
### Estadio 4.2 — Modo Impersonación del Superadmin con Trazabilidad
|
|
||||||
* **Cabecera `X-Impersonate-User`:** Inyección y soporte en `backend/app/utils/jwt_decorators.py` dentro de `jwt_required` para usuarios administradores. Asigna `g.jwt_user` al usuario objetivo preservando `g.real_admin_user` y marcando `g.is_impersonating = True`.
|
|
||||||
* **Endpoints de Control:** `POST /api/v1/admin/impersonate` y `POST /api/v1/admin/stop-impersonating` en Backend Flask con auditoría inmutable en `AuditLog` (`IMPERSONATE_START` e `IMPERSONATE_END`).
|
|
||||||
* **Integración en BFF Node.js:** Middleware `authMiddleware.js` y proxy `/api` en `app.js` configurados para propagar cookies seguras `impersonate_user_id` e inyectar `X-Impersonate-User` en todas las peticiones a la API Flask.
|
|
||||||
* **Banner Visual Institucional:** Banner de advertencia permanente en `base.html` con fondo de alerta ámbar, spinner de estado, identificación del usuario y rol adoptado, y botón de salida inmediata.
|
|
||||||
* **Acceso de un Clic:** Botón directo de impersonación (`bi-incognito`) en cada fila de usuario activo en `/admin/users_list`.
|
|
||||||
|
|
||||||
### Estadio 4.3 — Acople del Optimizador Genético a Datos y Turnos Reales
|
|
||||||
* **Turnos Académicos Reales:** `ReservationOptimizer._calculate_preferred_time` sincronizado con las franjas horarias de UniCABA (`Mañana`: 08:00 a 12:00, `Tarde`: 14:00 a 18:00, `Vespertino`/`Noche`: 18:30 a 22:30).
|
|
||||||
* **Persistencia Segura:** Endpoint `POST /api/genetic/apply-optimization` adaptado con parseo riguroso de strings ISO y prevención de colisiones físicas en guardado masivo.
|
|
||||||
|
|
||||||
### Cobertura de Pruebas
|
|
||||||
* Suite completa en `backend/tests/test_phase4_interactive_impersonation.py` validando los 6 criterios de aceptación (impersonación de docente, impersonación de alumno, trazabilidad en auditoría, rechazo a no-admins, bloqueo 409 por colisión en drag-drop y actualización exitosa 200). 18/18 tests aprobados en suite global de Fases 2, 3 y 4.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [Fase 3: Gestión de Hitos Evaluativos y Libro de Calificaciones] — v2.7.0 (2026-09-23)
|
|
||||||
**Estado:** ✅ 100% Completada
|
|
||||||
|
|
||||||
### Estadio 3.1 — Calendario de Evaluación por Comisión y Notificaciones Preventivas
|
|
||||||
* **Modelo `MilestoneGrade`:** Tabla relacional `milestone_grades` para registrar calificaciones por estudiante y reserva evaluativa, con validación de unicidad (`student_id`, `reservation_id`), notas en escala 1.0 a 10.0, estado de inasistencia (`is_absent`) y devoluciones pedagógicas (`feedback`).
|
|
||||||
* **Consulta de Hitos del Alumno:** Endpoint `GET /api/v1/students/my-upcoming-milestones` que detecta dinámicamente exámenes parciales, recuperatorios y entregas de TP de las comisiones del estudiante con cálculo automático de días restantes (`days_remaining`).
|
|
||||||
* **Dashboard del Alumno:** Widget interactivo de próximos exámenes en `/dashboard?role=alumno` con badges visuales de urgencia (*¡Hoy!*, *¡Mañana!*, *Faltan X días*) y acceso directo desde el Launchpad a *"Mis Calificaciones"*.
|
|
||||||
|
|
||||||
### Estadio 3.2 — Libro de Calificaciones (*Gradebook*) y Cierre Formal de Actas
|
|
||||||
* **Extensiones en `Commission`:** Incorporación de columnas `grades_closed` (boolean), `grades_closed_at` (datetime), `grades_closed_by` (foreign key) y `acta_number` (identificador oficial generado).
|
|
||||||
* **Servicio `GradebookService`:** Motor centralizado para matriz de notas por comisión, guardado asíncrono individual y por lote (`bulk_save_grades`), cálculo dinámico de promedios ponderados y condiciones académicas en tiempo real (*Promocionado* ≥ 7, *Regular* ≥ 4, *Libre* < 4).
|
|
||||||
* **Cierre Formal de Actas:** Sellado institucional con código único (`ACTA-YYYY-Sem-ID`), auditoría inmutable en `audit_logs` con acción `CLOSE_ACTA` y bloqueo total contra modificaciones posteriores.
|
|
||||||
* **Reapertura Excepcional:** Endpoint para Bedelía/Administración (`POST /api/v1/commissions/<id>/gradebook/reopen`) con justificación administrativa obligatoria asentada en auditoría.
|
|
||||||
* **Interfaz Docente & Bedelía (`/schedule/gradebook/:id`):** Matriz interactiva de carga rápida con autoguardado asíncrono debounced (500ms), indicador visual flotante de sincronización, recálculo instantáneo de promedios en el cliente y modales de sellado y reapertura de actas.
|
|
||||||
* **Portal del Alumno (`/schedule/my_grades` / `/mis-materias/mis-notas`):** Vista consolidada de todas las materias del estudiante, notas de parciales, condición final y número de acta emitida, con soporte de impresión de certificado de regularidad.
|
|
||||||
* **Acceso Directo en Comisión:** Botón destacado *"Libro de Calificaciones (Actas)"* y badge de acta sellada incorporados en `/admin/commission_detail`.
|
|
||||||
|
|
||||||
### Cobertura de Pruebas
|
|
||||||
* Suite completa en `backend/tests/test_phase3_gradebook.py` validando los 6 criterios de aceptación: estructura de matriz bidimensional, persistencia y autoguardado de notas, validación estricta de notas inválidas (fuera del rango 1-10), cierre formal de acta e inmutabilidad estricta ante intentos de edición, consulta integral del alumno y cuenta regresiva de próximos hitos. 6/6 tests aprobados (12/12 en conjunto con Fase 2).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [Fase 2: Co-docencia, Matriz de Conflictos y Regla Diaria] — v2.6.0 (2026-09-19)
|
|
||||||
**Estado:** ✅ 100% Completada
|
|
||||||
|
|
||||||
### Estadio 2.1 — Modelo y Gestión Integral de Co-docencia
|
|
||||||
* **Modelo `CommissionTeacher`:** Soporte para múltiples docentes por comisión en base de datos (`commission_teachers`), admitiendo roles académicos diferenciados (`Titular`, `Adjunto`, `JTP`, `Ayudante`) y flag `is_primary`.
|
|
||||||
* **API REST de Cátedras:** Endpoints `POST /api/v1/commissions/<id>/teachers` y `DELETE /api/v1/commissions/<id>/teachers/<user_id>` con serialización completa de cátedra en comisiones.
|
|
||||||
* **Interfaz de Gestión:** Tarjeta interactiva *"Equipo Docente / Cátedra"* en `/admin/commission_detail` para incorporar y desvincular profesores con rol dinámico, y visualización de badges en `/admin/commissions_list`.
|
|
||||||
|
|
||||||
### Estadio 2.2 — Matriz de Conflictos y Validación Integral
|
|
||||||
* **Conflicto Físico:** Bloqueo estricto e infranqueable de superposiciones de reservas en la misma aula física (`ReservationRepository.find_overlapping`).
|
|
||||||
* **Conflicto Docente:** Detección de doble asignación simultánea para un mismo docente en diferentes aulas o formatos.
|
|
||||||
* **Excepción Justificada por Co-docencia:** Si un docente asignado presenta solapamiento pero la comisión cuenta con co-docentes registrados en `CommissionTeacher`, la matriz de validación autoriza la reserva validando la cobertura del equipo de cátedra.
|
|
||||||
* **Conflicto de Aforo:** Bloqueo estricto cuando `expected_attendees > classroom.capacity` en espacios físicos.
|
|
||||||
* **Endpoint de Pre-validación:** `POST /api/v1/reservations/check-conflicts` para validación asíncrona de conflictos físicos, docentes y de aforo antes del commit.
|
|
||||||
|
|
||||||
### Estadio 2.3 — Regla de Restricción Diaria del Alumno y Motor de Excepciones
|
|
||||||
* **Servicio `EnrollmentService`:** Lógica de matriculación académica con análisis de días de cursada por comisión y verificación de cupos.
|
|
||||||
* **Regla Restrictiva:** Un alumno no puede cursar dos asignaturas regulares el mismo día de la semana.
|
|
||||||
* **Excepción Automática por Curso Corto:** Atributo `is_short_course` en modelo `Subject`; si una de las materias es un taller o curso corto, el sistema autoriza la cursada simultánea en el mismo día.
|
|
||||||
* **Excepción Expresa de Bedelía:** Campos `allow_same_day_exception` y `exception_reason` en `StudentEnrollment`. La oficina de Bedelía puede autorizar la matriculación simultánea mediante switch justificado en la interfaz.
|
|
||||||
* **Endpoints de Matrícula:** `GET/POST /api/v1/commissions/<id>/enrollments`, `DELETE/PUT /api/v1/commissions/<id>/enrollments/<student_id>`.
|
|
||||||
* **UI en Detalle de Comisión:** Formulario de matriculación con toggle de autorización de Bedelía, motivo de excepción y tabla de inscriptos con badges de estado.
|
|
||||||
|
|
||||||
### Cobertura de Pruebas
|
|
||||||
* Suite completa en `backend/tests/test_phase2_rules.py` validando los 6 criterios de aceptación (conflicto físico, aforo, co-docencia, restricción diaria, bypass de curso corto y bypass de Bedelía). 6/6 tests aprobados con 100% de éxito.
|
|
||||||
|
|
||||||
### Complementos y Seguridad Transversal
|
|
||||||
* **Dashboard de Bedelía:** Integración de paneles en tiempo real para análisis de **Comisiones Sin Reserva** y grilla de **Aulas Disponibles** (Filtros Semana/Mes) conectando frontend UI con el servicio analítico backend.
|
|
||||||
* **Auditoría Automatizada (Fase 1):** Implantación de un pipeline de seguridad local/Gitea que aísla dependencias de desarrollo (`requirements-dev.txt`) y ejecuta controles SAST, DAST y SCA (Bandit, pip-audit, njsscan y Schemathesis) sin vulnerabilidades detectadas.
|
|
||||||
* **Optimizador Genético de Reservas:** Reparación de enrutamiento 404, migración a JWT (`@jwt_required`), corrección de UI de métricas dinámicas de algoritmo, y refactorización de layout para superposición de herramientas.
|
|
||||||
* **UI/UX General:** Ajustes de CSS en modo claro (soporte dinámico de contraste de isologotipo blanco a rosa institucional mediante filtros) y traducciones de menús de usuario en `base.html`.
|
|
||||||
---
|
|
||||||
|
|
||||||
## [Fase 1: Paridad Legacy y Estabilización] — v2.5.0 / v2.4.0 (2026-09-19)
|
|
||||||
**Estado:** ✅ 100% Completada
|
|
||||||
* Seguridad automatizada SAST/DAST/SCA (Bandit, pip-audit, schemathesis, njsscan, npm audit).
|
|
||||||
* Tipificaciones de documentos argentinos y extranjeros, perfiles extendidos de usuarios con email personal e institucional.
|
|
||||||
* Gestión completa de aulas físicas vs virtuales con percentiles de asignación y métricas de ocupación real.
|
|
||||||
* Reingeniería del modal de edición y eliminación segura de asignaturas.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [Fase 0: Desacople y Arquitectura Base] — v2.0.0 (2026-09-18)
|
|
||||||
**Estado:** ✅ 100% Completada
|
|
||||||
* Desacople arquitectónico completo: Backend Flask RESTful (`/api/v1/`) + Frontend BFF Node.js/Express con Nunjucks.
|
|
||||||
* Autenticación basada en tokens JWT con sincronización de estado de sesión.
|
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
# Resumen de Cambios - Sesión de Desarrollo (Rama `dev`)
|
||||||
|
|
||||||
|
**Fecha:** 2026-09-02 / 2026-09-03
|
||||||
|
**Proyecto:** Admin Edu-Space (Flask + PostgreSQL)
|
||||||
|
**Rama:** `dev`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Auditoría Git y Puesta en Producción
|
||||||
|
* **Informe Técnico Completo (`DEPLOYMENT_AUDIT.md`):**
|
||||||
|
* Análisis de ramas locales y remotas (`main`, `dev`, `origin/planning`, `origin/commissions`, `origin/spanish`).
|
||||||
|
* Manual de despliegue en producción con arquitectura WSGI (Gunicorn), Nginx como proxy inverso, Systemd unit y contenedorización Docker / Docker Compose.
|
||||||
|
* **Archivos Base:**
|
||||||
|
* Creación de `.env.example` con todas las variables requeridas (PostgreSQL, Flask, Secret Key, Babel).
|
||||||
|
* Creación y posterior actualización de `init_db.py` para bootstrap inicial seguro de base de datos, roles RBAC y usuario administrador por defecto (`admin@edu-space.com` / `admin123`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Base de Datos y Modelos
|
||||||
|
* **Preferencia de Idioma Persistente:**
|
||||||
|
* Migración en PostgreSQL agregando la columna `preferred_language VARCHAR(10)` a la tabla `users`.
|
||||||
|
* Actualización del modelo `User` en `app/models/user.py` con el campo `preferred_language`.
|
||||||
|
* **Preferencia de Tema Visual Persistente:**
|
||||||
|
* Migración agregando la columna `theme_preference VARCHAR(10) DEFAULT 'auto' NOT NULL` a la tabla `users`.
|
||||||
|
* Soporte en el modelo `User` para valores `'auto'`, `'light'` y `'dark'`.
|
||||||
|
* **Control de Acceso Basado en Roles (RBAC):**
|
||||||
|
* Migración creando las tablas `roles` y `permissions`, y agregando la clave foránea `role_id` a la tabla `users`.
|
||||||
|
* Modelos `Role` y `Permission` en `app/models/role.py`.
|
||||||
|
* Métodos `has_permission(module, min_level)` y `can_manage()` en `User`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Implementación de Vistas y Plantillas
|
||||||
|
* **Perfil de Usuario (`app/templates/auth/profile.html`):**
|
||||||
|
* Vista completa con datos del usuario, rol, estado, fecha de registro y reservas recientes.
|
||||||
|
* Formulario integral para configurar **Idioma de la interfaz** (*Detectar automáticamente del navegador*, *Español*, *English*) y **Tema visual** (*Automático*, *Modo Claro*, *Modo Oscuro*).
|
||||||
|
* **Cambio de Contraseña (`app/templates/auth/change_password.html`):**
|
||||||
|
* Formulario seguro para actualización de credenciales.
|
||||||
|
* **Manejo de Errores (`app/templates/auth/403.html`):**
|
||||||
|
* Pantalla de error 403 Forbidden estilizada e integrada con el sistema de diseño.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Correcciones de UX, Linter y Bugs de Rutas
|
||||||
|
* **Navbar Limpio (`app/templates/base.html`):**
|
||||||
|
* Eliminación del bloque duplicado del menú de usuario `System Administrator`.
|
||||||
|
* **Corrección de BuildError en Comisiones:**
|
||||||
|
* En `app/routes/classrooms.py`, se agregó el alias de ruta `@classrooms_bp.route('/', endpoint='index')` para resolver enlaces a `classrooms.index`.
|
||||||
|
* En `app/templates/schedule/commissions.html`, se reemplazó el filtro inválido de Django `truncatewords(20)` por el filtro estándar de Jinja2 `truncate(100)`.
|
||||||
|
* **Corrección Visual en `/genetic-optimizer` (`app/templates/genetic_optimizer.html`):**
|
||||||
|
* Se agregó el espaciado `pt-5 mt-4` evitando que la barra fija superior se monte sobre el contenido.
|
||||||
|
* Rediseño de tarjeta hero con contraste óptimo y reemplazo de iconos faltantes por Bootstrap Icons (`bi bi-...`).
|
||||||
|
* **Corrección de Errores del Linter en `schedule/add.html` y `schedule/view.html`:**
|
||||||
|
* En `add.html`: Desacople de iteraciones Jinja a `<script type="application/json" id="classroom-data">` y parseo con `JSON.parse()`, eliminando 19 errores sintácticos del editor.
|
||||||
|
* En `view.html`: Formateo de la propiedad inline `style` en barras de progreso para cumplir con el validador CSS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Rediseño UI/UX, Assets Institucionales y Modo Claro/Oscuro
|
||||||
|
* **Exploración e Integración de Assets Gráficos:**
|
||||||
|
* Sincronización desde `app/resources` hacia `app/static/img/`:
|
||||||
|
* `logo_unicaba.png`: Logo institucional principal para el login.
|
||||||
|
* `logo_unicaba_mini.png`: Isotipo oficial para el navbar.
|
||||||
|
* `favicon.png`: Favicon vinculado en las plantillas base.
|
||||||
|
* `icons/`: Paquete de resoluciones WebApp/PWA.
|
||||||
|
* **Sistema de Diseño y Tokens de Color (`app/static/css/theme.css`):**
|
||||||
|
* Paleta institucional basada en Magenta/Uva (`#B43E8E`), acentos violetas (`#682CAD` / `#AE7FFF`) y secundarios magentas.
|
||||||
|
* Tokens CSS para `:root` (Modo Claro) y `[data-bs-theme="dark"]` (Modo Oscuro).
|
||||||
|
* Clases especializadas: `.auth-card`, `.navbar-app`, `.user-avatar-small`, `.theme-toggle-btn`, `.badge-brand`.
|
||||||
|
* **Modo Claro / Modo Oscuro Dinámico (`app/static/js/theme-toggle.js`):**
|
||||||
|
* Script anti-flickering en `<head>` para evitar parpadeos visuales al cargar la página.
|
||||||
|
* Botón toggle interactivo con persistencia en `localStorage`.
|
||||||
|
* En Modo Claro: Isotipo del navbar con filtro blanco puro (`brightness(0) invert(1)`) sobre el fondo magenta.
|
||||||
|
* Selector de idioma (`#languageDropdown`) con fondo y bordes de alto contraste y tilde de verificación blanca (`bi-check-lg`) visible **únicamente** en el idioma activo sobre gradiente magenta.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Internacionalización (i18n), Localización y Formateo de Fechas
|
||||||
|
* **Detección Inteligente de Subtags Regionales (`get_locale` en `app/__init__.py`):**
|
||||||
|
* Extracción del subtag primario (`es` a partir de `es-419`, `es-AR`, `es-ES`), resolviendo el fallo donde Werkzeug saltaba erróneamente a inglés.
|
||||||
|
* Configuración institucional: `BABEL_DEFAULT_LOCALE = 'es'` y `LANGUAGES = ['es', 'en']`.
|
||||||
|
* **Reglas de Idioma para la Pantalla de Login:**
|
||||||
|
* Resuelve según el navegador del visitante o recurre a español por defecto.
|
||||||
|
* Inmunidad contra cookies o sesiones residuales de otros usuarios.
|
||||||
|
* Flash messages traducidos (`¡Bienvenido de nuevo!`, `Has cerrado sesión correctamente`).
|
||||||
|
* **Formateador de Fechas Localizadas en Jinja2:**
|
||||||
|
* Registro de `format_date` y `format_datetime` de Flask-Babel.
|
||||||
|
* Fechas en español natural en `schedule/today.html` (`Jueves, 3 de septiembre de 2026`), `view.html` y `list.html`.
|
||||||
|
* **Traducción Integral de `schedule/add.html`:**
|
||||||
|
* Formulario y textos de ayuda 100% traducidos.
|
||||||
|
* Caja dinámica de resumen (JavaScript e `i18n-strings`) con cálculo de duración y validación de sobrecupo en español.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Módulo de Gestión de Accesos (RBAC) con Matriz FortiGate
|
||||||
|
* **Modelos y Persistencia:**
|
||||||
|
* Módulo `app/models/role.py` con `Role` y `Permission`.
|
||||||
|
* Roles iniciales sembrados: `Admin` (total, protegido), `Docente` (reservas y horarios), `Operador` (aulas y reservas), `Consulta` (solo lectura).
|
||||||
|
* Vinculación del usuario inicial `admin@edu-space.com` al rol `Admin`.
|
||||||
|
* **Matriz de Permisos Estilo FortiGate (`/admin/roles/edit/<id>`):**
|
||||||
|
* Tabla interactiva con columnas **Access Control** y **Permissions**.
|
||||||
|
* Segmented control por módulo con tres estados: `🚫 None`, `👁️ Read` y `✏️ Read/Write`.
|
||||||
|
* Color activo verde esmeralda FortiGate (`#2D9F6F`) con texto e icono en blanco puro.
|
||||||
|
* Menú desplegable masivo **Set All ▾** (*Set All to None*, *Set All to Read*, *Set All to Read/Write*).
|
||||||
|
* **Módulo de Administración (`app/routes/admin.py`):**
|
||||||
|
* `/admin/roles`: Listado de roles, conteo de miembros, visualización de permisos en badges (RW, R, -) y modal de borrado seguro.
|
||||||
|
* `/admin/users`: Listado de cuentas con filtros por nombre, rol y estado (Activo/Inactivo), paginación y switch rápido para alternar accesos.
|
||||||
|
* `/admin/users/add` y `/admin/users/edit/<id>`: Formularios de alta y edición con asignación de rol y contraseña opcional.
|
||||||
|
* **Seguridad y Decoradores (`app/utils/decorators.py`):**
|
||||||
|
* `@permission_required(module, min_level)` y `@admin_required`.
|
||||||
|
* Pestaña **Management** en el navbar visible exclusivamente para administradores.
|
||||||
|
* Botones de acción sensibles en el navbar (*Add Classroom*, *New Reservation*) condicionados a permisos de escritura.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Sincronización y Actualización de `init_db.py`
|
||||||
|
* Migraciones DDL automáticas (`ALTER TABLE ... ADD COLUMN IF NOT EXISTS`) para `preferred_language`, `theme_preference`, `role_id`, y para la tabla `careers` y columna `career_id` en `subjects`.
|
||||||
|
* Creación y siembra de roles institucionales y permisos en PostgreSQL.
|
||||||
|
* Configuración de salida con codificación UTF-8 segura en Windows para evitar excepciones de `cp1252`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Sincronización e Importación de Datos Académicos (Google Sheets)
|
||||||
|
* **Modelo `Career` (`app/models/career.py`):**
|
||||||
|
* Entidad de carreras universitarias vinculada con `Subject` vía relación uno a muchos.
|
||||||
|
* Migración en PostgreSQL de la tabla `careers` y campo `career_id` en `subjects`.
|
||||||
|
* **Servicio de Extracción ([app/services/sheets_importer.py](file:///c:/Workspace/admin-edu-space/app/services/sheets_importer.py)):**
|
||||||
|
* Integración con la cartelera oficial UniCABA 2026 a través de 6 hojas CSV (`Lunes` a `Sábado`).
|
||||||
|
* Normalización inteligente de espacios áulicos físicos (`Aula 402`, `Aula 302`), espacios virtuales (`VIRTUAL`), auditorios y salas de reunión.
|
||||||
|
* Extracción y parseo de franjas horarias (`8:00 a 9:00hs`, `17:30 a 21:30hs`).
|
||||||
|
* Rutina de **upsert** transaccional e idempotente que previene la duplicación de datos.
|
||||||
|
* Generación de reservas/clases en el calendario para poblar instantáneamente las vistas de horario diario y semanal.
|
||||||
|
* **Interfaz de Gestión (`/admin/import`):**
|
||||||
|
* Vista interactiva con métricas en tiempo real (total de carreras, asignaturas, aulas, comisiones y clases).
|
||||||
|
* Botón de ejecución manual de sincronización con indicador visual de progreso (`spinner`).
|
||||||
|
* Reporte detallado de resultados y tabla con vista previa de asignaturas y comisiones sincronizadas.
|
||||||
|
* **Acceso Rápido en Navbar (`app/templates/base.html`):**
|
||||||
|
* Nueva opción **"Import from Google Sheets"** incorporada en el menú desplegable **Management**, restringida a roles con permisos administrativos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Correcciones en la Vista de Calendario (FullCalendar) y Códigos de Asignatura
|
||||||
|
* **Carga de Eventos y Corrección de Error 500 (`/schedule/calendar-data`):**
|
||||||
|
* Se identificó y corrigió el error `AttributeError: 'str' object has no attribute 'value'` provocado por llamadas a `.status.value` sobre campos de estado textuales.
|
||||||
|
* Se implementó el TypeDecorator `StatusType` con la clase `StatusString` en `app/models/reservation.py` garantizando compatibilidad total tanto para `.status` como para `.status.value`.
|
||||||
|
* Sanitización de fechas ISO 8601 con zona horaria (`Z`, `-03:00`) enviadas por FullCalendar para comparación adecuada con campos `TIMESTAMP WITHOUT TIME ZONE` en PostgreSQL.
|
||||||
|
* Anclaje de fecha inicial con `initialDate: '{{ today_date }}'` para posicionar el calendario en la semana activa de cursada 2026.
|
||||||
|
* **Corrección del Código de Asignatura (`subject_code`):**
|
||||||
|
* Se reemplazó la concatenación de código extendido de comisión (`Commission.get_full_code()` que arrojaba cadenas distorsionadas como `INGA0003-C1-2026-22026`) por el **código de asignatura real** del Sheet (`Subject.code`, ej. `INGA0003`, `ASIG00165`).
|
||||||
|
* Propiedades de acceso directo añadidas en `Reservation`: `subject_code`, `subject_name` y `classroom_name`.
|
||||||
|
* **Jerarquía Visual y Renderizado de Tarjetas (`calendar.html`):**
|
||||||
|
* Implementación del hook `eventContent` en FullCalendar 5 para construir tarjetas personalizadas.
|
||||||
|
* **Nombre de la Asignatura** destacado como elemento principal con tipografía grande y en negrita (`font-weight: 700; font-size: 0.88rem`).
|
||||||
|
* **Código de Asignatura** y **Aula** mostrados como datos secundarios en badges de alto contraste (código con fondo oscuro translúcido y aula con isotipo y fondo blanco translúcido).
|
||||||
|
* Modal interactivo instantáneo al hacer clic en cualquier evento con todos los detalles académicos de la clase y enlace a la ficha completa.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Filtros por Aula y Materia en el Calendario de Reservas
|
||||||
|
* **Barra de Herramientas de Filtros Interactiva (`calendar.html`):**
|
||||||
|
* **Filtrar por Aula:** Desplegable dinámico con todas las aulas activas ordenadas por edificio y código (físicas y virtuales). Al seleccionar un aula (ej. `Aula 402`), se eliminan todas las superposiciones visuales mostrando el cronograma limpio de ese espacio.
|
||||||
|
* **Filtrar por Materia:** Desplegable ordenado alfabéticamente con formato `[Código] Nombre de la Asignatura`. Permite aislar todas las clases de una asignatura en particular.
|
||||||
|
* **Filtrar por Carrera:** Filtro adicional por plan académico/carrera.
|
||||||
|
* **Botón Limpiar Filtros:** Restablece instantáneamente todos los controles y refresca la vista.
|
||||||
|
* **Insignias de Estado:** Indicador en tiempo real de los filtros aplicados y contador de clases visibles (`X clases visibles`).
|
||||||
|
* **Soporte en Backend (`app/routes/schedule.py`):**
|
||||||
|
* `calendar_view`: Suministra las listas de `classrooms`, `subjects` y `careers` a la plantilla.
|
||||||
|
* `calendar_data`: Procesa `classroom_id`, `subject_id`, `career_id` y `search` aplicando cláusulas `filter()` con `JOIN` en SQLAlchemy.
|
||||||
|
* `refetchEvents()` de FullCalendar conectado a los eventos `change` de cada selector para recarga inmediata vía AJAX sin parpadeo de pantalla.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Módulo de Gestión de Edificios y Corrección de Vistas de Aulas
|
||||||
|
* **Corrección de Plantillas de Aulas Faltantes:**
|
||||||
|
* Se crearon las plantillas [app/templates/classrooms/edit.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/edit.html) y [app/templates/classrooms/view.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/view.html), solucionando el error HTTP 500 (`TemplateNotFound: classrooms/edit.html`) y la redirección 302 que ocurría al hacer clic en cualquier aula.
|
||||||
|
* La vista de ficha técnica del aula ahora muestra detalles de capacidad, piso, estado, edificio y la tabla interactiva de próximas reservas.
|
||||||
|
* **Modelo y Base de Datos de Edificios (`Building`):**
|
||||||
|
* Se creó el modelo [app/models/building.py](file:///c:/Workspace/admin-edu-space/app/models/building.py) (`id`, `name`, `code`, `address`, `floors`, `description`, `is_active`, timestamps y relación con `Classroom`).
|
||||||
|
* Migración DDL en PostgreSQL (`CREATE TABLE buildings`, `ALTER TABLE classrooms ADD COLUMN building_id`).
|
||||||
|
* Actualización de [init_db.py](file:///c:/Workspace/admin-edu-space/init_db.py) y siembra de edificios institucionales iniciales (`Edificio Central` y `Campus Virtual`) con vinculación de las 25 aulas existentes.
|
||||||
|
* **Rutas y Gestión Completa (`app/routes/buildings.py`):**
|
||||||
|
* `/buildings`: Listado con métricas de infraestructura (total de edificios, aulas activas, capacidad total y plantas/pisos), barra de búsqueda y filtros por estado.
|
||||||
|
* `/buildings/<id>`: Vista de detalle con distribución de aulas organizadas visualmente por piso (Planta Baja, Piso 1, Piso 2, etc.), capacidad por sala y acciones directas.
|
||||||
|
* `/buildings/add` y `/buildings/<id>/edit`: Formularios con validación WTForms para alta y actualización.
|
||||||
|
* `/buildings/<id>/toggle` y `/buildings/<id>/delete`: Control de activación y eliminación protegida (impide borrar edificios que contengan aulas asignadas).
|
||||||
|
* **Navegación (`app/templates/base.html`):**
|
||||||
|
* Enlace a **Edificios** (`/buildings`) incorporado en el menú **Classrooms** y en el menú administrativo **Management**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Mapeo de Campos del Google Sheet y Concepto de Turno (Mañana / Tarde)
|
||||||
|
* **Uso de los 5 Campos de la Cartelera Oficial:**
|
||||||
|
* **Codigo (`Subject.code` y `Commission.code`):** Almacena el código académico real (`INGA0003`, `ASIG00165`). Se expone como badge monoespaciado en eventos de calendario, comisiones y vistas administrativas.
|
||||||
|
* **Asignatura (`Subject.name`):** Título principal de la materia destacado en texto grande y en negrita en FullCalendar y en la cartelera diaria.
|
||||||
|
* **Aula (`Classroom.code`, `Classroom.building`, `Classroom.floor`, `Classroom.capacity`):** Normalización de espacios físicos y virtuales vinculados al nuevo modelo `Building`.
|
||||||
|
* **Carrera (`Career.name` y `Subject.career_id`):** Entidad de carrera universitaria con relaciones relacionales normalizadas.
|
||||||
|
* **Horarios (`Commission.schedule`, `Reservation.start_time`, `Reservation.end_time`):** Rango horario parseado hacia objetos `datetime` nativos para el motor de reservas y el calendario.
|
||||||
|
* **Modelado y Captura de Turnos (`TURNO MAÑANA` y `TURNO VESPERTINO`):**
|
||||||
|
* Se agregó la columna `shift` en las tablas `commissions` y `reservations` de PostgreSQL y en sus respectivos modelos de SQLAlchemy.
|
||||||
|
* El parser de [app/services/sheets_importer.py](file:///c:/Workspace/admin-edu-space/app/services/sheets_importer.py) identifica las filas delimitadoras de bloque (`TURNO MAÑANA` vs `TURNO VESPERTINO / TARDE`) y corrobora la hora de inicio (< 13:00hs = Mañana, $\ge$ 14:00hs = Tarde).
|
||||||
|
* Se sincronizaron todas las comisiones y reservas de cursada: 15 comisiones y 48 reservas en `Turno Mañana`; 191 comisiones y 364 reservas en `Turno Tarde / Vespertino`.
|
||||||
|
* En el Calendario de Reservas (`/schedule/calendar`) se añadió el selector **Filtrar por Turno** (`Todos`, `Mañana`, `Tarde / Vespertino`), permitiendo a los usuarios aislar las franjas horarias y resolver la alta concentración de clases en el horario vespertino (17:30 a 21:30hs).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Accesos Directos en Panel, Ocultamiento de Virtuales, Filtro por Piso, Dirección Oficial y Traducción
|
||||||
|
* **Tarjetas Estadísticas como Accesos Directos Interactivos ([app/templates/dashboard.html](file:///c:/Workspace/admin-edu-space/app/templates/dashboard.html)):**
|
||||||
|
* **Total de Aulas (25):** Redirige a `/classrooms/list` con feedback visual de hover y elevación.
|
||||||
|
* **Total de Materias (206):** Redirige a `/schedule/commissions`.
|
||||||
|
* **Reservas de Hoy (18):** Redirige a `/schedule/today`.
|
||||||
|
* **Esta Semana (103):** Redirige a `/schedule/calendar`.
|
||||||
|
* Se añadieron microinteracciones CSS en [app/static/css/style.css](file:///c:/Workspace/admin-edu-space/app/static/css/style.css) (`.dashboard-stat-card`).
|
||||||
|
* **Ocultar Aulas Virtuales y Filtro por Piso:**
|
||||||
|
* **Calendario ([app/templates/schedule/calendar.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/calendar.html)):** Switch interactivo *Ocultar Aulas Virtuales* (indica capacidad ilimitada) y selector *Filtrar por Piso* (`Planta Baja`, `Piso 1`, `Piso 2`, `Piso 3`, `Piso 4`). El backend `/schedule/calendar-data` excluye las aulas virtuales o filtra por número de piso.
|
||||||
|
* **Cartelera del Día ([app/templates/schedule/today.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/today.html)):** Barra de filtros completa con soporte para ocultar virtuales, piso y turno.
|
||||||
|
* **Listado de Reservas ([app/templates/schedule/list.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/list.html)):** Endpoint `/schedule/list` totalmente operativo con paginación de 20 registros, preservación de query params y filtros de piso, turno, virtuales, estado y rango de fechas.
|
||||||
|
* **Dirección Oficial de la Sede Central:**
|
||||||
|
* Se configuró la dirección oficial de la Universidad de la Ciudad de Buenos Aires (UniCABA): **`Tte. Gral. Juan Domingo Perón 802, CABA`** en la base de datos, en [init_db.py](file:///c:/Workspace/admin-edu-space/init_db.py) y en el importador [app/services/sheets_importer.py](file:///c:/Workspace/admin-edu-space/app/services/sheets_importer.py).
|
||||||
|
* **Traducción Integral al Español y Catálogo Babel:**
|
||||||
|
* Se agregaron traducciones en [translations/es/LC_MESSAGES/messages.po](file:///c:/Workspace/admin-edu-space/translations/es/LC_MESSAGES/messages.po) y se recompiló el binario `.mo` con `pybabel`.
|
||||||
|
* Textos traducidos: `Buildings` $\rightarrow$ *Edificios*, `Management` $\rightarrow$ *Gestión*, `Timeline View` $\rightarrow$ *Vista Cronológica*, `Grid View` $\rightarrow$ *Vista en Tabla*, `Active Classrooms` $\rightarrow$ *Aulas Ocupadas*, `All Reservations` $\rightarrow$ *Todas las Reservas*, `AI Room Optimizer` $\rightarrow$ *Optimizador de Aulas*, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Actualización Integral de Roles RBAC, Capacidad Infinita (∞) y Horarios de Próximas Clases
|
||||||
|
* **Actualización de Roles, Grupos de Acceso y Matriz de Permisos ([app/models/role.py](file:///c:/Workspace/admin-edu-space/app/models/role.py), [init_db.py](file:///c:/Workspace/admin-edu-space/init_db.py)):**
|
||||||
|
* Se amplió `SYSTEM_MODULES` para cubrir los 8 módulos del sistema:
|
||||||
|
1. `classrooms`: **Aulas y Espacios** (físicas, virtuales y capacidades).
|
||||||
|
2. `buildings`: **Edificios y Sedes** (Sede Central, Campus Virtual, plantas).
|
||||||
|
3. `reservations`: **Reservas de Aulas** (aprobación, confirmación, cancelación).
|
||||||
|
4. `schedule`: **Cartelera y Cronograma** (calendario, cartelera del día, comisiones).
|
||||||
|
5. `academic`: **Gestión Académica** (carreras, asignaturas, códigos).
|
||||||
|
6. `import`: **Sincronización Sheets** (importador Google Sheets).
|
||||||
|
7. `optimizer`: **Optimizador IA** (algoritmo genético áulico).
|
||||||
|
8. `users`: **Usuarios y Accesos RBAC** (cuentas, roles y matriz).
|
||||||
|
* Se actualizaron los perfiles estándar (`Admin`, `Docente`, `Operador`, `Consulta`) en PostgreSQL y en `init_db.py`.
|
||||||
|
* Se tradujeron completamente las plantillas [app/templates/admin/roles/list.html](file:///c:/Workspace/admin-edu-space/app/templates/admin/roles/list.html) y [app/templates/admin/roles/edit.html](file:///c:/Workspace/admin-edu-space/app/templates/admin/roles/edit.html) al español.
|
||||||
|
* **Soporte de Capacidad Infinita (∞) y Aclaración ([app/forms/classroom.py](file:///c:/Workspace/admin-edu-space/app/forms/classroom.py), [app/templates/classrooms/edit.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/edit.html)):**
|
||||||
|
* Se actualizó `ClassroomForm.capacity` con validador personalizado que admite el símbolo **`∞`**, `inf`, `0` o cualquier capacidad numérica positiva.
|
||||||
|
* Se corrigió la validación de `floor` con `InputRequired` y `NumberRange(min=0)` para aceptar Planta Baja (Piso 0).
|
||||||
|
* En la interfaz de edición y alta de aulas se incluyó el botón de acción rápida **`Ilimitada (∞)`** que rellena automáticamente el campo con el símbolo `∞`.
|
||||||
|
* Se añadió una leyenda explicativa directa: *"Para aulas virtuales o eventos sin aforo restringido, ingrese ∞ o 0 (Capacidad ilimitada)"*.
|
||||||
|
* Se tradujo el botón de guardado a *"Guardar Cambios"* (reemplazando *"Save Changes"*).
|
||||||
|
* El aula virtual institucional (`Campus Virtual - VIRTUAL`) se actualizó a capacidad `0` (`∞`), renderizando el badge `∞ Ilimitada` en listados y fichas.
|
||||||
|
* **Próximas Clases: Visualización Exclusiva del Horario ([app/templates/dashboard.html](file:///c:/Workspace/admin-edu-space/app/templates/dashboard.html), [app/templates/classrooms/view.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/view.html)):**
|
||||||
|
* En la tabla de *Próximas Reservas* del panel y en la ficha del aula se cambió la columna a **"Horario"**, removiendo la fecha del calendario y destacando únicamente el rango horario de cursada (ej. `17:30 - 21:30 hs`).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
# 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,273 +1,249 @@
|
|||||||
# Panel de Administración Edu-Space (UniCABA)
|
# Edu-Space Admin Panel
|
||||||
|
|
||||||
Plataforma integral de gestión áulica, asignación de espacios, control de horarios, seguimiento académico y reservas para la **Universidad de la Ciudad de Buenos Aires (UniCABA)**.
|
A comprehensive web-based administration interface for the Edu-Space Classroom Reservation System built with Flask.
|
||||||
|
|
||||||
El proyecto se basa en una arquitectura **desacoplada (Frontend / Backend)** en formato monorepo.
|
## Features
|
||||||
|
|
||||||
**Versión Actual:** `3.1.0` (Fases 0 a 5 completadas, Fase 5.1 Bedelía UX & ABMs finalizada)
|
### 🔐 Authentication & Security
|
||||||
**Institución:** Universidad de la Ciudad de Buenos Aires (UniCABA)
|
- 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
|
||||||
|
|
||||||
## 🗺️ Estado del Roadmap MVP
|
### 📅 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
|
||||||
Fase 0: Desacople y Arquitectura Base [████████████████████] 100% (Completado)
|
- Real-time statistics
|
||||||
Fase 1: Paridad Legacy y Estabilización [████████████████████] 100% (Completado)
|
- Monthly reservation charts
|
||||||
Fase 2: Co-docencia y Matriz Conflictos [████████████████████] 100% (Completado)
|
- Quick actions interface
|
||||||
Fase 3: Hitos Evaluativos y Calificaciones[████████████████████] 100% (Completado)
|
- Upcoming reservations overview
|
||||||
Fase 4: UI/UX Drag & Drop e Impersonación[████████████████████] 100% (Completado)
|
|
||||||
Fase 5: Integración Auth, Email & Moodle [████████████████████] 100% (Completado)
|
|
||||||
Fase 5.1: Bedelía UX, ABMs y Purga Datos [████████████████████] 100% (Completado)
|
|
||||||
Fase 6: Despliegue Producción e Infraest.[███████░░░░░░░░░░░░░] 35% (En Curso)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
### 🎨 Modern UI/UX
|
||||||
|
- Responsive Bootstrap 5 design
|
||||||
|
- Dark theme support
|
||||||
|
- Mobile-friendly interface
|
||||||
|
- Interactive components and charts
|
||||||
|
|
||||||
## 🏛️ Sede Institucional y Ubicaciones
|
## Tech Stack
|
||||||
* **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$)**.
|
|
||||||
|
|
||||||
---
|
- **Backend**: Flask (Python)
|
||||||
|
- **Database**: SQLAlchemy (SQLite/PostgreSQL)
|
||||||
|
- **Frontend**: Bootstrap 5, jQuery, Chart.js
|
||||||
|
- **Authentication**: Flask-Login, Werkzeug
|
||||||
|
- **Forms**: Flask-WTF
|
||||||
|
- **Calendar**: FullCalendar.js
|
||||||
|
|
||||||
## 🏗️ Arquitectura del Proyecto (Monorepo)
|
## Installation
|
||||||
|
|
||||||
El repositorio implementa un patrón desacoplado en formato monorepo compuesto por la **Core API (Backend)**, la capa de mediación y vistas **BFF (Frontend)**, herramientas unificadas de despliegue, auditoría automatizada y flujos de CI/CD:
|
### Prerequisites
|
||||||
|
- Python 3.8+
|
||||||
|
- pip package manager
|
||||||
|
|
||||||
```text
|
### Setup
|
||||||
/
|
|
||||||
├── backend/ # Capa Core API (Python 3.10+ / Flask / SQLAlchemy)
|
|
||||||
│ ├── app/ # Lógica de negocio y arquitectura desacoplada
|
|
||||||
│ │ ├── models/ # Modelos ORM (User, Role, Classroom, Reservation, etc.)
|
|
||||||
│ │ ├── routes/api/ # Endpoints REST JSON v1 (/auth, /classrooms, /admin, etc.)
|
|
||||||
│ │ ├── schemas/ # DTOs y validación de contratos (Pydantic v2)
|
|
||||||
│ │ ├── services/ # Servicios de dominio (JWT, Email SMTP, Moodle, Queue)
|
|
||||||
│ │ └── security/ # Pipeline de filtros y validación stateless de JWT
|
|
||||||
│ ├── tests/ # Suite activa de pruebas unitarias y de integración (pytest)
|
|
||||||
│ ├── init_db.py # Inicializador idempotente de esquema, roles RBAC y semillas
|
|
||||||
│ ├── backup_db.sh # Generador automatizado de backups para PostgreSQL
|
|
||||||
│ ├── classrooms_db.sql # Snapshot integral de base de datos para despliegue
|
|
||||||
│ ├── wsgi.py # Entrada WSGI para ejecución con Gunicorn
|
|
||||||
│ └── requirements.txt # Dependencias Python auditadas (0 vulnerabilidades / CVEs)
|
|
||||||
│
|
|
||||||
├── frontend/ # Backend-For-Frontend BFF (Node.js 20 LTS / Express)
|
|
||||||
│ ├── src/ # Capa servidora de mediación web
|
|
||||||
│ │ ├── app.js # Servidor Express, seguridad Helmet, rate-limiting
|
|
||||||
│ │ ├── controllers/ # Controladores de orquestación y mediación
|
|
||||||
│ │ ├── middlewares/ # Sesión por Cookie HttpOnly, JWT injection y RBAC
|
|
||||||
│ │ ├── routes/ # Enrutamiento web (auth, schedule, classrooms, admin)
|
|
||||||
│ │ └── services/ # Cliente Axios centralizado hacia Flask API
|
|
||||||
│ ├── views/ # Plantillas y componentes dinámicos en Nunjucks (.html)
|
|
||||||
│ │ ├── admin/ # Paneles de gestión institucional y configuración
|
|
||||||
│ │ ├── classrooms/ # Catálogo de aulas y visualización de sedes
|
|
||||||
│ │ ├── schedule/ # Cronograma de comisiones, reservas y calendario
|
|
||||||
│ │ ├── my_subjects/ # Libreta de calificaciones, hitos evaluativos y cursada
|
|
||||||
│ │ └── partials/ # Componentes reutilizables, modales y navegación
|
|
||||||
│ ├── public/ # Assets estáticos (CSS responsive 720p/1080p, JS cliente)
|
|
||||||
│ └── package.json # Dependencias y manifiesto de Node.js
|
|
||||||
│
|
|
||||||
├── install.sh # Instalador nativo unificado para Linux / Proxmox LXC
|
|
||||||
├── security_audit.bat # Suite automatizada de auditoría (SAST, SCA, DAST)
|
|
||||||
├── .gitea/workflows/ # Pipelines de integración continua (CI/CD en Gitea)
|
|
||||||
├── docs/ # Especificaciones técnicas, auditorías y changelogs
|
|
||||||
├── ROADMAP_MVP.md # Plan de evolución, estadios y fases del proyecto
|
|
||||||
├── SECURITY_AUDIT_REPORT.md # Informe formal de auditoría y hardening
|
|
||||||
└── README.md # Documentación general y guía operativa
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🐍 Backend Core API (Python + Flask)
|
1. **Clone or setup the project:**
|
||||||
- Funciona exclusivamente como una **REST API** que expone endpoints bajo `/api/v1/` retornando **JSON**.
|
```bash
|
||||||
- Encargado de la persistencia de datos (SQLAlchemy / PostgreSQL o SQLite), lógica de negocio, servicio de tokens JWT (Access & Refresh), matriz RBAC, y algoritmos de optimización heurística (Módulo Predictivo).
|
cd /Users/alejandro.v/PycharmProjects/admin-edu-space
|
||||||
- **Prohibido** retornar HTML o utilizar Jinja2/`render_template` en este entorno.
|
```
|
||||||
|
|
||||||
### 🟢 Frontend BFF (Node.js + Express)
|
2. **Create virtual environment:**
|
||||||
- Aplicación Backend-For-Frontend que renderiza las vistas para el cliente mediante **Nunjucks**.
|
|
||||||
- Gestiona la sesión segura del usuario almacenando el token JWT recibido de Flask en una **Cookie HttpOnly**.
|
|
||||||
- Actúa como proxy de conexión con el Backend de Python empleando **Axios** (inyectando el Bearer token interceptado).
|
|
||||||
- Blindado con **Helmet** (cabeceras de seguridad HTTP) y **express-rate-limit** para mitigar ataques de fuerza bruta.
|
|
||||||
- Interfaz adaptativa y responsive diseñada para pantallas estándar argentinas (720p, 1366x768 y 1080p) con gráficos dinámicos (**Chart.js**).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Dashboards Personalizados por Rol
|
|
||||||
|
|
||||||
La plataforma cuenta con paneles adaptados a las necesidades operativas de cada perfil institucional:
|
|
||||||
|
|
||||||
* **Administrador (`/dashboard?role=admin`):** Cockpit institucional con métricas globales, auditoría de seguridad RBAC, tendencias mensuales y panel interactivo de **Estado y Percentiles de Asignación de Aulas Físicas** (asignadas vs disponibles en número y porcentaje).
|
|
||||||
* **Bedelía (`/dashboard?role=bedelia`):** Control operativo del campus en tiempo real, balance de aulas presenciales asignadas (96.3%) y libres (3.7%), cartelera del día y bandeja de aprobación de reservas pendientes.
|
|
||||||
* **Docente / Profesor (`/dashboard?role=docente`):** Sesiones del día con accesos directos a videollamadas y gestión de comisiones.
|
|
||||||
* **Alumno / Estudiante (`/dashboard?role=alumno`):** Brújula de cursada *"¿Dónde curso hoy?"* con ubicación por piso y aula o enlace virtual, junto al cronograma de exámenes y entregas de TPs.
|
|
||||||
|
|
||||||
> **Modo Vista Previa:** Los usuarios con perfil administrador o directivo pueden previsualizar cualquier perspectiva utilizando el selector de roles en la barra superior o pasando el parámetro `?role={admin|bedelia|docente|alumno}` en la URL.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🏛️ Circuito Operativo de Bedelía (UX & Flujo Simplificado)
|
|
||||||
|
|
||||||
Para garantizar una experiencia de usuario rápida y sin fricción para el personal de Bedelía, el circuito de gestión áulica se divide en 3 etapas directas:
|
|
||||||
|
|
||||||
```
|
|
||||||
[ 1. Creación / Edición de Aula ] ──► [ 2. Asignación de Comisión al Aula ] ──► [ 3. Cartelera y Grilla ]
|
|
||||||
- Sede Central o Campus Virtual - Desde Comisiones (1 clic) - Detección de colisiones
|
|
||||||
- Piso y Aforo real - O desde Ficha de Aula - Drag & Drop semanal
|
|
||||||
- Tipo (Teórica / Laboratorio) - Autocompletado de cupo y Meet - Cartelera del día en vivo
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1. Creación y Mantenimiento de Aulas (`/classrooms/list_classrooms`)
|
|
||||||
* Acceso directo a **"Nueva Aula"**: se selecciona la sede (Edificio Central o Campus Virtual), piso (`PB`, `1`, `2`, `3`, `4`) y tipo de espacio (Teórica, Laboratorio de Informática, etc.).
|
|
||||||
* Las aulas virtuales se configuran con capacidad infinita ($\infty$) y no distorsionan la capacidad de bancos físicos del edificio central.
|
|
||||||
* En el catálogo de aulas, cada tarjeta muestra el aforo, piso, equipamiento y el botón de acción rápida **"Asignar Comisión"**, que abre el formulario de reserva con el aula ya preseleccionada.
|
|
||||||
|
|
||||||
### 2. Asignación de Comisión al Aula (`/schedule/add_reservation`)
|
|
||||||
La asignación puede realizarse desde dos puntos de entrada según la necesidad operativa:
|
|
||||||
* **Desde el Catálogo de Comisiones (`/admin/commissions_list`):**
|
|
||||||
- Cada fila cuenta con el botón de acceso rápido **"Asignar Aula / Horario"** (`bi-calendar-plus`).
|
|
||||||
- Al hacer clic, redirige a `/schedule/add_reservation?commission_id={id}` precargando de forma automática:
|
|
||||||
- Nombre de la asignatura y código de comisión.
|
|
||||||
- Cupo esperado de alumnos (`expected_attendees = max_students`).
|
|
||||||
- Enlace de videollamada preexistente (Google Meet / Teams).
|
|
||||||
* **Desde el Catálogo de Aulas (`/classrooms/list_classrooms`):**
|
|
||||||
- Al pulsar **"Asignar Comisión"**, redirige a `/schedule/add_reservation?classroom_id={id}`.
|
|
||||||
- El selector en cascada (**1. Edificio ➔ 2. Piso ➔ 3. Aula**) se auto-posiciona en la sede, planta y aula elegidas.
|
|
||||||
* **Validación en Vivo:** El sistema alerta inmediatamente si la cantidad de alumnos de la comisión excede la capacidad física del aula seleccionada.
|
|
||||||
|
|
||||||
### 3. Visualización y Control de Ocupación
|
|
||||||
* **Cartelera del Día (`/schedule/today_schedule`):** Bloques horarios de 07:00 a 21:00 hs con filtros por piso, turno y switch para ocultar aulas virtuales.
|
|
||||||
* **Grilla Semanal Interactiva (`/schedule/calendar`):** Arrastre de comisiones sin aula asignada desde el panel lateral con detección de colisiones físicas, solapamientos docentes y aforo en tiempo real (HTTP 409).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧹 Módulo de Purga y Sincronización Académica
|
|
||||||
|
|
||||||
* **Purga Segura y Selectiva (`/admin/google_sheets_import`):**
|
|
||||||
- Permite restablecer limpiamente reservas, comisiones, asignaturas, carreras y/o aulas para comenzar nuevos ciclos lectivos o pruebas de carga.
|
|
||||||
- **Confirmación Interactiva:** Cuenta con un modal de confirmación (`#purgeConfirmModal`) que lista las entidades específicas a eliminar y previene clics accidentales.
|
|
||||||
- **Integridad Referencial Estricta:** Ejecuta la eliminación en orden topológico inverso de claves foráneas (`Reservation` ➔ `MilestoneGrade` ➔ `CommissionTeacher` ➔ `StudentEnrollment` ➔ `Commission` ➔ `Subject` ➔ `Career` ➔ `Classroom`), garantizando que la base de datos no rechace el borrado por restricciones relacionales.
|
|
||||||
- **Preservación Total de Identidades:** Los usuarios (`User`), contraseñas y roles (`Role`) están estrictamente blindados y nunca son afectados por la purga de datos académicos.
|
|
||||||
* **Importación desde Google Sheets:** Carga y sincronización dinámica de todas las materias y franjas horarias directamente desde la hoja institucional publicada.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚡ Capacidades Avanzadas de Gestión (Fases 2, 3 y 4 del MVP)
|
|
||||||
|
|
||||||
### 1. Grilla Semanal Interactiva (Drag & Drop) y Validación de Colisiones en Tiempo Real
|
|
||||||
- **FullCalendar Interactivo:** Arrastre y edición de horarios directo en el calendario general de Bedelía (`/schedule/calendar`).
|
|
||||||
- **Sidebar de Comisiones por Asignar:** Panel lateral colapsable con las comisiones sin aula, con arrastre fluido hacia la grilla semanal o diaria.
|
|
||||||
- **Detección Automática de Choques (HTTP 409):** Validación previa ante cada movimiento (`POST /api/v1/reservations/drag-update`) verificando colisiones físicas de aula, superposición docente y límite de aforo. Si hay conflicto, el evento se revierte automáticamente (`info.revert()`) y se notifica al usuario con un Toast explicativo.
|
|
||||||
|
|
||||||
### 2. Modo Impersonación del Superadmin ("Login as")
|
|
||||||
- Permite al Administrador navegar la plataforma adoptando exactamente la perspectiva y permisos de cualquier docente o estudiante.
|
|
||||||
- Activación con un clic (`bi-incognito`) desde el catálogo de usuarios (`/admin/users_list`).
|
|
||||||
- Banner superior permanente de advertencia visual en color ámbar con identificación del usuario adoptado y botón de salida inmediata.
|
|
||||||
- Trazabilidad y auditoría obligatoria inmutable en `audit_logs` (`IMPERSONATE_START` e `IMPERSONATE_END`).
|
|
||||||
|
|
||||||
### 3. Libro de Calificaciones (*Gradebook*) & Cierre Formal de Actas
|
|
||||||
- Matriz bidimensional interactiva de calificaciones por comisión (`/schedule/gradebook/:id`) con autoguardado asíncrono debounced (500 ms).
|
|
||||||
- Cálculo en vivo de promedios ponderados y condiciones académicas (*Promocionado*, *Regular*, *Libre*).
|
|
||||||
- Cierre formal de actas con generación de código inmutable (`ACTA-YYYY-Sem-ID`), auditoría en `audit_logs` y congelamiento de modificaciones.
|
|
||||||
- Soporte para reapertura excepcional justificada por Bedelía.
|
|
||||||
- Portal del Estudiante (`/schedule/my_grades` / `/mis-materias/mis-notas`) para consulta de calificaciones y comprobantes.
|
|
||||||
|
|
||||||
### 4. Co-docencia, Matriz de Conflictos y Regla Diaria
|
|
||||||
- Soporte de cátedras con múltiples docentes por comisión (`CommissionTeacher`).
|
|
||||||
- Excepción por co-docencia: autoriza superposiciones de horario docente si la cátedra cuenta con un co-docente registrado disponible.
|
|
||||||
- Regla restrictiva de cursada diaria: un alumno no puede cursar dos asignaturas regulares el mismo día, con excepciones automáticas para talleres/cursos cortos (`is_short_course`) o autorizaciones especiales emitidas por Bedelía.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📅 Ficha de Reserva & Gestión de Comisiones
|
|
||||||
|
|
||||||
* **Ficha de Reserva Detallada (`/schedule/view_reservation?id={id}`):** Información completa del espacio físico o virtual asignado, capacidad y asistencia prevista, comisión académica vinculada, docente a cargo, botones de acción inmediata (*Modificar Reserva*, *Confirmar Reserva*, *Cancelar Reserva*) y acceso con un clic a la sala de videollamada.
|
|
||||||
* **Gestión de Comisiones y Vinculación con Asignaturas (`/admin/commissions_list`):**
|
|
||||||
- Botón y modal interactivo **Nueva Comisión** (`#modalAddCommission`) para registrar comisiones y vincularlas directamente a su **Asignatura / Materia**, indicando cuatrimestre, año, turno, cupo y docente responsable.
|
|
||||||
- Generación automática de enlace institucional de Google Meet cuando no se especifica un enlace propio.
|
|
||||||
- Tabla operativa con visualización explícita del código de comisión, nombre de materia, código de asignatura y carrera.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Instalación y Despliegue Local
|
|
||||||
|
|
||||||
### Requisitos previos
|
|
||||||
- Node.js (v18 o superior)
|
|
||||||
- Python 3.10+
|
|
||||||
- SQLite / PostgreSQL
|
|
||||||
|
|
||||||
### 1. Iniciar el Backend (Flask)
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
|
||||||
python -m venv venv
|
python -m venv venv
|
||||||
# Activar venv (Windows: venv\Scripts\activate, Linux/Mac: source venv/bin/activate)
|
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Install dependencies:**
|
||||||
|
```bash
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
flask run --port=5000
|
|
||||||
```
|
```
|
||||||
*(El backend quedará escuchando en http://localhost:5000)*
|
|
||||||
|
|
||||||
### 2. Iniciar el Frontend (Node/Express)
|
4. **Setup environment variables:**
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cp .env.example .env
|
||||||
npm install
|
# Edit .env with your configuration
|
||||||
npm run dev
|
|
||||||
# o en su defecto:
|
|
||||||
node src/app.js
|
|
||||||
```
|
```
|
||||||
*(El frontend quedará escuchando en http://localhost:3000)*
|
|
||||||
|
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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📦 Despliegue Automatizado en Producción (Linux / Proxmox LXC)
|
**Note**: This admin panel is designed to work alongside the Spring Boot microservice. Ensure both services are running for complete functionality.
|
||||||
|
|
||||||
Para servidores Debian 12 / Ubuntu en Proxmox LXC o VPS, el repositorio incluye el instalador unificado [install.sh](install.sh) en la raíz:
|
|
||||||
|
|
||||||
# Despliegue interactivo (pregunta si mantener BD, restaurar snapshot o crear base limpia):
|
|
||||||
sudo bash install.sh
|
|
||||||
|
|
||||||
# Despliegue desatendido manteniendo la base de datos existente:
|
|
||||||
sudo bash install.sh --keep-db
|
|
||||||
|
|
||||||
# Despliegue desatendido preservando la base de datos y los usuarios/credenciales configurados:
|
|
||||||
sudo bash install.sh --keep-db --keep-users
|
|
||||||
|
|
||||||
# Despliegue desatendido con base de datos limpia sin carreras, materias, ciclos lectivos, aulas ni edificios demo:
|
|
||||||
sudo bash install.sh --empty-academic
|
|
||||||
|
|
||||||
# Despliegue desatendido recreando la base de datos desde cero con snapshot de muestra (DROP y restore classrooms_db.sql):
|
|
||||||
sudo bash install.sh --fresh-db
|
|
||||||
```
|
|
||||||
|
|
||||||
También es posible inicializar directamente la base de datos limpia desde Python en cualquier entorno:
|
|
||||||
```bash
|
|
||||||
# Inicialización estándar con datos de muestra:
|
|
||||||
python backend/init_db.py
|
|
||||||
|
|
||||||
# Inicialización limpia (sin carreras, materias, ciclos lectivos, aulas, edificios ni reservas):
|
|
||||||
python backend/init_db.py --empty-academic
|
|
||||||
```
|
|
||||||
|
|
||||||
El script configura automáticamente:
|
|
||||||
1. Entorno Python 3.10+ y virtualenv en `backend/venv` con Gunicorn y servicio `admin-edu-space-backend.service` (puerto 5000).
|
|
||||||
2. Entorno Node.js 20 LTS en `frontend/` con servicio `admin-edu-space-frontend.service` (puerto 3000).
|
|
||||||
3. Motor PostgreSQL local con configuración de usuario y opciones seguras para preservar o restaurar la base `classrooms_db`.
|
|
||||||
4. Servicio maestro unificado `admin-edu-space.service` gestionable vía `systemctl {status|restart|stop} admin-edu-space`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 Pruebas Automatizadas
|
|
||||||
|
|
||||||
Para ejecutar la suite completa de 47 pruebas unitarias y de integración (JWT, ABMs, Reglas, Optimizador, Calificaciones y Moodle):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
$env:PYTHONPATH="backend"
|
|
||||||
.\venv\Scripts\python.exe -m pytest tests -v
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 Documentación Técnica & Changelogs
|
|
||||||
* [Roadmap MVP: Fases y Estadios del Proyecto](ROADMAP_MVP.md)
|
|
||||||
* [Changelog Oficial del Proyecto (v2.8.0)](CHANGELOG.md)
|
|
||||||
* [Changelog MVP Consolidado](CHANGELOG_MVP.md)
|
|
||||||
* [Estado de la Migración y Arquitectura Objetivo UniCABA](ESTADO_MIGRACION_Y_ARQUITECTURA_OBJETIVO.md)
|
|
||||||
* [Changelog Detallado: Clases Virtuales y Dashboards](docs/CHANGELOG_VIRTUAL_CLASSES_AND_DASHBOARDS.md)
|
|
||||||
* [Auditoría y Sesión de Reingeniería](docs/CHANGELOG_SESSION.md)
|
|
||||||
* [Guía de Integración de Cronograma](docs/SCHEDULE_INTEGRATION_GUIDE.md)
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
# ROADMAP MVP — Sistema Integral UniCABA
|
|
||||||
## Gestión de Espacios Áulicos, Actividad de Cursada y Seguimiento Académico
|
|
||||||
|
|
||||||
**Institución:** Universidad de la Ciudad de Buenos Aires (UniCABA)
|
|
||||||
**Versión:** 3.1.0
|
|
||||||
**Fecha de Actualización:** 23 de Septiembre de 2026
|
|
||||||
**Rama Base:** `mvp`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🗺️ Visión del Proyecto
|
|
||||||
Construir y evolucionar la plataforma de gestión académica y espacial de **UniCABA** hacia un sistema integral de clase universitaria, desacoplado en una arquitectura **Backend RESTful (Python/Flask) + Frontend BFF (Node.js/Express/Nunjucks)**, capaz de gobernar tanto los espacios físicos de la sede central como la dinámica áulica del campus virtual, garantizando una experiencia fluida para **Administradores**, **Bedelía**, **Docentes** y **Estudiantes**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Estado Global de Progreso
|
|
||||||
|
|
||||||
```
|
|
||||||
Fase 0: Desacople y Arquitectura Base [████████████████████] 100% (Completado)
|
|
||||||
Fase 1: Paridad Legacy y Estabilización [████████████████████] 100% (Completado)
|
|
||||||
Fase 2: Co-docencia y Matriz Conflictos [████████████████████] 100% (Completado)
|
|
||||||
Fase 3: Hitos Evaluativos y Calificaciones[████████████████████] 100% (Completado)
|
|
||||||
Fase 4: UI/UX Drag & Drop e Impersonación[████████████████████] 100% (Completado)
|
|
||||||
Fase 5: Integración Auth, Email & Moodle [████████████████████] 100% (Completado)
|
|
||||||
Fase 5.1: Bedelía UX, ABMs y Purga Datos [████████████████████] 100% (Completado)
|
|
||||||
Fase 6: Despliegue Producción e Infraest.[███████░░░░░░░░░░░░░] 35% (En Curso)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Detalle de Fases y Estadios
|
|
||||||
|
|
||||||
### Fase 0: Desacople y Arquitectura Base ✅ *(Completada)*
|
|
||||||
* **Objetivo:** Separar el monolito Jinja2 legacy en dos servicios independientes de alto rendimiento.
|
|
||||||
* **Entregables:**
|
|
||||||
* [x] Creación del Backend Flask como API REST pura bajo `/api/v1/` retornando exclusivamente JSON.
|
|
||||||
* [x] Implementación del Frontend BFF en Node.js + Express con renderizado de plantillas mediante Nunjucks.
|
|
||||||
* [x] Autenticación desacoplada mediante JWT (`access_token`) transportado en Cookie `HttpOnly` segura.
|
|
||||||
* [x] Pipeline de seguridad `SecurityFilterChain` con cabeceras de blindaje OWASP y rate limiting dinámico.
|
|
||||||
* [x] Cliente HTTP Axios centralizado con inyección automática de Bearer token y manejo de sesiones.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Fase 1: Paridad Legacy, Estabilización de Virtualidad y BFF Core ✅ *(Completada)*
|
|
||||||
* **Objetivo:** Alcanzar paridad funcional total con la versión legacy, subsanando defectos críticos de virtualidad y dotando de interfaz específica a cada rol.
|
|
||||||
* **Entregables:**
|
|
||||||
* [x] **Importador de Google Sheets Corregido:**
|
|
||||||
* Eliminación de la colisión de horarios en el aula virtual única (`Campus Virtual · VIRTUAL`).
|
|
||||||
* Incremento de reservas registradas de 363 a 643 (incorporando 280 clases virtuales que se descartaban).
|
|
||||||
* Generación y persistencia automática de enlaces de Google Meet institucional por comisión y reserva.
|
|
||||||
* [x] **Dashboards Especializados por Rol:**
|
|
||||||
* Dashboard interactivo para **Bedelía** (aulas físicas, salas virtuales, aprobaciones pendientes).
|
|
||||||
* Dashboard interactivo para **Docente** (clases del día, comisiones, enlaces directos a Meet).
|
|
||||||
* Dashboard interactivo para **Alumno** (brújula de cursada *"¿Dónde curso hoy?"*, hitos y calendario).
|
|
||||||
* Cockpit institucional para **Admin** (métricas de capacidad, auditoría RBAC, demanda mensual).
|
|
||||||
* Selector dinámico de vista previa / roles en tiempo real (`?role=...`).
|
|
||||||
* [x] **Cartelera del Día (`today_schedule`):**
|
|
||||||
* Grilla temporal por bloques horarios (07:00 a 21:00 hs) con indicadores físicos y virtuales.
|
|
||||||
* Filtro dinámico para ocultar clases virtuales (`hide_virtual=1`) y segmentación por piso o turno.
|
|
||||||
* [x] **Acople de Ficha de Reserva (`schedule.view_reservation`):**
|
|
||||||
* Ruteo en BFF (`/schedule/view_reservation?id=...` y `/view/:id`).
|
|
||||||
* Endpoint REST `/api/v1/reservations/<id>` con enriquecimiento relacional (aula, comisión, docente).
|
|
||||||
* Soporte para modificar reserva (`edit_reservation`) con selectores en cascada, confirmación y cancelación.
|
|
||||||
* [x] **Gestión de Comisiones (`admin/commissions_list`):**
|
|
||||||
* Botón y modal interactivo `+ Nueva Comisión` (`#modalAddCommission`).
|
|
||||||
* Vinculación obligatoria y explícita a la **Asignatura / Materia**, indicando cuatrimestre, año lectivo, turno, docente a cargo, cupo y enlace virtual.
|
|
||||||
* Visualización en tabla de la materia vinculada con su código y carrera asociada.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Fase 2: Co-docencia, Matriz de Conflictos y Regla Diaria ✅ *(100% Completado)*
|
|
||||||
* **Objetivo:** Implementar las reglas de negocio académicas complejas para asignación de cátedras y cursada de alumnos.
|
|
||||||
* **Estadios:**
|
|
||||||
1. **Estadio 2.1 — Modelo de Co-docencia:**
|
|
||||||
- [x] Tabla relacional `commission_teachers` para soportar múltiples docentes por comisión (titulares, adjuntos, ayudantes).
|
|
||||||
- [x] Endpoints `/api/v1/commissions/<id>/teachers` para asociar y desvincular docentes de cátedra.
|
|
||||||
- [x] Adaptación de vistas de Bedelía y Comisiones para visualizar el equipo docente completo.
|
|
||||||
2. **Estadio 2.2 — Matriz de Conflictos y Validación:**
|
|
||||||
- [x] Validación estricta de conflicto físico: bloqueo de doble asignación de aula en mismo día y horario.
|
|
||||||
- [x] Validación de conflicto docente: detección de superposición horaria del profesor, con soporte de excepción justificada cuando la comisión cuenta con co-docencia.
|
|
||||||
- [x] Validación de aforo: bloqueo cuando la cantidad de alumnos excede la capacidad del aula física.
|
|
||||||
3. **Estadio 2.3 — Regla de Restricción Diaria del Alumno:**
|
|
||||||
- [x] Validación al matricular: un alumno no puede cursar dos asignaturas regulares en el mismo día calendario.
|
|
||||||
- [x] Excepción parametrizable: autorización automática si al menos una de las materias es un "curso corto" (`is_short_course`) o si existe autorización manual de Bedelía (`allow_same_day_exception`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Fase 3: Gestión de Hitos Evaluativos y Libro de Calificaciones 📅 *(Completada)*
|
|
||||||
* **Objetivo:** Proveer al cuerpo docente y estudiantil el seguimiento completo de instancias evaluativas.
|
|
||||||
* **Estadios:**
|
|
||||||
1. **Estadio 3.1 — Calendario de Evaluación por Comisión:**
|
|
||||||
- [x] Programación de parciales, recuperatorios, entregas de trabajos prácticos y exámenes finales.
|
|
||||||
- [x] Notificaciones preventivas en el dashboard del alumno con cuenta regresiva para exámenes.
|
|
||||||
2. **Estadio 3.2 — Libro de Calificaciones (*Gradebook*):**
|
|
||||||
- [x] Matriz de carga rápida de notas para docentes con autoguardado asíncrono.
|
|
||||||
- [x] Consulta individualizada de calificaciones y retroalimentaciones pedagógicas para alumnos.
|
|
||||||
- [x] Cierre de actas de regularidad y promoción para Bedelía.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Fase 4: UI/UX Avanzada para Bedelía & Modo Impersonación 🖥️ ✅ *(100% Completado)*
|
|
||||||
* **Objetivo:** Dotar a la oficina de Bedelía y a la Administración de herramientas interactivas de última generación.
|
|
||||||
* **Estadios:**
|
|
||||||
1. **Estadio 4.1 — Grilla Semanal Interactiva (Drag-and-Drop):**
|
|
||||||
- [x] Interfaz tipo TimeGrid interactiva con arrastre de comisiones para asignación rápida de aulas y turnos (`FullCalendar` `editable: true` + `droppable: true`).
|
|
||||||
- [x] Panel lateral de comisiones pendientes de aula (`unassigned_commissions`) con arrastre directo hacia la grilla.
|
|
||||||
- [x] Detección visual en tiempo real de aulas ocupadas, colisiones horarias, aforo insuficiente o choques de docentes con reversión automática y mensajes de alerta descriptivos vía `POST /api/v1/reservations/drag-update`.
|
|
||||||
2. **Estadio 4.2 — Modo Impersonación del Superadmin:**
|
|
||||||
- [x] Funcionalidad de *login as* con soporte para cabecera `X-Impersonate-User` en Backend Flask (`jwt_required`) y Frontend BFF (`authMiddleware`).
|
|
||||||
- [x] Botón directo de impersonación en la lista administrativa de usuarios (`admin/users/list.html`).
|
|
||||||
- [x] Banner de advertencia visual permanente en la barra superior con indicador de usuario impersonado y botón de salida inmediata hacia la sesión administrativa.
|
|
||||||
- [x] Trazabilidad obligatoria y estricta en el registro de auditoría (`audit_logs`) con eventos `IMPERSONATE_START` e `IMPERSONATE_END`.
|
|
||||||
3. **Estadio 4.3 — Integración del Optimizador Genético:**
|
|
||||||
- [x] Acople del motor heurístico de algoritmos genéticos a la base de datos real con cálculo de turnos reales (Mañana, Tarde, Vespertino).
|
|
||||||
- [x] Prevención de choques en guardado masivo con persistencia directa en base de datos (`POST /api/genetic/apply-optimization`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Fase 5: Integración Auth, Email & Moodle Backend 🛡️ ✉️ 🎓 ✅ *(100% Completado)*
|
|
||||||
* **Objetivo:** Centralizar la gestión de credenciales, autenticación híbrida, motor transaccional de correo y sincronización bidireccional asíncrona con Moodle 4.1 con tolerancia total a fallos.
|
|
||||||
* **Épicas Entregadas:**
|
|
||||||
1. **Épica 1 — Panel de Configuración Global (Perfil ADMIN):**
|
|
||||||
- [x] Módulo SMTP: UI/API dinámica con host, puerto, usuario, seguridad (TLS/SSL/NONE) y contraseña cifrada con Fernet (`/admin/settings`).
|
|
||||||
- [x] Módulo SSO: Toggles para habilitar/deshabilitar Login Local, Google OAuth 2.0 y Moodle SSO en base de datos.
|
|
||||||
- [x] Credenciales OAuth & Moodle: Client ID/Secret de Google y URL de Servidor / Token Web Services de Moodle 4.1.
|
|
||||||
2. **Épica 2 — Infraestructura Asíncrona y Caché:**
|
|
||||||
- [x] Capa de Caché Híbrida: `CacheService` con integración Redis y fallback en memoria/TTL.
|
|
||||||
- [x] Cola Persistente `MoodleSyncTask` con reintentos exponenciales y Dead Letter Queue (DLQ).
|
|
||||||
- [x] Panel de monitorización de cola con métricas en tiempo real, botón de "Forzar Sincronización Inmediata" y reintento masivo de tareas fallidas.
|
|
||||||
3. **Épica 3 — Autenticación Híbrida (SSO & Local Tolerante a Fallos):**
|
|
||||||
- [x] Login Local: Fallback con contraseña nativa y contingencia para ADMIN cuando está desactivado para usuarios generales.
|
|
||||||
- [x] Google OAuth 2.0: Restricción arquitectónica de dominios (`@unicaba.edu.ar`, `@lasalle.edu.ar`) inspirada en AlumnosLS.
|
|
||||||
- [x] Moodle Delegated Login: Autenticación contra `/login/token.php` de Moodle 4.1 con mapeo inicial de roles (Admin/Docente/Alumno) y desacople local para cambios posteriores por Bedelía/Admin.
|
|
||||||
4. **Épica 4 — Adaptadores Moodle 4.1 (Bidireccional):**
|
|
||||||
- [x] Cliente REST Moodle 4.1 (`moodle_client`) con soporte para `core_user_create_users`, `core_user_update_users`, `core_user_get_users`, `enrol_manual_enrol_users`, `enrol_manual_unenrol_users`, `core_enrol_get_enrolled_users`, `core_role_assign_roles`, `core_role_unassign_roles`.
|
|
||||||
- [x] Encolado automático y no bloqueante al crear o editar usuarios en Edu-Space.
|
|
||||||
5. **Épica 5 — Motor de Notificaciones (Email Dinámico):**
|
|
||||||
- [x] Transporte SMTP dinámico (`email_service`) instanciado en tiempo de ejecución leyendo credenciales descifradas de la BD.
|
|
||||||
- [x] Notificaciones transaccionales automáticas para profesores (asignación de comisiones, cronogramas de exámenes y alertas de sistema).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Fase 5.1: Bedelía UX, Cobertura Integral de ABMs y Purga Segura de Datos 🏛️ ⚡ ✅ *(100% Completado)*
|
|
||||||
* **Objetivo:** Optimizar de punta a punta la experiencia de usuario de Bedelía, dotar de ABMs completos a todas las entidades de Espacios, Académica y Reservas, y asegurar la purga de datos sin comprometer identidades ni claves foráneas.
|
|
||||||
* **Entregables:**
|
|
||||||
1. **Circuito Ágil de Bedelía:**
|
|
||||||
- [x] Flujo de asignación ágil: de lista de comisiones (`/admin/commissions_list`) a reserva de aula (`/schedule/add_reservation?commission_id=...`) con 1 clic.
|
|
||||||
- [x] Sincronización en cascada de selectores (Edificio -> Piso -> Aula) con detección de modalidad física/virtual y aforo.
|
|
||||||
- [x] Conexión de reservas con comisiones activas en vivo vía API REST desacoplada (fin de mocks estáticos).
|
|
||||||
- [x] Creación de aulas con vinculación consistente de sede y tipología.
|
|
||||||
2. **Cobertura ABM 100% (Espacios, Académica y Reservas):**
|
|
||||||
- [x] **Ciclos Lectivos (`AcademicTerm`):** ABM completo (Crear, Editar, Activar como Ciclo Actual, Toggle Activo/Inactivo rápido y Borrado protegido con soft-deactivate ante comisiones asociadas). Endpoints `/api/v1/admin/academic-terms` y vistas en `/admin/academic_terms`.
|
|
||||||
- [x] **Aulas y Espacios Físicos/Virtuales (`Classroom`):** ABM completo (Alta, Edición de aforo y piso, Toggle rápido activo/inactivo vía `/api/v1/classrooms/<id>/toggle`, y Eliminación con validación de reservas previas). Botones y modales en lista y detalle.
|
|
||||||
- [x] **Sedes y Edificios (`Building`):** ABM completo (Crear, Editar, Toggle y Eliminar sedes físicas e institucionales).
|
|
||||||
- [x] **Carreras (`Career`):** ABM completo (`/admin/careers_list`) con protección ante asignaturas existentes (soft-deactivation).
|
|
||||||
- [x] **Asignaturas (`Subject`):** ABM completo (`/admin/subjects_list`) con vinculación por carrera y mitigación de desvinculación forzada.
|
|
||||||
- [x] **Comisiones (`Commission`):** ABM completo (`/admin/commissions_list`) con asignación directa de profesores, turnos y cupos.
|
|
||||||
- [x] **Reservas (`Reservation`):** ABM completo (Creación, Edición, Confirmación, Cancelación lógica y Eliminación definitiva / Hard Delete con `/api/v1/reservations/<id>?hard=true`).
|
|
||||||
3. **Purga Segura de Datos Académicos:**
|
|
||||||
- [x] Modal de confirmación interactivo en frontend (`#purgeConfirmModal`) con feedback visual y prevención de borrado accidental.
|
|
||||||
- [x] Secuencia de purga backend en estricto orden de integridad referencial (respetando Foreign Keys).
|
|
||||||
- [x] Preservación garantizada de usuarios y roles RBAC ante reseteos de datos de cursada.
|
|
||||||
4. **Configuración y Personalización UI:**
|
|
||||||
- [x] Panel de configuración global (`/admin/settings`) con opción para habilitar o deshabilitar el saludo superior y banner de rol en el dashboard.
|
|
||||||
5. **Inicialización y Despliegue con Base Limpia:**
|
|
||||||
- [x] Soporte `--empty-academic` / `--no-demo` en `install.sh` e `init_db.py` para inicializar el sistema con roles y usuarios intactos pero con tablas académicas y de espacios totalmente vacías para configuración desde cero.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Fase 6: Producción Institucional, CI/CD y Despliegue 🏢 *(En curso)*
|
|
||||||
* **Objetivo:** Puesta en marcha definitiva en la infraestructura de servidores de UniCABA.
|
|
||||||
* **Estadios:**
|
|
||||||
1. **Estadio 6.1 — Despliegue e Infraestructura:**
|
|
||||||
- [ ] `docker-compose.yml` productivo orquestando Flask (Gunicorn), Node.js BFF (PM2) y PostgreSQL 15.
|
|
||||||
- [ ] Proxy inverso Nginx con certificados SSL/TLS y compresión Brotli/Gzip.
|
|
||||||
2. **Estadio 6.2 — Sincronización Continua Moodle:**
|
|
||||||
- [x] Sincronización automática de aulas virtuales y usuarios con Moodle 4.1 institucional (`10.0.0.207`).
|
|
||||||
- [ ] Automatización de workers persistentes (systemd / supervisor) para el procesamiento continuo de la cola.
|
|
||||||
3. **Estadio 6.3 — Pruebas de Carga y Hardening:**
|
|
||||||
- [ ] Pruebas de estrés de concurrencia en días pico de inicio de cuatrimestre (10.000 solicitudes simultáneas).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📈 Criterios de Aceptación del MVP
|
|
||||||
1. **Zero Colisiones Físicas:** Imposibilidad absoluta de asignar dos materias simultáneas a una misma aula física.
|
|
||||||
2. **Virtualidad Fluida:** Acceso con un clic a reuniones virtuales válidas para el 100% de las comisiones remotas.
|
|
||||||
3. **Roles Seguros:** Separación total de permisos donde ningún alumno o docente acceda a configuración global ni a datos privados de otras comisiones.
|
|
||||||
4. **Respuesta Sub-segundo:** Carga de carteleras y calendarios en menos de 300 ms en el BFF Node.js.
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
# Informe de Auditoría y Seguridad Automatizada (Python & Node.js)
|
|
||||||
**Proyecto:** Edu-Space Admin Architecture
|
|
||||||
**Fecha:** Septiembre 2026 (Actualización Post Fase 5)
|
|
||||||
**Entorno:** Backend Flask (REST API) + Node.js BFF (Express) + SQLite / PostgreSQL
|
|
||||||
**Estado General:** Aprobado ✅ (`[PASS]` en los 5 controles automatizados, 0 vulnerabilidades conocidas, 0 fallos críticos SAST/DAST)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Resumen Ejecutivo
|
|
||||||
Se ejecutó y validó la suite completa del **Plan de Auditoría y Seguridad Automatizada** que cubre el ciclo de vida completo de las dos capas de la plataforma:
|
|
||||||
- **Capa Core API (Python / Flask)**: Análisis Estático (SAST), Análisis de Dependencias de Terceros (SCA), Pruebas Dinámicas / Fuzzing contra especificación OpenAPI 3.0.3 (DAST).
|
|
||||||
- **Capa BFF / Frontend Server (Node.js / Express)**: Análisis Estático (SAST), Análisis de Dependencias (SCA) y Blindaje Activo con cabeceras HTTP (`helmet`) y limitador de tasa (`express-rate-limit`).
|
|
||||||
|
|
||||||
Todos los controles fueron unificados en el script ejecutable [security_audit.bat](file:///c:/Users/Soporte%20IT/workspace/admin-edu-space/security_audit.bat), compatible con ejecuciones interactivas en Windows y modos desatendidos (`--no-pause`) para integración continua.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Capa Backend Python (Flask API)
|
|
||||||
|
|
||||||
### 2.1. Análisis Estático de Código Fuente (SAST) - `Bandit`
|
|
||||||
- **Herramienta:** Bandit v1.8.3 (`backend/venv/Scripts/bandit.exe`)
|
|
||||||
- **Comando:** `bandit -r backend/app -ll -ii`
|
|
||||||
- **Líneas Escaneadas:** 11.321 líneas de código Python analizadas en toda la aplicación.
|
|
||||||
- **Hallazgos y Remediaciones:**
|
|
||||||
1. *CWE-22 / B310 en `sheets_importer.py`:* Uso de `urllib.request.urlopen` sin validación estricta de esquema. Remediado validando `url.startswith('https://')` o `http://` y documentando la verificación con `# nosec B310`.
|
|
||||||
2. *CWE-829 / B113 en `genetic_algorithm.py`:* Falso positivo producido por el parámetro de dominio `requests` (lista de solicitudes de aula `ReservationRequest`) que colisionaba con el analizador de llamadas HTTP sin timeout. Remediado renombrando el parámetro a `reservation_requests` con tipado estricto.
|
|
||||||
- **Resultado Actual:** **0 problemas identificados** (Medium = 0, High = 0).
|
|
||||||
|
|
||||||
### 2.2. Auditoría de Dependencias de Terceros (SCA) - `Pip-Audit`
|
|
||||||
- **Herramienta:** Pip-Audit v2.10.1 (`backend/venv/Scripts/pip-audit.exe`)
|
|
||||||
- **Comando:** `pip-audit -s osv --progress-spinner off -r backend/requirements.txt` (con fallback de resiliencia automática a servicio PyPI ante indisponibilidad de red).
|
|
||||||
- **Librerías Core Aseguradas:**
|
|
||||||
- `Flask>=3.1.3`
|
|
||||||
- `Werkzeug>=3.1.6` (v3.1.8 instalada)
|
|
||||||
- `requests>=2.32.4` (v2.34.2 instalada)
|
|
||||||
- `bleach>=6.4.0`
|
|
||||||
- `python-dotenv>=1.2.2` (v1.2.3 instalada)
|
|
||||||
- `cryptography>=44.0.0`
|
|
||||||
- **Resultado Actual:** **No known vulnerabilities found** (0 CVEs pendientes).
|
|
||||||
|
|
||||||
### 2.3. Pruebas Dinámicas y Fuzzing de API REST (DAST) - `Schemathesis`
|
|
||||||
- **Herramienta:** Schemathesis v4.27.4 (`backend/venv/Scripts/st.exe`)
|
|
||||||
- **Especificación OpenAPI:** OpenAPI 3.0.3 en `/api/v1/openapi.json`.
|
|
||||||
- **Comando:** `st run http://127.0.0.1:5000/api/v1/openapi.json --checks not_a_server_error --max-examples=10 --no-color`
|
|
||||||
- **Fuzzing & Robustez de Endpoints:**
|
|
||||||
- `POST /api/v1/auth/login`: Blindado contra payloads no estructurados (JSON arrays anómalos o tipos no diccionario devuelven `400 Bad Request` antes de invocar DTOs). Manejo seguro de base de datos garantizando códigos `401 Unauthorized` ante credenciales erróneas o usuarios no existentes sin generar fallos 500 no capturados.
|
|
||||||
- `GET /api/v1/admin/subjects`, `GET /api/v1/admin/users`, `GET /api/v1/auth/me`, `GET /api/v1/classrooms`: Rechazan peticiones sin token JWT con código `401 Unauthorized`.
|
|
||||||
- **Resultado Actual:** **114 casos de prueba generados y superados con éxito**, **0 errores 500 del servidor** (100% PASS).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Capa Backend Node.js (Express BFF)
|
|
||||||
|
|
||||||
### 3.1. Análisis Estático de Código Fuente (SAST) - `njsscan`
|
|
||||||
- **Herramienta:** NodeJsScan v1.0.0 (`backend/venv/Scripts/njsscan.exe`)
|
|
||||||
- **Comando:** `njsscan frontend/src`
|
|
||||||
- **Reglas Evaluadas:** Inyecciones de código, llamadas a `eval()`, exposición de credenciales hardcodeadas, omisión de headers de seguridad, CORS inseguro.
|
|
||||||
- **Resultado:** **No issues found** (0 problemas de seguridad).
|
|
||||||
|
|
||||||
### 3.2. Blindaje de Middleware (`helmet` & `express-rate-limit`)
|
|
||||||
- **Paquetes:** `helmet`, `express-rate-limit` en `frontend/src/app.js`.
|
|
||||||
- **Protecciones Activas:**
|
|
||||||
- `app.disable('x-powered-by')`: Oculta el motor Express.
|
|
||||||
- `helmet`:
|
|
||||||
- `X-Content-Type-Options: nosniff`
|
|
||||||
- `X-Frame-Options: SAMEORIGIN` (prevención de Clickjacking)
|
|
||||||
- `Strict-Transport-Security: max-age=31536000; includeSubDomains` (HSTS)
|
|
||||||
- `Cross-Origin-Opener-Policy: same-origin`
|
|
||||||
- `Cross-Origin-Resource-Policy: same-origin`
|
|
||||||
- `express-rate-limit`:
|
|
||||||
- `/auth/login`: Límite estricto de 30 peticiones por ventana de 15 minutos por IP (mitigación de ataques de fuerza bruta).
|
|
||||||
- Endpoints `/api`: Límite de 180 peticiones por minuto por IP (mitigación de scraping y saturación).
|
|
||||||
|
|
||||||
### 3.3. Auditoría de Dependencias (SCA) - `npm audit`
|
|
||||||
- **Herramienta:** `npm audit` nativo en `frontend/`
|
|
||||||
- **Comando:** `cmd.exe /c npm audit`
|
|
||||||
- **Resultado:** **found 0 vulnerabilities** en 108 dependencias evaluadas.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Automatización de la Suite de Seguridad
|
|
||||||
|
|
||||||
El archivo [security_audit.bat](file:///c:/Users/Soporte%20IT/workspace/admin-edu-space/security_audit.bat) provee la suite de ejecución automatizada:
|
|
||||||
|
|
||||||
```cmd
|
|
||||||
:: Ejecución interactiva con consola Windows:
|
|
||||||
security_audit.bat
|
|
||||||
|
|
||||||
:: Ejecución desatendida / CI:
|
|
||||||
security_audit.bat --no-pause
|
|
||||||
```
|
|
||||||
|
|
||||||
### Características de la suite adaptada:
|
|
||||||
1. **Página de códigos UTF-8 (`chcp 65001`) y variables de entorno Python (`PYTHONUTF8=1`):** Evita fallos de codificación `charmap` en terminales Windows tradicionales.
|
|
||||||
2. **Fallback resiliente en `pip-audit`:** Conmutación automática a base PyPI en caso de indisponibilidad temporal de la API OSV.
|
|
||||||
3. **Comprobación de conectividad al backend:** Antes de lanzar Schemathesis DAST, verifica la respuesta HTTP de `/api/v1/openapi.json`. Si el backend está inactivo, emite una advertencia sin abortar los controles estáticos previos.
|
|
||||||
4. **Bandera `--no-pause`:** Permite integración fluida en pipelines de CI/CD conservando la pausa para desarrolladores que ejecuten por doble click.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Matriz de Resultados Finales
|
|
||||||
|
|
||||||
| Control | Capa | Herramienta | Métricas Escaneadas | Estado Final |
|
|
||||||
|---|---|---|---|:---:|
|
|
||||||
| **SAST (Python)** | Flask API | `bandit` | 11.321 líneas de código | **0 Issues (PASS)** ✅ |
|
|
||||||
| **SCA (Python)** | Flask API | `pip-audit` | 42 librerías en requirements.txt | **0 CVEs (PASS)** ✅ |
|
|
||||||
| **SAST (Node.js)**| Express BFF| `njsscan` | Archivos en `frontend/src` | **0 Issues (PASS)** ✅ |
|
|
||||||
| **SCA (Node.js)** | Express BFF| `npm audit` | 108 paquetes npm | **0 Vulnerabilidades (PASS)** ✅ |
|
|
||||||
| **DAST (Python)** | Flask API | `schemathesis` | 114 casos de prueba generados | **114/114 Pasados - 0 Error 500 (PASS)** ✅ |
|
|
||||||
| **Protección HTTP** | Express BFF| `helmet` + `rate-limit` | HSTS, nosniff, limiters activos | **Activo y Blindado** ✅ |
|
|
||||||
|
|
||||||
**Dictamen:** **SUITE DE SEGURIDAD APROBADA AL 100% [PASS]**.
|
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
from flask import Flask, request, session
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_migrate import Migrate
|
||||||
|
from flask_login import LoginManager
|
||||||
|
from flask_cors import CORS
|
||||||
|
from flask_babel import Babel, gettext as _
|
||||||
|
from config.config import Config
|
||||||
|
|
||||||
|
# Initialize extensions
|
||||||
|
db = SQLAlchemy()
|
||||||
|
migrate = Migrate()
|
||||||
|
login_manager = LoginManager()
|
||||||
|
cors = CORS()
|
||||||
|
babel = Babel()
|
||||||
|
|
||||||
|
def create_app(config_class=Config):
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config.from_object(config_class)
|
||||||
|
|
||||||
|
# Initialize extensions with app
|
||||||
|
db.init_app(app)
|
||||||
|
migrate.init_app(app, db)
|
||||||
|
login_manager.init_app(app)
|
||||||
|
cors.init_app(app)
|
||||||
|
|
||||||
|
# Login manager configuration
|
||||||
|
login_manager.login_view = 'auth.login'
|
||||||
|
login_manager.login_message = 'Por favor inicia sesión para acceder a esta página.'
|
||||||
|
login_manager.login_message_category = 'info'
|
||||||
|
|
||||||
|
# Import models to ensure they are registered
|
||||||
|
from app.models import user
|
||||||
|
# Register blueprints
|
||||||
|
from app.routes import auth_bp, classrooms_bp, buildings_bp, main_bp, schedule_bp, genetic_bp, genetic_web_bp, admin_bp
|
||||||
|
|
||||||
|
app.register_blueprint(auth_bp, url_prefix="/")
|
||||||
|
app.register_blueprint(classrooms_bp, url_prefix="/classrooms")
|
||||||
|
app.register_blueprint(buildings_bp)
|
||||||
|
app.register_blueprint(main_bp, url_prefix="/")
|
||||||
|
app.register_blueprint(schedule_bp, url_prefix="/schedule")
|
||||||
|
app.register_blueprint(genetic_bp)
|
||||||
|
app.register_blueprint(genetic_web_bp)
|
||||||
|
app.register_blueprint(admin_bp)
|
||||||
|
|
||||||
|
# Language selector function
|
||||||
|
def get_locale():
|
||||||
|
# 1. Parámetro explícito en la URL (?lang=es o ?lang=en)
|
||||||
|
lang = request.args.get('lang')
|
||||||
|
if lang and lang in app.config['LANGUAGES']:
|
||||||
|
session['language'] = lang
|
||||||
|
return lang
|
||||||
|
|
||||||
|
# 2. Si el usuario está autenticado y tiene idioma configurado en su perfil, prevalece en toda la sesión
|
||||||
|
from flask_login import current_user
|
||||||
|
if current_user.is_authenticated and getattr(current_user, 'preferred_language', None):
|
||||||
|
if current_user.preferred_language in app.config['LANGUAGES']:
|
||||||
|
return current_user.preferred_language
|
||||||
|
|
||||||
|
# 3. En la pantalla de Login (usuario no autenticado):
|
||||||
|
# El idioma predeterminado del login debe ser español o respetar el idioma del navegador.
|
||||||
|
# No debe verse afectado por cookies o sesiones residuales de otros usuarios.
|
||||||
|
is_login = (request.endpoint == 'auth.login' or request.path.rstrip('/') == '/login')
|
||||||
|
if is_login:
|
||||||
|
# Solo si el usuario cambió el idioma explícitamente en el selector del login
|
||||||
|
if session.get('login_lang_explicit') in app.config['LANGUAGES']:
|
||||||
|
return session['login_lang_explicit']
|
||||||
|
|
||||||
|
# Detectar según el navegador del usuario (soporta es-419, es-AR, es-ES, etc.)
|
||||||
|
if request.accept_languages:
|
||||||
|
for accepted_lang, _quality in request.accept_languages:
|
||||||
|
primary = accepted_lang.split('-')[0].lower()
|
||||||
|
if primary in app.config['LANGUAGES']:
|
||||||
|
return primary
|
||||||
|
|
||||||
|
# Idioma predeterminado por defecto del login: Español
|
||||||
|
return 'es'
|
||||||
|
|
||||||
|
# 4. Para otras rutas: verificar si hay idioma en sesión (ej. selector manual)
|
||||||
|
if 'language' in session and session['language'] in app.config['LANGUAGES']:
|
||||||
|
return session['language']
|
||||||
|
|
||||||
|
# 5. Detección automática del navegador por defecto (soporta subtags regionales)
|
||||||
|
if request.accept_languages:
|
||||||
|
for accepted_lang, _quality in request.accept_languages:
|
||||||
|
primary = accepted_lang.split('-')[0].lower()
|
||||||
|
if primary in app.config['LANGUAGES']:
|
||||||
|
return primary
|
||||||
|
|
||||||
|
# Fallback del sistema: Español
|
||||||
|
return app.config['BABEL_DEFAULT_LOCALE']
|
||||||
|
|
||||||
|
|
||||||
|
# Babel context processor
|
||||||
|
@app.context_processor
|
||||||
|
def inject_conf_vars():
|
||||||
|
return dict(
|
||||||
|
languages=app.config['LANGUAGES'],
|
||||||
|
current_lang=str(get_locale())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Configure Babel
|
||||||
|
babel.init_app(app, locale_selector=get_locale)
|
||||||
|
|
||||||
|
# Register localized date and datetime formatters for Jinja2 templates
|
||||||
|
from flask_babel import format_date, format_datetime
|
||||||
|
app.jinja_env.globals['format_date'] = format_date
|
||||||
|
app.jinja_env.globals['format_datetime'] = format_datetime
|
||||||
|
app.jinja_env.filters['format_date'] = format_date
|
||||||
|
app.jinja_env.filters['format_datetime'] = format_datetime
|
||||||
|
|
||||||
|
return app
|
||||||
@@ -4,14 +4,11 @@ from wtforms.validators import DataRequired, Email, Length
|
|||||||
from flask_babel import lazy_gettext as _l
|
from flask_babel import lazy_gettext as _l
|
||||||
|
|
||||||
class LoginForm(FlaskForm):
|
class LoginForm(FlaskForm):
|
||||||
email = StringField(_l('Email'),
|
email = StringField(_l('Email'), validators=[
|
||||||
validators=[
|
|
||||||
DataRequired(),
|
DataRequired(),
|
||||||
Email(),
|
Email(),
|
||||||
Length(max=120)
|
Length(max=120)
|
||||||
],
|
])
|
||||||
filters=[lambda x: x.strip().lower() if x else '']
|
|
||||||
)
|
|
||||||
password = PasswordField(_l('Password'), validators=[DataRequired()])
|
password = PasswordField(_l('Password'), validators=[DataRequired()])
|
||||||
remember_me = BooleanField(_l('Remember me'))
|
remember_me = BooleanField(_l('Remember me'))
|
||||||
submit = SubmitField(_l('Sign In'))
|
submit = SubmitField(_l('Sign In'))
|
||||||
@@ -19,25 +19,27 @@ def validate_capacity(form, field):
|
|||||||
raise ValidationError(_l('Ingrese un número entero o el símbolo ∞ para capacidad ilimitada.'))
|
raise ValidationError(_l('Ingrese un número entero o el símbolo ∞ para capacidad ilimitada.'))
|
||||||
|
|
||||||
class ClassroomForm(FlaskForm):
|
class ClassroomForm(FlaskForm):
|
||||||
building_id = SelectField(_l('Edificio'), validators=[DataRequired()])
|
building = StringField(_l('Building'), validators=[
|
||||||
new_building_name = StringField(_l('Nombre del Nuevo Edificio'), validators=[Optional(), Length(max=100)])
|
DataRequired(),
|
||||||
room_number = StringField(_l('Número / Nombre del Aula'), validators=[
|
Length(min=2, max=100)
|
||||||
|
])
|
||||||
|
room_number = StringField(_l('Room Number'), validators=[
|
||||||
DataRequired(),
|
DataRequired(),
|
||||||
Length(min=1, max=50)
|
Length(min=1, max=50)
|
||||||
])
|
])
|
||||||
floor = StringField(_l('Piso'), validators=[
|
floor = IntegerField(_l('Floor'), validators=[
|
||||||
DataRequired(message=_l('El piso es obligatorio (ej: PB, 1, 2, 4°P)')),
|
InputRequired(message=_l('El piso es obligatorio (0 para Planta Baja)')),
|
||||||
Length(min=1, max=50)
|
NumberRange(min=0, max=50, message=_l('El piso debe estar entre 0 (Planta Baja) y 50'))
|
||||||
])
|
])
|
||||||
capacity = StringField(_l('Capacidad'), validators=[
|
capacity = StringField(_l('Capacity'), validators=[
|
||||||
validate_capacity
|
validate_capacity
|
||||||
])
|
])
|
||||||
description = TextAreaField(_l('Descripción'), validators=[
|
description = TextAreaField(_l('Description'), validators=[
|
||||||
Optional(),
|
Optional(),
|
||||||
Length(max=500)
|
Length(max=500)
|
||||||
])
|
])
|
||||||
is_active = BooleanField(_l('Activa'), default=True)
|
is_active = BooleanField(_l('Active'), default=True)
|
||||||
submit = SubmitField(_l('Guardar Aula'))
|
submit = SubmitField(_l('Save Classroom'))
|
||||||
|
|
||||||
class ClassroomResourceForm(FlaskForm):
|
class ClassroomResourceForm(FlaskForm):
|
||||||
resource_type = SelectField(_l('Resource Type'), choices=[
|
resource_type = SelectField(_l('Resource Type'), choices=[
|
||||||
@@ -21,16 +21,8 @@ class ReservationForm(FlaskForm):
|
|||||||
Optional(),
|
Optional(),
|
||||||
Length(max=1000)
|
Length(max=1000)
|
||||||
])
|
])
|
||||||
virtual_link = StringField(_l('Virtual Classroom Link'), validators=[
|
|
||||||
Optional(),
|
|
||||||
Length(max=500)
|
|
||||||
])
|
|
||||||
submit = SubmitField(_l('Make Reservation'))
|
submit = SubmitField(_l('Make Reservation'))
|
||||||
|
|
||||||
def __init__(self, *args, is_edit=False, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.is_edit = is_edit
|
|
||||||
|
|
||||||
def validate_end_time(self, field):
|
def validate_end_time(self, field):
|
||||||
if field.data <= self.start_time.data:
|
if field.data <= self.start_time.data:
|
||||||
raise ValidationError('End time must be after start time.')
|
raise ValidationError('End time must be after start time.')
|
||||||
@@ -40,8 +32,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 new reservations in the past
|
# Don't allow reservations in the past
|
||||||
if not self.is_edit and field.data < datetime.now() - timedelta(minutes=10):
|
if field.data < datetime.now():
|
||||||
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,25 @@
|
|||||||
|
from .user import User
|
||||||
|
from .building import Building
|
||||||
|
from .classroom import Classroom, ClassroomResource
|
||||||
|
from .career import Career
|
||||||
|
from .subject import Subject, Commission
|
||||||
|
from .reservation import Reservation, ReservationStatus
|
||||||
|
from .genetic_algorithm import GeneticAlgorithm, ReservationOptimizer
|
||||||
|
from .role import Role, Permission, SYSTEM_MODULES
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'User',
|
||||||
|
'Building',
|
||||||
|
'Classroom',
|
||||||
|
'ClassroomResource',
|
||||||
|
'Career',
|
||||||
|
'Subject',
|
||||||
|
'Commission',
|
||||||
|
'Reservation',
|
||||||
|
'ReservationStatus',
|
||||||
|
'GeneticAlgorithm',
|
||||||
|
'ReservationOptimizer',
|
||||||
|
'Role',
|
||||||
|
'Permission',
|
||||||
|
'SYSTEM_MODULES'
|
||||||
|
]
|
||||||
@@ -17,40 +17,10 @@ class Building(db.Model):
|
|||||||
# Relationships
|
# Relationships
|
||||||
classrooms = db.relationship('Classroom', backref='building_entity', lazy=True, foreign_keys='Classroom.building_id')
|
classrooms = db.relationship('Classroom', backref='building_entity', lazy=True, foreign_keys='Classroom.building_id')
|
||||||
|
|
||||||
@property
|
|
||||||
def is_virtual(self):
|
|
||||||
"""Identifica si la sede corresponde al Campus Virtual"""
|
|
||||||
c = (self.code or '').upper()
|
|
||||||
n = (self.name or '').upper()
|
|
||||||
return 'VIRTUAL' in c or 'VIRTUAL' in n
|
|
||||||
|
|
||||||
@property
|
|
||||||
def physical_capacity(self):
|
|
||||||
"""Capacidad física instalada real (excluye aulas virtuales con capacidad 9999 o infinitas)"""
|
|
||||||
if self.is_virtual:
|
|
||||||
return 0
|
|
||||||
return sum(c.capacity for c in self.classrooms if c.is_active and (c.capacity or 0) < 999 and not getattr(c, 'is_virtual', False))
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total_capacity(self):
|
def total_capacity(self):
|
||||||
"""Capacidad para compatibilidad histórica"""
|
"""Total student capacity across active classrooms in this building"""
|
||||||
if self.is_virtual:
|
return sum(c.capacity for c in self.classrooms if c.is_active)
|
||||||
return 0
|
|
||||||
return self.physical_capacity
|
|
||||||
|
|
||||||
@property
|
|
||||||
def capacity_display(self):
|
|
||||||
"""Representación textual clara para UI/UX"""
|
|
||||||
if self.is_virtual:
|
|
||||||
return "Ilimitada (Virtual)"
|
|
||||||
return str(self.physical_capacity)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def floors_display(self):
|
|
||||||
"""Representación de pisos"""
|
|
||||||
if self.is_virtual:
|
|
||||||
return "Digital"
|
|
||||||
return str(self.floors)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def classrooms_count(self):
|
def classrooms_count(self):
|
||||||
@@ -64,14 +34,10 @@ class Building(db.Model):
|
|||||||
'code': self.code,
|
'code': self.code,
|
||||||
'address': self.address,
|
'address': self.address,
|
||||||
'floors': self.floors,
|
'floors': self.floors,
|
||||||
'floors_display': self.floors_display,
|
|
||||||
'description': self.description,
|
'description': self.description,
|
||||||
'is_active': self.is_active,
|
'is_active': self.is_active,
|
||||||
'is_virtual': self.is_virtual,
|
|
||||||
'classrooms_count': self.classrooms_count,
|
'classrooms_count': self.classrooms_count,
|
||||||
'physical_capacity': self.physical_capacity,
|
'total_capacity': self.total_capacity,
|
||||||
'total_capacity': self.physical_capacity if not self.is_virtual else 0,
|
|
||||||
'capacity_display': self.capacity_display,
|
|
||||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9,57 +9,23 @@ class Classroom(db.Model):
|
|||||||
building_id = db.Column(db.Integer, db.ForeignKey('buildings.id'), nullable=True)
|
building_id = db.Column(db.Integer, db.ForeignKey('buildings.id'), nullable=True)
|
||||||
building = db.Column(db.String(100), nullable=False)
|
building = db.Column(db.String(100), nullable=False)
|
||||||
code = db.Column('room_number', db.String(50), nullable=False) # Maps to room_number column in DB
|
code = db.Column('room_number', db.String(50), nullable=False) # Maps to room_number column in DB
|
||||||
floor = db.Column(db.String(50), nullable=False, default='1')
|
floor = db.Column(db.Integer, nullable=False)
|
||||||
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_name}-{self.code}"
|
return f"{self.building}-{self.code}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def campus_location(self):
|
def campus_location(self):
|
||||||
"""Placeholder for campus location"""
|
"""Placeholder for campus location - can be expanded when campus model is implemented"""
|
||||||
return self.building_rel.address if self.building_rel else None
|
return None
|
||||||
|
|
||||||
# Virtual field for compatibility - combines building and code
|
# Virtual field for compatibility - combines building and code
|
||||||
@property
|
@property
|
||||||
@@ -73,34 +39,28 @@ 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_name} - {self.code}"
|
return f"{self.building}-{self.code}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def capacity_display(self):
|
def capacity_display(self):
|
||||||
"""Retorna '∞' para aulas virtuales o capacidad ilimitada, o el número entero."""
|
"""Retorna '∞' para aulas virtuales o capacidad ilimitada, o el número entero."""
|
||||||
if self.capacity == 0 or self.is_virtual:
|
if self.capacity == 0 or (self.code and 'VIRTUAL' in self.code.upper()):
|
||||||
return '∞'
|
return '∞'
|
||||||
return str(self.capacity)
|
return str(self.capacity)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_unlimited(self):
|
def is_unlimited(self):
|
||||||
return self.capacity == 0 or self.is_virtual
|
return self.capacity == 0 or (self.code and 'VIRTUAL' in self.code.upper())
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
"""Convert classroom to dictionary"""
|
"""Convert classroom to dictionary"""
|
||||||
return {
|
return {
|
||||||
'id': self.id,
|
'id': self.id,
|
||||||
'building_id': self.building_id,
|
'code': self.code_display,
|
||||||
'building': self.building_name,
|
'building': self.building,
|
||||||
'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,
|
||||||
@@ -111,9 +71,10 @@ class Classroom(db.Model):
|
|||||||
|
|
||||||
def get_full_location(self):
|
def get_full_location(self):
|
||||||
"""Get full classroom location"""
|
"""Get full classroom location"""
|
||||||
if self.is_virtual:
|
location = f"{self.building} - Floor {self.floor}"
|
||||||
return 'Campus Virtual (Remoto)'
|
if self.campus_location:
|
||||||
return f"{self.building_name} - {self.floor_display}"
|
location += f" ({self.campus_location})"
|
||||||
|
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):
|
||||||
@@ -45,37 +45,16 @@ class Individual:
|
|||||||
self.fitness = 0.0
|
self.fitness = 0.0
|
||||||
self.conflicts = []
|
self.conflicts = []
|
||||||
|
|
||||||
def calculate_fitness(self, classrooms: Dict[int, Classroom], reservation_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 with fast O(K log K) interval conflict detection"""
|
"""Calculate fitness score based on multiple factors"""
|
||||||
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)
|
||||||
res_req = reservation_requests.get(gene.commission_id)
|
request = requests.get(gene.commission_id)
|
||||||
|
|
||||||
if not classroom or not res_req:
|
if not classroom or not request:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Factor 1: Capacity efficiency (40% weight)
|
# Factor 1: Capacity efficiency (40% weight)
|
||||||
@@ -83,24 +62,23 @@ class Individual:
|
|||||||
score += capacity_score * 0.4
|
score += capacity_score * 0.4
|
||||||
|
|
||||||
# Factor 2: Time preference satisfaction (30% weight)
|
# Factor 2: Time preference satisfaction (30% weight)
|
||||||
time_score = self._calculate_time_score(gene, res_req)
|
time_score = self._calculate_time_score(gene, request)
|
||||||
score += time_score * 0.3
|
score += time_score * 0.3
|
||||||
|
|
||||||
# Factor 3: Conflict penalty (20% weight)
|
# Factor 3: Conflict penalty (20% weight)
|
||||||
c_count = conflict_count_by_id.get(id(gene), 0)
|
conflict_score = self._calculate_conflict_penalty(gene, self.genes)
|
||||||
if c_count > 0:
|
score += conflict_score * 0.2
|
||||||
conflict_score = -c_count * 0.5
|
|
||||||
|
# Factor 4: Resource matching (10% weight)
|
||||||
|
resource_score = self._calculate_resource_score(classroom, request.subject_requirements)
|
||||||
|
score += resource_score * 0.1
|
||||||
|
|
||||||
|
# Store conflicts for debugging
|
||||||
|
if conflict_score < 0:
|
||||||
self.conflicts.append({
|
self.conflicts.append({
|
||||||
'gene': gene,
|
'gene': gene,
|
||||||
'conflict_type': 'time_overlap'
|
'conflict_type': 'time_overlap'
|
||||||
})
|
})
|
||||||
else:
|
|
||||||
conflict_score = 1.0
|
|
||||||
score += conflict_score * 0.2
|
|
||||||
|
|
||||||
# Factor 4: Resource matching (10% weight)
|
|
||||||
resource_score = self._calculate_resource_score(classroom, res_req.subject_requirements)
|
|
||||||
score += resource_score * 0.1
|
|
||||||
|
|
||||||
self.fitness = score
|
self.fitness = score
|
||||||
return score
|
return score
|
||||||
@@ -111,7 +89,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) / max(1, expected_attendees)
|
waste_ratio = (classroom.capacity - expected_attendees) / 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
|
||||||
@@ -129,12 +107,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 / max(1, request.flexibility_hours)) * 0.3
|
return 0.8 - (time_diff / 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 (kept for backward compatibility)"""
|
"""Check for time conflicts in the same classroom"""
|
||||||
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:
|
||||||
@@ -149,6 +127,9 @@ 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:
|
||||||
@@ -157,24 +138,19 @@ class Individual:
|
|||||||
|
|
||||||
|
|
||||||
class GeneticAlgorithm:
|
class GeneticAlgorithm:
|
||||||
"""Main genetic algorithm for room reservation optimization with concurrency and progress tracking"""
|
"""Main genetic algorithm for room reservation optimization"""
|
||||||
|
|
||||||
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,
|
start_date: datetime, end_date: datetime) -> Individual:
|
||||||
progress_callback=None) -> Individual:
|
"""Run genetic algorithm to find optimal reservation schedule"""
|
||||||
"""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}
|
||||||
|
|
||||||
@@ -183,29 +159,18 @@ class GeneticAlgorithm:
|
|||||||
|
|
||||||
# Evolve population
|
# Evolve population
|
||||||
for generation in range(self.generations):
|
for generation in range(self.generations):
|
||||||
# Calculate fitness for all individuals concurrently when workers > 1
|
# Calculate fitness for all individuals
|
||||||
if self.workers > 1 and len(population) > 10:
|
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=self.workers) as executor:
|
|
||||||
futures = [executor.submit(ind.calculate_fitness, classrooms_dict, requests_dict) for ind in population]
|
|
||||||
concurrent.futures.wait(futures)
|
|
||||||
else:
|
|
||||||
for individual in population:
|
for individual in population:
|
||||||
individual.calculate_fitness(classrooms_dict, requests_dict)
|
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 = max(2, int(self.population_size * 0.1))
|
elite_size = 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
|
||||||
@@ -231,9 +196,7 @@ class GeneticAlgorithm:
|
|||||||
|
|
||||||
population = new_population[:self.population_size]
|
population = new_population[:self.population_size]
|
||||||
|
|
||||||
# Calculate fitness for final generation
|
# Return best individual from 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]
|
||||||
|
|
||||||
@@ -321,14 +284,13 @@ 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, population_size: int = 50, generations: int = 100, workers: int = 4):
|
def __init__(self):
|
||||||
self.ga = GeneticAlgorithm(population_size=population_size, generations=generations, workers=workers)
|
self.ga = GeneticAlgorithm()
|
||||||
|
|
||||||
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,
|
end_date: datetime = None) -> Dict:
|
||||||
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
|
||||||
@@ -363,7 +325,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, progress_callback=progress_callback)
|
best_individual = self.ga.optimize_reservations(requests, classrooms, start_date, end_date)
|
||||||
|
|
||||||
# Convert to reservation format
|
# Convert to reservation format
|
||||||
optimized_reservations = []
|
optimized_reservations = []
|
||||||
@@ -390,61 +352,28 @@ class ReservationOptimizer:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def _calculate_preferred_time(self, commission: Commission) -> Dict:
|
def _calculate_preferred_time(self, commission: Commission) -> Dict:
|
||||||
"""Calculate preferred time slots for a commission based on shift and schedule"""
|
"""Calculate preferred time slots for a commission"""
|
||||||
|
# This is a simplified version - would need to be based on actual semester schedule
|
||||||
current_date = datetime.utcnow()
|
current_date = datetime.utcnow()
|
||||||
|
|
||||||
# Determine day of week from commission schedule or default to Monday
|
# Default to weekday mornings for regular classes
|
||||||
target_weekday = 0 # Monday
|
days_ahead = (0 - current_date.weekday()) % 7 # Next Monday
|
||||||
sched = (commission.schedule or '').lower()
|
if days_ahead == 0: # If today is Monday, use next week
|
||||||
if 'martes' in sched:
|
|
||||||
target_weekday = 1
|
|
||||||
elif 'miercoles' in sched or 'miércoles' in sched:
|
|
||||||
target_weekday = 2
|
|
||||||
elif 'jueves' in sched:
|
|
||||||
target_weekday = 3
|
|
||||||
elif 'viernes' in sched:
|
|
||||||
target_weekday = 4
|
|
||||||
elif 'sabado' in sched or 'sábado' in sched:
|
|
||||||
target_weekday = 5
|
|
||||||
|
|
||||||
days_ahead = (target_weekday - current_date.weekday()) % 7
|
|
||||||
if days_ahead == 0:
|
|
||||||
days_ahead = 7
|
days_ahead = 7
|
||||||
|
|
||||||
preferred_date = current_date + timedelta(days=days_ahead)
|
preferred_date = current_date + timedelta(days=days_ahead)
|
||||||
|
|
||||||
# Determine hours based on shift
|
|
||||||
shift = (commission.shift or '').strip().lower()
|
|
||||||
if 'mañana' in shift or 'manana' in shift:
|
|
||||||
start_h, start_m = 8, 0
|
|
||||||
end_h, end_m = 12, 0
|
|
||||||
elif 'tarde' in shift:
|
|
||||||
start_h, start_m = 14, 0
|
|
||||||
end_h, end_m = 18, 0
|
|
||||||
elif 'noche' in shift or 'vespertino' in shift:
|
|
||||||
start_h, start_m = 18, 30
|
|
||||||
end_h, end_m = 22, 30
|
|
||||||
else:
|
|
||||||
start_h, start_m = 9, 0
|
|
||||||
end_h, end_m = 13, 0
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'start': preferred_date.replace(hour=start_h, minute=start_m, second=0, microsecond=0),
|
'start': preferred_date.replace(hour=9, minute=0, second=0, microsecond=0),
|
||||||
'end': preferred_date.replace(hour=end_h, minute=end_m, second=0, microsecond=0)
|
'end': preferred_date.replace(hour=11, minute=0, second=0, microsecond=0)
|
||||||
}
|
}
|
||||||
|
|
||||||
def apply_optimized_reservations(self, reservation_data: List[Dict]) -> List[Reservation]:
|
def apply_optimized_reservations(self, reservation_data: List[Dict]) -> List[Reservation]:
|
||||||
"""Apply the optimized reservations to the database with ISO string parsing and conflict avoidance"""
|
"""Apply the optimized reservations to the database"""
|
||||||
applied_reservations = []
|
applied_reservations = []
|
||||||
|
|
||||||
for item in reservation_data:
|
for data in reservation_data:
|
||||||
data = dict(item)
|
# Check for conflicts before creating
|
||||||
if isinstance(data.get('start_time'), str):
|
|
||||||
data['start_time'] = datetime.fromisoformat(data['start_time'].replace('Z', '+00:00'))
|
|
||||||
if isinstance(data.get('end_time'), str):
|
|
||||||
data['end_time'] = datetime.fromisoformat(data['end_time'].replace('Z', '+00:00'))
|
|
||||||
|
|
||||||
# Check for physical room conflicts before creating
|
|
||||||
conflicts = Reservation.find_conflicts(
|
conflicts = Reservation.find_conflicts(
|
||||||
data['classroom_id'],
|
data['classroom_id'],
|
||||||
data['start_time'],
|
data['start_time'],
|
||||||
@@ -452,17 +381,12 @@ class ReservationOptimizer:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not conflicts:
|
if not conflicts:
|
||||||
# Ensure status enum value compatibility
|
|
||||||
if 'status' in data and isinstance(data['status'], str):
|
|
||||||
try:
|
|
||||||
data['status'] = ReservationStatus(data['status']).value
|
|
||||||
except ValueError:
|
|
||||||
data['status'] = ReservationStatus.CONFIRMED.value
|
|
||||||
reservation = Reservation(**data)
|
reservation = Reservation(**data)
|
||||||
db.session.add(reservation)
|
db.session.add(reservation)
|
||||||
applied_reservations.append(reservation)
|
applied_reservations.append(reservation)
|
||||||
else:
|
else:
|
||||||
current_app.logger.warning(f"Conflict detected for reservation during genetic apply: {data}")
|
# Log conflict - could be handled differently
|
||||||
|
print(f"Conflict detected for reservation data: {data}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -47,7 +47,6 @@ class Reservation(db.Model):
|
|||||||
status = db.Column(StatusType, default='PENDING', nullable=False) # Maps to VARCHAR(20) with StatusString
|
status = db.Column(StatusType, default='PENDING', nullable=False) # Maps to VARCHAR(20) with StatusString
|
||||||
expected_attendees = db.Column(db.Integer, nullable=False)
|
expected_attendees = db.Column(db.Integer, nullable=False)
|
||||||
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
|
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
|
||||||
virtual_link = db.Column(db.String(500), nullable=True) # Zoom, Meet, Teams, etc.
|
|
||||||
notes = db.Column(db.Text)
|
notes = db.Column(db.Text)
|
||||||
|
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
@@ -83,24 +82,6 @@ class Reservation(db.Model):
|
|||||||
return self.classroom.code
|
return self.classroom.code
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
@property
|
|
||||||
def effective_virtual_link(self):
|
|
||||||
"""Devuelve el enlace explícito de la reserva o hereda el de la comisión"""
|
|
||||||
if self.virtual_link and self.virtual_link.strip():
|
|
||||||
return self.virtual_link.strip()
|
|
||||||
if self.commission and self.commission.virtual_link:
|
|
||||||
return self.commission.virtual_link.strip()
|
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_virtual(self):
|
|
||||||
"""Verifica si la reserva es virtual por aula o por contar con enlace virtual"""
|
|
||||||
if self.classroom:
|
|
||||||
return bool(self.classroom.is_virtual)
|
|
||||||
if self.effective_virtual_link:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
classroom = db.relationship('Classroom', backref=db.backref('reservation_list', lazy=True, cascade='all, delete-orphan'))
|
classroom = db.relationship('Classroom', backref=db.backref('reservation_list', lazy=True, cascade='all, delete-orphan'))
|
||||||
commission = db.relationship('Commission', backref=db.backref('reservation_list', lazy=True, cascade='all, delete-orphan'))
|
commission = db.relationship('Commission', backref=db.backref('reservation_list', lazy=True, cascade='all, delete-orphan'))
|
||||||
@@ -119,8 +100,6 @@ class Reservation(db.Model):
|
|||||||
'status': self.status, # Already a string in DB
|
'status': self.status, # Already a string in DB
|
||||||
'expected_attendees': self.expected_attendees,
|
'expected_attendees': self.expected_attendees,
|
||||||
'notes': self.notes,
|
'notes': self.notes,
|
||||||
'virtual_link': self.effective_virtual_link,
|
|
||||||
'is_virtual': self.is_virtual,
|
|
||||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||||
'classroom': self.classroom.to_dict() if self.classroom else None,
|
'classroom': self.classroom.to_dict() if self.classroom else None,
|
||||||
@@ -22,7 +22,7 @@ SYSTEM_MODULES = [
|
|||||||
'name': 'Reservas de Aulas',
|
'name': 'Reservas de Aulas',
|
||||||
'name_en': 'Classroom Reservations',
|
'name_en': 'Classroom Reservations',
|
||||||
'icon': 'bi-calendar-check',
|
'icon': 'bi-calendar-check',
|
||||||
'description': 'Creación, modificación, aprobación, confirmación y cancelación de reservas.'
|
'description': 'Creación, aprobación, confirmación y cancelación de reservas.'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'id': 'schedule',
|
'id': 'schedule',
|
||||||
@@ -58,27 +58,6 @@ SYSTEM_MODULES = [
|
|||||||
'name_en': 'Users & Access RBAC',
|
'name_en': 'Users & Access RBAC',
|
||||||
'icon': 'bi-shield-lock',
|
'icon': 'bi-shield-lock',
|
||||||
'description': 'Administración de cuentas, roles y matriz granular de permisos.'
|
'description': 'Administración de cuentas, roles y matriz granular de permisos.'
|
||||||
},
|
|
||||||
{
|
|
||||||
'id': 'milestone_types',
|
|
||||||
'name': 'Tipificaciones de Hitos',
|
|
||||||
'name_en': 'Milestone Types',
|
|
||||||
'icon': 'bi-tags',
|
|
||||||
'description': 'Catálogo dinámico de tipos de evaluaciones, parciales, TPs y eventos.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'id': 'metrics',
|
|
||||||
'name': 'Métricas de Ocupación',
|
|
||||||
'name_en': 'Occupancy Metrics',
|
|
||||||
'icon': 'bi-graph-up',
|
|
||||||
'description': 'Visualización y análisis de porcentajes de ocupación por aula (día, semana y mes).'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'id': 'enrollment',
|
|
||||||
'name': 'Inscripciones y Cursadas',
|
|
||||||
'name_en': 'Enrollments & Courses',
|
|
||||||
'icon': 'bi-diagram-3',
|
|
||||||
'description': 'Gestión de inscripciones de alumnos a comisiones y asignación de docentes.'
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
from app import db
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class Subject(db.Model):
|
||||||
|
__tablename__ = 'subjects'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
code = db.Column(db.String(50), unique=True, nullable=False, index=True)
|
||||||
|
name = db.Column(db.String(200), nullable=False)
|
||||||
|
description = db.Column(db.Text)
|
||||||
|
department = db.Column(db.String(100), nullable=False, default='')
|
||||||
|
credits = db.Column(db.Integer, nullable=False, default=4)
|
||||||
|
career_id = db.Column(db.Integer, db.ForeignKey('careers.id', ondelete='SET NULL'), nullable=True)
|
||||||
|
is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
commissions = db.relationship('Commission', backref='subject', lazy=True, cascade='all, delete-orphan')
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Convert subject to dictionary"""
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'code': self.code,
|
||||||
|
'name': self.name,
|
||||||
|
'description': self.description,
|
||||||
|
'department': self.department,
|
||||||
|
'credits': self.credits,
|
||||||
|
'career_id': self.career_id,
|
||||||
|
'career_name': self.career_obj.name if self.career_obj else None,
|
||||||
|
'is_active': self.is_active,
|
||||||
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||||
|
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
||||||
|
}
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Subject {self.code}>'
|
||||||
|
|
||||||
|
class Commission(db.Model):
|
||||||
|
__tablename__ = 'commissions'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
subject_id = db.Column(db.Integer, db.ForeignKey('subjects.id'), nullable=False)
|
||||||
|
code = db.Column(db.String(50), nullable=False)
|
||||||
|
semester = db.Column(db.String(20), nullable=False)
|
||||||
|
year = db.Column(db.Integer, nullable=False)
|
||||||
|
teacher_id = db.Column(db.Integer, db.ForeignKey('users.id'))
|
||||||
|
max_students = db.Column(db.Integer, nullable=False)
|
||||||
|
current_students = db.Column(db.Integer, default=0)
|
||||||
|
schedule = db.Column(db.String(255))
|
||||||
|
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
|
||||||
|
active = db.Column(db.Boolean, default=True) # Changed from is_active to active
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
# Virtual property for compatibility
|
||||||
|
@property
|
||||||
|
def is_active(self):
|
||||||
|
return self.active
|
||||||
|
|
||||||
|
@is_active.setter
|
||||||
|
def is_active(self, value):
|
||||||
|
self.active = value
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
pass # Reservation already defines the relationship back to commission
|
||||||
|
teacher = db.relationship('User', backref='teaching_commissions', foreign_keys=[teacher_id])
|
||||||
|
|
||||||
|
# Virtual field for teacher_name
|
||||||
|
@property
|
||||||
|
def teacher_name(self):
|
||||||
|
return f"{self.teacher.first_name} {self.teacher.last_name}" if self.teacher else "TBD"
|
||||||
|
|
||||||
|
# Unique constraint
|
||||||
|
__table_args__ = (db.UniqueConstraint('subject_id', 'code', 'semester', 'year'),)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Convert commission to dictionary"""
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'subject_id': self.subject_id,
|
||||||
|
'code': self.code,
|
||||||
|
'semester': self.semester,
|
||||||
|
'year': self.year,
|
||||||
|
'teacher_name': self.teacher_name,
|
||||||
|
'max_students': self.max_students,
|
||||||
|
'current_students': self.current_students,
|
||||||
|
'schedule': self.schedule,
|
||||||
|
'active': self.active,
|
||||||
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||||
|
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||||
|
'subject': self.subject.to_dict() if self.subject else None
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_full_code(self):
|
||||||
|
"""Get full commission code"""
|
||||||
|
return f"{self.subject.code if self.subject else 'N/A'}-{self.code}-{self.semester.upper()}{self.year if self.year else ''}"
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Commission {self.get_full_code()}>'
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
from app import db, login_manager
|
||||||
|
from flask_login import UserMixin
|
||||||
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class User(UserMixin, db.Model):
|
||||||
|
__tablename__ = 'users'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
||||||
|
password_hash = db.Column(db.String(255), nullable=False)
|
||||||
|
name = db.Column(db.String(100), nullable=False)
|
||||||
|
role = db.Column(db.String(50), nullable=False)
|
||||||
|
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
last_login = db.Column(db.DateTime)
|
||||||
|
preferred_language = db.Column(db.String(10), default=None, nullable=True)
|
||||||
|
theme_preference = db.Column(db.String(10), default='auto', nullable=False)
|
||||||
|
role_id = db.Column(db.Integer, db.ForeignKey('roles.id', ondelete='SET NULL'), nullable=True)
|
||||||
|
|
||||||
|
# Virtual fields for future compatibility
|
||||||
|
@property
|
||||||
|
def first_name(self):
|
||||||
|
return self.name.split()[0] if self.name else ''
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_name(self):
|
||||||
|
parts = self.name.split()
|
||||||
|
return ' '.join(parts[1:]) if len(parts) > 1 else ''
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
reservations = db.relationship('Reservation', backref='user', lazy=True, cascade='all, delete-orphan')
|
||||||
|
|
||||||
|
def set_password(self, password):
|
||||||
|
"""Set password hash"""
|
||||||
|
self.password_hash = generate_password_hash(password, method='pbkdf2:sha256')
|
||||||
|
|
||||||
|
def check_password(self, password):
|
||||||
|
"""Check password"""
|
||||||
|
return check_password_hash(self.password_hash, password)
|
||||||
|
|
||||||
|
def is_admin(self):
|
||||||
|
"""Check if user is admin"""
|
||||||
|
if self.role_obj and self.role_obj.name.lower() in ['admin', 'administrador']:
|
||||||
|
return True
|
||||||
|
return str(self.role).upper() in ['ADMIN', 'ADMINISTRADOR']
|
||||||
|
|
||||||
|
def has_permission(self, module, min_level='read'):
|
||||||
|
"""Check if user has granular permission on a given module"""
|
||||||
|
if self.is_admin():
|
||||||
|
return True
|
||||||
|
if self.role_obj:
|
||||||
|
return self.role_obj.has_permission(module, min_level)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def can_manage(self):
|
||||||
|
"""Check if user can access the Management / Admin section"""
|
||||||
|
return self.is_admin() or self.has_permission('users', 'read')
|
||||||
|
|
||||||
|
|
||||||
|
def update_last_login(self):
|
||||||
|
"""Update last login timestamp"""
|
||||||
|
self.last_login = datetime.utcnow()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Convert user to dictionary"""
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'email': self.email,
|
||||||
|
'name': self.name,
|
||||||
|
'role': self.role,
|
||||||
|
'is_active': self.is_active,
|
||||||
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||||
|
'last_login': self.last_login.isoformat() if self.last_login else None,
|
||||||
|
'preferred_language': self.preferred_language,
|
||||||
|
'theme_preference': self.theme_preference
|
||||||
|
}
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<User {self.email}>'
|
||||||
|
|
||||||
|
@login_manager.user_loader
|
||||||
|
def load_user(user_id):
|
||||||
|
"""Load user from database by ID"""
|
||||||
|
return User.query.get(int(user_id))
|
||||||
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
@@ -5,6 +5,5 @@ from .buildings import buildings_bp
|
|||||||
from .schedule import schedule_bp
|
from .schedule import schedule_bp
|
||||||
from .genetic_algorithm import genetic_bp, genetic_web_bp
|
from .genetic_algorithm import genetic_bp, genetic_web_bp
|
||||||
from .admin import admin_bp
|
from .admin import admin_bp
|
||||||
from .my_subjects import my_subjects_bp
|
|
||||||
|
|
||||||
__all__ = ['main_bp', 'auth_bp', 'classrooms_bp', 'buildings_bp', 'schedule_bp', 'genetic_bp', 'genetic_web_bp', 'admin_bp', 'my_subjects_bp']
|
__all__ = ['main_bp', 'auth_bp', 'classrooms_bp', 'buildings_bp', 'schedule_bp', 'genetic_bp', 'genetic_web_bp', 'admin_bp']
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, abort
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from flask_babel import gettext as _
|
||||||
|
from app import db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.role import Role, Permission, SYSTEM_MODULES, ACCESS_LEVELS
|
||||||
|
from app.utils.decorators import admin_required, permission_required
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# USERS MANAGEMENT
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
|
||||||
|
@admin_bp.route('/users')
|
||||||
|
@admin_required
|
||||||
|
def users_list():
|
||||||
|
"""List system users with filtering and role information"""
|
||||||
|
search = request.args.get('search', '').strip()
|
||||||
|
role_filter = request.args.get('role', '').strip()
|
||||||
|
status_filter = request.args.get('status', '').strip()
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
per_page = 15
|
||||||
|
|
||||||
|
query = User.query
|
||||||
|
|
||||||
|
if search:
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
User.name.ilike(f'%{search}%'),
|
||||||
|
User.email.ilike(f'%{search}%')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if role_filter:
|
||||||
|
if role_filter.isdigit():
|
||||||
|
query = query.filter(User.role_id == int(role_filter))
|
||||||
|
else:
|
||||||
|
query = query.filter(User.role.ilike(role_filter))
|
||||||
|
|
||||||
|
if status_filter in ['active', 'inactive']:
|
||||||
|
query = query.filter(User.is_active == (status_filter == 'active'))
|
||||||
|
|
||||||
|
pagination = query.order_by(User.id.asc()).paginate(page=page, per_page=per_page, error_out=False)
|
||||||
|
roles = Role.query.order_by(Role.name.asc()).all()
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'admin/users/list.html',
|
||||||
|
users=pagination.items,
|
||||||
|
pagination=pagination,
|
||||||
|
roles=roles,
|
||||||
|
search=search,
|
||||||
|
role_filter=role_filter,
|
||||||
|
status_filter=status_filter
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/users/add', methods=['GET', 'POST'])
|
||||||
|
@admin_required
|
||||||
|
def user_add():
|
||||||
|
"""Create a new user and assign a role"""
|
||||||
|
roles = Role.query.order_by(Role.name.asc()).all()
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
name = request.form.get('name', '').strip()
|
||||||
|
email = request.form.get('email', '').strip().lower()
|
||||||
|
password = request.form.get('password', '')
|
||||||
|
role_id = request.form.get('role_id', type=int)
|
||||||
|
is_active = request.form.get('is_active') == 'on'
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
if not name:
|
||||||
|
errors.append(_('El nombre es requerido.'))
|
||||||
|
if not email:
|
||||||
|
errors.append(_('El correo electrónico es requerido.'))
|
||||||
|
elif User.query.filter_by(email=email).first():
|
||||||
|
errors.append(_('Ya existe una cuenta con este correo electrónico.'))
|
||||||
|
if not password or len(password) < 6:
|
||||||
|
errors.append(_('La contraseña debe tener al menos 6 caracteres.'))
|
||||||
|
|
||||||
|
role_obj = Role.query.get(role_id) if role_id else None
|
||||||
|
if not role_obj:
|
||||||
|
errors.append(_('Debes seleccionar un rol válido.'))
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
for err in errors:
|
||||||
|
flash(err, 'error')
|
||||||
|
return render_template('admin/users/form.html', user=None, roles=roles)
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
name=name,
|
||||||
|
email=email,
|
||||||
|
role_id=role_obj.id,
|
||||||
|
role=role_obj.name,
|
||||||
|
is_active=is_active
|
||||||
|
)
|
||||||
|
user.set_password(password)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash(_('Usuario "%(name)s" creado exitosamente.', name=user.name), 'success')
|
||||||
|
return redirect(url_for('admin.users_list'))
|
||||||
|
|
||||||
|
return render_template('admin/users/form.html', user=None, roles=roles)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/users/edit/<int:id>', methods=['GET', 'POST'])
|
||||||
|
@admin_required
|
||||||
|
def user_edit(id):
|
||||||
|
"""Edit user details, role and status"""
|
||||||
|
user = User.query.get_or_404(id)
|
||||||
|
roles = Role.query.order_by(Role.name.asc()).all()
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
name = request.form.get('name', '').strip()
|
||||||
|
email = request.form.get('email', '').strip().lower()
|
||||||
|
password = request.form.get('password', '')
|
||||||
|
role_id = request.form.get('role_id', type=int)
|
||||||
|
is_active = request.form.get('is_active') == 'on'
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
if not name:
|
||||||
|
errors.append(_('El nombre es requerido.'))
|
||||||
|
if not email:
|
||||||
|
errors.append(_('El correo electrónico es requerido.'))
|
||||||
|
else:
|
||||||
|
existing = User.query.filter_by(email=email).first()
|
||||||
|
if existing and existing.id != user.id:
|
||||||
|
errors.append(_('Ya existe otra cuenta con este correo electrónico.'))
|
||||||
|
|
||||||
|
role_obj = Role.query.get(role_id) if role_id else None
|
||||||
|
if not role_obj:
|
||||||
|
errors.append(_('Debes seleccionar un rol válido.'))
|
||||||
|
|
||||||
|
# Do not allow deactivating the currently logged in admin user
|
||||||
|
if user.id == current_user.id and not is_active:
|
||||||
|
errors.append(_('No puedes desactivar tu propia cuenta mientras estás en sesión.'))
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
for err in errors:
|
||||||
|
flash(err, 'error')
|
||||||
|
return render_template('admin/users/form.html', user=user, roles=roles)
|
||||||
|
|
||||||
|
user.name = name
|
||||||
|
user.email = email
|
||||||
|
user.role_id = role_obj.id
|
||||||
|
user.role = role_obj.name
|
||||||
|
user.is_active = is_active
|
||||||
|
|
||||||
|
if password:
|
||||||
|
if len(password) < 6:
|
||||||
|
flash(_('La nueva contraseña debe tener al menos 6 caracteres.'), 'error')
|
||||||
|
return render_template('admin/users/form.html', user=user, roles=roles)
|
||||||
|
user.set_password(password)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
flash(_('Usuario "%(name)s" actualizado correctamente.', name=user.name), 'success')
|
||||||
|
return redirect(url_for('admin.users_list'))
|
||||||
|
|
||||||
|
return render_template('admin/users/form.html', user=user, roles=roles)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/users/toggle/<int:id>', methods=['POST'])
|
||||||
|
@admin_required
|
||||||
|
def user_toggle(id):
|
||||||
|
"""Toggle user active status"""
|
||||||
|
user = User.query.get_or_404(id)
|
||||||
|
if user.id == current_user.id:
|
||||||
|
flash(_('No puedes desactivar tu propia cuenta.'), 'error')
|
||||||
|
return redirect(url_for('admin.users_list'))
|
||||||
|
|
||||||
|
user.is_active = not user.is_active
|
||||||
|
db.session.commit()
|
||||||
|
status_str = _('activada') if user.is_active else _('desactivada')
|
||||||
|
flash(_('La cuenta de %(name)s ha sido %(status)s.', name=user.name, status=status_str), 'success')
|
||||||
|
return redirect(url_for('admin.users_list'))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# ROLES & PERMISSIONS MANAGEMENT (FortiGate Matrix)
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
|
||||||
|
@admin_bp.route('/roles')
|
||||||
|
@admin_required
|
||||||
|
def roles_list():
|
||||||
|
"""List all roles and permission summaries"""
|
||||||
|
roles = Role.query.order_by(Role.id.asc()).all()
|
||||||
|
return render_template(
|
||||||
|
'admin/roles/list.html',
|
||||||
|
roles=roles,
|
||||||
|
modules=SYSTEM_MODULES
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/roles/add', methods=['GET', 'POST'])
|
||||||
|
@admin_required
|
||||||
|
def role_add():
|
||||||
|
"""Create a new role with the FortiGate permission matrix"""
|
||||||
|
if request.method == 'POST':
|
||||||
|
name = request.form.get('name', '').strip()
|
||||||
|
description = request.form.get('description', '').strip()
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
flash(_('El nombre del rol es requerido.'), 'error')
|
||||||
|
return render_template('admin/roles/edit.html', role=None, modules=SYSTEM_MODULES, current_perms={})
|
||||||
|
|
||||||
|
if Role.query.filter_by(name=name).first():
|
||||||
|
flash(_('Ya existe un rol con ese nombre.'), 'error')
|
||||||
|
return render_template('admin/roles/edit.html', role=None, modules=SYSTEM_MODULES, current_perms={})
|
||||||
|
|
||||||
|
new_role = Role(name=name, description=description, is_system=False)
|
||||||
|
db.session.add(new_role)
|
||||||
|
db.session.flush() # to obtain new_role.id
|
||||||
|
|
||||||
|
# Save permissions from matrix
|
||||||
|
for mod in SYSTEM_MODULES:
|
||||||
|
mod_id = mod['id']
|
||||||
|
level = request.form.get(f'perm_{mod_id}', 'none')
|
||||||
|
if level not in ACCESS_LEVELS:
|
||||||
|
level = 'none'
|
||||||
|
new_role.set_permission(mod_id, level)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
flash(_('Rol "%(name)s" creado exitosamente con sus permisos.', name=new_role.name), 'success')
|
||||||
|
return redirect(url_for('admin.roles_list'))
|
||||||
|
|
||||||
|
return render_template('admin/roles/edit.html', role=None, modules=SYSTEM_MODULES, current_perms={})
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/roles/edit/<int:id>', methods=['GET', 'POST'])
|
||||||
|
@admin_required
|
||||||
|
def role_edit(id):
|
||||||
|
"""Edit an existing role and its FortiGate permission matrix"""
|
||||||
|
role = Role.query.get_or_404(id)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
name = request.form.get('name', '').strip()
|
||||||
|
description = request.form.get('description', '').strip()
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
flash(_('El nombre del rol es requerido.'), 'error')
|
||||||
|
return render_template('admin/roles/edit.html', role=role, modules=SYSTEM_MODULES, current_perms={p.module: p.access_level for p in role.permissions})
|
||||||
|
|
||||||
|
existing = Role.query.filter_by(name=name).first()
|
||||||
|
if existing and existing.id != role.id:
|
||||||
|
flash(_('Ya existe otro rol con ese nombre.'), 'error')
|
||||||
|
return render_template('admin/roles/edit.html', role=role, modules=SYSTEM_MODULES, current_perms={p.module: p.access_level for p in role.permissions})
|
||||||
|
|
||||||
|
# If it's a system role, keep name unchanged or protected
|
||||||
|
if not role.is_system:
|
||||||
|
role.name = name
|
||||||
|
role.description = description
|
||||||
|
|
||||||
|
# Update permissions from the FortiGate matrix
|
||||||
|
for mod in SYSTEM_MODULES:
|
||||||
|
mod_id = mod['id']
|
||||||
|
level = request.form.get(f'perm_{mod_id}', 'none')
|
||||||
|
if level not in ACCESS_LEVELS:
|
||||||
|
level = 'none'
|
||||||
|
role.set_permission(mod_id, level)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
flash(_('Rol "%(name)s" y permisos actualizados correctamente.', name=role.name), 'success')
|
||||||
|
return redirect(url_for('admin.roles_list'))
|
||||||
|
|
||||||
|
current_perms = {p.module: p.access_level for p in role.permissions}
|
||||||
|
return render_template(
|
||||||
|
'admin/roles/edit.html',
|
||||||
|
role=role,
|
||||||
|
modules=SYSTEM_MODULES,
|
||||||
|
current_perms=current_perms
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/roles/delete/<int:id>', methods=['POST'])
|
||||||
|
@admin_required
|
||||||
|
def role_delete(id):
|
||||||
|
"""Delete a non-system role if it has no assigned users"""
|
||||||
|
role = Role.query.get_or_404(id)
|
||||||
|
if role.is_system:
|
||||||
|
flash(_('No se pueden eliminar roles protegidos del sistema.'), 'error')
|
||||||
|
return redirect(url_for('admin.roles_list'))
|
||||||
|
|
||||||
|
user_count = len(role.users) if role.users else 0
|
||||||
|
if user_count > 0:
|
||||||
|
flash(_('No se puede eliminar el rol "%(name)s" porque tiene %(count)d usuario(s) asignado(s). Reasígnalos primero.', name=role.name, count=user_count), 'error')
|
||||||
|
return redirect(url_for('admin.roles_list'))
|
||||||
|
|
||||||
|
role_name = role.name
|
||||||
|
db.session.delete(role)
|
||||||
|
db.session.commit()
|
||||||
|
flash(_('El rol "%(name)s" ha sido eliminado.', name=role_name), 'success')
|
||||||
|
return redirect(url_for('admin.roles_list'))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# GOOGLE SHEETS IMPORT & SYNCHRONIZATION
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
|
||||||
|
@admin_bp.route('/import', methods=['GET', 'POST'])
|
||||||
|
@admin_required
|
||||||
|
def google_sheets_import():
|
||||||
|
"""Manual sync and status page for Google Sheets academic schedule data"""
|
||||||
|
from app.services.sheets_importer import GoogleSheetsImporter, SHEETS_CONFIG
|
||||||
|
from app.models.classroom import Classroom
|
||||||
|
from app.models.career import Career
|
||||||
|
from app.models.subject import Subject, Commission
|
||||||
|
from app.models.reservation import Reservation
|
||||||
|
|
||||||
|
stats = None
|
||||||
|
if request.method == 'POST':
|
||||||
|
try:
|
||||||
|
importer = GoogleSheetsImporter()
|
||||||
|
stats = importer.sync()
|
||||||
|
if stats['errors']:
|
||||||
|
flash(_('La sincronización se completó con algunas advertencias.'), 'warning')
|
||||||
|
else:
|
||||||
|
flash(_('Sincronización completada exitosamente. %(rows)d registros procesados.', rows=stats['total_rows_processed']), 'success')
|
||||||
|
except Exception as e:
|
||||||
|
flash(_('Error al sincronizar con Google Sheets: %(err)s', err=str(e)), 'error')
|
||||||
|
|
||||||
|
# Calculate current system metrics
|
||||||
|
metrics = {
|
||||||
|
'total_classrooms': Classroom.query.count(),
|
||||||
|
'total_careers': Career.query.count(),
|
||||||
|
'total_subjects': Subject.query.count(),
|
||||||
|
'total_commissions': Commission.query.count(),
|
||||||
|
'total_reservations': Reservation.query.count(),
|
||||||
|
'sheets': SHEETS_CONFIG
|
||||||
|
}
|
||||||
|
|
||||||
|
# Recent samples from DB if available
|
||||||
|
recent_subjects = Subject.query.order_by(Subject.id.desc()).limit(10).all()
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'admin/import.html',
|
||||||
|
metrics=metrics,
|
||||||
|
stats=stats,
|
||||||
|
recent_subjects=recent_subjects
|
||||||
|
)
|
||||||
|
|
||||||
@@ -8,43 +8,24 @@ from datetime import datetime
|
|||||||
|
|
||||||
auth_bp = Blueprint('auth', __name__)
|
auth_bp = Blueprint('auth', __name__)
|
||||||
|
|
||||||
from sqlalchemy import func
|
|
||||||
|
|
||||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||||
def login():
|
def login():
|
||||||
"""Handle user login with case-insensitive email lookup and whitespace trimming"""
|
"""Handle user login"""
|
||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
return redirect(url_for('main.dashboard'))
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
from app.models.setting import SystemSetting
|
|
||||||
demo_access_enabled = SystemSetting.get_value('demo_access_enabled', 'true') in ['true', 'True', '1', True]
|
|
||||||
|
|
||||||
form = LoginForm()
|
form = LoginForm()
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
clean_email = (form.email.data or '').strip().lower()
|
user = User.query.filter_by(email=form.email.data).first()
|
||||||
user = User.query.filter(func.lower(User.email) == clean_email).first()
|
|
||||||
|
|
||||||
if user and user.check_password(form.password.data):
|
if user and user.check_password(form.password.data):
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
flash(_('Tu cuenta ha sido desactivada. Por favor contacta al administrador.'), 'error')
|
flash(_('Tu cuenta ha sido desactivada. Por favor contacta al administrador.'), 'error')
|
||||||
return render_template('auth/login.html', form=form, demo_access_enabled=demo_access_enabled)
|
return render_template('auth/login.html', form=form)
|
||||||
|
|
||||||
login_user(user, remember=form.remember_me.data)
|
login_user(user, remember=form.remember_me.data)
|
||||||
user.update_last_login()
|
user.update_last_login()
|
||||||
|
|
||||||
# Registrar en bitácora de auditoría
|
|
||||||
try:
|
|
||||||
from app.services.audit_service import AuditService
|
|
||||||
AuditService.log(
|
|
||||||
action='LOGIN',
|
|
||||||
module='auth',
|
|
||||||
user_id=user.id,
|
|
||||||
user_email=user.email,
|
|
||||||
details=f"Inicio de sesión exitoso ({user.role})"
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Si el usuario tiene idioma configurado en su perfil, sobrescribe el navegador en toda la sesión
|
# Si el usuario tiene idioma configurado en su perfil, sobrescribe el navegador en toda la sesión
|
||||||
if user.preferred_language and user.preferred_language in ['es', 'en']:
|
if user.preferred_language and user.preferred_language in ['es', 'en']:
|
||||||
session['language'] = user.preferred_language
|
session['language'] = user.preferred_language
|
||||||
@@ -61,18 +42,9 @@ def login():
|
|||||||
flash(_('¡Bienvenido de nuevo, %(name)s!', name=user_full_name), 'success')
|
flash(_('¡Bienvenido de nuevo, %(name)s!', name=user_full_name), 'success')
|
||||||
return redirect(next_page)
|
return redirect(next_page)
|
||||||
else:
|
else:
|
||||||
try:
|
|
||||||
from app.services.audit_service import AuditService
|
|
||||||
AuditService.log(
|
|
||||||
action='LOGIN_FAILED',
|
|
||||||
module='auth',
|
|
||||||
details=f"Intento fallido de login con email: {clean_email}"
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
flash(_('Correo electrónico o contraseña inválidos.'), 'error')
|
flash(_('Correo electrónico o contraseña inválidos.'), 'error')
|
||||||
|
|
||||||
return render_template('auth/login.html', form=form, demo_access_enabled=demo_access_enabled)
|
return render_template('auth/login.html', form=form)
|
||||||
|
|
||||||
@auth_bp.route('/logout')
|
@auth_bp.route('/logout')
|
||||||
@login_required
|
@login_required
|
||||||
@@ -32,18 +32,15 @@ def list_buildings():
|
|||||||
|
|
||||||
buildings = query.order_by(Building.name.asc()).all()
|
buildings = query.order_by(Building.name.asc()).all()
|
||||||
|
|
||||||
# Calculate global building stats (excluye aulas virtuales con 9999)
|
# Calculate global building stats
|
||||||
total_classrooms = Classroom.query.filter_by(is_active=True).count()
|
total_classrooms = Classroom.query.filter_by(is_active=True).count()
|
||||||
physical_classrooms = [c for c in Classroom.query.filter_by(is_active=True).all() if not getattr(c, 'is_virtual', False) and (c.capacity or 0) < 999 and 'VIRTUAL' not in (c.code or '').upper()]
|
total_capacity = sum(c.capacity for c in Classroom.query.filter_by(is_active=True).all())
|
||||||
total_physical_capacity = sum(c.capacity for c in physical_classrooms)
|
|
||||||
total_capacity = total_physical_capacity
|
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'buildings/list.html',
|
'buildings/list.html',
|
||||||
buildings=buildings,
|
buildings=buildings,
|
||||||
total_classrooms=total_classrooms,
|
total_classrooms=total_classrooms,
|
||||||
total_capacity=total_capacity,
|
total_capacity=total_capacity,
|
||||||
total_physical_capacity=total_physical_capacity,
|
|
||||||
search=search,
|
search=search,
|
||||||
status_filter=status_filter,
|
status_filter=status_filter,
|
||||||
title='Building Management'
|
title='Building Management'
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from datetime import datetime, date
|
||||||
|
from app.models.classroom import Classroom
|
||||||
|
from app.models.user import User
|
||||||
|
from app.forms.classroom import ClassroomForm
|
||||||
|
from app import db
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
classrooms_bp = Blueprint('classrooms', __name__)
|
||||||
|
|
||||||
|
@classrooms_bp.route('/add', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def add_classroom():
|
||||||
|
"""Add a new classroom"""
|
||||||
|
form = ClassroomForm()
|
||||||
|
|
||||||
|
# TODO: Update when campus model is implemented
|
||||||
|
# form.campus_id.choices = [(1, 'Main Campus')]
|
||||||
|
|
||||||
|
if form.validate_on_submit():
|
||||||
|
try:
|
||||||
|
existing_classroom = Classroom.query.filter_by(
|
||||||
|
building=form.building.data,
|
||||||
|
code=form.room_number.data,
|
||||||
|
floor=form.floor.data
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_classroom:
|
||||||
|
flash('Classroom with this building, room number, and floor already exists.', 'error')
|
||||||
|
return render_template('classrooms/add.html', form=form, title='Add Classroom')
|
||||||
|
|
||||||
|
cap_raw = str(form.capacity.data or '').strip()
|
||||||
|
if cap_raw in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']:
|
||||||
|
cap_val = 0
|
||||||
|
else:
|
||||||
|
cap_val = int(cap_raw)
|
||||||
|
|
||||||
|
classroom = Classroom(
|
||||||
|
# campus_id=form.campus_id.data,
|
||||||
|
building=form.building.data,
|
||||||
|
code=form.room_number.data,
|
||||||
|
floor=form.floor.data,
|
||||||
|
capacity=cap_val,
|
||||||
|
description=form.description.data,
|
||||||
|
is_active=form.is_active.data
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(classroom)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash(f'Classroom {classroom.code_display} has been created successfully!', 'success')
|
||||||
|
return redirect(url_for('classrooms.list_classrooms'))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f'Error creating classroom: {str(e)}', 'error')
|
||||||
|
|
||||||
|
return render_template('classrooms/add.html', form=form, title='Add Classroom')
|
||||||
|
|
||||||
|
@classrooms_bp.route('/', endpoint='index')
|
||||||
|
@classrooms_bp.route('/list')
|
||||||
|
@login_required
|
||||||
|
def list_classrooms():
|
||||||
|
"""List all classrooms"""
|
||||||
|
try:
|
||||||
|
# Get filters
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
active_only = request.args.get('active_only', 'true').lower() == 'true'
|
||||||
|
search = request.args.get('search', '', type=str)
|
||||||
|
|
||||||
|
# Build query
|
||||||
|
query = Classroom.query
|
||||||
|
|
||||||
|
if active_only:
|
||||||
|
query = query.filter_by(is_active=True)
|
||||||
|
|
||||||
|
if search:
|
||||||
|
query = query.filter(
|
||||||
|
db.or_(
|
||||||
|
Classroom.building.ilike(f'%{search}%'),
|
||||||
|
Classroom.code.ilike(f'%{search}%'),
|
||||||
|
Classroom.description.ilike(f'%{search}%')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Paginate results
|
||||||
|
classrooms = query.order_by(Classroom.building, Classroom.floor, Classroom.code).paginate(
|
||||||
|
page=page, per_page=12, error_out=False
|
||||||
|
)
|
||||||
|
|
||||||
|
return render_template('classrooms/list.html',
|
||||||
|
classrooms=classrooms,
|
||||||
|
active_only=active_only,
|
||||||
|
search=search,
|
||||||
|
title='Classrooms')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
flash(f'Error loading classrooms: {str(e)}', 'error')
|
||||||
|
return render_template('classrooms/list.html', classrooms=None, title='Classrooms')
|
||||||
|
|
||||||
|
@classrooms_bp.route('/<int:id>')
|
||||||
|
@login_required
|
||||||
|
def view_classroom(id):
|
||||||
|
"""View classroom details"""
|
||||||
|
try:
|
||||||
|
classroom = Classroom.query.get_or_404(id)
|
||||||
|
|
||||||
|
# Get upcoming reservations for this classroom
|
||||||
|
from app.models.reservation import Reservation, ReservationStatus
|
||||||
|
|
||||||
|
upcoming_reservations = Reservation.query.filter(
|
||||||
|
Reservation.classroom_id == id,
|
||||||
|
Reservation.start_time > datetime.utcnow(),
|
||||||
|
Reservation.status == ReservationStatus.CONFIRMED.value
|
||||||
|
).order_by(Reservation.start_time).limit(10).all()
|
||||||
|
|
||||||
|
return render_template('classrooms/view.html',
|
||||||
|
classroom=classroom,
|
||||||
|
upcoming_reservations=upcoming_reservations,
|
||||||
|
title=f'Classroom {classroom.code_display}')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
flash(f'Error loading classroom: {str(e)}', 'error')
|
||||||
|
return redirect(url_for('classrooms.list_classrooms'))
|
||||||
|
|
||||||
|
@classrooms_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def edit_classroom(id):
|
||||||
|
"""Edit classroom"""
|
||||||
|
classroom = Classroom.query.get_or_404(id)
|
||||||
|
form = ClassroomForm(obj=classroom)
|
||||||
|
|
||||||
|
if request.method == 'GET':
|
||||||
|
if classroom.capacity == 0 or (classroom.code and 'VIRTUAL' in classroom.code.upper()):
|
||||||
|
form.capacity.data = '∞'
|
||||||
|
else:
|
||||||
|
form.capacity.data = str(classroom.capacity)
|
||||||
|
|
||||||
|
# TODO: Update when campus model is implemented
|
||||||
|
# form.campus_id.choices = [(1, 'Main Campus')]
|
||||||
|
|
||||||
|
if form.validate_on_submit():
|
||||||
|
try:
|
||||||
|
# Check for duplicates (excluding current classroom)
|
||||||
|
existing_classroom = Classroom.query.filter(
|
||||||
|
Classroom.building == form.building.data,
|
||||||
|
Classroom.code == form.room_number.data,
|
||||||
|
Classroom.floor == form.floor.data,
|
||||||
|
Classroom.id != id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_classroom:
|
||||||
|
flash('Classroom with this building, room number, and floor already exists.', 'error')
|
||||||
|
return render_template('classrooms/edit.html', form=form, classroom=classroom, title='Edit Classroom')
|
||||||
|
|
||||||
|
cap_raw = str(form.capacity.data or '').strip()
|
||||||
|
if cap_raw in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']:
|
||||||
|
cap_val = 0
|
||||||
|
else:
|
||||||
|
cap_val = int(cap_raw)
|
||||||
|
|
||||||
|
# Update classroom
|
||||||
|
classroom.building = form.building.data
|
||||||
|
classroom.code = form.room_number.data
|
||||||
|
classroom.floor = form.floor.data
|
||||||
|
classroom.capacity = cap_val
|
||||||
|
classroom.description = form.description.data
|
||||||
|
classroom.is_active = form.is_active.data
|
||||||
|
classroom.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash(f'Classroom {classroom.code_display} has been updated successfully!', 'success')
|
||||||
|
return redirect(url_for('classrooms.view_classroom', id=classroom.id))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f'Error updating classroom: {str(e)}', 'error')
|
||||||
|
|
||||||
|
return render_template('classrooms/edit.html', form=form, classroom=classroom, title='Edit Classroom')
|
||||||
|
|
||||||
|
@classrooms_bp.route('/<int:id>/toggle', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def toggle_classroom(id):
|
||||||
|
"""Toggle classroom active status"""
|
||||||
|
classroom = Classroom.query.get_or_404(id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
classroom.is_active = not classroom.is_active
|
||||||
|
classroom.updated_at = datetime.utcnow()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
status = 'activated' if classroom.is_active else 'deactivated'
|
||||||
|
flash(f'Classroom {classroom.code_display} has been {status} successfully!', 'success')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f'Error updating classroom status: {str(e)}', 'error')
|
||||||
|
|
||||||
|
return redirect(request.referrer or url_for('classrooms.list_classrooms'))
|
||||||
|
|
||||||
|
# API endpoints for AJAX requests
|
||||||
|
@classrooms_bp.route('/api/available')
|
||||||
|
@login_required
|
||||||
|
def get_available_classrooms():
|
||||||
|
"""Get available classrooms for a specific time slot"""
|
||||||
|
start_time = request.args.get('start_time')
|
||||||
|
end_time = request.args.get('end_time')
|
||||||
|
|
||||||
|
if not start_time or not end_time:
|
||||||
|
return jsonify({'error': 'Start time and end time are required'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
from app.models.reservation import Reservation, ReservationStatus
|
||||||
|
|
||||||
|
start_dt = datetime.fromisoformat(start_time)
|
||||||
|
end_dt = datetime.fromisoformat(end_time)
|
||||||
|
|
||||||
|
# Find all classrooms
|
||||||
|
all_classrooms = Classroom.query.filter_by(is_active=True).all()
|
||||||
|
available_classrooms = []
|
||||||
|
|
||||||
|
for classroom in all_classrooms:
|
||||||
|
# Check if classroom has conflicting reservations
|
||||||
|
conflicts = Reservation.query.filter(
|
||||||
|
Reservation.classroom_id == classroom.id,
|
||||||
|
Reservation.status == ReservationStatus.CONFIRMED.value,
|
||||||
|
Reservation.start_time < end_dt,
|
||||||
|
Reservation.end_time > start_dt
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if not conflicts:
|
||||||
|
available_classrooms.append({
|
||||||
|
'id': classroom.id,
|
||||||
|
'code': classroom.code,
|
||||||
|
'building': classroom.building,
|
||||||
|
'room_number': classroom.code,
|
||||||
|
'floor': classroom.floor,
|
||||||
|
'capacity': classroom.capacity,
|
||||||
|
'code_display': classroom.code_display
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({'classrooms': available_classrooms})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
from flask import Blueprint, request, jsonify, current_app, render_template, g
|
from flask import Blueprint, request, jsonify, current_app, render_template
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from app.models.genetic_algorithm import ReservationOptimizer
|
from app.models.genetic_algorithm import ReservationOptimizer
|
||||||
from app.models.reservation import Reservation, ReservationStatus
|
from app.models.reservation import Reservation, ReservationStatus
|
||||||
from app import db
|
from app import db
|
||||||
from app.utils.jwt_decorators import jwt_required
|
from flask_login import login_required, current_user
|
||||||
|
|
||||||
genetic_bp = Blueprint('genetic_algorithm', __name__, url_prefix='/api/genetic')
|
genetic_bp = Blueprint('genetic_algorithm', __name__, url_prefix='/api/genetic')
|
||||||
|
|
||||||
|
|
||||||
@genetic_bp.route('/optimize', methods=['POST'])
|
@genetic_bp.route('/optimize', methods=['POST'])
|
||||||
@jwt_required
|
@login_required
|
||||||
def optimize_reservations():
|
def optimize_reservations():
|
||||||
"""Optimize reservations using genetic algorithm"""
|
"""Optimize reservations using genetic algorithm"""
|
||||||
try:
|
try:
|
||||||
@@ -39,15 +39,15 @@ def optimize_reservations():
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return jsonify({'error': 'Invalid end_date format. Use ISO format.'}), 400
|
return jsonify({'error': 'Invalid end_date format. Use ISO format.'}), 400
|
||||||
|
|
||||||
# Check if user has permission (admin, teacher or RBAC optimizer write)
|
# Check if user has permission (admin or teacher)
|
||||||
if not (g.jwt_user.is_admin() or g.jwt_user.has_permission('optimizer', 'read_write') or g.jwt_user.role in ['ADMIN', 'teacher']):
|
if current_user.role not in ['ADMIN', 'teacher']:
|
||||||
return jsonify({'error': 'Insufficient permissions to optimize reservations'}), 403
|
return jsonify({'error': 'Insufficient permissions to optimize reservations'}), 403
|
||||||
|
|
||||||
# Initialize optimizer and run optimization
|
# Initialize optimizer and run optimization
|
||||||
optimizer = ReservationOptimizer()
|
optimizer = ReservationOptimizer()
|
||||||
result = optimizer.optimize_schedule(
|
result = optimizer.optimize_schedule(
|
||||||
commission_ids=commission_ids,
|
commission_ids=commission_ids,
|
||||||
admin_user_id=g.jwt_user.id,
|
admin_user_id=current_user.id,
|
||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
end_date=end_date
|
end_date=end_date
|
||||||
)
|
)
|
||||||
@@ -69,7 +69,7 @@ def optimize_reservations():
|
|||||||
|
|
||||||
|
|
||||||
@genetic_bp.route('/apply-optimization', methods=['POST'])
|
@genetic_bp.route('/apply-optimization', methods=['POST'])
|
||||||
@jwt_required
|
@login_required
|
||||||
def apply_optimization():
|
def apply_optimization():
|
||||||
"""Apply optimized reservations to the database"""
|
"""Apply optimized reservations to the database"""
|
||||||
try:
|
try:
|
||||||
@@ -84,7 +84,7 @@ def apply_optimization():
|
|||||||
return jsonify({'error': 'reservations must be a list'}), 400
|
return jsonify({'error': 'reservations must be a list'}), 400
|
||||||
|
|
||||||
# Check if user has permission
|
# Check if user has permission
|
||||||
if not (g.jwt_user.is_admin() or g.jwt_user.has_permission('optimizer', 'read_write') or g.jwt_user.role in ['ADMIN', 'teacher']):
|
if current_user.role not in ['ADMIN', 'teacher']:
|
||||||
return jsonify({'error': 'Insufficient permissions to apply reservations'}), 403
|
return jsonify({'error': 'Insufficient permissions to apply reservations'}), 403
|
||||||
|
|
||||||
# Initialize optimizer and apply reservations
|
# Initialize optimizer and apply reservations
|
||||||
@@ -106,7 +106,7 @@ def apply_optimization():
|
|||||||
|
|
||||||
|
|
||||||
@genetic_bp.route('/preview-optimization', methods=['POST'])
|
@genetic_bp.route('/preview-optimization', methods=['POST'])
|
||||||
@jwt_required
|
@login_required
|
||||||
def preview_optimization():
|
def preview_optimization():
|
||||||
"""Preview optimization without applying to database"""
|
"""Preview optimization without applying to database"""
|
||||||
try:
|
try:
|
||||||
@@ -124,14 +124,14 @@ def preview_optimization():
|
|||||||
return jsonify({'error': 'commission_ids must be a non-empty list'}), 400
|
return jsonify({'error': 'commission_ids must be a non-empty list'}), 400
|
||||||
|
|
||||||
# Check if user has permission
|
# Check if user has permission
|
||||||
if g.jwt_user.role not in ['ADMIN', 'teacher']:
|
if current_user.role not in ['ADMIN', 'teacher']:
|
||||||
return jsonify({'error': 'Insufficient permissions to preview optimization'}), 403
|
return jsonify({'error': 'Insufficient permissions to preview optimization'}), 403
|
||||||
|
|
||||||
# Initialize optimizer and run optimization
|
# Initialize optimizer and run optimization
|
||||||
optimizer = ReservationOptimizer()
|
optimizer = ReservationOptimizer()
|
||||||
result = optimizer.optimize_schedule(
|
result = optimizer.optimize_schedule(
|
||||||
commission_ids=commission_ids,
|
commission_ids=commission_ids,
|
||||||
admin_user_id=g.jwt_user.id
|
admin_user_id=current_user.id
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add additional preview information
|
# Add additional preview information
|
||||||
@@ -166,12 +166,12 @@ def preview_optimization():
|
|||||||
|
|
||||||
|
|
||||||
@genetic_bp.route('/commissions', methods=['GET'])
|
@genetic_bp.route('/commissions', methods=['GET'])
|
||||||
@jwt_required
|
@login_required
|
||||||
def get_commissions():
|
def get_commissions():
|
||||||
"""Get available commissions for optimization"""
|
"""Get available commissions for optimization"""
|
||||||
try:
|
try:
|
||||||
# Check if user has permission
|
# Check if user has permission
|
||||||
if g.jwt_user.role not in ['ADMIN', 'teacher']:
|
if current_user.role not in ['ADMIN', 'teacher']:
|
||||||
return jsonify({'error': 'Insufficient permissions'}), 403
|
return jsonify({'error': 'Insufficient permissions'}), 403
|
||||||
|
|
||||||
from app.models.subject import Commission, Subject
|
from app.models.subject import Commission, Subject
|
||||||
@@ -196,12 +196,12 @@ def get_commissions():
|
|||||||
|
|
||||||
|
|
||||||
@genetic_bp.route('/algorithm-status', methods=['GET'])
|
@genetic_bp.route('/algorithm-status', methods=['GET'])
|
||||||
@jwt_required
|
@login_required
|
||||||
def get_algorithm_status():
|
def get_algorithm_status():
|
||||||
"""Get genetic algorithm configuration status"""
|
"""Get genetic algorithm configuration status"""
|
||||||
try:
|
try:
|
||||||
# Check if user has permission
|
# Check if user has permission
|
||||||
if g.jwt_user.role not in ['ADMIN']:
|
if current_user.role not in ['ADMIN']:
|
||||||
return jsonify({'error': 'Insufficient permissions'}), 403
|
return jsonify({'error': 'Insufficient permissions'}), 403
|
||||||
|
|
||||||
# Get system status
|
# Get system status
|
||||||
@@ -246,10 +246,10 @@ genetic_web_bp = Blueprint('genetic_algorithm_web', __name__)
|
|||||||
|
|
||||||
|
|
||||||
@genetic_web_bp.route('/genetic-optimizer')
|
@genetic_web_bp.route('/genetic-optimizer')
|
||||||
@jwt_required
|
@login_required
|
||||||
def genetic_optimizer():
|
def genetic_optimizer():
|
||||||
"""Render the genetic algorithm optimizer interface"""
|
"""Render the genetic algorithm optimizer interface"""
|
||||||
if not (g.jwt_user.has_permission('optimizer', 'read') or (g.jwt_user.role and g.jwt_user.role.upper() == 'ADMIN')):
|
if current_user.role not in ['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,137 @@
|
|||||||
|
from flask import Blueprint, render_template, jsonify, redirect, url_for, session, request, flash
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from app import db
|
||||||
|
from app.models.reservation import Reservation
|
||||||
|
from app.models.classroom import Classroom
|
||||||
|
from app.models.subject import Subject
|
||||||
|
from app.models.subject import Commission
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from sqlalchemy import func
|
||||||
|
from flask_babel import gettext as _
|
||||||
|
|
||||||
|
main_bp = Blueprint('main', __name__)
|
||||||
|
|
||||||
|
@main_bp.route('/')
|
||||||
|
@login_required
|
||||||
|
def dashboard():
|
||||||
|
"""Main dashboard page"""
|
||||||
|
# Get statistics
|
||||||
|
try:
|
||||||
|
total_classrooms = Classroom.query.filter_by(is_active=True).count()
|
||||||
|
except:
|
||||||
|
total_classrooms = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
total_subjects = Subject.query.filter_by(is_active=True).count()
|
||||||
|
except:
|
||||||
|
total_subjects = 0
|
||||||
|
|
||||||
|
# Get today's reservations
|
||||||
|
today = datetime.utcnow().date()
|
||||||
|
try:
|
||||||
|
today_reservations = Reservation.query.filter(
|
||||||
|
func.date(Reservation.start_time) == today
|
||||||
|
).count()
|
||||||
|
except:
|
||||||
|
today_reservations = 0
|
||||||
|
|
||||||
|
# Get this week's reservations
|
||||||
|
week_start = today - timedelta(days=today.weekday())
|
||||||
|
week_end = week_start + timedelta(days=6)
|
||||||
|
try:
|
||||||
|
week_reservations = Reservation.query.filter(
|
||||||
|
func.date(Reservation.start_time) >= week_start,
|
||||||
|
func.date(Reservation.start_time) <= week_end
|
||||||
|
).count()
|
||||||
|
except:
|
||||||
|
week_reservations = 0
|
||||||
|
|
||||||
|
# Get upcoming reservations (next 7 days)
|
||||||
|
try:
|
||||||
|
upcoming_reservations = Reservation.query.filter(
|
||||||
|
Reservation.start_time >= datetime.utcnow(),
|
||||||
|
Reservation.start_time <= datetime.utcnow() + timedelta(days=7)
|
||||||
|
).limit(10).all()
|
||||||
|
except:
|
||||||
|
upcoming_reservations = []
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
'total_classrooms': total_classrooms,
|
||||||
|
'total_subjects': total_subjects,
|
||||||
|
'today_reservations': today_reservations,
|
||||||
|
'week_reservations': week_reservations
|
||||||
|
}
|
||||||
|
|
||||||
|
return render_template('dashboard.html',
|
||||||
|
stats=stats,
|
||||||
|
upcoming_reservations=upcoming_reservations)
|
||||||
|
|
||||||
|
@main_bp.route('/api/dashboard-stats')
|
||||||
|
@login_required
|
||||||
|
def dashboard_stats_api():
|
||||||
|
"""API endpoint for dashboard statistics"""
|
||||||
|
# Get today's data
|
||||||
|
today = datetime.utcnow().date()
|
||||||
|
today_reservations = Reservation.query.filter(
|
||||||
|
func.date(Reservation.start_time) == today
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# Get this week's data
|
||||||
|
week_start = today - timedelta(days=today.weekday())
|
||||||
|
week_end = week_start + timedelta(days=6)
|
||||||
|
week_reservations = Reservation.query.filter(
|
||||||
|
func.date(Reservation.start_time) >= week_start,
|
||||||
|
func.date(Reservation.start_time) <= week_end
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# Get monthly data for chart
|
||||||
|
current_date = datetime.utcnow()
|
||||||
|
monthly_data = []
|
||||||
|
|
||||||
|
for i in range(12):
|
||||||
|
month_date = current_date - timedelta(days=30 * i)
|
||||||
|
month_start = month_date.replace(day=1)
|
||||||
|
month_end = (month_start + timedelta(days=32)).replace(day=1) - timedelta(days=1)
|
||||||
|
|
||||||
|
monthly_reservations = Reservation.query.filter(
|
||||||
|
func.date(Reservation.start_time) >= month_start.date(),
|
||||||
|
func.date(Reservation.start_time) <= month_end.date()
|
||||||
|
).count()
|
||||||
|
|
||||||
|
monthly_data.append({
|
||||||
|
'month': month_start.strftime('%Y-%m'),
|
||||||
|
'reservations': monthly_reservations
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'today_reservations': today_reservations,
|
||||||
|
'week_reservations': week_reservations,
|
||||||
|
'monthly_data': list(reversed(monthly_data))
|
||||||
|
})
|
||||||
|
|
||||||
|
@main_bp.route('/genetic-optimizer')
|
||||||
|
@login_required
|
||||||
|
def genetic_optimizer():
|
||||||
|
"""Serve the genetic algorithm optimization interface"""
|
||||||
|
if current_user.role not in ['ADMIN']:
|
||||||
|
flash('You do not have permission to access the optimization tool.', 'danger')
|
||||||
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
|
return render_template('genetic_optimizer.html')
|
||||||
|
|
||||||
|
@main_bp.route('/set_language/<language>')
|
||||||
|
def set_language(language=None):
|
||||||
|
if language not in ['en', 'es']:
|
||||||
|
language = 'es'
|
||||||
|
|
||||||
|
session['language'] = language
|
||||||
|
|
||||||
|
# Si se selecciona el idioma desde la pantalla de login, registrar la selección explícita
|
||||||
|
if request.referrer and 'login' in request.referrer:
|
||||||
|
session['login_lang_explicit'] = language
|
||||||
|
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
current_user.preferred_language = language
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return redirect(request.referrer or url_for('main.dashboard'))␍
|
||||||
@@ -40,12 +40,8 @@ def today_schedule():
|
|||||||
~Classroom.building.ilike('%Virtual%')
|
~Classroom.building.ilike('%Virtual%')
|
||||||
)
|
)
|
||||||
|
|
||||||
if floor_filter:
|
if floor_filter and floor_filter.isdigit():
|
||||||
flr = str(floor_filter).strip()
|
query = query.filter(Classroom.floor == int(floor_filter))
|
||||||
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
|
|
||||||
query = query.filter(or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
|
|
||||||
else:
|
|
||||||
query = query.filter(Classroom.floor == flr)
|
|
||||||
|
|
||||||
if shift_filter:
|
if shift_filter:
|
||||||
query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%'))
|
query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%'))
|
||||||
@@ -139,102 +135,14 @@ def view_reservation(id):
|
|||||||
flash(f'Error loading reservation details: {str(e)}', 'error')
|
flash(f'Error loading reservation details: {str(e)}', 'error')
|
||||||
return redirect(url_for('schedule.today_schedule'))
|
return redirect(url_for('schedule.today_schedule'))
|
||||||
|
|
||||||
@schedule_bp.route('/reservation/<int:id>/edit', methods=['GET', 'POST'])
|
|
||||||
@login_required
|
|
||||||
def edit_reservation(id):
|
|
||||||
"""Edit existing reservation"""
|
|
||||||
from app.models.classroom import Classroom
|
|
||||||
from app.models.subject import Commission
|
|
||||||
from app.models.building import Building
|
|
||||||
from flask_babel import gettext as _
|
|
||||||
|
|
||||||
reservation = Reservation.query.options(
|
|
||||||
joinedload(Reservation.classroom),
|
|
||||||
joinedload(Reservation.commission),
|
|
||||||
joinedload(Reservation.user)
|
|
||||||
).get_or_404(id)
|
|
||||||
|
|
||||||
if not current_user.can_edit_reservation(reservation):
|
|
||||||
flash(_('No tienes los permisos necesarios para modificar esta reserva.'), 'error')
|
|
||||||
return redirect(url_for('schedule.view_reservation', id=reservation.id))
|
|
||||||
|
|
||||||
form = ReservationForm(is_edit=True)
|
|
||||||
|
|
||||||
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
|
|
||||||
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all()
|
|
||||||
commissions = Commission.query.filter_by(active=True).all()
|
|
||||||
|
|
||||||
form.classroom_id.choices = [(c.id, f'{c.building_name} - {c.code} (Cap: {c.capacity_display})') for c in classrooms]
|
|
||||||
form.commission_id.choices = [(c.id, f'{c.get_full_code()} - {c.subject.name if c.subject else "No Subject"} (Students: {c.max_students})') for c in commissions]
|
|
||||||
|
|
||||||
if request.method == 'GET':
|
|
||||||
form.classroom_id.data = reservation.classroom_id
|
|
||||||
form.commission_id.data = reservation.commission_id
|
|
||||||
form.start_time.data = reservation.start_time
|
|
||||||
form.end_time.data = reservation.end_time
|
|
||||||
form.purpose.data = reservation.purpose
|
|
||||||
form.expected_attendees.data = reservation.expected_attendees
|
|
||||||
form.notes.data = reservation.notes or ''
|
|
||||||
form.virtual_link.data = reservation.virtual_link or ''
|
|
||||||
|
|
||||||
elif request.method == 'POST':
|
|
||||||
if form.validate_on_submit():
|
|
||||||
try:
|
|
||||||
# Verificar colisión de horario si no es virtual
|
|
||||||
target_room = Classroom.query.get(form.classroom_id.data)
|
|
||||||
if target_room and not target_room.is_virtual:
|
|
||||||
conflict = Reservation.query.filter(
|
|
||||||
Reservation.id != reservation.id,
|
|
||||||
Reservation.classroom_id == form.classroom_id.data,
|
|
||||||
Reservation.status == ReservationStatus.CONFIRMED.value,
|
|
||||||
Reservation.start_time < form.end_time.data,
|
|
||||||
Reservation.end_time > form.start_time.data
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if conflict:
|
|
||||||
flash(_('El aula seleccionada ya cuenta con una reserva confirmada en esa franja horaria.'), 'danger')
|
|
||||||
return render_template('schedule/edit.html', form=form, reservation=reservation, classroom_list=classrooms, commission_list=commissions, buildings=buildings)
|
|
||||||
|
|
||||||
# Asignar cambios
|
|
||||||
reservation.classroom_id = form.classroom_id.data
|
|
||||||
reservation.commission_id = form.commission_id.data
|
|
||||||
reservation.start_time = form.start_time.data
|
|
||||||
reservation.end_time = form.end_time.data
|
|
||||||
reservation.purpose = form.purpose.data
|
|
||||||
reservation.expected_attendees = form.expected_attendees.data
|
|
||||||
reservation.notes = form.notes.data
|
|
||||||
reservation.virtual_link = form.virtual_link.data.strip() if form.virtual_link.data else None
|
|
||||||
|
|
||||||
# Turno
|
|
||||||
h = reservation.start_time.hour
|
|
||||||
if h < 13:
|
|
||||||
reservation.shift = 'Mañana'
|
|
||||||
elif h < 18:
|
|
||||||
reservation.shift = 'Tarde'
|
|
||||||
elif h < 21:
|
|
||||||
reservation.shift = 'Vespertino'
|
|
||||||
else:
|
|
||||||
reservation.shift = 'Noche'
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
flash(_('Reserva #%(id)d modificada exitosamente.', id=reservation.id), 'success')
|
|
||||||
return redirect(url_for('schedule.view_reservation', id=reservation.id))
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
flash(f'Error al modificar reserva: {str(e)}', 'danger')
|
|
||||||
|
|
||||||
return render_template('schedule/edit.html', form=form, reservation=reservation, classroom_list=classrooms, commission_list=commissions, buildings=buildings)
|
|
||||||
|
|
||||||
@schedule_bp.route('/calendar')
|
@schedule_bp.route('/calendar')
|
||||||
@login_required
|
@login_required
|
||||||
def calendar_view():
|
def calendar_view():
|
||||||
"""Show calendar view with classroom, subject, career, shift, floor and virtual filtering"""
|
"""Show calendar view with classroom, subject, career, shift, floor and virtual filtering"""
|
||||||
from app.models.career import Career
|
from app.models.career import Career
|
||||||
from app.models.building import Building
|
|
||||||
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building.asc(), Classroom.code.asc()).all()
|
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building.asc(), Classroom.code.asc()).all()
|
||||||
subjects = Subject.query.filter_by(is_active=True).order_by(Subject.name.asc()).all()
|
subjects = Subject.query.filter_by(is_active=True).order_by(Subject.name.asc()).all()
|
||||||
careers = Career.query.order_by(Career.name.asc()).all()
|
careers = Career.query.order_by(Career.name.asc()).all()
|
||||||
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
|
|
||||||
|
|
||||||
floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()
|
floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()
|
||||||
available_floors = [f[0] for f in floors]
|
available_floors = [f[0] for f in floors]
|
||||||
@@ -246,246 +154,22 @@ def calendar_view():
|
|||||||
classrooms=classrooms,
|
classrooms=classrooms,
|
||||||
subjects=subjects,
|
subjects=subjects,
|
||||||
careers=careers,
|
careers=careers,
|
||||||
buildings=buildings,
|
|
||||||
available_floors=available_floors
|
available_floors=available_floors
|
||||||
)
|
)
|
||||||
|
|
||||||
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')
|
@schedule_bp.route('/commissions')
|
||||||
@login_required
|
|
||||||
def commissions_view():
|
def commissions_view():
|
||||||
"""Vista y ABM de Comisiones con soporte de enlace virtual e hitos académicos"""
|
subjects = Subject.query.all()
|
||||||
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()
|
# Calculamos el total de capacidad sumando todas las comisiones de todas las materias
|
||||||
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.code.asc()).all()
|
|
||||||
teachers = User.query.filter_by(is_active=True).order_by(User.name.asc()).all()
|
|
||||||
milestone_types = MilestoneType.query.filter_by(is_active=True).order_by(MilestoneType.sort_order.asc()).all()
|
|
||||||
|
|
||||||
# Totales para indicadores superiores
|
|
||||||
total_capacity = sum(c.max_students for s in subjects for c in s.commissions)
|
total_capacity = sum(c.max_students for s in subjects for c in s.commissions)
|
||||||
|
# Calculamos el total de comisiones
|
||||||
total_commissions = sum(len(s.commissions) for s in subjects)
|
total_commissions = sum(len(s.commissions) for s in subjects)
|
||||||
|
|
||||||
can_manage_milestones = user_can_manage_commission_milestones(current_user)
|
|
||||||
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
|
|
||||||
can_edit_commissions = current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')
|
|
||||||
|
|
||||||
return render_template('schedule/commissions.html',
|
return render_template('schedule/commissions.html',
|
||||||
subjects=subjects,
|
subjects=subjects,
|
||||||
classrooms=classrooms,
|
|
||||||
teachers=teachers,
|
|
||||||
milestone_types=milestone_types,
|
|
||||||
total_capacity=total_capacity,
|
total_capacity=total_capacity,
|
||||||
total_commissions=total_commissions,
|
total_commissions=total_commissions)
|
||||||
can_manage_milestones=can_manage_milestones,
|
|
||||||
can_edit_commissions=can_edit_commissions)
|
|
||||||
|
|
||||||
|
|
||||||
@schedule_bp.route('/commissions/add', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def commission_add():
|
|
||||||
"""Crear una nueva comisión de cursada"""
|
|
||||||
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
|
|
||||||
if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')):
|
|
||||||
flash(_('No tienes permisos para crear comisiones.'), 'error')
|
|
||||||
return redirect(url_for('schedule.commissions_view'))
|
|
||||||
|
|
||||||
from app.models.subject import Commission
|
|
||||||
subject_id = request.form.get('subject_id', type=int)
|
|
||||||
code = request.form.get('code', '').strip()
|
|
||||||
semester = request.form.get('semester', '1C').strip()
|
|
||||||
year = request.form.get('year', datetime.now().year, type=int)
|
|
||||||
teacher_id = request.form.get('teacher_id', type=int) or None
|
|
||||||
max_students = request.form.get('max_students', 40, type=int)
|
|
||||||
schedule = request.form.get('schedule', '').strip()
|
|
||||||
shift = request.form.get('shift', 'Tarde').strip()
|
|
||||||
virtual_link = request.form.get('virtual_link', '').strip()
|
|
||||||
|
|
||||||
if not subject_id or not code:
|
|
||||||
flash(_('La asignatura y el código de comisión son obligatorios.'), 'error')
|
|
||||||
return redirect(url_for('schedule.commissions_view'))
|
|
||||||
|
|
||||||
# Verificar existencia duplicada
|
|
||||||
existing = Commission.query.filter_by(subject_id=subject_id, code=code, semester=semester, year=year).first()
|
|
||||||
if existing:
|
|
||||||
flash(_('Ya existe una comisión %(code)s para el ciclo %(sem)s %(yr)d.', code=code, sem=semester, yr=year), 'error')
|
|
||||||
return redirect(url_for('schedule.commissions_view'))
|
|
||||||
|
|
||||||
new_comm = Commission(
|
|
||||||
subject_id=subject_id,
|
|
||||||
code=code,
|
|
||||||
semester=semester,
|
|
||||||
year=year,
|
|
||||||
teacher_id=teacher_id,
|
|
||||||
max_students=max_students,
|
|
||||||
schedule=schedule,
|
|
||||||
shift=shift,
|
|
||||||
virtual_link=virtual_link or None,
|
|
||||||
active=True
|
|
||||||
)
|
|
||||||
db.session.add(new_comm)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
flash(_('Comisión %(code)s creada exitosamente.', code=new_comm.get_full_code()), 'success')
|
|
||||||
return redirect(url_for('schedule.commissions_view'))
|
|
||||||
|
|
||||||
|
|
||||||
@schedule_bp.route('/commissions/<int:id>/edit', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def commission_edit(id):
|
|
||||||
"""Editar datos de una comisión"""
|
|
||||||
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
|
|
||||||
if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')):
|
|
||||||
flash(_('No tienes permisos para modificar comisiones.'), 'error')
|
|
||||||
return redirect(url_for('schedule.commissions_view'))
|
|
||||||
|
|
||||||
from app.models.subject import Commission
|
|
||||||
commission = Commission.query.get_or_404(id)
|
|
||||||
|
|
||||||
commission.code = request.form.get('code', commission.code).strip()
|
|
||||||
commission.semester = request.form.get('semester', commission.semester).strip()
|
|
||||||
commission.year = request.form.get('year', commission.year, type=int)
|
|
||||||
commission.teacher_id = request.form.get('teacher_id', type=int) or None
|
|
||||||
commission.max_students = request.form.get('max_students', commission.max_students, type=int)
|
|
||||||
commission.schedule = request.form.get('schedule', commission.schedule).strip()
|
|
||||||
commission.shift = request.form.get('shift', commission.shift).strip()
|
|
||||||
commission.virtual_link = request.form.get('virtual_link', '').strip() or None
|
|
||||||
commission.active = True if request.form.get('active') in ['1', 'true', 'on'] else False
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
flash(_('Comisión %(code)s actualizada con éxito.', code=commission.get_full_code()), 'success')
|
|
||||||
return redirect(url_for('schedule.commissions_view'))
|
|
||||||
|
|
||||||
|
|
||||||
@schedule_bp.route('/commissions/<int:id>/delete', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def commission_delete(id):
|
|
||||||
"""Eliminar una comisión"""
|
|
||||||
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
|
|
||||||
if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')):
|
|
||||||
flash(_('No tienes permisos para eliminar comisiones.'), 'error')
|
|
||||||
return redirect(url_for('schedule.commissions_view'))
|
|
||||||
|
|
||||||
from app.models.subject import Commission
|
|
||||||
commission = Commission.query.get_or_404(id)
|
|
||||||
full_code = commission.get_full_code()
|
|
||||||
|
|
||||||
db.session.delete(commission)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
flash(_('Comisión %(code)s eliminada permanentemente.', code=full_code), 'success')
|
|
||||||
return redirect(url_for('schedule.commissions_view'))
|
|
||||||
|
|
||||||
|
|
||||||
@schedule_bp.route('/commissions/<int:id>/virtual-link', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def commission_virtual_link(id):
|
|
||||||
"""Actualizar enlace del aula virtual (Zoom, Meet, Teams, Moodle) - Profesores, Admin y Bedelía"""
|
|
||||||
from app.models.subject import Commission
|
|
||||||
commission = Commission.query.get_or_404(id)
|
|
||||||
|
|
||||||
if not user_can_manage_commission_milestones(current_user, commission):
|
|
||||||
flash(_('No tienes permisos para actualizar el aula virtual de esta comisión.'), 'error')
|
|
||||||
return redirect(url_for('schedule.commissions_view'))
|
|
||||||
|
|
||||||
link = request.form.get('virtual_link', '').strip()
|
|
||||||
commission.virtual_link = link or None
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
flash(_('Enlace del aula virtual actualizado para la comisión %(code)s.', code=commission.get_full_code()), 'success')
|
|
||||||
return redirect(url_for('schedule.commissions_view'))
|
|
||||||
|
|
||||||
|
|
||||||
@schedule_bp.route('/commissions/<int:id>/milestones/add', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def commission_milestone_add(id):
|
|
||||||
"""Agregar un hito académico (Parcial, TP, Recuperatorio) a una comisión - Profesores, Admin y Bedelía"""
|
|
||||||
from app.models.subject import Commission
|
|
||||||
from app.models.milestone import AcademicMilestone, MilestoneType
|
|
||||||
commission = Commission.query.get_or_404(id)
|
|
||||||
|
|
||||||
if not user_can_manage_commission_milestones(current_user, commission):
|
|
||||||
flash(_('No tienes permisos para agregar hitos a esta comisión.'), 'error')
|
|
||||||
return redirect(url_for('schedule.commissions_view'))
|
|
||||||
|
|
||||||
milestone_type_id = request.form.get('milestone_type_id', type=int)
|
|
||||||
title = request.form.get('title', '').strip()
|
|
||||||
date_str = request.form.get('date', '').strip()
|
|
||||||
time_str = request.form.get('time', '18:00').strip()
|
|
||||||
classroom = request.form.get('classroom', '').strip()
|
|
||||||
link = request.form.get('link', '').strip() or commission.virtual_link or ''
|
|
||||||
notes = request.form.get('notes', '').strip()
|
|
||||||
|
|
||||||
if not milestone_type_id or not date_str:
|
|
||||||
flash(_('Debes especificar el tipo de hito y la fecha.'), 'error')
|
|
||||||
return redirect(url_for('schedule.commissions_view'))
|
|
||||||
|
|
||||||
m_type = MilestoneType.query.get(milestone_type_id)
|
|
||||||
if not m_type:
|
|
||||||
flash(_('Tipo de hito inválido.'), 'error')
|
|
||||||
return redirect(url_for('schedule.commissions_view'))
|
|
||||||
|
|
||||||
try:
|
|
||||||
dt = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M")
|
|
||||||
except ValueError:
|
|
||||||
try:
|
|
||||||
dt = datetime.strptime(date_str, "%Y-%m-%d")
|
|
||||||
except ValueError:
|
|
||||||
flash(_('Formato de fecha inválido.'), 'error')
|
|
||||||
return redirect(url_for('schedule.commissions_view'))
|
|
||||||
|
|
||||||
milestone = AcademicMilestone(
|
|
||||||
title=title or m_type.name,
|
|
||||||
materia_codigo=commission.subject.code if commission.subject else 'ASIG',
|
|
||||||
subject_id=commission.subject_id,
|
|
||||||
commission_id=commission.id,
|
|
||||||
milestone_type_id=m_type.id,
|
|
||||||
date=dt,
|
|
||||||
classroom=classroom or 'Campus Virtual',
|
|
||||||
link=link or None,
|
|
||||||
notes=notes or None,
|
|
||||||
is_active=True
|
|
||||||
)
|
|
||||||
db.session.add(milestone)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
flash(_('Hito "%(title)s" registrado correctamente en %(comm)s.', title=milestone.title, comm=commission.get_full_code()), 'success')
|
|
||||||
return redirect(url_for('schedule.commissions_view'))
|
|
||||||
|
|
||||||
|
|
||||||
@schedule_bp.route('/commissions/<int:commission_id>/milestones/<int:milestone_id>/delete', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def commission_milestone_delete(commission_id, milestone_id):
|
|
||||||
"""Eliminar un hito académico de una comisión"""
|
|
||||||
from app.models.subject import Commission
|
|
||||||
from app.models.milestone import AcademicMilestone
|
|
||||||
commission = Commission.query.get_or_404(commission_id)
|
|
||||||
|
|
||||||
if not user_can_manage_commission_milestones(current_user, commission):
|
|
||||||
flash(_('No tienes permisos para eliminar hitos de esta comisión.'), 'error')
|
|
||||||
return redirect(url_for('schedule.commissions_view'))
|
|
||||||
|
|
||||||
milestone = AcademicMilestone.query.filter_by(id=milestone_id, commission_id=commission_id).first_or_404()
|
|
||||||
title = milestone.title
|
|
||||||
db.session.delete(milestone)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
flash(_('Hito "%(title)s" eliminado de la comisión.', title=title), 'success')
|
|
||||||
return redirect(url_for('schedule.commissions_view'))
|
|
||||||
|
|
||||||
@schedule_bp.route('/add', methods=['GET', 'POST'])
|
@schedule_bp.route('/add', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@@ -493,17 +177,17 @@ def add_reservation():
|
|||||||
"""Add new reservation"""
|
"""Add new reservation"""
|
||||||
from app.models.classroom import Classroom
|
from app.models.classroom import Classroom
|
||||||
from app.models.subject import Commission
|
from app.models.subject import Commission
|
||||||
from app.models.building import Building
|
|
||||||
|
|
||||||
form = ReservationForm()
|
form = ReservationForm()
|
||||||
|
|
||||||
# Get active buildings and classrooms
|
# Get active classrooms for the dropdown
|
||||||
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
|
classrooms = Classroom.query.filter_by(is_active=True).all()
|
||||||
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all()
|
|
||||||
|
# Get active commissions for the dropdown
|
||||||
commissions = Commission.query.filter_by(active=True).all()
|
commissions = Commission.query.filter_by(active=True).all()
|
||||||
|
|
||||||
# Populate form choices
|
# Populate form choices
|
||||||
form.classroom_id.choices = [(c.id, f'{c.building_name} - {c.code} (Cap: {c.capacity_display})') for c in classrooms]
|
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]
|
form.commission_id.choices = [(c.id, f'{c.get_full_code()} - {c.subject.name if c.subject else "No Subject"} (Students: {c.max_students})') for c in commissions]
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
@@ -521,7 +205,7 @@ def add_reservation():
|
|||||||
|
|
||||||
if existing_reservation:
|
if existing_reservation:
|
||||||
flash('This classroom is already booked for the selected time slot.', 'danger')
|
flash('This classroom is already booked for the selected time slot.', 'danger')
|
||||||
return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions, buildings=buildings)
|
return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions)
|
||||||
|
|
||||||
# Create new reservation
|
# Create new reservation
|
||||||
reservation = Reservation(
|
reservation = Reservation(
|
||||||
@@ -532,7 +216,6 @@ def add_reservation():
|
|||||||
end_time=form.end_time.data,
|
end_time=form.end_time.data,
|
||||||
purpose=form.purpose.data,
|
purpose=form.purpose.data,
|
||||||
notes=form.notes.data,
|
notes=form.notes.data,
|
||||||
virtual_link=form.virtual_link.data.strip() if form.virtual_link.data else None,
|
|
||||||
status=ReservationStatus.PENDING.value,
|
status=ReservationStatus.PENDING.value,
|
||||||
expected_attendees=form.expected_attendees.data
|
expected_attendees=form.expected_attendees.data
|
||||||
)
|
)
|
||||||
@@ -545,53 +228,10 @@ def add_reservation():
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
|
current_app.logger.error(f"Error creating reservation: {str(e)}")
|
||||||
flash(f'Error creating reservation: {str(e)}', 'danger')
|
flash(f'Error creating reservation: {str(e)}', 'danger')
|
||||||
|
|
||||||
return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions, buildings=buildings)
|
return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions)
|
||||||
|
|
||||||
@schedule_bp.route('/reservation/add', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def quick_reservation_add():
|
|
||||||
"""Endpoint AJAX para crear reserva rápida desde el modal de comisiones"""
|
|
||||||
try:
|
|
||||||
commission_id = request.form.get('commission_id', type=int)
|
|
||||||
classroom_id = request.form.get('classroom_id', type=int)
|
|
||||||
purpose = request.form.get('purpose', '').strip() or 'Clase de cursada'
|
|
||||||
start_str = request.form.get('start_time', '').strip()
|
|
||||||
end_str = request.form.get('end_time', '').strip()
|
|
||||||
virtual_link = request.form.get('virtual_link', '').strip() or None
|
|
||||||
|
|
||||||
if not commission_id or not classroom_id or not start_str or not end_str:
|
|
||||||
return jsonify({'error': 'Faltan campos obligatorios'}), 400
|
|
||||||
|
|
||||||
start_time = datetime.strptime(start_str, '%Y-%m-%d %H:%M')
|
|
||||||
end_time = datetime.strptime(end_str, '%Y-%m-%d %H:%M')
|
|
||||||
|
|
||||||
from app.models.subject import Commission
|
|
||||||
from app.models.classroom import Classroom
|
|
||||||
comm = Commission.query.get_or_404(commission_id)
|
|
||||||
|
|
||||||
if not virtual_link and comm.virtual_link:
|
|
||||||
virtual_link = comm.virtual_link
|
|
||||||
|
|
||||||
new_res = Reservation(
|
|
||||||
classroom_id=classroom_id,
|
|
||||||
commission_id=commission_id,
|
|
||||||
user_id=comm.teacher_id or current_user.id,
|
|
||||||
start_time=start_time,
|
|
||||||
end_time=end_time,
|
|
||||||
purpose=purpose,
|
|
||||||
expected_attendees=comm.max_students or 30,
|
|
||||||
shift=comm.shift or 'Tarde',
|
|
||||||
virtual_link=virtual_link,
|
|
||||||
status=ReservationStatus.CONFIRMED.value if current_user.is_admin() else ReservationStatus.PENDING.value
|
|
||||||
)
|
|
||||||
db.session.add(new_res)
|
|
||||||
db.session.commit()
|
|
||||||
return jsonify({'success': True, 'id': new_res.id})
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
return jsonify({'error': str(e)}), 500
|
|
||||||
|
|
||||||
@schedule_bp.route('/list')
|
@schedule_bp.route('/list')
|
||||||
@login_required
|
@login_required
|
||||||
@@ -604,7 +244,6 @@ def list_reservations():
|
|||||||
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
|
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
|
||||||
floor_filter = request.args.get('floor', '').strip()
|
floor_filter = request.args.get('floor', '').strip()
|
||||||
shift_filter = request.args.get('shift', '').strip()
|
shift_filter = request.args.get('shift', '').strip()
|
||||||
classroom_id = request.args.get('classroom_id', type=int)
|
|
||||||
search = request.args.get('search', '').strip()
|
search = request.args.get('search', '').strip()
|
||||||
|
|
||||||
query = Reservation.query.options(
|
query = Reservation.query.options(
|
||||||
@@ -613,9 +252,6 @@ def list_reservations():
|
|||||||
joinedload(Reservation.user)
|
joinedload(Reservation.user)
|
||||||
).join(Reservation.classroom).outerjoin(Reservation.commission).outerjoin(Commission.subject)
|
).join(Reservation.classroom).outerjoin(Reservation.commission).outerjoin(Commission.subject)
|
||||||
|
|
||||||
if classroom_id:
|
|
||||||
query = query.filter(Reservation.classroom_id == classroom_id)
|
|
||||||
|
|
||||||
if status_filter and status_filter != 'all':
|
if status_filter and status_filter != 'all':
|
||||||
query = query.filter(Reservation.status == status_filter.upper())
|
query = query.filter(Reservation.status == status_filter.upper())
|
||||||
|
|
||||||
@@ -639,12 +275,8 @@ def list_reservations():
|
|||||||
~Classroom.building.ilike('%Virtual%')
|
~Classroom.building.ilike('%Virtual%')
|
||||||
)
|
)
|
||||||
|
|
||||||
if floor_filter:
|
if floor_filter and floor_filter.isdigit():
|
||||||
flr = str(floor_filter).strip()
|
query = query.filter(Classroom.floor == int(floor_filter))
|
||||||
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
|
|
||||||
query = query.filter(or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
|
|
||||||
else:
|
|
||||||
query = query.filter(Classroom.floor == flr)
|
|
||||||
|
|
||||||
if shift_filter:
|
if shift_filter:
|
||||||
query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%'))
|
query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%'))
|
||||||
@@ -654,10 +286,8 @@ def list_reservations():
|
|||||||
or_(
|
or_(
|
||||||
Subject.name.ilike(f'%{search}%'),
|
Subject.name.ilike(f'%{search}%'),
|
||||||
Subject.code.ilike(f'%{search}%'),
|
Subject.code.ilike(f'%{search}%'),
|
||||||
Commission.code.ilike(f'%{search}%'),
|
|
||||||
Classroom.code.ilike(f'%{search}%'),
|
Classroom.code.ilike(f'%{search}%'),
|
||||||
Reservation.purpose.ilike(f'%{search}%'),
|
Reservation.purpose.ilike(f'%{search}%')
|
||||||
Reservation.shift.ilike(f'%{search}%')
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -667,7 +297,6 @@ def list_reservations():
|
|||||||
|
|
||||||
floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()
|
floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()
|
||||||
available_floors = [f[0] for f in floors]
|
available_floors = [f[0] for f in floors]
|
||||||
all_classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all()
|
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'schedule/list.html',
|
'schedule/list.html',
|
||||||
@@ -678,8 +307,6 @@ def list_reservations():
|
|||||||
hide_virtual=hide_virtual,
|
hide_virtual=hide_virtual,
|
||||||
floor_filter=floor_filter,
|
floor_filter=floor_filter,
|
||||||
shift_filter=shift_filter,
|
shift_filter=shift_filter,
|
||||||
classroom_id=classroom_id,
|
|
||||||
all_classrooms=all_classrooms,
|
|
||||||
search=search,
|
search=search,
|
||||||
available_floors=available_floors
|
available_floors=available_floors
|
||||||
)
|
)
|
||||||
@@ -816,6 +443,7 @@ def calendar_data():
|
|||||||
search = request.args.get('search', '').strip()
|
search = request.args.get('search', '').strip()
|
||||||
|
|
||||||
if start and end:
|
if start and end:
|
||||||
|
from datetime import datetime
|
||||||
# Clean up ISO timezone strings for PostgreSQL naive timestamp comparison
|
# Clean up ISO timezone strings for PostgreSQL naive timestamp comparison
|
||||||
start_clean = start.replace('Z', '+00:00')
|
start_clean = start.replace('Z', '+00:00')
|
||||||
end_clean = end.replace('Z', '+00:00')
|
end_clean = end.replace('Z', '+00:00')
|
||||||
@@ -844,16 +472,6 @@ def calendar_data():
|
|||||||
if career_id and career_id.isdigit():
|
if career_id and career_id.isdigit():
|
||||||
query = query.filter(Subject.career_id == int(career_id))
|
query = query.filter(Subject.career_id == int(career_id))
|
||||||
|
|
||||||
building = request.args.get('building', '').strip()
|
|
||||||
if building:
|
|
||||||
from app.models.building import Building
|
|
||||||
query = query.filter(
|
|
||||||
or_(
|
|
||||||
Classroom.building.ilike(f'%{building}%'),
|
|
||||||
Classroom.building_entity.has(Building.name.ilike(f'%{building}%'))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if shift:
|
if shift:
|
||||||
query = query.filter(Reservation.shift.ilike(f'%{shift}%'))
|
query = query.filter(Reservation.shift.ilike(f'%{shift}%'))
|
||||||
|
|
||||||
@@ -863,12 +481,8 @@ def calendar_data():
|
|||||||
~Classroom.building.ilike('%Virtual%')
|
~Classroom.building.ilike('%Virtual%')
|
||||||
)
|
)
|
||||||
|
|
||||||
if floor:
|
if floor and floor.isdigit():
|
||||||
flr = str(floor).strip()
|
query = query.filter(Classroom.floor == int(floor))
|
||||||
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
|
|
||||||
query = query.filter(or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
|
|
||||||
else:
|
|
||||||
query = query.filter(Classroom.floor == flr)
|
|
||||||
|
|
||||||
if search:
|
if search:
|
||||||
query = query.filter(
|
query = query.filter(
|
||||||
@@ -888,9 +502,8 @@ def calendar_data():
|
|||||||
subject_code = subject.code if subject else (reservation.commission.code if reservation.commission else '')
|
subject_code = subject.code if subject else (reservation.commission.code if reservation.commission else '')
|
||||||
subject_name = subject.name if subject else (reservation.purpose or 'Clase')
|
subject_name = subject.name if subject else (reservation.purpose or 'Clase')
|
||||||
classroom_name = reservation.classroom.code if reservation.classroom else 'Sin Aula'
|
classroom_name = reservation.classroom.code if reservation.classroom else 'Sin Aula'
|
||||||
classroom_floor = reservation.classroom.floor_display if reservation.classroom else None
|
classroom_floor = reservation.classroom.floor if reservation.classroom else None
|
||||||
classroom_building = reservation.classroom.building_name if reservation.classroom else ''
|
is_virtual = bool(reservation.classroom and ('VIRTUAL' in (reservation.classroom.code or '').upper() or 'VIRTUAL' in (reservation.classroom.building or '').upper()))
|
||||||
is_virtual = bool(reservation.classroom and reservation.classroom.is_virtual)
|
|
||||||
career_name = subject.career_obj.name if (subject and subject.career_obj) else (subject.department if subject else '')
|
career_name = subject.career_obj.name if (subject and subject.career_obj) else (subject.department if subject else '')
|
||||||
status_val = str(reservation.status.value if hasattr(reservation.status, 'value') else reservation.status)
|
status_val = str(reservation.status.value if hasattr(reservation.status, 'value') else reservation.status)
|
||||||
|
|
||||||
@@ -905,20 +518,16 @@ def calendar_data():
|
|||||||
'subject_code': subject_code,
|
'subject_code': subject_code,
|
||||||
'subject_name': subject_name,
|
'subject_name': subject_name,
|
||||||
'classroom': classroom_name,
|
'classroom': classroom_name,
|
||||||
'building': classroom_building,
|
'building': reservation.classroom.building if reservation.classroom else '',
|
||||||
'floor': classroom_floor,
|
'floor': classroom_floor,
|
||||||
'is_virtual': is_virtual,
|
'is_virtual': is_virtual,
|
||||||
'location_display': reservation.classroom.location_display if reservation.classroom else '',
|
|
||||||
'career': career_name,
|
'career': career_name,
|
||||||
'commission': reservation.commission.code if reservation.commission else '',
|
'commission': reservation.commission.code if reservation.commission else '',
|
||||||
'shift': reservation.shift or '',
|
'shift': reservation.shift or '',
|
||||||
'status': status_val,
|
'status': status_val,
|
||||||
'purpose': reservation.purpose or '',
|
'purpose': reservation.purpose or '',
|
||||||
'attendees': reservation.expected_attendees or 0,
|
'attendees': reservation.expected_attendees or 0,
|
||||||
'notes': reservation.notes or '',
|
'notes': reservation.notes or ''
|
||||||
'teacher': reservation.user.name if reservation.user else '',
|
|
||||||
'virtual_link': reservation.effective_virtual_link,
|
|
||||||
'cancel_url': url_for('schedule.cancel_reservation', id=reservation.id)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .sheets_importer import GoogleSheetsImporter
|
||||||
|
|
||||||
|
__all__ = ['GoogleSheetsImporter']
|
||||||
@@ -2,7 +2,6 @@ import urllib.request
|
|||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
import re
|
import re
|
||||||
import os
|
|
||||||
from datetime import datetime, date, time, timedelta
|
from datetime import datetime, date, time, timedelta
|
||||||
from app import db
|
from app import db
|
||||||
from app.models.classroom import Classroom
|
from app.models.classroom import Classroom
|
||||||
@@ -13,41 +12,6 @@ from app.models.user import User
|
|||||||
|
|
||||||
BASE_CSV_URL = 'https://docs.google.com/spreadsheets/d/e/2PACX-1vSc_T_BQjbn3uPelioCgx52UM5Py-qNhJN0TYPd1kmsN5jdb3Q8rAaIvNMF_2ZTzQt6bH--yIWQKrKR/pub?single=true&output=csv'
|
BASE_CSV_URL = 'https://docs.google.com/spreadsheets/d/e/2PACX-1vSc_T_BQjbn3uPelioCgx52UM5Py-qNhJN0TYPd1kmsN5jdb3Q8rAaIvNMF_2ZTzQt6bH--yIWQKrKR/pub?single=true&output=csv'
|
||||||
|
|
||||||
def map_day_to_idx(day_str):
|
|
||||||
"""Mapea el nombre del día (o prefijo) a su índice numérico 0..6 (Lunes=0)"""
|
|
||||||
d = (day_str or '').lower()
|
|
||||||
if 'lun' in d: return 0
|
|
||||||
if 'mar' in d: return 1
|
|
||||||
if 'mie' in d or 'mié' in d: return 2
|
|
||||||
if 'jue' in d: return 3
|
|
||||||
if 'vie' in d: return 4
|
|
||||||
if 'sab' in d or 'sáb' in d: return 5
|
|
||||||
if 'dom' in d: return 6
|
|
||||||
return 0
|
|
||||||
|
|
||||||
def normalize_sheets_url(url):
|
|
||||||
"""
|
|
||||||
Normaliza enlaces de Google Sheets (compartidos, de edición, con /u/1/, #gid=... o /pubhtml)
|
|
||||||
para obtener la URL base que acepta parámetro gid=<gid>&output=csv o format=csv.
|
|
||||||
"""
|
|
||||||
if not url:
|
|
||||||
return BASE_CSV_URL
|
|
||||||
url = url.strip()
|
|
||||||
|
|
||||||
# 1. Enlace publicado web (/d/e/2PACX-... con o sin /u/X/ y con pub o pubhtml)
|
|
||||||
pub_match = re.search(r'/spreadsheets/(?:u/\d+/)?d/e/([a-zA-Z0-9-_]+)', url)
|
|
||||||
if pub_match:
|
|
||||||
doc_id = pub_match.group(1)
|
|
||||||
return f"https://docs.google.com/spreadsheets/d/e/{doc_id}/pub?single=true&output=csv"
|
|
||||||
|
|
||||||
# 2. Enlace de edición o exportación (/d/<SPREADSHEET_ID>/...)
|
|
||||||
sheet_id_match = re.search(r'/spreadsheets/(?:u/\d+/)?d/([a-zA-Z0-9-_]+)', url)
|
|
||||||
if sheet_id_match:
|
|
||||||
sheet_id = sheet_id_match.group(1)
|
|
||||||
return f"https://docs.google.com/spreadsheets/d/{sheet_id}/export?format=csv"
|
|
||||||
|
|
||||||
return url
|
|
||||||
|
|
||||||
SHEETS_CONFIG = [
|
SHEETS_CONFIG = [
|
||||||
{ 'name': 'Lunes', 'gid': '1915752353', 'day_idx': 0 },
|
{ 'name': 'Lunes', 'gid': '1915752353', 'day_idx': 0 },
|
||||||
{ 'name': 'Martes', 'gid': '466778263', 'day_idx': 1 },
|
{ 'name': 'Martes', 'gid': '466778263', 'day_idx': 1 },
|
||||||
@@ -66,64 +30,16 @@ class GoogleSheetsImporter:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, base_url=None):
|
def __init__(self, base_url=None):
|
||||||
if base_url:
|
self.base_url = base_url or BASE_CSV_URL
|
||||||
self.base_url = normalize_sheets_url(base_url)
|
|
||||||
else:
|
|
||||||
configured_url = None
|
|
||||||
try:
|
|
||||||
from app.models.setting import SystemSetting
|
|
||||||
configured_url = SystemSetting.get_value('google_sheets_url')
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if not configured_url:
|
|
||||||
configured_url = os.getenv('GOOGLE_SHEETS_URL')
|
|
||||||
self.base_url = normalize_sheets_url(configured_url) if configured_url else BASE_CSV_URL
|
|
||||||
|
|
||||||
def get_sheets_config(self):
|
|
||||||
"""
|
|
||||||
Descubre las pestañas y GIDs dinámicamente desde /pubhtml si está disponible,
|
|
||||||
o utiliza el fallback SHEETS_CONFIG preconfigurado.
|
|
||||||
"""
|
|
||||||
pub_match = re.search(r'/spreadsheets/d/e/([a-zA-Z0-9-_]+)', self.base_url)
|
|
||||||
if pub_match:
|
|
||||||
doc_id = pub_match.group(1)
|
|
||||||
pubhtml_url = f"https://docs.google.com/spreadsheets/d/e/{doc_id}/pubhtml"
|
|
||||||
try:
|
|
||||||
req = urllib.request.Request(
|
|
||||||
pubhtml_url,
|
|
||||||
headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) EduSpace/2.0'}
|
|
||||||
)
|
|
||||||
with urllib.request.urlopen(req, timeout=12) as resp:
|
|
||||||
html_content = resp.read().decode('utf-8', errors='replace')
|
|
||||||
# Extraer pares name y gid de Google Sheets pubhtml
|
|
||||||
found = re.findall(r'name:\s*["\']([^"\']+)["\'],\s*gid:\s*["\'](\d+)["\']', html_content)
|
|
||||||
if found:
|
|
||||||
discovered = []
|
|
||||||
for name, gid in found:
|
|
||||||
clean_name = re.sub(r'[^a-zA-ZáéíóúÁÉÍÓÚñÑ]', '', name).capitalize()
|
|
||||||
d_idx = map_day_to_idx(clean_name)
|
|
||||||
discovered.append({
|
|
||||||
'name': clean_name if clean_name else name,
|
|
||||||
'gid': gid,
|
|
||||||
'day_idx': d_idx
|
|
||||||
})
|
|
||||||
if discovered:
|
|
||||||
return discovered
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return SHEETS_CONFIG
|
|
||||||
|
|
||||||
def fetch_sheet_csv(self, gid):
|
def fetch_sheet_csv(self, gid):
|
||||||
"""Fetch CSV string for a specific sheet gid"""
|
"""Fetch CSV string for a specific sheet gid"""
|
||||||
separator = '&' if '?' in self.base_url else '?'
|
url = f"{self.base_url}&gid={gid}"
|
||||||
url = f"{self.base_url}{separator}gid={gid}"
|
|
||||||
if not (url.startswith('https://') or url.startswith('http://')):
|
|
||||||
raise ValueError(f"URL scheme not permitted: {url}")
|
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
url,
|
url,
|
||||||
headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) EduSpace/2.0'}
|
headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) EduSpace/2.0'}
|
||||||
)
|
)
|
||||||
with urllib.request.urlopen(req, timeout=25) as resp: # nosec B310
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
return resp.read().decode('utf-8', errors='replace')
|
return resp.read().decode('utf-8', errors='replace')
|
||||||
|
|
||||||
def parse_sheet_rows(self, csv_content, sheet_name):
|
def parse_sheet_rows(self, csv_content, sheet_name):
|
||||||
@@ -151,10 +67,7 @@ class GoogleSheetsImporter:
|
|||||||
current_shift = 'Mañana'
|
current_shift = 'Mañana'
|
||||||
for row in rows[header_idx + 1:]:
|
for row in rows[header_idx + 1:]:
|
||||||
if len(row) >= col_offset + 5:
|
if len(row) >= col_offset + 5:
|
||||||
def _clean(val):
|
raw_cell = row[col_offset].strip().replace('\n', ' - ')
|
||||||
return val.replace('\ufb01', 'fi').replace('\ufb02', 'fl').strip()
|
|
||||||
|
|
||||||
raw_cell = _clean(row[col_offset]).replace('\n', ' - ')
|
|
||||||
row_full_text = ' '.join(row).upper()
|
row_full_text = ' '.join(row).upper()
|
||||||
|
|
||||||
# Detect shift delimiters
|
# Detect shift delimiters
|
||||||
@@ -172,10 +85,10 @@ class GoogleSheetsImporter:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
codigo = raw_cell
|
codigo = raw_cell
|
||||||
asignatura = _clean(row[col_offset + 1]).replace('\n', ' ')
|
asignatura = row[col_offset + 1].strip().replace('\n', ' ')
|
||||||
aula = _clean(row[col_offset + 2]).replace('\n', ' ')
|
aula = row[col_offset + 2].strip().replace('\n', ' ')
|
||||||
carrera = _clean(row[col_offset + 3]).replace('\n', ' ')
|
carrera = row[col_offset + 3].strip().replace('\n', ' ')
|
||||||
horario = _clean(row[col_offset + 4]).replace('\n', ' ')
|
horario = row[col_offset + 4].strip().replace('\n', ' ')
|
||||||
|
|
||||||
# Check valid data row
|
# Check valid data row
|
||||||
if codigo and asignatura and not 'codigo' in codigo.lower():
|
if codigo and asignatura and not 'codigo' in codigo.lower():
|
||||||
@@ -294,8 +207,7 @@ class GoogleSheetsImporter:
|
|||||||
}
|
}
|
||||||
|
|
||||||
all_raw_records = []
|
all_raw_records = []
|
||||||
sheets_to_process = self.get_sheets_config()
|
for sheet_cfg in SHEETS_CONFIG:
|
||||||
for sheet_cfg in sheets_to_process:
|
|
||||||
try:
|
try:
|
||||||
csv_data = self.fetch_sheet_csv(sheet_cfg['gid'])
|
csv_data = self.fetch_sheet_csv(sheet_cfg['gid'])
|
||||||
rows = self.parse_sheet_rows(csv_data, sheet_cfg['name'])
|
rows = self.parse_sheet_rows(csv_data, sheet_cfg['name'])
|
||||||
@@ -454,31 +366,11 @@ class GoogleSheetsImporter:
|
|||||||
# 5. Generate Calendar Reservations
|
# 5. Generate Calendar Reservations
|
||||||
t_start, t_end = self.parse_time_range(horario_raw)
|
t_start, t_end = self.parse_time_range(horario_raw)
|
||||||
if classroom and t_start and t_end and day_idx is not None:
|
if classroom and t_start and t_end and day_idx is not None:
|
||||||
is_room_virtual = bool(classroom.is_virtual or 'VIRTUAL' in (classroom.code or '').upper())
|
|
||||||
|
|
||||||
# Generar enlace de videollamada para clases virtuales si no tienen uno asignado
|
|
||||||
v_link = None
|
|
||||||
if is_room_virtual:
|
|
||||||
clean_subj = re.sub(r'[^a-zA-Z0-9]', '', subject.code or 'cls').lower()
|
|
||||||
clean_comm = re.sub(r'[^a-zA-Z0-9]', '', commission.code or 'c1').lower()
|
|
||||||
v_link = f"https://meet.google.com/edu-{clean_subj}-{clean_comm}"
|
|
||||||
if not commission.virtual_link:
|
|
||||||
commission.virtual_link = v_link
|
|
||||||
|
|
||||||
for w in range(weeks_to_generate):
|
for w in range(weeks_to_generate):
|
||||||
class_date = base_monday + timedelta(days=day_idx, weeks=w)
|
class_date = base_monday + timedelta(days=day_idx, weeks=w)
|
||||||
dt_start = datetime.combine(class_date, t_start)
|
dt_start = datetime.combine(class_date, t_start)
|
||||||
dt_end = datetime.combine(class_date, t_end)
|
dt_end = datetime.combine(class_date, t_end)
|
||||||
|
|
||||||
# En aulas virtuales la capacidad es infinita y pueden cursar múltiples comisiones en paralelo
|
|
||||||
if is_room_virtual:
|
|
||||||
existing_res = Reservation.query.filter_by(
|
|
||||||
classroom_id=classroom.id,
|
|
||||||
commission_id=commission.id,
|
|
||||||
start_time=dt_start,
|
|
||||||
end_time=dt_end
|
|
||||||
).first()
|
|
||||||
else:
|
|
||||||
existing_res = Reservation.query.filter_by(
|
existing_res = Reservation.query.filter_by(
|
||||||
classroom_id=classroom.id,
|
classroom_id=classroom.id,
|
||||||
start_time=dt_start,
|
start_time=dt_start,
|
||||||
@@ -494,9 +386,8 @@ class GoogleSheetsImporter:
|
|||||||
end_time=dt_end,
|
end_time=dt_end,
|
||||||
purpose=f"{subject.code} - {subject.name}",
|
purpose=f"{subject.code} - {subject.name}",
|
||||||
status='CONFIRMED',
|
status='CONFIRMED',
|
||||||
expected_attendees=min(classroom.capacity, 30) if not is_room_virtual else 35,
|
expected_attendees=min(classroom.capacity, 30),
|
||||||
shift=turno,
|
shift=turno,
|
||||||
virtual_link=v_link,
|
|
||||||
notes=f"Clase semanal {dia} ({turno}) - Carrera: {career.name}"
|
notes=f"Clase semanal {dia} ({turno}) - Carrera: {career.name}"
|
||||||
)
|
)
|
||||||
db.session.add(res)
|
db.session.add(res)
|
||||||
@@ -504,8 +395,6 @@ class GoogleSheetsImporter:
|
|||||||
else:
|
else:
|
||||||
existing_res.shift = turno
|
existing_res.shift = turno
|
||||||
existing_res.notes = f"Clase semanal {dia} ({turno}) - Carrera: {career.name}"
|
existing_res.notes = f"Clase semanal {dia} ({turno}) - Carrera: {career.name}"
|
||||||
if is_room_virtual and not existing_res.virtual_link:
|
|
||||||
existing_res.virtual_link = v_link
|
|
||||||
|
|
||||||
if len(stats['sample_records']) < 10:
|
if len(stats['sample_records']) < 10:
|
||||||
stats['sample_records'].append({
|
stats['sample_records'].append({
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
/* Custom Enhancements for Edu-Space Admin */
|
||||||
|
|
||||||
|
/* Card Styles */
|
||||||
|
.card {
|
||||||
|
border-radius: 14px;
|
||||||
|
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out, border-color 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation */
|
||||||
|
.navbar {
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table Hover */
|
||||||
|
.table:hover tbody tr:hover {
|
||||||
|
background-color: rgba(var(--brand-primary-rgb), 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Badge Colors */
|
||||||
|
.bg-confirmed-custom {
|
||||||
|
background-color: #10B981;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-pending-custom {
|
||||||
|
background-color: #F59E0B;
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-cancelled-custom {
|
||||||
|
background-color: #EF4444;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading Spinner */
|
||||||
|
.loading-spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 3px solid rgba(var(--brand-primary-rgb), 0.3);
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top-color: var(--brand-primary);
|
||||||
|
animation: spin 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Calendar Customizations */
|
||||||
|
.fc-event {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none !important;
|
||||||
|
padding: 4px 8px !important;
|
||||||
|
font-size: 12px !important;
|
||||||
|
margin: 1px 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard Stat Cards */
|
||||||
|
.stat-card {
|
||||||
|
background: var(--gradient-primary);
|
||||||
|
color: #FFFFFF;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 6px 20px rgba(var(--brand-primary-rgb), 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-primary { background: linear-gradient(135deg, #B43E8E 0%, #9E367D 100%); }
|
||||||
|
.stat-card-success { background: linear-gradient(135deg, #059669 0%, #10B981 100%); }
|
||||||
|
.stat-card-info { background: linear-gradient(135deg, #682CAD 0%, #8B5CF6 100%); }
|
||||||
|
.stat-card-warning { background: linear-gradient(135deg, #D97706 0%, #F59E0B 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(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty State Styles */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state i {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Notification Toast enhancements */
|
||||||
|
.toast {
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pagination Styles */
|
||||||
|
.pagination .page-link {
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 0 3px;
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination .page-link:hover {
|
||||||
|
background-color: var(--brand-primary);
|
||||||
|
border-color: var(--brand-primary);
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination .page-item.active .page-link {
|
||||||
|
background: var(--gradient-primary);
|
||||||
|
border-color: var(--brand-primary);
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard Stat Shortcuts */
|
||||||
|
.dashboard-stat-card {
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.06) !important;
|
||||||
|
}
|
||||||
|
.dashboard-stat-card:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 0.5rem 1.25rem rgba(0, 0, 0, 0.1) !important;
|
||||||
|
border-color: rgba(30, 77, 140, 0.3) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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: #FFFFFF !important;
|
||||||
|
color: #000000 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,67 +4,56 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* --- Brand Primary (Magenta / Frambuesa Oficial UniCABA) --- */
|
/* --- Brand Primary (Magenta / Uva) --- */
|
||||||
--brand-primary: #B43E8E;
|
--brand-primary: #B43E8E;
|
||||||
--brand-primary-rgb: 180, 62, 142;
|
--brand-primary-rgb: 180, 62, 142;
|
||||||
--brand-primary-shade: #9E2D77;
|
--brand-primary-shade: #9E367D;
|
||||||
--brand-primary-tint: #D63384;
|
--brand-primary-tint: #BC5199;
|
||||||
--brand-primary-contrast: #FFFFFF;
|
--brand-primary-contrast: #FFFFFF;
|
||||||
|
|
||||||
/* --- Accent Colors (Modo Claro) UniCABA: Rosas y Púrpuras Cálidos --- */
|
/* --- Accent Colors (Modo Claro) --- */
|
||||||
--accent-purple: #682CAD;
|
--accent-purple: #682CAD;
|
||||||
--accent-purple-rgb: 104, 44, 173;
|
--accent-purple-rgb: 104, 44, 173;
|
||||||
--secondary-magenta: #9E2D77;
|
--secondary-magenta: #7B2049;
|
||||||
--secondary-magenta-rgb: 158, 45, 119;
|
--secondary-magenta-rgb: 123, 32, 73;
|
||||||
--accent-pink-soft: #FDF2F8;
|
|
||||||
--accent-pink-border: #FBCFE8;
|
|
||||||
|
|
||||||
/* --- Global Surface & Text (Modo Claro - Rosas Suaves, Blanco Puro y Contraste Nitido) --- */
|
/* --- Global Surface & Text (Modo Claro) --- */
|
||||||
--app-bg: #FAF5F8;
|
--app-bg: #F8FAFC;
|
||||||
--card-bg: #FFFFFF;
|
--card-bg: #FFFFFF;
|
||||||
--card-border: rgba(180, 62, 142, 0.16);
|
--card-border: rgba(0, 0, 0, 0.08);
|
||||||
--card-hover-border: #B43E8E;
|
--card-hover-border: rgba(180, 62, 142, 0.3);
|
||||||
--card-shadow: 0 4px 18px rgba(180, 62, 142, 0.05), 0 1px 3px rgba(0, 0, 0, 0.02);
|
--card-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
|
||||||
--card-hover-shadow: 0 8px 24px rgba(180, 62, 142, 0.12);
|
--card-hover-shadow: 0 10px 25px rgba(180, 62, 142, 0.12);
|
||||||
|
|
||||||
--text-primary: #1E293B;
|
--text-primary: #1E293B;
|
||||||
--text-secondary: #475569;
|
--text-secondary: #64748B;
|
||||||
--text-muted: #64748B;
|
--text-muted: #94A3B8;
|
||||||
|
|
||||||
--navbar-bg: linear-gradient(135deg, #B43E8E 0%, #8E2B6E 100%);
|
--navbar-bg: linear-gradient(135deg, #B43E8E 0%, #9E367D 100%);
|
||||||
--navbar-text: #FFFFFF;
|
--navbar-text: #FFFFFF;
|
||||||
--navbar-border: transparent;
|
--navbar-border: transparent;
|
||||||
--navbar-shadow: 0 4px 18px rgba(180, 62, 142, 0.25);
|
--navbar-shadow: 0 4px 20px rgba(180, 62, 142, 0.25);
|
||||||
|
|
||||||
--input-bg: #FFFFFF;
|
--input-bg: #FFFFFF;
|
||||||
--input-border: #CBD5E1;
|
--input-border: #CBD5E1;
|
||||||
--input-text: #1E293B;
|
--input-text: #1E293B;
|
||||||
--input-focus-border: #B43E8E;
|
--input-focus-border: #B43E8E;
|
||||||
--input-focus-shadow: 0 0 0 0.2rem rgba(180, 62, 142, 0.22);
|
--input-focus-shadow: 0 0 0 0.2rem rgba(180, 62, 142, 0.25);
|
||||||
|
|
||||||
/* --- Distinctive Gradient UniCABA --- */
|
/* --- Distinctive Gradient --- */
|
||||||
--gradient-primary: linear-gradient(135deg, #B43E8E 0%, #9E2D77 100%);
|
--gradient-primary: linear-gradient(135deg, #B43E8E 0%, #9E367D 100%);
|
||||||
--gradient-primary-shadow: 0 6px 20px rgba(180, 62, 142, 0.28);
|
--gradient-primary-shadow: 0 8px 24px rgba(180, 62, 142, 0.25);
|
||||||
--gradient-hero: linear-gradient(135deg, rgba(180, 62, 142, 0.08) 0%, rgba(104, 44, 173, 0.06) 100%);
|
--gradient-hero: linear-gradient(135deg, rgba(180, 62, 142, 0.08) 0%, rgba(104, 44, 173, 0.12) 100%);
|
||||||
|
|
||||||
/* --- Badges (Modo Claro) --- */
|
/* --- Badges (Modo Claro) --- */
|
||||||
--badge-primary-bg: #B43E8E;
|
--badge-primary-bg: #B43E8E;
|
||||||
--badge-primary-text: #FFFFFF;
|
--badge-primary-text: #FFFFFF;
|
||||||
--badge-accent-bg: #682CAD;
|
--badge-accent-bg: #682CAD;
|
||||||
--badge-accent-text: #FFFFFF;
|
--badge-accent-text: #FFFFFF;
|
||||||
--badge-magenta-bg: #9E2D77;
|
--badge-magenta-bg: #7B2049;
|
||||||
--badge-magenta-text: #FFFFFF;
|
--badge-magenta-text: #FFFFFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-bs-theme="light"] {
|
|
||||||
--bs-body-bg: #FAF5F8;
|
|
||||||
--bs-body-color: #1E293B;
|
|
||||||
--bs-secondary-color: #475569;
|
|
||||||
--bs-tertiary-bg: #FDF2F8;
|
|
||||||
--bs-border-color: rgba(180, 62, 142, 0.18);
|
|
||||||
--bs-border-color-translucent: rgba(180, 62, 142, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =======================================================
|
/* =======================================================
|
||||||
MODO OSCURO ([data-bs-theme="dark"])
|
MODO OSCURO ([data-bs-theme="dark"])
|
||||||
======================================================= */
|
======================================================= */
|
||||||
@@ -153,20 +142,6 @@ body {
|
|||||||
transition: color 0.15s ease, transform 0.15s ease;
|
transition: color 0.15s ease, transform 0.15s ease;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 0.5rem 0.85rem !important;
|
padding: 0.5rem 0.85rem !important;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1280px) and (min-width: 992px) {
|
|
||||||
.navbar-app {
|
|
||||||
padding: 0.5rem 0.75rem !important;
|
|
||||||
}
|
|
||||||
.navbar-app .nav-link {
|
|
||||||
padding: 0.4rem 0.5rem !important;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
.navbar-app .navbar-brand span {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-app .nav-link:hover,
|
.navbar-app .nav-link:hover,
|
||||||
@@ -184,14 +159,18 @@ body {
|
|||||||
height: 32px;
|
height: 32px;
|
||||||
width: auto;
|
width: auto;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
filter: none !important;
|
transition: filter 0.25s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* El isotipo de UniCABA se adapta a ambos modos sin alteraciones de color */
|
/* En Modo Claro el navbar es magenta (#B43E8E), aplicamos color blanco al isotipo */
|
||||||
[data-bs-theme="light"] .navbar-logo,
|
[data-bs-theme="light"] .navbar-logo,
|
||||||
:root:not([data-bs-theme="dark"]) .navbar-logo,
|
:root:not([data-bs-theme="dark"]) .navbar-logo {
|
||||||
|
filter: brightness(0) invert(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* En Modo Oscuro se mantiene el isotipo con su tono natural */
|
||||||
[data-bs-theme="dark"] .navbar-logo {
|
[data-bs-theme="dark"] .navbar-logo {
|
||||||
filter: none !important;
|
filter: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* User Avatar Component */
|
/* User Avatar Component */
|
||||||
@@ -687,12 +666,3 @@ body {
|
|||||||
color: #E2E8F0;
|
color: #E2E8F0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Chart Container Utility — previene expansión infinita en Chart.js */
|
|
||||||
.chart-container {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 260px;
|
|
||||||
max-height: 320px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,242 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{% trans %}Import from Google Sheets{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid pt-5 mt-4">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-1">
|
||||||
|
<i class="bi bi-file-earmark-spreadsheet text-success me-2"></i>{% trans %}Google Sheets Academic Sync{% endtrans %}
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted small mb-0">{% trans %}Extract and synchronize classrooms, careers, subjects, and weekly schedules directly from the official Google Sheet.{% endtrans %}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="{{ url_for('main.dashboard') }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>{% trans %}Dashboard{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metrics Cards -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-sm-6 col-xl-2">
|
||||||
|
<div class="card border-0 shadow-sm text-center p-3 h-100">
|
||||||
|
<div class="text-primary mb-1"><i class="bi bi-mortarboard fs-3"></i></div>
|
||||||
|
<div class="h4 mb-0 fw-bold">{{ metrics.total_careers }}</div>
|
||||||
|
<small class="text-muted">{% trans %}Careers{% endtrans %}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-6 col-xl-2">
|
||||||
|
<div class="card border-0 shadow-sm text-center p-3 h-100">
|
||||||
|
<div class="text-info mb-1"><i class="bi bi-book fs-3"></i></div>
|
||||||
|
<div class="h4 mb-0 fw-bold">{{ metrics.total_subjects }}</div>
|
||||||
|
<small class="text-muted">{% trans %}Subjects{% endtrans %}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-6 col-xl-2">
|
||||||
|
<div class="card border-0 shadow-sm text-center p-3 h-100">
|
||||||
|
<div class="text-success mb-1"><i class="bi bi-door-open fs-3"></i></div>
|
||||||
|
<div class="h4 mb-0 fw-bold">{{ metrics.total_classrooms }}</div>
|
||||||
|
<small class="text-muted">{% trans %}Classrooms{% endtrans %}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-6 col-xl-3">
|
||||||
|
<div class="card border-0 shadow-sm text-center p-3 h-100">
|
||||||
|
<div class="text-warning mb-1"><i class="bi bi-people fs-3"></i></div>
|
||||||
|
<div class="h4 mb-0 fw-bold">{{ metrics.total_commissions }}</div>
|
||||||
|
<small class="text-muted">{% trans %}Commissions{% endtrans %}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-6 col-xl-3">
|
||||||
|
<div class="card border-0 shadow-sm text-center p-3 h-100">
|
||||||
|
<div class="text-danger mb-1"><i class="bi bi-calendar-check fs-3"></i></div>
|
||||||
|
<div class="h4 mb-0 fw-bold">{{ metrics.total_reservations }}</div>
|
||||||
|
<small class="text-muted">{% trans %}Calendar Classes{% endtrans %}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sync Action Banner -->
|
||||||
|
<div class="card border-0 shadow-sm mb-4">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-lg-8 mb-3 mb-lg-0">
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<span class="badge bg-success-subtle text-success border border-success-subtle px-3 py-1 me-2 fw-semibold">
|
||||||
|
<i class="bi bi-cloud-check me-1"></i>Google Sheets Live Integration
|
||||||
|
</span>
|
||||||
|
<span class="badge bg-light text-muted border">UniCABA 2026</span>
|
||||||
|
</div>
|
||||||
|
<h4 class="h5 mb-2 fw-bold">{% trans %}Official Schedule Board (Cartelera de Aulas){% endtrans %}</h4>
|
||||||
|
<p class="text-muted small mb-2">
|
||||||
|
{% trans %}Reads published CSV sheets for all 6 days of the week, automatically creating or updating academic careers, classrooms, subject codes, commissions, and weekly calendar entries without duplicating records.{% endtrans %}
|
||||||
|
</p>
|
||||||
|
<div class="d-flex flex-wrap gap-1">
|
||||||
|
{% for s in metrics.sheets %}
|
||||||
|
<span class="badge bg-body-tertiary text-secondary border small">
|
||||||
|
<i class="bi bi-calendar-event me-1 text-primary"></i>{{ s.name }}
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4 text-lg-end">
|
||||||
|
<form method="POST" id="syncForm" onsubmit="startSync()">
|
||||||
|
<button type="submit" id="syncBtn" class="btn btn-success btn-lg px-4 py-2 shadow fw-semibold">
|
||||||
|
<i class="bi bi-arrow-repeat me-2"></i>{% trans %}Sync Data Now{% endtrans %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div id="syncSpinner" class="d-none mt-2 text-muted small">
|
||||||
|
<div class="spinner-border spinner-border-sm text-success me-1" role="status"></div>
|
||||||
|
{% trans %}Connecting to Google Sheets and processing records...{% endtrans %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sync Results (if just completed) -->
|
||||||
|
{% if stats %}
|
||||||
|
<div class="card border-0 shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-success bg-opacity-10 text-success py-3">
|
||||||
|
<h5 class="card-title h6 mb-0 fw-bold">
|
||||||
|
<i class="bi bi-check-circle-fill me-2"></i>{% trans %}Synchronization Report{% endtrans %}
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3 text-center mb-3">
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div class="p-2 border rounded bg-body-tertiary">
|
||||||
|
<div class="fw-bold fs-5">{{ stats.total_rows_processed }}</div>
|
||||||
|
<small class="text-muted">{% trans %}Rows Processed{% endtrans %}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div class="p-2 border rounded bg-body-tertiary">
|
||||||
|
<div class="fw-bold fs-5 text-primary">+{{ stats.careers_created }}</div>
|
||||||
|
<small class="text-muted">{% trans %}Careers Created{% endtrans %}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div class="p-2 border rounded bg-body-tertiary">
|
||||||
|
<div class="fw-bold fs-5 text-success">+{{ stats.classrooms_created }}</div>
|
||||||
|
<small class="text-muted">{% trans %}Classrooms Created{% endtrans %}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div class="p-2 border rounded bg-body-tertiary">
|
||||||
|
<div class="fw-bold fs-5 text-info">+{{ stats.subjects_created }}</div>
|
||||||
|
<small class="text-muted">{% trans %}Subjects Created{% endtrans %}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div class="p-2 border rounded bg-body-tertiary">
|
||||||
|
<div class="fw-bold fs-5 text-warning">+{{ stats.commissions_created }}</div>
|
||||||
|
<small class="text-muted">{% trans %}Commissions Created{% endtrans %}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div class="p-2 border rounded bg-body-tertiary">
|
||||||
|
<div class="fw-bold fs-5 text-danger">+{{ stats.reservations_created }}</div>
|
||||||
|
<small class="text-muted">{% trans %}Reservations Created{% endtrans %}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if stats.errors %}
|
||||||
|
<div class="alert alert-warning mb-0 small">
|
||||||
|
<h6 class="alert-heading fw-bold mb-1">{% trans %}Warnings / Errors detected:{% endtrans %}</h6>
|
||||||
|
<ul class="mb-0 ps-3">
|
||||||
|
{% for err in stats.errors %}
|
||||||
|
<li>{{ err }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Database Preview Table -->
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-body-tertiary d-flex justify-content-between align-items-center py-3">
|
||||||
|
<h5 class="card-title h6 mb-0">
|
||||||
|
<i class="bi bi-table text-primary me-2"></i>{% trans %}Recently Synchronized Subjects & Comissions{% endtrans %}
|
||||||
|
</h5>
|
||||||
|
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary btn-sm">
|
||||||
|
<i class="bi bi-calendar3 me-1"></i>{% trans %}View Calendar{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="bg-body-tertiary">
|
||||||
|
<tr>
|
||||||
|
<th class="ps-4">{% trans %}Code{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Subject Name{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Career{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Commissions{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Status{% endtrans %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in recent_subjects %}
|
||||||
|
<tr>
|
||||||
|
<td class="ps-4">
|
||||||
|
<span class="badge bg-secondary-subtle text-secondary font-monospace border">
|
||||||
|
{{ s.code }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="fw-semibold">{{ s.name }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-brand small">
|
||||||
|
{{ s.career_obj.name if s.career_obj else (s.department or _('General')) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="small text-muted">
|
||||||
|
{{ s.commissions|length }} {% trans %}commission(s){% endtrans %}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if s.is_active %}
|
||||||
|
<span class="badge bg-success-subtle text-success border border-success-subtle small">
|
||||||
|
<i class="bi bi-check-circle me-1"></i>{% trans %}Active{% endtrans %}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary small">{% trans %}Inactive{% endtrans %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center py-5 text-muted">
|
||||||
|
<i class="bi bi-cloud-arrow-down fs-1 d-block mb-2 text-secondary"></i>
|
||||||
|
{% trans %}No subjects in database yet. Click "Sync Data Now" to import.{% endtrans %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
function startSync() {
|
||||||
|
const btn = document.getElementById('syncBtn');
|
||||||
|
const spinner = document.getElementById('syncSpinner');
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status"></span>Sincronizando...';
|
||||||
|
}
|
||||||
|
if (spinner) {
|
||||||
|
spinner.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}{% if role %}Editar Rol: {{ role.name }}{% else %}Nuevo Rol{% endif %} - Edu-Space Admin{% endblock %}
|
{% block title %}{% if role %}{% trans %}Editar Rol{% endtrans %}: {{ role.name }}{% else %}{% trans %}Nuevo Rol{% endtrans %}{% endif %} - Edu-Space Admin{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container-fluid pt-5 mt-4">
|
<div class="container-fluid pt-5 mt-4">
|
||||||
@@ -9,58 +9,55 @@
|
|||||||
<div>
|
<div>
|
||||||
<h1 class="h3 mb-1">
|
<h1 class="h3 mb-1">
|
||||||
<i class="bi bi-shield-lock text-primary me-2"></i>
|
<i class="bi bi-shield-lock text-primary me-2"></i>
|
||||||
{% if role %}Editar Rol y Permisos de Acceso{% else %}Crear Rol y Permisos de Acceso{% endif %}
|
{% if role %}{% trans %}Editar Rol y Permisos de Acceso{% endtrans %}{% else %}{% trans %}Crear Rol y Permisos de Acceso{% endtrans %}{% endif %}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-muted small mb-0">Configure permisos granulares por módulo con la matriz de acceso estilo FortiGate.</p>
|
<p class="text-muted small mb-0">{% trans %}Configure permisos granulares por módulo con la matriz de acceso estilo FortiGate.{% endtrans %}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<a href="/admin/roles_list" class="btn btn-outline-secondary">
|
<a href="{{ url_for('admin.roles_list') }}" class="btn btn-outline-secondary">
|
||||||
<i class="bi bi-arrow-left me-1"></i>Volver a Roles
|
<i class="bi bi-arrow-left me-1"></i>{% trans %}Volver a Roles{% endtrans %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
{% if role %}
|
|
||||||
<input type="hidden" name="id" value="{{ role.id }}">
|
|
||||||
{% endif %}
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<!-- Left Column: Role Details -->
|
<!-- Left Column: Role Details -->
|
||||||
<div class="col-lg-4 mb-4">
|
<div class="col-lg-4 mb-4">
|
||||||
<div class="card border-0 shadow-sm h-100">
|
<div class="card border-0 shadow-sm h-100">
|
||||||
<div class="card-header bg-body-tertiary py-3">
|
<div class="card-header bg-body-tertiary py-3">
|
||||||
<h5 class="card-title h6 mb-0">
|
<h5 class="card-title h6 mb-0">
|
||||||
<i class="bi bi-person-badge text-primary me-2"></i>Información del Rol
|
<i class="bi bi-person-badge text-primary me-2"></i>{% trans %}Información del Rol{% endtrans %}
|
||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="name" class="form-label fw-semibold">Nombre del Rol <span class="text-danger">*</span></label>
|
<label for="name" class="form-label fw-semibold">{% trans %}Nombre del Rol{% endtrans %} <span class="text-danger">*</span></label>
|
||||||
<input type="text" class="form-control" id="name" name="name"
|
<input type="text" class="form-control" id="name" name="name"
|
||||||
value="{{ role.name if role else '' }}" required
|
value="{{ role.name if role else '' }}" required
|
||||||
placeholder="ej. Docente, Operador, Bedelía"
|
placeholder="{% trans %}ej. Docente, Operador, Bedelía{% endtrans %}"
|
||||||
{% if role and role.is_system %}readonly title="El nombre del rol del sistema no se puede modificar"{% endif %}>
|
{% if role and role.is_system %}readonly title="{% trans %}El nombre del rol del sistema no se puede modificar{% endtrans %}"{% endif %}>
|
||||||
{% if role and role.is_system %}
|
{% if role and role.is_system %}
|
||||||
<small class="text-muted d-block mt-1">
|
<small class="text-muted d-block mt-1">
|
||||||
<i class="bi bi-info-circle me-1"></i>Rol del sistema protegido.
|
<i class="bi bi-info-circle me-1"></i>{% trans %}Rol del sistema protegido.{% endtrans %}
|
||||||
</small>
|
</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="description" class="form-label fw-semibold">Descripción</label>
|
<label for="description" class="form-label fw-semibold">{% trans %}Descripción{% endtrans %}</label>
|
||||||
<textarea class="form-control" id="description" name="description" rows="3"
|
<textarea class="form-control" id="description" name="description" rows="3"
|
||||||
placeholder="Describa las responsabilidades o alcance de este rol...">{{ role.description if role else '' }}</textarea>
|
placeholder="{% trans %}Describa las responsabilidades o alcance de este rol...{% endtrans %}">{{ role.description if role else '' }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr class="my-4">
|
<hr class="my-4">
|
||||||
|
|
||||||
<div class="d-grid gap-2">
|
<div class="d-grid gap-2">
|
||||||
<button type="submit" class="btn btn-primary py-2 fw-semibold">
|
<button type="submit" class="btn btn-primary py-2 fw-semibold">
|
||||||
<i class="bi bi-check-lg me-1"></i>Guardar Rol y Permisos
|
<i class="bi bi-check-lg me-1"></i>{% trans %}Guardar Rol y Permisos{% endtrans %}
|
||||||
</button>
|
</button>
|
||||||
<a href="/admin/roles_list" class="btn btn-outline-secondary">
|
<a href="{{ url_for('admin.roles_list') }}" class="btn btn-outline-secondary">
|
||||||
Cancelar
|
{% trans %}Cancelar{% endtrans %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,28 +70,28 @@
|
|||||||
<!-- Matrix Header -->
|
<!-- Matrix Header -->
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<span class="fw-bold text-primary">
|
<span class="fw-bold text-primary">
|
||||||
<i class="bi bi-grid-3x3-gap-fill me-2"></i>Permisos de Acceso
|
<i class="bi bi-grid-3x3-gap-fill me-2"></i>{% trans %}Permisos de Acceso{% endtrans %}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- Set All Action Dropdown (FortiGate style) -->
|
<!-- Set All Action Dropdown (FortiGate style) -->
|
||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
<button class="btn btn-fg-set-all dropdown-toggle" type="button" id="setAllDropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
<button class="btn btn-fg-set-all dropdown-toggle" type="button" id="setAllDropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
<i class="bi bi-lightning-charge me-1"></i>Establecer Todos
|
<i class="bi bi-lightning-charge me-1"></i>{% trans %}Establecer Todos{% endtrans %}
|
||||||
</button>
|
</button>
|
||||||
<ul class="dropdown-menu dropdown-menu-end shadow-sm" aria-labelledby="setAllDropdown">
|
<ul class="dropdown-menu dropdown-menu-end shadow-sm" aria-labelledby="setAllDropdown">
|
||||||
<li>
|
<li>
|
||||||
<button type="button" class="dropdown-item py-2" onclick="setAllPermissions('none')">
|
<button type="button" class="dropdown-item py-2" onclick="setAllPermissions('none')">
|
||||||
<i class="bi bi-slash-circle text-danger me-2"></i>Establecer Todos en Ninguno
|
<i class="bi bi-slash-circle text-danger me-2"></i>{% trans %}Establecer Todos en Ninguno{% endtrans %}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<button type="button" class="dropdown-item py-2" onclick="setAllPermissions('read')">
|
<button type="button" class="dropdown-item py-2" onclick="setAllPermissions('read')">
|
||||||
<i class="bi bi-eye text-info me-2"></i>Establecer Todos en Solo Lectura
|
<i class="bi bi-eye text-info me-2"></i>{% trans %}Establecer Todos en Solo Lectura{% endtrans %}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<button type="button" class="dropdown-item py-2" onclick="setAllPermissions('read_write')">
|
<button type="button" class="dropdown-item py-2" onclick="setAllPermissions('read_write')">
|
||||||
<i class="bi bi-pencil text-success me-2"></i>Establecer Todos en Lectura y Escritura
|
<i class="bi bi-pencil text-success me-2"></i>{% trans %}Establecer Todos en Lectura y Escritura{% endtrans %}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -106,13 +103,13 @@
|
|||||||
<table class="fg-table">
|
<table class="fg-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width: 48%;">Módulo del Sistema</th>
|
<th style="width: 48%;">{% trans %}Módulo del Sistema{% endtrans %}</th>
|
||||||
<th style="width: 52%;" class="text-end pe-4">Nivel de Permiso</th>
|
<th style="width: 52%;" class="text-end pe-4">{% trans %}Nivel de Permiso{% endtrans %}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for mod in modules %}
|
{% for mod in modules %}
|
||||||
{% set cur_level = (current_perms[mod.id] if current_perms else (role.permissions[mod.id] if (role and role.permissions) else 'none')) or 'none' %}
|
{% set cur_level = current_perms.get(mod.id, 'none') %}
|
||||||
<tr data-module-row="{{ mod.id }}">
|
<tr data-module-row="{{ mod.id }}">
|
||||||
<td>
|
<td>
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
@@ -136,21 +133,21 @@
|
|||||||
data-val="none"
|
data-val="none"
|
||||||
data-target="input_perm_{{ mod.id }}"
|
data-target="input_perm_{{ mod.id }}"
|
||||||
onclick="selectPerm(this, 'input_perm_{{ mod.id }}', 'none')">
|
onclick="selectPerm(this, 'input_perm_{{ mod.id }}', 'none')">
|
||||||
<i class="bi bi-slash-circle"></i> Ninguno
|
<i class="bi bi-slash-circle"></i> {% trans %}Ninguno{% endtrans %}
|
||||||
</button>
|
</button>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="fg-seg-btn {% if cur_level == 'read' %}active{% endif %}"
|
class="fg-seg-btn {% if cur_level == 'read' %}active{% endif %}"
|
||||||
data-val="read"
|
data-val="read"
|
||||||
data-target="input_perm_{{ mod.id }}"
|
data-target="input_perm_{{ mod.id }}"
|
||||||
onclick="selectPerm(this, 'input_perm_{{ mod.id }}', 'read')">
|
onclick="selectPerm(this, 'input_perm_{{ mod.id }}', 'read')">
|
||||||
<i class="bi bi-eye"></i> Lectura
|
<i class="bi bi-eye"></i> {% trans %}Lectura{% endtrans %}
|
||||||
</button>
|
</button>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="fg-seg-btn {% if cur_level == 'read_write' %}active{% endif %}"
|
class="fg-seg-btn {% if cur_level == 'read_write' %}active{% endif %}"
|
||||||
data-val="read_write"
|
data-val="read_write"
|
||||||
data-target="input_perm_{{ mod.id }}"
|
data-target="input_perm_{{ mod.id }}"
|
||||||
onclick="selectPerm(this, 'input_perm_{{ mod.id }}', 'read_write')">
|
onclick="selectPerm(this, 'input_perm_{{ mod.id }}', 'read_write')">
|
||||||
<i class="bi bi-pencil"></i> Lectura/Escritura
|
<i class="bi bi-pencil"></i> {% trans %}Lectura/Escritura{% endtrans %}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{% trans %}Roles y Grupos de Acceso{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid pt-5 mt-4">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-1">
|
||||||
|
<i class="bi bi-person-badge text-primary me-2"></i>{% trans %}Roles y Grupos de Acceso{% endtrans %}
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted small mb-0">{% trans %}Administración de perfiles de usuario y matriz granular de permisos por módulo del sistema.{% endtrans %}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="{{ url_for('admin.role_add') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i>{% trans %}Nuevo Rol{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Roles Grid / Table -->
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="bg-body-tertiary">
|
||||||
|
<tr>
|
||||||
|
<th class="ps-4">{% trans %}Nombre del Rol{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Descripción{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Integrantes{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Resumen de Permisos{% endtrans %}</th>
|
||||||
|
<th class="text-end pe-4">{% trans %}Acciones{% endtrans %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for role in roles %}
|
||||||
|
<tr>
|
||||||
|
<td class="ps-4">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="badge-brand rounded-circle p-2 me-3 d-inline-flex align-items-center justify-content-center" style="width: 38px; height: 38px;">
|
||||||
|
<i class="bi bi-shield-check fs-6 text-white"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="fw-bold text-primary">{{ role.name }}</div>
|
||||||
|
{% if role.is_system %}
|
||||||
|
<span class="badge bg-secondary-subtle text-secondary border small">{% trans %}Protegido del Sistema{% endtrans %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="text-muted small">{{ role.description or _('Sin descripción provista.') }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-primary-subtle text-primary border px-2 py-1">
|
||||||
|
<i class="bi bi-people me-1"></i>{{ role.users|length }} {% trans %}usuarios{% endtrans %}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex flex-wrap gap-1">
|
||||||
|
{% for mod in modules %}
|
||||||
|
{% set p_level = role.get_permission(mod.id) %}
|
||||||
|
{% if p_level == 'read_write' %}
|
||||||
|
<span class="badge bg-success-subtle text-success border small" title="{{ mod.name }}: {% trans %}Lectura y Escritura{% endtrans %}">
|
||||||
|
<i class="bi {{ mod.icon }} me-1"></i>{{ mod.name }}: RW
|
||||||
|
</span>
|
||||||
|
{% elif p_level == 'read' %}
|
||||||
|
<span class="badge bg-info-subtle text-info border small" title="{{ mod.name }}: {% trans %}Solo Lectura{% endtrans %}">
|
||||||
|
<i class="bi {{ mod.icon }} me-1"></i>{{ mod.name }}: R
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-light text-muted border small opacity-75" title="{{ mod.name }}: {% trans %}Sin Acceso{% endtrans %}">
|
||||||
|
<i class="bi {{ mod.icon }} me-1"></i>{{ mod.name }}: -
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-end pe-4">
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<a href="{{ url_for('admin.role_edit', id=role.id) }}" class="btn btn-outline-primary" title="{% trans %}Configurar Permisos{% endtrans %}">
|
||||||
|
<i class="bi bi-pencil-square me-1"></i>{% trans %}Permisos{% endtrans %}
|
||||||
|
</a>
|
||||||
|
{% if not role.is_system %}
|
||||||
|
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#deleteModal{{ role.id }}" title="{% trans %}Eliminar Rol{% endtrans %}" {% if role.users|length > 0 %}disabled{% endif %}>
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not role.is_system %}
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
<div class="modal fade" id="deleteModal{{ role.id }}" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content border-0 shadow">
|
||||||
|
<div class="modal-header border-0">
|
||||||
|
<h5 class="modal-title text-danger">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>{% trans %}Confirmar Eliminación de Rol{% endtrans %}
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body text-start">
|
||||||
|
<p>{% trans %}¿Está seguro de que desea eliminar el rol{% endtrans %} <strong>{{ role.name }}</strong>?</p>
|
||||||
|
<p class="text-muted small mb-0">{% trans %}Esta acción no se puede deshacer.{% endtrans %}</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer border-0">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
|
||||||
|
<form action="{{ url_for('admin.role_delete', id=role.id) }}" method="POST" class="d-inline">
|
||||||
|
<button type="submit" class="btn btn-danger">{% trans %}Eliminar Rol{% endtrans %}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{% if user %}{% trans %}Edit User{% endtrans %}: {{ user.name }}{% else %}{% trans %}New User{% endtrans %}{% endif %} - Edu-Space Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid pt-5 mt-4">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-1">
|
||||||
|
<i class="bi bi-person-gear text-primary me-2"></i>
|
||||||
|
{% if user %}{% trans %}Edit User Account{% endtrans %}{% else %}{% trans %}Create User Account{% endtrans %}{% endif %}
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted small mb-0">{% trans %}Configure user credentials, profile information, and role assignment.{% endtrans %}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="{{ url_for('admin.users_list') }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>{% trans %}Back to Users{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-6 mx-auto">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-body-tertiary py-3">
|
||||||
|
<h5 class="card-title h6 mb-0">
|
||||||
|
<i class="bi bi-person-vcard text-primary me-2"></i>{% trans %}Account Details{% endtrans %}
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<form method="POST">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="name" class="form-label fw-semibold">{% trans %}Full Name{% endtrans %} <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" id="name" name="name"
|
||||||
|
value="{{ user.name if user else '' }}" required
|
||||||
|
placeholder="{% trans %}e.g. María González{% endtrans %}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="email" class="form-label fw-semibold">{% trans %}Email Address{% endtrans %} <span class="text-danger">*</span></label>
|
||||||
|
<input type="email" class="form-control" id="email" name="email"
|
||||||
|
value="{{ user.email if user else '' }}" required
|
||||||
|
placeholder="{% trans %}e.g. mgonzalez@edu-space.com{% endtrans %}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label fw-semibold">
|
||||||
|
{% trans %}Password{% endtrans %} {% if not user %}<span class="text-danger">*</span>{% endif %}
|
||||||
|
</label>
|
||||||
|
<input type="password" class="form-control" id="password" name="password"
|
||||||
|
{% if not user %}required{% endif %}
|
||||||
|
placeholder="{% if user %}{% trans %}Leave empty to keep current password{% endtrans %}{% else %}{% trans %}At least 6 characters{% endtrans %}{% endif %}">
|
||||||
|
{% if user %}
|
||||||
|
<small class="text-muted d-block mt-1">
|
||||||
|
{% trans %}Only fill this in if you want to reset this user's password.{% endtrans %}
|
||||||
|
</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="role_id" class="form-label fw-semibold">{% trans %}Role / Access Group{% endtrans %} <span class="text-danger">*</span></label>
|
||||||
|
<select class="form-select" id="role_id" name="role_id" required>
|
||||||
|
<option value="" disabled {% if not user or not user.role_id %}selected{% endif %}>{% trans %}Select a role...{% endtrans %}</option>
|
||||||
|
{% for r in roles %}
|
||||||
|
<option value="{{ r.id }}" {% if user and user.role_id == r.id %}selected{% endif %}>
|
||||||
|
{{ r.name }} - {{ r.description or '' }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small class="text-muted d-block mt-1">
|
||||||
|
<a href="{{ url_for('admin.roles_list') }}" target="_blank" class="text-decoration-none">
|
||||||
|
<i class="bi bi-box-arrow-up-right me-1"></i>{% trans %}View or manage roles and permissions{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4 form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" role="switch" id="is_active" name="is_active"
|
||||||
|
{% if not user or user.is_active %}checked{% endif %}
|
||||||
|
{% if user and user.id == current_user.id %}disabled{% endif %}>
|
||||||
|
<label class="form-check-label fw-semibold" for="is_active">
|
||||||
|
{% trans %}Active Account{% endtrans %}
|
||||||
|
</label>
|
||||||
|
<small class="text-muted d-block">
|
||||||
|
{% trans %}Inactive users are prevented from signing in to the platform.{% endtrans %}
|
||||||
|
</small>
|
||||||
|
{% if user and user.id == current_user.id %}
|
||||||
|
<input type="hidden" name="is_active" value="on">
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center pt-2">
|
||||||
|
<a href="{{ url_for('admin.users_list') }}" class="btn btn-outline-secondary">
|
||||||
|
{% trans %}Cancel{% endtrans %}
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="btn btn-primary px-4 fw-semibold">
|
||||||
|
<i class="bi bi-check-lg me-1"></i>{% trans %}Save User{% endtrans %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{% trans %}User Management{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid pt-5 mt-4">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-1">
|
||||||
|
<i class="bi bi-people text-primary me-2"></i>{% trans %}User Management{% endtrans %}
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted small mb-0">{% trans %}Manage user accounts, assign roles, and control active status.{% endtrans %}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="{{ url_for('admin.user_add') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-person-plus me-1"></i>{% trans %}New User{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters & Search -->
|
||||||
|
<div class="card border-0 shadow-sm mb-4">
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<form method="GET" class="row g-2 align-items-center">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<span class="input-group-text bg-body-tertiary border-end-0">
|
||||||
|
<i class="bi bi-search text-muted"></i>
|
||||||
|
</span>
|
||||||
|
<input type="text" name="search" class="form-control border-start-0"
|
||||||
|
placeholder="{% trans %}Search by name or email...{% endtrans %}"
|
||||||
|
value="{{ search }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<select name="role" class="form-select form-select-sm">
|
||||||
|
<option value="">{% trans %}All Roles{% endtrans %}</option>
|
||||||
|
{% for r in roles %}
|
||||||
|
<option value="{{ r.id }}" {% if role_filter == (r.id|string) %}selected{% endif %}>{{ r.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<select name="status" class="form-select form-select-sm">
|
||||||
|
<option value="">{% trans %}All Statuses{% endtrans %}</option>
|
||||||
|
<option value="active" {% if status_filter == 'active' %}selected{% endif %}>{% trans %}Active{% endtrans %}</option>
|
||||||
|
<option value="inactive" {% if status_filter == 'inactive' %}selected{% endif %}>{% trans %}Inactive{% endtrans %}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm w-100">
|
||||||
|
<i class="bi bi-filter me-1"></i>{% trans %}Filter{% endtrans %}
|
||||||
|
</button>
|
||||||
|
{% if search or role_filter or status_filter %}
|
||||||
|
<a href="{{ url_for('admin.users_list') }}" class="btn btn-outline-secondary btn-sm" title="{% trans %}Clear filters{% endtrans %}">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Users Table -->
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="bg-body-tertiary">
|
||||||
|
<tr>
|
||||||
|
<th class="ps-4">{% trans %}User{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Email{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Role / Group{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Status{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Last Login{% endtrans %}</th>
|
||||||
|
<th class="text-end pe-4">{% trans %}Actions{% endtrans %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for u in users %}
|
||||||
|
<tr>
|
||||||
|
<td class="ps-4">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="user-avatar-small me-2">
|
||||||
|
{{ (u.first_name[0] if u.first_name else 'U')|upper }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="fw-bold">{{ u.name }}</div>
|
||||||
|
<small class="text-muted">ID: #{{ u.id }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="font-monospace small">{{ u.email }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if u.role_obj %}
|
||||||
|
<span class="badge badge-brand">
|
||||||
|
<i class="bi bi-shield-check me-1"></i>{{ u.role_obj.name }}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">
|
||||||
|
{{ u.role or _('No Role') }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if u.is_active %}
|
||||||
|
<span class="badge bg-success-subtle text-success border border-success-subtle px-2 py-1">
|
||||||
|
<i class="bi bi-check-circle me-1"></i>{% trans %}Active{% endtrans %}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger-subtle text-danger border border-danger-subtle px-2 py-1">
|
||||||
|
<i class="bi bi-dash-circle me-1"></i>{% trans %}Inactive{% endtrans %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<small class="text-muted">
|
||||||
|
{% if u.last_login %}
|
||||||
|
{{ format_datetime(u.last_login, format='short') }}
|
||||||
|
{% else %}
|
||||||
|
{% trans %}Never{% endtrans %}
|
||||||
|
{% endif %}
|
||||||
|
</small>
|
||||||
|
</td>
|
||||||
|
<td class="text-end pe-4">
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<a href="{{ url_for('admin.user_edit', id=u.id) }}" class="btn btn-outline-primary" title="{% trans %}Edit User{% endtrans %}">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</a>
|
||||||
|
{% if u.id != current_user.id %}
|
||||||
|
<form action="{{ url_for('admin.user_toggle', id=u.id) }}" method="POST" class="d-inline">
|
||||||
|
<button type="submit" class="btn {% if u.is_active %}btn-outline-warning{% else %}btn-outline-success{% endif %}"
|
||||||
|
title="{% if u.is_active %}{% trans %}Deactivate User{% endtrans %}{% else %}{% trans %}Activate User{% endtrans %}{% endif %}">
|
||||||
|
<i class="bi {% if u.is_active %}bi-person-x{% else %}bi-person-check{% endif %}"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-center py-5 text-muted">
|
||||||
|
<i class="bi bi-people fs-1 d-block mb-2 text-secondary"></i>
|
||||||
|
{% trans %}No users found matching the criteria.{% endtrans %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if pagination.pages > 1 %}
|
||||||
|
<div class="card-footer bg-body-tertiary d-flex justify-content-between align-items-center py-2 px-4">
|
||||||
|
<small class="text-muted">
|
||||||
|
{% trans %}Showing page{% endtrans %} {{ pagination.page }} {% trans %}of{% endtrans %} {{ pagination.pages }} ({{ pagination.total }} {% trans %}total{% endtrans %})
|
||||||
|
</small>
|
||||||
|
<ul class="pagination pagination-sm mb-0">
|
||||||
|
{% if pagination.has_prev %}
|
||||||
|
<li class="page-item"><a class="page-link" href="{{ url_for('admin.users_list', page=pagination.prev_num, search=search, role=role_filter, status=status_filter) }}">«</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% for p in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
|
||||||
|
{% if p %}
|
||||||
|
<li class="page-item {% if p == pagination.page %}active{% endif %}">
|
||||||
|
<a class="page-link" href="{{ url_for('admin.users_list', page=p, search=search, role=role_filter, status=status_filter) }}">{{ p }}</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled"><span class="page-link">…</span></li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if pagination.has_next %}
|
||||||
|
<li class="page-item"><a class="page-link" href="{{ url_for('admin.users_list', page=pagination.next_num, search=search, role=role_filter, status=status_filter) }}">»</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
No tienes los permisos necesarios para acceder a este recurso. Esta sección está restringida exclusivamente a administradores.
|
No tienes los permisos necesarios para acceder a este recurso. Esta sección está restringida exclusivamente a administradores.
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
<a href="/main/dashboard" class="btn btn-primary">
|
<a href="{{ url_for('main.dashboard') }}" class="btn btn-primary">
|
||||||
<i class="bi bi-house me-1"></i>Ir al Dashboard
|
<i class="bi bi-house me-1"></i>Ir al Dashboard
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Cambiar Contraseña - Edu-Space Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container pt-5 mt-4">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-6 col-md-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||||
|
<h1 class="h3 mb-0">
|
||||||
|
<i class="bi bi-shield-lock text-primary me-2"></i>Cambiar Contraseña
|
||||||
|
</h1>
|
||||||
|
<a href="{{ url_for('auth.profile') }}" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>Volver al Perfil
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form Card -->
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<form method="POST" action="{{ url_for('auth.change_password') }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="current_password" class="form-label">Contraseña Actual</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text"><i class="bi bi-lock"></i></span>
|
||||||
|
<input type="password" class="form-control" id="current_password" name="current_password" required placeholder="Ingresa tu contraseña actual">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="new_password" class="form-label">Nueva Contraseña</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text"><i class="bi bi-key"></i></span>
|
||||||
|
<input type="password" class="form-control" id="new_password" name="new_password" required minlength="6" placeholder="Mínimo 6 caracteres">
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Debe tener al menos 6 caracteres.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="confirm_password" class="form-label">Confirmar Nueva Contraseña</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text"><i class="bi bi-check2-circle"></i></span>
|
||||||
|
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required minlength="6" placeholder="Repite la nueva contraseña">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-save me-1"></i>Actualizar Contraseña
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('auth.profile') }}" class="btn btn-link text-muted">
|
||||||
|
Cancelar
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{{ current_lang }}" data-bs-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% trans %}Login - Edu-Space Admin{% endtrans %}</title>
|
||||||
|
|
||||||
|
<!-- Favicon -->
|
||||||
|
<link rel="icon" type="image/png" href="{{ url_for('static', filename='img/favicon.png') }}">
|
||||||
|
|
||||||
|
<!-- Script Anti-Flicker para Modo Claro / Oscuro -->
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const storedTheme = localStorage.getItem('theme');
|
||||||
|
const theme = storedTheme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||||
|
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Bootstrap 5 CSS -->
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||||
|
<!-- Design System Theme -->
|
||||||
|
<link href="{{ url_for('static', filename='css/theme.css') }}" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="auth-wrapper">
|
||||||
|
<div class="auth-card">
|
||||||
|
<!-- Header con Controles Superiores & Logo Oficial -->
|
||||||
|
<div class="auth-header">
|
||||||
|
<div class="auth-controls">
|
||||||
|
<button class="theme-toggle-btn" type="button" aria-label="Cambiar tema" title="Modo Claro/Oscuro">
|
||||||
|
<i class="bi bi-moon-stars-fill"></i>
|
||||||
|
</button>
|
||||||
|
{% include 'partials/language_switcher.html' %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<img src="{{ url_for('static', filename='img/logo_unicaba.png') }}" alt="UniCABA" class="auth-logo">
|
||||||
|
<h4 class="fw-bold mb-1">Edu-Space Admin</h4>
|
||||||
|
<p class="text-muted small mb-0">{% trans %}Classroom Management System{% endtrans %}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-4 pb-4 pt-2">
|
||||||
|
<!-- Flash Messages -->
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show rounded-3 py-2 small" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close py-2" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<form method="POST" novalidate>
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="email" class="form-label small fw-semibold text-muted">{{ form.email.label.text }}</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text bg-body-tertiary border-end-0 text-muted">
|
||||||
|
<i class="bi bi-envelope"></i>
|
||||||
|
</span>
|
||||||
|
<input type="email" class="form-control border-start-0 ps-0" id="email" name="email"
|
||||||
|
value="{{ form.email.data or '' }}" required
|
||||||
|
placeholder="{% trans %}Enter your email{% endtrans %}">
|
||||||
|
</div>
|
||||||
|
{% for error in form.email.errors %}
|
||||||
|
<div class="text-danger small mt-1">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label small fw-semibold text-muted">{{ form.password.label.text }}</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text bg-body-tertiary border-end-0 text-muted">
|
||||||
|
<i class="bi bi-lock"></i>
|
||||||
|
</span>
|
||||||
|
<input type="password" class="form-control border-start-0 ps-0" id="password" name="password"
|
||||||
|
required placeholder="{% trans %}Enter your password{% endtrans %}">
|
||||||
|
</div>
|
||||||
|
{% for error in form.password.errors %}
|
||||||
|
<div class="text-danger small mt-1">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4 form-check">
|
||||||
|
{{ form.remember_me(class="form-check-input") }}
|
||||||
|
<label class="form-check-label text-muted small" for="remember_me">
|
||||||
|
{{ form.remember_me.label.text }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg fw-semibold py-2">
|
||||||
|
<i class="bi bi-box-arrow-in-right me-1"></i> {{ form.submit.label.text }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<small class="text-muted d-inline-flex align-items-center">
|
||||||
|
<i class="bi bi-shield-check text-primary me-1"></i>
|
||||||
|
{% trans %}Secure Login • All access is logged{% endtrans %}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/theme-toggle.js') }}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}User Profile - Edu-Space Admin{% endblock %}
|
{% block title %}{% trans %}User Profile{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container pt-5 mt-4">
|
<div class="container pt-5 mt-4">
|
||||||
@@ -9,10 +9,10 @@
|
|||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||||
<h1 class="h3 mb-0">
|
<h1 class="h3 mb-0">
|
||||||
<i class="bi bi-person-badge text-primary me-2"></i>My Profile
|
<i class="bi bi-person-badge text-primary me-2"></i>{% trans %}My Profile{% endtrans %}
|
||||||
</h1>
|
</h1>
|
||||||
<a href="/main/dashboard" class="btn btn-outline-secondary btn-sm">
|
<a href="{{ url_for('main.dashboard') }}" class="btn btn-outline-secondary btn-sm">
|
||||||
<i class="bi bi-arrow-left me-1"></i>Dashboard
|
<i class="bi bi-arrow-left me-1"></i>{% trans %}Dashboard{% endtrans %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -24,13 +24,13 @@
|
|||||||
<i class="bi bi-person"></i>
|
<i class="bi bi-person"></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 class="mb-1">{{ user.name }}</h4>
|
<h4 class="mb-1">{{ current_user.name }}</h4>
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<span class="badge bg-primary">{{ user.role }}</span>
|
<span class="badge bg-primary">{{ current_user.role }}</span>
|
||||||
{% if user.is_active %}
|
{% if current_user.is_active %}
|
||||||
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Active</span>
|
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>{% trans %}Active{% endtrans %}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-danger"><i class="bi bi-x-circle me-1"></i>Inactive</span>
|
<span class="badge bg-danger"><i class="bi bi-x-circle me-1"></i>{% trans %}Inactive{% endtrans %}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,32 +41,32 @@
|
|||||||
<!-- User Details Grid -->
|
<!-- User Details Grid -->
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-sm-6">
|
<div class="col-sm-6">
|
||||||
<label class="form-label text-muted small mb-1">Email</label>
|
<label class="form-label text-muted small mb-1">{% trans %}Email{% endtrans %}</label>
|
||||||
<div class="fw-semibold">{{ user.email }}</div>
|
<div class="fw-semibold">{{ current_user.email }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-6">
|
<div class="col-sm-6">
|
||||||
<label class="form-label text-muted small mb-1">Full Name</label>
|
<label class="form-label text-muted small mb-1">{% trans %}Full Name{% endtrans %}</label>
|
||||||
<div class="fw-semibold">{{ user.name }}</div>
|
<div class="fw-semibold">{{ current_user.name }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-6">
|
<div class="col-sm-6">
|
||||||
<label class="form-label text-muted small mb-1">Access Role</label>
|
<label class="form-label text-muted small mb-1">{% trans %}Access Role{% endtrans %}</label>
|
||||||
<div class="fw-semibold">{{ user.role }}</div>
|
<div class="fw-semibold">{{ current_user.role }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-6">
|
<div class="col-sm-6">
|
||||||
<label class="form-label text-muted small mb-1">Last Login</label>
|
<label class="form-label text-muted small mb-1">{% trans %}Last Login{% endtrans %}</label>
|
||||||
<div class="fw-semibold">
|
<div class="fw-semibold">
|
||||||
{% if user.last_login %}
|
{% if current_user.last_login %}
|
||||||
{{ user.last_login }}
|
{{ current_user.last_login.strftime('%Y-%m-%d %H:%M:%S') }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">First login</span>
|
<span class="text-muted">{% trans %}First login{% endtrans %}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-6">
|
<div class="col-sm-6">
|
||||||
<label class="form-label text-muted small mb-1">Registration Date</label>
|
<label class="form-label text-muted small mb-1">{% trans %}Registration Date{% endtrans %}</label>
|
||||||
<div class="fw-semibold">
|
<div class="fw-semibold">
|
||||||
{% if user.created_at %}
|
{% if current_user.created_at %}
|
||||||
{{ user.created_at }}
|
{{ current_user.created_at.strftime('%Y-%m-%d') }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">-</span>
|
<span class="text-muted">-</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -77,76 +77,76 @@
|
|||||||
<hr class="my-4">
|
<hr class="my-4">
|
||||||
|
|
||||||
<!-- Preferences Section -->
|
<!-- Preferences Section -->
|
||||||
<form method="POST" action="/auth/profile">
|
<form method="POST" action="{{ url_for('auth.profile') }}">
|
||||||
<h5 class="fw-bold mb-3">
|
<h5 class="fw-bold mb-3">
|
||||||
<i class="bi bi-sliders text-primary me-2"></i>User Preferences
|
<i class="bi bi-sliders text-primary me-2"></i>{% trans %}User Preferences{% endtrans %}
|
||||||
</h5>
|
</h5>
|
||||||
|
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<!-- Preferred Language -->
|
<!-- Preferred Language -->
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label for="preferred_language" class="form-label fw-semibold">
|
<label for="preferred_language" class="form-label fw-semibold">
|
||||||
<i class="bi bi-translate text-primary me-1"></i>Interface Language
|
<i class="bi bi-translate text-primary me-1"></i>{% trans %}Interface Language{% endtrans %}
|
||||||
</label>
|
</label>
|
||||||
<select class="form-select" id="preferred_language" name="preferred_language">
|
<select class="form-select" id="preferred_language" name="preferred_language">
|
||||||
<option value="" {% if not user.preferred_language %}selected{% endif %}>
|
<option value="" {% if not current_user.preferred_language %}selected{% endif %}>
|
||||||
Auto-detect from browser (Default)
|
{% trans %}Auto-detect from browser (Default){% endtrans %}
|
||||||
</option>
|
</option>
|
||||||
<option value="es" {% if user.preferred_language == 'es' %}selected{% endif %}>
|
<option value="es" {% if current_user.preferred_language == 'es' %}selected{% endif %}>
|
||||||
Español
|
Español
|
||||||
</option>
|
</option>
|
||||||
<option value="en" {% if user.preferred_language == 'en' %}selected{% endif %}>
|
<option value="en" {% if current_user.preferred_language == 'en' %}selected{% endif %}>
|
||||||
English
|
English
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<small class="text-muted d-block mt-1">
|
<small class="text-muted d-block mt-1">
|
||||||
If set, this language overrides the browser configuration for all your sessions.
|
{% trans %}If set, this language overrides the browser configuration for all your sessions.{% endtrans %}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Theme Preference -->
|
<!-- Theme Preference -->
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label for="theme_preference" class="form-label fw-semibold">
|
<label for="theme_preference" class="form-label fw-semibold">
|
||||||
<i class="bi bi-palette text-primary me-1"></i>Visual Theme
|
<i class="bi bi-palette text-primary me-1"></i>{% trans %}Visual Theme{% endtrans %}
|
||||||
</label>
|
</label>
|
||||||
<select class="form-select" id="theme_preference" name="theme_preference">
|
<select class="form-select" id="theme_preference" name="theme_preference">
|
||||||
<option value="auto" {% if user.theme_preference == 'auto' or not user.theme_preference %}selected{% endif %}>
|
<option value="auto" {% if current_user.theme_preference == 'auto' or not current_user.theme_preference %}selected{% endif %}>
|
||||||
Automatic (follow system)
|
{% trans %}Automatic (follow system){% endtrans %}
|
||||||
</option>
|
</option>
|
||||||
<option value="light" {% if user.theme_preference == 'light' %}selected{% endif %}>
|
<option value="light" {% if current_user.theme_preference == 'light' %}selected{% endif %}>
|
||||||
Light Mode
|
{% trans %}Light Mode{% endtrans %}
|
||||||
</option>
|
</option>
|
||||||
<option value="dark" {% if user.theme_preference == 'dark' %}selected{% endif %}>
|
<option value="dark" {% if current_user.theme_preference == 'dark' %}selected{% endif %}>
|
||||||
Dark Mode
|
{% trans %}Dark Mode{% endtrans %}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<small class="text-muted d-block mt-1">
|
<small class="text-muted d-block mt-1">
|
||||||
Choose your preferred visual theme.
|
{% trans %}Choose your preferred visual theme.{% endtrans %}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-end mt-2">
|
<div class="text-end mt-2">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
<i class="bi bi-check-lg me-1"></i>Save Preferences
|
<i class="bi bi-check-lg me-1"></i>{% trans %}Save Preferences{% endtrans %}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer bg-body-tertiary px-4 py-3 d-flex justify-content-between align-items-center">
|
<div class="card-footer bg-body-tertiary px-4 py-3 d-flex justify-content-between align-items-center">
|
||||||
<span class="text-muted small">Account Security</span>
|
<span class="text-muted small">{% trans %}Account Security{% endtrans %}</span>
|
||||||
<a href="/auth/change_password" class="btn btn-primary btn-sm">
|
<a href="{{ url_for('auth.change_password') }}" class="btn btn-primary btn-sm">
|
||||||
<i class="bi bi-key me-1"></i>Change Password
|
<i class="bi bi-key me-1"></i>{% trans %}Change Password{% endtrans %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- User Reservations Overview (if any) -->
|
<!-- User Reservations Overview (if any) -->
|
||||||
{% if user.reservations %}
|
{% if current_user.reservations %}
|
||||||
<div class="card border-0 shadow-sm mb-4">
|
<div class="card border-0 shadow-sm mb-4">
|
||||||
<div class="card-header bg-body-tertiary">
|
<div class="card-header bg-body-tertiary">
|
||||||
<h5 class="card-title mb-0 h6">
|
<h5 class="card-title mb-0 h6">
|
||||||
<i class="bi bi-calendar-event me-2"></i>My Recent Reservations
|
<i class="bi bi-calendar-event me-2"></i>{% trans %}My Recent Reservations{% endtrans %}
|
||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
@@ -154,22 +154,22 @@
|
|||||||
<table class="table table-hover align-middle mb-0">
|
<table class="table table-hover align-middle mb-0">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Classroom</th>
|
<th>{% trans %}Classroom{% endtrans %}</th>
|
||||||
<th>Purpose</th>
|
<th>{% trans %}Purpose{% endtrans %}</th>
|
||||||
<th>Date & Time</th>
|
<th>{% trans %}Date & Time{% endtrans %}</th>
|
||||||
<th>Status</th>
|
<th>{% trans %}Status{% endtrans %}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for res in (user.reservations or []).slice(0, 5) %}
|
{% for res in current_user.reservations[:5] %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge bg-secondary">{{ res.classroom.code_display if res.classroom else 'N/A' }}</span>
|
<span class="badge bg-secondary">{{ res.classroom.code_display if res.classroom else 'N/A' }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ res.purpose }}</td>
|
<td>{{ res.purpose }}</td>
|
||||||
<td>{{ res.start_time }}</td>
|
<td>{{ res.start_time.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge bg-{{ 'success' if res.status == 'CONFIRMED' else ('warning' if res.status == 'PENDING' else 'secondary') }}">
|
<span class="badge bg-{{ 'success' if res.status == 'CONFIRMED' else 'warning' if res.status == 'PENDING' else 'secondary' }}">
|
||||||
{{ res.status }}
|
{{ res.status }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{{ current_lang }}" data-bs-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}{% trans %}Edu-Space Admin{% endtrans %}{% endblock %}</title>
|
||||||
|
|
||||||
|
<!-- Favicon -->
|
||||||
|
<link rel="icon" type="image/png" href="{{ url_for('static', filename='img/favicon.png') }}">
|
||||||
|
|
||||||
|
<!-- Script Anti-Flicker para Modo Claro / Oscuro -->
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
let userTheme = "{{ current_user.theme_preference if (current_user and current_user.is_authenticated) else '' }}";
|
||||||
|
let theme = userTheme;
|
||||||
|
if (!theme || theme === 'auto') {
|
||||||
|
const storedTheme = localStorage.getItem('theme');
|
||||||
|
theme = storedTheme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||||
|
}
|
||||||
|
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Bootstrap 5 CSS -->
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<!-- Bootstrap Icons -->
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||||
|
<!-- Design System & Custom Theme -->
|
||||||
|
<link href="{{ url_for('static', filename='css/theme.css') }}" rel="stylesheet">
|
||||||
|
<link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
|
||||||
|
|
||||||
|
{% block extra_css %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body data-user-authenticated="{{ 'true' if (current_user and current_user.is_authenticated) else 'false' }}">
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark navbar-app fixed-top">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand d-flex align-items-center" href="{{ url_for('main.dashboard') }}">
|
||||||
|
<img src="{{ url_for('static', filename='img/logo_unicaba_mini.png') }}" alt="Logo UniCABA" class="navbar-logo me-2">
|
||||||
|
<span>Edu-Space Admin</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav me-auto">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('main.dashboard') }}">
|
||||||
|
<i class="bi bi-speedometer2"></i> {% trans %}Dashboard{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
|
<i class="bi bi-door-open"></i> {% trans %}Classrooms{% endtrans %}
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms') }}">
|
||||||
|
<i class="bi bi-list"></i> {% trans %}All Classrooms{% endtrans %}
|
||||||
|
</a></li>
|
||||||
|
{% if current_user.has_permission('classrooms', 'read_write') %}
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('classrooms.add_classroom') }}">
|
||||||
|
<i class="bi bi-plus-circle"></i> {% trans %}Add Classroom{% endtrans %}
|
||||||
|
</a></li>
|
||||||
|
{% endif %}
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}">
|
||||||
|
<i class="bi bi-buildings"></i> {% trans %}Buildings{% endtrans %}
|
||||||
|
</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
|
<i class="bi bi-calendar3"></i> {% trans %}Schedule{% endtrans %}
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('schedule.calendar_view') }}">
|
||||||
|
<i class="bi bi-calendar-week"></i> {% trans %}Calendar View{% endtrans %}
|
||||||
|
</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}">
|
||||||
|
<i class="bi bi-list-check"></i> {% trans %}All Reservations{% endtrans %}
|
||||||
|
</a></li>
|
||||||
|
{% if current_user.has_permission('reservations', 'read_write') %}
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('schedule.add_reservation') }}">
|
||||||
|
<i class="bi bi-plus-circle"></i> {% trans %}New Reservation{% endtrans %}
|
||||||
|
</a></li>
|
||||||
|
{% endif %}
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('schedule.today_schedule') }}">
|
||||||
|
<i class="bi bi-calendar-today"></i> {% trans %}Today's Schedule{% endtrans %}
|
||||||
|
</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% if current_user.is_authenticated and current_user.can_manage() %}
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
|
<i class="bi bi-shield-lock"></i> {% trans %}Management{% endtrans %}
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu shadow-sm">
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('admin.users_list') }}"><i class="bi bi-people me-2"></i>{% trans %}Users{% endtrans %}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('admin.roles_list') }}"><i class="bi bi-person-badge me-2"></i>{% trans %}Roles & Groups{% endtrans %}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}"><i class="bi bi-buildings me-2"></i>{% trans %}Buildings{% endtrans %}</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('admin.google_sheets_import') }}"><i class="bi bi-file-earmark-spreadsheet me-2 text-success"></i>{% trans %}Import from Google Sheets{% endtrans %}</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<ul class="navbar-nav align-items-center">
|
||||||
|
<!-- Theme Mode Toggle Button -->
|
||||||
|
<li class="nav-item me-2">
|
||||||
|
<button class="theme-toggle-btn" type="button" aria-label="Cambiar tema" title="Modo Claro/Oscuro">
|
||||||
|
<i class="bi bi-moon-stars-fill"></i>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- Language Switcher -->
|
||||||
|
<li class="nav-item me-2">
|
||||||
|
{% include 'partials/language_switcher.html' %}
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- User Dropdown with Avatar -->
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle d-flex align-items-center py-1" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
|
<span class="user-avatar-small">{{ current_user.name[:1]|upper if current_user.name else 'U' }}</span>
|
||||||
|
<span>{{ current_user.first_name }}</span>
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end shadow-sm border-0">
|
||||||
|
<li>
|
||||||
|
<div class="px-3 py-2 border-bottom">
|
||||||
|
<small class="text-muted d-block">{% trans %}Signed in as{% endtrans %}</small>
|
||||||
|
<span class="fw-bold small">{{ current_user.email }}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}">
|
||||||
|
<i class="bi bi-person me-2"></i> {% trans %}Profile{% endtrans %}
|
||||||
|
</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('auth.change_password') }}">
|
||||||
|
<i class="bi bi-key me-2"></i> {% trans %}Change Password{% endtrans %}
|
||||||
|
</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item text-danger" href="{{ url_for('auth.logout') }}">
|
||||||
|
<i class="bi bi-box-arrow-right me-2"></i> {% trans %}Logout{% endtrans %}
|
||||||
|
</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Flash Messages -->
|
||||||
|
<div class="toast-container position-fixed bottom-0 end-0 p-3" style="z-index: 1080;">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="toast align-items-center text-white bg-{{ 'danger' if category == 'error' else 'success' if category == 'success' else 'info' if category == 'info' else 'warning' }} border-0 shadow" role="alert">
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="toast-body">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
|
||||||
|
<!-- Bootstrap JS -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<!-- jQuery -->
|
||||||
|
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||||
|
<!-- Theme Toggle JS -->
|
||||||
|
<script src="{{ url_for('static', filename='js/theme-toggle.js') }}"></script>
|
||||||
|
<!-- Custom JS -->
|
||||||
|
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||||
|
|
||||||
|
<!-- Show flash messages -->
|
||||||
|
<script>
|
||||||
|
$(document).ready(function() {
|
||||||
|
$('.toast').toast('show');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% block extra_js %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
{% if building %}Editar Edificio - {{ building.name }}{% else %}Nuevo Edificio{% endif %} - Edu-Space Admin
|
{% if building %}{% trans %}Editar Edificio{% endtrans %} - {{ building.name }}{% else %}{% trans %}Nuevo Edificio{% endtrans %}{% endif %} - Edu-Space Admin
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@@ -11,24 +11,22 @@
|
|||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h1 class="h3 mb-0">
|
<h1 class="h3 mb-0">
|
||||||
<i class="bi bi-buildings text-primary me-2"></i>
|
<i class="bi bi-buildings text-primary me-2"></i>
|
||||||
{% if building %}Editar Edificio{% else %}Nuevo Edificio{% endif %}
|
{% if building %}{% trans %}Editar Edificio{% endtrans %}{% else %}{% trans %}Nuevo Edificio{% endtrans %}{% endif %}
|
||||||
</h1>
|
</h1>
|
||||||
<a href="/buildings/list_buildings" class="btn btn-outline-secondary">
|
<a href="{{ url_for('buildings.list_buildings') }}" class="btn btn-outline-secondary">
|
||||||
<i class="bi bi-arrow-left me-1"></i>Volver a Edificios
|
<i class="bi bi-arrow-left me-1"></i>{% trans %}Volver a Edificios{% endtrans %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card border-0 shadow-sm">
|
<div class="card border-0 shadow-sm">
|
||||||
<div class="card-header bg-body-tertiary border-0 py-3">
|
<div class="card-header bg-body-tertiary border-0 py-3">
|
||||||
<h5 class="mb-0 h6 fw-bold">
|
<h5 class="mb-0 h6 fw-bold">
|
||||||
<i class="bi bi-info-circle text-primary me-2"></i>Información del Edificio / Sede
|
<i class="bi bi-info-circle text-primary me-2"></i>{% trans %}Información del Edificio / Sede{% endtrans %}
|
||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<form method="POST" action="{% if building %}/buildings/edit/{{ building.id }}{% else %}/buildings/add_building{% endif %}" novalidate>
|
<form method="POST" novalidate>
|
||||||
{% if building %}
|
{{ form.hidden_tag() }}
|
||||||
<input type="hidden" name="id" value="{{ building.id }}">
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
@@ -45,12 +43,12 @@
|
|||||||
{% if form.code.errors %}
|
{% if form.code.errors %}
|
||||||
<div class="invalid-feedback">{{ form.code.errors[0] }}</div>
|
<div class="invalid-feedback">{{ form.code.errors[0] }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<small class="text-muted">Código identificador corto</small>
|
<small class="text-muted">{% trans %}Código identificador corto{% endtrans %}</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
{{ form.address.label(class="form-label fw-semibold") }}
|
{{ form.address.label(class="form-label fw-semibold") }}
|
||||||
{{ form.address(class="form-control" + (" is-invalid" if form.address.errors else ""), placeholder="ej. Av. Paseo Colón 255, CABA o campus.unicaba.edu.ar") }}
|
{{ form.address(class="form-control" + (" is-invalid" if form.address.errors else ""), placeholder="ej. Av. Paseo Colón 255, CABA") }}
|
||||||
{% if form.address.errors %}
|
{% if form.address.errors %}
|
||||||
<div class="invalid-feedback">{{ form.address.errors[0] }}</div>
|
<div class="invalid-feedback">{{ form.address.errors[0] }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -58,11 +56,11 @@
|
|||||||
|
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
{{ form.floors.label(class="form-label fw-semibold") }}
|
{{ form.floors.label(class="form-label fw-semibold") }}
|
||||||
{{ form.floors(class="form-control" + (" is-invalid" if form.floors.errors else ""), min=0, max=100) }}
|
{{ form.floors(class="form-control" + (" is-invalid" if form.floors.errors else ""), min=1, max=100) }}
|
||||||
{% if form.floors.errors %}
|
{% if form.floors.errors %}
|
||||||
<div class="invalid-feedback">{{ form.floors.errors[0] }}</div>
|
<div class="invalid-feedback">{{ form.floors.errors[0] }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<small class="text-muted">Plantas físicas (0 para Campus Virtual)</small>
|
<small class="text-muted">{% trans %}Cantidad de plantas/pisos{% endtrans %}</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
@@ -82,10 +80,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex justify-content-end gap-2 mt-4 pt-3 border-top">
|
<div class="d-flex justify-content-end gap-2 mt-4 pt-3 border-top">
|
||||||
<a href="/buildings/list_buildings" class="btn btn-outline-secondary">Cancelar</a>
|
<a href="{{ url_for('buildings.list_buildings') }}" class="btn btn-outline-secondary">{% trans %}Cancelar{% endtrans %}</a>
|
||||||
<button type="submit" class="btn btn-primary px-4">
|
<button type="submit" class="btn btn-primary px-4">
|
||||||
<i class="bi bi-check-circle me-1"></i>
|
<i class="bi bi-check-circle me-1"></i>
|
||||||
{% if building %}Guardar Cambios{% else %}Crear Edificio{% endif %}
|
{% if building %}{% trans %}Guardar Cambios{% endtrans %}{% else %}{% trans %}Crear Edificio{% endtrans %}{% endif %}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Gestión de Edificios - Edu-Space Admin{% endblock %}
|
{% block title %}{% trans %}Gestión de Edificios{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container-fluid pt-5 mt-4">
|
<div class="container-fluid pt-5 mt-4">
|
||||||
@@ -8,31 +8,22 @@
|
|||||||
<div class="row mb-4 align-items-center">
|
<div class="row mb-4 align-items-center">
|
||||||
<div class="col-md-7">
|
<div class="col-md-7">
|
||||||
<h1 class="h3 mb-1">
|
<h1 class="h3 mb-1">
|
||||||
<i class="bi bi-buildings text-primary me-2"></i>Gestión de Edificios
|
<i class="bi bi-buildings text-primary me-2"></i>{% trans %}Gestión de Edificios{% endtrans %}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-muted small mb-0">Administra las sedes, edificios físicos y campus virtuales de UniCABA</p>
|
<p class="text-muted small mb-0">{% trans %}Administra las sedes, edificios físicos y campus virtuales de UniCABA{% endtrans %}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-5 text-md-end mt-3 mt-md-0">
|
<div class="col-md-5 text-md-end mt-3 mt-md-0">
|
||||||
{% if user.has_permission('classrooms', 'read_write') %}
|
{% if current_user.has_permission('classrooms', 'read_write') %}
|
||||||
<a href="/buildings/add_building" class="btn btn-primary me-2">
|
<a href="{{ url_for('buildings.add_building') }}" class="btn btn-primary me-2">
|
||||||
<i class="bi bi-plus-circle me-1"></i>Nuevo Edificio
|
<i class="bi bi-plus-circle me-1"></i>{% trans %}Nuevo Edificio{% endtrans %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/classrooms/list_classrooms" class="btn btn-outline-secondary">
|
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
|
||||||
<i class="bi bi-door-open me-1"></i>Ver Aulas
|
<i class="bi bi-door-open me-1"></i>{% trans %}Ver Aulas{% endtrans %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Alertas de estado -->
|
|
||||||
{% if success or updated or created %}
|
|
||||||
<div class="alert alert-success alert-dismissible fade show border-0 shadow-sm mb-4" role="alert">
|
|
||||||
<i class="bi bi-check-circle-fill me-2"></i>
|
|
||||||
{% if created %}El edificio ha sido creado exitosamente.{% else %}Los datos del edificio se actualizaron correctamente.{% endif %}
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Statistics Cards -->
|
<!-- Statistics Cards -->
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
<div class="col-sm-6 col-xl-3">
|
<div class="col-sm-6 col-xl-3">
|
||||||
@@ -43,9 +34,8 @@
|
|||||||
<i class="bi bi-buildings fs-3"></i>
|
<i class="bi bi-buildings fs-3"></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-muted small fw-semibold">Sedes y Edificios</div>
|
<div class="text-muted small fw-semibold">{% trans %}Total Edificios{% endtrans %}</div>
|
||||||
<h3 class="fw-bold mb-0">{{ buildings|length }}</h3>
|
<h3 class="fw-bold mb-0">{{ buildings|length }}</h3>
|
||||||
<small class="text-muted" style="font-size: 0.72rem;">Físicas y Digitales</small>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -60,9 +50,8 @@
|
|||||||
<i class="bi bi-door-open fs-3"></i>
|
<i class="bi bi-door-open fs-3"></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-muted small fw-semibold">Aulas Totales</div>
|
<div class="text-muted small fw-semibold">{% trans %}Aulas Activas{% endtrans %}</div>
|
||||||
<h3 class="fw-bold mb-0 text-success">{{ total_classrooms }}</h3>
|
<h3 class="fw-bold mb-0 text-success">{{ total_classrooms }}</h3>
|
||||||
<small class="text-muted" style="font-size: 0.72rem;">Activas en el sistema</small>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,11 +66,8 @@
|
|||||||
<i class="bi bi-people fs-3"></i>
|
<i class="bi bi-people fs-3"></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-muted small fw-semibold">Capacidad Física Instalada</div>
|
<div class="text-muted small fw-semibold">{% trans %}Capacidad Total{% endtrans %}</div>
|
||||||
<h3 class="fw-bold mb-0 text-info">{{ total_physical_capacity or total_capacity }}</h3>
|
<h3 class="fw-bold mb-0 text-info">{{ total_capacity }}</h3>
|
||||||
<small class="text-info fw-semibold" style="font-size: 0.72rem;">
|
|
||||||
<i class="bi bi-cloud-check me-1"></i>+ Campus Virtual (Ilimitado)
|
|
||||||
</small>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,9 +82,8 @@
|
|||||||
<i class="bi bi-layers fs-3"></i>
|
<i class="bi bi-layers fs-3"></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-muted small fw-semibold">Pisos Presenciales</div>
|
<div class="text-muted small fw-semibold">{% trans %}Pisos Disponibles{% endtrans %}</div>
|
||||||
<h3 class="fw-bold mb-0 text-warning">{{ buildings|sum(attribute='floors') }}</h3>
|
<h3 class="fw-bold mb-0 text-warning">{{ buildings|sum(attribute='floors') }}</h3>
|
||||||
<small class="text-muted" style="font-size: 0.72rem;">Plantas físicas activas</small>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,20 +100,20 @@
|
|||||||
<span class="input-group-text bg-body-tertiary border-end-0">
|
<span class="input-group-text bg-body-tertiary border-end-0">
|
||||||
<i class="bi bi-search text-muted"></i>
|
<i class="bi bi-search text-muted"></i>
|
||||||
</span>
|
</span>
|
||||||
<input type="text" name="search" value="{{ search }}" class="form-control border-start-0" placeholder="Buscar por nombre, código o dirección...">
|
<input type="text" name="search" value="{{ search }}" class="form-control border-start-0" placeholder="{% trans %}Buscar por nombre, código o dirección...{% endtrans %}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<select name="status" class="form-select" onchange="this.form.submit()">
|
<select name="status" class="form-select" onchange="this.form.submit()">
|
||||||
<option value="">Todos los estados</option>
|
<option value="">{% trans %}Todos los estados{% endtrans %}</option>
|
||||||
<option value="active" {% if status_filter == 'active' %}selected{% endif %}>Activos únicamente</option>
|
<option value="active" {% if status_filter == 'active' %}selected{% endif %}>{% trans %}Activos únicamente{% endtrans %}</option>
|
||||||
<option value="inactive" {% if status_filter == 'inactive' %}selected{% endif %}>Inactivos</option>
|
<option value="inactive" {% if status_filter == 'inactive' %}selected{% endif %}>{% trans %}Inactivos{% endtrans %}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2 d-flex gap-2">
|
<div class="col-md-2 d-flex gap-2">
|
||||||
<button type="submit" class="btn btn-primary w-100">Buscar</button>
|
<button type="submit" class="btn btn-primary w-100">{% trans %}Buscar{% endtrans %}</button>
|
||||||
{% if search or status_filter %}
|
{% if search or status_filter %}
|
||||||
<a href="/buildings/list_buildings" class="btn btn-outline-secondary" title="Limpiar filtro">
|
<a href="{{ url_for('buildings.list_buildings') }}" class="btn btn-outline-secondary" title="{% trans %}Limpiar filtro{% endtrans %}">
|
||||||
<i class="bi bi-x-lg"></i>
|
<i class="bi bi-x-lg"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -141,14 +126,14 @@
|
|||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
{% for b in buildings %}
|
{% for b in buildings %}
|
||||||
<div class="col-lg-6">
|
<div class="col-lg-6">
|
||||||
<div class="card border-0 shadow-sm h-100 position-relative {% if b.is_virtual %}border-start border-4 border-info{% endif %}">
|
<div class="card border-0 shadow-sm h-100 position-relative">
|
||||||
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
|
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
|
||||||
<div>
|
<div>
|
||||||
<span class="badge {% if b.is_virtual %}bg-info-subtle text-info border border-info-subtle{% else %}bg-secondary-subtle text-secondary border{% endif %} font-monospace mb-1">
|
<span class="badge bg-secondary-subtle text-secondary font-monospace border mb-1">
|
||||||
{% if b.is_virtual %}<i class="bi bi-cloud-fill me-1"></i>{% endif %}{{ b.code or 'EDIF' }}
|
{{ b.code or 'EDIF' }}
|
||||||
</span>
|
</span>
|
||||||
<h5 class="card-title fw-bold mb-0">
|
<h5 class="card-title fw-bold mb-0">
|
||||||
<a href="/buildings/view/{{ b.id }}" class="text-decoration-none text-primary">
|
<a href="{{ url_for('buildings.view_building', id=b.id) }}" class="text-decoration-none text-primary">
|
||||||
{{ b.name }}
|
{{ b.name }}
|
||||||
</a>
|
</a>
|
||||||
</h5>
|
</h5>
|
||||||
@@ -156,11 +141,11 @@
|
|||||||
<div>
|
<div>
|
||||||
{% if b.is_active %}
|
{% if b.is_active %}
|
||||||
<span class="badge bg-success-subtle text-success border border-success-subtle px-2 py-1">
|
<span class="badge bg-success-subtle text-success border border-success-subtle px-2 py-1">
|
||||||
<i class="bi bi-check-circle me-1"></i>Activo
|
<i class="bi bi-check-circle me-1"></i>{% trans %}Activo{% endtrans %}
|
||||||
</span>
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-danger-subtle text-danger border border-danger-subtle px-2 py-1">
|
<span class="badge bg-danger-subtle text-danger border border-danger-subtle px-2 py-1">
|
||||||
<i class="bi bi-x-circle me-1"></i>Inactivo
|
<i class="bi bi-x-circle me-1"></i>{% trans %}Inactivo{% endtrans %}
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -168,7 +153,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% if b.address %}
|
{% if b.address %}
|
||||||
<p class="text-muted small mb-3">
|
<p class="text-muted small mb-3">
|
||||||
<i class="bi {% if b.is_virtual %}bi-globe2 text-info{% else %}bi-geo-alt-fill text-danger{% endif %} me-1"></i>{{ b.address }}
|
<i class="bi bi-geo-alt-fill text-danger me-1"></i>{{ b.address }}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -179,46 +164,36 @@
|
|||||||
<div class="row g-2 text-center pt-2 border-top">
|
<div class="row g-2 text-center pt-2 border-top">
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<div class="p-2 rounded bg-body-tertiary">
|
<div class="p-2 rounded bg-body-tertiary">
|
||||||
<small class="text-muted d-block">Pisos</small>
|
<small class="text-muted d-block">{% trans %}Pisos{% endtrans %}</small>
|
||||||
{% if b.is_virtual %}
|
|
||||||
<span class="badge bg-info-subtle text-info border border-info-subtle mt-1">Digital</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="fw-bold fs-6">{{ b.floors }}</span>
|
<span class="fw-bold fs-6">{{ b.floors }}</span>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<div class="p-2 rounded bg-body-tertiary">
|
<div class="p-2 rounded bg-body-tertiary">
|
||||||
<small class="text-muted d-block">Aulas</small>
|
<small class="text-muted d-block">{% trans %}Aulas{% endtrans %}</small>
|
||||||
<span class="fw-bold fs-6 text-primary">{{ b.classrooms_count }}</span>
|
<span class="fw-bold fs-6 text-primary">{{ b.classrooms_count }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<div class="p-2 rounded bg-body-tertiary">
|
<div class="p-2 rounded bg-body-tertiary">
|
||||||
<small class="text-muted d-block">Capacidad</small>
|
<small class="text-muted d-block">{% trans %}Capacidad{% endtrans %}</small>
|
||||||
{% if b.is_virtual %}
|
<span class="fw-bold fs-6 text-success">{{ b.total_capacity }}</span>
|
||||||
<span class="badge bg-primary-subtle text-primary border border-primary-subtle mt-1" title="Capacidad de concurrencia virtual ilimitada">
|
|
||||||
<i class="bi bi-infinity me-1"></i>Ilimitada
|
|
||||||
</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="fw-bold fs-6 text-success">{{ b.physical_capacity or b.total_capacity }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer bg-transparent border-0 py-3 d-flex justify-content-between align-items-center">
|
<div class="card-footer bg-transparent border-0 py-3 d-flex justify-content-between align-items-center">
|
||||||
<a href="/buildings/view/{{ b.id }}" class="btn btn-outline-primary btn-sm">
|
<a href="{{ url_for('buildings.view_building', id=b.id) }}" class="btn btn-outline-primary btn-sm">
|
||||||
<i class="bi bi-eye me-1"></i>Ver Aulas
|
<i class="bi bi-eye me-1"></i>{% trans %}Ver Distribución de Aulas{% endtrans %}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{% if user.has_permission('classrooms', 'read_write') or user.is_admin() or user.is_bedelia() %}
|
{% if current_user.has_permission('classrooms', 'read_write') %}
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<a href="/buildings/edit/{{ b.id }}" class="btn btn-outline-secondary btn-sm" title="Editar Edificio">
|
<a href="{{ url_for('buildings.edit_building', id=b.id) }}" class="btn btn-outline-secondary btn-sm" title="{% trans %}Editar Edificio{% endtrans %}">
|
||||||
<i class="bi bi-pencil me-1"></i>Editar
|
<i class="bi bi-pencil"></i>
|
||||||
</a>
|
</a>
|
||||||
<form method="POST" action="/buildings/toggle/{{ b.id }}" class="d-inline">
|
<form method="POST" action="{{ url_for('buildings.toggle_building', id=b.id) }}" class="d-inline">
|
||||||
<button type="submit" class="btn btn-outline-secondary btn-sm" title="{% if b.is_active %}Desactivar{% else %}Activar{% endif %}">
|
<button type="submit" class="btn btn-outline-secondary btn-sm" title="{% if b.is_active %}{% trans %}Desactivar{% endtrans %}{% else %}{% trans %}Activar{% endtrans %}{% endif %}">
|
||||||
<i class="bi bi-power"></i>
|
<i class="bi bi-power"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -231,12 +206,12 @@
|
|||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card border-0 shadow-sm p-5 text-center">
|
<div class="card border-0 shadow-sm p-5 text-center">
|
||||||
<i class="bi bi-buildings text-muted fs-1 mb-3"></i>
|
<i class="bi bi-buildings text-muted fs-1 mb-3"></i>
|
||||||
<h5 class="fw-bold">No se encontraron edificios</h5>
|
<h5 class="fw-bold">{% trans %}No se encontraron edificios{% endtrans %}</h5>
|
||||||
<p class="text-muted mb-3">No hay edificios que coincidan con los criterios de búsqueda.</p>
|
<p class="text-muted mb-3">{% trans %}No hay edificios que coincidan con los criterios de búsqueda.{% endtrans %}</p>
|
||||||
{% if user.has_permission('classrooms', 'read_write') %}
|
{% if current_user.has_permission('classrooms', 'read_write') %}
|
||||||
<div>
|
<div>
|
||||||
<a href="/buildings/add_building" class="btn btn-primary">
|
<a href="{{ url_for('buildings.add_building') }}" class="btn btn-primary">
|
||||||
<i class="bi bi-plus-circle me-1"></i>Crear Nuevo Edificio
|
<i class="bi bi-plus-circle me-1"></i>{% trans %}Crear Nuevo Edificio{% endtrans %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}{{ building.name }} - Detalle del Edificio - Edu-Space Admin{% endblock %}
|
{% block title %}{{ building.name }} - {% trans %}Detalle del Edificio{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container-fluid pt-5 mt-4">
|
<div class="container-fluid pt-5 mt-4">
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
<div class="col-md-7">
|
<div class="col-md-7">
|
||||||
<nav aria-label="breadcrumb">
|
<nav aria-label="breadcrumb">
|
||||||
<ol class="breadcrumb mb-1 small">
|
<ol class="breadcrumb mb-1 small">
|
||||||
<li class="breadcrumb-item"><a href="/buildings/list_buildings">Edificios</a></li>
|
<li class="breadcrumb-item"><a href="{{ url_for('buildings.list_buildings') }}">{% trans %}Edificios{% endtrans %}</a></li>
|
||||||
<li class="breadcrumb-item active">{{ building.name }}</li>
|
<li class="breadcrumb-item active">{{ building.name }}</li>
|
||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -18,22 +18,22 @@
|
|||||||
<span class="badge bg-secondary-subtle text-secondary font-monospace border fs-6">{{ building.code or 'EDIF' }}</span>
|
<span class="badge bg-secondary-subtle text-secondary font-monospace border fs-6">{{ building.code or 'EDIF' }}</span>
|
||||||
{% if building.is_active %}
|
{% if building.is_active %}
|
||||||
<span class="badge bg-success-subtle text-success border border-success-subtle fs-6">
|
<span class="badge bg-success-subtle text-success border border-success-subtle fs-6">
|
||||||
<i class="bi bi-check-circle me-1"></i>Activo
|
<i class="bi bi-check-circle me-1"></i>{% trans %}Activo{% endtrans %}
|
||||||
</span>
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-danger-subtle text-danger border border-danger-subtle fs-6">
|
<span class="badge bg-danger-subtle text-danger border border-danger-subtle fs-6">
|
||||||
<i class="bi bi-x-circle me-1"></i>Inactivo
|
<i class="bi bi-x-circle me-1"></i>{% trans %}Inactivo{% endtrans %}
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-5 text-md-end mt-3 mt-md-0">
|
<div class="col-md-5 text-md-end mt-3 mt-md-0">
|
||||||
{% if user.has_permission('classrooms', 'read_write') or user.is_admin() or user.is_bedelia() %}
|
{% if current_user.has_permission('classrooms', 'read_write') %}
|
||||||
<a href="/buildings/edit/{{ building.id }}" class="btn btn-outline-secondary me-2">
|
<a href="{{ url_for('buildings.edit_building', id=building.id) }}" class="btn btn-outline-secondary me-2">
|
||||||
<i class="bi bi-pencil me-1"></i>Editar Edificio
|
<i class="bi bi-pencil me-1"></i>{% trans %}Editar Edificio{% endtrans %}
|
||||||
</a>
|
</a>
|
||||||
<a href="/classrooms/add_classroom" class="btn btn-primary">
|
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary">
|
||||||
<i class="bi bi-plus-circle me-1"></i>Nueva Aula
|
<i class="bi bi-plus-circle me-1"></i>{% trans %}Nueva Aula{% endtrans %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -44,9 +44,9 @@
|
|||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="card border-0 shadow-sm h-100">
|
<div class="card border-0 shadow-sm h-100">
|
||||||
<div class="card-body p-3">
|
<div class="card-body p-3">
|
||||||
<div class="text-muted small fw-semibold">Ubicación / Dirección</div>
|
<div class="text-muted small fw-semibold">{% trans %}Ubicación / Dirección{% endtrans %}</div>
|
||||||
<p class="h6 fw-bold mb-2">
|
<p class="h6 fw-bold mb-2">
|
||||||
<i class="bi {% if building.is_virtual %}bi-globe2 text-info{% else %}bi-geo-alt text-danger{% endif %} me-1"></i>{{ building.address or 'Sin dirección especificada' }}
|
<i class="bi bi-geo-alt text-danger me-1"></i>{{ building.address or 'Sin dirección especificada' }}
|
||||||
</p>
|
</p>
|
||||||
{% if building.description %}
|
{% if building.description %}
|
||||||
<small class="text-secondary">{{ building.description }}</small>
|
<small class="text-secondary">{{ building.description }}</small>
|
||||||
@@ -58,30 +58,20 @@
|
|||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-sm-4">
|
<div class="col-sm-4">
|
||||||
<div class="card border-0 shadow-sm text-center p-3">
|
<div class="card border-0 shadow-sm text-center p-3">
|
||||||
<small class="text-muted text-uppercase fw-bold">Pisos</small>
|
<small class="text-muted text-uppercase fw-bold">{% trans %}Pisos{% endtrans %}</small>
|
||||||
{% if building.is_virtual %}
|
|
||||||
<span class="fs-5 fw-bold text-info d-block mt-2">Digital / Nube</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="display-6 fw-bold text-primary">{{ building.floors }}</span>
|
<span class="display-6 fw-bold text-primary">{{ building.floors }}</span>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-4">
|
<div class="col-sm-4">
|
||||||
<div class="card border-0 shadow-sm text-center p-3">
|
<div class="card border-0 shadow-sm text-center p-3">
|
||||||
<small class="text-muted text-uppercase fw-bold">Aulas Asociadas</small>
|
<small class="text-muted text-uppercase fw-bold">{% trans %}Aulas Asociadas{% endtrans %}</small>
|
||||||
<span class="display-6 fw-bold text-success">{{ classrooms|length }}</span>
|
<span class="display-6 fw-bold text-success">{{ classrooms|length }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-4">
|
<div class="col-sm-4">
|
||||||
<div class="card border-0 shadow-sm text-center p-3">
|
<div class="card border-0 shadow-sm text-center p-3">
|
||||||
<small class="text-muted text-uppercase fw-bold">Capacidad Total</small>
|
<small class="text-muted text-uppercase fw-bold">{% trans %}Capacidad Total{% endtrans %}</small>
|
||||||
{% if building.is_virtual %}
|
<span class="display-6 fw-bold text-info">{{ building.total_capacity }}</span>
|
||||||
<span class="fs-5 fw-bold text-primary d-block mt-2" title="Capacidad de concurrencia virtual ilimitada">
|
|
||||||
<i class="bi bi-infinity me-1"></i>Ilimitada
|
|
||||||
</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="display-6 fw-bold text-info">{{ building.physical_capacity or building.total_capacity }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,9 +82,9 @@
|
|||||||
<div class="card border-0 shadow-sm mb-4">
|
<div class="card border-0 shadow-sm mb-4">
|
||||||
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
|
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
|
||||||
<h5 class="mb-0 h6 fw-bold">
|
<h5 class="mb-0 h6 fw-bold">
|
||||||
<i class="bi bi-layers text-primary me-2"></i>Distribución de Aulas por Piso
|
<i class="bi bi-layers text-primary me-2"></i>{% trans %}Distribución de Aulas por Piso{% endtrans %}
|
||||||
</h5>
|
</h5>
|
||||||
<span class="badge badge-brand">{{ classrooms|length }} aulas configuradas</span>
|
<span class="badge badge-brand">{{ classrooms|length }} {% trans %}aulas configuradas{% endtrans %}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
{% if classrooms_by_floor %}
|
{% if classrooms_by_floor %}
|
||||||
@@ -116,7 +106,7 @@
|
|||||||
<div class="card-body p-3">
|
<div class="card-body p-3">
|
||||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||||
<h6 class="fw-bold mb-0">
|
<h6 class="fw-bold mb-0">
|
||||||
<a href="/classrooms/view_classroom" class="text-decoration-none text-primary">
|
<a href="{{ url_for('classrooms.view_classroom', id=c.id) }}" class="text-decoration-none text-primary">
|
||||||
<i class="bi bi-door-open me-1"></i>{{ c.code }}
|
<i class="bi bi-door-open me-1"></i>{{ c.code }}
|
||||||
</a>
|
</a>
|
||||||
</h6>
|
</h6>
|
||||||
@@ -136,12 +126,12 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center pt-2 border-top">
|
<div class="d-flex justify-content-between align-items-center pt-2 border-top">
|
||||||
<a href="/classrooms/view_classroom" class="btn btn-outline-primary btn-sm py-0 px-2">
|
<a href="{{ url_for('classrooms.view_classroom', id=c.id) }}" class="btn btn-outline-primary btn-sm py-0 px-2">
|
||||||
<small>Ver Ficha</small>
|
<small>{% trans %}Ver Ficha{% endtrans %}</small>
|
||||||
</a>
|
</a>
|
||||||
{% if user.has_permission('classrooms', 'read_write') %}
|
{% if current_user.has_permission('classrooms', 'read_write') %}
|
||||||
<a href="/classrooms/edit_classroom" class="btn btn-outline-secondary btn-sm py-0 px-2">
|
<a href="{{ url_for('classrooms.edit_classroom', id=c.id) }}" class="btn btn-outline-secondary btn-sm py-0 px-2">
|
||||||
<small>Editar</small>
|
<small>{% trans %}Editar{% endtrans %}</small>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -155,11 +145,11 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-center py-5">
|
<div class="text-center py-5">
|
||||||
<i class="bi bi-door-closed text-muted fs-1 mb-2"></i>
|
<i class="bi bi-door-closed text-muted fs-1 mb-2"></i>
|
||||||
<h6 class="fw-bold">No hay aulas asignadas a este edificio</h6>
|
<h6 class="fw-bold">{% trans %}No hay aulas asignadas a este edificio{% endtrans %}</h6>
|
||||||
<p class="text-muted small mb-3">Puedes crear aulas nuevas o editar las existentes para vincularlas aquí.</p>
|
<p class="text-muted small mb-3">{% trans %}Puedes crear aulas nuevas o editar las existentes para vincularlas aquí.{% endtrans %}</p>
|
||||||
{% if user.has_permission('classrooms', 'read_write') %}
|
{% if current_user.has_permission('classrooms', 'read_write') %}
|
||||||
<a href="/classrooms/add_classroom" class="btn btn-primary btn-sm">
|
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary btn-sm">
|
||||||
<i class="bi bi-plus-circle me-1"></i>Agregar Primera Aula
|
<i class="bi bi-plus-circle me-1"></i>{% trans %}Agregar Primera Aula{% endtrans %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -168,16 +158,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Danger Zone (Delete) -->
|
<!-- Danger Zone (Delete) -->
|
||||||
{% if user.has_permission('classrooms', 'read_write') %}
|
{% if current_user.has_permission('classrooms', 'read_write') %}
|
||||||
<div class="card border-danger border-opacity-25 shadow-sm mb-4">
|
<div class="card border-danger border-opacity-25 shadow-sm mb-4">
|
||||||
<div class="card-body p-3 d-flex justify-content-between align-items-center flex-wrap gap-2">
|
<div class="card-body p-3 d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
<div>
|
<div>
|
||||||
<h6 class="text-danger fw-bold mb-1">Eliminar Edificio</h6>
|
<h6 class="text-danger fw-bold mb-1">{% trans %}Eliminar Edificio{% endtrans %}</h6>
|
||||||
<small class="text-muted">Solo es posible eliminar edificios que no tengan aulas asignadas.</small>
|
<small class="text-muted">{% trans %}Solo es posible eliminar edificios que no tengan aulas asignadas.{% endtrans %}</small>
|
||||||
</div>
|
</div>
|
||||||
<form method="POST" action="/buildings/delete_building" onsubmit="return confirm('¿Estás seguro de que deseas eliminar este edificio?');">
|
<form method="POST" action="{{ url_for('buildings.delete_building', id=building.id) }}" onsubmit="return confirm('¿Estás seguro de que deseas eliminar este edificio?');">
|
||||||
<button type="submit" class="btn btn-outline-danger btn-sm" {% if classrooms|length > 0 %}disabled title="Tiene aulas asociadas"{% endif %}>
|
<button type="submit" class="btn btn-outline-danger btn-sm" {% if classrooms|length > 0 %}disabled title="Tiene aulas asociadas"{% endif %}>
|
||||||
<i class="bi bi-trash me-1"></i>Eliminar Edificio
|
<i class="bi bi-trash me-1"></i>{% trans %}Eliminar Edificio{% endtrans %}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{% trans %}Add Classroom{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid pt-5 mt-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="h3">
|
||||||
|
<i class="bi bi-plus-circle text-primary"></i> {% trans %}Add Classroom{% endtrans %}
|
||||||
|
</h1>
|
||||||
|
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> {% trans %}Back to Classrooms{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8 mx-auto">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-body-tertiary border-0 py-3">
|
||||||
|
<h5 class="mb-0 h6 fw-bold">
|
||||||
|
<i class="bi bi-door-open text-primary me-2"></i> {% trans %}Classroom Information{% endtrans %}
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" id="classroom-form" novalidate>
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
{{ form.building.label(class="form-label") }}
|
||||||
|
{{ form.building(class="form-control", placeholder="A") }}
|
||||||
|
{% if form.building.errors %}
|
||||||
|
<div class="text-danger small mt-1">
|
||||||
|
{{ form.building.errors[0] }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
{{ form.room_number.label(class="form-label") }}
|
||||||
|
{{ form.room_number(class="form-control", placeholder="101") }}
|
||||||
|
{% if form.room_number.errors %}
|
||||||
|
<div class="text-danger small mt-1">
|
||||||
|
{{ form.room_number.errors[0] }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
{{ form.floor.label(class="form-label") }}
|
||||||
|
{{ form.floor(class="form-control") }}
|
||||||
|
{% if form.floor.errors %}
|
||||||
|
<div class="text-danger small mt-1">
|
||||||
|
{{ form.floor.errors[0] }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
|
{{ form.capacity.label(class="form-label mb-0") }}
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary py-0 px-2" id="btnInfinityAdd" style="font-size: 0.8rem;" title="{% trans %}Asignar capacidad ilimitada{% endtrans %}">
|
||||||
|
<i class="bi bi-infinity me-1"></i>{% trans %}Ilimitada (∞){% endtrans %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="input-group">
|
||||||
|
{{ form.capacity(class="form-control", id="capacity", placeholder="ej. 30 o ∞") }}
|
||||||
|
<span class="input-group-text bg-body-tertiary text-muted font-monospace"><i class="bi bi-people"></i></span>
|
||||||
|
</div>
|
||||||
|
<div class="form-text small text-muted mt-1">
|
||||||
|
<i class="bi bi-info-circle text-info me-1"></i>{% trans %}Para aulas virtuales o sin límite de aforo, ingrese <strong>∞</strong> o <strong>0</strong> (Capacidad ilimitada).{% endtrans %}
|
||||||
|
</div>
|
||||||
|
{% if form.capacity.errors %}
|
||||||
|
<div class="text-danger small mt-1">
|
||||||
|
{{ form.capacity.errors[0] }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label d-block"> </label>
|
||||||
|
<div class="form-check pt-2">
|
||||||
|
{{ form.is_active(class="form-check-input") }}
|
||||||
|
{{ form.is_active.label(class="form-check-label") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
{{ form.description.label(class="form-label") }}
|
||||||
|
{{ form.description(class="form-control", rows="3", placeholder=_("Optional: Add any additional details about this classroom...")) }}
|
||||||
|
{% if form.description.errors %}
|
||||||
|
<div class="text-danger small mt-1">
|
||||||
|
{{ form.description.errors[0] }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview Section -->
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<h6 class="alert-heading">
|
||||||
|
<i class="bi bi-info-circle"></i> {% trans %}Preview{% endtrans %}
|
||||||
|
</h6>
|
||||||
|
<div id="classroom-preview">
|
||||||
|
<p class="mb-1">{% trans %}Fill in the form to see a preview of the classroom code:{% endtrans %}
|
||||||
|
<span class="fw-bold" id="preview-code">-</span>
|
||||||
|
</p>
|
||||||
|
<p class="mb-0">{% trans %}Full location:{% endtrans %}
|
||||||
|
<span class="fw-bold" id="preview-location">-</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-x-circle"></i> {% trans %}Cancel{% endtrans %}
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-save me-1"></i> {{ form.submit.label.text }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resources Section -->
|
||||||
|
<div class="row mt-4 mb-4">
|
||||||
|
<div class="col-lg-8 mx-auto">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-body-tertiary border-0 py-3">
|
||||||
|
<h5 class="mb-0 h6 fw-bold">
|
||||||
|
<i class="bi bi-tools text-warning me-2"></i> {% trans %}Resources (Optional){% endtrans %}
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted mb-0">{% trans %}You can add classroom resources after creating the classroom.{% endtrans %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
$(document).ready(function() {
|
||||||
|
function updatePreview() {
|
||||||
|
const building = $('#building').val();
|
||||||
|
const roomNumber = $('#room_number').val();
|
||||||
|
const floor = $('#floor').val();
|
||||||
|
const floorLabel = '{{ _("Floor") }}';
|
||||||
|
|
||||||
|
if (building && roomNumber && floor) {
|
||||||
|
$('#preview-code').text(`${building}-${roomNumber}`);
|
||||||
|
$('#preview-location').text(`${building} - ${floorLabel} ${floor}`);
|
||||||
|
} else {
|
||||||
|
$('#preview-code').text('-');
|
||||||
|
$('#preview-location').text('-');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update preview when inputs change
|
||||||
|
$('#building, #room_number, #floor').on('input', updatePreview);
|
||||||
|
|
||||||
|
// Initialize preview
|
||||||
|
updatePreview();
|
||||||
|
|
||||||
|
// Form validation
|
||||||
|
$('#classroom-form').on('submit', function(e) {
|
||||||
|
let isValid = true;
|
||||||
|
|
||||||
|
if (!$('#building').val().trim()) {
|
||||||
|
$('#building').addClass('is-invalid');
|
||||||
|
isValid = false;
|
||||||
|
} else {
|
||||||
|
$('#building').removeClass('is-invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$('#room_number').val().trim()) {
|
||||||
|
$('#room_number').addClass('is-invalid');
|
||||||
|
isValid = false;
|
||||||
|
} else {
|
||||||
|
$('#room_number').removeClass('is-invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
var floorVal = $('#floor').val();
|
||||||
|
if (floorVal === '' || isNaN(floorVal)) {
|
||||||
|
$('#floor').addClass('is-invalid');
|
||||||
|
isValid = false;
|
||||||
|
} else {
|
||||||
|
$('#floor').removeClass('is-invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
var capVal = $('#capacity').val() ? $('#capacity').val().trim() : '';
|
||||||
|
if (!capVal || (capVal !== '∞' && capVal !== '0' && isNaN(capVal))) {
|
||||||
|
$('#capacity').addClass('is-invalid');
|
||||||
|
isValid = false;
|
||||||
|
} else {
|
||||||
|
$('#capacity').removeClass('is-invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#btnInfinityAdd').on('click', function() {
|
||||||
|
$('#capacity').val('∞').focus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{% trans %}Edit Classroom{% endtrans %} - {{ classroom.code_display }} - Edu-Space Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid pt-5 mt-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-1">
|
||||||
|
<i class="bi bi-pencil-square text-primary me-2"></i>{% trans %}Edit Classroom{% endtrans %}
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted small mb-0">{{ classroom.building }} — {{ classroom.code }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}" class="btn btn-outline-primary">
|
||||||
|
<i class="bi bi-eye me-1"></i>{% trans %}Ver Ficha{% endtrans %}
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>{% trans %}Back to Classrooms{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8 mx-auto">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0 h6 fw-bold">
|
||||||
|
<i class="bi bi-door-open text-primary me-2"></i>{% trans %}Classroom Information{% endtrans %}
|
||||||
|
</h5>
|
||||||
|
<span class="badge bg-secondary-subtle text-secondary font-monospace border">ID #{{ classroom.id }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<form method="POST" id="classroom-form" novalidate>
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
{{ form.building.label(class="form-label fw-semibold") }}
|
||||||
|
{{ form.building(class="form-control" + (" is-invalid" if form.building.errors else ""), placeholder="Edificio Central") }}
|
||||||
|
{% if form.building.errors %}
|
||||||
|
<div class="invalid-feedback">{{ form.building.errors[0] }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
{{ form.room_number.label(class="form-label fw-semibold") }}
|
||||||
|
{{ form.room_number(class="form-control" + (" is-invalid" if form.room_number.errors else ""), placeholder="402") }}
|
||||||
|
{% if form.room_number.errors %}
|
||||||
|
<div class="invalid-feedback">{{ form.room_number.errors[0] }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
{{ form.floor.label(class="form-label fw-semibold") }}
|
||||||
|
{{ form.floor(class="form-control" + (" is-invalid" if form.floor.errors else "")) }}
|
||||||
|
{% if form.floor.errors %}
|
||||||
|
<div class="invalid-feedback">{{ form.floor.errors[0] }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
|
{{ form.capacity.label(class="form-label fw-semibold mb-0") }}
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary py-0 px-2" id="btnInfinity" style="font-size: 0.8rem;" title="{% trans %}Asignar capacidad ilimitada{% endtrans %}">
|
||||||
|
<i class="bi bi-infinity me-1"></i>{% trans %}Ilimitada (∞){% endtrans %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="input-group">
|
||||||
|
{{ form.capacity(class="form-control" + (" is-invalid" if form.capacity.errors else ""), id="capacityInput", placeholder="ej. 35 o ∞") }}
|
||||||
|
<span class="input-group-text bg-body-tertiary text-muted font-monospace"><i class="bi bi-people"></i></span>
|
||||||
|
</div>
|
||||||
|
<div class="form-text small text-muted mt-1">
|
||||||
|
<i class="bi bi-info-circle text-info me-1"></i>{% trans %}Para aulas virtuales o eventos sin aforo restringido, ingrese <strong>∞</strong> o <strong>0</strong> (Capacidad ilimitada).{% endtrans %}
|
||||||
|
</div>
|
||||||
|
{% if form.capacity.errors %}
|
||||||
|
<div class="invalid-feedback d-block">{{ form.capacity.errors[0] }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 d-flex align-items-center pt-md-4">
|
||||||
|
<div class="form-check form-switch pt-2">
|
||||||
|
{{ form.is_active(class="form-check-input", role="switch") }}
|
||||||
|
{{ form.is_active.label(class="form-check-label fw-semibold") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
{{ form.description.label(class="form-label fw-semibold") }}
|
||||||
|
{{ form.description(class="form-control" + (" is-invalid" if form.description.errors else ""), rows="3", placeholder="Detalles sobre equipamiento o uso preferente del aula...") }}
|
||||||
|
{% if form.description.errors %}
|
||||||
|
<div class="invalid-feedback">{{ form.description.errors[0] }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live Preview Card -->
|
||||||
|
<div class="alert alert-info border-0 mt-4 mb-0">
|
||||||
|
<h6 class="alert-heading fw-bold mb-2">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>{% trans %}Vista Previa de Identificador{% endtrans %}
|
||||||
|
</h6>
|
||||||
|
<div id="classroom-preview">
|
||||||
|
<p class="mb-0">
|
||||||
|
{% trans %}Código de visualización{% endtrans %}:
|
||||||
|
<strong id="preview-code" class="text-primary font-monospace fs-6">{{ classroom.code_display }}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end gap-2 mt-4 pt-3 border-top">
|
||||||
|
<a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}" class="btn btn-outline-secondary">
|
||||||
|
{% trans %}Cancelar{% endtrans %}
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="btn btn-primary px-4">
|
||||||
|
<i class="bi bi-check-circle me-1"></i>{% trans %}Guardar Cambios{% endtrans %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var buildingInput = document.getElementById('building');
|
||||||
|
var roomInput = document.getElementById('room_number');
|
||||||
|
var previewCode = document.getElementById('preview-code');
|
||||||
|
var capacityInput = document.getElementById('capacityInput');
|
||||||
|
var btnInfinity = document.getElementById('btnInfinity');
|
||||||
|
|
||||||
|
function updatePreview() {
|
||||||
|
var b = buildingInput ? buildingInput.value.trim() : '';
|
||||||
|
var r = roomInput ? roomInput.value.trim() : '';
|
||||||
|
if (b && r) {
|
||||||
|
previewCode.textContent = b + '-' + r;
|
||||||
|
} else if (r) {
|
||||||
|
previewCode.textContent = r;
|
||||||
|
} else {
|
||||||
|
previewCode.textContent = '---';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buildingInput) buildingInput.addEventListener('input', updatePreview);
|
||||||
|
if (roomInput) roomInput.addEventListener('input', updatePreview);
|
||||||
|
|
||||||
|
if (btnInfinity && capacityInput) {
|
||||||
|
btnInfinity.addEventListener('click', function() {
|
||||||
|
capacityInput.value = '∞';
|
||||||
|
capacityInput.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{% trans %}Classrooms{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid pt-5 mt-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="h3">
|
||||||
|
<i class="bi bi-door-open text-primary"></i> {% trans %}Classrooms{% endtrans %}
|
||||||
|
</h1>
|
||||||
|
<div>
|
||||||
|
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus-circle"></i> {% trans %}Add Classroom{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="GET" class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<input type="text" name="search" class="form-control"
|
||||||
|
placeholder="{% trans %}Search by building, room, description...{% endtrans %}"
|
||||||
|
value="{{ search }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check pt-2">
|
||||||
|
<input class="form-check-input" type="checkbox" name="active_only"
|
||||||
|
id="activeOnly" {% if active_only %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="activeOnly">
|
||||||
|
{% trans %}Show active classrooms only{% endtrans %}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<button type="submit" class="btn btn-outline-primary">
|
||||||
|
<i class="bi bi-search"></i> {% trans %}Search{% endtrans %}
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-x-circle"></i> {% trans %}Clear{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Classrooms Grid -->
|
||||||
|
{% if classrooms and classrooms.items %}
|
||||||
|
<div class="row mt-4">
|
||||||
|
{% for classroom in classrooms.items %}
|
||||||
|
<div class="col-lg-4 col-md-6 mb-4">
|
||||||
|
<div class="card border-0 shadow-sm h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||||
|
<div>
|
||||||
|
<h5 class="card-title mb-1">
|
||||||
|
{{ classroom.code_display }}
|
||||||
|
</h5>
|
||||||
|
<p class="text-muted small mb-2">
|
||||||
|
<i class="bi bi-geo-alt"></i> {{ classroom.get_full_location() }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge {% if classroom.is_active %}bg-success{% else %}bg-secondary{% endif %}">
|
||||||
|
{% if classroom.is_active %}{% trans %}Active{% endtrans %}{% else %}{% trans %}Inactive{% endtrans %}{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row small">
|
||||||
|
<div class="col-6">
|
||||||
|
<span class="text-muted">{% trans %}Capacity{% endtrans %}:</span>
|
||||||
|
<strong>
|
||||||
|
{% if classroom.is_unlimited %}
|
||||||
|
<span class="badge bg-primary-subtle text-primary border"><i class="bi bi-infinity me-1"></i>∞</span>
|
||||||
|
{% else %}
|
||||||
|
{{ classroom.capacity }}
|
||||||
|
{% endif %}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<span class="text-muted">{% trans %}Building{% endtrans %}:</span>
|
||||||
|
<strong>{{ classroom.building }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if classroom.description %}
|
||||||
|
<p class="card-text small text-muted mt-2">
|
||||||
|
{{ classroom.description[:100] }}{% if classroom.description|length > 100 %}...{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Resources indicator -->
|
||||||
|
<div class="mt-2">
|
||||||
|
<small class="text-muted">
|
||||||
|
<i class="bi bi-tools"></i>
|
||||||
|
{% if classroom.resources %}
|
||||||
|
{{ classroom.resources|length }} {% trans %}resources{% endtrans %}
|
||||||
|
{% else %}
|
||||||
|
{% trans %}No resources registered{% endtrans %}
|
||||||
|
{% endif %}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-footer bg-body-tertiary border-0">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}"
|
||||||
|
class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-eye"></i> {% trans %}View{% endtrans %}
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('classrooms.edit_classroom', id=classroom.id) }}"
|
||||||
|
class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="bi bi-pencil"></i> {% trans %}Edit{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="{{ url_for('classrooms.toggle_classroom', id=classroom.id) }}"
|
||||||
|
class="d-inline">
|
||||||
|
<button type="submit" class="btn btn-sm
|
||||||
|
{% if classroom.is_active %}btn-outline-warning{% else %}btn-outline-success{% endif %}">
|
||||||
|
{% if classroom.is_active %}
|
||||||
|
<i class="bi bi-pause-circle"></i> {% trans %}Deactivate{% endtrans %}
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-play-circle"></i> {% trans %}Activate{% endtrans %}
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if classrooms.pages > 1 %}
|
||||||
|
<nav aria-label="Classroom pagination">
|
||||||
|
<ul class="pagination justify-content-center">
|
||||||
|
{% if classrooms.has_prev %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=classrooms.prev_num, search=search, active_only=active_only) }}">
|
||||||
|
{% trans %}Previous{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for page_num in classrooms.iter_pages() %}
|
||||||
|
{% if page_num %}
|
||||||
|
{% if page_num != classrooms.page %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=page_num, search=search, active_only=active_only) }}">
|
||||||
|
{{ page_num }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item active">
|
||||||
|
<span class="page-link">{{ page_num }}</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled">
|
||||||
|
<span class="page-link">…</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if classrooms.has_next %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=classrooms.next_num, search=search, active_only=active_only) }}">
|
||||||
|
{% trans %}Next{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="bi bi-door-open text-muted" style="font-size: 4rem;"></i>
|
||||||
|
<h4 class="mt-3 text-muted">{% trans %}No classrooms found{% endtrans %}</h4>
|
||||||
|
<p class="text-muted">
|
||||||
|
{% if search %}
|
||||||
|
{% trans %}No classrooms match your search criteria.{% endtrans %}
|
||||||
|
{% else %}
|
||||||
|
{% trans %}No classrooms have been registered yet.{% endtrans %}
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus-circle"></i> {% trans %}Add First Classroom{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}{{ classroom.code_display }} - Classroom Details - Edu-Space Admin{% endblock %}
|
{% block title %}{{ classroom.code_display }} - {% trans %}Classroom Details{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container-fluid pt-5 mt-4">
|
<div class="container-fluid pt-5 mt-4">
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
<div class="col-md-7">
|
<div class="col-md-7">
|
||||||
<nav aria-label="breadcrumb">
|
<nav aria-label="breadcrumb">
|
||||||
<ol class="breadcrumb mb-1 small">
|
<ol class="breadcrumb mb-1 small">
|
||||||
<li class="breadcrumb-item"><a href="/classrooms/list_classrooms">Classrooms</a></li>
|
<li class="breadcrumb-item"><a href="{{ url_for('classrooms.list_classrooms') }}">{% trans %}Classrooms{% endtrans %}</a></li>
|
||||||
<li class="breadcrumb-item active">{{ classroom.code_display }}</li>
|
<li class="breadcrumb-item active">{{ classroom.code_display }}</li>
|
||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -17,26 +17,23 @@
|
|||||||
<i class="bi bi-door-open text-primary"></i> {{ classroom.code_display }}
|
<i class="bi bi-door-open text-primary"></i> {{ classroom.code_display }}
|
||||||
{% if classroom.is_active %}
|
{% if classroom.is_active %}
|
||||||
<span class="badge bg-success-subtle text-success border border-success-subtle fs-6">
|
<span class="badge bg-success-subtle text-success border border-success-subtle fs-6">
|
||||||
<i class="bi bi-check-circle me-1"></i>Active
|
<i class="bi bi-check-circle me-1"></i>{% trans %}Active{% endtrans %}
|
||||||
</span>
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-danger-subtle text-danger border border-danger-subtle fs-6">
|
<span class="badge bg-danger-subtle text-danger border border-danger-subtle fs-6">
|
||||||
<i class="bi bi-x-circle me-1"></i>Inactive
|
<i class="bi bi-x-circle me-1"></i>{% trans %}Inactive{% endtrans %}
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-5 text-md-end mt-3 mt-md-0">
|
<div class="col-md-5 text-md-end mt-3 mt-md-0">
|
||||||
{% if user.has_permission('classrooms', 'read_write') %}
|
{% if current_user.has_permission('classrooms', 'read_write') %}
|
||||||
<a href="/classrooms/edit_classroom/{{ classroom.id }}" class="btn btn-primary me-2">
|
<a href="{{ url_for('classrooms.edit_classroom', id=classroom.id) }}" class="btn btn-primary me-2">
|
||||||
<i class="bi bi-pencil me-1"></i>Edit Classroom
|
<i class="bi bi-pencil me-1"></i>{% trans %}Edit Classroom{% endtrans %}
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="btn btn-outline-danger me-2" data-bs-toggle="modal" data-bs-target="#modalDeleteClassroom">
|
|
||||||
<i class="bi bi-trash me-1"></i>Eliminar
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/classrooms/list_classrooms" class="btn btn-outline-secondary">
|
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
|
||||||
<i class="bi bi-arrow-left me-1"></i>Back to Classrooms
|
<i class="bi bi-arrow-left me-1"></i>{% trans %}Back to Classrooms{% endtrans %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,16 +44,16 @@
|
|||||||
<div class="card border-0 shadow-sm h-100">
|
<div class="card border-0 shadow-sm h-100">
|
||||||
<div class="card-header bg-body-tertiary border-0 py-3">
|
<div class="card-header bg-body-tertiary border-0 py-3">
|
||||||
<h5 class="mb-0 h6 fw-bold">
|
<h5 class="mb-0 h6 fw-bold">
|
||||||
<i class="bi bi-info-circle text-primary me-2"></i>Ficha Técnica
|
<i class="bi bi-info-circle text-primary me-2"></i>{% trans %}Ficha Técnica{% endtrans %}
|
||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-3">
|
<div class="card-body p-3">
|
||||||
<ul class="list-group list-group-flush">
|
<ul class="list-group list-group-flush">
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
||||||
<span class="text-muted"><i class="bi bi-building me-2"></i>Edificio / Sede</span>
|
<span class="text-muted"><i class="bi bi-building me-2"></i>{% trans %}Edificio / Sede{% endtrans %}</span>
|
||||||
<span class="fw-bold">
|
<span class="fw-bold">
|
||||||
{% if classroom.building_entity %}
|
{% if classroom.building_entity %}
|
||||||
<a href="/buildings/view_building" class="text-decoration-none">
|
<a href="{{ url_for('buildings.view_building', id=classroom.building_entity.id) }}" class="text-decoration-none">
|
||||||
{{ classroom.building_entity.name }}
|
{{ classroom.building_entity.name }}
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -65,28 +62,28 @@
|
|||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
||||||
<span class="text-muted"><i class="bi bi-door-closed me-2"></i>Número / Nombre</span>
|
<span class="text-muted"><i class="bi bi-door-closed me-2"></i>{% trans %}Número / Nombre{% endtrans %}</span>
|
||||||
<span class="fw-bold font-monospace">{{ classroom.code }}</span>
|
<span class="fw-bold font-monospace">{{ classroom.code }}</span>
|
||||||
</li>
|
</li>
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
||||||
<span class="text-muted"><i class="bi bi-layers me-2"></i>Piso / Planta</span>
|
<span class="text-muted"><i class="bi bi-layers me-2"></i>{% trans %}Piso / Planta{% endtrans %}</span>
|
||||||
<span class="fw-bold">{% if classroom.floor == 0 %}Planta Baja{% else %}Piso {{ classroom.floor }}{% endif %}</span>
|
<span class="fw-bold">{% if classroom.floor == 0 %}Planta Baja{% else %}Piso {{ classroom.floor }}{% endif %}</span>
|
||||||
</li>
|
</li>
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
||||||
<span class="text-muted"><i class="bi bi-people me-2"></i>Capacidad Máxima</span>
|
<span class="text-muted"><i class="bi bi-people me-2"></i>{% trans %}Capacidad Máxima{% endtrans %}</span>
|
||||||
{% if classroom.is_unlimited %}
|
{% if classroom.is_unlimited %}
|
||||||
<span class="badge bg-primary-subtle text-primary border px-2 py-1 fs-6">
|
<span class="badge bg-primary-subtle text-primary border px-2 py-1 fs-6">
|
||||||
<i class="bi bi-infinity me-1"></i>Ilimitada (∞)
|
<i class="bi bi-infinity me-1"></i>{% trans %}Ilimitada (∞){% endtrans %}
|
||||||
</span>
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="fw-bold text-success fs-6">{{ classroom.capacity }} estudiantes</span>
|
<span class="fw-bold text-success fs-6">{{ classroom.capacity }} {% trans %}estudiantes{% endtrans %}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{% if classroom.description %}
|
{% if classroom.description %}
|
||||||
<div class="mt-3 pt-3 border-top">
|
<div class="mt-3 pt-3 border-top">
|
||||||
<small class="text-muted fw-bold d-block mb-1">Descripción / Notas:</small>
|
<small class="text-muted fw-bold d-block mb-1">{% trans %}Descripción / Notas{% endtrans %}:</small>
|
||||||
<p class="small text-secondary mb-0">{{ classroom.description }}</p>
|
<p class="small text-secondary mb-0">{{ classroom.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -99,10 +96,10 @@
|
|||||||
<div class="card border-0 shadow-sm h-100">
|
<div class="card border-0 shadow-sm h-100">
|
||||||
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
|
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
|
||||||
<h5 class="mb-0 h6 fw-bold">
|
<h5 class="mb-0 h6 fw-bold">
|
||||||
<i class="bi bi-calendar-check text-primary me-2"></i>Próximas Clases y Reservas
|
<i class="bi bi-calendar-check text-primary me-2"></i>{% trans %}Próximas Clases y Reservas{% endtrans %}
|
||||||
</h5>
|
</h5>
|
||||||
<a href="/schedule/calendar_view" class="btn btn-outline-primary btn-sm">
|
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary btn-sm">
|
||||||
<i class="bi bi-calendar-week me-1"></i>Ver en Calendario
|
<i class="bi bi-calendar-week me-1"></i>{% trans %}Ver en Calendario{% endtrans %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
@@ -111,22 +108,19 @@
|
|||||||
<table class="table table-hover align-middle mb-0">
|
<table class="table table-hover align-middle mb-0">
|
||||||
<thead class="table-light small text-uppercase">
|
<thead class="table-light small text-uppercase">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="ps-3">Fecha y Horario</th>
|
<th class="ps-3">{% trans %}Horario{% endtrans %}</th>
|
||||||
<th>Materia / Motivo</th>
|
<th>{% trans %}Materia / Motivo{% endtrans %}</th>
|
||||||
<th>Comisión</th>
|
<th>{% trans %}Comisión{% endtrans %}</th>
|
||||||
<th>Asistentes</th>
|
<th>{% trans %}Asistentes{% endtrans %}</th>
|
||||||
<th class="text-end pe-3">Acciones</th>
|
<th class="text-end pe-3">{% trans %}Acciones{% endtrans %}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for res in upcoming_reservations %}
|
{% for res in upcoming_reservations %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="ps-3">
|
<td class="ps-3">
|
||||||
<div class="fw-bold font-monospace text-primary fs-6 mb-1">
|
<div class="fw-bold font-monospace text-primary fs-6">
|
||||||
<i class="bi bi-calendar-event text-primary me-1"></i>{{ res.start_time }}
|
<i class="bi bi-clock text-warning me-1"></i>{{ res.start_time.strftime('%H:%M') }} - {{ res.end_time.strftime('%H:%M') }} hs
|
||||||
</div>
|
|
||||||
<div class="small font-monospace text-body-secondary">
|
|
||||||
<i class="bi bi-clock text-warning me-1"></i>{{ res.start_time }} - {{ res.end_time }} hs
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -142,12 +136,7 @@
|
|||||||
<span class="small">{{ res.expected_attendees }} / {{ classroom.capacity }}</span>
|
<span class="small">{{ res.expected_attendees }} / {{ classroom.capacity }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end pe-3">
|
<td class="text-end pe-3">
|
||||||
{% if res.effective_virtual_link %}
|
<a href="{{ url_for('schedule.view_reservation', id=res.id) }}" class="btn btn-outline-secondary btn-sm py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}">
|
||||||
<a href="{{ res.effective_virtual_link }}" target="_blank" class="btn btn-outline-success btn-sm py-0 px-2 me-1" title="Unirse a Clase Virtual (Zoom / Meet)">
|
|
||||||
<i class="bi bi-camera-video"></i>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
<a href="/schedule/view_reservation?id={{ res.id }}" class="btn btn-outline-secondary btn-sm py-0 px-2" title="Ver Ficha">
|
|
||||||
<i class="bi bi-eye"></i>
|
<i class="bi bi-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
@@ -159,11 +148,11 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-center py-5">
|
<div class="text-center py-5">
|
||||||
<i class="bi bi-calendar-x text-muted fs-1 mb-2"></i>
|
<i class="bi bi-calendar-x text-muted fs-1 mb-2"></i>
|
||||||
<h6 class="fw-bold">Sin reservas programadas próximamente</h6>
|
<h6 class="fw-bold">{% trans %}Sin reservas programadas próximamente{% endtrans %}</h6>
|
||||||
<p class="text-muted small mb-3">Esta aula no tiene clases asignadas en las próximas fechas.</p>
|
<p class="text-muted small mb-3">{% trans %}Esta aula no tiene clases asignadas en las próximas fechas.{% endtrans %}</p>
|
||||||
{% if user.has_permission('reservations', 'read_write') %}
|
{% if current_user.has_permission('reservations', 'read_write') %}
|
||||||
<a href="/schedule/add_reservation" class="btn btn-primary btn-sm">
|
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-primary btn-sm">
|
||||||
<i class="bi bi-plus-circle me-1"></i>Asignar Nueva Reserva
|
<i class="bi bi-plus-circle me-1"></i>{% trans %}Asignar Nueva Reserva{% endtrans %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -172,35 +161,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Modal Confirmar Eliminación de Aula -->
|
|
||||||
<div class="modal fade text-start" id="modalDeleteClassroom" tabindex="-1" aria-hidden="true">
|
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
|
||||||
<div class="modal-content border-0 shadow">
|
|
||||||
<div class="modal-header bg-danger text-white py-2">
|
|
||||||
<h6 class="modal-title fw-bold">
|
|
||||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>Confirmar Eliminación de Aula
|
|
||||||
</h6>
|
|
||||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body p-4">
|
|
||||||
<p class="mb-2">¿Está seguro de que desea eliminar el aula <strong>{{ classroom.code_display }}</strong>?</p>
|
|
||||||
<div class="alert alert-warning small mb-0 mt-3 d-flex align-items-center gap-2">
|
|
||||||
<i class="bi bi-shield-exclamation fs-5"></i>
|
|
||||||
<div>
|
|
||||||
Si el espacio cuenta con reservas históricas o cursadas registradas, se <strong>desactivará</strong> automáticamente para mantener la integridad académica del sistema.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer py-2 bg-light">
|
|
||||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancelar</button>
|
|
||||||
<form method="POST" action="/classrooms/delete_classroom" class="d-inline">
|
|
||||||
<input type="hidden" name="classroom_id" value="{{ classroom.id }}">
|
|
||||||
<button type="submit" class="btn btn-sm btn-danger">
|
|
||||||
<i class="bi bi-trash me-1"></i>Confirmar Eliminación
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{% trans %}Dashboard - Edu-Space Admin{% endtrans %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<div class="container-fluid pt-5 mt-4">
|
||||||
|
{% endif %}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<h1 class="h3 mb-1">
|
||||||
|
<i class="bi bi-speedometer2 text-primary me-2"></i>{% trans %}Panel de Control{% endtrans %}
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted small mb-0">{% trans %}Resumen general de espacios, materias y reservas académicas{% endtrans %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Statistics Cards with Direct Shortcuts -->
|
||||||
|
<div class="row mb-4 g-3">
|
||||||
|
<!-- 1. Total Aulas -->
|
||||||
|
<div class="col-md-6 col-xl-3">
|
||||||
|
<a href="{{ url_for('classrooms.list_classrooms') }}" class="text-decoration-none text-reset d-block h-100">
|
||||||
|
<div class="card border-0 shadow-sm h-100 dashboard-stat-card transition-all">
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<span class="text-muted small fw-semibold text-uppercase">{% trans %}Total de Aulas{% endtrans %}</span>
|
||||||
|
<h2 class="mb-0 fw-bold mt-1 text-primary">{{ stats.total_classrooms }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="bg-primary bg-opacity-10 rounded-circle p-3 text-primary">
|
||||||
|
<i class="bi bi-door-open fs-3"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center justify-content-between pt-3 mt-2 border-top text-primary small fw-semibold">
|
||||||
|
<span>{% trans %}Ver todas las aulas{% endtrans %}</span>
|
||||||
|
<i class="bi bi-arrow-right"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. Total Materias -->
|
||||||
|
<div class="col-md-6 col-xl-3">
|
||||||
|
<a href="{{ url_for('schedule.commissions_view') }}" class="text-decoration-none text-reset d-block h-100">
|
||||||
|
<div class="card border-0 shadow-sm h-100 dashboard-stat-card transition-all">
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<span class="text-muted small fw-semibold text-uppercase">{% trans %}Total de Materias{% endtrans %}</span>
|
||||||
|
<h2 class="mb-0 fw-bold mt-1 text-success">{{ stats.total_subjects }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="bg-success bg-opacity-10 rounded-circle p-3 text-success">
|
||||||
|
<i class="bi bi-book fs-3"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center justify-content-between pt-3 mt-2 border-top text-success small fw-semibold">
|
||||||
|
<span>{% trans %}Gestionar materias{% endtrans %}</span>
|
||||||
|
<i class="bi bi-arrow-right"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3. Reservas de Hoy -->
|
||||||
|
<div class="col-md-6 col-xl-3">
|
||||||
|
<a href="{{ url_for('schedule.today_schedule') }}" class="text-decoration-none text-reset d-block h-100">
|
||||||
|
<div class="card border-0 shadow-sm h-100 dashboard-stat-card transition-all">
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<span class="text-muted small fw-semibold text-uppercase">{% trans %}Reservas de Hoy{% endtrans %}</span>
|
||||||
|
<h2 class="mb-0 fw-bold mt-1 text-info">{{ stats.today_reservations }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="bg-info bg-opacity-10 rounded-circle p-3 text-info">
|
||||||
|
<i class="bi bi-calendar-check fs-3"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center justify-content-between pt-3 mt-2 border-top text-info small fw-semibold">
|
||||||
|
<span>{% trans %}Ver cartelera de hoy{% endtrans %}</span>
|
||||||
|
<i class="bi bi-arrow-right"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 4. Esta Semana -->
|
||||||
|
<div class="col-md-6 col-xl-3">
|
||||||
|
<a href="{{ url_for('schedule.calendar_view') }}" class="text-decoration-none text-reset d-block h-100">
|
||||||
|
<div class="card border-0 shadow-sm h-100 dashboard-stat-card transition-all">
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<span class="text-muted small fw-semibold text-uppercase">{% trans %}Esta Semana{% endtrans %}</span>
|
||||||
|
<h2 class="mb-0 fw-bold mt-1 text-warning">{{ stats.week_reservations }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="bg-warning bg-opacity-10 rounded-circle p-3 text-warning">
|
||||||
|
<i class="bi bi-calendar-week fs-3"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center justify-content-between pt-3 mt-2 border-top text-warning small fw-semibold">
|
||||||
|
<span>{% trans %}Abrir calendario semanal{% endtrans %}</span>
|
||||||
|
<i class="bi bi-arrow-right"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Monthly Chart -->
|
||||||
|
<div class="col-md-8 mb-4">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header border-0 py-3 bg-body-tertiary">
|
||||||
|
<h5 class="mb-0 h6 fw-bold">
|
||||||
|
<i class="bi bi-graph-up text-primary me-2"></i>{% trans %}Reservas Mensuales{% endtrans %}
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<canvas id="monthlyChart" height="100"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="col-md-4 mb-4">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header border-0 py-3 bg-body-tertiary">
|
||||||
|
<h5 class="mb-0 h6 fw-bold">
|
||||||
|
<i class="bi bi-lightning text-warning me-2"></i>{% trans %}Accesos Rápidos{% endtrans %}
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary text-start">
|
||||||
|
<i class="bi bi-calendar3 me-2"></i>{% trans %}Calendario de Reservas{% endtrans %}
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-info text-start">
|
||||||
|
<i class="bi bi-calendar-day me-2"></i>{% trans %}Cartelera del Día{% endtrans %}
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary text-start">
|
||||||
|
<i class="bi bi-door-open me-2"></i>{% trans %}Listado de Aulas{% endtrans %}
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('buildings.list_buildings') }}" class="btn btn-outline-secondary text-start">
|
||||||
|
<i class="bi bi-buildings me-2"></i>{% trans %}Gestión de Edificios{% endtrans %}
|
||||||
|
</a>
|
||||||
|
{% if current_user.has_permission('reservations', 'read_write') %}
|
||||||
|
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-outline-success text-start">
|
||||||
|
<i class="bi bi-calendar-plus me-2"></i>{% trans %}Nueva Reserva{% endtrans %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if current_user.has_permission('classrooms', 'read_write') %}
|
||||||
|
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-outline-primary text-start">
|
||||||
|
<i class="bi bi-plus-circle me-2"></i>{% trans %}Crear Nueva Aula{% endtrans %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upcoming Reservations -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header border-0 py-3 bg-body-tertiary d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0 h6 fw-bold">
|
||||||
|
<i class="bi bi-clock-history text-info me-2"></i>{% trans %}Próximas Reservas (Próximos 7 Días){% endtrans %}
|
||||||
|
</h5>
|
||||||
|
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-sm btn-outline-primary">
|
||||||
|
{% trans %}Ver en Calendario{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{% if upcoming_reservations %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light small text-uppercase">
|
||||||
|
<tr>
|
||||||
|
<th class="ps-3">{% trans %}Horario{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Aula{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Asignatura / Código{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Turno / Comisión{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Estado{% endtrans %}</th>
|
||||||
|
<th class="text-end pe-3">{% trans %}Acciones{% endtrans %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for reservation in upcoming_reservations %}
|
||||||
|
<tr>
|
||||||
|
<td class="ps-3">
|
||||||
|
<div class="fw-bold font-monospace text-primary fs-6">
|
||||||
|
<i class="bi bi-clock text-warning me-1"></i>{{ reservation.start_time.strftime('%H:%M') }} - {{ reservation.end_time.strftime('%H:%M') }} hs
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-secondary-subtle text-secondary border font-monospace">
|
||||||
|
{{ reservation.classroom.code if reservation.classroom else 'Sin Aula' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="fw-semibold text-primary">{{ reservation.subject_name }}</div>
|
||||||
|
<small class="text-muted font-monospace">{{ reservation.subject_code }}</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-info-subtle text-info border me-1">
|
||||||
|
{{ reservation.shift or 'Tarde' }}
|
||||||
|
</span>
|
||||||
|
<small class="text-muted">{{ reservation.commission.code if reservation.commission else 'C1' }}</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if reservation.status == 'CONFIRMED' or reservation.status.value == 'CONFIRMED' %}
|
||||||
|
<span class="badge bg-success-subtle text-success border border-success-subtle">
|
||||||
|
<i class="bi bi-check-circle me-1"></i>{% trans %}Confirmada{% endtrans %}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-warning-subtle text-warning border border-warning-subtle">
|
||||||
|
<i class="bi bi-clock me-1"></i>{% trans %}Pendiente{% endtrans %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-end pe-3">
|
||||||
|
<a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}"
|
||||||
|
class="btn btn-sm btn-outline-secondary py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="bi bi-calendar-x text-muted fs-1 d-block mb-2"></i>
|
||||||
|
<h6 class="fw-bold">{% trans %}No hay reservas para los próximos 7 días{% endtrans %}</h6>
|
||||||
|
<p class="text-muted small">{% trans %}Puedes programar nuevas reservas desde el calendario.{% endtrans %}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<script>
|
||||||
|
$(document).ready(function() {
|
||||||
|
// Fetch monthly data and create chart
|
||||||
|
$.get('/api/dashboard-stats')
|
||||||
|
.done(function(data) {
|
||||||
|
const ctx = document.getElementById('monthlyChart').getContext('2d');
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: data.monthly_data.map(item => item.month),
|
||||||
|
datasets: [{
|
||||||
|
label: 'Reservations',
|
||||||
|
data: data.monthly_data.map(item => item.reservations),
|
||||||
|
borderColor: '#667eea',
|
||||||
|
backgroundColor: 'rgba(102, 126, 234, 0.1)',
|
||||||
|
borderWidth: 2,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.4
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
stepSize: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}AI Room Reservation Optimizer - Edu-Space Admin{% endblock %}
|
{% block title %}{% trans %}AI Room Reservation Optimizer{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<style>
|
||||||
@@ -67,10 +67,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="h3 mb-1 fw-bold">
|
<h1 class="h3 mb-1 fw-bold">
|
||||||
AI Room Reservation Optimizer
|
{% trans %}AI Room Reservation Optimizer{% endtrans %}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-muted mb-0">
|
<p class="text-muted mb-0">
|
||||||
Use genetic algorithms to automatically optimize room assignments based on capacity, availability, and student enrollment.
|
{% trans %}Use genetic algorithms to automatically optimize room assignments based on capacity, availability, and student enrollment.{% endtrans %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,99 +83,52 @@
|
|||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<!-- Algorithm Status Card -->
|
<!-- Algorithm Status Card -->
|
||||||
<div class="col-lg-4 mb-4">
|
<div class="col-lg-4 mb-4">
|
||||||
<div class="algorithm-status" style="z-index: 10;">
|
<div class="card border-0 shadow-sm algorithm-status">
|
||||||
<div class="card border-0 shadow-sm mb-4">
|
|
||||||
<div class="card-header bg-body-tertiary border-0 py-3">
|
<div class="card-header bg-body-tertiary border-0 py-3">
|
||||||
<h5 class="mb-0 h6 fw-bold">
|
<h5 class="mb-0 h6 fw-bold">
|
||||||
<i class="bi bi-gear-wide-connected text-primary me-2"></i>
|
<i class="bi bi-gear-wide-connected text-primary me-2"></i>
|
||||||
Algorithm Status
|
{% trans %}Algorithm Status{% endtrans %}
|
||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<small class="text-muted text-uppercase fw-semibold d-block mb-2">System Status</small>
|
<small class="text-muted text-uppercase fw-semibold d-block mb-2">{% trans %}System Status{% endtrans %}</small>
|
||||||
<div class="d-flex justify-content-between align-items-center py-1 border-bottom border-body-secondary">
|
<div class="d-flex justify-content-between align-items-center py-1 border-bottom border-body-secondary">
|
||||||
<span>Available Classrooms</span>
|
<span>{% trans %}Available Classrooms{% endtrans %}</span>
|
||||||
<span class="badge bg-success" id="available-classrooms">--</span>
|
<span class="badge bg-success" id="available-classrooms">--</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-content-between align-items-center py-1 border-bottom border-body-secondary">
|
<div class="d-flex justify-content-between align-items-center py-1 border-bottom border-body-secondary">
|
||||||
<span>Active Commissions</span>
|
<span>{% trans %}Active Commissions{% endtrans %}</span>
|
||||||
<span class="badge bg-info" id="active-commissions">--</span>
|
<span class="badge bg-info" id="active-commissions">--</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-content-between align-items-center py-1">
|
<div class="d-flex justify-content-between align-items-center py-1">
|
||||||
<span>Existing Reservations</span>
|
<span>{% trans %}Existing Reservations{% endtrans %}</span>
|
||||||
<span class="badge bg-secondary" id="existing-reservations">--</span>
|
<span class="badge bg-secondary" id="existing-reservations">--</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<small class="text-muted text-uppercase fw-semibold d-block mb-2">Algorithm Configuration</small>
|
<small class="text-muted text-uppercase fw-semibold d-block mb-2">{% trans %}Algorithm Configuration{% endtrans %}</small>
|
||||||
<div class="d-flex justify-content-between align-items-center py-1 border-bottom border-body-secondary">
|
<div class="d-flex justify-content-between align-items-center py-1 border-bottom border-body-secondary">
|
||||||
<span>Population Size</span>
|
<span>{% trans %}Population Size{% endtrans %}</span>
|
||||||
<span class="text-muted">50</span>
|
<span class="text-muted">50</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-content-between align-items-center py-1 border-bottom border-body-secondary">
|
<div class="d-flex justify-content-between align-items-center py-1 border-bottom border-body-secondary">
|
||||||
<span>Generations</span>
|
<span>{% trans %}Generations{% endtrans %}</span>
|
||||||
<span class="text-muted">100</span>
|
<span class="text-muted">100</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-content-between align-items-center py-1">
|
<div class="d-flex justify-content-between align-items-center py-1">
|
||||||
<span>Mutation Rate</span>
|
<span>{% trans %}Mutation Rate{% endtrans %}</span>
|
||||||
<span class="text-muted">10%</span>
|
<span class="text-muted">10%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="btn btn-sm btn-outline-primary w-100" onclick="window.refreshStatus()">
|
<button class="btn btn-sm btn-outline-primary w-100" onclick="geneticManager.getAlgorithmStatus()">
|
||||||
<i class="bi bi-arrow-clockwise me-1"></i>
|
<i class="bi bi-arrow-clockwise me-1"></i>
|
||||||
Refresh Status
|
{% trans %}Refresh Status{% endtrans %}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Optimization Controls -->
|
|
||||||
<div class="card border-0 shadow-sm">
|
|
||||||
<div class="card-header bg-body-tertiary border-0 py-3">
|
|
||||||
<h5 class="mb-0 h6 fw-bold">
|
|
||||||
<i class="bi bi-sliders text-primary me-2"></i>
|
|
||||||
Optimization Controls
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row align-items-center mb-3">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="start-date" class="form-label">Start Date</label>
|
|
||||||
<input type="datetime-local" class="form-control" id="start-date" name="start_date">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="end-date" class="form-label">End Date</label>
|
|
||||||
<input type="datetime-local" class="form-control" id="end-date" name="end_date">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-grid gap-2">
|
|
||||||
<button class="btn btn-info text-white" id="preview-optimization-btn">
|
|
||||||
<i class="bi bi-eye me-1"></i>
|
|
||||||
Preview
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-success" id="optimize-assignments-btn">
|
|
||||||
<i class="bi bi-play-circle me-1"></i>
|
|
||||||
Run Full Optimization
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-warning" id="apply-optimization-btn" style="display: none;">
|
|
||||||
<i class="bi bi-check-circle me-1"></i>
|
|
||||||
Apply
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-outline-secondary" onclick="resetOptimization()">
|
|
||||||
<i class="bi bi-arrow-counterclockwise me-1"></i>
|
|
||||||
Reset
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Commission Selection -->
|
<!-- Commission Selection -->
|
||||||
@@ -184,14 +137,14 @@
|
|||||||
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
|
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
|
||||||
<h5 class="mb-0 h6 fw-bold">
|
<h5 class="mb-0 h6 fw-bold">
|
||||||
<i class="bi bi-list-check text-primary me-2"></i>
|
<i class="bi bi-list-check text-primary me-2"></i>
|
||||||
Select Commissions to Optimize
|
{% trans %}Select Commissions to Optimize{% endtrans %}
|
||||||
</h5>
|
</h5>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button class="btn btn-sm btn-outline-primary" onclick="selectAllCommissions()">
|
<button class="btn btn-sm btn-outline-primary" onclick="selectAllCommissions()">
|
||||||
Select All
|
{% trans %}Select All{% endtrans %}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-secondary" onclick="deselectAllCommissions()">
|
<button class="btn btn-sm btn-outline-secondary" onclick="deselectAllCommissions()">
|
||||||
Deselect All
|
{% trans %}Deselect All{% endtrans %}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -200,7 +153,7 @@
|
|||||||
<!-- Commissions will be loaded here -->
|
<!-- Commissions will be loaded here -->
|
||||||
<div class="col-12 text-center text-muted py-5">
|
<div class="col-12 text-center text-muted py-5">
|
||||||
<div class="spinner-border spinner-border-sm me-2 text-primary" role="status"></div>
|
<div class="spinner-border spinner-border-sm me-2 text-primary" role="status"></div>
|
||||||
Loading commissions...
|
{% trans %}Loading commissions...{% endtrans %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -208,7 +161,54 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Optimization Controls -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-body-tertiary border-0 py-3">
|
||||||
|
<h5 class="mb-0 h6 fw-bold">
|
||||||
|
<i class="bi bi-sliders text-primary me-2"></i>
|
||||||
|
{% trans %}Optimization Controls{% endtrans %}
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row align-items-center mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="start-date" class="form-label">{% trans %}Start Date Range{% endtrans %}</label>
|
||||||
|
<input type="datetime-local" class="form-control" id="start-date" name="start_date">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="end-date" class="form-label">{% trans %}End Date Range{% endtrans %}</label>
|
||||||
|
<input type="datetime-local" class="form-control" id="end-date" name="end_date">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
|
<button class="btn btn-info text-white" id="preview-optimization-btn">
|
||||||
|
<i class="bi bi-eye me-1"></i>
|
||||||
|
{% trans %}Preview Optimization{% endtrans %}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-success" id="optimize-assignments-btn">
|
||||||
|
<i class="bi bi-play-circle me-1"></i>
|
||||||
|
{% trans %}Run Full Optimization{% endtrans %}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-warning" id="apply-optimization-btn" style="display: none;">
|
||||||
|
<i class="bi bi-check-circle me-1"></i>
|
||||||
|
{% trans %}Apply Reservations{% endtrans %}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary" onclick="resetOptimization()">
|
||||||
|
<i class="bi bi-arrow-counterclockwise me-1"></i>
|
||||||
|
{% trans %}Reset{% endtrans %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Results Section -->
|
<!-- Results Section -->
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
@@ -224,8 +224,8 @@
|
|||||||
<div class="spinner-overlay" id="loading-spinner">
|
<div class="spinner-overlay" id="loading-spinner">
|
||||||
<div class="text-center text-white">
|
<div class="text-center text-white">
|
||||||
<div class="spinner-border text-primary" style="width: 3rem; height: 3rem;" role="status"></div>
|
<div class="spinner-border text-primary" style="width: 3rem; height: 3rem;" role="status"></div>
|
||||||
<p class="mt-3 mb-1 fw-bold fs-5">Running genetic algorithm optimization...</p>
|
<p class="mt-3 mb-1 fw-bold fs-5">{% trans %}Running genetic algorithm optimization...{% endtrans %}</p>
|
||||||
<small class="text-white-50">This may take a few moments</small>
|
<small class="text-white-50">{% trans %}This may take a few moments{% endtrans %}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -234,17 +234,17 @@
|
|||||||
<div class="modal-dialog modal-lg">
|
<div class="modal-dialog modal-lg">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title" id="detailsModalLabel">Reservation Details</h5>
|
<h5 class="modal-title" id="detailsModalLabel">{% trans %}Reservation Details{% endtrans %}</h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<!-- Content populated dynamically -->
|
<!-- Content populated dynamically -->
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans %}Close{% endtrans %}</button>
|
||||||
<button type="button" class="btn btn-primary" onclick="downloadReservations()">
|
<button type="button" class="btn btn-primary" onclick="downloadReservations()">
|
||||||
<i class="bi bi-download me-1"></i>
|
<i class="bi bi-download me-1"></i>
|
||||||
Download
|
{% trans %}Download{% endtrans %}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -253,7 +253,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script src="/js/genetic-algorithm.js"></script>
|
<script src="{{ url_for('static', filename='js/genetic-algorithm.js') }}"></script>
|
||||||
<script>
|
<script>
|
||||||
function selectAllCommissions() {
|
function selectAllCommissions() {
|
||||||
const checkboxes = document.querySelectorAll('input[name="commission_ids"]');
|
const checkboxes = document.querySelectorAll('input[name="commission_ids"]');
|
||||||
@@ -292,25 +292,11 @@ function resetOptimization() {
|
|||||||
document.getElementById('end-date').value = '';
|
document.getElementById('end-date').value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
window.refreshStatus = async function() {
|
|
||||||
if (window.geneticManager) {
|
|
||||||
try {
|
|
||||||
const statusResult = await geneticManager.getAlgorithmStatus();
|
|
||||||
if (statusResult && statusResult.success && statusResult.data) {
|
|
||||||
const sysStats = statusResult.data.system_status;
|
|
||||||
document.getElementById('available-classrooms').textContent = sysStats.available_classrooms;
|
|
||||||
document.getElementById('active-commissions').textContent = sysStats.active_commissions;
|
|
||||||
document.getElementById('existing-reservations').textContent = sysStats.confirmed_reservations;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Error refreshing status:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async function() {
|
document.addEventListener('DOMContentLoaded', async function() {
|
||||||
await loadCommissions();
|
await loadCommissions();
|
||||||
await window.refreshStatus();
|
if (window.geneticManager) {
|
||||||
|
await geneticManager.getAlgorithmStatus();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadCommissions() {
|
async function loadCommissions() {
|
||||||
@@ -318,7 +304,7 @@ async function loadCommissions() {
|
|||||||
const response = await fetch('/api/genetic/commissions');
|
const response = await fetch('/api/genetic/commissions');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const commissionsList = document.getElementById('commissions-list');
|
const commissionsList = document.getElementById('commissions-list');
|
||||||
const isSpanish = '{{ session.language or "es" }}' === 'es';
|
const isSpanish = '{{ session.get("language", "es") }}' === 'es';
|
||||||
|
|
||||||
if (data.success && data.commissions && data.commissions.length > 0) {
|
if (data.success && data.commissions && data.commissions.length > 0) {
|
||||||
const commissionsHtml = data.commissions.map(commission => `
|
const commissionsHtml = data.commissions.map(commission => `
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<ul class="dropdown-menu dropdown-menu-end shadow-sm" aria-labelledby="languageDropdown">
|
<ul class="dropdown-menu dropdown-menu-end shadow-sm" aria-labelledby="languageDropdown">
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-item d-flex align-items-center justify-content-between {% if current_lang == 'es' %}active{% endif %}"
|
<a class="dropdown-item d-flex align-items-center justify-content-between {% if current_lang == 'es' %}active{% endif %}"
|
||||||
href="/main/set_language">
|
href="{{ url_for('main.set_language', language='es') }}">
|
||||||
<span>Español</span>
|
<span>Español</span>
|
||||||
{% if current_lang == 'es' %}
|
{% if current_lang == 'es' %}
|
||||||
<i class="bi bi-check-lg ms-2 text-white"></i>
|
<i class="bi bi-check-lg ms-2 text-white"></i>
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-item d-flex align-items-center justify-content-between {% if current_lang == 'en' %}active{% endif %}"
|
<a class="dropdown-item d-flex align-items-center justify-content-between {% if current_lang == 'en' %}active{% endif %}"
|
||||||
href="/main/set_language">
|
href="{{ url_for('main.set_language', language='en') }}">
|
||||||
<span>English</span>
|
<span>English</span>
|
||||||
{% if current_lang == 'en' %}
|
{% if current_lang == 'en' %}
|
||||||
<i class="bi bi-check-lg ms-2 text-white"></i>
|
<i class="bi bi-check-lg ms-2 text-white"></i>
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{% trans %}New Reservation{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid pt-5 mt-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="h3">
|
||||||
|
<i class="bi bi-calendar-plus text-success"></i> {% trans %}New Reservation{% endtrans %}
|
||||||
|
</h1>
|
||||||
|
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> {% trans %}Back to Reservations{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8 mx-auto">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header border-0 py-3">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="bi bi-calendar3 text-success"></i> {% trans %}Reservation Details{% endtrans %}
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" id="reservation-form" novalidate>
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
{{ form.classroom_id.label(class="form-label") }}
|
||||||
|
{{ form.classroom_id(class="form-select", id="classroom-select") }}
|
||||||
|
{% if form.classroom_id.errors %}
|
||||||
|
<div class="text-danger small mt-1">
|
||||||
|
{{ form.classroom_id.errors[0] }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<small class="text-muted">{% trans %}Select a classroom for your reservation{% endtrans %}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
{{ form.commission_id.label(class="form-label") }}
|
||||||
|
{{ form.commission_id(class="form-select", id="commission-select") }}
|
||||||
|
{% if form.commission_id.errors %}
|
||||||
|
<div class="text-danger small mt-1">
|
||||||
|
{{ form.commission_id.errors[0] }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<small class="text-muted">{% trans %}Select the subject/commission{% endtrans %}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
{{ form.start_time.label(class="form-label") }}
|
||||||
|
{{ form.start_time(class="form-control", id="start-time") }}
|
||||||
|
{% if form.start_time.errors %}
|
||||||
|
<div class="text-danger small mt-1">
|
||||||
|
{{ form.start_time.errors[0] }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<small class="text-muted">{% trans %}When will the class start?{% endtrans %}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
{{ form.end_time.label(class="form-label") }}
|
||||||
|
{{ form.end_time(class="form-control", id="end-time") }}
|
||||||
|
{% if form.end_time.errors %}
|
||||||
|
<div class="text-danger small mt-1">
|
||||||
|
{{ form.end_time.errors[0] }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<small class="text-muted">{% trans %}When will the class end?{% endtrans %}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8 mb-3">
|
||||||
|
{{ form.purpose.label(class="form-label") }}
|
||||||
|
{{ form.purpose(class="form-control", placeholder=_("Regular class, exam, study session, etc.")) }}
|
||||||
|
{% if form.purpose.errors %}
|
||||||
|
<div class="text-danger small mt-1">
|
||||||
|
{{ form.purpose.errors[0] }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
{{ form.expected_attendees.label(class="form-label") }}
|
||||||
|
{{ form.expected_attendees(class="form-control", placeholder="30") }}
|
||||||
|
{% if form.expected_attendees.errors %}
|
||||||
|
<div class="text-danger small mt-1">
|
||||||
|
{{ form.expected_attendees.errors[0] }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<small class="text-muted">{% trans %}Number of expected attendees{% endtrans %}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
{{ form.notes.label(class="form-label") }}
|
||||||
|
{{ form.notes(class="form-control", rows="3", placeholder=_("Any additional notes or special requirements...")) }}
|
||||||
|
{% if form.notes.errors %}
|
||||||
|
<div class="text-danger small mt-1">
|
||||||
|
{{ form.notes.errors[0] }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary Section -->
|
||||||
|
<div class="alert alert-info" id="reservation-summary">
|
||||||
|
<h6 class="alert-heading">
|
||||||
|
<i class="bi bi-info-circle"></i> {% trans %}Reservation Summary{% endtrans %}
|
||||||
|
</h6>
|
||||||
|
<div id="summary-content">
|
||||||
|
<p class="mb-2">
|
||||||
|
<strong>{% trans %}Selected Classroom:{% endtrans %}</strong>
|
||||||
|
<span id="summary-classroom">{% trans %}Not selected{% endtrans %}</span>
|
||||||
|
</p>
|
||||||
|
<p class="mb-2">
|
||||||
|
<strong>{% trans %}Duration:{% endtrans %}</strong>
|
||||||
|
<span id="summary-duration">{% trans %}Not calculated{% endtrans %}</span>
|
||||||
|
</p>
|
||||||
|
<p class="mb-0">
|
||||||
|
<strong>{% trans %}Commission:{% endtrans %}</strong>
|
||||||
|
<span id="summary-commission">{% trans %}Not selected{% endtrans %}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-x-circle"></i> {% trans %}Cancel{% endtrans %}
|
||||||
|
</a>
|
||||||
|
{{ form.submit(class="btn btn-success") }}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Instructions Card -->
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-lg-8 mx-auto">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header border-0 py-3">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="bi bi-question-circle text-info"></i> {% trans %}Reservation Guidelines{% endtrans %}
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="text-primary">📅 {% trans %}Booking Rules{% endtrans %}</h6>
|
||||||
|
<ul class="small">
|
||||||
|
<li>{% trans %}Reservations can be made up to 3 months in advance{% endtrans %}</li>
|
||||||
|
<li>{% trans %}Cannot book in the past{% endtrans %}</li>
|
||||||
|
<li>{% trans %}Maximum duration: 8 hours per reservation{% endtrans %}</li>
|
||||||
|
<li>{% trans %}Cancellations must be 24 hours before start time{% endtrans %}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="text-warning">🚨 {% trans %}Important Notes{% endtrans %}</h6>
|
||||||
|
<ul class="small">
|
||||||
|
<li>{% trans %}Check classroom capacity before booking{% endtrans %}</li>
|
||||||
|
<li>{% trans %}Pending reservations require approval{% endtrans %}</li>
|
||||||
|
<li>{% trans %}Time slots must not overlap{% endtrans %}</li>
|
||||||
|
<li>{% trans %}System automatically detects conflicts{% endtrans %}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script type="application/json" id="classroom-data">
|
||||||
|
{
|
||||||
|
{% for classroom in classroom_list %}
|
||||||
|
"{{ classroom.id }}": {
|
||||||
|
"capacity": {{ classroom.capacity }},
|
||||||
|
"code_display": {{ classroom.code_display|tojson }},
|
||||||
|
"name": {{ classroom.name|tojson }}
|
||||||
|
}{% if not loop.last %},{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="application/json" id="i18n-strings">
|
||||||
|
{
|
||||||
|
"not_selected": "{% trans %}Not selected{% endtrans %}",
|
||||||
|
"not_calculated": "{% trans %}Not calculated{% endtrans %}",
|
||||||
|
"invalid_time": "{% trans %}Invalid time range{% endtrans %}",
|
||||||
|
"hour": "{% trans %}hour{% endtrans %}",
|
||||||
|
"hours": "{% trans %}hours{% endtrans %}",
|
||||||
|
"minute": "{% trans %}minute{% endtrans %}",
|
||||||
|
"minutes": "{% trans %}minutes{% endtrans %}",
|
||||||
|
"capacity": "{% trans %}Capacity{% endtrans %}",
|
||||||
|
"capacity_exceeded": "{% trans %}Expected attendees exceeds classroom capacity{% endtrans %}"
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
$(document).ready(function() {
|
||||||
|
// Classroom data for capacity checking
|
||||||
|
let classroomData = {};
|
||||||
|
try {
|
||||||
|
const rawData = document.getElementById('classroom-data')?.textContent;
|
||||||
|
if (rawData) {
|
||||||
|
classroomData = JSON.parse(rawData);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error parsing classroomData:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// I18n strings
|
||||||
|
let i18n = {};
|
||||||
|
try {
|
||||||
|
const rawI18n = document.getElementById('i18n-strings')?.textContent;
|
||||||
|
if (rawI18n) {
|
||||||
|
i18n = JSON.parse(rawI18n);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error parsing i18n:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSummary() {
|
||||||
|
const classroomId = $('#classroom-select').val();
|
||||||
|
const commissionId = $('#commission-select').val();
|
||||||
|
const startTime = $('#start-time').val();
|
||||||
|
const endTime = $('#end-time').val();
|
||||||
|
|
||||||
|
// Update classroom summary
|
||||||
|
if (classroomId && classroomData[classroomId]) {
|
||||||
|
const capLabel = i18n.capacity || 'Capacidad';
|
||||||
|
$('#summary-classroom').text(`${classroomData[classroomId].code_display} - ${classroomData[classroomId].name} (${capLabel}: ${classroomData[classroomId].capacity})`);
|
||||||
|
} else {
|
||||||
|
$('#summary-classroom').text(i18n.not_selected || 'No seleccionada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update commission summary
|
||||||
|
if (commissionId) {
|
||||||
|
const commissionText = $('#commission-select option:selected').text();
|
||||||
|
$('#summary-commission').text(commissionText);
|
||||||
|
} else {
|
||||||
|
$('#summary-commission').text(i18n.not_selected || 'No seleccionada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate duration
|
||||||
|
if (startTime && endTime) {
|
||||||
|
const start = new Date(startTime);
|
||||||
|
const end = new Date(endTime);
|
||||||
|
|
||||||
|
if (end > start) {
|
||||||
|
const diffMs = end - start;
|
||||||
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||||
|
const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
|
||||||
|
let durationText = '';
|
||||||
|
if (diffHours > 0) {
|
||||||
|
const hLabel = diffHours > 1 ? (i18n.hours || 'horas') : (i18n.hour || 'hora');
|
||||||
|
durationText += `${diffHours} ${hLabel}`;
|
||||||
|
}
|
||||||
|
if (diffMins > 0) {
|
||||||
|
if (durationText) durationText += ' ';
|
||||||
|
const mLabel = diffMins > 1 ? (i18n.minutes || 'minutos') : (i18n.minute || 'minuto');
|
||||||
|
durationText += `${diffMins} ${mLabel}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#summary-duration').text(durationText || '0 min');
|
||||||
|
} else {
|
||||||
|
$('#summary-duration').text(i18n.invalid_time || 'Rango horario inválido');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$('#summary-duration').text(i18n.not_calculated || 'No calculada');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check capacity vs expected attendees
|
||||||
|
function checkCapacity() {
|
||||||
|
const classroomId = $('#classroom-select').val();
|
||||||
|
const attendees = parseInt($('#expected_attendees').val()) || 0;
|
||||||
|
|
||||||
|
if (classroomId && classroomData[classroomId] && attendees > 0) {
|
||||||
|
const capacity = classroomData[classroomId].capacity;
|
||||||
|
|
||||||
|
if (attendees > capacity) {
|
||||||
|
$('#expected_attendees').addClass('is-invalid');
|
||||||
|
const msg = i18n.capacity_exceeded || 'Los asistentes esperados superan la capacidad del aula';
|
||||||
|
$('#expected_attendees').after(`
|
||||||
|
<div class="invalid-feedback">
|
||||||
|
${msg} (${attendees} > ${capacity})
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
} else {
|
||||||
|
$('#expected_attendees').removeClass('is-invalid');
|
||||||
|
$('#expected_attendees').siblings('.invalid-feedback').remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update on change
|
||||||
|
$('#classroom-select, #commission-select, #start-time, #end-time, #expected_attendees').on('change', updateSummary);
|
||||||
|
$('#expected_attendees').on('input', checkCapacity);
|
||||||
|
|
||||||
|
// Initialize summary
|
||||||
|
updateSummary();
|
||||||
|
|
||||||
|
// Form validation
|
||||||
|
$('#reservation-form').on('submit', function(e) {
|
||||||
|
let isValid = true;
|
||||||
|
|
||||||
|
// Check required fields
|
||||||
|
if (!$('#classroom-select').val()) {
|
||||||
|
$('#classroom-select').addClass('is-invalid');
|
||||||
|
isValid = false;
|
||||||
|
} else {
|
||||||
|
$('#classroom-select').removeClass('is-invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$('#commission-select').val()) {
|
||||||
|
$('#commission-select').addClass('is-invalid');
|
||||||
|
isValid = false;
|
||||||
|
} else {
|
||||||
|
$('#commission-select').removeClass('is-invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$('#start-time').val()) {
|
||||||
|
$('#start-time').addClass('is-invalid');
|
||||||
|
isValid = false;
|
||||||
|
} else {
|
||||||
|
$('#start-time').removeClass('is-invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$('#end-time').val()) {
|
||||||
|
$('#end-time').addClass('is-invalid');
|
||||||
|
isValid = false;
|
||||||
|
} else {
|
||||||
|
$('#end-time').removeClass('is-invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate time range
|
||||||
|
const startTime = new Date($('#start-time').val());
|
||||||
|
const endTime = new Date($('#end-time').val());
|
||||||
|
|
||||||
|
if (endTime <= startTime) {
|
||||||
|
$('#end-time').addClass('is-invalid');
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check capacity
|
||||||
|
checkCapacity();
|
||||||
|
if ($('#expected_attendees').hasClass('is-invalid')) {
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set minimum datetime to current
|
||||||
|
const now = new Date();
|
||||||
|
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
|
||||||
|
$('#start-time, #end-time').attr('min', now.toISOString().slice(0, 16));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,585 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{% trans %}Reservations Calendar{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid pt-5 mt-4">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="row mb-4 align-items-center">
|
||||||
|
<div class="col-md-7">
|
||||||
|
<h1 class="h3 mb-1">
|
||||||
|
<i class="bi bi-calendar3 text-primary me-2"></i>{% trans %}Reservations Calendar{% endtrans %}
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted small mb-0">{% trans %}View & Manage Reservations{% endtrans %} — {% trans %}Horarios de cursada y asignación de aulas UniCABA{% endtrans %}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5 text-md-end mt-3 mt-md-0">
|
||||||
|
{% if current_user.has_permission('reservations', 'read_write') %}
|
||||||
|
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-primary me-2">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i>{% trans %}Add Reservation{% endtrans %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-list me-1"></i>{% trans %}List View{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters Toolbar (Classroom & Subject Filters to prevent overlapping) -->
|
||||||
|
<div class="card border-0 shadow-sm mb-4">
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<div class="row g-2 align-items-center">
|
||||||
|
<!-- Filter by Classroom -->
|
||||||
|
<div class="col-lg-3 col-md-6">
|
||||||
|
<label for="filterClassroom" class="form-label small fw-bold text-muted mb-1">
|
||||||
|
<i class="bi bi-door-open text-success me-1"></i>{% trans %}Aula{% endtrans %}
|
||||||
|
</label>
|
||||||
|
<select id="filterClassroom" class="form-select form-select-sm">
|
||||||
|
<option value="">{% trans %}Todas las aulas{% endtrans %}</option>
|
||||||
|
{% for c in classrooms %}
|
||||||
|
<option value="{{ c.id }}">{{ c.code }} ({{ c.building }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter by Subject -->
|
||||||
|
<div class="col-lg-3 col-md-6">
|
||||||
|
<label for="filterSubject" class="form-label small fw-bold text-muted mb-1">
|
||||||
|
<i class="bi bi-book text-primary me-1"></i>{% trans %}Materia{% endtrans %}
|
||||||
|
</label>
|
||||||
|
<select id="filterSubject" class="form-select form-select-sm">
|
||||||
|
<option value="">{% trans %}Todas las materias{% endtrans %}</option>
|
||||||
|
{% for s in subjects %}
|
||||||
|
<option value="{{ s.id }}">{{ s.name }} [{{ s.code }}]</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter by Career -->
|
||||||
|
<div class="col-lg-3 col-md-6">
|
||||||
|
<label for="filterCareer" class="form-label small fw-bold text-muted mb-1">
|
||||||
|
<i class="bi bi-mortarboard text-info me-1"></i>{% trans %}Carrera{% endtrans %}
|
||||||
|
</label>
|
||||||
|
<select id="filterCareer" class="form-select form-select-sm">
|
||||||
|
<option value="">{% trans %}Todas las carreras{% endtrans %}</option>
|
||||||
|
{% for cr in careers %}
|
||||||
|
<option value="{{ cr.id }}">{{ cr.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter by Turno (Mañana / Tarde) -->
|
||||||
|
<div class="col-lg-3 col-md-6">
|
||||||
|
<label for="filterShift" class="form-label small fw-bold text-muted mb-1">
|
||||||
|
<i class="bi bi-brightness-high text-warning me-1"></i>{% trans %}Turno{% endtrans %}
|
||||||
|
</label>
|
||||||
|
<select id="filterShift" class="form-select form-select-sm">
|
||||||
|
<option value="">{% trans %}Todos los turnos{% endtrans %}</option>
|
||||||
|
<option value="Mañana">{% trans %}Turno Mañana{% endtrans %}</option>
|
||||||
|
<option value="Tarde">{% trans %}Turno Tarde / Vespertino{% endtrans %}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Second row of filters: Floor & Hide Virtual -->
|
||||||
|
<div class="row g-2 align-items-center mt-2 pt-2 border-top">
|
||||||
|
<!-- Filter by Floor -->
|
||||||
|
<div class="col-lg-3 col-md-4">
|
||||||
|
<label for="filterFloor" class="form-label small fw-bold text-muted mb-1">
|
||||||
|
<i class="bi bi-layers text-secondary me-1"></i>{% trans %}Filtrar por Piso{% endtrans %}
|
||||||
|
</label>
|
||||||
|
<select id="filterFloor" class="form-select form-select-sm">
|
||||||
|
<option value="">{% trans %}Todos los pisos{% endtrans %}</option>
|
||||||
|
{% for f in available_floors %}
|
||||||
|
<option value="{{ f }}">{% if f == 0 %}{% trans %}Planta Baja (Piso 0){% endtrans %}{% else %}{% trans %}Piso{% endtrans %} {{ f }}{% endif %}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hide Virtual Classrooms Toggle -->
|
||||||
|
<div class="col-lg-6 col-md-5 pt-md-3">
|
||||||
|
<div class="form-check form-switch pt-1">
|
||||||
|
<input class="form-check-input" type="checkbox" role="switch" id="filterHideVirtual">
|
||||||
|
<label class="form-check-label fw-semibold small text-dark" for="filterHideVirtual">
|
||||||
|
<i class="bi bi-laptop me-1 text-primary"></i>{% trans %}Ocultar Aulas Virtuales{% endtrans %}
|
||||||
|
<span class="badge bg-secondary-subtle text-secondary border ms-1" style="font-size: 0.72rem;">{% trans %}Capacidad ilimitada{% endtrans %}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reset Filters Button -->
|
||||||
|
<div class="col-lg-3 col-md-3 text-md-end pt-md-3">
|
||||||
|
<button type="button" id="btnResetFilters" class="btn btn-outline-secondary btn-sm px-3" title="{% trans %}Restablecer filtros{% endtrans %}">
|
||||||
|
<i class="bi bi-arrow-counterclockwise me-1"></i>{% trans %}Limpiar Filtros{% endtrans %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active filter indicator & counter -->
|
||||||
|
<div class="d-flex align-items-center flex-wrap gap-2 mt-2 pt-2 border-top">
|
||||||
|
<small class="text-muted fw-semibold">
|
||||||
|
<i class="bi bi-funnel-fill text-secondary me-1"></i>{% trans %}Estado{% endtrans %}:
|
||||||
|
</small>
|
||||||
|
<span id="activeFilterBadge" class="badge bg-secondary-subtle text-secondary border small">
|
||||||
|
{% trans %}Mostrando todas las aulas y materias{% endtrans %}
|
||||||
|
</span>
|
||||||
|
<span class="badge badge-brand ms-auto small">
|
||||||
|
<i class="bi bi-calendar-check me-1"></i><span id="eventCountNumber">0</span> {% trans %}clases visibles{% endtrans %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Calendar Card -->
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-body-tertiary border-0 py-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<span class="fw-bold small text-secondary text-uppercase tracking-wide">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>{% trans %}Referencia{% endtrans %}:
|
||||||
|
</span>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<span class="badge badge-brand me-1" style="width: 14px; height: 14px; border-radius: 4px;"></span>
|
||||||
|
<small class="text-muted">{% trans %}Clase Confirmada{% endtrans %}</small>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<span class="badge bg-warning me-1" style="width: 14px; height: 14px; border-radius: 4px;"></span>
|
||||||
|
<small class="text-muted">{% trans %}Pendiente{% endtrans %}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small">
|
||||||
|
<i class="bi bi-lightning-charge-fill text-warning me-1"></i>{% trans %}Haz clic en cualquier clase para ver los detalles completos{% endtrans %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<!-- FullCalendar Container -->
|
||||||
|
<div id="calendar"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reservation Details Modal -->
|
||||||
|
<div class="modal fade" id="reservationModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||||
|
<div class="modal-content border-0 shadow-lg">
|
||||||
|
<div class="modal-header border-0 pb-0">
|
||||||
|
<div>
|
||||||
|
<span id="modalCategoryBadge" class="badge badge-brand mb-1">Clase Académica</span>
|
||||||
|
<h4 class="modal-title fw-bold text-primary" id="modalSubjectTitle">Detalle de la Clase</h4>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body pt-3" id="reservationDetails">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="p-3 rounded bg-body-tertiary h-100">
|
||||||
|
<h6 class="text-muted small text-uppercase fw-bold mb-2">
|
||||||
|
<i class="bi bi-book text-primary me-1"></i>{% trans %}Materia y Código{% endtrans %}
|
||||||
|
</h6>
|
||||||
|
<p class="h6 fw-bold mb-1" id="modalSubjectName">-</p>
|
||||||
|
<p class="mb-2">
|
||||||
|
<span class="badge bg-dark bg-opacity-75 font-monospace text-white" id="modalSubjectCode">-</span>
|
||||||
|
</p>
|
||||||
|
<p class="small text-muted mb-0" id="modalCareerWrap">
|
||||||
|
<i class="bi bi-mortarboard me-1"></i><span id="modalCareer">-</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="p-3 rounded bg-body-tertiary h-100">
|
||||||
|
<h6 class="text-muted small text-uppercase fw-bold mb-2">
|
||||||
|
<i class="bi bi-door-open text-success me-1"></i>{% trans %}Espacio Asignado{% endtrans %}
|
||||||
|
</h6>
|
||||||
|
<p class="h5 fw-bold text-success mb-1" id="modalClassroom">-</p>
|
||||||
|
<p class="small text-muted mb-0" id="modalBuilding">-</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="p-3 rounded bg-body-tertiary h-100">
|
||||||
|
<h6 class="text-muted small text-uppercase fw-bold mb-2">
|
||||||
|
<i class="bi bi-clock text-warning me-1"></i>{% trans %}Horario Programado{% endtrans %}
|
||||||
|
</h6>
|
||||||
|
<p class="h6 fw-bold mb-0" id="modalTimeRange">-</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="p-3 rounded bg-body-tertiary h-100">
|
||||||
|
<h6 class="text-muted small text-uppercase fw-bold mb-2">
|
||||||
|
<i class="bi bi-shield-check text-info me-1"></i>{% trans %}Estado y Capacidad{% endtrans %}
|
||||||
|
</h6>
|
||||||
|
<p class="mb-1">
|
||||||
|
<span class="badge bg-success-subtle text-success border border-success-subtle px-2 py-1" id="modalStatus">-</span>
|
||||||
|
</p>
|
||||||
|
<small class="text-muted" id="modalAttendees">-</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer border-0 pt-0">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Close{% endtrans %}</button>
|
||||||
|
<a href="#" id="modalFullLink" class="btn btn-primary">
|
||||||
|
<i class="bi bi-box-arrow-up-right me-1"></i>{% trans %}Ver Ficha Completa{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@5.11.0/main.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
/* FullCalendar Modern Overrides */
|
||||||
|
#calendar {
|
||||||
|
font-family: inherit;
|
||||||
|
min-height: 700px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc .fc-toolbar-title {
|
||||||
|
font-size: 1.25rem !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc .fc-button-primary {
|
||||||
|
background: var(--gradient-primary, linear-gradient(135deg, #B43E8E 0%, #9E367D 100%)) !important;
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 6px !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
font-size: 0.85rem !important;
|
||||||
|
padding: 0.35rem 0.75rem !important;
|
||||||
|
box-shadow: 0 2px 4px rgba(180, 62, 142, 0.25) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc .fc-button-primary:hover {
|
||||||
|
background: linear-gradient(135deg, #9E367D 0%, #832765 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc .fc-button-primary:disabled {
|
||||||
|
background: #94A3B8 !important;
|
||||||
|
opacity: 0.6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-theme-standard td, .fc-theme-standard th {
|
||||||
|
border-color: var(--card-border, #E2E8F0) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc .fc-col-header-cell-cushion {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 8px 4px !important;
|
||||||
|
text-decoration: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc .fc-timegrid-slot-label-cushion {
|
||||||
|
color: var(--text-secondary, #64748B);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Event Card Visual Hierarchy */
|
||||||
|
.fc-event {
|
||||||
|
cursor: pointer;
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12) !important;
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-event:hover {
|
||||||
|
transform: translateY(-2px) scale(1.01);
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.22) !important;
|
||||||
|
z-index: 10 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-event-confirmed {
|
||||||
|
background: linear-gradient(135deg, #B43E8E 0%, #832765 100%) !important;
|
||||||
|
color: #FFFFFF !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-event-pending {
|
||||||
|
background: linear-gradient(135deg, #F59E0B 0%, #D97706 100%) !important;
|
||||||
|
color: #FFFFFF !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-event-custom-container {
|
||||||
|
padding: 5px 7px;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-event-time-pill {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* High Visual Hierarchy for Subject Name */
|
||||||
|
.fc-event-subject-title {
|
||||||
|
font-size: 0.88rem !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
line-height: 1.22 !important;
|
||||||
|
color: #FFFFFF !important;
|
||||||
|
letter-spacing: -0.15px;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
word-break: break-word;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Secondary Badges */
|
||||||
|
.fc-event-badges {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 3px;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-badge-code {
|
||||||
|
background-color: rgba(0, 0, 0, 0.3) !important;
|
||||||
|
color: #FFFFFF !important;
|
||||||
|
font-size: 0.70rem !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
padding: 2px 5px !important;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-badge-room {
|
||||||
|
background-color: rgba(255, 255, 255, 0.28) !important;
|
||||||
|
color: #FFFFFF !important;
|
||||||
|
font-size: 0.70rem !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
padding: 2px 5px !important;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Month View Optimization */
|
||||||
|
.fc-daygrid-event .fc-event-custom-container {
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
|
.fc-daygrid-event .fc-event-subject-title {
|
||||||
|
font-size: 0.78rem !important;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.11.0/main.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.11.0/locales-all.min.js"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var calendarEl = document.getElementById('calendar');
|
||||||
|
var currentLang = '{{ session.get("language", "es") }}';
|
||||||
|
var todayDate = '{{ today_date or "2026-09-03" }}';
|
||||||
|
|
||||||
|
var filterRoom = document.getElementById('filterClassroom');
|
||||||
|
var filterSubj = document.getElementById('filterSubject');
|
||||||
|
var filterCar = document.getElementById('filterCareer');
|
||||||
|
var filterShift = document.getElementById('filterShift');
|
||||||
|
var filterFloor = document.getElementById('filterFloor');
|
||||||
|
var filterHideVirtual = document.getElementById('filterHideVirtual');
|
||||||
|
var activeBadge = document.getElementById('activeFilterBadge');
|
||||||
|
var countEl = document.getElementById('eventCountNumber');
|
||||||
|
|
||||||
|
function updateFilterSummaryBadge() {
|
||||||
|
var parts = [];
|
||||||
|
if (filterRoom.value && filterRoom.selectedOptions[0]) {
|
||||||
|
parts.push('Aula: ' + filterRoom.selectedOptions[0].text.split('(')[0].trim());
|
||||||
|
}
|
||||||
|
if (filterSubj.value && filterSubj.selectedOptions[0]) {
|
||||||
|
parts.push('Materia: ' + filterSubj.selectedOptions[0].text.split('[')[0].trim());
|
||||||
|
}
|
||||||
|
if (filterCar.value && filterCar.selectedOptions[0]) {
|
||||||
|
parts.push('Carrera: ' + filterCar.selectedOptions[0].text.trim());
|
||||||
|
}
|
||||||
|
if (filterShift && filterShift.value) {
|
||||||
|
parts.push('Turno ' + filterShift.value);
|
||||||
|
}
|
||||||
|
if (filterFloor && filterFloor.value) {
|
||||||
|
parts.push(filterFloor.value === '0' ? 'Planta Baja' : 'Piso ' + filterFloor.value);
|
||||||
|
}
|
||||||
|
if (filterHideVirtual && filterHideVirtual.checked) {
|
||||||
|
parts.push('Sin Virtuales');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length > 0) {
|
||||||
|
activeBadge.className = 'badge bg-primary text-white border small';
|
||||||
|
activeBadge.textContent = parts.join(' | ');
|
||||||
|
} else {
|
||||||
|
activeBadge.className = 'badge bg-secondary-subtle text-secondary border small';
|
||||||
|
activeBadge.textContent = 'Mostrando todas las aulas y materias';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var calendar = new FullCalendar.Calendar(calendarEl, {
|
||||||
|
locale: currentLang,
|
||||||
|
initialView: 'timeGridWeek',
|
||||||
|
initialDate: todayDate,
|
||||||
|
allDaySlot: false,
|
||||||
|
headerToolbar: {
|
||||||
|
left: 'prev,next today',
|
||||||
|
center: 'title',
|
||||||
|
right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
|
||||||
|
},
|
||||||
|
eventTimeFormat: {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
},
|
||||||
|
slotMinTime: '07:30:00',
|
||||||
|
slotMaxTime: '22:30:00',
|
||||||
|
height: 'auto',
|
||||||
|
slotEventOverlap: true,
|
||||||
|
|
||||||
|
/* Dynamic Server-Side Filtered Events */
|
||||||
|
events: function(info, successCallback, failureCallback) {
|
||||||
|
var params = new URLSearchParams({
|
||||||
|
start: info.startStr,
|
||||||
|
end: info.endStr,
|
||||||
|
classroom_id: filterRoom.value || '',
|
||||||
|
subject_id: filterSubj.value || '',
|
||||||
|
career_id: filterCar.value || '',
|
||||||
|
shift: (filterShift ? filterShift.value : '') || '',
|
||||||
|
floor: (filterFloor ? filterFloor.value : '') || '',
|
||||||
|
hide_virtual: (filterHideVirtual && filterHideVirtual.checked) ? '1' : ''
|
||||||
|
});
|
||||||
|
|
||||||
|
updateFilterSummaryBadge();
|
||||||
|
|
||||||
|
fetch('{{ url_for("schedule.calendar_data") }}?' + params.toString())
|
||||||
|
.then(function(response) {
|
||||||
|
if (!response.ok) throw new Error('Network error');
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(function(eventsData) {
|
||||||
|
if (countEl) {
|
||||||
|
countEl.textContent = eventsData.length;
|
||||||
|
}
|
||||||
|
successCallback(eventsData);
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
console.error('Error loading calendar events:', err);
|
||||||
|
failureCallback(err);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Custom Card Render with Visual Hierarchy */
|
||||||
|
eventContent: function(arg) {
|
||||||
|
var ext = arg.event.extendedProps || {};
|
||||||
|
var subjectName = ext.subject_name || arg.event.title || '';
|
||||||
|
var subjectCode = ext.subject_code || '';
|
||||||
|
var classroom = ext.classroom || '';
|
||||||
|
var timeText = arg.timeText || '';
|
||||||
|
|
||||||
|
var container = document.createElement('div');
|
||||||
|
container.className = 'fc-event-custom-container';
|
||||||
|
|
||||||
|
var timeHtml = timeText ? `<div class="fc-event-time-pill"><i class="bi bi-clock me-1"></i>${timeText}</div>` : '';
|
||||||
|
var titleHtml = `<div class="fc-event-subject-title" title="${subjectName}">${subjectName}</div>`;
|
||||||
|
var badgesHtml = `
|
||||||
|
<div class="fc-event-badges">
|
||||||
|
${subjectCode ? `<span class="fc-badge-code font-monospace">${subjectCode}</span>` : ''}
|
||||||
|
${classroom ? `<span class="fc-badge-room"><i class="bi bi-door-open me-1"></i>${classroom}</span>` : ''}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
container.innerHTML = timeHtml + titleHtml + badgesHtml;
|
||||||
|
return { domNodes: [container] };
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Interactive Instant Modal */
|
||||||
|
eventClick: function(info) {
|
||||||
|
info.jsEvent.preventDefault();
|
||||||
|
var ext = info.event.extendedProps || {};
|
||||||
|
var subjectName = ext.subject_name || info.event.title || '';
|
||||||
|
var subjectCode = ext.subject_code || '-';
|
||||||
|
var classroom = ext.classroom || 'Sin Aula';
|
||||||
|
var building = ext.building || 'Edificio Central';
|
||||||
|
var career = ext.career || 'General';
|
||||||
|
var status = ext.status || 'CONFIRMED';
|
||||||
|
var attendees = ext.attendees || 0;
|
||||||
|
|
||||||
|
var startStr = info.event.start ? info.event.start.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit', hour12: false}) : '';
|
||||||
|
var endStr = info.event.end ? info.event.end.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit', hour12: false}) : '';
|
||||||
|
var dateStr = info.event.start ? info.event.start.toLocaleDateString([], {weekday: 'long', day: 'numeric', month: 'long'}) : '';
|
||||||
|
|
||||||
|
document.getElementById('modalSubjectTitle').textContent = subjectName;
|
||||||
|
document.getElementById('modalSubjectName').textContent = subjectName;
|
||||||
|
document.getElementById('modalSubjectCode').textContent = subjectCode;
|
||||||
|
document.getElementById('modalCareer').textContent = career;
|
||||||
|
document.getElementById('modalClassroom').textContent = classroom;
|
||||||
|
document.getElementById('modalBuilding').textContent = building;
|
||||||
|
document.getElementById('modalTimeRange').textContent = `${dateStr} (${startStr} a ${endStr} hs)`;
|
||||||
|
document.getElementById('modalStatus').textContent = status;
|
||||||
|
document.getElementById('modalAttendees').textContent = `${attendees} estudiantes esperados`;
|
||||||
|
|
||||||
|
if (info.event.url) {
|
||||||
|
var btn = document.getElementById('modalFullLink');
|
||||||
|
btn.href = info.event.url;
|
||||||
|
btn.classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
document.getElementById('modalFullLink').classList.add('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
var modal = new bootstrap.Modal(document.getElementById('reservationModal'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
calendar.render();
|
||||||
|
|
||||||
|
// Event listeners to trigger immediate calendar refetch on filter change
|
||||||
|
filterRoom.addEventListener('change', function() {
|
||||||
|
calendar.refetchEvents();
|
||||||
|
});
|
||||||
|
|
||||||
|
filterSubj.addEventListener('change', function() {
|
||||||
|
calendar.refetchEvents();
|
||||||
|
});
|
||||||
|
|
||||||
|
filterCar.addEventListener('change', function() {
|
||||||
|
calendar.refetchEvents();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filterShift) {
|
||||||
|
filterShift.addEventListener('change', function() {
|
||||||
|
calendar.refetchEvents();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterFloor) {
|
||||||
|
filterFloor.addEventListener('change', function() {
|
||||||
|
calendar.refetchEvents();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterHideVirtual) {
|
||||||
|
filterHideVirtual.addEventListener('change', function() {
|
||||||
|
calendar.refetchEvents();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btnResetFilters').addEventListener('click', function() {
|
||||||
|
filterRoom.value = '';
|
||||||
|
filterSubj.value = '';
|
||||||
|
filterCar.value = '';
|
||||||
|
if (filterShift) filterShift.value = '';
|
||||||
|
if (filterFloor) filterFloor.value = '';
|
||||||
|
if (filterHideVirtual) filterHideVirtual.checked = false;
|
||||||
|
calendar.refetchEvents();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Commissions Management - Edu-Space Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid pt-5 mt-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="h3">
|
||||||
|
<i class="bi bi-diagram-3 text-info"></i> Commissions Management
|
||||||
|
</h1>
|
||||||
|
<div class="btn-group">
|
||||||
|
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-success">
|
||||||
|
<i class="bi bi-plus-circle"></i> New Reservation
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-calendar-day"></i> Today's Schedule
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Statistics Cards -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h2 class="text-primary">{{ subjects|length }}</h2>
|
||||||
|
<p class="mb-0">Active Subjects</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h2 class="text-success">{{ subjects|map(attribute='commissions')|map('length')|sum }}</h2>
|
||||||
|
<p class="mb-0">Total Commissions</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h2 class="text-success">{{ total_commissions }}</h2>
|
||||||
|
|
||||||
|
<h2 class="text-info">{{ total_capacity }}</h2>
|
||||||
|
<p class="mb-0">Total Student Capacity</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h2 class="text-warning">{{ subjects|selectattr('commissions')|list|length }}</h2>
|
||||||
|
<p class="mb-0">Subjects with Commissions</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Subjects and Commissions -->
|
||||||
|
<div class="row">
|
||||||
|
{% for subject in subjects %}
|
||||||
|
<div class="col-lg-6 mb-4">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header border-0 py-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<h5 class="mb-1">
|
||||||
|
<span class="badge bg-primary me-2">{{ subject.code }}</span>
|
||||||
|
{{ subject.name }}
|
||||||
|
</h5>
|
||||||
|
<p class="text-muted mb-0 small">
|
||||||
|
<i class="bi bi-building"></i> {{ subject.department }} •
|
||||||
|
<i class="bi bi-award"></i> {{ subject.credits }} credits
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-end">
|
||||||
|
<span class="badge bg-info">{{ subject.commissions|length }} commission{{ 's' if subject.commissions|length != 1 else '' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if subject.commissions %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Commission</th>
|
||||||
|
<th>Semester</th>
|
||||||
|
<th>Teacher</th>
|
||||||
|
<th>Students</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for commission in subject.commissions %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{{ commission.code }}</strong>
|
||||||
|
{% if commission.schedule %}
|
||||||
|
<br><small class="text-muted">{{ commission.schedule }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-light text-dark">{{ commission.semester }} {{ commission.year }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if commission.teacher %}
|
||||||
|
<small>{{ commission.teacher.first_name }} {{ commission.teacher.last_name }}</small>
|
||||||
|
{% else %}
|
||||||
|
<small class="text-muted">Not assigned</small>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<small>{{ commission.current_students }}/{{ commission.max_students }}</small>
|
||||||
|
{% set fill_percentage = (commission.current_students / commission.max_students * 100) %}
|
||||||
|
<div class="progress ms-2" style="height: 4px; width: 30px;">
|
||||||
|
<div class="progress-bar
|
||||||
|
{% if fill_percentage > 90 %}bg-danger
|
||||||
|
{% elif fill_percentage > 75 %}bg-warning
|
||||||
|
{% else %}bg-success
|
||||||
|
{% endif %}"
|
||||||
|
style="width: {{ fill_percentage }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if commission.active %}
|
||||||
|
<span class="badge bg-success">Active</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">Inactive</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button class="btn btn-outline-primary quick-reserve-btn"
|
||||||
|
data-commission-id="{{ commission.id }}"
|
||||||
|
data-max-students="{{ commission.max_students }}"
|
||||||
|
data-commission-name="{{ commission.get_full_code() }}"
|
||||||
|
data-subject-name="{{ subject.name }}"
|
||||||
|
title="Quick Reservation">
|
||||||
|
<i class="bi bi-calendar-plus"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-info view-schedule-btn"
|
||||||
|
data-commission-id="{{ commission.id }}"
|
||||||
|
title="View Schedule">
|
||||||
|
<i class="bi bi-calendar-week"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center text-muted py-3">
|
||||||
|
<i class="bi bi-inbox fs-2 d-block mb-2"></i>
|
||||||
|
No commissions registered for this subject
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if subject.description %}
|
||||||
|
<div class="card-footer bg-light">
|
||||||
|
<small class="text-muted">
|
||||||
|
<i class="bi bi-info-circle"></i> {{ subject.description|truncate(100) }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="bi bi-inbox fs-1 text-muted d-block mb-3"></i>
|
||||||
|
<h4 class="text-muted">No Subjects Found</h4>
|
||||||
|
<p class="text-muted">There are no active subjects in the system.</p>
|
||||||
|
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-door-open"></i> View Classrooms
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Reservation Modal -->
|
||||||
|
<div class="modal fade" id="quickReservationModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">
|
||||||
|
<i class="bi bi-calendar-plus text-success"></i> Quick Reservation
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="quick-reservation-form">
|
||||||
|
<input type="hidden" id="quick-commission-id" name="commission_id">
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<h6 class="alert-heading">
|
||||||
|
<i class="bi bi-info-circle"></i> Commission Details
|
||||||
|
</h6>
|
||||||
|
<div id="commission-details"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="quick-date" class="form-label">Date</label>
|
||||||
|
<input type="date" class="form-control" id="quick-date" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="quick-start-time" class="form-label">Start Time</label>
|
||||||
|
<input type="time" class="form-control" id="quick-start-time" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="quick-end-time" class="form-label">End Time</label>
|
||||||
|
<input type="time" class="form-control" id="quick-end-time" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12 mb-3">
|
||||||
|
<label for="quick-purpose" class="form-label">Purpose</label>
|
||||||
|
<input type="text" class="form-control" id="quick-purpose" placeholder="Regular class, exam, review, etc." required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Classroom Selection & Availability -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Select Classroom</label>
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<select class="form-select" id="quick-classroom-select" required>
|
||||||
|
<option value="">Choose date and time first</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn btn-outline-primary ms-2" id="check-availability-btn" disabled>
|
||||||
|
<i class="bi bi-search"></i> Check
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="availability-results" class="mt-3"></div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-success" id="create-reservation-btn" disabled>
|
||||||
|
<i class="bi bi-plus-circle"></i> Create Reservation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
$(document).ready(function() {
|
||||||
|
// Quick Reservation Button Click
|
||||||
|
$('.quick-reserve-btn').on('click', function() {
|
||||||
|
const commissionId = $(this).data('commission-id');
|
||||||
|
const maxStudents = $(this).data('max-students');
|
||||||
|
const commissionName = $(this).data('commission-name');
|
||||||
|
const subjectName = $(this).data('subject-name');
|
||||||
|
|
||||||
|
// Set commission details in modal
|
||||||
|
$('#quick-commission-id').val(commissionId);
|
||||||
|
$('#commission-details').html(`
|
||||||
|
<strong>${commissionName}</strong><br>
|
||||||
|
<small>Subject: ${subjectName}</small><br>
|
||||||
|
<small>Max Students: ${maxStudents}</small>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
$('#quick-date').val('');
|
||||||
|
$('#quick-start-time').val('');
|
||||||
|
$('#quick-end-time').val('');
|
||||||
|
$('#quick-purpose').val('');
|
||||||
|
$('#quick-classroom-select').html('<option value="">Choose date and time first</option>').prop('disabled', true);
|
||||||
|
$('#check-availability-btn').prop('disabled', true);
|
||||||
|
$('#create-reservation-btn').prop('disabled', true);
|
||||||
|
$('#availability-results').empty();
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
$('#quickReservationModal').modal('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enable date/time inputs to check availability
|
||||||
|
function validateDateTimeInputs() {
|
||||||
|
const date = $('#quick-date').val();
|
||||||
|
const startTime = $('#quick-start-time').val();
|
||||||
|
const endTime = $('#quick-end-time').val();
|
||||||
|
|
||||||
|
const isValid = date && startTime && endTime && startTime < endTime;
|
||||||
|
$('#check-availability-btn').prop('disabled', !isValid);
|
||||||
|
|
||||||
|
if (isValid) {
|
||||||
|
loadClassroomOptions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#quick-date, #quick-start-time, #quick-end-time').on('change', validateDateTimeInputs);
|
||||||
|
|
||||||
|
function loadClassroomOptions() {
|
||||||
|
$.get('/schedule/api/classroom-availability', {
|
||||||
|
date: $('#quick-date').val(),
|
||||||
|
start_time: $('#quick-start-time').val(),
|
||||||
|
end_time: $('#quick-end-time').val(),
|
||||||
|
max_students: parseInt($('#quick-commission-id').data('max-students'))
|
||||||
|
}, function(response) {
|
||||||
|
const classroomSelect = $('#quick-classroom-select');
|
||||||
|
classroomSelect.empty().append('<option value="">Select a classroom</option>');
|
||||||
|
|
||||||
|
// Add available classrooms
|
||||||
|
response.availability.available.forEach(function(classroom) {
|
||||||
|
classroomSelect.append(`<option value="${classroom.id}">${classroom.code} - ${classroom.name} (Cap: ${classroom.capacity}) - ${classroom.location}</option>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enable selection
|
||||||
|
classroomSelect.prop('disabled', response.availability.available.length === 0);
|
||||||
|
|
||||||
|
// Show recommendation if available
|
||||||
|
if (response.recommendation) {
|
||||||
|
const rec = response.recommendation.classroom;
|
||||||
|
$('#availability-results').html(`
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<h6><i class="bi bi-star-fill"></i> Recommended Classroom</h6>
|
||||||
|
<strong>${rec.code} - ${rec.name}</strong><br>
|
||||||
|
<small>Capacity: ${rec.capacity} | Location: ${rec.location}</small><br>
|
||||||
|
<small class="text-muted">${response.recommendation.reason}</small>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show statistics
|
||||||
|
$('#availability-results').append(`
|
||||||
|
<div class="alert alert-info mt-2">
|
||||||
|
<small>
|
||||||
|
<strong>Availability:</strong> ${response.availability.stats.total_available} available,
|
||||||
|
${response.availability.stats.total_occupied} occupied classrooms
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check availability button click
|
||||||
|
$('#check-availability-btn').on('click', loadClassroomOptions);
|
||||||
|
|
||||||
|
// Classroom selection enables create button
|
||||||
|
$('#quick-classroom-select').on('change', function() {
|
||||||
|
const isValid = $(this).val() && $('#quick-purpose').val();
|
||||||
|
$('#create-reservation-btn').prop('disabled', !isValid);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Purpose input validation
|
||||||
|
$('#quick-purpose').on('input', function() {
|
||||||
|
const isValid = $('#quick-classroom-select').val() && $(this).val();
|
||||||
|
$('#create-reservation-btn').prop('disabled', !isValid);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create reservation
|
||||||
|
$('#create-reservation-btn').on('click', function() {
|
||||||
|
const formData = {
|
||||||
|
commission_id: $('#quick-commission-id').val(),
|
||||||
|
classroom_id: $('#quick-classroom-select').val(),
|
||||||
|
purpose: $('#quick-purpose').val(),
|
||||||
|
start_time: `${$('#quick-date').val()} ${$('#quick-start-time').val()}`,
|
||||||
|
end_time: `${$('#quick-date').val()} ${$('#quick-end-time').val()}`,
|
||||||
|
expected_attendees: $('#quick-commission-id').data('max-students')
|
||||||
|
};
|
||||||
|
|
||||||
|
$.post('/schedule/reservation/add', formData)
|
||||||
|
.done(function() {
|
||||||
|
$('#quickReservationModal').modal('hide');
|
||||||
|
location.reload();
|
||||||
|
})
|
||||||
|
.fail(function(xhr) {
|
||||||
|
alert('Error creating reservation: ' + xhr.responseJSON.error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{% trans %}Listado de Reservas{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid pt-5 mt-4">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="row mb-4 align-items-center">
|
||||||
|
<div class="col-md-7">
|
||||||
|
<h1 class="h3 mb-1">
|
||||||
|
<i class="bi bi-calendar-check text-primary me-2"></i>{% trans %}Listado de Reservas{% endtrans %}
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted small mb-0">{% trans %}Registro histórico y programación completa de clases y asignación de aulas{% endtrans %}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5 text-md-end mt-3 mt-md-0">
|
||||||
|
{% if current_user.has_permission('reservations', 'read_write') %}
|
||||||
|
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-success me-2">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i>{% trans %}Nueva Reserva{% endtrans %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary me-2">
|
||||||
|
<i class="bi bi-calendar3 me-1"></i>{% trans %}Ver Calendario{% endtrans %}
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-info">
|
||||||
|
<i class="bi bi-calendar-day me-1"></i>{% trans %}Cartelera de Hoy{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters Card -->
|
||||||
|
<div class="card border-0 shadow-sm mb-4">
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<form method="GET" action="{{ url_for('schedule.list_reservations') }}" class="row g-2 align-items-end">
|
||||||
|
<!-- Search text -->
|
||||||
|
<div class="col-lg-3 col-md-6">
|
||||||
|
<label class="form-label small fw-bold text-muted mb-1">
|
||||||
|
<i class="bi bi-search me-1"></i>{% trans %}Buscar{% endtrans %}
|
||||||
|
</label>
|
||||||
|
<input type="text" name="search" class="form-control form-control-sm" placeholder="Materia, código o aula..." value="{{ search or '' }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Filter -->
|
||||||
|
<div class="col-lg-2 col-md-3">
|
||||||
|
<label class="form-label small fw-bold text-muted mb-1">
|
||||||
|
<i class="bi bi-shield-check me-1"></i>{% trans %}Estado{% endtrans %}
|
||||||
|
</label>
|
||||||
|
<select name="status" class="form-select form-select-sm">
|
||||||
|
<option value="all" {% if status_filter == 'all' or not status_filter %}selected{% endif %}>{% trans %}Todos los estados{% endtrans %}</option>
|
||||||
|
<option value="confirmed" {% if status_filter == 'confirmed' %}selected{% endif %}>{% trans %}Confirmadas{% endtrans %}</option>
|
||||||
|
<option value="pending" {% if status_filter == 'pending' %}selected{% endif %}>{% trans %}Pendientes{% endtrans %}</option>
|
||||||
|
<option value="cancelled" {% if status_filter == 'cancelled' %}selected{% endif %}>{% trans %}Canceladas{% endtrans %}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Floor Filter -->
|
||||||
|
<div class="col-lg-2 col-md-3">
|
||||||
|
<label class="form-label small fw-bold text-muted mb-1">
|
||||||
|
<i class="bi bi-layers me-1"></i>{% trans %}Piso{% endtrans %}
|
||||||
|
</label>
|
||||||
|
<select name="floor" class="form-select form-select-sm">
|
||||||
|
<option value="">{% trans %}Todos los pisos{% endtrans %}</option>
|
||||||
|
{% for f in available_floors %}
|
||||||
|
<option value="{{ f }}" {% if floor_filter == f|string %}selected{% endif %}>
|
||||||
|
{% if f == 0 %}{% trans %}Planta Baja{% endtrans %}{% else %}{% trans %}Piso{% endtrans %} {{ f }}{% endif %}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Shift Filter -->
|
||||||
|
<div class="col-lg-2 col-md-4">
|
||||||
|
<label class="form-label small fw-bold text-muted mb-1">
|
||||||
|
<i class="bi bi-brightness-high text-warning me-1"></i>{% trans %}Turno{% endtrans %}
|
||||||
|
</label>
|
||||||
|
<select name="shift" class="form-select form-select-sm">
|
||||||
|
<option value="">{% trans %}Todos los turnos{% endtrans %}</option>
|
||||||
|
<option value="Mañana" {% if shift_filter == 'Mañana' %}selected{% endif %}>{% trans %}Turno Mañana{% endtrans %}</option>
|
||||||
|
<option value="Tarde" {% if shift_filter == 'Tarde' %}selected{% endif %}>{% trans %}Turno Tarde{% endtrans %}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date Range -->
|
||||||
|
<div class="col-lg-3 col-md-4">
|
||||||
|
<label class="form-label small fw-bold text-muted mb-1">
|
||||||
|
<i class="bi bi-calendar-event me-1"></i>{% trans %}Desde Fecha{% endtrans %}
|
||||||
|
</label>
|
||||||
|
<input type="date" name="date_from" class="form-control form-control-sm" value="{{ date_from or '' }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 2: Hide Virtual, Submit, Clear -->
|
||||||
|
<div class="col-lg-6 col-md-6 pt-2">
|
||||||
|
<div class="form-check form-switch pt-1">
|
||||||
|
<input class="form-check-input" type="checkbox" role="switch" name="hide_virtual" value="1" id="hideVirtualList" {% if hide_virtual %}checked{% endif %}>
|
||||||
|
<label class="form-check-label fw-semibold small text-dark" for="hideVirtualList">
|
||||||
|
<i class="bi bi-laptop me-1 text-primary"></i>{% trans %}Ocultar Aulas Virtuales{% endtrans %}
|
||||||
|
<span class="badge bg-secondary-subtle text-secondary border ms-1" style="font-size: 0.72rem;">{% trans %}Capacidad ilimitada{% endtrans %}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-6 col-md-6 pt-2 text-md-end d-flex justify-content-md-end gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm px-3">
|
||||||
|
<i class="bi bi-funnel me-1"></i>{% trans %}Filtrar{% endtrans %}
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary btn-sm px-3">
|
||||||
|
<i class="bi bi-arrow-counterclockwise me-1"></i>{% trans %}Limpiar{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reservations Table -->
|
||||||
|
{% if reservations and reservations.items %}
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0 h6 fw-bold">
|
||||||
|
<i class="bi bi-table text-primary me-2"></i>{% trans %}Registros Encontrados{% endtrans %}
|
||||||
|
<span class="badge bg-secondary-subtle text-secondary border ms-2">{{ reservations.total }} {% trans %}reservas{% endtrans %}</span>
|
||||||
|
</h5>
|
||||||
|
<small class="text-muted">{% trans %}Página{% endtrans %} {{ reservations.page }} {% trans %}de{% endtrans %} {{ reservations.pages }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light small text-uppercase">
|
||||||
|
<tr>
|
||||||
|
<th class="ps-3">{% trans %}Fecha y Horario{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Aula / Piso{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Asignatura / Código{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Turno / Comisión{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Capacidad{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Estado{% endtrans %}</th>
|
||||||
|
<th class="text-end pe-3">{% trans %}Acciones{% endtrans %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for reservation in reservations.items %}
|
||||||
|
<tr>
|
||||||
|
<td class="ps-3">
|
||||||
|
<div class="fw-bold">{{ reservation.start_time.strftime('%d/%m/%Y') }}</div>
|
||||||
|
<small class="text-muted font-monospace">{{ reservation.start_time.strftime('%H:%M') }} - {{ reservation.end_time.strftime('%H:%M') }} hs</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-secondary-subtle text-secondary border font-monospace">
|
||||||
|
{{ reservation.classroom.code if reservation.classroom else 'Sin Aula' }}
|
||||||
|
</span>
|
||||||
|
<small class="text-muted d-block">
|
||||||
|
{% if reservation.classroom and reservation.classroom.floor == 0 %}PB{% elif reservation.classroom %}Piso {{ reservation.classroom.floor }}{% endif %}
|
||||||
|
{% if reservation.classroom and reservation.classroom.building %} · {{ reservation.classroom.building }}{% endif %}
|
||||||
|
</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="fw-bold text-primary">{{ reservation.subject_name }}</div>
|
||||||
|
<small class="badge bg-secondary-subtle text-secondary font-monospace">{{ reservation.subject_code }}</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-info-subtle text-info border me-1">
|
||||||
|
{{ reservation.shift or 'Tarde' }}
|
||||||
|
</span>
|
||||||
|
<small class="text-muted">{{ reservation.commission.code if reservation.commission else 'C1' }}</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="small">{{ reservation.expected_attendees }} / {{ reservation.classroom.capacity if reservation.classroom else '-' }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if reservation.status == 'CONFIRMED' or reservation.status.value == 'CONFIRMED' %}
|
||||||
|
<span class="badge bg-success-subtle text-success border border-success-subtle">
|
||||||
|
<i class="bi bi-check-circle me-1"></i>{% trans %}Confirmada{% endtrans %}
|
||||||
|
</span>
|
||||||
|
{% elif reservation.status == 'PENDING' or reservation.status.value == 'PENDING' %}
|
||||||
|
<span class="badge bg-warning-subtle text-warning border border-warning-subtle">
|
||||||
|
<i class="bi bi-clock me-1"></i>{% trans %}Pendiente{% endtrans %}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger-subtle text-danger border border-danger-subtle">
|
||||||
|
{% trans %}Cancelada{% endtrans %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-end pe-3">
|
||||||
|
<a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}"
|
||||||
|
class="btn btn-sm btn-outline-secondary py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
{% if current_user.has_permission('reservations', 'read_write') and (reservation.status == 'PENDING' or reservation.status.value == 'PENDING') %}
|
||||||
|
<a href="{{ url_for('schedule.confirm_reservation', id=reservation.id) }}"
|
||||||
|
class="btn btn-sm btn-outline-success py-0 px-2 ms-1" title="{% trans %}Confirmar{% endtrans %}">
|
||||||
|
<i class="bi bi-check-lg"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if reservations.pages > 1 %}
|
||||||
|
<div class="card-footer bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
|
||||||
|
<small class="text-muted">{% trans %}Mostrando 20 registros por página{% endtrans %}</small>
|
||||||
|
<nav>
|
||||||
|
<ul class="pagination pagination-sm mb-0">
|
||||||
|
{% if reservations.has_prev %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=reservations.prev_num, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, hide_virtual='1' if hide_virtual else '', search=search) }}">
|
||||||
|
<i class="bi bi-chevron-left"></i>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for p in reservations.iter_pages(left_edge=2, left_current=2, right_current=2, right_edge=2) %}
|
||||||
|
{% if p %}
|
||||||
|
{% if p == reservations.page %}
|
||||||
|
<li class="page-item active"><span class="page-link">{{ p }}</span></li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=p, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, hide_virtual='1' if hide_virtual else '', search=search) }}">{{ p }}</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled"><span class="page-link">…</span></li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if reservations.has_next %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=reservations.next_num, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, hide_virtual='1' if hide_virtual else '', search=search) }}">
|
||||||
|
<i class="bi bi-chevron-right"></i>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body text-center py-5">
|
||||||
|
<i class="bi bi-calendar-x text-muted fs-1 d-block mb-3"></i>
|
||||||
|
<h5 class="fw-bold">{% trans %}No se encontraron reservas{% endtrans %}</h5>
|
||||||
|
<p class="text-muted small mb-3">{% trans %}No hay registros que coincidan con los criterios de búsqueda o filtros seleccionados.{% endtrans %}</p>
|
||||||
|
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-primary btn-sm">
|
||||||
|
<i class="bi bi-arrow-counterclockwise me-1"></i>{% trans %}Restablecer Todos los Filtros{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,29 +1,29 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Cartelera de Hoy - Edu-Space Admin{% endblock %}
|
{% block title %}{% trans %}Cartelera de Hoy{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container-fluid px-0">
|
<div class="container-fluid pt-5 mt-4">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="row mb-3 align-items-center">
|
<div class="row mb-4 align-items-center">
|
||||||
<div class="col-md-7">
|
<div class="col-md-7">
|
||||||
<h1 class="h3 mb-1">
|
<h1 class="h3 mb-1">
|
||||||
<i class="bi bi-calendar-day text-info me-2"></i>Cartelera del Día
|
<i class="bi bi-calendar-day text-info me-2"></i>{% trans %}Cartelera del Día{% endtrans %}
|
||||||
<small class="text-muted ms-2 fs-6">{{ format_date(today, format='full')|capitalize }}</small>
|
<small class="text-muted ms-2 fs-6">{{ format_date(today, format='full')|capitalize }}</small>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-muted small mb-0">Cronograma de clases y asignación de aulas activas para el día de hoy</p>
|
<p class="text-muted small mb-0">{% trans %}Cronograma de clases y asignación de aulas activas para el día de hoy{% endtrans %}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-5 text-md-end mt-3 mt-md-0">
|
<div class="col-md-5 text-md-end mt-3 mt-md-0">
|
||||||
{% if user.has_permission('reservations', 'read_write') %}
|
{% if current_user.has_permission('reservations', 'read_write') %}
|
||||||
<a href="/schedule/add_reservation" class="btn btn-success me-2">
|
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-success me-2">
|
||||||
<i class="bi bi-plus-circle me-1"></i>Nueva Reserva
|
<i class="bi bi-plus-circle me-1"></i>{% trans %}Nueva Reserva{% endtrans %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/schedule/calendar_view" class="btn btn-outline-primary me-2">
|
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary me-2">
|
||||||
<i class="bi bi-calendar3 me-1"></i>Ver Calendario
|
<i class="bi bi-calendar3 me-1"></i>{% trans %}Ver Calendario{% endtrans %}
|
||||||
</a>
|
</a>
|
||||||
<a href="/schedule/list_reservations" class="btn btn-outline-secondary">
|
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
|
||||||
<i class="bi bi-list me-1"></i>Todas las Reservas
|
<i class="bi bi-list me-1"></i>{% trans %}Todas las Reservas{% endtrans %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
<div class="card border-0 shadow-sm h-100">
|
<div class="card border-0 shadow-sm h-100">
|
||||||
<div class="card-body p-3 d-flex justify-content-between align-items-center">
|
<div class="card-body p-3 d-flex justify-content-between align-items-center">
|
||||||
<div>
|
<div>
|
||||||
<span class="text-muted small fw-semibold text-uppercase">Total de Reservas</span>
|
<span class="text-muted small fw-semibold text-uppercase">{% trans %}Total de Reservas{% endtrans %}</span>
|
||||||
<h2 class="mb-0 fw-bold mt-1 text-info">{{ stats.total_reservations }}</h2>
|
<h2 class="mb-0 fw-bold mt-1 text-info">{{ stats.total_reservations }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-info bg-opacity-10 rounded-circle p-3 text-info">
|
<div class="bg-info bg-opacity-10 rounded-circle p-3 text-info">
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
<div class="card border-0 shadow-sm h-100">
|
<div class="card border-0 shadow-sm h-100">
|
||||||
<div class="card-body p-3 d-flex justify-content-between align-items-center">
|
<div class="card-body p-3 d-flex justify-content-between align-items-center">
|
||||||
<div>
|
<div>
|
||||||
<span class="text-muted small fw-semibold text-uppercase">Confirmadas</span>
|
<span class="text-muted small fw-semibold text-uppercase">{% trans %}Confirmadas{% endtrans %}</span>
|
||||||
<h2 class="mb-0 fw-bold mt-1 text-success">{{ stats.confirmed }}</h2>
|
<h2 class="mb-0 fw-bold mt-1 text-success">{{ stats.confirmed }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-success bg-opacity-10 rounded-circle p-3 text-success">
|
<div class="bg-success bg-opacity-10 rounded-circle p-3 text-success">
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
<div class="card border-0 shadow-sm h-100">
|
<div class="card border-0 shadow-sm h-100">
|
||||||
<div class="card-body p-3 d-flex justify-content-between align-items-center">
|
<div class="card-body p-3 d-flex justify-content-between align-items-center">
|
||||||
<div>
|
<div>
|
||||||
<span class="text-muted small fw-semibold text-uppercase">Pendientes</span>
|
<span class="text-muted small fw-semibold text-uppercase">{% trans %}Pendientes{% endtrans %}</span>
|
||||||
<h2 class="mb-0 fw-bold mt-1 text-warning">{{ stats.pending }}</h2>
|
<h2 class="mb-0 fw-bold mt-1 text-warning">{{ stats.pending }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-warning bg-opacity-10 rounded-circle p-3 text-warning">
|
<div class="bg-warning bg-opacity-10 rounded-circle p-3 text-warning">
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
<div class="card border-0 shadow-sm h-100">
|
<div class="card border-0 shadow-sm h-100">
|
||||||
<div class="card-body p-3 d-flex justify-content-between align-items-center">
|
<div class="card-body p-3 d-flex justify-content-between align-items-center">
|
||||||
<div>
|
<div>
|
||||||
<span class="text-muted small fw-semibold text-uppercase">Aulas Ocupadas</span>
|
<span class="text-muted small fw-semibold text-uppercase">{% trans %}Aulas Ocupadas{% endtrans %}</span>
|
||||||
<h2 class="mb-0 fw-bold mt-1 text-primary">{{ stats.occupied_classrooms }}/{{ stats.total_classrooms }}</h2>
|
<h2 class="mb-0 fw-bold mt-1 text-primary">{{ stats.occupied_classrooms }}/{{ stats.total_classrooms }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-primary bg-opacity-10 rounded-circle p-3 text-primary">
|
<div class="bg-primary bg-opacity-10 rounded-circle p-3 text-primary">
|
||||||
@@ -87,17 +87,17 @@
|
|||||||
<!-- Filters Toolbar (Piso, Turno y Ocultar Virtuales) -->
|
<!-- Filters Toolbar (Piso, Turno y Ocultar Virtuales) -->
|
||||||
<div class="card border-0 shadow-sm mb-4">
|
<div class="card border-0 shadow-sm mb-4">
|
||||||
<div class="card-body p-3">
|
<div class="card-body p-3">
|
||||||
<form method="GET" action="/schedule/today_schedule" class="row g-2 align-items-center">
|
<form method="GET" action="{{ url_for('schedule.today_schedule') }}" class="row g-2 align-items-center">
|
||||||
<!-- Filter by Floor -->
|
<!-- Filter by Floor -->
|
||||||
<div class="col-lg-3 col-md-4">
|
<div class="col-lg-3 col-md-4">
|
||||||
<label for="floorSelect" class="form-label small fw-bold text-muted mb-1">
|
<label for="floorSelect" class="form-label small fw-bold text-muted mb-1">
|
||||||
<i class="bi bi-layers text-secondary me-1"></i>Piso / Planta
|
<i class="bi bi-layers text-secondary me-1"></i>{% trans %}Piso / Planta{% endtrans %}
|
||||||
</label>
|
</label>
|
||||||
<select name="floor" id="floorSelect" class="form-select form-select-sm">
|
<select name="floor" id="floorSelect" class="form-select form-select-sm">
|
||||||
<option value="">Todos los pisos</option>
|
<option value="">{% trans %}Todos los pisos{% endtrans %}</option>
|
||||||
{% for f in available_floors %}
|
{% for f in available_floors %}
|
||||||
<option value="{{ f }}" {% if floor_filter == f|string %}selected{% endif %}>
|
<option value="{{ f }}" {% if floor_filter == f|string %}selected{% endif %}>
|
||||||
{% if f == 0 %}Planta Baja (Piso 0){% else %}Piso {{ f }}{% endif %}
|
{% if f == 0 %}{% trans %}Planta Baja (Piso 0){% endtrans %}{% else %}{% trans %}Piso{% endtrans %} {{ f }}{% endif %}
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
@@ -106,12 +106,12 @@
|
|||||||
<!-- Filter by Turno -->
|
<!-- Filter by Turno -->
|
||||||
<div class="col-lg-3 col-md-4">
|
<div class="col-lg-3 col-md-4">
|
||||||
<label for="shiftSelect" class="form-label small fw-bold text-muted mb-1">
|
<label for="shiftSelect" class="form-label small fw-bold text-muted mb-1">
|
||||||
<i class="bi bi-brightness-high text-warning me-1"></i>Turno
|
<i class="bi bi-brightness-high text-warning me-1"></i>{% trans %}Turno{% endtrans %}
|
||||||
</label>
|
</label>
|
||||||
<select name="shift" id="shiftSelect" class="form-select form-select-sm">
|
<select name="shift" id="shiftSelect" class="form-select form-select-sm">
|
||||||
<option value="">Todos los turnos</option>
|
<option value="">{% trans %}Todos los turnos{% endtrans %}</option>
|
||||||
<option value="Mañana" {% if shift_filter == 'Mañana' %}selected{% endif %}>Turno Mañana</option>
|
<option value="Mañana" {% if shift_filter == 'Mañana' %}selected{% endif %}>{% trans %}Turno Mañana{% endtrans %}</option>
|
||||||
<option value="Tarde" {% if shift_filter == 'Tarde' %}selected{% endif %}>Turno Tarde / Vespertino</option>
|
<option value="Tarde" {% if shift_filter == 'Tarde' %}selected{% endif %}>{% trans %}Turno Tarde / Vespertino{% endtrans %}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -120,8 +120,8 @@
|
|||||||
<div class="form-check form-switch pt-1">
|
<div class="form-check form-switch pt-1">
|
||||||
<input class="form-check-input" type="checkbox" role="switch" name="hide_virtual" value="1" id="hideVirtualToday" {% if hide_virtual %}checked{% endif %} onchange="this.form.submit()">
|
<input class="form-check-input" type="checkbox" role="switch" name="hide_virtual" value="1" id="hideVirtualToday" {% if hide_virtual %}checked{% endif %} onchange="this.form.submit()">
|
||||||
<label class="form-check-label fw-semibold small text-dark" for="hideVirtualToday">
|
<label class="form-check-label fw-semibold small text-dark" for="hideVirtualToday">
|
||||||
<i class="bi bi-laptop me-1 text-primary"></i>Ocultar Aulas Virtuales
|
<i class="bi bi-laptop me-1 text-primary"></i>{% trans %}Ocultar Aulas Virtuales{% endtrans %}
|
||||||
<span class="badge bg-secondary-subtle text-secondary border ms-1" style="font-size: 0.72rem;">Capacidad ilimitada</span>
|
<span class="badge bg-secondary-subtle text-secondary border ms-1" style="font-size: 0.72rem;">{% trans %}Capacidad ilimitada{% endtrans %}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,9 +129,9 @@
|
|||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="col-lg-2 col-md-12 pt-md-3 text-lg-end d-flex gap-2">
|
<div class="col-lg-2 col-md-12 pt-md-3 text-lg-end d-flex gap-2">
|
||||||
<button type="submit" class="btn btn-primary btn-sm flex-fill">
|
<button type="submit" class="btn btn-primary btn-sm flex-fill">
|
||||||
<i class="bi bi-funnel me-1"></i>Filtrar
|
<i class="bi bi-funnel me-1"></i>{% trans %}Filtrar{% endtrans %}
|
||||||
</button>
|
</button>
|
||||||
<a href="/schedule/today_schedule" class="btn btn-outline-secondary btn-sm" title="Limpiar Filtros">
|
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-secondary btn-sm" title="{% trans %}Limpiar Filtros{% endtrans %}">
|
||||||
<i class="bi bi-arrow-counterclockwise"></i>
|
<i class="bi bi-arrow-counterclockwise"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,14 +147,14 @@
|
|||||||
<div class="card-header border-0 py-3 bg-body-tertiary">
|
<div class="card-header border-0 py-3 bg-body-tertiary">
|
||||||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
|
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
<h5 class="mb-0 h6 fw-bold">
|
<h5 class="mb-0 h6 fw-bold">
|
||||||
<i class="bi bi-clock-history text-info me-2"></i>Vista Cronológica por Horario
|
<i class="bi bi-clock-history text-info me-2"></i>{% trans %}Vista Cronológica por Horario{% endtrans %}
|
||||||
</h5>
|
</h5>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button class="btn btn-sm btn-outline-primary" id="refresh-btn">
|
<button class="btn btn-sm btn-outline-primary" id="refresh-btn">
|
||||||
<i class="bi bi-arrow-clockwise me-1"></i>Actualizar
|
<i class="bi bi-arrow-clockwise me-1"></i>{% trans %}Actualizar{% endtrans %}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-secondary" id="toggle-view-btn">
|
<button class="btn btn-sm btn-outline-secondary" id="toggle-view-btn">
|
||||||
<i class="bi bi-grid me-1"></i>Vista en Tabla
|
<i class="bi bi-grid me-1"></i>{% trans %}Vista en Tabla{% endtrans %}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -166,7 +166,7 @@
|
|||||||
<div class="row border-bottom py-3 align-items-start {% if time_blocks[hour] %}bg-light-subtle{% endif %}">
|
<div class="row border-bottom py-3 align-items-start {% if time_blocks[hour] %}bg-light-subtle{% endif %}">
|
||||||
<div class="col-md-2 col-3">
|
<div class="col-md-2 col-3">
|
||||||
<div class="text-center p-2 rounded bg-body-tertiary border">
|
<div class="text-center p-2 rounded bg-body-tertiary border">
|
||||||
<strong class="h5 text-primary mb-0 font-monospace">{% if hour < 10 %}0{% endif %}{{ hour }}:00</strong>
|
<strong class="h5 text-primary mb-0 font-monospace">{{ "%02d:00"|format(hour) }}</strong>
|
||||||
<div class="small text-muted font-monospace">{{ hour+1 }}:00 hs</div>
|
<div class="small text-muted font-monospace">{{ hour+1 }}:00 hs</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -191,9 +191,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="badge bg-{% if item.reservation.status == 'CONFIRMED' or item.reservation.status.value == 'CONFIRMED' %}success-subtle text-success border border-success-subtle{% else %}warning-subtle text-warning border border-warning-subtle{% endif %}">
|
<span class="badge bg-{% if item.reservation.status == 'CONFIRMED' or item.reservation.status.value == 'CONFIRMED' %}success-subtle text-success border border-success-subtle{% else %}warning-subtle text-warning border border-warning-subtle{% endif %}">
|
||||||
{% if item.reservation.status == 'CONFIRMED' or item.reservation.status.value == 'CONFIRMED' %}
|
{% if item.reservation.status == 'CONFIRMED' or item.reservation.status.value == 'CONFIRMED' %}
|
||||||
Confirmada
|
{% trans %}Confirmada{% endtrans %}
|
||||||
{% else %}
|
{% else %}
|
||||||
Pendiente
|
{% trans %}Pendiente{% endtrans %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -206,29 +206,17 @@
|
|||||||
<i class="bi bi-layers me-1"></i>{% if item.classroom.floor == 0 %}PB{% else %}Piso {{ item.classroom.floor }}{% endif %}
|
<i class="bi bi-layers me-1"></i>{% if item.classroom.floor == 0 %}PB{% else %}Piso {{ item.classroom.floor }}{% endif %}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<i class="bi bi-clock me-1 text-warning"></i>{{ item.reservation.start_time }} - {{ item.reservation.end_time }} hs
|
<i class="bi bi-clock me-1 text-warning"></i>{{ item.reservation.start_time.strftime('%H:%M') }} - {{ item.reservation.end_time.strftime('%H:%M') }} hs
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center pt-2 border-top">
|
<div class="d-flex justify-content-between align-items-center pt-2 border-top">
|
||||||
<span class="small text-muted">
|
<span class="small text-muted">
|
||||||
<i class="bi bi-people me-1"></i>{{ item.reservation.expected_attendees }} estudiantes
|
<i class="bi bi-people me-1"></i>{{ item.reservation.expected_attendees }} {% trans %}estudiantes{% endtrans %}
|
||||||
</span>
|
</span>
|
||||||
<div class="d-flex gap-1">
|
<a href="{{ url_for('schedule.view_reservation', id=item.reservation.id) }}" class="btn btn-sm btn-outline-secondary py-0 px-2">
|
||||||
{% if item.reservation.effective_virtual_link %}
|
<i class="bi bi-eye me-1"></i>{% trans %}Detalles{% endtrans %}
|
||||||
<a href="{{ item.reservation.effective_virtual_link }}" target="_blank" class="btn btn-sm btn-outline-success py-0 px-2 d-inline-flex align-items-center" title="Unirse a Videollamada (Zoom / Meet)">
|
|
||||||
<i class="bi bi-camera-video me-1"></i>Zoom/Meet
|
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
|
||||||
{% if user.can_edit_reservation(item.reservation) %}
|
|
||||||
<a href="/schedule/edit_reservation?id={{ item.reservation.id }}" class="btn btn-sm btn-outline-warning py-0 px-2" title="Modificar Reserva">
|
|
||||||
<i class="bi bi-pencil"></i>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
<a href="/schedule/view_reservation?id={{ item.reservation.id }}" class="btn btn-sm btn-outline-secondary py-0 px-2" title="Ver Ficha Detallada">
|
|
||||||
<i class="bi bi-eye me-1"></i>Detalles
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -237,7 +225,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-muted py-2 small ps-2">
|
<div class="text-muted py-2 small ps-2">
|
||||||
<i class="bi bi-dash-circle me-1"></i>Sin clases programadas en este horario
|
<i class="bi bi-dash-circle me-1"></i>{% trans %}Sin clases programadas en este horario{% endtrans %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -251,20 +239,20 @@
|
|||||||
<table class="table table-hover align-middle mb-0">
|
<table class="table table-hover align-middle mb-0">
|
||||||
<thead class="table-light small text-uppercase">
|
<thead class="table-light small text-uppercase">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="ps-3">Horario</th>
|
<th class="ps-3">{% trans %}Horario{% endtrans %}</th>
|
||||||
<th>Aula / Piso</th>
|
<th>{% trans %}Aula / Piso{% endtrans %}</th>
|
||||||
<th>Asignatura / Código</th>
|
<th>{% trans %}Asignatura / Código{% endtrans %}</th>
|
||||||
<th>Turno</th>
|
<th>{% trans %}Turno{% endtrans %}</th>
|
||||||
<th>Asistentes</th>
|
<th>{% trans %}Asistentes{% endtrans %}</th>
|
||||||
<th>Estado</th>
|
<th>{% trans %}Estado{% endtrans %}</th>
|
||||||
<th class="text-end pe-3">Acciones</th>
|
<th class="text-end pe-3">{% trans %}Acciones{% endtrans %}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for reservation in reservations %}
|
{% for reservation in reservations %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="ps-3 font-monospace fw-bold">
|
<td class="ps-3 font-monospace fw-bold">
|
||||||
{{ reservation.start_time }} - {{ reservation.end_time }} hs
|
{{ reservation.start_time.strftime('%H:%M') }} - {{ reservation.end_time.strftime('%H:%M') }} hs
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge bg-secondary-subtle text-secondary font-monospace border">
|
<span class="badge bg-secondary-subtle text-secondary font-monospace border">
|
||||||
@@ -289,29 +277,17 @@
|
|||||||
<td>
|
<td>
|
||||||
{% if reservation.status == 'CONFIRMED' or reservation.status.value == 'CONFIRMED' %}
|
{% if reservation.status == 'CONFIRMED' or reservation.status.value == 'CONFIRMED' %}
|
||||||
<span class="badge bg-success-subtle text-success border border-success-subtle">
|
<span class="badge bg-success-subtle text-success border border-success-subtle">
|
||||||
<i class="bi bi-check-circle me-1"></i>Confirmada
|
<i class="bi bi-check-circle me-1"></i>{% trans %}Confirmada{% endtrans %}
|
||||||
</span>
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-warning-subtle text-warning border border-warning-subtle">
|
<span class="badge bg-warning-subtle text-warning border border-warning-subtle">
|
||||||
<i class="bi bi-clock me-1"></i>Pendiente
|
<i class="bi bi-clock me-1"></i>{% trans %}Pendiente{% endtrans %}
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end pe-3">
|
<td class="text-end pe-3">
|
||||||
{% if reservation.effective_virtual_link %}
|
<a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}"
|
||||||
<a href="{{ reservation.effective_virtual_link }}" target="_blank"
|
class="btn btn-sm btn-outline-secondary py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}">
|
||||||
class="btn btn-sm btn-outline-success py-0 px-2 me-1" title="Unirse a Clase Virtual (Zoom/Meet)">
|
|
||||||
<i class="bi bi-camera-video"></i>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if user.can_edit_reservation(reservation) %}
|
|
||||||
<a href="/schedule/edit_reservation?id={{ reservation.id }}"
|
|
||||||
class="btn btn-sm btn-outline-warning py-0 px-2 me-1" title="Modificar Reserva">
|
|
||||||
<i class="bi bi-pencil"></i>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
<a href="/schedule/view_reservation?id={{ reservation.id }}"
|
|
||||||
class="btn btn-sm btn-outline-secondary py-0 px-2" title="Ver Ficha">
|
|
||||||
<i class="bi bi-eye"></i>
|
<i class="bi bi-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
@@ -330,18 +306,18 @@
|
|||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="text-center py-5">
|
<div class="text-center py-5">
|
||||||
<i class="bi bi-calendar-x text-muted" style="font-size: 4rem;"></i>
|
<i class="bi bi-calendar-x text-muted" style="font-size: 4rem;"></i>
|
||||||
<h4 class="mt-3 text-muted">No hay reservas programadas para hoy</h4>
|
<h4 class="mt-3 text-muted">{% trans %}No hay reservas programadas para hoy{% endtrans %}</h4>
|
||||||
<p class="text-muted">
|
<p class="text-muted">
|
||||||
No se encontraron clases para los filtros seleccionados.
|
{% trans %}No se encontraron clases para los filtros seleccionados.{% endtrans %}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
{% if user.has_permission('reservations', 'read_write') %}
|
{% if current_user.has_permission('reservations', 'read_write') %}
|
||||||
<a href="/schedule/add_reservation" class="btn btn-success">
|
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-success">
|
||||||
<i class="bi bi-plus-circle me-1"></i>Crear Primera Reserva
|
<i class="bi bi-plus-circle me-1"></i>{% trans %}Crear Primera Reserva{% endtrans %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/schedule/calendar_view" class="btn btn-outline-primary ms-2">
|
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary ms-2">
|
||||||
<i class="bi bi-calendar-week me-1"></i>Ver Calendario Semanal
|
<i class="bi bi-calendar-week me-1"></i>{% trans %}Ver Calendario Semanal{% endtrans %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -363,11 +339,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
if (timelineView.style.display === 'none') {
|
if (timelineView.style.display === 'none') {
|
||||||
timelineView.style.display = 'block';
|
timelineView.style.display = 'block';
|
||||||
gridView.style.display = 'none';
|
gridView.style.display = 'none';
|
||||||
toggleBtn.innerHTML = '<i class="bi bi-grid me-1"></i>Vista en Tabla';
|
toggleBtn.innerHTML = '<i class="bi bi-grid me-1"></i>{% trans %}Vista en Tabla{% endtrans %}';
|
||||||
} else {
|
} else {
|
||||||
timelineView.style.display = 'none';
|
timelineView.style.display = 'none';
|
||||||
gridView.style.display = 'block';
|
gridView.style.display = 'block';
|
||||||
toggleBtn.innerHTML = '<i class="bi bi-clock-history me-1"></i>Vista Cronológica';
|
toggleBtn.innerHTML = '<i class="bi bi-clock-history me-1"></i>{% trans %}Vista Cronológica{% endtrans %}';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -10,14 +10,9 @@
|
|||||||
<h1 class="h3">
|
<h1 class="h3">
|
||||||
<i class="bi bi-calendar-check text-info"></i> Reservation Details
|
<i class="bi bi-calendar-check text-info"></i> Reservation Details
|
||||||
</h1>
|
</h1>
|
||||||
<div class="d-flex gap-2">
|
<div>
|
||||||
{% if user.can_edit_reservation(reservation) %}
|
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
|
||||||
<a href="/schedule/edit_reservation?id={{ reservation.id }}" class="btn btn-warning">
|
<i class="bi bi-arrow-left"></i> Back to List
|
||||||
<i class="bi bi-pencil-square me-1"></i>Modificar Reserva
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
<a href="/schedule/list_reservations" class="btn btn-outline-secondary">
|
|
||||||
<i class="bi bi-arrow-left me-1"></i>Back to List
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -60,14 +55,14 @@
|
|||||||
<p class="mb-0">
|
<p class="mb-0">
|
||||||
<i class="bi bi-clock"></i>
|
<i class="bi bi-clock"></i>
|
||||||
{{ format_date(reservation.start_time, format='long')|capitalize }}<br>
|
{{ format_date(reservation.start_time, format='long')|capitalize }}<br>
|
||||||
{{ reservation.start_time }} - {{ reservation.end_time }}
|
{{ reservation.start_time.strftime('%H:%M') }} - {{ reservation.end_time.strftime('%H:%M') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label class="text-muted small">Duration</label>
|
<label class="text-muted small">Duration</label>
|
||||||
<p class="mb-0">
|
<p class="mb-0">
|
||||||
<i class="bi bi-hourglass"></i>
|
<i class="bi bi-hourglass"></i>
|
||||||
{% set duration = reservation.duration or 120 %}
|
{% set duration = (reservation.end_time - reservation.start_time).total_seconds() / 60 %}
|
||||||
{{ (duration / 60) | round(1) }} hours
|
{{ (duration / 60) | round(1) }} hours
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,68 +86,33 @@
|
|||||||
|
|
||||||
<!-- Classroom Details -->
|
<!-- Classroom Details -->
|
||||||
<div class="card border-0 shadow-sm">
|
<div class="card border-0 shadow-sm">
|
||||||
<div class="card-header border-0 py-3 bg-body-tertiary">
|
<div class="card-header border-0 py-3">
|
||||||
<h5 class="mb-0 h6 fw-bold">
|
<h5 class="mb-0">
|
||||||
<i class="bi bi-geo-alt text-primary me-2"></i>Ubicación y Espacio Asignado
|
<i class="bi bi-building text-info"></i> Classroom Information
|
||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-4">
|
<div class="card-body">
|
||||||
{% if reservation.classroom %}
|
{% if reservation.classroom %}
|
||||||
<div class="row g-3 mb-3">
|
<div class="row">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4 mb-3">
|
||||||
<div class="p-3 rounded bg-body-tertiary border">
|
<label class="text-muted small">Classroom</label>
|
||||||
<small class="text-muted d-block mb-1">🏢 Edificio / Sede</small>
|
<p class="mb-0"><strong>{{ reservation.classroom.code_display }}</strong></p>
|
||||||
<h6 class="mb-0 fw-bold text-primary">{{ reservation.classroom.building_name }}</h6>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label class="text-muted small">Building</label>
|
||||||
|
<p class="mb-0">{{ reservation.classroom.building }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4 mb-3">
|
||||||
<div class="p-3 rounded bg-body-tertiary border">
|
<label class="text-muted small">Capacity</label>
|
||||||
<small class="text-muted d-block mb-1">🪜 Piso / Nivel</small>
|
<p class="mb-0">{{ reservation.classroom.capacity }} people</p>
|
||||||
<h6 class="mb-0 fw-bold text-secondary">
|
|
||||||
{% if reservation.classroom.is_virtual %}
|
|
||||||
<span class="badge bg-primary-subtle text-primary border">Campus Virtual</span>
|
|
||||||
{% else %}
|
|
||||||
{{ reservation.classroom.floor_display }}
|
|
||||||
{% endif %}
|
|
||||||
</h6>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="p-3 rounded bg-body-tertiary border">
|
|
||||||
<small class="text-muted d-block mb-1">🚪 Aula</small>
|
|
||||||
<h6 class="mb-0 fw-bold text-success">{{ reservation.classroom.code }}</h6>
|
|
||||||
<small class="text-muted">Capacidad: <strong>{{ reservation.classroom.capacity_display }}</strong></small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if reservation.classroom.description %}
|
{% if reservation.classroom.description %}
|
||||||
<div class="mt-2">
|
<div class="col-12">
|
||||||
<label class="text-muted small">Descripción del Aula</label>
|
<label class="text-muted small">Description</label>
|
||||||
<p class="mb-0 small">{{ reservation.classroom.description }}</p>
|
<p class="mb-0">{{ reservation.classroom.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Banner de Aula Virtual (Zoom, Meet, Teams) -->
|
|
||||||
{% if reservation.effective_virtual_link or (reservation.classroom and reservation.classroom.is_virtual) %}
|
|
||||||
<div class="mt-3 p-3 rounded bg-primary bg-opacity-10 border border-primary border-opacity-25 d-flex flex-column flex-sm-row justify-content-between align-items-sm-center gap-3">
|
|
||||||
<div>
|
|
||||||
<div class="fw-bold text-primary mb-1">
|
|
||||||
<i class="bi bi-camera-video me-1"></i>Aula Virtual / Videoconferencia
|
|
||||||
</div>
|
</div>
|
||||||
{% if reservation.effective_virtual_link %}
|
|
||||||
<small class="text-secondary text-break font-monospace d-block">{{ reservation.effective_virtual_link }}</small>
|
|
||||||
{% else %}
|
|
||||||
<small class="text-muted fst-italic">Modalidad remota sin enlace específico registrado</small>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% if reservation.effective_virtual_link %}
|
|
||||||
<a href="{{ reservation.effective_virtual_link }}" target="_blank" class="btn btn-success btn-sm px-3 text-nowrap">
|
|
||||||
<i class="bi bi-box-arrow-up-right me-1"></i>Unirse a Clase (Zoom / Meet)
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-muted mb-0">Classroom information not available</p>
|
<p class="text-muted mb-0">Classroom information not available</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -199,23 +159,15 @@
|
|||||||
<h3 class="mb-1">{{ reservation.expected_attendees }}</h3>
|
<h3 class="mb-1">{{ reservation.expected_attendees }}</h3>
|
||||||
<p class="text-muted small">Expected Attendees</p>
|
<p class="text-muted small">Expected Attendees</p>
|
||||||
{% if reservation.classroom %}
|
{% if reservation.classroom %}
|
||||||
{% if reservation.classroom.capacity and reservation.classroom.capacity > 0 %}
|
|
||||||
<div class="progress mt-3" style="height: 8px;">
|
<div class="progress mt-3" style="height: 8px;">
|
||||||
{% set percentage = (reservation.expected_attendees / reservation.classroom.capacity * 100) %}
|
{% set percentage = (reservation.expected_attendees / reservation.classroom.capacity * 100) %}
|
||||||
<div class="progress-bar bg-{% if percentage > 90 %}danger{% elif percentage > 75 %}warning{% else %}success{% endif %}"
|
<div class="progress-bar bg-{% if percentage > 90 %}danger{% elif percentage > 75 %}warning{% else %}success{% endif %}"
|
||||||
role="progressbar"
|
role="progressbar"
|
||||||
{{ ('style="width: ' ~ ([percentage|round(1), 100]|min|string) ~ '%;"') | safe }}></div>
|
style="{{ 'width: ' ~ (percentage|round(1)|string) ~ '%;' }}"></div>
|
||||||
</div>
|
</div>
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
{{ reservation.expected_attendees }} / {{ reservation.classroom.capacity }} capacidad
|
{{ reservation.expected_attendees }} / {{ reservation.classroom.capacity }} capacity
|
||||||
</small>
|
</small>
|
||||||
{% else %}
|
|
||||||
<div class="mt-2">
|
|
||||||
<span class="badge bg-primary-subtle text-primary border">
|
|
||||||
{% if reservation.classroom.is_virtual %}🌐 Campus Virtual (Sin límite físico){% else %}Capacidad no definida{% endif %}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -230,38 +182,23 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-grid gap-2">
|
<div class="d-grid gap-2">
|
||||||
{% if user.can_edit_reservation(reservation) %}
|
{% if reservation.status.value == 'PENDING' %}
|
||||||
<a href="/schedule/edit_reservation?id={{ reservation.id }}" class="btn btn-warning">
|
<form method="POST" action="{{ url_for('schedule.confirm_reservation', id=reservation.id) }}">
|
||||||
<i class="bi bi-pencil-square me-1"></i>Modificar Reserva
|
<button type="submit" class="btn btn-success" onclick="return confirm('Confirm this reservation?')">
|
||||||
</a>
|
<i class="bi bi-check-circle"></i> Confirm Reservation
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if reservation.status.value == 'PENDING' or reservation.status == 'PENDING' %}
|
|
||||||
<form method="POST" action="/schedule/confirm_reservation?id={{ reservation.id }}">
|
|
||||||
<input type="hidden" name="id" value="{{ reservation.id }}">
|
|
||||||
<button type="submit" class="btn btn-success" onclick="return confirm('¿Confirmar esta reserva?')">
|
|
||||||
<i class="bi bi-check-circle me-1"></i>Confirmar Reserva
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if reservation.can_be_cancelled() %}
|
{% if reservation.can_be_cancelled() %}
|
||||||
<form method="POST" action="/schedule/cancel_reservation?id={{ reservation.id }}">
|
<form method="POST" action="{{ url_for('schedule.cancel_reservation', id=reservation.id) }}">
|
||||||
<input type="hidden" name="id" value="{{ reservation.id }}">
|
<button type="submit" class="btn btn-danger" onclick="return confirm('Cancel this reservation?')">
|
||||||
<button type="submit" class="btn btn-outline-warning" onclick="return confirm('¿Cancelar esta reserva?')">
|
<i class="bi bi-x-circle"></i> Cancel Reservation
|
||||||
<i class="bi bi-x-circle me-1"></i>Cancelar Reserva
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form method="POST" action="/schedule/delete_reservation?id={{ reservation.id }}" onsubmit="return confirm('¿Está seguro de que desea eliminar definitivamente esta reserva? Esta acción no se puede deshacer.');">
|
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
|
||||||
<input type="hidden" name="id" value="{{ reservation.id }}">
|
|
||||||
<button type="submit" class="btn btn-outline-danger w-100">
|
|
||||||
<i class="bi bi-trash me-1"></i>Eliminar Reserva Definitivamente
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<a href="/schedule/list_reservations" class="btn btn-outline-secondary">
|
|
||||||
<i class="bi bi-list"></i> View All Reservations
|
<i class="bi bi-list"></i> View All Reservations
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -278,12 +215,12 @@
|
|||||||
<div class="row text-muted small">
|
<div class="row text-muted small">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<i class="bi bi-clock-history"></i>
|
<i class="bi bi-clock-history"></i>
|
||||||
Created: {{ reservation.created_at }}
|
Created: {{ reservation.created_at.strftime('%Y-%m-%d %H:%M:%S') }}
|
||||||
</div>
|
</div>
|
||||||
{% if reservation.updated_at %}
|
{% if reservation.updated_at %}
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<i class="bi bi-arrow-repeat"></i>
|
<i class="bi bi-arrow-repeat"></i>
|
||||||
Updated: {{ reservation.updated_at }}
|
Updated: {{ reservation.updated_at.strftime('%Y-%m-%d %H:%M:%S') }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||