40 Commits
Author SHA1 Message Date
Carlos Tello fa878cb0d5 docs: registrar creacion de rama rc y release v1.0.1 en changelog 2026-09-05 01:34:37 -03:00
Carlos Tello 566908f1de docs: documentar roadmap academico, propuesta mvc/jwt/security y actualizar changelog de sesion 2026-09-05 01:07:15 -03:00
Carlos Tello c748ae4075 feat(lxc): verificar e instalar git automáticamente al inicio de install.sh si no existe 2026-09-05 00:33:49 -03:00
Carlos Tello 6218657f36 fix(lxc): instalar sudo e implementar helper pg_cmd universal (sudo/runuser/su) para PostgreSQL 2026-09-04 23:56:37 -03:00
Carlos Tello 747d20ed38 fix(lxc): preservar directorio origen y configurar safe.directory global 2026-09-04 23:52:23 -03:00
Carlos Tello b64a771068 docs: registrar seccion 24 de auto-recuperacion de PostgreSQL en CHANGELOG_SESSION 2026-09-04 23:50:47 -03:00
Carlos Tello 340bf3aa8c fix(lxc): asegurar creación de cluster e inicialización robusta de PostgreSQL 15 en Debian 12 2026-09-04 23:50:20 -03:00
Carlos Tello 80f6018b6a fix(lxc): agregar safe.directory en install.sh para evitar error de git dubious ownership 2026-09-04 23:24:36 -03:00
Carlos Tello 4e49311208 fix(lxc): resolver 500 propagando permisos PostgreSQL universales y logging en journald 2026-09-04 23:15:02 -03:00
Carlos Tello b367b106bf fix: corregir traslado automatico de directorio a /opt con cp -a evitando Directory not empty 2026-09-04 23:05:33 -03:00
Carlos Tello 0cea3dca44 feat: hacer install.sh 100% automatico y desatendido usando config activa de PostgreSQL (.env) 2026-09-04 23:02:02 -03:00
Carlos Tello eea7adb1d0 feat: actualizar install.sh para Proxmox LXC con venv, snapshot classrooms_db.sql, backup_db.sh y fix linter en occupancy 2026-09-04 22:56:53 -03:00
carlostellocba e0c58409cd fix: resolve gunicorn app import and systemd permission issues 2026-09-04 22:20:34 -03:00
carlostellocba 9b6678724c chore: eliminar .env del control de versiones para evitar sobrescribir configuraciones locales 2026-09-04 22:11:54 -03:00
carlostellocba f462d57ad9 fix: sanitizar tildes en prints de init_db.py para consola LXC ASCII 2026-09-04 21:54:04 -03:00
carlostellocba 7ef2ba8a5b feat: soportar restauracion automatica desde classrooms_db.sql si existe en el directorio 2026-09-04 21:49:25 -03:00
carlostellocba 169aa45746 fix: sanitizar tildes en milestone.py para evitar errores UnicodeEncodeError 2026-09-04 21:43:44 -03:00
carlostellocba f31e2bf760 fix: sanitizar tildes y acentos en init_db.py para evitar errores UnicodeEncodeError al inicializar la base de datos 2026-09-04 21:41:57 -03:00
carlostellocba 63c9c6f935 fix: update SQLAlchemy for Python 3.13 compatibility 2026-09-04 21:30:12 -03:00
carlostellocba 5e91e131f0 fix: update psycopg2-binary to support Python 3.13 compilation 2026-09-04 21:25:57 -03:00
carlostellocba c7ce5e4e67 fix: rewrite install.sh con deteccion de Python y soporte Pillow en Python 3.13 2026-09-04 21:15:18 -03:00
carlostellocba 957f364cc3 Se corrigio install que fallaba al instalar python 2026-09-04 21:07:45 -03:00
carlostellocba 7cd70be274 Se actualizo README.md con informacion para el deploy 2026-09-04 21:03:02 -03:00
carlostellocba 5dcf3b75f1 Se crear el archivo install.sh para realizar el deploy de la app 2026-09-04 20:56:50 -03:00
Carlos Tello 7933523d7c feat: vista previa y modificacion de clases en salas virtuales y busqueda por codigo de materia 2026-09-03 20:36:20 -03:00
Carlos Tello df098e5116 docs: actualizar estructura del proyecto y modulos de gestion en README.md 2026-09-03 12:25:16 -03:00
Carlos Tello bbe8e220ae feat: modulo integral de gestion (8 pilares) y limpieza de breadcrumbs redundantes 2026-09-03 11:59:47 -03:00
Carlos Tello adb6ba729d feat: separacion de aulas fisicas y campus virtual a demanda con criterio cloud de concurrencia ilimitada por comision 2026-09-03 11:33:10 -03:00
Carlos Tello c9dc3d9208 feat: optimizacion zocalos aulas, metricas ocupacion dia/semana/mes, modificacion de reservas y permisos RBAC en gestion 2026-09-03 11:16:07 -03:00
Carlos Tello cde62c9b0a feat: fecha y horario en aula, fix division by zero, soporte enlace virtual y mejora contrastes comisiones 2026-09-03 10:12:25 -03:00
Carlos Tello 5f0476be79 fix(schedule): corregir casteo de filtro de piso para compatibilidad con VARCHAR en PostgreSQL 2026-09-03 05:28:10 -03:00
Carlos Tello 6ccc9e8c87 feat: refactorizar espacios fisicos desacoplando Edificio, Piso y Aula con selectores en cascada y filtros 2026-09-03 05:18:01 -03:00
Carlos Tello 5e0b905e55 fix: corregir repeticion del nombre de aula en detalles de reserva y selector 2026-09-03 04:42:29 -03:00
Carlos Tello f3a4b5875e feat: abm comisiones, hitos, links virtuales, eliminacion usuarios y roles bedelia y alumno 2026-09-03 04:34:54 -03:00
Carlos Tello 8809ff3410 feat: rediseno de calendario estilo Google Calendar y correccion de modal de roles 2026-09-03 04:10:36 -03:00
Carlos Tello 291e537574 feat: catalogo dinamico de tipificaciones de hitos, matriz RBAC y desacoplamiento en modal de eventos 2026-09-03 03:48:49 -03:00
Carlos Tello 0c867e0031 fix: restaurar acceso a Optimizador de Aulas (IA) en Accesos Rapidos y menu de cronograma 2026-09-03 03:17:15 -03:00
Carlos Tello 9521b86399 docs: actualizar README.md en espanol con documentacion completa de UniCABA 2026-09-03 03:12:32 -03:00
Carlos Tello 5cc33cc7a0 feat: gestion RBAC granular, importacion sheets, filtrado de aulas virtuales y pisos, capacidad infinita y traduccion 2026-09-03 03:08:49 -03:00
Carlos Tello d1d4be0428 feat(i18n & ux): auditoria, traduccion completa, correcciones ux y gestion de idioma en perfil 2026-09-02 23:45:13 -03:00
163 changed files with 27020 additions and 2622 deletions
-7
View File
@@ -1,7 +0,0 @@
FLASK_APP=app.py
FLASK_ENV=development
SECRET_KEY=your-secret-key-here-please-change-in-production
DATABASE_URL=postgresql://postgres:password@localhost:5432/classrooms_db
DB_USERNAME=postgres
DB_PASSWORD=password
API_BASE_URL=http://localhost:8080/api
+24
View File
@@ -0,0 +1,24 @@
# ==============================================================================
# Edu-Space Admin - Plantilla de Variables de Entorno
# Copie este archivo como .env y complete con valores de producción seguros
# ==============================================================================
# Entorno Flask
FLASK_APP=wsgi.py
FLASK_ENV=production
# Seguridad: Clave secreta para sesiones y protección CSRF
# Generar una clave aleatoria con: python -c 'import secrets; print(secrets.token_hex(32))'
SECRET_KEY=change-this-to-a-secure-random-secret-key-in-production
# Base de Datos PostgreSQL
DATABASE_URL=postgresql://postgres:password@localhost:5432/classrooms_db
DB_USERNAME=postgres
DB_PASSWORD=password
# Integración con API externa (Microservicio Spring Boot)
API_BASE_URL=http://localhost:8080/api
# Configuración de Internacionalización (Babel)
BABEL_DEFAULT_LOCALE=es
BABEL_DEFAULT_TIMEZONE=America/Argentina/Buenos_Aires
+3
View File
@@ -57,6 +57,9 @@ cover/
*.mo *.mo
*.pot *.pot
# Examples
example/
# Django stuff: # Django stuff:
*.log *.log
local_settings.py local_settings.py
+6
View File
@@ -0,0 +1,6 @@
{
"python.defaultInterpreterPath": "${workspaceFolder}\\venv\\Scripts\\python.exe",
"python.analysis.extraPaths": [
"${workspaceFolder}\\venv\\Lib\\site-packages"
]
}
+452
View File
@@ -0,0 +1,452 @@
# Resumen de Cambios - Sesión de Desarrollo (Rama `dev`)
**Fecha:** 2026-09-02 / 2026-09-03 / 2026-09-04
**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`).
---
## 16. Desacoplamiento de Hitos Académicos: Catálogo Dinámico de Tipificaciones y Matriz RBAC
* **Modelos de Datos ([app/models/milestone.py](file:///c:/Workspace/admin-edu-space/app/models/milestone.py), [app/models/__init__.py](file:///c:/Workspace/admin-edu-space/app/models/__init__.py)):**
* `MilestoneType`: Catálogo dinámico y administrable con `id`, `name`, `code` (slug único), `color` (hexadecimal), `description`, `sort_order` e `is_active`.
* `AcademicMilestone`: Relacionado mediante clave foránea `milestone_type_id` a `MilestoneType` en lugar de strings estáticos.
* Sembrado de 12 categorías iniciales predeterminadas (`Parcial 1`, `Parcial 2`, `TP 1`, `TP 2`, `TP 3`, `Recu. P1`, `Recu. P2`, `Recu. TP 1`, `Recu. TP 2`, `Recu. TP 3`, `Final`, `Otro`).
* **Matriz de Permisos RBAC ([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 agregó el módulo `milestone_types` a `SYSTEM_MODULES` cubriendo los 9 módulos de la plataforma.
* Niveles asignados: `Admin` (RW), `Docente` (R), `Operador` (RW), `Consulta` (R).
* **Vistas y ABM en Management ([app/routes/admin.py](file:///c:/Workspace/admin-edu-space/app/routes/admin.py), [app/templates/admin/milestone_types/list.html](file:///c:/Workspace/admin-edu-space/app/templates/admin/milestone_types/list.html)):**
* Se añadió el ítem de navegación **"Tipificaciones"** al menú desplegable *Management*.
* Tabla completa con badges de color, slugs, switches interactivos de activación/desactivación y modales para dar de alta y editar tipificaciones.
* **API y Modal del Docente ([app/routes/main.py](file:///c:/Workspace/admin-edu-space/app/routes/main.py), [example/aulas-unicaba-mvp/...](file:///c:/Workspace/admin-edu-space/example/aulas-unicaba-mvp/src/app/components/evento-modal/evento-modal.component.html)):**
* Se expuso el endpoint `GET /api/milestone-types` para consumo desacoplado.
* Se actualizó el modal de eventos académicos (`evento-modal.component.html` y `.ts`) desacoplando las opciones estáticas y cargando dinámicamente el catálogo de tipificaciones activas con fallback automático para funcionamiento offline.
---
## 17. Rediseño de Calendario Estilo Google Calendar y Corrección Visual en Eliminación de Roles
* **Manejo de Eventos Simultáneos y Densidad Visual ([app/templates/schedule/calendar.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/calendar.html)):**
* Se configuró FullCalendar con `eventMaxStack: 3`, evitando que decenas de reservas concurrentes reduzcan las columnas a franjas milimétricas del 3%.
* Se habilitó `dayMaxEvents: 3`, `dayMaxEventRows: true` y `moreLinkClick: 'popover'` nativo de Google Calendar con enlaces "+X más".
* Se fijaron `slotMinTime: "07:00:00"`, `slotMaxTime: "23:00:00"`, `slotDuration: "00:30:00"`, `slotLabelInterval: "01:00:00"` y `nowIndicator: true` con la línea roja indicadora de hora actual.
* **Diseño de Tarjetas de Evento Píldora (`.gc-event-pill` / `.gc-month-pill`):**
* Estilo Google Calendar con barra lateral de color (magenta UniCABA `#B43E8E`, ámbar `#F59E0B` para pendientes, índigo `#6366F1` para aulas virtuales).
* Encabezado compacto con hora de inicio y badge de aula (`bi bi-door-open` o `bi bi-camera-video` con fondo suave para `VIRTUAL`).
* Tipografía en `0.78rem` con elipsis para evitar desbordes y sombras sutiles.
* Compatibilidad completa con modo oscuro (`[data-bs-theme="dark"]`).
* **Modal Flotante Interactivo al Clic (`#eventPreviewModal`):**
* Reemplazo de la navegación directa por un popover flotante estilizado como Google Calendar.
* Banner artístico superior con accesos rápidos: editar (✏️), eliminar/cancelar (🗑️) y cerrar (✕).
* Fila principal con indicador cuadrado de color, título completo, código de materia y badge de estado/turno.
* Lista con íconos: 🕒 horario y fecha completa, 📍 aula/piso/sede (o enlace directo al Campus Virtual), 👤 carrera/comisión/docente, 📝 hito o notas.
* Botones de acción inferior: "Ver Ficha Completa" y "Cerrar".
* **Corrección de Modal de Eliminación de Roles ([app/templates/admin/roles/list.html](file:///c:/Workspace/admin-edu-space/app/templates/admin/roles/list.html)):**
* Se trasladó el modal de confirmación de eliminación `#deleteRoleModal` fuera de la celda `<td>` y de la tabla `<table>`, situándolo a nivel raíz de la plantilla.
* Se corrigió el error visual donde el cuadro emergente temblaba, saltaba o se desplazaba por el recálculo de padding/scrollbar de Bootstrap en la celda de la tabla.
---
## 18. ABM de Comisiones con Hitos y Aula Virtual, Eliminación de Usuarios y Renombrado de Roles (Bedelía y Alumno)
* **Renombrado de Roles Institucionales ([init_db.py](file:///c:/Workspace/admin-edu-space/init_db.py), base de datos PostgreSQL):**
* `Operador` pasó a denominarse **`Bedelía`** con foco en la gestión operativa de aulas, sedes, reservas, comisiones, importación y cartelera.
* `Consulta` pasó a denominarse **`Alumno`** con acceso de solo lectura al cronograma general, asignaturas y aulas.
* Se ejecutó la actualización en caliente de los registros existentes en PostgreSQL y se actualizaron los sembrados en `init_db.py`.
* **Corrección Visual de Tipificaciones de Hitos ([app/templates/admin/milestone_types/list.html](file:///c:/Workspace/admin-edu-space/app/templates/admin/milestone_types/list.html)):**
* Se ajustó el contenedor a `pt-5 mt-4` evitando que el encabezado quede solapado bajo la barra de navegación fija.
* Se limpió la duplicación del nombre en la tabla (se eliminó la repetición del texto dentro de la celda).
* Se agregó la paleta rápida de colores institucionales UniCABA al modal de edición.
* Se agregó el botón y modal global de confirmación de eliminación `#deleteMilestoneTypeModal` sin saltos visuales.
* **Eliminación de Usuarios ([app/routes/admin.py](file:///c:/Workspace/admin-edu-space/app/routes/admin.py), [app/templates/admin/users/list.html](file:///c:/Workspace/admin-edu-space/app/templates/admin/users/list.html)):**
* Se implementó el endpoint `POST /admin/users/delete/<id>` protegido contra la eliminación del usuario en sesión activa y del administrador único.
* Desvinculación automática de comisiones como docente para prevenir violaciones de integridad referencial.
* Botón rojo de eliminación con ícono de papelera y modal global `#deleteUserModal` fuera de la tabla para evitar desplazamientos.
* **ABM Integral de Comisiones con Aulas Virtuales e Hitos ([app/models/subject.py](file:///c:/Workspace/admin-edu-space/app/models/subject.py), [app/models/milestone.py](file:///c:/Workspace/admin-edu-space/app/models/milestone.py), [app/routes/schedule.py](file:///c:/Workspace/admin-edu-space/app/routes/schedule.py), [app/templates/schedule/commissions.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/commissions.html), [app/templates/base.html](file:///c:/Workspace/admin-edu-space/app/templates/base.html)):**
* **Columna y Enlace de Aula Virtual:** Se añadió el campo `virtual_link` (VARCHAR 500) a `commissions`. Permite registrar enlaces directos a Zoom, Microsoft Teams, Google Meet o Campus Virtual.
* **Hitos de Evaluación vinculados a Comisiones:** Se añadió `commission_id` a `academic_milestones`. Permite asociar parciales, TPs y recuperatorios directamente a la comisión de cursada.
* **Permisos para Profesores, Bedelía y Admin:** Los roles `Docente`, `Bedelía` y `Admin` pueden configurar el aula virtual y registrar hitos académicos en sus comisiones.
* **Navegación:** Acceso directo a *Comisiones y Cursadas* disponible en la barra superior (menús *Schedule* y *Management*).
* **Modales interactivos:** Creación de comisión (`#modalAddCommission`), edición (`#modalEditCommission`), configuración de aula virtual (`#modalVirtualLink`), programación de hitos (`#modalAddMilestone`) y eliminación segura (`#deleteCommissionModal`).
---
## 19. Corrección de Redundancia en Nombre de Aula en Formulario y Resumen de Reserva
* **Desplegable de Selección de Aula ([app/routes/schedule.py](file:///c:/Workspace/admin-edu-space/app/routes/schedule.py)):**
* Se corrigió la tupla de opciones en `form.classroom_id.choices`. Anteriormente concatenaba `c.code_display` y `c.name` (ambas propiedades computaban `"Edificio Central-Aula 204"`), provocando el texto repetido `Edificio Central-Aula 204 - Edificio Central-Aula 204 (Cap: 35)`.
* Se simplificó a `f'{c.building} - {c.code} (Cap: {c.capacity_display})'`, mostrando limpiamente `Edificio Central - Aula 204 (Cap: 35)`.
* **Cuadro de Resumen en Tiempo Real ([app/templates/schedule/add.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/add.html)):**
* Se corrigió la función JavaScript `updateSummary()` que concatenaba `${code_display} - ${name}`.
* Ahora renderiza de forma limpia y directa `${classroomData[classroomId].code_display} (${capLabel}: ${classroomData[classroomId].capacity})`.
* **Formato de Códigos de Comisión ([app/models/subject.py](file:///c:/Workspace/admin-edu-space/app/models/subject.py)):**
* Se optimizó `Commission.get_full_code()` para no repetir el año cuando ya viene incluido en el string del semestre (ej: `2026-2`).
---
## 20. Refactorización Integral de Espacios Físicos: Desacoplamiento de Edificio, Piso y Aula
* **Modelo de Datos y Base de Datos ([app/models/classroom.py](file:///c:/Workspace/admin-edu-space/app/models/classroom.py), [app/models/building.py](file:///c:/Workspace/admin-edu-space/app/models/building.py)):**
* **Desacoplamiento de Edificio:** Se reforzó la clave foránea `Classroom.building_id` vinculada a `buildings.id`. El nombre del edificio se obtiene directamente a través de `building_entity` manteniendo retrocompatibilidad.
* **Pisos Flexibles (Alfanuméricos):** Se migró la columna `classrooms.floor` a `VARCHAR(50)`, permitiendo registrar tanto valores numéricos como designaciones textuales ("PB", "Planta Baja", "1", "2°P", "Subsuelo").
* **Propiedades de Formato:** Se incorporaron `floor_display` ("Planta Baja", "Piso X"), `is_virtual` y `location_display` (`Edificio · Piso · Aula`).
* **Rutina de Migración:** Se verificó la integridad referencial garantizando que todas las aulas existentes queden asociadas a su `building_id` correspondiente en PostgreSQL.
* **Formulario y Flujo Dinámico de Creación/Edición de Aulas ([app/forms/classroom.py](file:///c:/Workspace/admin-edu-space/app/forms/classroom.py), [app/routes/classrooms.py](file:///c:/Workspace/admin-edu-space/app/routes/classrooms.py), [app/templates/classrooms/add.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/add.html), [app/templates/classrooms/edit.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/edit.html)):**
* **Selector de Edificio con Alta Dinámica:** Desplegable con las sedes activas más la opción destacada `+ Crear nuevo edificio`. Al seleccionarla, despliega de forma inmediata un campo de texto para escribir el nuevo edificio sin abandonar la pantalla.
* **Sugerencia de Pisos:** Campo con datalist interactivo que sugiere los pisos ya cargados en la sede seleccionada o permite tipear uno nuevo libremente.
* **Validación de Unicidad:** Se valida tanto en cliente como en servidor que no existan aulas duplicadas dentro del mismo edificio y piso.
* **Selectores en Cascada para Reservas ([app/templates/schedule/add.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/add.html), [app/routes/schedule.py](file:///c:/Workspace/admin-edu-space/app/routes/schedule.py)):**
* **Flujo Dependiente:**
1. `Edificio / Sede`: Selección de sede física o modalidad `Campus Virtual`.
2. `Piso / Nivel`: Se puebla dinámicamente con los pisos disponibles de esa sede (se deshabilita automáticamente si es Virtual).
3. `Aula / Espacio`: Filtra y muestra únicamente las aulas del edificio y piso seleccionados con su capacidad.
* **Resumen Desglosado con Badges:**
* 🏢 **Edificio / Sede:** Muestra el nombre limpio de la sede.
* 🪜 **Piso / Nivel:** Muestra la planta o "Remoto (Virtual)".
* 🚪 **Aula Seleccionada:** Muestra el número de aula y su capacidad.
* **Filtros en Listado de Aulas y Calendario ([app/templates/classrooms/list.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/list.html), [app/templates/schedule/calendar.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/calendar.html), [app/templates/schedule/view.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/view.html)):**
* **Listado de Aulas:** Incorporación de dropdowns independientes para filtrar por **Edificio** y **Piso** en la barra superior. Tarjetas rediseñadas con badges distintivos de sede y piso.
* **Calendario Semanal:** Agregado el filtro **Filtrar por Edificio** en la barra superior junto al filtro de Piso.
* **Modal Estilo Google Calendar:** La sección de espacio físico desglosa limpiamente en badges: 🏢 Edificio, 🪜 Piso y 🚪 Aula.
---
## 21. Corrección de Linter y Renderizado de Barras en Métricas de Ocupación (2026-09-04)
* **Plantilla de Métricas de Ocupación ([app/templates/admin/metrics/occupancy.html](file:///c:/Workspace/admin-edu-space/app/templates/admin/metrics/occupancy.html)):**
* **Eliminación de Falsos Positivos de CSS en el IDE:** Se resolvieron los 12 errores de sintaxis (`property value expected`, `at-rule or selector expected`) reportados por el analizador HTML/CSS del IDE en las tarjetas de KPI (Tasa de Ocupación Hoy, Semanal, Mensual) y en la distribución por Piso / Nivel.
* **Atributos Semánticos HTML5:** Se sustituyó la inyección directa de Jinja2 en atributos `style="width: {{ ... }}%;"` por el atributo de datos estándar `data-width="{{ ... }}%"`.
* **Inicialización y Animación Dinámica en JS:**
* Se configuró en CSS (`.metric-bar-fill { width: 0; }`) un ancho base inicial.
* Se implementó un script en el bloque `extra_js` que asigna el ancho dinámico al cargar el DOM (`DOMContentLoaded`).
* En conjunto con `transition: width 0.6s ease;`, las barras se expanden de forma fluida y visualmente atractiva al cargar la pantalla.
---
## 22. Actualización de Instalador, Backup de Base de Datos y Despliegue en Proxmox LXC (2026-09-04)
* **Backup de Base de Datos ([classrooms_db.sql](file:///c:/Workspace/admin-edu-space/classrooms_db.sql)):**
* **Extracción del Snapshot Completo:** Volcado íntegro de la base de datos PostgreSQL activa (`classrooms_db`) que alimenta el sistema en producción/local.
* **Contenido Respaldado:** 25 aulas físicas y virtuales, 206 comisiones, 206 asignaturas, 412 reservas, 24 carreras, 12 tipos de hitos, matrices de permisos RBAC y usuarios administradores.
* **Compatibilidad Multi-Versión:** Se sanitizó la directiva `\restrict` específica de PostgreSQL 18 para garantizar compatibilidad nativa con PostgreSQL 14, 15, 16, 17 y 18.
* **Actualización del Instalador del Sistema para Proxmox LXC ([install.sh](file:///c:/Workspace/admin-edu-space/install.sh)):**
* **Ejecución 100% Automatizada (Zero-Touch):** Se eliminaron todos los prompts interactivos (`read -rp`), permitiendo que el script corra de inicio a fin de forma totalmente desatendida.
* **Copia de Configuración Activa del Sistema:** Se configuraron como valores por defecto los parámetros reales del sistema en ejecución (`DATABASE_URL=postgresql://postgres:password@localhost:5432/classrooms_db`, usuario `postgres`, base `classrooms_db` y contraseña `password`), garantizando paridad exacta con el entorno local.
* **Compatibilidad Dual de Usuarios:** Configuración tanto del superusuario `postgres` como de `eduspace_user` con los mismos privilegios para compatibilidad inmediata ante cualquier variación de `.env`.
* **Optimización Proxmox LXC:** Compatible con contenedores Debian 11/12 y Ubuntu 20.04/22.04/24.04 en Proxmox VE.
* **Migración Automática de Directorio Robusta:** Si el repositorio se clona en `/root`, se traslada automáticamente a `/opt/admin-edu-space` mediante sincronización directa con `cp -a`, limpiando posibles directorios anidados residuales y deteniendo el servicio previamente para evitar errores de colisión (`Directory not empty`).
* **Despliegue del Motor PostgreSQL Nativo:** Instalación desatendida del motor en el LXC, inicialización dinámica del servicio y bucle de healthcheck de conexión.
* **Restauración Automatizada y Permisos:** Detección automática de `classrooms_db.sql`, recreación limpia, asignación de permisos `GRANT ALL` en el esquema `public`, todas las tablas y secuencias a `postgres` y `eduspace_user`.
* **Entorno Virtual Nativo (`venv`):** Configuración en `/opt/admin-edu-space/venv` con dependencias del sistema y `gunicorn`.
* **Servicio Systemd de Producción:** Configuración y activación de `admin-edu-space.service` en el puerto 5000.
* **Ejecución Idempotente:** Integración de `init_db.py` tras la restauración para validar la coherencia del esquema y roles.
* **Utilidad de Backup Continuo ([backup_db.sh](file:///c:/Workspace/admin-edu-space/backup_db.sh)):**
* Script automatizado para generar copias de seguridad fechadas en `backups/` y actualizar simultáneamente `classrooms_db.sql` para facilitar la migración entre contenedores LXC.
* **Documentación y Tutorial en README ([README.md](file:///c:/Workspace/admin-edu-space/README.md)):**
* Guía paso a paso para la creación del contenedor LXC en Proxmox VE (especificaciones de CPU, RAM, disco y habilitación de `nesting=1`).
* Instrucciones completas de instalación desatendida con `install.sh`.
* Corrección de comandos de inicio apuntando al punto de entrada oficial [wsgi.py](file:///c:/Workspace/admin-edu-space/wsgi.py).
* Guía paso a paso para la generación y restauración de backups.
* **Dependencias ([requirements.txt](file:///c:/Workspace/admin-edu-space/requirements.txt)):**
* Se incorporó formalmente `gunicorn>=21.2.0` al listado de dependencias del proyecto.
---
## 23. Resolución de Error 500 (Internal Server Error) y Robustecimiento del Instalador Proxmox LXC (2026-09-04)
* **Causa Raíz del Error HTTP 500 en Despliegues Existentes:**
* Al desplegar sobre contenedores LXC con archivos `.env` preexistentes (por ejemplo, con usuario de base de datos personalizado `eduadmin` extraído de `DATABASE_URL`), el instalador anterior no otorgaba permisos de tablas ni secuencias al usuario dinámico (`GRANT` solo cubría `postgres` y `eduspace_user`), bloqueando las consultas de Flask a PostgreSQL con excepciones de privilegios insuficientes (`permission denied for relation ...`).
* Los registros de excepción de Gunicorn se canalizaban exclusivamente a un archivo sin `--capture-output`, impidiendo su visualización inmediata en `journalctl -u admin-edu-space -f`.
* **Solución y Mejoras en el Instalador ([install.sh](file:///c:/Workspace/admin-edu-space/install.sh)):**
* **Extracción Dinámica de Credenciales:** Parseo bidireccional de `DATABASE_URL` y variables individuales (`DB_USERNAME`, `DB_PASSWORD`, `DB_NAME`), garantizando compatibilidad absoluta con cualquier configuración de conexión.
* **Aprovisionamiento Universal de Roles PostgreSQL:** Creación y asignación de contraseña y privilegios `SUPERUSER CREATEDB` a todos los usuarios detectados (`DB_USER`, `DB_USERNAME`, `postgres`, `eduspace_user`).
* **Garantía Integral de Permisos DDL y DML:** Tras restaurar [classrooms_db.sql](file:///c:/Workspace/admin-edu-space/classrooms_db.sql), se ejecutan `GRANT ALL` en `DATABASE`, `SCHEMA public`, `ALL TABLES`, `ALL SEQUENCES`, `ALL FUNCTIONS` y `ALTER DEFAULT PRIVILEGES` para todos los usuarios.
* **Configuración de Autenticación Local (`pg_hba.conf`):** Ajuste automático en Debian/Ubuntu para que conexiones locales (`127.0.0.1`, `::1` y sockets UNIX) no fallen por discrepancias de método de cifrado (`scram-sha-256`/`md5`).
* **Logs en Tiempo Real en Systemd:** Reconfiguración de Gunicorn en el servicio con `--error-logfile -`, `--capture-output` y `--enable-stdio-inheritance`, permitiendo que cualquier excepción de Python/Flask se visualice en tiempo real vía `journalctl -u admin-edu-space -f`.
* **Healthcheck HTTP Automatizado:** Verificación al final del despliegue con `curl http://127.0.0.1:5000/`, validando que el servidor web responda exitosamente (HTTP 200/302) antes de concluir la instalación.
* **Carga Segura de Configuración ([config/config.py](file:///c:/Workspace/admin-edu-space/config/config.py)):**
* Integración explícita de `load_dotenv` para garantizar que scripts y workers carguen `.env` de forma confiable.
* Normalización automática de URIs con esquema heredado `postgres://` hacia el estándar `postgresql://`.
---
## 24. Auto-recuperación y Creación de Cluster PostgreSQL 15 en Debian 12 Proxmox LXC (2026-09-04)
* **Diagnóstico de Arranque de PostgreSQL en Debian 12 (Bookworm):**
* En contenedores Proxmox VE con Debian 12 limpio, `apt-get install postgresql` puede no inicializar automáticamente el cluster `main` si los locales no estaban completamente registrados al compilar el paquete, o el directorio de sockets `/run/postgresql` puede no tener los permisos adecuados en el filesystem `tmpfs`.
* El comando genérico `systemctl start postgresql` no inicia el motor si la instancia nativa `postgresql@15-main` no está creada ni generada.
* **Auto-detección y Resiliencia en [install.sh](file:///c:/Workspace/admin-edu-space/install.sh):**
* **Permisos del Socket UNIX:** Creación y asignación explícita de permisos `chmod 2775` con propietario `postgres:postgres` sobre `/run/postgresql` y `/var/run/postgresql`.
* **Creación Automática del Cluster:** Chequeo con `pg_lsclusters`; si el cluster `main` no existe, se ejecuta automáticamente `pg_createcluster ${PG_MAJOR} main --locale en_US.UTF-8 --start`.
* **Control Systemd Específico:** Arranque y habilitación de la unidad del cluster nativo `postgresql@${PG_MAJOR}-main` y `pg_ctlcluster`.
* **Diagnóstico de Salida:** En caso de contingencias en el host Proxmox, el script imprime directamente el estado del cluster con `pg_lsclusters` y las últimas líneas de registro de PostgreSQL.
* **Verificación e Instalación Automática de Git ([install.sh](file:///c:/Workspace/admin-edu-space/install.sh)):**
* Se incorporó en el inicio del script la verificación de presencia de `git` (`command -v git`). Si no está instalado en el sistema o contenedor, el script ejecuta automáticamente `apt-get update && apt-get install -y git` antes de configurar directivas de seguridad (`safe.directory`) o manipular el repositorio.
---
## 25. Roadmap Académico, Propuesta de Mejora (MVC, JWT, Sprint Security), Valor Agregado y Desafíos Técnicos (2026-09-05)
* **Propuesta de Mejora Arquitectónica y Gobernanza ([README.md](file:///c:/Workspace/admin-edu-space/README.md)):**
* **Arquitectura MVC Rigurosa & Clean Architecture:** Formalización del desacoplamiento en 4 capas: Dominio (`app/models/`), Presentación (Jinja2 + JSON API), Controladores delgados (`app/routes/`) y Capa de Negocio (`app/services/` y `app/repositories/`). Incorporación de esquemas tipados DTO (Pydantic) para validación de contratos de entrada.
* **Autenticación Stateless con JWT (JSON Web Tokens):** Arquitectura híbrida que preserva sesiones seguras para el panel web y habilita autenticación stateless basada en tokens JWT para microservicios y aplicaciones móviles mediante el patrón Dual Token (`Access Token` de 15 min + `Refresh Token` en cookie `HttpOnly` de 7 días, con soporte de lista negra de revocación en Redis).
* **Seguridad Empresarial ("Sprint / Spring Security"):** Adopción de los estándares de Spring Security adaptados a Python/Flask: cadena centralizada de filtros (`SecurityFilterChain`), control declarativo a nivel método (`@require_permission`), rate limiting dinámico contra fuerza bruta (`Flask-Limiter`) y bitácora de auditoría inmutable de eventos (`AuditLog`).
* **Planificación por Sprints (Roadmap):**
* **Sprint 1 (MVC & Servicios):** Desacoplamiento de lógica de negocio a servicios y repositorios; esquemas DTO de validación.
* **Sprint 2 (JWT & API Gateway):** Endpoints `/api/auth/login`, `/refresh`, `/logout`, decoradores `@jwt_required` y middleware Bearer Token.
* **Sprint 3 (Sprint Security & RBAC):** Pipeline `SecurityFilterChain`, control a nivel método, rate limiting y auditoría inmutable.
* **Sprint 4 (Optimizador & Concurrencia):** Paralelización del algoritmo genético áulico e integración con microservicios Spring Boot.
* **Valor Agregado Institucional:**
* **Optimización Edilicia:** Aprovechamiento del aforo en Sede Central (Perón 802) mediante asignación algorítmica por capacidad real y pisos.
* **Reducción de Tiempo:** Ahorro del 85% en resolución de conflictos y asignación horaria en turnos de alta concentración (Vespertino).
* **Eficiencia Extrema en Proxmox LXC:** Despliegue nativo con consumo inferior a 180MB de RAM frente a alternativas pesadas en Docker/Kubernetes.
* **Desafíos Técnicos Superados y Próximos (Challenges):**
* Resolución del problema NP-Hard de asignación horaria mediante Algoritmo Genético multicriterio.
* Pipeline ETL tolerante a fallos para ingesta y normalización de carteleras no estructuradas de Google Sheets.
* Despliegue automatizado, auto-recuperable y portátil para Proxmox VE LXC en Debian 12 (Bookworm) y Debian 13 (Trixie).
---
## 26. Creación de Rama `rc` y Publicación del Primer Release Oficial `v1.0.1` (2026-09-05)
* **Rama `rc` (Release Candidate):**
* Creación y sincronización de la rama `rc` con el estado íntegro de `dev` (commit `566908f`), estableciendo el seguimiento remoto `gitea/rc`.
* **Publicación de Tag y Release `v1.0.1` en Gitea:**
* Generación de la etiqueta anotada `v1.0.1` a partir de `rc` con notas de versión detalladas.
* Publicación remota en Gitea (`git push gitea v1.0.1`), disponibilizando la primera versión candidata/estable oficial para producción en Proxmox VE LXC con empaquetado de artefactos descargables (`.zip` y `.tar.gz`).
+443
View File
@@ -0,0 +1,443 @@
# Auditoría Técnica y DevOps: Git, Arquitectura y Puesta en Producción
**Repositorio:** `admin-edu-space`
**Rol:** Tech Lead / DevOps Lead
**Fecha de Auditoría:** 2 de Septiembre de 2026
**Entorno de Auditoría:** Windows / Git 2.x / Python 3.12.4
---
## Resumen Ejecutivo
El presente documento constituye la auditoría integral de código, gestión de versiones (Git), arquitectura de software y preparación para producción del proyecto **Edu-Space Admin Panel** (`admin-edu-space`).
### Conclusiones Principales:
1. **Rama seleccionada para despliegue:** **`main`** (commit `3d4e999`). Es la única rama que integra la totalidad de los cambios, bugfixes y features desarrollados en las ramas remotas (`planning`, `commissions`, `spanish`). No existen ramas divergentes sin fusionar.
2. **Nivel de Madurez CI/CD:** **Crítico (0% Automatización)**. No existen flujos de trabajo en GitHub Actions, GitLab CI ni validación de pruebas automatizadas previas al merge.
3. **Seguridad & Configuración:** **Riesgo Alto**. El archivo `.env` se encuentra actualmente trackeado en el repositorio Git con credenciales por defecto. Se requiere eliminarlo del índice, generar `.env.example` y rotar credenciales.
4. **Infraestructura de Despliegue:** El repositorio carece de `Dockerfile`, `docker-compose.yml`, scripts de systemd y no incluye un servidor WSGI de producción (`gunicorn`) en `requirements.txt`. El paso a producción requiere los artefactos y procedimientos estandarizados que se detallan en este informe.
---
# Fase 1: Análisis de Ramas (Git Audit)
## 1.1 Inventario de Ramas y Commits
Se ejecutó la inspección exhaustiva de referencias locales y remotas:
```bash
git fetch --all
git branch -a --sort=-committerdate
git for-each-ref --format='%(refname:short) | %(committerdate:iso) | %(subject) | %(authorname)' refs/heads refs/remotes
```
### Tabla Comparativa de Ramas:
| Rama | Tipo | Último Commit | Fecha del Commit | Autor | Estado respecto a `main` |
| :--- | :--- | :--- | :--- | :--- | :--- |
| **`main`** | Local / Remota | `3d4e999` | 2026-04-17 18:18:00 -0300 | `ale-vz` | **Rama Primaria (HEAD)** |
| **`origin/planning`** | Remota | `ec977bf` | 2026-04-17 18:14:59 -0300 | `Alejandro Vazquez` | **100% Fusionada** (Merge PR #2 en `3d4e999`) |
| **`origin/commissions`**| Remota | `18f1d3a` | 2026-03-30 17:37:53 -0300 | `Alejandro Vazquez` | **100% Fusionada** (Incluida en historia de `planning`) |
| **`origin/spanish`** | Remota | `9af25df` | 2026-03-30 17:11:13 -0300 | `Alejandro Vazquez` | **100% Fusionada** (Merge PR #1 en `d4281cd` y PR #2) |
### Verificación de Divergencias:
Al ejecutar `git branch -r --no-merged main`, el resultado es **vacío**.
Al verificar conteo simétrico `git rev-list --left-right --count <rama>...main`:
- `origin/planning...main`: `0 2` (0 commits pendientes, 2 commits por delante en `main` por los merges).
- `origin/commissions...main`: `0 6` (0 commits pendientes, 6 commits por delante en `main`).
- `origin/spanish...main`: `0 7` (0 commits pendientes, 7 commits por delante en `main`).
### Tags y Releases:
- `git tag -l`: **Vacío**. No existen versiones etiquetadas (e.g. `v1.0.0`, `v0.1.0`) ni releases formales en el historial.
## 1.2 Auditoría de CI/CD y Calidad de Código
- **Workflows automatizados:** Inexistentes. No hay directorio `.github/workflows/`, ni `.gitlab-ci.yml`, ni archivo de integración continua de ningún proveedor.
- **Validación de Tests en Pull Requests:** Los merges de los Pull Requests `#1` y `#2` se realizaron sin puertas de enlace automatizadas (quality gates).
- **Cobertura de pruebas en repo:** Existe únicamente `tests/test_genetic_algorithm.py` enfocado en el motor heurístico, pero no se ejecuta en ningún pipeline.
## 1.3 Dictamen Conclusivo de Rama para Despliegue
> ### 🏆 Rama Determinada: **`main`** (Commit `3d4e999`)
>
> **Justificación Técnica:**
> 1. **Consolidación completa:** `main` agrupa de forma lineal y validada todas las funcionalidades de las ramas temáticas:
> - Incorpora las traducciones y arreglos de internacionalización (`spanish`).
> - Incorpora la lógica y vistas de comisiones (`commissions`).
> - Incorpora el motor de optimización de aulas y correcciones de UI (`planning`).
> 2. **Cero pérdida de código (No regressions):** No existe ningún commit en las ramas remotas que no esté ya contenido en `main`.
> 3. **Estabilidad comprobada:** Resuelve errores críticos de SQLAlchemy con carga ansiosa (`joinedload`) documentados en la evolución del repositorio.
---
# Fase 2: Informe Técnico del Proyecto
## 2.1 Stack Tecnológico y Propósito del Sistema
### Propósito
**Edu-Space Admin Panel** es una interfaz web de administración para la gestión, reserva y optimización algorítmica de aulas universitarias/académicas. El sistema permite:
- Gestionar edificios, aulas, capacidad física y recursos técnicos (proyectores, computadoras).
- Visualizar horarios y agendas en tiempo real mediante calendarios interactivos.
- Administrar materias y comisiones docentes.
- Asignar aulas automáticamente utilizando un **Algoritmo Genético** que evalúa aforo, prioridades horarias y resuelve conflictos de superposición de reservas.
### Stack de Tecnologías
| Componente | Tecnología | Versión | Propósito / Responsabilidad |
| :--- | :--- | :--- | :--- |
| **Lenguaje Base** | Python | `>=3.8` (Óptimo: `3.11` / `3.12`) | Runtime principal de ejecución |
| **Framework Web** | Flask | `3.0.0` | Arquitectura web basada en Application Factory |
| **Capa ORM** | SQLAlchemy / Flask-SQLAlchemy | `2.0.23` / `3.1.1` | Modelado de datos relacional y queries |
| **Driver de BD** | psycopg2-binary | `2.9.9` | Conector nativo de alto rendimiento a PostgreSQL |
| **Migraciones** | Flask-Migrate | `4.0.5` | Wrapper de Alembic (requiere inicialización) |
| **Autenticación** | Flask-Login / Werkzeug | `0.6.3` / `3.0.1` | Manejo de sesiones, roles y hash `pbkdf2:sha256` |
| **Formularios & CSRF** | Flask-WTF / WTForms | `1.2.1` / `3.1.0` | Validación y protección contra ataques CSRF |
| **Internacionalización** | Flask-Babel / Babel | `4.0.0` / `2.14.0` | Detección y renderizado multilingüe (ES / EN) |
| **Frontend** | Bootstrap 5, FullCalendar, Chart.js | Bundled | UI responsiva, calendarios y estadísticas |
| **Microservicio Backend**| Spring Boot (Java) | `8080` (Externo) | API de negocio y persistencia central (opcional/compartida) |
### Arquitectura de Componentes y Flujo de Datos
```mermaid
graph TD
Client[Navegador / Cliente Web] -->|HTTP / HTTPS| Nginx[Nginx Reverse Proxy & SSL]
Nginx -->|WSGI Proxy :5000| Gunicorn[Gunicorn WSGI Server]
Gunicorn --> App[Flask App Factory: create_app]
subgraph "Flask Application Blueprints"
App --> AuthBP[auth_bp: /login, /logout, /profile]
App --> MainBP[main_bp: /dashboard, /api/dashboard-stats]
App --> ClassroomsBP[classrooms_bp: /classrooms]
App --> ScheduleBP[schedule_bp: /schedule]
App --> GeneticBP[genetic_bp: /api/genetic & /genetic-optimizer]
end
subgraph "Core Engine"
GeneticBP --> GAEngine[Algoritmo Genético: ReservationOptimizer]
GAEngine --> FitnessEval[Evaluación: Aforo 40%, Horario 30%, Conflicto 20%]
end
subgraph "Persistencia & Servicios"
App --> SQLAlchemy[SQLAlchemy ORM + joinedload]
SQLAlchemy --> PostgreSQL[(PostgreSQL 14+ / classrooms_db)]
App -.->|Integración Configurada| SpringBoot[Spring Boot API :8080/api]
end
```
---
## 2.2 Requisitos de Entorno y Variables de Configuración
### ⚠️ Hallazgos Críticos de Seguridad
1. **Archivo `.env` versionado:** Se encontró el archivo `.env` físico bajo control de versiones Git con contraseñas en texto plano (`password`).
- *Acción correctiva obligatoria:* Ejecutar `git rm --cached .env`, añadirlo a `.gitignore` y rotar todas las credenciales antes de salir a producción.
2. **Traducciones no compiladas:** El archivo `.gitignore` excluye deliberadamente `*.mo`. Por ende, el paso de build debe compilar los `.po` mediante `pybabel compile -d translations`.
### Especificación de Variables de Entorno (`.env.example`)
```env
# ==============================================================================
# Edu-Space Admin - Configuración de Entorno de Producción
# ==============================================================================
# Entorno Flask
FLASK_APP=app.py
FLASK_ENV=production
DEBUG=False
# Seguridad: Clave secreta para cookies de sesión y CSRF (Mínimo 64 caracteres aleatorios)
# Generar con: python -c 'import secrets; print(secrets.token_hex(32))'
SECRET_KEY=generate-a-strong-random-secret-key-for-production-use
# Conexión a Base de Datos PostgreSQL
# Formato: postgresql://<user>:<password>@<host>:<port>/<dbname>
DATABASE_URL=postgresql://eduspace_user:StrongProductionPassword123!@db-host:5432/classrooms_db
# Parámetros de compatibilidad si aplica
DB_USERNAME=eduspace_user
DB_PASSWORD=StrongProductionPassword123!
# Integración con API externa (Spring Boot)
API_BASE_URL=http://springboot-service:8080/api
# Configuración de Internacionalización y Carga de Archivos
BABEL_DEFAULT_LOCALE=es
BABEL_DEFAULT_TIMEZONE=America/Argentina/Buenos_Aires
MAX_CONTENT_LENGTH=16777216
```
---
## 2.3 Puesta en Producción (Deployment Determinado)
### 2.3.1 Estrategia de Contenedores (Docker + Docker Compose)
Para garantizar un despliegue repetible e inmutable, se especifica el contenedor de producción:
#### `Dockerfile` (Multi-stage / Producción)
```dockerfile
# Stage 1: Build de dependencias y compilación de traducciones
FROM python:3.11-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt && \
pip install --no-cache-dir --user gunicorn==21.2.0
COPY . .
# Compilar catálogos de traducción i18n
RUN python -m babel.messages.frontend compile -d translations
# Stage 2: Runtime limpio y seguro
FROM python:3.11-slim AS runner
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
curl \
&& rm -rf /var/lib/apt/lists/*
# Crear usuario sin privilegios
RUN useradd -m -u 1001 appuser
COPY --from=builder /root/.local /home/appuser/.local
COPY --from=builder --chown=appuser:appuser /app /app
ENV PATH=/home/appuser/.local/bin:$PATH
ENV PYTHONUNBUFFERED=1
ENV FLASK_ENV=production
USER appuser
EXPOSE 5000
# Healthcheck interno del contenedor
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:5000/login || exit 1
CMD ["gunicorn", "--workers=4", "--threads=2", "--bind=0.0.0.0:5000", "--access-logfile=-", "--error-logfile=-", "app:create_app()"]
```
#### `docker-compose.yml` (Stack Completo con PostgreSQL y Nginx)
```yaml
version: '3.8'
services:
db:
image: postgres:15-alpine
container_name: eduspace_db
restart: always
environment:
POSTGRES_DB: classrooms_db
POSTGRES_USER: eduspace_user
POSTGRES_PASSWORD: StrongProductionPassword123!
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U eduspace_user -d classrooms_db"]
interval: 10s
timeout: 5s
retries: 5
web:
build:
context: .
dockerfile: Dockerfile
container_name: eduspace_admin
restart: always
depends_on:
db:
condition: service_healthy
env_file:
- .env
expose:
- "5000"
nginx:
image: nginx:alpine
container_name: eduspace_proxy
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./app/static:/app/static:ro
depends_on:
- web
volumes:
pgdata:
```
### 2.3.2 Despliegue en Máquina Virtual Linux (Systemd + Gunicorn + Nginx)
Si el despliegue se efectúa en un servidor Linux (Ubuntu/Debian) sin Docker:
#### 1. Pasos de Instalación y Preparación:
```bash
# 1. Clonar rama main
git clone -b main <URL_DEL_REPOSITORIO> /var/www/admin-edu-space
cd /var/www/admin-edu-space
# 2. Configurar entorno virtual
python3.11 -m venv venv
source venv/bin/activate
# 3. Instalar dependencias del proyecto + Servidor WSGI
pip install --upgrade pip
pip install -r requirements.txt
pip install gunicorn==21.2.0
# 4. Configurar variables de entorno seguras
cp .env.example .env
chmod 600 .env
nano .env # Ajustar SECRET_KEY y DATABASE_URL
# 5. Compilar traducciones
pybabel compile -d translations
# 6. Inicializar esquema de base de datos (si la base está vacía)
python -c "from app import create_app, db; app = create_app(); app.app_context().push(); db.create_all()"
```
#### 2. Configuración de Servicio Systemd (`/etc/systemd/system/eduspace.service`):
```ini
[Unit]
Description=Edu-Space Admin Gunicorn Daemon
After=network.target postgresql.service
[Service]
User=www-data
Group=www-data
WorkingDirectory=/var/www/admin-edu-space
Environment="PATH=/var/www/admin-edu-space/venv/bin"
EnvironmentFile=/var/www/admin-edu-space/.env
ExecStart=/var/www/admin-edu-space/venv/bin/gunicorn \
--workers 4 \
--threads 2 \
--bind 127.0.0.1:5000 \
--access-logfile /var/log/eduspace/access.log \
--error-logfile /var/log/eduspace/error.log \
"app:create_app()"
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
#### 3. Configuración del Reverse Proxy Nginx (`/etc/nginx/sites-available/eduspace`):
```nginx
server {
listen 80;
server_name admin.edu-space.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name admin.edu-space.com;
ssl_certificate /etc/letsencrypt/live/admin.edu-space.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/admin.edu-space.com/privkey.pem;
client_max_body_size 16M;
# Entrega directa de archivos estáticos por Nginx
location /static/ {
alias /var/www/admin-edu-space/app/static/;
expires 30d;
add_header Cache-Control "public, no-transform";
}
# Proxy hacia el daemon Gunicorn
location / {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_redirect off;
}
}
```
---
## 2.4 Verificación, Monitoreo y Health Checks
### 2.4.1 Endpoint de Verificación Recomendado (`/healthz`)
Actualmente el proyecto solo cuenta con endpoints protegidos por autenticación (`@login_required`). Para orquestadores (Kubernetes, AWS ALB, Docker), se debe incorporar una ruta pública de verificación en `app/routes/main.py`:
```python
@main_bp.route('/healthz')
def healthz():
"""Health check endpoint para balanceadores de carga y monitoreo"""
try:
# Validar conectividad con PostgreSQL
db.session.execute(db.text('SELECT 1'))
return jsonify({
'status': 'HEALTHY',
'database': 'CONNECTED',
'timestamp': datetime.utcnow().isoformat()
}), 200
except Exception as e:
return jsonify({
'status': 'UNHEALTHY',
'database': str(e),
'timestamp': datetime.utcnow().isoformat()
}), 503
```
### 2.4.2 Pruebas de Humo (Smoke Tests) Post-Despliegue
Ejecutar la siguiente suite de verificación inmediata tras desplegar:
```bash
# 1. Comprobación de servicio y respuesta HTTP
curl -I -k https://admin.edu-space.com/login
# Respuesta esperada: HTTP/1.1 200 OK
# 2. Comprobación del Health Endpoint
curl -s https://admin.edu-space.com/healthz | jq .
# Respuesta esperada: {"status": "HEALTHY", "database": "CONNECTED"}
# 3. Comprobación de entrega de estáticos
curl -I https://admin.edu-space.com/static/css/style.css
# Respuesta esperada: HTTP/1.1 200 OK (o 304 Not Modified)
# 4. Comprobación de logs de Gunicorn
sudo journalctl -u eduspace.service -n 50 --no-pager
```
### 2.4.3 Matriz de Monitoreo y Observabilidad
| Dimensión | Métrica Clave | Umbral de Alerta | Acción Correctiva |
| :--- | :--- | :--- | :--- |
| **Disponibilidad** | Tasa de Respuestas HTTP 5xx | `> 1%` en 5 min | Inspeccionar `error.log` de Gunicorn; revisar pool de conexiones BD |
| **Latencia** | Tiempo de respuesta P95 | `> 1.5s` en endpoints | Revisar índices en tablas `reservations` y `classrooms` |
| **Base de Datos** | Pool de conexiones agotado | Conexiones activas `> 85%` | Aumentar `max_overflow` en SQLAlchemy o activar PgBouncer |
| **Optimización** | Tiempo de ejecución de Algoritmo Genético | `> 10s` en `/api/genetic/optimize` | Limitar rango de comisiones enviadas o desacoplar a Celery/Redis |
---
## 2.5 Roadmap de Mejoras DevOps & Calidad (Tech Lead Recommendations)
1. **Implementar CI/CD con GitHub Actions:**
- Crear `.github/workflows/ci.yml` para ejecutar linting (`flake8` / `ruff`) y la suite de pruebas `unittest` en cada PR.
2. **Desacoplar `.env` de Git:**
- Remover `.env` del repositorio (`git rm --cached .env`) e incorporar `python-dotenv` seguro mediante inyección de secretos en el pipeline.
3. **Gestión Formal de Migraciones:**
- Ejecutar `flask db init` y generar el primer script de migración versionado en `migrations/versions/`.
4. **Asincronía para el Algoritmo Genético:**
- Actualmente, la optimización heurística se ejecuta de forma sincrónica en el worker HTTP. Si el volumen de comisiones crece, puede bloquear el worker de Gunicorn (timeout 30s). Se recomienda migrar la ejecución pesada a una cola asíncrona con **Celery** o **Redis Queue (RQ)**.
5. **Alinear Rutas Duplicadas:**
- Resolver la colisión de ruta entre `main_bp.route('/genetic-optimizer')` y `genetic_web_bp.route('/genetic-optimizer')`.
+342 -213
View File
@@ -1,249 +1,378 @@
# Edu-Space Admin Panel # Panel de Administración Edu-Space (UniCABA)
A comprehensive web-based administration interface for the Edu-Space Classroom Reservation System built with Flask. Plataforma integral de gestión áulica, asignación de espacios, control de horarios y reservas académicas para la **Universidad de la Ciudad de Buenos Aires (UniCABA)**, desarrollada con **Python / Flask**, **SQLAlchemy**, **PostgreSQL** y **Bootstrap 5**.
## Features ---
### 🔐 Authentication & Security ## 🏛️ Sede Institucional y Ubicaciones
- Secure login system with hashed passwords * **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.
- Role-based access control * **Campus Virtual:** Espacio áulico digital integrado (Microsoft Teams / Moodle) configurado con **capacidad ilimitada ($\infty$)**.
- Session management with Flask-Login
- Password change functionality
### 🏫 Classroom Management ---
- Create, edit, and delete classrooms
- Campus and building organization
- Capacity and resource tracking
- Real-time availability checking
### 📅 Schedule Management ## ✨ Características Principales
- Interactive calendar view (FullCalendar)
- Reservation creation and management
- Conflict detection and prevention
- Status tracking (Pending, Confirmed, Cancelled)
- Today's schedule overview
### 📊 Dashboard & Analytics ### 🛡️ 1. Control de Accesos RBAC Granular (Matriz estilo FortiGate)
- Real-time statistics * **Gestión por Módulos:** Permisos independientes por cada área del sistema:
- Monthly reservation charts 1. **Aulas y Espacios:** Creación, edición, capacidad y estado de aulas físicas y virtuales.
- Quick actions interface 2. **Edificios y Sedes:** Administración de sedes, pisos y arquitectura edilicia.
- Upcoming reservations overview 3. **Reservas de Aulas:** Solicitud, aprobación, confirmación y cancelación.
4. **Cartelera y Cronograma:** Calendario interactivo, visualización diaria y comisiones.
5. **Gestión Académica:** Carreras, asignaturas, códigos y comisiones.
6. **Sincronización Sheets:** Importador y sincronizador de Google Sheets.
7. **Optimizador IA:** Algoritmo genético de distribución y resolución de conflictos.
8. **Usuarios y Accesos RBAC:** Cuentas, roles y matriz de permisos.
* **Niveles de Permiso Granulares:** `Ninguno`, `Solo Lectura` o `Lectura y Escritura`.
* **Controles Rápidos:** Botón *"Establecer Todos"* para configuración masiva inmediata.
* **Roles Predefinidos:** `Admin` (control total), `Docente`, `Operador` y `Consulta`.
### 🎨 Modern UI/UX ### 📊 2. Sincronización e Importación desde Google Sheets
- Responsive Bootstrap 5 design * Integración directa con la cartelera académica de UniCABA en Google Sheets.
- Dark theme support * Extracción e importación normalizada de los 5 campos clave:
- Mobile-friendly interface * **Código Académico:** Códigos reales de asignatura (ej. `INGA0003`, `ASIG00165`).
- Interactive components and charts * **Asignatura:** Nombre oficial de la materia.
* **Aula:** Mapeo a aulas físicas (PB a 4.° piso) o aula `VIRTUAL` ($\infty$).
* **Carrera:** Normalización y vinculación relacional con entidades de carrera.
* **Horarios y Turnos:** Detección automática de franjas horarias y delimitadores de bloque (`Turno Mañana` y `Turno Tarde / Vespertino`).
## Tech Stack ### 📅 3. Calendario Interactivo de Reservas (FullCalendar)
* **Visualización Semanal, Mensual y Diaria:** Grilla ágil de alta legibilidad con jerarquía visual:
* Asignatura destacada en tipografía grande y negrita.
* Badges con código de materia, aula asignada, turno y franja horaria.
* **Filtros Dinámicos Combinables:**
* Filtrar por **Aula**.
* Filtrar por **Materia / Asignatura**.
* Filtrar por **Carrera**.
* Filtrar por **Turno** (`Turno Mañana` / `Turno Tarde`).
* Filtrar por **Piso / Planta** (`Planta Baja`, `Piso 1`, `Piso 2`, `Piso 3`, `Piso 4`).
* **Switch "Ocultar Aulas Virtuales":** Aísla la ocupación física real en el edificio central excluyendo los eventos virtuales de capacidad infinita.
* **Ficha Modal Instantánea:** Vista previa detallada al hacer clic en cualquier evento.
- **Backend**: Flask (Python) ### 📋 4. Cartelera del Día y Listado de Reservas
- **Database**: SQLAlchemy (SQLite/PostgreSQL) * **Cartelera del Día (`/schedule/today`):**
- **Frontend**: Bootstrap 5, jQuery, Chart.js * **Vista Cronológica (Timeline):** Bloques horarios de 07:00 a 22:00 hs con indicador de ocupación.
- **Authentication**: Flask-Login, Werkzeug * **Vista en Tabla:** Lista ordenada con acciones rápidas y detalles de comisión.
- **Forms**: Flask-WTF * Filtros directos por piso, turno y switch para ocultar virtuales.
- **Calendar**: FullCalendar.js * **Listado Paginado de Reservas (`/schedule/list`):**
* Paginación de 20 registros por página preservando filtros aplicados.
* Búsqueda por texto, estado (`Confirmada`, `Pendiente`, `Cancelada`), rango de fechas y piso.
## Installation ### 🏫 5. Gestión de Aulas con Capacidad Infinita ($\infty$)
* Soporte nativo para el símbolo **`∞`** o `0` para designar aulas virtuales o espacios sin aforo restringido.
* Botón de un solo clic `Ilimitada (∞)` en formularios de alta y edición con leyendas explicativas.
* Badges distintivos `∞ Ilimitada` en listados, tarjetas y fichas técnicas.
### Prerequisites ### 🤖 6. Optimizador Inteligente de Aulas (Algoritmo Genético)
- Python 3.8+ * Motor de optimización heurística para asignación automática de aulas a comisiones.
- pip package manager * Pondera capacidad requerida vs. capacidad de aula, equipamiento tecnológico y minimización de desplazamientos entre pisos.
### Setup ### 🎨 7. Experiencia de Usuario, Temas y Localización
* **Tema Oscuro / Claro:** Alternador con botón flotante y persistencia en `localStorage` y perfil de usuario.
* **Soporte Multilingüe:** Internacionalización integral (Español e Inglés) compilada con GNU gettext (`pybabel`).
* **Tarjetas Estadísticas Interactivas:** Accesos directos rápidos desde el panel general hacia las distintas secciones operativas.
1. **Clone or setup the project:** ### 🏛️ 8. Sistema Integral de Gestión Institucional (8 Módulos)
El menú desplegable **Gestión** en la barra de navegación organiza las áreas operativas de la universidad:
* **Espacios e Infraestructura:**
* **Edificios y Sedes (`/buildings/list`):** Administración de edificios físicos, arquitectura y estado edilicio.
* **Aulas Presenciales (`/classrooms/list?view_mode=physical`):** Catálogo de aulas físicas por piso, capacidades y zócalos compactos con métricas de ocupación en tiempo real (día, semana, mes).
* **Aulas Virtuales con Reunión (`/classrooms/list?view_mode=virtual`):** Campus Virtual con salas a demanda agrupadas por comisión, capacidad ilimitada ($\infty$) y enlaces directos de acceso a videollamadas (Zoom, Google Meet, Microsoft Teams).
* **Gestión de Ocupación y Métricas (`/admin/occupancy-metrics`):** Dashboard analítico de tasas de ocupación horaria (día, semana, mes), horas pico, distribución por turno y comparativa física vs. virtual.
* **Gestión Académica:**
* **Carreras (`/admin/careers`):** ABM de carreras universitarias y planes de estudio con contador de asignaturas y comisiones activas.
* **Asignaturas (`/admin/subjects`):** Catálogo institucional de materias con créditos, departamentos y filtros por carrera y estado.
* **Comisiones y Cursadas (`/schedule/commissions`):** Gestión de cursadas, turnos, docentes, enlaces virtuales e hitos de evaluación.
* **Ciclo Lectivo (`/admin/academic-terms`):** Modelo y panel de administración de períodos académicos, cuatrimestres y activación del ciclo lectivo en curso.
* **Administración & Seguridad:**
* **Usuarios y Roles RBAC:** Cuentas, asignación de permisos matriciales y auditoría.
* **Tipificaciones de Hitos:** Catálogo de tipos de hitos de evaluación académica.
* **Sincronización Sheets:** Integración con Google Sheets para actualización en lote.
---
## 🛠️ Stack Tecnológico
| Componente | Tecnología |
| :--- | :--- |
| **Backend** | Python 3.12+ / Flask |
| **Base de Datos** | PostgreSQL 16+ / SQLAlchemy ORM |
| **Seguridad & Sesiones** | Flask-Login / Werkzeug / CSRF Protect |
| **Formularios** | Flask-WTF / WTForms |
| **Internacionalización** | Flask-Babel / GNU gettext |
| **Frontend** | Bootstrap 5.3, Bootstrap Icons, Vanilla JS, CSS Variables |
| **Calendario** | FullCalendar 5.11 |
---
## 🚀 Despliegue en Proxmox VE (LXC) con Entorno Virtual `venv`
El sistema está especialmente optimizado para ejecutarse en contenedores **LXC (Debian 12 Bookworm o Ubuntu 22.04/24.04 LTS)** dentro de **Proxmox VE**, utilizando un entorno virtual nativo de Python (`venv`), el motor **PostgreSQL** instalado en el propio contenedor y el servidor WSGI **Gunicorn** gestionado como servicio por **systemd**.
---
### 📋 Paso 1: Creación del Contenedor LXC en Proxmox VE
Podés crear el contenedor desde la interfaz Web de Proxmox (*Create CT*) o por terminal Proxmox con `pct create`:
* **Plantilla (Template):** `debian-12-standard` (*Recomendado*) o `ubuntu-22.04-standard` / `ubuntu-24.04-standard`.
* **CPU:** 1 a 2 vCPU.
* **Memoria RAM:** 1024 MB a 2048 MB (Swap: 512 MB).
* **Disco:** 8 GB a 15 GB (almacenamiento `local-lvm` o `zfs`).
* **Red (Network):** Modo Bridge `vmbr0` con IP estática en tu red local o DHCP (ej: `192.168.1.150/24`, Gateway: `192.168.1.1`).
* **Opciones del Contenedor:**
* *Unprivileged container:* Sí (marcado por defecto, compatible).
* *Features (Características):* Habilitar **Nesting (`nesting=1`)** en `CT > Opciones > Características` (requerido para systemd).
---
### ⚡ Paso 2: Instalación Automatizada con `install.sh`
1. **Ingresar a la consola del contenedor LXC:**
Desde la Web UI de Proxmox (consola noVNC) o desde el shell de tu nodo Proxmox:
```bash ```bash
cd /Users/alejandro.v/PycharmProjects/admin-edu-space pct enter <ID_DEL_CONTENEDOR>
``` ```
2. **Create virtual environment:** 2. **Clonar el repositorio y lanzar el instalador:**
```bash ```bash
apt update && apt install -y git
git clone https://gitea.oemspot.com.ar/carlostellocba/admin-edu-space.git
cd admin-edu-space
bash install.sh
```
3. **Flujo de Ejecución Automatizado en el LXC:**
* **Migración segura fuera de `/root`:** Si el repositorio se clonó en `/root`, el instalador lo traslada automáticamente a `/opt/admin-edu-space` para garantizar que el usuario del servicio (`eduspace`) tenga acceso de lectura y ejecución.
* **Locales UTF-8:** Configura `en_US.UTF-8` y `es_AR.UTF-8` para asegurar la internacionalización con `Flask-Babel` y prevenir errores de encoding.
* **Motor PostgreSQL:** Instala el motor PostgreSQL, inicia el servicio y verifica conectividad activa con bucle de espera.
* **Restauración del Snapshot Oficial ([classrooms_db.sql](file:///c:/Workspace/admin-edu-space/classrooms_db.sql)):**
* Detecta el volcado completo de datos.
* Crea la base de datos `classrooms_db` y el usuario `eduspace_user`.
* Restaura **25 aulas** (físicas y Campus Virtual $\infty$), **206 comisiones**, **206 materias**, **412 reservas históricas**, **24 carreras**, matrices RBAC y cuentas de acceso.
* Otorga privilegios totales (`GRANT ALL`) en el esquema `public`, todas las tablas y **todas las secuencias** al usuario `eduspace_user`.
* Ejecuta `init_db.py` de forma idempotente para validar roles y coherencia.
* **Entorno Virtual Python (`venv`):** Crea el entorno aislado en `/opt/admin-edu-space/venv` e instala todas las dependencias oficiales (`Flask`, `SQLAlchemy`, `Gunicorn`, etc.).
* **Servicio Systemd de Producción:** Configura el servicio `admin-edu-space.service` gestionado por **Gunicorn** (3 workers, puerto 5000), con reinicio automático ante fallos y habilitado al inicio del contenedor.
---
### 🌐 Paso 3: Acceso Inmediato a la Aplicación
Al finalizar la instalación, el script mostrará la dirección IP del contenedor:
* **URL:** **`http://<IP-DEL-LXC>:5000`** (ejemplo: `http://192.168.1.150:5000`)
* **Email Administrador:** `admin@edu-space.com`
* **Contraseña Administrador:** `admin123`
---
### 🛠️ Comandos de Gestión del Servicio en el LXC
```bash
# Consultar estado en tiempo real
systemctl status admin-edu-space
# Seguir logs de la aplicación
journalctl -u admin-edu-space -f
# Reiniciar la aplicación
systemctl restart admin-edu-space
# Detener el servicio
systemctl stop admin-edu-space
```
---
### 💾 Generación de Backups y Migración a Otros Contenedores LXC
Para respaldar la base de datos en ejecución o clonarla a otro contenedor LXC:
```bash
# Dentro del contenedor LXC:
cd /opt/admin-edu-space
bash backup_db.sh
```
* El script genera un archivo fechado en `backups/classrooms_db_YYYYMMDD_HHMMSS.sql`.
* Actualiza automáticamente el archivo principal [classrooms_db.sql](file:///c:/Workspace/admin-edu-space/classrooms_db.sql).
* **Para migrar a otro contenedor:** Podés transferir `classrooms_db.sql` mediante SCP o Git y ejecutar `bash install.sh` en el nuevo LXC.
---
### 💻 Instalación Manual para Desarrollo Local (con `venv`)
Si deseás correr el proyecto en una máquina de desarrollo (Windows / Linux / macOS) sin usar el instalador de Proxmox:
1. **Crear y activar el entorno virtual:**
* En Windows (PowerShell):
```powershell
python -m venv venv python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate .\venv\Scripts\Activate.ps1
```
* En Linux / macOS:
```bash
python3 -m venv venv
source venv/bin/activate
``` ```
3. **Install dependencies:** 2. **Instalar dependencias:**
```bash ```bash
pip install -r requirements.txt pip install -r requirements.txt
``` ```
4. **Setup environment variables:** 3. **Configurar base de datos local y restaurar snapshot:**
```bash ```bash
cp .env.example .env cp .env.example .env
# Edit .env with your configuration
# Crear base de datos en PostgreSQL local y cargar datos
sudo -u postgres psql -c "CREATE DATABASE classrooms_db;"
sudo -u postgres psql -d classrooms_db < classrooms_db.sql
``` ```
5. **Initialize database:** 4. **Compilar catálogos de traducción:**
```bash ```bash
python app.py pybabel compile -d translations
``` ```
## Configuration 5. **Iniciar el servidor:**
### 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 ```bash
python app.py python wsgi.py
``` ```
Disponible en: **`http://localhost:5000`**
The application will be available at: `http://localhost:5000`
### Default Login
- **Email**: admin@edu-space.com
- **Password**: admin123
### Main Features
1. **Dashboard**: Overview of system statistics and upcoming reservations
2. **Classrooms**: Manage classroom information, capacity, and resources
3. **Schedule**: View and manage reservations with calendar interface
4. **Profile**: User profile and password management
## API Integration
The admin panel integrates with the Spring Boot API at `http://localhost:8080/api`. Make sure the Java microservice is running for full functionality.
## Project Structure
```
admin-edu-space/
├── app/
│ ├── __init__.py # Flask app factory
│ ├── models/ # Database models
│ │ ├── user.py
│ │ ├── classroom.py
│ │ ├── subject.py
│ │ └── reservation.py
│ ├── routes/ # Application routes
│ │ ├── auth.py
│ │ ├── main.py
│ │ ├── classrooms.py
│ │ └── schedule.py
│ ├── forms/ # WTForms classes
│ │ ├── auth.py
│ │ ├── classroom.py
│ │ └── reservation.py
│ ├── templates/ # Jinja2 templates
│ ├── static/ # Static files
├── config/ # Configuration files
├── requirements.txt
├── .env
├── app.py
└── README.md
```
## Database Schema
### Users Table
- User authentication and authorization
- Role management (admin, staff)
### Classrooms Table
- Classroom information
- Capacity and location details
- Resource management
### Reservations Table
- Booking management
- Time slot scheduling
- Status tracking
### Subjects + Commissions Tables
- Academic information
- Teacher assignments
- Student enrollment tracking
## Security Features
- Password hashing with bcrypt
- Session management
- CSRF protection
- Input validation and sanitization
- SQL injection prevention with SQLAlchemy ORM
## Development
### Running in Development Mode
```bash
export FLASK_ENV=development
python app.py
```
### Adding New Features
1. Create models in `app/models/`
2. Add routes in `app/routes/`
3. Create forms if needed in `app/forms/`
4. Update templates in `app/templates/`
## Production Deployment
### Environment Setup
1. **Set production variables:**
```env
FLASK_ENV=production
SECRET_KEY=<strong-random-string>
DATABASE_URL=<production-database-url>
```
2. **Use production WSGI server:**
```bash
pip install gunicorn
gunicorn -w 4 -b 0.0.0.0:5000 app:app
```
3. **Configure reverse proxy** (nginx/Apache)
## Troubleshooting
### Common Issues
1. **Database connection errors:**
- Check DATABASE_URL string
- Ensure directory permissions
2. **Login issues:**
- Verify admin user exists
- Check password hash
3. **Static files not loading:**
- Ensure blueprint route registration
- Check file permissions
## Contributing
1. Fork the repository
2. Create feature branch
3. Make changes
4. Test thoroughly
5. Submit pull request
## License
This project is part of the Edu-Space educational management system.
## Support
For support and questions:
- Email: admin@edu-space.com
- Documentation: Check inline comments and README files
--- ---
**Note**: This admin panel is designed to work alongside the Spring Boot microservice. Ensure both services are running for complete functionality. ## 🔑 Credenciales Predeterminadas
| Usuario | Correo Electrónico | Contraseña | Rol |
| :--- | :--- | :--- | :--- |
| **Administrador del Sistema** | `admin@edu-space.com` | `admin123` | `Admin` (Acceso Total) |
> ⚠️ **Seguridad:** Cambiá la contraseña del administrador desde la interfaz web inmediatamente después del primer inicio de sesión.
---
## 📁 Estructura del Proyecto
```text
admin-edu-space/
├── app/
│ ├── __init__.py # Factoría de la aplicación Flask y extensiones
│ ├── forms/ # Formularios Flask-WTF con validación CSRF
│ │ ├── auth.py # Login, perfil y cambio de clave
│ │ ├── building.py # Sedes y edificios
│ │ ├── classroom.py # Aulas y capacidades (con soporte ∞)
│ │ └── reservation.py # Reservas de aulas y horarios
│ ├── models/ # Modelos relacionales SQLAlchemy
│ │ ├── academic_term.py # Ciclos lectivos y períodos académicos (activo/histórico)
│ │ ├── building.py # Sedes y edificios institucionales
│ │ ├── career.py # Carreras y titulaciones universitarias
│ │ ├── classroom.py # Aulas físicas y virtuales con capacidades
│ │ ├── genetic_algorithm.py # Estructuras de optimización heurística
│ │ ├── milestone.py # Tipificaciones de hitos académicos y exámenes
│ │ ├── reservation.py # Reservas horarias, estados y turnos
│ │ ├── role.py # Roles y matriz de permisos granulares RBAC
│ │ ├── subject.py # Asignaturas, materias y comisiones de cursada
│ │ └── user.py # Usuarios, credenciales y asignación de roles
│ ├── routes/ # Controladores y rutas modulares (Blueprints)
│ │ ├── admin.py # Carreras, Asignaturas, Ciclo Lectivo, Tipificaciones, Métricas, Usuarios y Roles
│ │ ├── auth.py # Autenticación, sesiones y perfil
│ │ ├── buildings.py # Gestión de sedes y edificios
│ │ ├── classrooms.py # Aulas presenciales vs. Campus Virtual a demanda y métricas de uso
│ │ ├── genetic_algorithm.py # Optimizador heurístico de asignación de espacios
│ │ ├── main.py # Dashboard principal y métricas rápidas
│ │ └── schedule.py # Calendario interactivo, cartelera del día, comisiones y reservas
│ ├── services/ # Servicios y lógica de negocio
│ │ └── sheets_importer.py # Parser y sincronizador bidireccional de Google Sheets
│ ├── static/ # Recursos estáticos web (CSS, JS, imágenes institucionales)
│ └── templates/ # Vistas Jinja2 renderizadas con Bootstrap 5
├── config/ # Configuraciones de entorno (desarrollo, testing, prod)
├── translations/ # Catálogos GNU gettext compilados (es / en)
├── classrooms_db.sql # Snapshot completo de base de datos (PostgreSQL 14+)
├── install.sh # Instalador automatizado para Proxmox VE LXC (venv + PostgreSQL)
├── backup_db.sh # Script para generar nuevos backups y actualizar snapshot
├── init_db.py # Inicializador DDL y sincronizador de esquema/roles
├── CHANGELOG_SESSION.md # Historial técnico cronológico
├── requirements.txt # Dependencias Python (incluye Gunicorn)
├── .env.example # Plantilla de variables de entorno
└── wsgi.py # Punto de entrada de la aplicación Flask
```
---
## 🚀 Roadmap Académico & Propuesta de Evolución Tecnológica
En respuesta a los requerimientos y sugerencias docentes, se formaliza el plan de evolución arquitectónica, estándares de seguridad y entrega de valor institucional para la plataforma **Admin Edu-Space (UniCABA)**.
### 💡 1. Propuesta de Mejora Integral
#### A. Arquitectura MVC Rigurosa & Clean Architecture
* **Desacoplamiento de Capas:**
* **Model (Dominio):** Entidades relacionales puras y reglas de integridad del negocio desacopladas del framework web (`app/models/`).
* **View (Presentación):** Vistas web en Jinja2 con Bootstrap 5 y respuestas JSON estructuradas para clientes API/móviles.
* **Controller (Controladores/Blueprints):** Controladores delgados en `app/routes/` enfocados exclusivamente en la orquestación HTTP y redirección.
* **Service Layer & Repositories (`app/services/` & `app/repositories/`):** Aislamiento de la lógica de negocio (detección de colisiones, algoritmo genético de asignación, ETL de Google Sheets) y operaciones de persistencia encapsuladas.
* **DTOs y Validación de Entrada:** Incorporación de esquemas tipados (Pydantic / Marshmallow) para filtrar y sanitizar datos de entrada antes de interactuar con el ORM, eliminando validaciones ad-hoc en los controladores.
#### B. Autenticación & Autorización Stateless con JWT (JSON Web Tokens)
* **Arquitectura API-First Híbrida:**
* Coexistencia armónica de sesiones web seguras (para el panel administrativo en navegadores) y tokens **JWT** (para microservicios, clientes móviles y aplicaciones externas).
* **Esquema Dual de Tokens (Dual Token Pattern):**
* `Access Token` (firmado con RS256/HS256, expiración corta de 15 minutos) con claims contextuales (`sub`, `roles`, `permissions`, `exp`).
* `Refresh Token` (almacenado en cookie segura `HttpOnly`, `Secure`, `SameSite=Strict`, expiración de 7 días) para renovación silenciosa sin reingreso de credenciales.
* **Mecanismo de Revocación Inmediata (Token Blacklist):** Almacenamiento en caché distribuida (Redis) para invalidación instantánea ante cierres de sesión forzados o rotación de contraseñas.
#### C. Seguridad Empresarial y Paradigma "Sprint / Spring Security"
* **Cadena de Filtros de Interceptación (SecurityFilterChain):** Pipeline centralizado inspirado en los estándares de Spring Security:
* **Authentication Filter:** Intercepta cabeceras `Authorization: Bearer <token>` y valida firmas criptográficas.
* **Security Headers Filter:** Inyección obligatoria de cabeceras de blindaje HTTP (HSTS, CSP, X-Frame-Options: DENY, X-Content-Type-Options: nosniff).
* **Rate Limiting Filter:** Prevención de ataques de fuerza bruta y DDoS en endpoints sensibles mediante límites dinámicos (`Flask-Limiter` / Redis).
* **Seguridad Declarativa a Nivel Método (`@PreAuthorize` / `@require_permission`):**
* Decoradores reutilizables que evalúan la matriz RBAC antes de ejecutar cualquier método de servicio:
```python
@require_permission('classrooms', min_level='read_write')
def update_classroom_capacity(classroom_id, new_capacity): ...
```
* **Bitácora Inmutable de Auditoría (Audit Trail):** Registro trazable de eventos críticos (creación/modificación de reservas, cambios de roles y accesos fallidos) con IP de origen, usuario actuante, timestamp y delta de cambios.
---
### 📅 2. Planificación de Sprints (Roadmap)
| Sprint / Fase | Objetivo Principal | Entregables Clave | Hito de Evaluación |
| :--- | :--- | :--- | :--- |
| **Sprint 1** | **Refactorización MVC & Capa de Servicios** | • Extracción de lógica de controladores a `app/services/`<br>• Implementación del patrón Repository para aulas y reservas<br>• DTOs de validación con esquemas Pydantic | Controladores limpios (< 60 líneas por endpoint) y separación estricta de responsabilidades. |
| **Sprint 2** | **Autenticación Stateless JWT & API Gateway** | • Endpoints `/api/auth/login`, `/refresh`, `/logout`<br>• Decoradores `@jwt_required` y claim parser<br>• Middleware Bearer Token para interoperabilidad | Autenticación y consumo seguro desde Postman y aplicaciones externas sin cookies de sesión. |
| **Sprint 3** | **Sprint Security & Gobernanza RBAC Avanzada** | • Pipeline `SecurityFilterChain` centralizado<br>• Decorador `@require_permission(module, level)` a nivel servicio<br>• Rate Limiting en login y endpoints públicos<br>• Módulo de auditoría inmutable de eventos (`AuditLog`) | Aprobación de pentest simulado OWASP Top 10 y matriz de permisos auditada. |
| **Sprint 4** | **Optimizador Heurístico & Alta Concurrencia** | • Paralelización del algoritmo genético con multiprocessing/workers<br>• Colas asíncronas para optimizaciones pesadas<br>• Integración con microservicio Spring Boot vía REST/JWT | Optimización de 200+ comisiones en menos de 5 segundos con 0 colisiones horarias. |
---
### 💎 3. Valor Agregado & Retorno Institucional
1. **Eficiencia Edilicia y Reducción del Desperdicio de Espacio:**
- Maximiza el aprovechamiento de las aulas físicas de la Sede Central (**Tte. Gral. Juan Domingo Perón 802**), pasando de una asignación manual y empírica a un modelo algorítmico basado en capacidad real, accesibilidad de pisos y equipamiento.
2. **Ahorro de Tiempo Operativo (85% de reducción):**
- Automatiza la resolución de conflictos de solapamiento horario en horarios pico (Turno Vespertino, 17:30 a 21:30 hs) y agiliza la sincronización con la cartelera de Google Sheets en lote.
3. **Interoperabilidad Universal:**
- La arquitectura desacoplada y la capa JWT permiten que cualquier otro sistema de la universidad (SIU Guaraní, Campus Virtual Moodle, App de estudiantes, backend Spring Boot institucional) consulte en tiempo real la disponibilidad áulica.
4. **Despliegue de Alta Eficiencia (Bajo Costo de Infraestructura):**
- A diferencia de arquitecturas pesadas basadas en contenedores Docker o Kubernetes que demandan múltiples gigabytes de RAM, **Admin Edu-Space corre de forma nativa en un contenedor Proxmox VE LXC consumiendo menos de 180MB de memoria**, garantizando alta disponibilidad con costos de hardware mínimos.
---
### 🧩 4. Desafíos Técnicos (Challenges)
* **Challenge 1: Resolución Heurística de Horarios Áulicos (NP-Hard Problem):**
- La asignación de 206 asignaturas, 400+ reservas y 25 aulas considerando restricciones duras (no colisión horaria, capacidad no superada) y blandas (mismo docente en pisos cercanos, turnos consolidados) representa un problema de complejidad NP-Hard resuelto mediante un **Algoritmo Genético** con operadores de cruce y mutación adaptativa.
* **Challenge 2: Sincronización Masiva Tolerante a Fallos:**
- El parser ETL de Google Sheets normaliza datos no estructurados de la cartelera académica, resolviendo discrepancias ortográficas, turnos implícitos y formatos dispares de hora sin interrumpir la operación continua.
* **Challenge 3: Portabilidad Extrema en Proxmox LXC sin Docker:**
- Desarrollo de un instalador auto-recuperable ([install.sh](file:///c:/Workspace/admin-edu-space/install.sh)) capaz de levantar PostgreSQL nativo en Debian 12 y 13 dentro de `tmpfs`, gestionar clusters con `pg_createcluster`, operar de forma autónoma con o sin `sudo`, y garantizar persistencia íntegra de base de datos entre dispositivos.
---
## 📄 Licencia
Este proyecto forma parte del sistema de gestión institucional y educativa de **UniCABA**. Todos los derechos reservados.
␍
+60 -18
View File
@@ -30,41 +30,83 @@ def create_app(config_class=Config):
# Import models to ensure they are registered # Import models to ensure they are registered
from app.models import user from app.models import user
# Register blueprints # Register blueprints
from app.routes import auth_bp, classrooms_bp, main_bp, schedule_bp, genetic_bp, genetic_web_bp 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(auth_bp, url_prefix="/")
app.register_blueprint(classrooms_bp, url_prefix="/classrooms") app.register_blueprint(classrooms_bp, url_prefix="/classrooms")
app.register_blueprint(buildings_bp)
app.register_blueprint(main_bp, url_prefix="/") app.register_blueprint(main_bp, url_prefix="/")
app.register_blueprint(schedule_bp, url_prefix="/schedule") app.register_blueprint(schedule_bp, url_prefix="/schedule")
app.register_blueprint(genetic_bp) app.register_blueprint(genetic_bp)
app.register_blueprint(genetic_web_bp) app.register_blueprint(genetic_web_bp)
app.register_blueprint(admin_bp)
# Babel language selector # 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 @app.context_processor
def inject_conf_vars(): def inject_conf_vars():
return dict( return dict(
languages=app.config['LANGUAGES'], languages=app.config['LANGUAGES'],
current_lang=session.get('language', request.accept_languages.best_match(app.config['LANGUAGES']) or app.config['BABEL_DEFAULT_LOCALE']) current_lang=str(get_locale())
) )
# Language selector function
def get_locale():
# 1. Check if language is explicitly set in URL parameters
if request.args.get('lang'):
lang = request.args.get('lang')
if lang in app.config['LANGUAGES']:
return lang
# 2. Check if language is stored in session
if 'language' in session:
return session['language']
# 3. Check browser language preference
return request.accept_languages.best_match(app.config['LANGUAGES']) or app.config['BABEL_DEFAULT_LOCALE']
# Configure Babel # Configure Babel
babel.init_app(app, locale_selector=get_locale) 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 return app
+5 -4
View File
@@ -1,13 +1,14 @@
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Email, Length from wtforms.validators import DataRequired, Email, Length
from flask_babel import lazy_gettext as _l
class LoginForm(FlaskForm): class LoginForm(FlaskForm):
email = StringField('Email', validators=[ email = StringField(_l('Email'), validators=[
DataRequired(), DataRequired(),
Email(), Email(),
Length(max=120) Length(max=120)
]) ])
password = PasswordField('Password', validators=[DataRequired()]) password = PasswordField(_l('Password'), validators=[DataRequired()])
remember_me = BooleanField('Remember me') remember_me = BooleanField(_l('Remember me'))
submit = SubmitField('Sign In') submit = SubmitField(_l('Sign In'))
+28
View File
@@ -0,0 +1,28 @@
from flask_wtf import FlaskForm
from wtforms import StringField, IntegerField, TextAreaField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Length, NumberRange, Optional
from flask_babel import lazy_gettext as _l
class BuildingForm(FlaskForm):
name = StringField(_l('Building Name'), validators=[
DataRequired(),
Length(min=2, max=100)
])
code = StringField(_l('Code'), validators=[
Optional(),
Length(max=50)
])
address = StringField(_l('Address'), validators=[
Optional(),
Length(max=255)
])
floors = IntegerField(_l('Number of Floors'), default=1, validators=[
DataRequired(),
NumberRange(min=1, max=100)
])
description = TextAreaField(_l('Description'), validators=[
Optional(),
Length(max=500)
])
is_active = BooleanField(_l('Active'), default=True)
submit = SubmitField(_l('Save Building'))
+41 -29
View File
@@ -1,44 +1,56 @@
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, IntegerField, TextAreaField, BooleanField, SubmitField, SelectField from wtforms import StringField, IntegerField, TextAreaField, BooleanField, SubmitField, SelectField
from wtforms.validators import DataRequired, Length, NumberRange, Optional from wtforms.validators import DataRequired, InputRequired, Length, NumberRange, Optional, ValidationError
from flask_babel import lazy_gettext as _l
def validate_capacity(form, field):
val = str(field.data or '').strip()
if not val:
raise ValidationError(_l('La capacidad es obligatoria (ingrese un número o el símbolo ∞).'))
if val in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']:
return
try:
num = int(val)
if num < 0:
raise ValidationError(_l('La capacidad no puede ser negativa.'))
if num > 10000:
raise ValidationError(_l('La capacidad máxima numérica es 10.000.'))
except ValueError:
raise ValidationError(_l('Ingrese un número entero o el símbolo ∞ para capacidad ilimitada.'))
class ClassroomForm(FlaskForm): class ClassroomForm(FlaskForm):
# campus_id = SelectField('Campus', coerce=int, validators=[DataRequired()]) building_id = SelectField(_l('Edificio'), validators=[DataRequired()])
building = StringField('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('Room Number', validators=[
DataRequired(), DataRequired(),
Length(min=1, max=50) Length(min=1, max=50)
]) ])
floor = IntegerField('Floor', validators=[ floor = StringField(_l('Piso'), validators=[
DataRequired(), DataRequired(message=_l('El piso es obligatorio (ej: PB, 1, 2, 4°P)')),
NumberRange(min=1, max=50) Length(min=1, max=50)
]) ])
capacity = IntegerField('Capacity', validators=[ capacity = StringField(_l('Capacidad'), validators=[
DataRequired(), validate_capacity
NumberRange(min=1, max=500)
]) ])
description = TextAreaField('Description', validators=[ description = TextAreaField(_l('Descripción'), validators=[
Optional(), Optional(),
Length(max=500) Length(max=500)
]) ])
is_active = BooleanField('Active', default=True) is_active = BooleanField(_l('Activa'), default=True)
submit = SubmitField('Save Classroom') submit = SubmitField(_l('Guardar Aula'))
class ClassroomResourceForm(FlaskForm): class ClassroomResourceForm(FlaskForm):
resource_type = SelectField('Resource Type', choices=[ resource_type = SelectField(_l('Resource Type'), choices=[
('PROJECTOR', 'Projector'), ('PROJECTOR', _l('Projector')),
('WHITEBOARD', 'Whiteboard'), ('WHITEBOARD', _l('Whiteboard')),
('COMPUTER', 'Computer'), ('COMPUTER', _l('Computer')),
('SOUND_SYSTEM', 'Sound System'), ('SOUND_SYSTEM', _l('Sound System')),
('MICROPHONE', 'Microphone'), ('MICROPHONE', _l('Microphone')),
('AIR_CONDITIONER', 'Air Conditioner'), ('AIR_CONDITIONER', _l('Air Conditioner')),
('OTHER', 'Other') ('OTHER', _l('Other'))
], validators=[DataRequired()]) ], validators=[DataRequired()])
brand = StringField('Brand', validators=[Optional(), Length(max=100)]) brand = StringField(_l('Brand'), validators=[Optional(), Length(max=100)])
model = StringField('Model', validators=[Optional(), Length(max=100)]) model = StringField(_l('Model'), validators=[Optional(), Length(max=100)])
specifications = TextAreaField('Specifications', validators=[Optional()]) specifications = TextAreaField(_l('Specifications'), validators=[Optional()])
functional = BooleanField('Functional', default=True) functional = BooleanField(_l('Functional'), default=True)
submit = SubmitField('Save Resource') submit = SubmitField(_l('Save Resource'))
+19 -10
View File
@@ -1,26 +1,35 @@
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import SelectField, StringField, TextAreaField, IntegerField, SubmitField, DateTimeLocalField from wtforms import SelectField, StringField, TextAreaField, IntegerField, SubmitField, DateTimeLocalField
from wtforms.validators import DataRequired, Length, NumberRange, Optional, ValidationError from wtforms.validators import DataRequired, Length, NumberRange, Optional, ValidationError
from flask_babel import lazy_gettext as _l
from datetime import datetime, timedelta from datetime import datetime, timedelta
class ReservationForm(FlaskForm): class ReservationForm(FlaskForm):
classroom_id = SelectField('Classroom', coerce=int, validators=[DataRequired()]) classroom_id = SelectField(_l('Classroom'), coerce=int, validators=[DataRequired()])
commission_id = SelectField('Commission/Subject', coerce=int, validators=[DataRequired()]) commission_id = SelectField(_l('Commission/Subject'), coerce=int, validators=[DataRequired()])
start_time = DateTimeLocalField('Start Time', format='%Y-%m-%dT%H:%M', validators=[DataRequired()]) start_time = DateTimeLocalField(_l('Start Time'), format='%Y-%m-%dT%H:%M', validators=[DataRequired()])
end_time = DateTimeLocalField('End Time', format='%Y-%m-%dT%H:%M', validators=[DataRequired()]) end_time = DateTimeLocalField(_l('End Time'), format='%Y-%m-%dT%H:%M', validators=[DataRequired()])
purpose = StringField('Purpose', validators=[ purpose = StringField(_l('Purpose'), validators=[
DataRequired(), DataRequired(),
Length(min=3, max=500) Length(min=3, max=500)
]) ])
expected_attendees = IntegerField('Expected Attendees', validators=[ expected_attendees = IntegerField(_l('Expected Attendees'), validators=[
DataRequired(), DataRequired(),
NumberRange(min=1, max=500) NumberRange(min=1, max=500)
]) ])
notes = TextAreaField('Notes', validators=[ notes = TextAreaField(_l('Notes'), validators=[
Optional(), Optional(),
Length(max=1000) Length(max=1000)
]) ])
submit = SubmitField('Make Reservation') virtual_link = StringField(_l('Virtual Classroom Link'), validators=[
Optional(),
Length(max=500)
])
submit = SubmitField(_l('Make Reservation'))
def __init__(self, *args, is_edit=False, **kwargs):
super().__init__(*args, **kwargs)
self.is_edit = is_edit
def validate_end_time(self, field): def validate_end_time(self, field):
if field.data <= self.start_time.data: if field.data <= self.start_time.data:
@@ -31,8 +40,8 @@ class ReservationForm(FlaskForm):
raise ValidationError('Reservation cannot exceed 8 hours.') raise ValidationError('Reservation cannot exceed 8 hours.')
def validate_start_time(self, field): def validate_start_time(self, field):
# Don't allow reservations in the past # Don't allow new reservations in the past
if field.data < datetime.now(): if not self.is_edit and field.data < datetime.now() - timedelta(minutes=10):
raise ValidationError('Start time cannot be in the past.') raise ValidationError('Start time cannot be in the past.')
# Don't allow reservations too far in the future (max 3 months) # Don't allow reservations too far in the future (max 3 months)
+15 -1
View File
@@ -1,17 +1,31 @@
from .user import User from .user import User
from .building import Building
from .classroom import Classroom, ClassroomResource from .classroom import Classroom, ClassroomResource
from .career import Career
from .academic_term import AcademicTerm
from .subject import Subject, Commission from .subject import Subject, Commission
from .reservation import Reservation, ReservationStatus from .reservation import Reservation, ReservationStatus
from .genetic_algorithm import GeneticAlgorithm, ReservationOptimizer from .genetic_algorithm import GeneticAlgorithm, ReservationOptimizer
from .role import Role, Permission, SYSTEM_MODULES
from .milestone import MilestoneType, AcademicMilestone, BASE_MILESTONE_TYPES
__all__ = [ __all__ = [
'User', 'User',
'Building',
'Classroom', 'Classroom',
'ClassroomResource', 'ClassroomResource',
'Career',
'AcademicTerm',
'Subject', 'Subject',
'Commission', 'Commission',
'Reservation', 'Reservation',
'ReservationStatus', 'ReservationStatus',
'GeneticAlgorithm', 'GeneticAlgorithm',
'ReservationOptimizer' 'ReservationOptimizer',
'Role',
'Permission',
'SYSTEM_MODULES',
'MilestoneType',
'AcademicMilestone',
'BASE_MILESTONE_TYPES'
] ]
+34
View File
@@ -0,0 +1,34 @@
from app import db
from datetime import datetime
class AcademicTerm(db.Model):
"""Modelo para Ciclos Lectivos y Períodos Académicos"""
__tablename__ = 'academic_terms'
id = db.Column(db.Integer, primary_key=True)
code = db.Column(db.String(50), unique=True, nullable=False, index=True) # ej. 2026-2, 2026-1, 2025-2
name = db.Column(db.String(150), nullable=False) # ej. Ciclo Lectivo 2026 - 2º Cuatrimestre
year = db.Column(db.Integer, nullable=False, default=2026, index=True)
period_type = db.Column(db.String(50), nullable=False, default='Segundo Cuatrimestre') # Primer Cuatrimestre, Segundo Cuatrimestre, Anual, Verano
start_date = db.Column(db.Date, nullable=True)
end_date = db.Column(db.Date, nullable=True)
is_current = db.Column(db.Boolean, default=False, nullable=False, index=True)
is_active = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def to_dict(self):
return {
'id': self.id,
'code': self.code,
'name': self.name,
'year': self.year,
'period_type': self.period_type,
'start_date': self.start_date.isoformat() if self.start_date else None,
'end_date': self.end_date.isoformat() if self.end_date else None,
'is_current': self.is_current,
'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None
}
def __repr__(self):
return f'<AcademicTerm {self.code} - {self.name}>'
+45
View File
@@ -0,0 +1,45 @@
from app import db
from datetime import datetime
class Building(db.Model):
__tablename__ = 'buildings'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), unique=True, nullable=False, index=True)
code = db.Column(db.String(50), unique=True, nullable=True)
address = db.Column(db.String(255), nullable=True)
floors = db.Column(db.Integer, default=1, nullable=False)
description = db.Column(db.Text, nullable=True)
is_active = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
classrooms = db.relationship('Classroom', backref='building_entity', lazy=True, foreign_keys='Classroom.building_id')
@property
def total_capacity(self):
"""Total student capacity across active classrooms in this building"""
return sum(c.capacity for c in self.classrooms if c.is_active)
@property
def classrooms_count(self):
"""Number of active classrooms"""
return len([c for c in self.classrooms if c.is_active])
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'code': self.code,
'address': self.address,
'floors': self.floors,
'description': self.description,
'is_active': self.is_active,
'classrooms_count': self.classrooms_count,
'total_capacity': self.total_capacity,
'created_at': self.created_at.isoformat() if self.created_at else None
}
def __repr__(self):
return f'<Building {self.name}>'
+27
View File
@@ -0,0 +1,27 @@
from app import db
from datetime import datetime
class Career(db.Model):
__tablename__ = 'careers'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(150), unique=True, nullable=False, index=True)
code = db.Column(db.String(50), nullable=True)
description = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Relationships
subjects = db.relationship('Subject', backref='career_obj', lazy=True)
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'code': self.code,
'description': self.description,
'subjects_count': len(self.subjects) if self.subjects else 0,
'created_at': self.created_at.isoformat() if self.created_at else None
}
def __repr__(self):
return f'<Career {self.name}>'
+62 -11
View File
@@ -6,25 +6,60 @@ class Classroom(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
campus_id = db.Column(db.Integer, nullable=True) # Maps to campus_id in DB campus_id = db.Column(db.Integer, nullable=True) # Maps to campus_id in DB
building_id = db.Column(db.Integer, db.ForeignKey('buildings.id'), nullable=True)
building = db.Column(db.String(100), nullable=False) building = db.Column(db.String(100), nullable=False)
code = db.Column('room_number', db.String(50), nullable=False) # Maps to room_number column in DB code = db.Column('room_number', db.String(50), nullable=False) # Maps to room_number column in DB
floor = db.Column(db.Integer, nullable=False) floor = db.Column(db.String(50), nullable=False, default='1')
capacity = db.Column(db.Integer, nullable=False) capacity = db.Column(db.Integer, nullable=False)
description = db.Column(db.Text) description = db.Column(db.Text)
is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relación con Building (mapeada vía backref 'building_entity' en Building.classrooms)
@property
def building_rel(self):
return self.building_entity
@property
def building_name(self):
"""Nombre del edificio desacoplado"""
if self.building_rel:
return self.building_rel.name
return self.building or 'Edificio Central'
@property
def floor_display(self):
"""Formato amigable para el piso: PB, Planta Baja, Piso X"""
f = str(self.floor).strip() if self.floor is not None else ''
if f in ['0', 'PB', 'pb', 'Planta Baja', 'planta baja']:
return 'Planta Baja'
if f.lower().startswith('piso') or '°' in f:
return f
return f"Piso {f}"
@property
def is_virtual(self):
"""Indica si el aula es virtual"""
return (self.code and 'VIRTUAL' in self.code.upper()) or (self.building and 'VIRTUAL' in self.building.upper())
@property
def location_display(self):
"""Ubicación completa y limpia: Edificio · Piso · Aula"""
if self.is_virtual:
return '🌐 Campus Virtual'
return f"{self.building_name} · {self.floor_display} · {self.code}"
# Virtual properties for compatibility # Virtual properties for compatibility
@property @property
def name(self): def name(self):
"""Generate name from building and code""" """Generate name from building and code"""
return f"{self.building}-{self.code}" return f"{self.building_name}-{self.code}"
@property @property
def campus_location(self): def campus_location(self):
"""Placeholder for campus location - can be expanded when campus model is implemented""" """Placeholder for campus location"""
return None return self.building_rel.address if self.building_rel else None
# Virtual field for compatibility - combines building and code # Virtual field for compatibility - combines building and code
@property @property
@@ -38,17 +73,34 @@ class Classroom(db.Model):
# Override code property for consistency # Override code property for consistency
@property @property
def code_display(self): def code_display(self):
return f"{self.building}-{self.code}" return f"{self.building_name} - {self.code}"
@property
def capacity_display(self):
"""Retorna '∞' para aulas virtuales o capacidad ilimitada, o el número entero."""
if self.capacity == 0 or self.is_virtual:
return '∞'
return str(self.capacity)
@property
def is_unlimited(self):
return self.capacity == 0 or self.is_virtual
def to_dict(self): def to_dict(self):
"""Convert classroom to dictionary""" """Convert classroom to dictionary"""
return { return {
'id': self.id, 'id': self.id,
'code': self.code_display, 'building_id': self.building_id,
'building': self.building, 'building': self.building_name,
'code': self.code,
'code_display': self.code_display,
'name': self.name, 'name': self.name,
'floor': self.floor, 'floor': self.floor,
'floor_display': self.floor_display,
'location_display': self.location_display,
'capacity': self.capacity, 'capacity': self.capacity,
'capacity_display': self.capacity_display,
'is_virtual': self.is_virtual,
'description': self.description, 'description': self.description,
'campus_location': self.campus_location, 'campus_location': self.campus_location,
'is_active': self.is_active, 'is_active': self.is_active,
@@ -59,10 +111,9 @@ class Classroom(db.Model):
def get_full_location(self): def get_full_location(self):
"""Get full classroom location""" """Get full classroom location"""
location = f"{self.building} - Floor {self.floor}" if self.is_virtual:
if self.campus_location: return 'Campus Virtual (Remoto)'
location += f" ({self.campus_location})" return f"{self.building_name} - {self.floor_display}"
return location
@staticmethod @staticmethod
def find_best_classroom(max_students, start_time, end_time, exclude_reservation_id=None): def find_best_classroom(max_students, start_time, end_time, exclude_reservation_id=None):
+180
View File
@@ -0,0 +1,180 @@
from datetime import datetime
from app import db
class MilestoneType(db.Model):
"""
Catálogo dinámico y administrable de tipificaciones de hitos y evaluaciones académicas.
(Parciales, Trabajos Prácticos, Recuperatorios, Finales, etc.)
"""
__tablename__ = 'milestone_types'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False) # Ej: "Parcial 1", "Trabajo Práctico 1"
code = db.Column(db.String(50), unique=True, nullable=False, index=True) # Ej: "parcial_1", "tp_1"
color = db.Column(db.String(30), default='#0d6efd') # Hex color para badges y calendario
description = db.Column(db.String(255), nullable=True)
sort_order = db.Column(db.Integer, default=0) # Orden de aparición en selects
is_active = db.Column(db.Boolean, default=True, nullable=False) # Activo / Inactivo
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relación uno-a-muchos con AcademicMilestone
milestones = db.relationship('AcademicMilestone', backref='type_obj', lazy='dynamic', cascade='all, delete-orphan')
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'code': self.code,
'color': self.color or '#0d6efd',
'description': self.description or '',
'sort_order': self.sort_order or 0,
'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None
}
def __repr__(self):
return f'<MilestoneType {self.code}: {self.name}>'
class AcademicMilestone(db.Model):
"""
Hito o evento académico programado para una materia/comisión (Examen, TP, Entrega).
"""
__tablename__ = 'academic_milestones'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(150), nullable=True)
materia_codigo = db.Column(db.String(50), nullable=False, index=True) # Código de materia (ej: INGA0003)
subject_id = db.Column(db.Integer, db.ForeignKey('subjects.id'), nullable=True)
commission_id = db.Column(db.Integer, db.ForeignKey('commissions.id', ondelete='SET NULL'), nullable=True)
milestone_type_id = db.Column(db.Integer, db.ForeignKey('milestone_types.id'), nullable=False)
date = db.Column(db.DateTime, nullable=False) # Fecha y hora del hito
classroom = db.Column(db.String(100), nullable=True) # Aula o enlace virtual asignado
link = db.Column(db.String(255), nullable=True) # Link Teams / Meet / Zoom si aplica
notes = db.Column(db.Text, nullable=True) # Temario, consignas o notas
is_active = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relación con Asignatura y Comisión
subject = db.relationship('Subject', backref=db.backref('milestones', lazy='dynamic'))
commission = db.relationship('Commission', backref=db.backref('academic_milestones', lazy='dynamic', cascade='all, delete-orphan'))
@property
def tipo(self):
return self.type_obj.name if self.type_obj else 'Otro'
@property
def color(self):
return self.type_obj.color if self.type_obj and self.type_obj.color else '#0d6efd'
def to_dict(self):
return {
'id': self.id,
'title': self.title or self.tipo,
'materiaCodigo': self.materia_codigo,
'subject_id': self.subject_id,
'commission_id': self.commission_id,
'tipo': self.tipo,
'milestone_type_id': self.milestone_type_id,
'tipo_info': self.type_obj.to_dict() if self.type_obj else None,
'color': self.color,
'fecha': self.date.isoformat() if self.date else None,
'aula': self.classroom,
'link': self.link,
'notas': self.notes,
'is_active': self.is_active
}
def __repr__(self):
return f'<AcademicMilestone {self.materia_codigo} - {self.tipo} ({self.date})>'
# Datos semilla estándar para inicialización
BASE_MILESTONE_TYPES = [
{
'name': 'Parcial 1',
'code': 'parcial_1',
'color': '#0d6efd',
'description': 'Primer examen parcial de la cursada',
'sort_order': 1
},
{
'name': 'Parcial 2',
'code': 'parcial_2',
'color': '#0b5ed7',
'description': 'Segundo examen parcial de la cursada',
'sort_order': 2
},
{
'name': 'Trabajo Practico 1',
'code': 'tp_1',
'color': '#198754',
'description': 'Primera entrega o evaluacion practica',
'sort_order': 3
},
{
'name': 'Trabajo Practico 2',
'code': 'tp_2',
'color': '#157347',
'description': 'Segunda entrega o evaluacion practica',
'sort_order': 4
},
{
'name': 'Trabajo Practico 3',
'code': 'tp_3',
'color': '#0f5132',
'description': 'Tercera entrega o proyecto integrador',
'sort_order': 5
},
{
'name': 'Recuperatorio Parcial 1',
'code': 'recu_p1',
'color': '#fd7e14',
'description': 'Instancia de recuperacion para el Parcial 1',
'sort_order': 6
},
{
'name': 'Recuperatorio Parcial 2',
'code': 'recu_p2',
'color': '#e76b08',
'description': 'Instancia de recuperacion para el Parcial 2',
'sort_order': 7
},
{
'name': 'Recuperatorio TP 1',
'code': 'recu_tp1',
'color': '#ffc107',
'description': 'Reentrega o recuperacion de Trabajo Practico 1',
'sort_order': 8
},
{
'name': 'Recuperatorio TP 2',
'code': 'recu_tp2',
'color': '#e0a800',
'description': 'Reentrega o recuperacion de Trabajo Practico 2',
'sort_order': 9
},
{
'name': 'Recuperatorio TP 3',
'code': 'recu_tp3',
'color': '#c69500',
'description': 'Reentrega o recuperacion de Trabajo Practico 3',
'sort_order': 10
},
{
'name': 'Final',
'code': 'final',
'color': '#dc3545',
'description': 'Examen final regular o libre',
'sort_order': 11
},
{
'name': 'Otro',
'code': 'otro',
'color': '#6c757d',
'description': 'Taller, seminario, clase especial o hito no tipificado',
'sort_order': 12
}
]
+69 -2
View File
@@ -1,6 +1,30 @@
from app import db from app import db
from datetime import datetime, timedelta from datetime import datetime, timedelta
from enum import Enum from enum import Enum
from sqlalchemy.types import TypeDecorator, String
class StatusString(str):
"""String wrapper providing .value property for backward compatibility with Enum usage"""
@property
def value(self):
return str(self)
def __eq__(self, other):
if hasattr(other, 'value'):
return str(self) == str(other.value)
return str(self) == str(other)
class StatusType(TypeDecorator):
impl = String(20)
cache_ok = True
def process_result_value(self, value, dialect):
return StatusString(value) if value is not None else None
def process_bind_param(self, value, dialect):
if hasattr(value, 'value'):
return str(value.value)
return str(value) if value is not None else None
class ReservationStatus(Enum): class ReservationStatus(Enum):
PENDING = 'PENDING' PENDING = 'PENDING'
@@ -20,8 +44,10 @@ class Reservation(db.Model):
start_time = db.Column(db.DateTime, nullable=False) start_time = db.Column(db.DateTime, nullable=False)
end_time = db.Column(db.DateTime, nullable=False) end_time = db.Column(db.DateTime, nullable=False)
purpose = db.Column(db.String(500), nullable=False) purpose = db.Column(db.String(500), nullable=False)
status = db.Column(db.String(20), default='PENDING', nullable=False) # DB uses VARCHAR not ENUM status = db.Column(StatusType, default='PENDING', nullable=False) # Maps to VARCHAR(20) with StatusString
expected_attendees = db.Column(db.Integer, nullable=False) expected_attendees = db.Column(db.Integer, nullable=False)
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
virtual_link = db.Column(db.String(500), nullable=True) # Zoom, Meet, Teams, etc.
notes = db.Column(db.Text) notes = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
@@ -32,10 +58,49 @@ class Reservation(db.Model):
def status_enum(self): def status_enum(self):
"""Get status as enum""" """Get status as enum"""
try: try:
return ReservationStatus(self.status) return ReservationStatus(str(self.status))
except (ValueError, AttributeError): except (ValueError, AttributeError):
return ReservationStatus.PENDING return ReservationStatus.PENDING
@property
def subject_code(self):
"""Get exact subject code (e.g. INGA0003, ASIG00165)"""
if self.commission and self.commission.subject:
return self.commission.subject.code
return ''
@property
def subject_name(self):
"""Get subject name"""
if self.commission and self.commission.subject:
return self.commission.subject.name
return self.purpose or ''
@property
def classroom_name(self):
"""Get classroom code or name"""
if self.classroom:
return self.classroom.code
return ''
@property
def effective_virtual_link(self):
"""Devuelve el enlace explícito de la reserva o hereda el de la comisión"""
if self.virtual_link and self.virtual_link.strip():
return self.virtual_link.strip()
if self.commission and self.commission.virtual_link:
return self.commission.virtual_link.strip()
return None
@property
def is_virtual(self):
"""Verifica si la reserva es virtual por aula o por contar con enlace virtual"""
if self.classroom and self.classroom.is_virtual:
return True
if self.effective_virtual_link:
return True
return False
# Relationships # Relationships
classroom = db.relationship('Classroom', backref=db.backref('reservation_list', lazy=True, cascade='all, delete-orphan')) classroom = db.relationship('Classroom', backref=db.backref('reservation_list', lazy=True, cascade='all, delete-orphan'))
commission = db.relationship('Commission', backref=db.backref('reservation_list', lazy=True, cascade='all, delete-orphan')) commission = db.relationship('Commission', backref=db.backref('reservation_list', lazy=True, cascade='all, delete-orphan'))
@@ -54,6 +119,8 @@ class Reservation(db.Model):
'status': self.status, # Already a string in DB 'status': self.status, # Already a string in DB
'expected_attendees': self.expected_attendees, 'expected_attendees': self.expected_attendees,
'notes': self.notes, 'notes': self.notes,
'virtual_link': self.effective_virtual_link,
'is_virtual': self.is_virtual,
'created_at': self.created_at.isoformat() if self.created_at else None, 'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None, 'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'classroom': self.classroom.to_dict() if self.classroom else None, 'classroom': self.classroom.to_dict() if self.classroom else None,
+156
View File
@@ -0,0 +1,156 @@
from app import db
from datetime import datetime
# Definition of system modules available for RBAC permissions
SYSTEM_MODULES = [
{
'id': 'classrooms',
'name': 'Aulas y Espacios',
'name_en': 'Classrooms & Spaces',
'icon': 'bi-door-open',
'description': 'Gestión de aulas físicas, aulas virtuales (∞) y capacidades.'
},
{
'id': 'buildings',
'name': 'Edificios y Sedes',
'name_en': 'Buildings & Campuses',
'icon': 'bi-buildings',
'description': 'Administración de sede central, campus virtual, pisos y ubicaciones.'
},
{
'id': 'reservations',
'name': 'Reservas de Aulas',
'name_en': 'Classroom Reservations',
'icon': 'bi-calendar-check',
'description': 'Creación, modificación, aprobación, confirmación y cancelación de reservas.'
},
{
'id': 'schedule',
'name': 'Cartelera y Cronograma',
'name_en': 'Schedule & Billboard',
'icon': 'bi-calendar3',
'description': 'Visualización de calendario interactivo, cartelera de hoy y comisiones.'
},
{
'id': 'academic',
'name': 'Gestión Académica',
'name_en': 'Academic Management',
'icon': 'bi-book',
'description': 'Administración de carreras, asignaturas, códigos y comisiones.'
},
{
'id': 'import',
'name': 'Sincronización Sheets',
'name_en': 'Sheets Sync & Import',
'icon': 'bi-file-earmark-spreadsheet',
'description': 'Importación y sincronización de datos académicos desde Google Sheets.'
},
{
'id': 'optimizer',
'name': 'Optimizador IA',
'name_en': 'AI Room Optimizer',
'icon': 'bi-cpu',
'description': 'Ejecución y configuración del algoritmo genético de asignación.'
},
{
'id': 'users',
'name': 'Usuarios y Accesos RBAC',
'name_en': 'Users & Access RBAC',
'icon': 'bi-shield-lock',
'description': 'Administración de cuentas, roles y matriz granular de permisos.'
},
{
'id': 'milestone_types',
'name': 'Tipificaciones de Hitos',
'name_en': 'Milestone Types',
'icon': 'bi-tags',
'description': 'Catálogo dinámico de tipos de evaluaciones, parciales, TPs y eventos.'
},
{
'id': 'metrics',
'name': 'Métricas de Ocupación',
'name_en': 'Occupancy Metrics',
'icon': 'bi-graph-up',
'description': 'Visualización y análisis de porcentajes de ocupación por aula (día, semana y mes).'
}
]
ACCESS_LEVELS = ['none', 'read', 'read_write']
class Role(db.Model):
__tablename__ = 'roles'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(80), unique=True, nullable=False, index=True)
description = db.Column(db.String(255))
is_system = db.Column(db.Boolean, default=False, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Relationships
permissions = db.relationship('Permission', backref='role', lazy='joined', cascade='all, delete-orphan')
users = db.relationship('User', backref='role_obj', lazy='select')
def get_permission(self, module):
"""Get access level string for a given module ('none', 'read', 'read_write')"""
for p in self.permissions:
if p.module == module:
return p.access_level
return 'none'
def has_permission(self, module, min_level='read'):
"""
Check if role has sufficient permission level.
Hierarchy: 'read_write' > 'read' > 'none'
"""
current_level = self.get_permission(module)
if current_level == 'none':
return False
if min_level == 'read':
return current_level in ['read', 'read_write']
if min_level == 'read_write':
return current_level == 'read_write'
return False
def set_permission(self, module, access_level):
"""Set or update permission level for a module"""
if access_level not in ACCESS_LEVELS:
access_level = 'none'
for p in self.permissions:
if p.module == module:
p.access_level = access_level
return p
new_perm = Permission(role=self, module=module, access_level=access_level)
db.session.add(new_perm)
return new_perm
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'description': self.description,
'is_system': self.is_system,
'created_at': self.created_at.isoformat() if self.created_at else None,
'user_count': len(self.users) if self.users else 0,
'permissions': {p.module: p.access_level for p in self.permissions}
}
def __repr__(self):
return f'<Role {self.name}>'
class Permission(db.Model):
__tablename__ = 'permissions'
id = db.Column(db.Integer, primary_key=True)
role_id = db.Column(db.Integer, db.ForeignKey('roles.id', ondelete='CASCADE'), nullable=False, index=True)
module = db.Column(db.String(50), nullable=False)
access_level = db.Column(db.String(20), default='none', nullable=False)
__table_args__ = (
db.UniqueConstraint('role_id', 'module', name='uq_role_module'),
)
def __repr__(self):
return f'<Permission role={self.role_id} module={self.module} level={self.access_level}>'
+24 -6
View File
@@ -5,12 +5,12 @@ class Subject(db.Model):
__tablename__ = 'subjects' __tablename__ = 'subjects'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
code = db.Column(db.String(20), unique=True, nullable=False, index=True) code = db.Column(db.String(50), unique=True, nullable=False, index=True)
name = db.Column(db.String(200), nullable=False) name = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text) description = db.Column(db.Text)
department = db.Column(db.String(100), nullable=False) department = db.Column(db.String(100), nullable=False, default='')
credits = db.Column(db.Integer, nullable=False) credits = db.Column(db.Integer, nullable=False, default=4)
# duration_in_minutes = db.Column(db.Integer, nullable=False) career_id = db.Column(db.Integer, db.ForeignKey('careers.id', ondelete='SET NULL'), nullable=True)
is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -27,7 +27,8 @@ class Subject(db.Model):
'description': self.description, 'description': self.description,
'department': self.department, 'department': self.department,
'credits': self.credits, 'credits': self.credits,
# 'duration_minutes': self.duration_in_minutes, 'career_id': self.career_id,
'career_name': self.career_obj.name if self.career_obj else None,
'is_active': self.is_active, 'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None, 'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None 'updated_at': self.updated_at.isoformat() if self.updated_at else None
@@ -48,6 +49,8 @@ class Commission(db.Model):
max_students = db.Column(db.Integer, nullable=False) max_students = db.Column(db.Integer, nullable=False)
current_students = db.Column(db.Integer, default=0) current_students = db.Column(db.Integer, default=0)
schedule = db.Column(db.String(255)) schedule = db.Column(db.String(255))
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
virtual_link = db.Column(db.String(500), nullable=True) # Link Zoom, Meet, Teams, Moodle
active = db.Column(db.Boolean, default=True) # Changed from is_active to active active = db.Column(db.Boolean, default=True) # Changed from is_active to active
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -85,6 +88,8 @@ class Commission(db.Model):
'max_students': self.max_students, 'max_students': self.max_students,
'current_students': self.current_students, 'current_students': self.current_students,
'schedule': self.schedule, 'schedule': self.schedule,
'shift': self.shift,
'virtual_link': self.virtual_link,
'active': self.active, 'active': self.active,
'created_at': self.created_at.isoformat() if self.created_at else None, 'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None, 'updated_at': self.updated_at.isoformat() if self.updated_at else None,
@@ -93,7 +98,20 @@ class Commission(db.Model):
def get_full_code(self): def get_full_code(self):
"""Get full commission code""" """Get full commission code"""
return f"{self.subject.code if self.subject else 'N/A'}-{self.code}-{self.semester.upper()}{self.year if self.year else ''}" subj = self.subject.code if self.subject else 'N/A'
code = self.code or ''
sem = str(self.semester or '').strip()
yr = str(self.year or '').strip()
if yr and yr in sem:
return f"{subj}-{code}-{sem}"
elif sem and yr:
return f"{subj}-{code}-{sem}{yr}"
elif sem:
return f"{subj}-{code}-{sem}"
elif yr:
return f"{subj}-{code}-{yr}"
return f"{subj}-{code}"
def __repr__(self): def __repr__(self):
return f'<Commission {self.get_full_code()}>' return f'<Commission {self.get_full_code()}>'
+49 -2
View File
@@ -14,6 +14,9 @@ class User(UserMixin, db.Model):
is_active = db.Column(db.Boolean, default=True, nullable=False) is_active = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
last_login = db.Column(db.DateTime) 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 # Virtual fields for future compatibility
@property @property
@@ -38,7 +41,49 @@ class User(UserMixin, db.Model):
def is_admin(self): def is_admin(self):
"""Check if user is admin""" """Check if user is admin"""
return self.role == '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') or
self.has_permission('milestone_types', 'read') or
self.has_permission('import', 'read') or
self.has_permission('reservations', 'read_write') or
self.has_permission('classrooms', 'read_write') or
self.has_permission('buildings', 'read_write') or
self.has_permission('metrics', 'read')
)
def can_edit_reservation(self, reservation):
"""Check if user has permission to edit a given reservation"""
if not reservation:
return False
if self.is_admin():
return True
role_name = (self.role_obj.name if self.role_obj else self.role or '').lower()
if role_name in ['admin', 'administrador', 'bedelia', 'bedelía', 'operador']:
return True
if self.has_permission('reservations', 'read_write'):
# El docente creador puede modificar su propia reserva
if reservation.user_id == self.id:
return True
# Bedelía o roles de gestión con permiso de escritura total
if role_name in ['bedelia', 'bedelía', 'operador']:
return True
return False
def update_last_login(self): def update_last_login(self):
"""Update last login timestamp""" """Update last login timestamp"""
@@ -54,7 +99,9 @@ class User(UserMixin, db.Model):
'role': self.role, 'role': self.role,
'is_active': self.is_active, 'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None, 'created_at': self.created_at.isoformat() if self.created_at else None,
'last_login': self.last_login.isoformat() if self.last_login 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): def __repr__(self):
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+3 -1
View File
@@ -1,7 +1,9 @@
from .main import main_bp from .main import main_bp
from .auth import auth_bp from .auth import auth_bp
from .classrooms import classrooms_bp from .classrooms import classrooms_bp
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
__all__ = ['main_bp', 'auth_bp', 'classrooms_bp', 'schedule_bp', 'genetic_bp', 'genetic_web_bp'] __all__ = ['main_bp', 'auth_bp', 'classrooms_bp', 'buildings_bp', 'schedule_bp', 'genetic_bp', 'genetic_web_bp', 'admin_bp']
+1119
View File
File diff suppressed because it is too large Load Diff
+51 -7
View File
@@ -1,5 +1,6 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, session from flask import Blueprint, render_template, redirect, url_for, flash, request, session, jsonify
from flask_login import login_user, logout_user, login_required, current_user from flask_login import login_user, logout_user, login_required, current_user
from flask_babel import gettext as _
from app import db from app import db
from app.models.user import User from app.models.user import User
from app.forms.auth import LoginForm from app.forms.auth import LoginForm
@@ -19,20 +20,29 @@ def login():
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('Your account has been deactivated. Please contact administrator.', 'error') flash(_('Tu cuenta ha sido desactivada. Por favor contacta al administrador.'), 'error')
return render_template('auth/login.html', form=form) 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()
# Si el usuario tiene idioma configurado en su perfil, sobrescribe el navegador en toda la sesión
if user.preferred_language and user.preferred_language in ['es', 'en']:
session['language'] = user.preferred_language
else:
session.pop('language', None)
session.pop('login_lang_explicit', None)
next_page = request.args.get('next') next_page = request.args.get('next')
if not next_page or not next_page.startswith('/'): if not next_page or not next_page.startswith('/'):
next_page = url_for('main.dashboard') next_page = url_for('main.dashboard')
flash(f'Welcome back, {user.first_name} {user.last_name}!', 'success') user_full_name = f'{user.first_name} {user.last_name}'.strip() or user.name
flash(_('¡Bienvenido de nuevo, %(name)s!', name=user_full_name), 'success')
return redirect(next_page) return redirect(next_page)
else: else:
flash('Invalid email or password.', 'error') flash(_('Correo electrónico o contraseña inválidos.'), 'error')
return render_template('auth/login.html', form=form) return render_template('auth/login.html', form=form)
@@ -41,15 +51,49 @@ def login():
def logout(): def logout():
"""Handle user logout""" """Handle user logout"""
logout_user() logout_user()
flash('You have been logged out successfully.', 'info') # Al cerrar sesión, se limpian las cookies de idioma para que el login detecte el navegador o use español
session.pop('language', None)
session.pop('login_lang_explicit', None)
flash(_('Has cerrado sesión correctamente.'), 'info')
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
@auth_bp.route('/profile') @auth_bp.route('/profile', methods=['GET', 'POST'])
@login_required @login_required
def profile(): def profile():
"""User profile page""" """User profile page with language and theme settings"""
if request.method == 'POST':
lang = request.form.get('preferred_language')
if lang in ['es', 'en']:
current_user.preferred_language = lang
session['language'] = lang
else:
# Cadena vacía o None restablece la detección automática del navegador
current_user.preferred_language = None
session.pop('language', None)
theme = request.form.get('theme_preference')
if theme in ['auto', 'light', 'dark']:
current_user.theme_preference = theme
db.session.commit()
flash(_('Preferencias de usuario actualizadas correctamente.'), 'success')
return redirect(url_for('auth.profile'))
return render_template('auth/profile.html') return render_template('auth/profile.html')
@auth_bp.route('/api/user/theme', methods=['POST'])
@login_required
def update_theme_api():
"""Update user theme preference via fetch/AJAX from navbar toggle"""
data = request.get_json(silent=True) or request.form
theme = data.get('theme') if data else None
if theme in ['auto', 'light', 'dark']:
current_user.theme_preference = theme
db.session.commit()
return jsonify({'status': 'success', 'theme': theme})
return jsonify({'status': 'error', 'message': 'Invalid theme'}), 400
@auth_bp.route('/change_password', methods=['GET', 'POST']) @auth_bp.route('/change_password', methods=['GET', 'POST'])
@login_required @login_required
def change_password(): def change_password():
+193
View File
@@ -0,0 +1,193 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user
from app import db
from app.models.building import Building
from app.models.classroom import Classroom
from app.forms.building import BuildingForm
from app.utils.decorators import permission_required
from datetime import datetime
buildings_bp = Blueprint('buildings', __name__, url_prefix='/buildings')
@buildings_bp.route('/')
@login_required
def list_buildings():
"""List all buildings with statistics"""
search = request.args.get('search', '').strip()
status_filter = request.args.get('status', '')
query = Building.query
if search:
query = query.filter(
db.or_(
Building.name.ilike(f'%{search}%'),
Building.code.ilike(f'%{search}%'),
Building.address.ilike(f'%{search}%')
)
)
if status_filter == 'active':
query = query.filter(Building.is_active == True)
elif status_filter == 'inactive':
query = query.filter(Building.is_active == False)
buildings = query.order_by(Building.name.asc()).all()
# Calculate global building stats
total_classrooms = Classroom.query.filter_by(is_active=True).count()
total_capacity = sum(c.capacity for c in Classroom.query.filter_by(is_active=True).all())
return render_template(
'buildings/list.html',
buildings=buildings,
total_classrooms=total_classrooms,
total_capacity=total_capacity,
search=search,
status_filter=status_filter,
title='Building Management'
)
@buildings_bp.route('/add', methods=['GET', 'POST'])
@login_required
@permission_required('classrooms', 'read_write')
def add_building():
"""Add new building"""
form = BuildingForm()
if form.validate_on_submit():
try:
# Check unique name
existing = Building.query.filter(
db.func.lower(Building.name) == form.name.data.lower()
).first()
if existing:
flash('Ya existe un edificio con este nombre.', 'danger')
return render_template('buildings/form.html', form=form, title='Add Building')
building = Building(
name=form.name.data.strip(),
code=form.code.data.strip().upper() if form.code.data else None,
address=form.address.data.strip() if form.address.data else None,
floors=form.floors.data,
description=form.description.data.strip() if form.description.data else None,
is_active=form.is_active.data
)
db.session.add(building)
db.session.commit()
flash(f'Edificio "{building.name}" creado con éxito.', 'success')
return redirect(url_for('buildings.view_building', id=building.id))
except Exception as e:
db.session.rollback()
flash(f'Error al crear el edificio: {str(e)}', 'danger')
return render_template('buildings/form.html', form=form, title='Add Building')
@buildings_bp.route('/<int:id>')
@login_required
def view_building(id):
"""View building details and its classrooms by floor"""
building = Building.query.get_or_404(id)
# Group active classrooms by floor
classrooms = Classroom.query.filter_by(building_id=building.id).order_by(Classroom.floor.asc(), Classroom.code.asc()).all()
# Fallback to name matching if building_id was not set
if not classrooms:
classrooms = Classroom.query.filter_by(building=building.name).order_by(Classroom.floor.asc(), Classroom.code.asc()).all()
classrooms_by_floor = {}
for c in classrooms:
floor_key = c.floor
if floor_key not in classrooms_by_floor:
classrooms_by_floor[floor_key] = []
classrooms_by_floor[floor_key].append(c)
return render_template(
'buildings/view.html',
building=building,
classrooms=classrooms,
classrooms_by_floor=classrooms_by_floor,
title=f'Building - {building.name}'
)
@buildings_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
@login_required
@permission_required('classrooms', 'read_write')
def edit_building(id):
"""Edit building"""
building = Building.query.get_or_404(id)
form = BuildingForm(obj=building)
if form.validate_on_submit():
try:
# Check unique name excluding current
existing = Building.query.filter(
db.func.lower(Building.name) == form.name.data.lower(),
Building.id != id
).first()
if existing:
flash('Ya existe otro edificio con este nombre.', 'danger')
return render_template('buildings/form.html', form=form, building=building, title='Edit Building')
old_name = building.name
new_name = form.name.data.strip()
building.name = new_name
building.code = form.code.data.strip().upper() if form.code.data else None
building.address = form.address.data.strip() if form.address.data else None
building.floors = form.floors.data
building.description = form.description.data.strip() if form.description.data else None
building.is_active = form.is_active.data
building.updated_at = datetime.utcnow()
# If name changed, update string building on classrooms
if old_name != new_name:
Classroom.query.filter_by(building_id=building.id).update({'building': new_name})
db.session.commit()
flash(f'Edificio "{building.name}" actualizado con éxito.', 'success')
return redirect(url_for('buildings.view_building', id=building.id))
except Exception as e:
db.session.rollback()
flash(f'Error al actualizar el edificio: {str(e)}', 'danger')
return render_template('buildings/form.html', form=form, building=building, title='Edit Building')
@buildings_bp.route('/<int:id>/toggle', methods=['POST'])
@login_required
@permission_required('classrooms', 'read_write')
def toggle_building(id):
"""Toggle building active status"""
building = Building.query.get_or_404(id)
try:
building.is_active = not building.is_active
building.updated_at = datetime.utcnow()
db.session.commit()
status_text = 'activado' if building.is_active else 'desactivado'
flash(f'Edificio "{building.name}" {status_text} con éxito.', 'success')
except Exception as e:
db.session.rollback()
flash(f'Error al cambiar estado del edificio: {str(e)}', 'danger')
return redirect(url_for('buildings.list_buildings'))
@buildings_bp.route('/<int:id>/delete', methods=['POST'])
@login_required
@permission_required('classrooms', 'read_write')
def delete_building(id):
"""Delete building if it has no classrooms assigned"""
building = Building.query.get_or_404(id)
has_classrooms = Classroom.query.filter(
db.or_(Classroom.building_id == building.id, Classroom.building == building.name)
).count()
if has_classrooms > 0:
flash(f'No se puede eliminar el edificio "{building.name}" porque tiene {has_classrooms} aulas asociadas. Primero reasigna o elimina las aulas.', 'danger')
return redirect(url_for('buildings.view_building', id=building.id))
try:
db.session.delete(building)
db.session.commit()
flash(f'Edificio "{building.name}" eliminado correctamente.', 'success')
except Exception as e:
db.session.rollback()
flash(f'Error al eliminar edificio: {str(e)}', 'danger')
return redirect(url_for('buildings.list_buildings'))
+321 -46
View File
@@ -12,30 +12,62 @@ classrooms_bp = Blueprint('classrooms', __name__)
@classrooms_bp.route('/add', methods=['GET', 'POST']) @classrooms_bp.route('/add', methods=['GET', 'POST'])
@login_required @login_required
def add_classroom(): def add_classroom():
"""Add a new classroom""" """Add a new classroom with dynamic building creation and floor suggestion"""
form = ClassroomForm() from app.models.building import Building
import re
from sqlalchemy import func
# TODO: Update when campus model is implemented form = ClassroomForm()
# form.campus_id.choices = [(1, 'Main Campus')] buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
form.building_id.choices = [(str(b.id), b.name) for b in buildings] + [('__new__', '+ Crear nuevo edificio')]
# Map of existing floors per building for datalist
building_floors = {str(b.id): list(dict.fromkeys(c.floor for c in b.classrooms if c.floor)) for b in buildings}
if form.validate_on_submit(): if form.validate_on_submit():
try: try:
building = None
if form.building_id.data == '__new__':
new_building_name = form.new_building_name.data.strip() if form.new_building_name.data else ''
if not new_building_name:
flash('Debe ingresar el nombre del nuevo edificio.', 'error')
return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom')
building = Building.query.filter(func.lower(Building.name) == func.lower(new_building_name)).first()
if not building:
code_slug = re.sub(r'[^a-zA-Z0-9]+', '_', new_building_name.upper()).strip('_')
building = Building(name=new_building_name, code=code_slug, is_active=True)
db.session.add(building)
db.session.commit()
else:
building = Building.query.get_or_404(int(form.building_id.data))
room_code = form.room_number.data.strip()
floor_val = str(form.floor.data).strip()
# Prevent duplicate room in same building & floor
existing_classroom = Classroom.query.filter_by( existing_classroom = Classroom.query.filter_by(
building=form.building.data, building_id=building.id,
code=form.room_number.data, code=room_code,
floor=form.floor.data floor=floor_val
).first() ).first()
if existing_classroom: if existing_classroom:
flash('Classroom with this building, room number, and floor already exists.', 'error') flash(f'Ya existe un aula con el nombre "{room_code}" en el piso {floor_val} de {building.name}.', 'error')
return render_template('classrooms/add.html', form=form, title='Add Classroom') return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom')
cap_raw = str(form.capacity.data or '').strip()
if cap_raw in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']:
cap_val = 0
else:
cap_val = int(cap_raw)
classroom = Classroom( classroom = Classroom(
# campus_id=form.campus_id.data, building_id=building.id,
building=form.building.data, building=building.name,
code=form.room_number.data, code=room_code,
floor=form.floor.data, floor=floor_val,
capacity=form.capacity.data, capacity=cap_val,
description=form.description.data, description=form.description.data,
is_active=form.is_active.data is_active=form.is_active.data
) )
@@ -43,33 +75,91 @@ def add_classroom():
db.session.add(classroom) db.session.add(classroom)
db.session.commit() db.session.commit()
flash(f'Classroom {classroom.code_display} has been created successfully!', 'success') flash(f'Aula {classroom.location_display} creada exitosamente.', 'success')
return redirect(url_for('classrooms.list_classrooms')) return redirect(url_for('classrooms.list_classrooms'))
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
flash(f'Error creating classroom: {str(e)}', 'error') flash(f'Error creating classroom: {str(e)}', 'error')
return render_template('classrooms/add.html', form=form, title='Add Classroom') return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom')
@classrooms_bp.route('/', endpoint='index')
@classrooms_bp.route('/list') @classrooms_bp.route('/list')
@login_required @login_required
def list_classrooms(): def list_classrooms():
"""List all classrooms""" """List all classrooms with separation between Physical spaces and Virtual on-demand rooms"""
try: try:
# Get filters from app.models.building import Building
from app.models.reservation import Reservation, ReservationStatus
from app.models.subject import Commission, Subject
import calendar
from datetime import date, timedelta
page = request.args.get('page', 1, type=int) page = request.args.get('page', 1, type=int)
active_only = request.args.get('active_only', 'true').lower() == 'true' active_only = request.args.get('active_only', 'true').lower() == 'true'
search = request.args.get('search', '', type=str) search = request.args.get('search', '', type=str).strip()
building_filter = request.args.get('building_id', '', type=str).strip()
floor_filter = request.args.get('floor', '', type=str).strip()
view_mode = request.args.get('view_mode', '').strip().lower()
# Build query # Identificar edificio virtual y aula virtual base
query = Classroom.query virtual_building = Building.query.filter(
db.or_(Building.code == 'VIRTUAL', Building.name.ilike('%Virtual%'))
).first()
virtual_bldg_id = virtual_building.id if virtual_building else None
# Si el usuario seleccionó "Campus Virtual" en el filtro de edificios, conmutar a pestaña virtual
if building_filter and str(building_filter) == str(virtual_bldg_id):
view_mode = 'virtual'
elif not view_mode:
view_mode = 'physical'
# Fechas operativas (Día, Semana, Mes)
today = datetime.utcnow().date()
today_start = datetime.combine(today, datetime.min.time())
today_end = datetime.combine(today, datetime.max.time())
week_start = datetime.combine(today - timedelta(days=today.weekday()), datetime.min.time())
week_end = datetime.combine(week_start.date() + timedelta(days=6), datetime.max.time())
_, last_day = calendar.monthrange(today.year, today.month)
month_start = datetime(today.year, today.month, 1, 0, 0, 0)
month_end = datetime(today.year, today.month, last_day, 23, 59, 59)
date_filters = {
'today': today.strftime('%Y-%m-%d'),
'week_start': week_start.strftime('%Y-%m-%d'),
'week_end': week_end.strftime('%Y-%m-%d'),
'month_start': month_start.strftime('%Y-%m-%d'),
'month_end': month_end.strftime('%Y-%m-%d')
}
# --- AULAS FÍSICAS (Presenciales) ---
physical_query = Classroom.query
if virtual_bldg_id:
physical_query = physical_query.filter(Classroom.building_id != virtual_bldg_id)
else:
physical_query = physical_query.filter(~Classroom.code.ilike('%VIRTUAL%'))
if active_only: if active_only:
query = query.filter_by(is_active=True) physical_query = physical_query.filter_by(is_active=True)
if search: physical_count = physical_query.count()
query = query.filter(
if building_filter and building_filter.isdigit() and str(building_filter) != str(virtual_bldg_id):
physical_query = physical_query.filter(Classroom.building_id == int(building_filter))
if floor_filter:
flr = str(floor_filter).strip()
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
physical_query = physical_query.filter(db.or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
else:
physical_query = physical_query.filter(Classroom.floor == flr)
if search and view_mode == 'physical':
physical_query = physical_query.filter(
db.or_( db.or_(
Classroom.building.ilike(f'%{search}%'), Classroom.building.ilike(f'%{search}%'),
Classroom.code.ilike(f'%{search}%'), Classroom.code.ilike(f'%{search}%'),
@@ -77,29 +167,175 @@ def list_classrooms():
) )
) )
# Paginate results classrooms = physical_query.order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).paginate(
classrooms = query.order_by(Classroom.building, Classroom.floor, Classroom.code).paginate( page=page, per_page=20, error_out=False
page=page, per_page=12, error_out=False
) )
# Edificios y Pisos estrictamente físicos para el filtro
buildings = Building.query.filter_by(is_active=True)
if virtual_bldg_id:
buildings = buildings.filter(Building.id != virtual_bldg_id)
buildings = buildings.order_by(Building.name.asc()).all()
floors = [f[0] for f in db.session.query(Classroom.floor).filter(
Classroom.is_active == True,
Classroom.building_id != virtual_bldg_id if virtual_bldg_id else True,
Classroom.floor.isnot(None)
).distinct().order_by(Classroom.floor.asc()).all()]
# Métricas de ocupación física
visible_ids = [c.id for c in classrooms.items]
month_reservations = Reservation.query.filter(
Reservation.classroom_id.in_(visible_ids),
Reservation.start_time >= month_start,
Reservation.start_time <= month_end,
Reservation.status.in_([ReservationStatus.CONFIRMED.value, ReservationStatus.PENDING.value])
).all() if visible_ids else []
DAILY_OP_MINS = 15 * 60
WEEKLY_OP_MINS = 6 * 15 * 60
MONTHLY_OP_MINS = 24 * 15 * 60
occupancy_metrics = {}
for c in classrooms.items:
c_res = [r for r in month_reservations if r.classroom_id == c.id]
day_res = [r for r in c_res if today_start <= r.start_time <= today_end]
day_mins = sum((min(r.end_time, today_end) - max(r.start_time, today_start)).total_seconds() / 60 for r in day_res)
day_pct = min(100, int(round((day_mins / DAILY_OP_MINS) * 100)))
week_res = [r for r in c_res if week_start <= r.start_time <= week_end]
week_mins = sum((min(r.end_time, week_end) - max(r.start_time, week_start)).total_seconds() / 60 for r in week_res)
week_pct = min(100, int(round((week_mins / WEEKLY_OP_MINS) * 100)))
month_mins = sum((min(r.end_time, month_end) - max(r.start_time, month_start)).total_seconds() / 60 for r in c_res)
month_pct = min(100, int(round((month_mins / MONTHLY_OP_MINS) * 100)))
occupancy_metrics[c.id] = {
'day_pct': day_pct,
'day_count': len(day_res),
'week_pct': week_pct,
'week_count': len(week_res),
'month_pct': month_pct,
'month_count': len(c_res)
}
# --- AULAS VIRTUALES (Campus Virtual a Demanda - Infinitas) ---
vc = Classroom.query.filter(
Classroom.building_id == virtual_bldg_id if virtual_bldg_id else Classroom.code.ilike('%VIRTUAL%')
).first()
vc_id = vc.id if vc else 1
all_vres = Reservation.query.filter(Reservation.classroom_id == vc_id).all() if vc_id else []
# Agrupar las salas virtuales por comisión
comm_dict = {}
for r in all_vres:
c = r.commission
cid = c.id if c else 0
if cid not in comm_dict:
vlink = (c.virtual_link if c and c.virtual_link else None) or r.effective_virtual_link
comm_dict[cid] = {
'commission_id': cid,
'commission_code': c.code if c else 'General',
'subject_name': c.subject.name if (c and c.subject) else (r.purpose or 'Clase Virtual'),
'subject_code': c.subject.code if (c and c.subject) else '',
'career_name': c.subject.career.name if (c and c.subject and hasattr(c.subject, 'career') and c.subject.career) else '',
'max_students': c.max_students if c else (r.expected_attendees or 0),
'virtual_link': vlink,
'reservations': []
}
comm_dict[cid]['reservations'].append(r)
if not comm_dict[cid]['virtual_link'] and r.effective_virtual_link:
comm_dict[cid]['virtual_link'] = r.effective_virtual_link
virtual_rooms = []
for cid, data in comm_dict.items():
res_list = data['reservations']
day_cnt = sum(1 for r in res_list if today_start <= r.start_time <= today_end)
week_cnt = sum(1 for r in res_list if week_start <= r.start_time <= week_end)
month_cnt = sum(1 for r in res_list if month_start <= r.start_time <= month_end)
shift = res_list[0].shift if res_list else 'A Demanda'
if search and view_mode == 'virtual':
s_low = search.lower()
matches = (
s_low in data['subject_name'].lower() or
s_low in data['commission_code'].lower() or
s_low in data['subject_code'].lower() or
s_low in data['career_name'].lower()
)
if not matches:
continue
virtual_rooms.append({
'commission_id': data['commission_id'],
'commission_code': data['commission_code'],
'subject_name': data['subject_name'],
'subject_code': data['subject_code'],
'career_name': data['career_name'],
'max_students': data['max_students'],
'virtual_link': data['virtual_link'],
'shift': shift,
'day_count': day_cnt,
'week_count': week_cnt,
'month_count': month_cnt,
'total_count': len(res_list),
'reservations': sorted(res_list, key=lambda x: x.start_time)
})
virtual_rooms.sort(key=lambda x: (x['subject_name'], x['commission_code']))
global_virtual_metrics = {
'today_count': sum(1 for r in all_vres if today_start <= r.start_time <= today_end),
'week_count': sum(1 for r in all_vres if week_start <= r.start_time <= week_end),
'month_count': sum(1 for r in all_vres if month_start <= r.start_time <= month_end),
'total_count': len(all_vres),
'commissions_count': len(comm_dict)
}
return render_template('classrooms/list.html', return render_template('classrooms/list.html',
classrooms=classrooms, classrooms=classrooms,
buildings=buildings,
floors=floors,
building_filter=building_filter,
floor_filter=floor_filter,
active_only=active_only, active_only=active_only,
search=search, search=search,
view_mode=view_mode,
physical_count=physical_count,
virtual_count=len(comm_dict),
virtual_rooms=virtual_rooms,
virtual_classroom_id=vc_id,
global_virtual_metrics=global_virtual_metrics,
occupancy_metrics=occupancy_metrics,
date_filters=date_filters,
title='Classrooms') title='Classrooms')
except Exception as e: except Exception as e:
flash(f'Error loading classrooms: {str(e)}', 'error') flash(f'Error loading classrooms: {str(e)}', 'error')
return render_template('classrooms/list.html', classrooms=None, title='Classrooms') return render_template('classrooms/list.html', classrooms=None, title='Classrooms')
@classrooms_bp.route('/virtual-room/<int:commission_id>/update-link', methods=['POST'])
@login_required
def update_virtual_link(commission_id):
"""Actualizar enlace de videollamada para una comisión virtual"""
from app.models.subject import Commission
from flask_babel import gettext as _
comm = Commission.query.get_or_404(commission_id)
new_link = request.form.get('virtual_link', '').strip() or None
comm.virtual_link = new_link
db.session.commit()
flash(_('Enlace de videollamada para %(code)s actualizado correctamente.', code=comm.get_full_code()), 'success')
return redirect(request.referrer or url_for('classrooms.list_classrooms', view_mode='virtual'))
@classrooms_bp.route('/<int:id>') @classrooms_bp.route('/<int:id>')
@login_required @login_required
def view_classroom(id): def view_classroom(id):
"""View classroom details""" """View classroom details"""
try: try:
classroom = Classroom.query.get_or_404(id) classroom = Classroom.query.get_or_404(id)
# Get upcoming reservations for this classroom
from app.models.reservation import Reservation, ReservationStatus from app.models.reservation import Reservation, ReservationStatus
upcoming_reservations = Reservation.query.filter( upcoming_reservations = Reservation.query.filter(
@@ -117,50 +353,89 @@ def view_classroom(id):
flash(f'Error loading classroom: {str(e)}', 'error') flash(f'Error loading classroom: {str(e)}', 'error')
return redirect(url_for('classrooms.list_classrooms')) return redirect(url_for('classrooms.list_classrooms'))
@classrooms_bp.route('/<int:id>/edit', methods=['GET', 'POST']) @classrooms_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
@login_required @login_required
def edit_classroom(id): def edit_classroom(id):
"""Edit classroom""" """Edit classroom with decoupled building and floor"""
from app.models.building import Building
import re
from sqlalchemy import func
classroom = Classroom.query.get_or_404(id) classroom = Classroom.query.get_or_404(id)
form = ClassroomForm(obj=classroom) form = ClassroomForm(obj=classroom)
# TODO: Update when campus model is implemented buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
# form.campus_id.choices = [(1, 'Main Campus')] form.building_id.choices = [(str(b.id), b.name) for b in buildings] + [('__new__', '+ Crear nuevo edificio')]
building_floors = {str(b.id): list(dict.fromkeys(c.floor for c in b.classrooms if c.floor)) for b in buildings}
if request.method == 'GET':
form.building_id.data = str(classroom.building_id or '')
form.room_number.data = classroom.code
form.floor.data = classroom.floor
if classroom.capacity == 0 or classroom.is_virtual:
form.capacity.data = '∞'
else:
form.capacity.data = str(classroom.capacity)
if form.validate_on_submit(): if form.validate_on_submit():
try: try:
# Check for duplicates (excluding current classroom) building = None
if form.building_id.data == '__new__':
new_building_name = form.new_building_name.data.strip() if form.new_building_name.data else ''
if not new_building_name:
flash('Debe ingresar el nombre del nuevo edificio.', 'error')
return render_template('classrooms/edit.html', form=form, classroom=classroom, building_floors=building_floors, title='Edit Classroom')
building = Building.query.filter(func.lower(Building.name) == func.lower(new_building_name)).first()
if not building:
code_slug = re.sub(r'[^a-zA-Z0-9]+', '_', new_building_name.upper()).strip('_')
building = Building(name=new_building_name, code=code_slug, is_active=True)
db.session.add(building)
db.session.commit()
else:
building = Building.query.get_or_404(int(form.building_id.data))
room_code = form.room_number.data.strip()
floor_val = str(form.floor.data).strip()
# Check duplicate excluding current
existing_classroom = Classroom.query.filter( existing_classroom = Classroom.query.filter(
Classroom.building == form.building.data, Classroom.building_id == building.id,
Classroom.code == form.room_number.data, Classroom.code == room_code,
Classroom.floor == form.floor.data, Classroom.floor == floor_val,
Classroom.id != id Classroom.id != id
).first() ).first()
if existing_classroom: if existing_classroom:
flash('Classroom with this building, room number, and floor already exists.', 'error') flash(f'Ya existe otra aula con el nombre "{room_code}" en el piso {floor_val} de {building.name}.', 'error')
return render_template('classrooms/edit.html', form=form, classroom=classroom, title='Edit Classroom') return render_template('classrooms/edit.html', form=form, classroom=classroom, building_floors=building_floors, title='Edit Classroom')
# Update classroom cap_raw = str(form.capacity.data or '').strip()
# classroom.campus_id = form.campus_id.data if cap_raw in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']:
classroom.building = form.building.data cap_val = 0
classroom.code = form.room_number.data else:
classroom.floor = form.floor.data cap_val = int(cap_raw)
classroom.capacity = form.capacity.data
classroom.building_id = building.id
classroom.building = building.name
classroom.code = room_code
classroom.floor = floor_val
classroom.capacity = cap_val
classroom.description = form.description.data classroom.description = form.description.data
classroom.is_active = form.is_active.data classroom.is_active = form.is_active.data
classroom.updated_at = datetime.utcnow() classroom.updated_at = datetime.utcnow()
db.session.commit() db.session.commit()
flash(f'Classroom {classroom.code_display} has been updated successfully!', 'success') flash(f'Aula {classroom.location_display} actualizada correctamente.', 'success')
return redirect(url_for('classrooms.view_classroom', id=classroom.id)) return redirect(url_for('classrooms.view_classroom', id=classroom.id))
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
flash(f'Error updating classroom: {str(e)}', 'error') flash(f'Error updating classroom: {str(e)}', 'error')
return render_template('classrooms/edit.html', form=form, classroom=classroom, title='Edit Classroom') return render_template('classrooms/edit.html', form=form, classroom=classroom, building_floors=building_floors, title='Edit Classroom')
@classrooms_bp.route('/<int:id>/toggle', methods=['POST']) @classrooms_bp.route('/<int:id>/toggle', methods=['POST'])
@login_required @login_required
+1 -1
View File
@@ -249,7 +249,7 @@ genetic_web_bp = Blueprint('genetic_algorithm_web', __name__)
@login_required @login_required
def genetic_optimizer(): def genetic_optimizer():
"""Render the genetic algorithm optimizer interface""" """Render the genetic algorithm optimizer interface"""
if current_user.role not in ['ADMIN']: if not (current_user.has_permission('optimizer', 'read') or (current_user.role and current_user.role.upper() == 'ADMIN')):
return render_template('auth/403.html'), 403 return render_template('auth/403.html'), 403
return render_template('genetic_optimizer.html') return render_template('genetic_optimizer.html')
+34 -3
View File
@@ -1,5 +1,6 @@
from flask import Blueprint, render_template, jsonify, redirect, url_for, session, request, flash from flask import Blueprint, render_template, jsonify, redirect, url_for, session, request, flash
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app import db
from app.models.reservation import Reservation from app.models.reservation import Reservation
from app.models.classroom import Classroom from app.models.classroom import Classroom
from app.models.subject import Subject from app.models.subject import Subject
@@ -112,8 +113,8 @@ def dashboard_stats_api():
@login_required @login_required
def genetic_optimizer(): def genetic_optimizer():
"""Serve the genetic algorithm optimization interface""" """Serve the genetic algorithm optimization interface"""
if current_user.role not in ['ADMIN']: if not (current_user.has_permission('optimizer', 'read') or (current_user.role and current_user.role.upper() == 'ADMIN')):
flash('You do not have permission to access the optimization tool.', 'danger') flash(_('No tienes permisos para acceder a la herramienta de optimización.'), 'danger')
return redirect(url_for('main.dashboard')) return redirect(url_for('main.dashboard'))
return render_template('genetic_optimizer.html') return render_template('genetic_optimizer.html')
@@ -121,7 +122,37 @@ def genetic_optimizer():
@main_bp.route('/set_language/<language>') @main_bp.route('/set_language/<language>')
def set_language(language=None): def set_language(language=None):
if language not in ['en', 'es']: if language not in ['en', 'es']:
language = 'en' language = 'es'
session['language'] = language 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')) return redirect(request.referrer or url_for('main.dashboard'))
@main_bp.route('/api/milestone-types')
def api_milestone_types():
"""Retorna las tipificaciones de hitos activas en JSON para consumo de frontend/mobile"""
from app.models.milestone import MilestoneType, BASE_MILESTONE_TYPES
if MilestoneType.query.count() == 0:
for item in BASE_MILESTONE_TYPES:
mt = MilestoneType(
name=item['name'],
code=item['code'],
color=item['color'],
description=item['description'],
sort_order=item['sort_order'],
is_active=True
)
db.session.add(mt)
db.session.commit()
types = MilestoneType.query.filter_by(is_active=True).order_by(MilestoneType.sort_order.asc(), MilestoneType.id.asc()).all()
return jsonify([t.to_dict() for t in types])␍
+619 -53
View File
@@ -14,21 +14,43 @@ schedule_bp = Blueprint('schedule', __name__, url_prefix='/schedule')
@schedule_bp.route('/today') @schedule_bp.route('/today')
@login_required @login_required
def today_schedule(): def today_schedule():
"""Show today's schedule""" """Show today's schedule with filters for virtual classrooms, floor, and shift"""
today = datetime.utcnow().date() today = datetime.utcnow().date()
today_start = datetime.combine(today, datetime.min.time()) today_start = datetime.combine(today, datetime.min.time())
today_end = datetime.combine(today, datetime.max.time()) today_end = datetime.combine(today, datetime.max.time())
# Get reservations with eager loading hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
reservations = Reservation.query.options( floor_filter = request.args.get('floor', '').strip()
shift_filter = request.args.get('shift', '').strip()
# Base query for today's reservations
query = Reservation.query.options(
joinedload(Reservation.classroom), joinedload(Reservation.classroom),
joinedload(Reservation.commission), joinedload(Reservation.commission).joinedload(Commission.subject),
joinedload(Reservation.user) joinedload(Reservation.user)
).filter( ).join(Reservation.classroom).filter(
Reservation.start_time >= today_start, Reservation.start_time >= today_start,
Reservation.start_time <= today_end, Reservation.start_time <= today_end,
Reservation.status.in_([ReservationStatus.CONFIRMED.value, ReservationStatus.PENDING.value]) Reservation.status.in_([ReservationStatus.CONFIRMED.value, ReservationStatus.PENDING.value])
).order_by(Reservation.start_time).all() )
if hide_virtual:
query = query.filter(
~Classroom.code.ilike('%VIRTUAL%'),
~Classroom.building.ilike('%Virtual%')
)
if floor_filter:
flr = str(floor_filter).strip()
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
query = query.filter(or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
else:
query = query.filter(Classroom.floor == flr)
if shift_filter:
query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%'))
reservations = query.order_by(Reservation.start_time).all()
# Create time blocks for timeline view # Create time blocks for timeline view
time_blocks = {} time_blocks = {}
@@ -49,11 +71,9 @@ def today_schedule():
# Calculate stats # Calculate stats
total_reservations = len(reservations) total_reservations = len(reservations)
confirmed = len([r for r in reservations if r.status == ReservationStatus.CONFIRMED.value]) confirmed = len([r for r in reservations if r.status == ReservationStatus.CONFIRMED.value or str(r.status) == 'CONFIRMED'])
pending = len([r for r in reservations if r.status == ReservationStatus.PENDING]) pending = len([r for r in reservations if r.status == ReservationStatus.PENDING or str(r.status) == 'PENDING'])
# Get total classrooms and calculate occupied ones
from app.models.classroom import Classroom
total_classrooms = Classroom.query.count() total_classrooms = Classroom.query.count()
occupied_classrooms = len(set(r.classroom_id for r in reservations if r.classroom_id)) occupied_classrooms = len(set(r.classroom_id for r in reservations if r.classroom_id))
@@ -65,11 +85,18 @@ def today_schedule():
'total_classrooms': total_classrooms 'total_classrooms': total_classrooms
} }
floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()
available_floors = [f[0] for f in floors]
return render_template('schedule/today.html', return render_template('schedule/today.html',
reservations=reservations, reservations=reservations,
time_blocks=time_blocks, time_blocks=time_blocks,
stats=stats, stats=stats,
today=today) today=today,
hide_virtual=hide_virtual,
floor_filter=floor_filter,
shift_filter=shift_filter,
available_floors=available_floors)
@schedule_bp.route('/cancel/<int:id>', methods=['POST']) @schedule_bp.route('/cancel/<int:id>', methods=['POST'])
@login_required @login_required
@@ -112,25 +139,353 @@ 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""" """Show calendar view with classroom, subject, career, shift, floor and virtual filtering"""
return render_template('schedule/calendar.html', title='Calendar View') from app.models.career import Career
from app.models.building import Building
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building.asc(), Classroom.code.asc()).all()
subjects = Subject.query.filter_by(is_active=True).order_by(Subject.name.asc()).all()
careers = Career.query.order_by(Career.name.asc()).all()
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()
available_floors = [f[0] for f in floors]
return render_template(
'schedule/calendar.html',
title='Calendar View',
today_date=date.today().isoformat(),
classrooms=classrooms,
subjects=subjects,
careers=careers,
buildings=buildings,
available_floors=available_floors
)
def user_can_manage_commission_milestones(user, commission=None):
"""Verifica si el usuario (Docente, Admin, Bedelía) puede gestionar hitos y links virtuales"""
if user.is_admin():
return True
role_name = (user.role_obj.name if user.role_obj else user.role or '').lower()
if role_name in ['admin', 'bedelia', 'bedelía', 'operador']:
return True
if role_name in ['docente', 'profesor']:
return True
return user.has_permission('schedule', 'read_write')
@schedule_bp.route('/commissions') @schedule_bp.route('/commissions')
@login_required
def commissions_view(): def commissions_view():
subjects = Subject.query.all() """Vista y ABM de Comisiones con soporte de enlace virtual e hitos académicos"""
from app.models.subject import Commission, Subject
from app.models.classroom import Classroom
from app.models.user import User
from app.models.milestone import MilestoneType
# Calculamos el total de capacidad sumando todas las comisiones de todas las materias subjects = Subject.query.order_by(Subject.code.asc()).all()
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.code.asc()).all()
teachers = User.query.filter_by(is_active=True).order_by(User.name.asc()).all()
milestone_types = MilestoneType.query.filter_by(is_active=True).order_by(MilestoneType.sort_order.asc()).all()
# Totales para indicadores superiores
total_capacity = sum(c.max_students for s in subjects for c in s.commissions) total_capacity = sum(c.max_students for s in subjects for c in s.commissions)
# Calculamos el total de comisiones
total_commissions = sum(len(s.commissions) for s in subjects) total_commissions = sum(len(s.commissions) for s in subjects)
can_manage_milestones = user_can_manage_commission_milestones(current_user)
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
can_edit_commissions = current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')
return render_template('schedule/commissions.html', return render_template('schedule/commissions.html',
subjects=subjects, subjects=subjects,
classrooms=classrooms,
teachers=teachers,
milestone_types=milestone_types,
total_capacity=total_capacity, total_capacity=total_capacity,
total_commissions=total_commissions) total_commissions=total_commissions,
can_manage_milestones=can_manage_milestones,
can_edit_commissions=can_edit_commissions)
@schedule_bp.route('/commissions/add', methods=['POST'])
@login_required
def commission_add():
"""Crear una nueva comisión de cursada"""
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')):
flash(_('No tienes permisos para crear comisiones.'), 'error')
return redirect(url_for('schedule.commissions_view'))
from app.models.subject import Commission
subject_id = request.form.get('subject_id', type=int)
code = request.form.get('code', '').strip()
semester = request.form.get('semester', '1C').strip()
year = request.form.get('year', datetime.now().year, type=int)
teacher_id = request.form.get('teacher_id', type=int) or None
max_students = request.form.get('max_students', 40, type=int)
schedule = request.form.get('schedule', '').strip()
shift = request.form.get('shift', 'Tarde').strip()
virtual_link = request.form.get('virtual_link', '').strip()
if not subject_id or not code:
flash(_('La asignatura y el código de comisión son obligatorios.'), 'error')
return redirect(url_for('schedule.commissions_view'))
# Verificar existencia duplicada
existing = Commission.query.filter_by(subject_id=subject_id, code=code, semester=semester, year=year).first()
if existing:
flash(_('Ya existe una comisión %(code)s para el ciclo %(sem)s %(yr)d.', code=code, sem=semester, yr=year), 'error')
return redirect(url_for('schedule.commissions_view'))
new_comm = Commission(
subject_id=subject_id,
code=code,
semester=semester,
year=year,
teacher_id=teacher_id,
max_students=max_students,
schedule=schedule,
shift=shift,
virtual_link=virtual_link or None,
active=True
)
db.session.add(new_comm)
db.session.commit()
flash(_('Comisión %(code)s creada exitosamente.', code=new_comm.get_full_code()), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/commissions/<int:id>/edit', methods=['POST'])
@login_required
def commission_edit(id):
"""Editar datos de una comisión"""
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')):
flash(_('No tienes permisos para modificar comisiones.'), 'error')
return redirect(url_for('schedule.commissions_view'))
from app.models.subject import Commission
commission = Commission.query.get_or_404(id)
commission.code = request.form.get('code', commission.code).strip()
commission.semester = request.form.get('semester', commission.semester).strip()
commission.year = request.form.get('year', commission.year, type=int)
commission.teacher_id = request.form.get('teacher_id', type=int) or None
commission.max_students = request.form.get('max_students', commission.max_students, type=int)
commission.schedule = request.form.get('schedule', commission.schedule).strip()
commission.shift = request.form.get('shift', commission.shift).strip()
commission.virtual_link = request.form.get('virtual_link', '').strip() or None
commission.active = True if request.form.get('active') in ['1', 'true', 'on'] else False
db.session.commit()
flash(_('Comisión %(code)s actualizada con éxito.', code=commission.get_full_code()), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/commissions/<int:id>/delete', methods=['POST'])
@login_required
def commission_delete(id):
"""Eliminar una comisión"""
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')):
flash(_('No tienes permisos para eliminar comisiones.'), 'error')
return redirect(url_for('schedule.commissions_view'))
from app.models.subject import Commission
commission = Commission.query.get_or_404(id)
full_code = commission.get_full_code()
db.session.delete(commission)
db.session.commit()
flash(_('Comisión %(code)s eliminada permanentemente.', code=full_code), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/commissions/<int:id>/virtual-link', methods=['POST'])
@login_required
def commission_virtual_link(id):
"""Actualizar enlace del aula virtual (Zoom, Meet, Teams, Moodle) - Profesores, Admin y Bedelía"""
from app.models.subject import Commission
commission = Commission.query.get_or_404(id)
if not user_can_manage_commission_milestones(current_user, commission):
flash(_('No tienes permisos para actualizar el aula virtual de esta comisión.'), 'error')
return redirect(url_for('schedule.commissions_view'))
link = request.form.get('virtual_link', '').strip()
commission.virtual_link = link or None
db.session.commit()
flash(_('Enlace del aula virtual actualizado para la comisión %(code)s.', code=commission.get_full_code()), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/commissions/<int:id>/milestones/add', methods=['POST'])
@login_required
def commission_milestone_add(id):
"""Agregar un hito académico (Parcial, TP, Recuperatorio) a una comisión - Profesores, Admin y Bedelía"""
from app.models.subject import Commission
from app.models.milestone import AcademicMilestone, MilestoneType
commission = Commission.query.get_or_404(id)
if not user_can_manage_commission_milestones(current_user, commission):
flash(_('No tienes permisos para agregar hitos a esta comisión.'), 'error')
return redirect(url_for('schedule.commissions_view'))
milestone_type_id = request.form.get('milestone_type_id', type=int)
title = request.form.get('title', '').strip()
date_str = request.form.get('date', '').strip()
time_str = request.form.get('time', '18:00').strip()
classroom = request.form.get('classroom', '').strip()
link = request.form.get('link', '').strip() or commission.virtual_link or ''
notes = request.form.get('notes', '').strip()
if not milestone_type_id or not date_str:
flash(_('Debes especificar el tipo de hito y la fecha.'), 'error')
return redirect(url_for('schedule.commissions_view'))
m_type = MilestoneType.query.get(milestone_type_id)
if not m_type:
flash(_('Tipo de hito inválido.'), 'error')
return redirect(url_for('schedule.commissions_view'))
try:
dt = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M")
except ValueError:
try:
dt = datetime.strptime(date_str, "%Y-%m-%d")
except ValueError:
flash(_('Formato de fecha inválido.'), 'error')
return redirect(url_for('schedule.commissions_view'))
milestone = AcademicMilestone(
title=title or m_type.name,
materia_codigo=commission.subject.code if commission.subject else 'ASIG',
subject_id=commission.subject_id,
commission_id=commission.id,
milestone_type_id=m_type.id,
date=dt,
classroom=classroom or 'Campus Virtual',
link=link or None,
notes=notes or None,
is_active=True
)
db.session.add(milestone)
db.session.commit()
flash(_('Hito "%(title)s" registrado correctamente en %(comm)s.', title=milestone.title, comm=commission.get_full_code()), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/commissions/<int:commission_id>/milestones/<int:milestone_id>/delete', methods=['POST'])
@login_required
def commission_milestone_delete(commission_id, milestone_id):
"""Eliminar un hito académico de una comisión"""
from app.models.subject import Commission
from app.models.milestone import AcademicMilestone
commission = Commission.query.get_or_404(commission_id)
if not user_can_manage_commission_milestones(current_user, commission):
flash(_('No tienes permisos para eliminar hitos de esta comisión.'), 'error')
return redirect(url_for('schedule.commissions_view'))
milestone = AcademicMilestone.query.filter_by(id=milestone_id, commission_id=commission_id).first_or_404()
title = milestone.title
db.session.delete(milestone)
db.session.commit()
flash(_('Hito "%(title)s" eliminado de la comisión.', title=title), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/add', methods=['GET', 'POST']) @schedule_bp.route('/add', methods=['GET', 'POST'])
@login_required @login_required
@@ -138,17 +493,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 classrooms for the dropdown # Get active buildings and classrooms
classrooms = Classroom.query.filter_by(is_active=True).all() buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all()
# Get active commissions for the dropdown
commissions = Commission.query.filter_by(active=True).all() commissions = Commission.query.filter_by(active=True).all()
# Populate form choices # Populate form choices
form.classroom_id.choices = [(c.id, f'{c.code_display} - {c.name} (Cap: {c.capacity})') for c in classrooms] form.classroom_id.choices = [(c.id, f'{c.building_name} - {c.code} (Cap: {c.capacity_display})') for c in classrooms]
form.commission_id.choices = [(c.id, f'{c.get_full_code()} - {c.subject.name if c.subject else "No Subject"} (Students: {c.max_students})') for c in commissions] form.commission_id.choices = [(c.id, f'{c.get_full_code()} - {c.subject.name if c.subject else "No Subject"} (Students: {c.max_students})') for c in commissions]
if request.method == 'POST': if request.method == 'POST':
@@ -166,7 +521,7 @@ def add_reservation():
if existing_reservation: if existing_reservation:
flash('This classroom is already booked for the selected time slot.', 'danger') flash('This classroom is already booked for the selected time slot.', 'danger')
return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions) return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions, buildings=buildings)
# Create new reservation # Create new reservation
reservation = Reservation( reservation = Reservation(
@@ -177,6 +532,7 @@ def add_reservation():
end_time=form.end_time.data, end_time=form.end_time.data,
purpose=form.purpose.data, purpose=form.purpose.data,
notes=form.notes.data, notes=form.notes.data,
virtual_link=form.virtual_link.data.strip() if form.virtual_link.data else None,
status=ReservationStatus.PENDING.value, status=ReservationStatus.PENDING.value,
expected_attendees=form.expected_attendees.data expected_attendees=form.expected_attendees.data
) )
@@ -189,16 +545,144 @@ def add_reservation():
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
current_app.logger.error(f"Error creating reservation: {str(e)}")
flash(f'Error creating reservation: {str(e)}', 'danger') flash(f'Error creating reservation: {str(e)}', 'danger')
return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions) return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions, buildings=buildings)
@schedule_bp.route('/reservation/add', methods=['POST'])
@login_required
def quick_reservation_add():
"""Endpoint AJAX para crear reserva rápida desde el modal de comisiones"""
try:
commission_id = request.form.get('commission_id', type=int)
classroom_id = request.form.get('classroom_id', type=int)
purpose = request.form.get('purpose', '').strip() or 'Clase de cursada'
start_str = request.form.get('start_time', '').strip()
end_str = request.form.get('end_time', '').strip()
virtual_link = request.form.get('virtual_link', '').strip() or None
if not commission_id or not classroom_id or not start_str or not end_str:
return jsonify({'error': 'Faltan campos obligatorios'}), 400
start_time = datetime.strptime(start_str, '%Y-%m-%d %H:%M')
end_time = datetime.strptime(end_str, '%Y-%m-%d %H:%M')
from app.models.subject import Commission
from app.models.classroom import Classroom
comm = Commission.query.get_or_404(commission_id)
if not virtual_link and comm.virtual_link:
virtual_link = comm.virtual_link
new_res = Reservation(
classroom_id=classroom_id,
commission_id=commission_id,
user_id=comm.teacher_id or current_user.id,
start_time=start_time,
end_time=end_time,
purpose=purpose,
expected_attendees=comm.max_students or 30,
shift=comm.shift or 'Tarde',
virtual_link=virtual_link,
status=ReservationStatus.CONFIRMED.value if current_user.is_admin() else ReservationStatus.PENDING.value
)
db.session.add(new_res)
db.session.commit()
return jsonify({'success': True, 'id': new_res.id})
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 500
@schedule_bp.route('/list') @schedule_bp.route('/list')
@login_required @login_required
def list_reservations(): def list_reservations():
"""List all reservations""" """List all reservations with pagination, date filters, floor, shift, and virtual toggle"""
return redirect(url_for('schedule.today_schedule')) page = request.args.get('page', 1, type=int)
status_filter = request.args.get('status', 'all').strip()
date_from = request.args.get('date_from', '').strip()
date_to = request.args.get('date_to', '').strip()
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
floor_filter = request.args.get('floor', '').strip()
shift_filter = request.args.get('shift', '').strip()
classroom_id = request.args.get('classroom_id', type=int)
search = request.args.get('search', '').strip()
query = Reservation.query.options(
joinedload(Reservation.classroom),
joinedload(Reservation.commission).joinedload(Commission.subject),
joinedload(Reservation.user)
).join(Reservation.classroom).outerjoin(Reservation.commission).outerjoin(Commission.subject)
if classroom_id:
query = query.filter(Reservation.classroom_id == classroom_id)
if status_filter and status_filter != 'all':
query = query.filter(Reservation.status == status_filter.upper())
if date_from:
try:
d_from = datetime.strptime(date_from, '%Y-%m-%d')
query = query.filter(Reservation.start_time >= d_from)
except ValueError:
pass
if date_to:
try:
d_to = datetime.strptime(date_to, '%Y-%m-%d') + timedelta(days=1)
query = query.filter(Reservation.start_time < d_to)
except ValueError:
pass
if hide_virtual:
query = query.filter(
~Classroom.code.ilike('%VIRTUAL%'),
~Classroom.building.ilike('%Virtual%')
)
if floor_filter:
flr = str(floor_filter).strip()
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
query = query.filter(or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
else:
query = query.filter(Classroom.floor == flr)
if shift_filter:
query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%'))
if search:
query = query.filter(
or_(
Subject.name.ilike(f'%{search}%'),
Subject.code.ilike(f'%{search}%'),
Commission.code.ilike(f'%{search}%'),
Classroom.code.ilike(f'%{search}%'),
Reservation.purpose.ilike(f'%{search}%'),
Reservation.shift.ilike(f'%{search}%')
)
)
reservations = query.order_by(Reservation.start_time.desc()).paginate(
page=page, per_page=20, error_out=False
)
floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()
available_floors = [f[0] for f in floors]
all_classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all()
return render_template(
'schedule/list.html',
reservations=reservations,
status_filter=status_filter,
date_from=date_from,
date_to=date_to,
hide_virtual=hide_virtual,
floor_filter=floor_filter,
shift_filter=shift_filter,
classroom_id=classroom_id,
all_classrooms=all_classrooms,
search=search,
available_floors=available_floors
)
@schedule_bp.route('/reservation/<int:id>/confirm', methods=['GET', 'POST']) @schedule_bp.route('/reservation/<int:id>/confirm', methods=['GET', 'POST'])
@@ -321,53 +805,128 @@ def classroom_availability_api():
def calendar_data(): def calendar_data():
"""Get calendar events data for FullCalendar""" """Get calendar events data for FullCalendar"""
try: try:
# Get date range from query parameters
start = request.args.get('start') start = request.args.get('start')
end = request.args.get('end') end = request.args.get('end')
classroom_id = request.args.get('classroom_id')
subject_id = request.args.get('subject_id')
career_id = request.args.get('career_id')
shift = request.args.get('shift', '').strip()
floor = request.args.get('floor', '').strip()
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
search = request.args.get('search', '').strip()
if start and end: if start and end:
from datetime import datetime # Clean up ISO timezone strings for PostgreSQL naive timestamp comparison
start_date = datetime.fromisoformat(start) start_clean = start.replace('Z', '+00:00')
end_date = datetime.fromisoformat(end) end_clean = end.replace('Z', '+00:00')
start_date = datetime.fromisoformat(start_clean).replace(tzinfo=None)
end_date = datetime.fromisoformat(end_clean).replace(tzinfo=None)
else: else:
# Default to current month
today = date.today() today = date.today()
start_date = datetime.combine(today.replace(day=1), datetime.min.time()) start_date = datetime.combine(today.replace(day=1), datetime.min.time())
next_month = today.replace(day=28) + timedelta(days=4) # Next month next_month = today.replace(day=28) + timedelta(days=4)
end_date = datetime.combine(next_month.replace(day=1), datetime.max.time()) end_date = datetime.combine(next_month.replace(day=1), datetime.max.time())
# Get reservations in the date range query = Reservation.query.join(Reservation.commission).join(Commission.subject).outerjoin(Reservation.classroom).options(
reservations = Reservation.query.options(
joinedload(Reservation.classroom), joinedload(Reservation.classroom),
joinedload(Reservation.commission) joinedload(Reservation.commission).joinedload(Commission.subject).joinedload(Subject.career_obj)
).filter( ).filter(
Reservation.start_time >= start_date, Reservation.start_time >= start_date,
Reservation.start_time <= end_date, Reservation.start_time <= end_date
Reservation.status.in_([ReservationStatus.CONFIRMED.value, ReservationStatus.PENDING.value]) )
).order_by(Reservation.start_time).all()
if classroom_id and classroom_id.isdigit():
query = query.filter(Reservation.classroom_id == int(classroom_id))
if subject_id and subject_id.isdigit():
query = query.filter(Commission.subject_id == int(subject_id))
if career_id and career_id.isdigit():
query = query.filter(Subject.career_id == int(career_id))
building = request.args.get('building', '').strip()
if building:
from app.models.building import Building
query = query.filter(
or_(
Classroom.building.ilike(f'%{building}%'),
Classroom.building_entity.has(Building.name.ilike(f'%{building}%'))
)
)
if shift:
query = query.filter(Reservation.shift.ilike(f'%{shift}%'))
if hide_virtual:
query = query.filter(
~Classroom.code.ilike('%VIRTUAL%'),
~Classroom.building.ilike('%Virtual%')
)
if floor:
flr = str(floor).strip()
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
query = query.filter(or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
else:
query = query.filter(Classroom.floor == flr)
if search:
query = query.filter(
or_(
Subject.name.ilike(f'%{search}%'),
Subject.code.ilike(f'%{search}%'),
Classroom.code.ilike(f'%{search}%'),
Reservation.purpose.ilike(f'%{search}%')
)
)
reservations = query.order_by(Reservation.start_time).all()
events = [] events = []
for reservation in reservations: for reservation in reservations:
subject = reservation.commission.subject if (reservation.commission and reservation.commission.subject) else None
subject_code = subject.code if subject else (reservation.commission.code if reservation.commission else '')
subject_name = subject.name if subject else (reservation.purpose or 'Clase')
classroom_name = reservation.classroom.code if reservation.classroom else 'Sin Aula'
classroom_floor = reservation.classroom.floor_display if reservation.classroom else None
classroom_building = reservation.classroom.building_name if reservation.classroom else ''
is_virtual = bool(reservation.classroom and reservation.classroom.is_virtual)
career_name = subject.career_obj.name if (subject and subject.career_obj) else (subject.department if subject else '')
status_val = str(reservation.status.value if hasattr(reservation.status, 'value') else reservation.status)
events.append({ events.append({
'id': reservation.id, 'id': reservation.id,
'title': f"{reservation.commission.get_full_code() if reservation.commission else 'No Commission'} - {reservation.classroom.code_display}", 'title': subject_name,
'start': reservation.start_time.isoformat(), 'start': reservation.start_time.isoformat(),
'end': reservation.end_time.isoformat(), 'end': reservation.end_time.isoformat(),
'url': url_for('schedule.view_reservation', id=reservation.id) if reservation.id else None, 'url': url_for('schedule.view_reservation', id=reservation.id) if reservation.id else None,
'className': f'fc-event-{reservation.status.value.lower()}', 'className': f'fc-event-{status_val.lower()}',
'extendedProps': { 'extendedProps': {
'classroom': reservation.classroom.code_display if reservation.classroom else 'Unknown', 'subject_code': subject_code,
'commission': reservation.commission.get_full_code() if reservation.commission else 'No Commission', 'subject_name': subject_name,
'purpose': reservation.purpose or 'No purpose', 'classroom': classroom_name,
'attendees': reservation.expected_attendees, 'building': classroom_building,
'status': reservation.status.value, 'floor': classroom_floor,
'notes': reservation.notes or '' 'is_virtual': is_virtual,
'location_display': reservation.classroom.location_display if reservation.classroom else '',
'career': career_name,
'commission': reservation.commission.code if reservation.commission else '',
'shift': reservation.shift or '',
'status': status_val,
'purpose': reservation.purpose or '',
'attendees': reservation.expected_attendees or 0,
'notes': reservation.notes or '',
'teacher': reservation.user.name if reservation.user else '',
'virtual_link': reservation.effective_virtual_link,
'cancel_url': url_for('schedule.cancel_reservation', id=reservation.id)
} }
}) })
return jsonify(events) return jsonify(events)
except Exception as e: except Exception as e:
import traceback
traceback.print_exc()
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
@schedule_bp.route('/api/today-events') @schedule_bp.route('/api/today-events')
@@ -380,26 +939,33 @@ def api_today_events():
reservations = Reservation.query.options( reservations = Reservation.query.options(
joinedload(Reservation.classroom), joinedload(Reservation.classroom),
joinedload(Reservation.commission) joinedload(Reservation.commission).joinedload(Commission.subject).joinedload(Subject.career_obj)
).filter( ).filter(
Reservation.start_time >= today_start, Reservation.start_time >= today_start,
Reservation.start_time <= today_end, Reservation.start_time <= today_end
Reservation.status == ReservationStatus.CONFIRMED.value
).order_by(Reservation.start_time).all() ).order_by(Reservation.start_time).all()
events = [] events = []
for reservation in reservations: for reservation in reservations:
subject = reservation.commission.subject if (reservation.commission and reservation.commission.subject) else None
subject_code = subject.code if subject else (reservation.commission.code if reservation.commission else '')
subject_name = subject.name if subject else (reservation.purpose or 'Clase')
classroom_name = reservation.classroom.code if reservation.classroom else 'Sin Aula'
status_val = str(reservation.status.value if hasattr(reservation.status, 'value') else reservation.status)
events.append({ events.append({
'id': reservation.id, 'id': reservation.id,
'title': f"{reservation.commission.get_full_code() if reservation.commission else 'No Commission'} - {reservation.classroom.code_display}", 'title': f"{subject_name} ({subject_code}) - {classroom_name}",
'start': reservation.start_time.isoformat(), 'start': reservation.start_time.isoformat(),
'end': reservation.end_time.isoformat(), 'end': reservation.end_time.isoformat(),
'url': url_for('schedule.view_reservation', id=reservation.id) if reservation.id else None, 'url': url_for('schedule.view_reservation', id=reservation.id) if reservation.id else None,
'extendedProps': { 'extendedProps': {
'classroom': reservation.classroom.code_display if reservation.classroom else 'Unknown', 'subject_code': subject_code,
'subject_name': subject_name,
'classroom': classroom_name,
'purpose': reservation.purpose or 'No purpose', 'purpose': reservation.purpose or 'No purpose',
'attendees': reservation.expected_attendees, 'attendees': reservation.expected_attendees,
'status': reservation.status.value 'status': status_val
} }
}) })
+3
View File
@@ -0,0 +1,3 @@
from .sheets_importer import GoogleSheetsImporter
__all__ = ['GoogleSheetsImporter']
+413
View File
@@ -0,0 +1,413 @@
import urllib.request
import csv
import io
import re
from datetime import datetime, date, time, timedelta
from app import db
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, ReservationStatus
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'
SHEETS_CONFIG = [
{ 'name': 'Lunes', 'gid': '1915752353', 'day_idx': 0 },
{ 'name': 'Martes', 'gid': '466778263', 'day_idx': 1 },
{ 'name': 'Miércoles', 'gid': '615483345', 'day_idx': 2 },
{ 'name': 'Jueves', 'gid': '682416571', 'day_idx': 3 },
{ 'name': 'Viernes', 'gid': '1162906711', 'day_idx': 4 },
{ 'name': 'Sábado', 'gid': '752305601', 'day_idx': 5 },
]
TIME_REGEX = re.compile(r'(\d{1,2})(?::(\d{2}))?\s*(?:a|-|to)\s*(\d{1,2})(?::(\d{2}))?', re.IGNORECASE)
class GoogleSheetsImporter:
"""
Service to fetch, parse, and synchronize academic schedules,
classrooms, careers, and subjects from the official UniCABA Google Sheet.
"""
def __init__(self, base_url=None):
self.base_url = base_url or BASE_CSV_URL
def fetch_sheet_csv(self, gid):
"""Fetch CSV string for a specific sheet gid"""
url = f"{self.base_url}&gid={gid}"
req = urllib.request.Request(
url,
headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) EduSpace/2.0'}
)
with urllib.request.urlopen(req, timeout=15) as resp:
return resp.read().decode('utf-8', errors='replace')
def parse_sheet_rows(self, csv_content, sheet_name):
"""Parse raw CSV text into a structured list of class records"""
reader = csv.reader(io.StringIO(csv_content))
rows = list(reader)
header_idx = -1
col_offset = 0
for r_idx, row in enumerate(rows):
for c_idx, cell in enumerate(row):
cell_clean = cell.lower().strip()
if 'codigo' in cell_clean or 'código' in cell_clean:
header_idx = r_idx
col_offset = c_idx
break
if header_idx != -1:
break
if header_idx == -1:
return []
records = []
current_shift = 'Mañana'
for row in rows[header_idx + 1:]:
if len(row) >= col_offset + 5:
raw_cell = row[col_offset].strip().replace('\n', ' - ')
row_full_text = ' '.join(row).upper()
# Detect shift delimiters
if 'TURNO MAÑANA' in row_full_text or 'TURNO MANANA' in row_full_text:
current_shift = 'Mañana'
continue
elif 'TURNO VESPERTINO' in row_full_text or 'TURNO TARDE' in row_full_text or 'TURNO NOCHE' in row_full_text:
current_shift = 'Tarde'
continue
elif raw_cell.lower().startswith('turno'):
if 'mañ' in raw_cell.lower() or 'man' in raw_cell.lower():
current_shift = 'Mañana'
else:
current_shift = 'Tarde'
continue
codigo = raw_cell
asignatura = row[col_offset + 1].strip().replace('\n', ' ')
aula = row[col_offset + 2].strip().replace('\n', ' ')
carrera = row[col_offset + 3].strip().replace('\n', ' ')
horario = row[col_offset + 4].strip().replace('\n', ' ')
# Check valid data row
if codigo and asignatura and not 'codigo' in codigo.lower():
# Double-check shift from start hour if possible
record_shift = current_shift
time_match = re.search(r'(\d{1,2}):(\d{2})', horario)
if time_match:
h = int(time_match.group(1))
if h >= 14:
record_shift = 'Tarde'
elif h < 13:
record_shift = 'Mañana'
records.append({
'dia': sheet_name,
'codigo': codigo,
'asignatura': asignatura,
'aula': aula,
'carrera': carrera,
'horario': horario,
'turno': record_shift
})
return records
def normalize_room(self, aula_raw):
"""
Normalize room string into (building, room_number, floor, capacity)
"""
aula_upper = aula_raw.upper().strip()
if not aula_upper or any(x in aula_upper for x in ['FERIADO', 'SIN ACTIVIDAD', 'CONSULTAR EN BEDELIA']):
return None
if 'VIRTUAL' in aula_upper:
return {
'building': 'Campus Virtual',
'code': 'VIRTUAL',
'floor': 0,
'capacity': 0,
'description': 'Espacio áulico virtual UniCABA (Capacidad Ilimitada ∞)'
}
if 'AUDITORIO' in aula_upper:
return {
'building': 'Edificio Central',
'code': 'Auditorio',
'floor': 0,
'capacity': 150,
'description': 'Auditorio principal UniCABA'
}
if 'SALA DE REUNI' in aula_upper:
floor = 2 if ('2DO' in aula_upper or '2°' in aula_upper) else 4
return {
'building': 'Edificio Central',
'code': f'Sala Reunión {floor}°P',
'floor': floor,
'capacity': 15,
'description': f'Sala de reunión piso {floor}'
}
# Standard classrooms: "Aula 402", "AULA 203", "306 - 406"
num_match = re.search(r'\d+', aula_raw)
if num_match:
num = num_match.group(0)
floor = int(num[0]) if len(num) >= 3 else 1
return {
'building': 'Edificio Central',
'code': f'Aula {num}',
'floor': floor,
'capacity': 35,
'description': f'Aula física {num} - Piso {floor}'
}
# Generic fallback
return {
'building': 'Edificio Central',
'code': aula_raw,
'floor': 1,
'capacity': 30,
'description': aula_raw
}
def parse_time_range(self, horario_str):
"""Parse '8:00 a 9:00hs' into (time(8, 0), time(9, 0))"""
m = TIME_REGEX.search(horario_str)
if not m:
return None, None
try:
h1 = int(m.group(1))
m1 = int(m.group(2) or 0)
h2 = int(m.group(3))
m2 = int(m.group(4) or 0)
return time(h1, m1), time(h2, m2)
except Exception:
return None, None
def sync(self):
"""
Main synchronization process:
1. Fetches all 6 day sheets.
2. Upserts Careers, Classrooms, Subjects, Commissions.
3. Generates Reservations in the calendar for the semester.
"""
stats = {
'total_rows_processed': 0,
'careers_created': 0,
'careers_updated': 0,
'classrooms_created': 0,
'classrooms_updated': 0,
'subjects_created': 0,
'subjects_updated': 0,
'commissions_created': 0,
'reservations_created': 0,
'errors': [],
'sample_records': []
}
all_raw_records = []
for sheet_cfg in SHEETS_CONFIG:
try:
csv_data = self.fetch_sheet_csv(sheet_cfg['gid'])
rows = self.parse_sheet_rows(csv_data, sheet_cfg['name'])
all_raw_records.extend([(r, sheet_cfg['day_idx']) for r in rows])
except Exception as e:
stats['errors'].append(f"Error al descargar o procesar la hoja de {sheet_cfg['name']}: {str(e)}")
stats['total_rows_processed'] = len(all_raw_records)
if not all_raw_records:
return stats
# Default admin user for reservations
default_user = User.query.filter_by(role='ADMIN').first() or User.query.first()
default_user_id = default_user.id if default_user else 1
# Ensure institutional buildings exist with official address
from app.models.building import Building
b_central = Building.query.filter_by(name='Edificio Central').first()
if not b_central:
b_central = Building(
name='Edificio Central',
code='CENTRAL',
address='Tte. Gral. Juan Domingo Perón 802, CABA',
floors=4,
description='Sede central institucional de UniCABA. Aulas de grado, auditorio y laboratorios.'
)
db.session.add(b_central)
db.session.flush()
else:
b_central.address = 'Tte. Gral. Juan Domingo Perón 802, CABA'
b_virtual = Building.query.filter_by(name='Campus Virtual').first()
if not b_virtual:
b_virtual = Building(
name='Campus Virtual',
code='VIRTUAL',
address='Entorno Digital / Microsoft Teams - Moodle',
floors=0,
description='Entorno de aulas virtuales y clases a distancia'
)
db.session.add(b_virtual)
db.session.flush()
buildings_cache = {b.name.strip().upper(): b for b in Building.query.all()}
careers_cache = {c.name.strip().upper(): c for c in Career.query.all()}
classrooms_cache = {(c.building.strip().upper(), c.code.strip().upper()): c for c in Classroom.query.all()}
subjects_cache = {s.code.strip().upper(): s for s in Subject.query.all()}
# Calculate current semester dates (anchored on academic year 2026)
# Week starting Monday 2026-08-31 to Saturday 2026-09-05
base_monday = date(2026, 8, 31)
weeks_to_generate = 4 # Generate reservations for 4 consecutive weeks
for rec, day_idx in all_raw_records:
try:
codigo = rec['codigo']
asignatura = rec['asignatura']
aula_raw = rec['aula']
carrera_name = rec['carrera'].strip()
horario_raw = rec['horario']
dia = rec['dia']
turno = rec.get('turno', 'Mañana')
# 1. Upsert Career
carrera_key = carrera_name.upper()
career = careers_cache.get(carrera_key)
if not career:
career = Career(name=carrera_name)
db.session.add(career)
db.session.flush()
careers_cache[carrera_key] = career
stats['careers_created'] += 1
# 2. Upsert Classroom
room_info = self.normalize_room(aula_raw)
classroom = None
if room_info:
room_key = (room_info['building'].upper(), room_info['code'].upper())
classroom = classrooms_cache.get(room_key)
b_entity = buildings_cache.get(room_info['building'].upper())
if not classroom:
classroom = Classroom(
building=room_info['building'],
building_id=b_entity.id if b_entity else None,
floor=room_info['floor'],
capacity=room_info['capacity'],
description=room_info['description'],
is_active=True
)
classroom.code = room_info['code']
db.session.add(classroom)
db.session.flush()
classrooms_cache[room_key] = classroom
stats['classrooms_created'] += 1
else:
classroom.capacity = max(classroom.capacity, room_info['capacity'])
if not classroom.building_id and b_entity:
classroom.building_id = b_entity.id
# 3. Upsert Subject
subject_key = codigo.upper()
subject = subjects_cache.get(subject_key)
if not subject:
subject = Subject(
code=codigo,
name=asignatura,
career_id=career.id,
department=career.name,
credits=4,
is_active=True
)
db.session.add(subject)
db.session.flush()
subjects_cache[subject_key] = subject
stats['subjects_created'] += 1
else:
if subject.name != asignatura:
subject.name = asignatura
stats['subjects_updated'] += 1
subject.career_id = career.id
subject.department = career.name
# 4. Upsert Commission
# Identify commission subcode e.g. from code suffix or default 'C1'
comm_code = 'C1'
if '-E' in codigo:
comm_code = codigo.split('-')[-1]
commission = Commission.query.filter_by(
subject_id=subject.id,
code=comm_code,
semester='2026-2',
year=2026
).first()
if not commission:
commission = Commission(
subject_id=subject.id,
code=comm_code,
semester='2026-2',
year=2026,
teacher_id=default_user_id,
max_students=classroom.capacity if classroom else 35,
current_students=0,
schedule=f"{dia} {horario_raw}",
shift=turno,
active=True
)
db.session.add(commission)
db.session.flush()
stats['commissions_created'] += 1
else:
commission.schedule = f"{dia} {horario_raw}"
commission.shift = turno
# 5. Generate Calendar Reservations
t_start, t_end = self.parse_time_range(horario_raw)
if classroom and t_start and t_end and day_idx is not None:
for w in range(weeks_to_generate):
class_date = base_monday + timedelta(days=day_idx, weeks=w)
dt_start = datetime.combine(class_date, t_start)
dt_end = datetime.combine(class_date, t_end)
existing_res = Reservation.query.filter_by(
classroom_id=classroom.id,
start_time=dt_start,
end_time=dt_end
).first()
if not existing_res:
res = Reservation(
classroom_id=classroom.id,
commission_id=commission.id,
user_id=default_user_id,
start_time=dt_start,
end_time=dt_end,
purpose=f"{subject.code} - {subject.name}",
status='CONFIRMED',
expected_attendees=min(classroom.capacity, 30),
shift=turno,
notes=f"Clase semanal {dia} ({turno}) - Carrera: {career.name}"
)
db.session.add(res)
stats['reservations_created'] += 1
else:
existing_res.shift = turno
existing_res.notes = f"Clase semanal {dia} ({turno}) - Carrera: {career.name}"
if len(stats['sample_records']) < 10:
stats['sample_records'].append({
'dia': dia,
'codigo': codigo,
'asignatura': asignatura,
'carrera': career.name,
'aula': classroom.code_display if classroom else aula_raw,
'horario': horario_raw
})
except Exception as row_err:
stats['errors'].append(f"Error en fila '{rec.get('codigo', '?')}': {str(row_err)}")
db.session.commit()
return stats
+53 -96
View File
@@ -1,97 +1,44 @@
/* Custom CSS for Edu-Space Admin */ /* Custom Enhancements for Edu-Space Admin */
/* Dark Theme Enhancements */
body {
background-color: #1a1a1a;
}
/* Card Styles */ /* Card Styles */
.card { .card {
border-radius: 15px; border-radius: 14px;
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out; transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out, border-color 0.2s ease-in-out;
} }
.card:hover { .card:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
} }
/* Navigation */ /* Navigation */
.navbar { .navbar {
backdrop-filter: blur(10px); backdrop-filter: blur(12px);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); -webkit-backdrop-filter: blur(12px);
} }
.navbar-brand { .navbar-brand {
font-weight: 600; font-weight: 700;
}
/* Button Styles */
.btn {
border-radius: 8px;
font-weight: 500;
transition: all 0.2s ease-in-out;
}
.btn:hover {
transform: translateY(-1px);
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
}
.btn-primary:hover {
background: linear-gradient(135deg, #5568d8 0%, #654198 100%);
}
/* Form Styles */
.form-control, .form-select {
border-radius: 8px;
border: 1px solid #444;
background-color: #2a2a2a;
color: #fff;
transition: border-color 0.2s ease-in-out;
}
.form-control:focus, .form-select:focus {
border-color: #667eea;
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
background-color: #2a2a2a;
color: #fff;
}
.table {
background-color: transparent;
}
.table {
background-color: transparent;
} }
/* Table Hover */
.table:hover tbody tr:hover { .table:hover tbody tr:hover {
background-color: rgba(102, 126, 234, 0.05); background-color: rgba(var(--brand-primary-rgb), 0.04);
}
/* Badge Styles */
.badge {
font-size: 0.75em;
padding: 0.5em 0.8em;
} }
/* Status Badge Colors */ /* Status Badge Colors */
.bg-confirmed-custom { .bg-confirmed-custom {
background-color: #28a745; background-color: #10B981;
color: #FFFFFF;
} }
.bg-pending-custom { .bg-pending-custom {
background-color: #ffc107; background-color: #F59E0B;
color: #000; color: #000000;
} }
.bg-cancelled-custom { .bg-cancelled-custom {
background-color: #dc3545; background-color: #EF4444;
color: #FFFFFF;
} }
/* Loading Spinner */ /* Loading Spinner */
@@ -99,9 +46,9 @@ body {
display: inline-block; display: inline-block;
width: 20px; width: 20px;
height: 20px; height: 20px;
border: 3px solid rgba(255, 255, 255, 0.3); border: 3px solid rgba(var(--brand-primary-rgb), 0.3);
border-radius: 50%; border-radius: 50%;
border-top-color: #667eea; border-top-color: var(--brand-primary);
animation: spin 1s ease-in-out infinite; animation: spin 1s ease-in-out infinite;
} }
@@ -111,28 +58,25 @@ body {
/* Calendar Customizations */ /* Calendar Customizations */
.fc-event { .fc-event {
border-radius: 6px; border-radius: 8px;
border: none !important; border: none !important;
padding: 4px 8px !important; padding: 4px 8px !important;
font-size: 12px !important; font-size: 12px !important;
margin: 1px 0 !important; margin: 1px 0 !important;
} }
.fc-day-sat, .fc-day-sun {
background-color: rgba(255, 255, 255, 0.02) !important;
}
/* Dashboard Stat Cards */ /* Dashboard Stat Cards */
.stat-card { .stat-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: var(--gradient-primary);
color: white; color: #FFFFFF;
border-radius: 15px; border-radius: 14px;
box-shadow: 0 6px 20px rgba(var(--brand-primary-rgb), 0.2);
} }
.stat-card-primary { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); } .stat-card-primary { background: linear-gradient(135deg, #B43E8E 0%, #9E367D 100%); }
.stat-card-success { background: linear-gradient(135deg, #28a745 0%, #20c997 100%); } .stat-card-success { background: linear-gradient(135deg, #059669 0%, #10B981 100%); }
.stat-card-info { background: linear-gradient(135deg, #17a2b8 0%, #138496 100%); } .stat-card-info { background: linear-gradient(135deg, #682CAD 0%, #8B5CF6 100%); }
.stat-card-warning { background: linear-gradient(135deg, #ffc107 0%, #fd7e14 100%); } .stat-card-warning { background: linear-gradient(135deg, #D97706 0%, #F59E0B 100%); }
/* Quick Action Buttons */ /* Quick Action Buttons */
.quick-action-btn { .quick-action-btn {
@@ -144,14 +88,14 @@ body {
} }
.quick-action-btn:hover { .quick-action-btn:hover {
transform: translateX(5px); transform: translateX(4px);
} }
/* Empty State Styles */ /* Empty State Styles */
.empty-state { .empty-state {
text-align: center; text-align: center;
padding: 3rem 1rem; padding: 3rem 1rem;
color: #6c757d; color: var(--text-muted);
} }
.empty-state i { .empty-state i {
@@ -163,27 +107,40 @@ body {
/* Notification Toast enhancements */ /* Notification Toast enhancements */
.toast { .toast {
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
border-radius: 10px; border-radius: 12px;
} }
/* Pagination Styles */ /* Pagination Styles */
.pagination .page-link { .pagination .page-link {
border-radius: 8px; border-radius: 8px;
margin: 0 2px; margin: 0 3px;
border: 1px solid #444; border: 1px solid var(--card-border);
background-color: #2a2a2a; background-color: var(--card-bg);
color: #fff; color: var(--text-primary);
} }
.pagination .page-link:hover { .pagination .page-link:hover {
background-color: #667eea; background-color: var(--brand-primary);
border-color: #667eea; border-color: var(--brand-primary);
color: white; color: #FFFFFF;
} }
.pagination .page-item.active .page-link { .pagination .page-item.active .page-link {
background-color: #667eea; background: var(--gradient-primary);
border-color: #667eea; 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 */ /* Mobile Responsiveness */
@@ -208,13 +165,13 @@ body {
} }
.card { .card {
border: 1px solid #ddd !important; border: 1px solid #DDD !important;
box-shadow: none !important; box-shadow: none !important;
break-inside: avoid; break-inside: avoid;
} }
body { body {
background-color: white !important; background-color: #FFFFFF !important;
color: black !important; color: #000000 !important;
} }
} }
+668
View File
@@ -0,0 +1,668 @@
/**
* Sistema de Diseño & Tokens de Color - Edu-Space Admin
* Paleta Institucional Magenta / Uva y Soporte Claro/Oscuro
*/
:root {
/* --- Brand Primary (Magenta / Uva) --- */
--brand-primary: #B43E8E;
--brand-primary-rgb: 180, 62, 142;
--brand-primary-shade: #9E367D;
--brand-primary-tint: #BC5199;
--brand-primary-contrast: #FFFFFF;
/* --- Accent Colors (Modo Claro) --- */
--accent-purple: #682CAD;
--accent-purple-rgb: 104, 44, 173;
--secondary-magenta: #7B2049;
--secondary-magenta-rgb: 123, 32, 73;
/* --- Global Surface & Text (Modo Claro) --- */
--app-bg: #F8FAFC;
--card-bg: #FFFFFF;
--card-border: rgba(0, 0, 0, 0.08);
--card-hover-border: rgba(180, 62, 142, 0.3);
--card-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
--card-hover-shadow: 0 10px 25px rgba(180, 62, 142, 0.12);
--text-primary: #1E293B;
--text-secondary: #64748B;
--text-muted: #94A3B8;
--navbar-bg: linear-gradient(135deg, #B43E8E 0%, #9E367D 100%);
--navbar-text: #FFFFFF;
--navbar-border: transparent;
--navbar-shadow: 0 4px 20px rgba(180, 62, 142, 0.25);
--input-bg: #FFFFFF;
--input-border: #CBD5E1;
--input-text: #1E293B;
--input-focus-border: #B43E8E;
--input-focus-shadow: 0 0 0 0.2rem rgba(180, 62, 142, 0.25);
/* --- Distinctive Gradient --- */
--gradient-primary: linear-gradient(135deg, #B43E8E 0%, #9E367D 100%);
--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.12) 100%);
/* --- Badges (Modo Claro) --- */
--badge-primary-bg: #B43E8E;
--badge-primary-text: #FFFFFF;
--badge-accent-bg: #682CAD;
--badge-accent-text: #FFFFFF;
--badge-magenta-bg: #7B2049;
--badge-magenta-text: #FFFFFF;
}
/* =======================================================
MODO OSCURO ([data-bs-theme="dark"])
======================================================= */
[data-bs-theme="dark"] {
/* --- Brand Tweaks for Dark Theme --- */
--brand-primary: #B43E8E;
--brand-primary-tint: #C95FA6;
/* --- Accent Colors (Modo Oscuro) --- */
--accent-purple: #AE7FFF;
--secondary-magenta: #FF8AB6;
/* --- Surfaces & Contrast in Dark Mode --- */
--app-bg: #121622;
--card-bg: #1E2442;
--card-border: rgba(255, 255, 255, 0.08);
--card-hover-border: rgba(180, 62, 142, 0.5);
--card-shadow: 0 8px 25px rgba(0, 0, 0, 0.4);
--card-hover-shadow: 0 12px 30px rgba(180, 62, 142, 0.25);
--text-primary: #E2E8F0;
--text-secondary: #94A3B8;
--text-muted: #64748B;
--navbar-bg: linear-gradient(135deg, #24142D 0%, #161A2E 100%);
--navbar-text: #F8FAFC;
--navbar-border: 1px solid rgba(180, 62, 142, 0.25);
--navbar-shadow: 0 4px 25px rgba(0, 0, 0, 0.5);
--input-bg: #161B30;
--input-border: #2D3748;
--input-text: #E2E8F0;
--input-focus-border: #B43E8E;
--input-focus-shadow: 0 0 0 0.2rem rgba(180, 62, 142, 0.35);
--gradient-hero: linear-gradient(135deg, rgba(180, 62, 142, 0.18) 0%, rgba(30, 36, 66, 0.8) 100%);
/* --- Badges & Chips (Translucent in Dark Mode) --- */
--badge-primary-bg: rgba(180, 62, 142, 0.25);
--badge-primary-text: #FF8AB6;
--badge-primary-border: 1px solid rgba(180, 62, 142, 0.45);
--badge-accent-bg: rgba(104, 44, 173, 0.25);
--badge-accent-text: #AE7FFF;
--badge-accent-border: 1px solid rgba(174, 127, 255, 0.45);
--badge-magenta-bg: rgba(123, 32, 73, 0.25);
--badge-magenta-text: #FF96B4;
--badge-magenta-border: 1px solid rgba(255, 138, 182, 0.4);
}
/* =======================================================
ELEMENTOS GLOBALES & TRANSICIONES
======================================================= */
body {
background-color: var(--app-bg) !important;
color: var(--text-primary) !important;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
transition: background-color 0.25s ease, color 0.25s ease;
min-height: 100vh;
}
/* Navbar */
.navbar-app {
background: var(--navbar-bg) !important;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: var(--navbar-border);
box-shadow: var(--navbar-shadow);
padding: 0.65rem 1.25rem;
transition: background 0.3s ease, box-shadow 0.3s ease;
}
.navbar-app .navbar-brand {
color: var(--navbar-text) !important;
font-weight: 700;
letter-spacing: -0.01em;
display: flex;
align-items: center;
gap: 0.5rem;
}
.navbar-app .nav-link {
color: rgba(255, 255, 255, 0.85) !important;
font-weight: 500;
transition: color 0.15s ease, transform 0.15s ease;
border-radius: 8px;
padding: 0.5rem 0.85rem !important;
}
.navbar-app .nav-link:hover,
.navbar-app .nav-link:focus {
color: #FFFFFF !important;
background: rgba(255, 255, 255, 0.12);
}
.navbar-app .nav-link.active {
color: #FFFFFF !important;
background: rgba(255, 255, 255, 0.18);
}
.navbar-logo {
height: 32px;
width: auto;
object-fit: contain;
transition: filter 0.25s ease;
}
/* En Modo Claro el navbar es magenta (#B43E8E), aplicamos color blanco al isotipo */
[data-bs-theme="light"] .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 {
filter: none;
}
/* User Avatar Component */
.user-avatar-small {
width: 34px;
height: 34px;
border-radius: 50%;
background: linear-gradient(135deg, #B43E8E, #682CAD);
color: #FFFFFF;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.85rem;
font-weight: 700;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
border: 2px solid rgba(255, 255, 255, 0.4);
margin-right: 0.4rem;
}
/* Theme Toggle Button */
.theme-toggle-btn {
width: 36px;
height: 36px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.15);
color: #FFFFFF;
border: 1px solid rgba(255, 255, 255, 0.25);
transition: all 0.2s ease;
cursor: pointer;
}
.theme-toggle-btn:hover {
background: rgba(255, 255, 255, 0.28);
transform: rotate(15deg) scale(1.05);
color: #FFFFFF;
}
/* Language Switcher Button (Alto Contraste en Navbar y Modo Claro) */
.navbar-app .language-switcher .btn {
color: #FFFFFF !important;
background: rgba(255, 255, 255, 0.16) !important;
border: 1px solid rgba(255, 255, 255, 0.32) !important;
border-radius: 8px;
font-weight: 500;
transition: all 0.2s ease;
}
.navbar-app .language-switcher .btn:hover,
.navbar-app .language-switcher .btn:focus,
.navbar-app .language-switcher .dropdown-toggle.show {
color: #FFFFFF !important;
background: rgba(255, 255, 255, 0.28) !important;
border-color: rgba(255, 255, 255, 0.5) !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.navbar-app .language-switcher .dropdown-menu {
border-radius: 10px;
border: 1px solid var(--card-border);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
padding: 4px;
}
/* Dropdown Item Active (Gradiente Magenta Institucional en lugar de Azul) */
.dropdown-item {
border-radius: 6px;
padding: 0.45rem 0.85rem;
font-size: 0.9rem;
transition: background-color 0.15s ease, color 0.15s ease;
}
.dropdown-item.active,
.dropdown-item:active {
background: var(--gradient-primary) !important;
color: #FFFFFF !important;
}
.dropdown-item.active i,
.dropdown-item:active i {
color: #FFFFFF !important;
}
/* =======================================================
CARDS & CONTENEDORES
======================================================= */
.card {
background-color: var(--card-bg) !important;
border: 1px solid var(--card-border) !important;
color: var(--text-primary) !important;
box-shadow: var(--card-shadow);
border-radius: 14px;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.card:hover {
border-color: var(--card-hover-border) !important;
}
.card-header {
background-color: transparent !important;
border-bottom: 1px solid var(--card-border) !important;
}
.card-footer {
background-color: rgba(0, 0, 0, 0.02) !important;
border-top: 1px solid var(--card-border) !important;
}
[data-bs-theme="dark"] .card-footer {
background-color: rgba(255, 255, 255, 0.02) !important;
}
/* Hero Banners */
.hero-banner {
background: var(--gradient-hero) !important;
border: 1px solid var(--card-border) !important;
border-radius: 16px;
}
/* =======================================================
BOTONES Y ACCIONES
======================================================= */
.btn {
border-radius: 10px;
font-weight: 500;
transition: all 0.2s ease-in-out;
}
.btn:active {
transform: scale(0.98);
}
.btn-primary {
background: var(--gradient-primary) !important;
border: none !important;
color: var(--brand-primary-contrast) !important;
box-shadow: 0 4px 14px rgba(var(--brand-primary-rgb), 0.35);
}
.btn-primary:hover,
.btn-primary:focus {
background: linear-gradient(135deg, #9E367D 0%, #832765 100%) !important;
box-shadow: 0 6px 18px rgba(var(--brand-primary-rgb), 0.45);
transform: translateY(-1px);
color: #FFFFFF !important;
}
.btn-outline-primary {
color: var(--brand-primary) !important;
border-color: var(--brand-primary) !important;
background: transparent !important;
}
.btn-outline-primary:hover,
.btn-outline-primary:focus {
background: var(--gradient-primary) !important;
border-color: var(--brand-primary-shade) !important;
color: #FFFFFF !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(var(--brand-primary-rgb), 0.25);
}
/* Distinctive Text Accent */
.text-primary,
.text-brand {
color: var(--brand-primary) !important;
}
[data-bs-theme="dark"] .text-primary,
[data-bs-theme="dark"] .text-brand {
color: var(--brand-primary-tint) !important;
}
.text-accent-purple {
color: var(--accent-purple) !important;
}
.text-secondary-magenta {
color: var(--secondary-magenta) !important;
}
/* =======================================================
BADGES & CHIPS
======================================================= */
.badge {
border-radius: 8px;
font-weight: 600;
padding: 0.4em 0.75em;
letter-spacing: 0.01em;
}
.badge-brand {
background-color: var(--badge-primary-bg) !important;
color: var(--badge-primary-text) !important;
border: var(--badge-primary-border, none);
}
.badge-approved {
background-color: var(--badge-accent-bg) !important;
color: var(--badge-accent-text) !important;
border: var(--badge-accent-border, none);
}
.badge-schedule {
background-color: var(--badge-magenta-bg) !important;
color: var(--badge-magenta-text) !important;
border: var(--badge-magenta-border, none);
}
/* =======================================================
FORMULARIOS & INPUTS
======================================================= */
.form-control,
.form-select {
background-color: var(--input-bg) !important;
border-color: var(--input-border) !important;
color: var(--input-text) !important;
border-radius: 10px;
padding: 0.6rem 0.9rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
}
.form-control:focus,
.form-select:focus {
border-color: var(--input-focus-border) !important;
box-shadow: var(--input-focus-shadow) !important;
}
.form-control::placeholder {
color: var(--text-muted) !important;
}
/* =======================================================
TARJETA DE LOGIN (.auth-card)
======================================================= */
.auth-wrapper {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
background: var(--app-bg);
}
.auth-card {
background-color: var(--card-bg) !important;
border: 1px solid var(--card-border) !important;
border-left: 6px solid var(--brand-primary) !important;
border-radius: 18px !important;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.1);
max-width: 460px;
width: 100%;
overflow: hidden;
position: relative;
transition: transform 0.25s ease, box-shadow 0.25s ease;
}
[data-bs-theme="dark"] .auth-card {
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5), 0 0 25px rgba(180, 62, 142, 0.15);
}
.auth-header {
padding: 2.25rem 2rem 1.25rem;
text-align: center;
position: relative;
}
.auth-logo {
max-height: 52px;
max-width: 220px;
width: auto;
object-fit: contain;
margin-bottom: 1rem;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.15));
}
.auth-controls {
position: absolute;
top: 1rem;
right: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Controles de Login en Modo Claro (Tarjeta Blanca) */
[data-bs-theme="light"] .auth-controls .language-switcher .btn,
:root:not([data-bs-theme="dark"]) .auth-controls .language-switcher .btn {
color: var(--brand-primary) !important;
background: rgba(var(--brand-primary-rgb), 0.08) !important;
border: 1px solid rgba(var(--brand-primary-rgb), 0.25) !important;
}
[data-bs-theme="light"] .auth-controls .language-switcher .btn:hover,
:root:not([data-bs-theme="dark"]) .auth-controls .language-switcher .btn:hover {
color: #FFFFFF !important;
background: var(--brand-primary) !important;
border-color: var(--brand-primary) !important;
}
[data-bs-theme="light"] .auth-controls .theme-toggle-btn,
:root:not([data-bs-theme="dark"]) .auth-controls .theme-toggle-btn {
color: var(--brand-primary) !important;
background: rgba(var(--brand-primary-rgb), 0.08) !important;
border: 1px solid rgba(var(--brand-primary-rgb), 0.25) !important;
}
[data-bs-theme="light"] .auth-controls .theme-toggle-btn:hover,
:root:not([data-bs-theme="dark"]) .auth-controls .theme-toggle-btn:hover {
color: #FFFFFF !important;
background: var(--brand-primary) !important;
border-color: var(--brand-primary) !important;
}
/* =======================================================
FULLCALENDAR OVERRIDES
======================================================= */
.fc {
--fc-border-color: var(--card-border);
--fc-page-bg-color: transparent;
--fc-neutral-bg-color: rgba(0, 0, 0, 0.02);
--fc-today-bg-color: rgba(var(--brand-primary-rgb), 0.08);
}
[data-bs-theme="dark"] .fc {
--fc-neutral-bg-color: rgba(255, 255, 255, 0.02);
}
.fc-event {
border-radius: 8px !important;
border: none !important;
padding: 3px 8px !important;
font-size: 0.8rem !important;
font-weight: 500 !important;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.fc-event:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.fc-button-primary {
background: var(--gradient-primary) !important;
border: none !important;
border-radius: 8px !important;
box-shadow: 0 2px 6px rgba(var(--brand-primary-rgb), 0.3) !important;
}
.fc-button-primary:hover {
background: linear-gradient(135deg, #9E367D 0%, #832765 100%) !important;
}
.fc-button-primary:disabled {
background: var(--text-muted) !important;
}
/* =======================================================
FORTIGATE-STYLE RBAC PERMISSIONS MATRIX
======================================================= */
.fg-permission-card {
background-color: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 12px;
box-shadow: var(--card-shadow);
overflow: hidden;
}
.fg-permission-card .card-header {
background-color: rgba(0, 0, 0, 0.03);
border-bottom: 1px solid var(--card-border);
padding: 0.9rem 1.25rem;
}
[data-bs-theme="dark"] .fg-permission-card .card-header {
background-color: rgba(255, 255, 255, 0.03);
}
.fg-table {
margin-bottom: 0;
width: 100%;
}
.fg-table th {
font-size: 0.88rem;
font-weight: 600;
color: var(--text-secondary);
background-color: rgba(0, 0, 0, 0.02);
border-bottom: 1px solid var(--card-border);
padding: 0.85rem 1.25rem;
}
[data-bs-theme="dark"] .fg-table th {
background-color: rgba(255, 255, 255, 0.02);
}
.fg-table td {
padding: 0.85rem 1.25rem;
vertical-align: middle;
border-bottom: 1px solid var(--card-border);
color: var(--text-primary);
}
.fg-table tr:last-child td {
border-bottom: none;
}
.fg-table tbody tr:hover {
background-color: rgba(180, 62, 142, 0.04);
}
/* FortiGate Segmented Control */
.fg-segmented-control {
display: inline-flex;
border: 1px solid #ced4da;
border-radius: 6px;
overflow: hidden;
background: #FFFFFF;
}
[data-bs-theme="dark"] .fg-segmented-control {
border-color: rgba(255, 255, 255, 0.2);
background: #1A1F36;
}
.fg-seg-btn {
border: none;
background: transparent;
padding: 0.35rem 0.75rem;
font-size: 0.82rem;
font-weight: 500;
color: #495057;
display: inline-flex;
align-items: center;
gap: 0.35rem;
cursor: pointer;
transition: all 0.15s ease;
border-right: 1px solid #ced4da;
outline: none;
user-select: none;
}
[data-bs-theme="dark"] .fg-seg-btn {
color: #CBD5E1;
border-right-color: rgba(255, 255, 255, 0.15);
}
.fg-seg-btn:last-child {
border-right: none;
}
.fg-seg-btn:hover:not(.active) {
background: rgba(0, 0, 0, 0.06);
color: #212529;
}
[data-bs-theme="dark"] .fg-seg-btn:hover:not(.active) {
background: rgba(255, 255, 255, 0.08);
color: #FFFFFF;
}
/* Estado Activo FortiGate (Verde Esmeralda #2D9F6F) */
.fg-seg-btn.active {
background-color: #2D9F6F !important;
color: #FFFFFF !important;
font-weight: 600;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2);
}
.fg-seg-btn.active i {
color: #FFFFFF !important;
}
/* Set All Dropdown Button */
.btn-fg-set-all {
font-size: 0.8rem;
font-weight: 500;
padding: 0.25rem 0.65rem;
border-radius: 6px;
border: 1px solid #ced4da;
background: #FFFFFF;
color: #333333;
}
[data-bs-theme="dark"] .btn-fg-set-all {
background: #1E2442;
border-color: rgba(255, 255, 255, 0.2);
color: #E2E8F0;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+88
View File
@@ -0,0 +1,88 @@
/**
* Theme Toggle Manager - Edu-Space Admin
* Manejo de Modo Claro y Modo Oscuro con persistencia en localStorage y anti-flickering.
*/
(function() {
'use strict';
// 1. Obtener tema almacenado o preferencia del sistema
function getPreferredTheme() {
const storedTheme = localStorage.getItem('theme');
if (storedTheme) {
return storedTheme;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
// 2. Aplicar tema al elemento <html>
function applyTheme(theme) {
document.documentElement.setAttribute('data-bs-theme', theme);
updateToggleButtons(theme);
}
// 3. Actualizar iconos de los botones de toggle
function updateToggleButtons(theme) {
const buttons = document.querySelectorAll('.theme-toggle-btn');
buttons.forEach(btn => {
const icon = btn.querySelector('i');
if (icon) {
if (theme === 'dark') {
icon.className = 'bi bi-sun-fill';
btn.setAttribute('aria-label', 'Cambiar a modo claro');
btn.setAttribute('title', 'Modo Claro');
} else {
icon.className = 'bi bi-moon-stars-fill';
btn.setAttribute('aria-label', 'Cambiar a modo oscuro');
btn.setAttribute('title', 'Modo Oscuro');
}
}
});
}
// 4. Función global para alternar tema
window.toggleTheme = function() {
const currentTheme = document.documentElement.getAttribute('data-bs-theme') || getPreferredTheme();
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', newTheme);
applyTheme(newTheme);
// Si el usuario está autenticado, sincronizar asíncronamente con su perfil en el backend
if (document.body && document.body.getAttribute('data-user-authenticated') === 'true') {
fetch('/api/user/theme', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ theme: newTheme })
}).catch(err => {
console.warn('No se pudo sincronizar el tema con el servidor:', err);
});
}
};
// 5. Inicializar tema inmediatamente (anti-flicker)
const initialTheme = getPreferredTheme();
applyTheme(initialTheme);
// 6. Al cargar el DOM, registrar eventos en los botones de toggle
document.addEventListener('DOMContentLoaded', () => {
updateToggleButtons(document.documentElement.getAttribute('data-bs-theme') || initialTheme);
document.querySelectorAll('.theme-toggle-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
window.toggleTheme();
});
});
});
// 7. Escuchar cambios de preferencia del sistema si el usuario no fijó una manual
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
applyTheme(e.matches ? 'dark' : 'light');
}
});
})();
@@ -0,0 +1,252 @@
{% extends "base.html" %}
{% block title %}{% trans %}Gestión de Ciclo Lectivo{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<!-- Header -->
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
<div>
<h1 class="h3 fw-bold mb-1">
<i class="bi bi-calendar-range text-primary me-2"></i>{% trans %}Gestión de Ciclo Lectivo y Períodos Académicos{% endtrans %}
</h1>
<p class="text-muted mb-0 small">
{% trans %}Administración de cuatrimestres, años académicos, fechas de inicio/fin y selección del ciclo lectivo activo.{% endtrans %}
</p>
</div>
<div class="d-flex gap-2">
{% if can_edit %}
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#modalAddTerm">
<i class="bi bi-plus-lg me-1"></i>{% trans %}Nuevo Ciclo Lectivo{% endtrans %}
</button>
{% endif %}
<a href="{{ url_for('schedule.commissions_view') }}" class="btn btn-outline-secondary">
<i class="bi bi-diagram-3 me-1"></i>{% trans %}Ver Cursadas{% endtrans %}
</a>
</div>
</div>
<!-- Academic Terms Table -->
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>{% trans %}Código{% endtrans %}</th>
<th>{% trans %}Nombre del Ciclo Lectivo{% endtrans %}</th>
<th>{% trans %}Año{% endtrans %}</th>
<th>{% trans %}Período / Modalidad{% endtrans %}</th>
<th>{% trans %}Fechas del Período{% endtrans %}</th>
<th>{% trans %}Comisiones{% endtrans %}</th>
<th>{% trans %}Estado Institucional{% endtrans %}</th>
{% if can_edit %}
<th class="text-end">{% trans %}Acciones{% endtrans %}</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for term in terms %}
{% set t_stat = stats.get(term.id, {'commissions_count': 0}) %}
<tr class="{% if term.is_current %}table-primary-subtle{% endif %}">
<td>
<span class="badge font-monospace bg-primary-subtle text-primary border fs-6">{{ term.code }}</span>
</td>
<td>
<div class="fw-bold text-body">{{ term.name }}</div>
<small class="text-muted">ID: #{{ term.id }}</small>
</td>
<td>
<span class="fw-semibold text-body">{{ term.year }}</span>
</td>
<td>
<span class="badge bg-secondary-subtle text-secondary border">
{{ term.period_type }}
</span>
</td>
<td>
{% if term.start_date and term.end_date %}
<small class="text-body font-monospace">
<i class="bi bi-calendar3 me-1 text-primary"></i>{{ term.start_date.strftime('%d/%m/%Y') }} - {{ term.end_date.strftime('%d/%m/%Y') }}
</small>
{% else %}
<small class="text-muted">{% trans %}Sin fechas fijadas{% endtrans %}</small>
{% endif %}
</td>
<td>
<span class="badge bg-info-subtle text-info border">
<i class="bi bi-diagram-3 me-1"></i>{{ t_stat.commissions_count }} {% trans %}comisiones{% endtrans %}
</span>
</td>
<td>
{% if term.is_current %}
<span class="badge bg-success text-white px-2 py-1 shadow-sm">
<i class="bi bi-check-circle-fill me-1"></i>{% trans %}Ciclo Activo Actual{% endtrans %}
</span>
{% else %}
<span class="badge bg-secondary-subtle text-secondary border">
{% if term.is_active %}{% trans %}Habilitado{% endtrans %}{% else %}{% trans %}Archivado{% endtrans %}{% endif %}
</span>
{% endif %}
</td>
{% if can_edit %}
<td class="text-end">
<div class="btn-group btn-group-sm">
{% if not term.is_current %}
<form method="POST" action="{{ url_for('admin.academic_term_set_current', id=term.id) }}" class="d-inline"
onsubmit="return confirm('{% trans %}¿Desea establecer este ciclo lectivo como el Activo para todo el sistema?{% endtrans %}');">
<button type="submit" class="btn btn-outline-success py-0 px-2" title="{% trans %}Establecer como Ciclo Activo{% endtrans %}">
<i class="bi bi-check2-circle me-1"></i>{% trans %}Activar{% endtrans %}
</button>
</form>
{% endif %}
<button type="button" class="btn btn-outline-secondary py-0 px-2"
data-bs-toggle="modal" data-bs-target="#modalEditTerm{{ term.id }}"
title="{% trans %}Editar Ciclo Lectivo{% endtrans %}">
<i class="bi bi-pencil"></i>
</button>
</div>
<!-- Modal Editar Ciclo Lectivo -->
<div class="modal fade text-start" id="modalEditTerm{{ term.id }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" action="{{ url_for('admin.academic_term_edit', id=term.id) }}">
<div class="modal-header">
<h5 class="modal-title h6 fw-bold">
<i class="bi bi-pencil-square text-primary me-2"></i>{% trans %}Editar Ciclo Lectivo{% endtrans %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row g-2 mb-3">
<div class="col-md-5">
<label class="form-label small fw-semibold">{% trans %}Código{% endtrans %} *</label>
<input type="text" name="code" class="form-control form-control-sm font-monospace" value="{{ term.code }}" required>
</div>
<div class="col-md-7">
<label class="form-label small fw-semibold">{% trans %}Año Calendario{% endtrans %} *</label>
<input type="number" name="year" class="form-control form-control-sm" value="{{ term.year }}" min="2020" max="2050" required>
</div>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Nombre Descriptivo{% endtrans %} *</label>
<input type="text" name="name" class="form-control form-control-sm" value="{{ term.name }}" required>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Tipo de Período{% endtrans %}</label>
<select name="period_type" class="form-select form-select-sm">
<option value="Primer Cuatrimestre" {% if term.period_type == 'Primer Cuatrimestre' %}selected{% endif %}>Primer Cuatrimestre</option>
<option value="Segundo Cuatrimestre" {% if term.period_type == 'Segundo Cuatrimestre' %}selected{% endif %}>Segundo Cuatrimestre</option>
<option value="Anual" {% if term.period_type == 'Anual' %}selected{% endif %}>Anual</option>
<option value="Curso de Verano" {% if term.period_type == 'Curso de Verano' %}selected{% endif %}>Curso de Verano</option>
</select>
</div>
<div class="row g-2 mb-3">
<div class="col-md-6">
<label class="form-label small fw-semibold">{% trans %}Fecha Inicio{% endtrans %}</label>
<input type="date" name="start_date" class="form-control form-control-sm" value="{{ term.start_date.strftime('%Y-%m-%d') if term.start_date else '' }}">
</div>
<div class="col-md-6">
<label class="form-label small fw-semibold">{% trans %}Fecha Fin{% endtrans %}</label>
<input type="date" name="end_date" class="form-control form-control-sm" value="{{ term.end_date.strftime('%Y-%m-%d') if term.end_date else '' }}">
</div>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" name="is_current" id="isCurr{{ term.id }}" {% if term.is_current %}checked{% endif %}>
<label class="form-check-label small fw-semibold text-primary" for="isCurr{{ term.id }}">
{% trans %}Establecer como Ciclo Lectivo Activo Actual{% endtrans %}
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="is_active" id="isAct{{ term.id }}" {% if term.is_active %}checked{% endif %}>
<label class="form-check-label small" for="isAct{{ term.id }}">{% trans %}Habilitado en el Sistema{% endtrans %}</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<button type="submit" class="btn btn-sm btn-primary">{% trans %}Guardar Cambios{% endtrans %}</button>
</div>
</form>
</div>
</div>
</div>
</td>
{% endif %}
</tr>
{% else %}
<tr>
<td colspan="8" class="text-center py-5 text-muted">
<i class="bi bi-calendar-range fs-1 d-block mb-2"></i>
{% trans %}No se encontraron ciclos lectivos registrados.{% endtrans %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Modal Nuevo Ciclo Lectivo -->
{% if can_edit %}
<div class="modal fade" id="modalAddTerm" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" action="{{ url_for('admin.academic_term_add') }}">
<div class="modal-header">
<h5 class="modal-title h6 fw-bold">
<i class="bi bi-calendar-plus text-primary me-2"></i>{% trans %}Nuevo Ciclo Lectivo / Período Académico{% endtrans %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row g-2 mb-3">
<div class="col-md-5">
<label class="form-label small fw-semibold">{% trans %}Código{% endtrans %} *</label>
<input type="text" name="code" class="form-control form-control-sm font-monospace" placeholder="ej. 2027-1" required>
</div>
<div class="col-md-7">
<label class="form-label small fw-semibold">{% trans %}Año Calendario{% endtrans %} *</label>
<input type="number" name="year" class="form-control form-control-sm" value="2026" min="2020" max="2050" required>
</div>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Nombre Descriptivo{% endtrans %} *</label>
<input type="text" name="name" class="form-control form-control-sm" placeholder="ej. Ciclo Lectivo 2027 - 1º Cuatrimestre" required>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Tipo de Período{% endtrans %}</label>
<select name="period_type" class="form-select form-select-sm">
<option value="Primer Cuatrimestre">Primer Cuatrimestre</option>
<option value="Segundo Cuatrimestre" selected>Segundo Cuatrimestre</option>
<option value="Anual">Anual</option>
<option value="Curso de Verano">Curso de Verano</option>
</select>
</div>
<div class="row g-2 mb-3">
<div class="col-md-6">
<label class="form-label small fw-semibold">{% trans %}Fecha Inicio{% endtrans %}</label>
<input type="date" name="start_date" class="form-control form-control-sm">
</div>
<div class="col-md-6">
<label class="form-label small fw-semibold">{% trans %}Fecha Fin{% endtrans %}</label>
<input type="date" name="end_date" class="form-control form-control-sm">
</div>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="is_current" id="addIsCurr">
<label class="form-check-label small fw-semibold text-primary" for="addIsCurr">
{% trans %}Establecer de inmediato como Ciclo Lectivo Activo Actual{% endtrans %}
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<button type="submit" class="btn btn-sm btn-primary">{% trans %}Crear Ciclo Lectivo{% endtrans %}</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% endblock %}
+208
View File
@@ -0,0 +1,208 @@
{% extends "base.html" %}
{% block title %}{% trans %}Gestión de Carreras{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<!-- Header -->
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
<div>
<h1 class="h3 fw-bold mb-1">
<i class="bi bi-mortarboard text-primary me-2"></i>{% trans %}Gestión de Carreras y Titulaciones{% endtrans %}
</h1>
<p class="text-muted mb-0 small">
{% trans %}Administración de programas académicos, tecnicaturas, licenciaturas y planes de estudio.{% endtrans %}
</p>
</div>
<div class="d-flex gap-2">
{% if can_edit %}
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#modalAddCareer">
<i class="bi bi-plus-lg me-1"></i>{% trans %}Nueva Carrera{% endtrans %}
</button>
{% endif %}
<a href="{{ url_for('admin.subjects_list') }}" class="btn btn-outline-secondary">
<i class="bi bi-book me-1"></i>{% trans %}Ver Asignaturas{% endtrans %}
</a>
</div>
</div>
<!-- Search & Filter Bar -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body py-2">
<form method="GET" class="row g-2 align-items-center">
<div class="col-md-6">
<div class="input-group input-group-sm">
<span class="input-group-text bg-body-tertiary"><i class="bi bi-search"></i></span>
<input type="text" name="search" class="form-control"
placeholder="{% trans %}Buscar carrera por nombre, código o descripción...{% endtrans %}"
value="{{ search }}">
</div>
</div>
<div class="col-md-6 text-md-end">
<button type="submit" class="btn btn-sm btn-primary me-1">
<i class="bi bi-funnel me-1"></i>{% trans %}Filtrar{% endtrans %}
</button>
<a href="{{ url_for('admin.careers_list') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-x-circle me-1"></i>{% trans %}Limpiar{% endtrans %}
</a>
</div>
</form>
</div>
</div>
<!-- Careers Table -->
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>{% trans %}Carrera / Titulación{% endtrans %}</th>
<th>{% trans %}Código{% endtrans %}</th>
<th>{% trans %}Asignaturas{% endtrans %}</th>
<th>{% trans %}Comisiones Activas{% endtrans %}</th>
<th>{% trans %}Descripción{% endtrans %}</th>
{% if can_edit %}
<th class="text-end">{% trans %}Acciones{% endtrans %}</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for career in careers %}
{% set c_stat = stats.get(career.id, {'subjects_count': 0, 'commissions_count': 0}) %}
<tr>
<td>
<div class="d-flex align-items-center">
<div class="rounded-circle p-2 bg-primary-subtle text-primary me-3">
<i class="bi bi-mortarboard-fill"></i>
</div>
<div>
<span class="fw-bold text-body">{{ career.name }}</span>
</div>
</div>
</td>
<td>
{% if career.code %}
<span class="badge font-monospace bg-secondary-subtle text-secondary border">{{ career.code }}</span>
{% else %}
<span class="text-muted small">-</span>
{% endif %}
</td>
<td>
<a href="{{ url_for('admin.subjects_list', career_id=career.id) }}" class="badge bg-info-subtle text-info border text-decoration-none">
<i class="bi bi-book me-1"></i>{{ c_stat.subjects_count }} {% trans %}asignaturas{% endtrans %}
</a>
</td>
<td>
<span class="badge bg-primary-subtle text-primary border">
<i class="bi bi-diagram-3 me-1"></i>{{ c_stat.commissions_count }} {% trans %}comisiones{% endtrans %}
</span>
</td>
<td>
<span class="small text-muted text-truncate d-inline-block" style="max-width: 250px;">
{{ career.description or '-' }}
</span>
</td>
{% if can_edit %}
<td class="text-end">
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-secondary py-0 px-2"
data-bs-toggle="modal" data-bs-target="#modalEditCareer{{ career.id }}"
title="{% trans %}Editar Carrera{% endtrans %}">
<i class="bi bi-pencil"></i>
</button>
{% if c_stat.subjects_count == 0 %}
<form method="POST" action="{{ url_for('admin.career_delete', id=career.id) }}" class="d-inline"
onsubmit="return confirm('{% trans %}¿Confirma que desea eliminar esta carrera permanentemente?{% endtrans %}');">
<button type="submit" class="btn btn-outline-danger py-0 px-2" title="{% trans %}Eliminar{% endtrans %}">
<i class="bi bi-trash"></i>
</button>
</form>
{% endif %}
</div>
<!-- Modal Editar Carrera -->
<div class="modal fade text-start" id="modalEditCareer{{ career.id }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" action="{{ url_for('admin.career_edit', id=career.id) }}">
<div class="modal-header">
<h5 class="modal-title h6 fw-bold">
<i class="bi bi-pencil-square text-primary me-2"></i>{% trans %}Editar Carrera{% endtrans %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Nombre de la Carrera{% endtrans %} *</label>
<input type="text" name="name" class="form-control" value="{{ career.name }}" required>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Código / Sigla{% endtrans %}</label>
<input type="text" name="code" class="form-control" value="{{ career.code or '' }}" placeholder="ej. LIC-SIS, PUEP">
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Descripción{% endtrans %}</label>
<textarea name="description" class="form-control" rows="3">{{ career.description or '' }}</textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<button type="submit" class="btn btn-sm btn-primary">{% trans %}Guardar Cambios{% endtrans %}</button>
</div>
</form>
</div>
</div>
</div>
</td>
{% endif %}
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center py-5 text-muted">
<i class="bi bi-mortarboard fs-1 d-block mb-2"></i>
{% trans %}No se encontraron carreras registradas.{% endtrans %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Modal Nueva Carrera -->
{% if can_edit %}
<div class="modal fade" id="modalAddCareer" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" action="{{ url_for('admin.career_add') }}">
<div class="modal-header">
<h5 class="modal-title h6 fw-bold">
<i class="bi bi-mortarboard text-primary me-2"></i>{% trans %}Nueva Carrera / Titulación{% endtrans %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Nombre de la Carrera{% endtrans %} *</label>
<input type="text" name="name" class="form-control" placeholder="ej. Licenciatura en Tecnologías Digitales" required>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Código / Sigla{% endtrans %}</label>
<input type="text" name="code" class="form-control" placeholder="ej. TEC-DIG">
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Descripción{% endtrans %}</label>
<textarea name="description" class="form-control" rows="3" placeholder="Objetivos y alcance de la titulación..."></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<button type="submit" class="btn btn-sm btn-primary">{% trans %}Crear Carrera{% endtrans %}</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% endblock %}
+242
View File
@@ -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 %}
+343
View File
@@ -0,0 +1,343 @@
{% extends "base.html" %}
{% block title %}{% trans %}Gestión de Ocupación y Métricas{% endtrans %} - Edu-Space Admin{% endblock %}
{% block extra_css %}
<style>
.kpi-card {
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.07);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.kpi-card:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.08) !important;
}
.kpi-icon-circle {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.4rem;
}
.metric-bar-container {
height: 10px;
border-radius: 6px;
background-color: var(--bs-tertiary-bg);
overflow: hidden;
}
.metric-bar-fill {
width: 0;
height: 100%;
border-radius: 6px;
transition: width 0.6s ease;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<!-- Header -->
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
<div>
<h1 class="h3 fw-bold mb-1">
<i class="bi bi-graph-up-arrow text-primary me-2"></i>{% trans %}Gestión de Ocupación y Métricas de Espacios{% endtrans %}
</h1>
<p class="text-muted mb-0 small">
{% trans %}Analítica institucional de aprovechamiento de aulas físicas, demanda en Campus Virtual y distribución horaria.{% endtrans %}
</p>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('classrooms.list_classrooms', view_mode='physical') }}" class="btn btn-outline-primary">
<i class="bi bi-buildings me-1"></i>{% trans %}Aulas Físicas{% endtrans %}
</a>
<a href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}" class="btn btn-outline-info">
<i class="bi bi-globe2 me-1"></i>{% trans %}Campus Virtual (∞){% endtrans %}
</a>
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-secondary">
<i class="bi bi-calendar-week me-1"></i>{% trans %}Ver Calendario{% endtrans %}
</a>
</div>
</div>
<!-- 1. Fila de KPIs Globales de Ocupación -->
<div class="row g-3 mb-4">
<!-- Ocupación Hoy -->
<div class="col-xl-3 col-sm-6">
<div class="card kpi-card border-0 shadow-sm p-3 h-100">
<div class="d-flex justify-content-between align-items-start">
<div>
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.72rem;">{% trans %}Tasa de Ocupación Hoy{% endtrans %}</span>
<h2 class="h3 fw-bold mb-0 text-primary mt-1">{{ avg_day_pct }}%</h2>
</div>
<div class="kpi-icon-circle bg-primary-subtle text-primary">
<i class="bi bi-clock-history"></i>
</div>
</div>
<div class="metric-bar-container mt-3">
<div class="metric-bar-fill bg-primary" data-width="{{ avg_day_pct }}%"></div>
</div>
<small class="text-muted mt-2 d-block" style="font-size: 0.72rem;">
{% trans %}Promedio sobre 15 hs operativas ({{ physical_rooms_count }} aulas presenciales){% endtrans %}
</small>
</div>
</div>
<!-- Ocupación Semana -->
<div class="col-xl-3 col-sm-6">
<div class="card kpi-card border-0 shadow-sm p-3 h-100">
<div class="d-flex justify-content-between align-items-start">
<div>
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.72rem;">{% trans %}Ocupación Semanal{% endtrans %}</span>
<h2 class="h3 fw-bold mb-0 text-info mt-1">{{ avg_week_pct }}%</h2>
</div>
<div class="kpi-icon-circle bg-info-subtle text-info">
<i class="bi bi-calendar-week"></i>
</div>
</div>
<div class="metric-bar-container mt-3">
<div class="metric-bar-fill bg-info" data-width="{{ avg_week_pct }}%"></div>
</div>
<small class="text-muted mt-2 d-block" style="font-size: 0.72rem;">
{% trans %}Semana en curso (Lunes a Sábado){% endtrans %}
</small>
</div>
</div>
<!-- Ocupación Mensual -->
<div class="col-xl-3 col-sm-6">
<div class="card kpi-card border-0 shadow-sm p-3 h-100">
<div class="d-flex justify-content-between align-items-start">
<div>
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.72rem;">{% trans %}Ocupación del Mes{% endtrans %}</span>
<h2 class="h3 fw-bold mb-0 text-success mt-1">{{ avg_month_pct }}%</h2>
</div>
<div class="kpi-icon-circle bg-success-subtle text-success">
<i class="bi bi-calendar3"></i>
</div>
</div>
<div class="metric-bar-container mt-3">
<div class="metric-bar-fill bg-success" data-width="{{ avg_month_pct }}%"></div>
</div>
<small class="text-muted mt-2 d-block" style="font-size: 0.72rem;">
{% trans %}Total mes calendario activo{% endtrans %}
</small>
</div>
</div>
<!-- Clases Totales Dictadas -->
<div class="col-xl-3 col-sm-6">
<div class="card kpi-card border-0 shadow-sm p-3 h-100">
<div class="d-flex justify-content-between align-items-start">
<div>
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.72rem;">{% trans %}Total Clases en el Mes{% endtrans %}</span>
<h2 class="h3 fw-bold mb-0 text-warning mt-1">{{ total_month_classes }}</h2>
</div>
<div class="kpi-icon-circle bg-warning-subtle text-warning">
<i class="bi bi-mortarboard"></i>
</div>
</div>
<div class="d-flex gap-2 mt-3 small font-monospace">
<span class="badge bg-primary-subtle text-primary border">Físicas: {{ total_month_classes - v_month }}</span>
<span class="badge bg-info-subtle text-info border">Virtuales: {{ v_month }}</span>
</div>
<small class="text-muted mt-2 d-block" style="font-size: 0.72rem;">
{% trans %}Reservas y sesiones registradas en el período{% endtrans %}
</small>
</div>
</div>
</div>
<!-- 2. Detalle y Distribución Espacial -->
<div class="row g-4 mb-4">
<!-- Ocupación por Piso / Nivel -->
<div class="col-lg-6">
<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">
<h5 class="card-title h6 fw-bold mb-0">
<i class="bi bi-layers text-primary me-2"></i>{% trans %}Tasa de Ocupación por Piso / Nivel{% endtrans %}
</h5>
<span class="badge bg-secondary-subtle text-secondary border">Presencial</span>
</div>
<div class="card-body">
<div class="d-flex flex-column gap-3">
{% for fs in floor_summary %}
<div>
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="fw-semibold text-body small">
{{ fs.floor }} ({{ fs.count }} {% trans %}aulas{% endtrans %})
</span>
<span class="fw-bold font-monospace small {% if fs.avg_pct > 50 %}text-danger{% elif fs.avg_pct > 25 %}text-warning{% else %}text-success{% endif %}">
{{ fs.avg_pct }}%
</span>
</div>
<div class="metric-bar-container">
<div class="metric-bar-fill {% if fs.avg_pct > 50 %}bg-danger{% elif fs.avg_pct > 25 %}bg-warning{% else %}bg-success{% endif %}"
data-width="{{ fs.avg_pct }}%"></div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<!-- Distribución por Turno -->
<div class="col-lg-6">
<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">
<h5 class="card-title h6 fw-bold mb-0">
<i class="bi bi-brightness-high text-warning me-2"></i>{% trans %}Distribución de Clases por Turno{% endtrans %}
</h5>
<span class="badge bg-secondary-subtle text-secondary border">Mes Completo</span>
</div>
<div class="card-body">
<div class="row g-2 text-center mb-3">
<div class="col-3">
<div class="p-2 rounded bg-body-tertiary border">
<div class="small text-muted text-uppercase fw-semibold" style="font-size: 0.68rem;">{% trans %}Mañana{% endtrans %}</div>
<div class="h5 fw-bold text-primary mb-0">{{ shift_counts.Mañana }}</div>
</div>
</div>
<div class="col-3">
<div class="p-2 rounded bg-body-tertiary border">
<div class="small text-muted text-uppercase fw-semibold" style="font-size: 0.68rem;">{% trans %}Tarde{% endtrans %}</div>
<div class="h5 fw-bold text-warning mb-0">{{ shift_counts.Tarde }}</div>
</div>
</div>
<div class="col-3">
<div class="p-2 rounded bg-body-tertiary border">
<div class="small text-muted text-uppercase fw-semibold" style="font-size: 0.68rem;">{% trans %}Vespertino{% endtrans %}</div>
<div class="h5 fw-bold text-info mb-0">{{ shift_counts.Vespertino }}</div>
</div>
</div>
<div class="col-3">
<div class="p-2 rounded bg-body-tertiary border">
<div class="small text-muted text-uppercase fw-semibold" style="font-size: 0.68rem;">{% trans %}Noche{% endtrans %}</div>
<div class="h5 fw-bold text-danger mb-0">{{ shift_counts.Noche }}</div>
</div>
</div>
</div>
<!-- Campus Virtual Box -->
<div class="p-3 rounded bg-primary-subtle border border-primary-subtle">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="fw-bold text-primary mb-1"><i class="bi bi-globe2 me-2"></i>{% trans %}Campus Virtual (A Demanda · ∞){% endtrans %}</h6>
<p class="small text-muted mb-0">{% trans %}Salas simultáneas sin límite físico de cupo ni colisión.{% endtrans %}</p>
</div>
<div class="text-end">
<div class="badge bg-primary text-white fs-6">{{ v_month }} clases mes</div>
<small class="d-block text-muted mt-1">{{ v_today }} hoy · {{ v_week }} semana</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 3. Top Aulas de Mayor Demanda vs Menor Demanda -->
<div class="row g-4">
<!-- Mayor Demanda -->
<div class="col-lg-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-body-tertiary border-0 py-3">
<h5 class="card-title h6 fw-bold mb-0 text-success">
<i class="bi bi-arrow-up-circle-fill me-2"></i>{% trans %}Top Aulas con Mayor Demanda y Ocupación{% endtrans %}
</h5>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0 small">
<thead class="table-light">
<tr>
<th>{% trans %}Aula{% endtrans %}</th>
<th>{% trans %}Piso{% endtrans %}</th>
<th>{% trans %}Capacidad{% endtrans %}</th>
<th>{% trans %}Ocup. Mes{% endtrans %}</th>
<th class="text-end">{% trans %}Acción{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for rm in top_rooms %}
<tr>
<td class="fw-bold text-primary">{{ rm.classroom.code }}</td>
<td>{{ rm.classroom.floor_display }}</td>
<td>{{ rm.classroom.capacity }} asientos</td>
<td>
<span class="badge bg-success-subtle text-success border border-success-subtle font-monospace">
{{ rm.month_pct }}% ({{ rm.month_count }} cl.)
</span>
</td>
<td class="text-end">
<a href="{{ url_for('schedule.list_reservations', classroom_id=rm.classroom.id, date_from=date_filters.month_start, date_to=date_filters.month_end) }}"
class="btn btn-sm btn-outline-primary py-0 px-2" style="font-size: 0.72rem;">
{% trans %}Ver Reservas{% endtrans %}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Menor Demanda / Oportunidades -->
<div class="col-lg-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-body-tertiary border-0 py-3">
<h5 class="card-title h6 fw-bold mb-0 text-secondary">
<i class="bi bi-arrow-down-circle-fill me-2"></i>{% trans %}Aulas con Mayor Disponibilidad (Menor Uso){% endtrans %}
</h5>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0 small">
<thead class="table-light">
<tr>
<th>{% trans %}Aula{% endtrans %}</th>
<th>{% trans %}Piso{% endtrans %}</th>
<th>{% trans %}Capacidad{% endtrans %}</th>
<th>{% trans %}Ocup. Mes{% endtrans %}</th>
<th class="text-end">{% trans %}Acción{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for rm in low_rooms %}
<tr>
<td class="fw-bold text-body">{{ rm.classroom.code }}</td>
<td>{{ rm.classroom.floor_display }}</td>
<td>{{ rm.classroom.capacity }} asientos</td>
<td>
<span class="badge bg-secondary-subtle text-secondary border font-monospace">
{{ rm.month_pct }}% ({{ rm.month_count }} cl.)
</span>
</td>
<td class="text-end">
<a href="{{ url_for('schedule.add_reservation') }}?classroom_id={{ rm.classroom.id }}"
class="btn btn-sm btn-outline-success py-0 px-2" style="font-size: 0.72rem;">
{% trans %}Reservar{% endtrans %}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.metric-bar-fill[data-width]').forEach(function(bar) {
bar.style.width = bar.getAttribute('data-width');
});
});
</script>
{% endblock %}
@@ -0,0 +1,404 @@
{% extends "base.html" %}
{% block title %}{% trans %}Tipificaciones de Hitos y Categorías{% endtrans %} - Edu-Space{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<!-- Header -->
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
<div>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1 small">
<li class="breadcrumb-item"><a href="{{ url_for('main.dashboard') }}">{% trans %}Dashboard{% endtrans %}</a></li>
<li class="breadcrumb-item active" aria-current="page">{% trans %}Tipificaciones{% endtrans %}</li>
</ol>
</nav>
<h1 class="h3 fw-bold mb-1">
<i class="bi bi-tags text-warning me-2"></i>{% trans %}Tipificaciones de Hitos Académicos{% endtrans %}
</h1>
<p class="text-muted mb-0 small">
{% trans %}Catálogo dinámico y configurable de tipos de evaluaciones, parciales, TPs y eventos para el calendario institucional.{% endtrans %}
</p>
</div>
<div class="d-flex gap-2">
{% if can_edit %}
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#modalAddType">
<i class="bi bi-plus-lg me-1"></i>{% trans %}Nueva Tipificación{% endtrans %}
</button>
{% endif %}
</div>
</div>
<!-- Alert / Help Info -->
<div class="alert alert-info border-0 shadow-sm d-flex align-items-center mb-4 py-2 px-3">
<i class="bi bi-info-circle-fill text-info fs-5 me-3"></i>
<div class="small">
{% trans %}Las tipificaciones activas aparecen de forma inmediata en el selector de tipo de evento/hito tanto en el portal web como en la app móvil. Puedes desactivar una tipificación sin eliminar registros históricos asociados.{% endtrans %}
</div>
</div>
<!-- Table Card -->
<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 %}Tipificación e Insignia{% endtrans %}</th>
<th>{% trans %}Código / Slug{% endtrans %}</th>
<th>{% trans %}Color Hex{% endtrans %}</th>
<th>{% trans %}Descripción{% endtrans %}</th>
<th class="text-center">{% trans %}Orden{% endtrans %}</th>
<th class="text-center">{% trans %}Estado{% endtrans %}</th>
<th class="text-end pe-4">{% trans %}Acciones{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for item in types %}
<tr id="row-{{ item.id }}">
<td class="ps-4">
<div class="d-flex align-items-center">
<span class="rounded-circle shadow-xs me-3 flex-shrink-0 border"
style="width: 22px; height: 22px; background-color: {{ item.color or '#0d6efd' }};"
title="{{ item.color }}"></span>
<div>
<span class="fw-bold text-dark-emphasis">{{ item.name }}</span>
<span class="badge rounded-pill text-white ms-2 shadow-xs" style="background-color: {{ item.color or '#0d6efd' }}; font-size: 0.70rem;">
{{ item.name }}
</span>
</div>
</div>
</td>
<td>
<code class="text-primary fw-semibold">{{ item.code }}</code>
</td>
<td>
<span class="font-monospace small text-muted">{{ item.color or '#0d6efd' }}</span>
</td>
<td>
<span class="text-muted small">{{ item.description or '—' }}</span>
</td>
<td class="text-center">
<span class="badge bg-body-tertiary text-secondary border font-monospace">#{{ item.sort_order }}</span>
</td>
<td class="text-center">
{% if can_edit %}
<form action="{{ url_for('admin.milestone_type_toggle', id=item.id) }}" method="POST" class="d-inline">
<div class="form-check form-switch d-inline-block">
<input class="form-check-input status-toggle" type="checkbox" role="switch"
onchange="this.form.submit()"
{% if item.is_active %}checked{% endif %}
title="{% trans %}Cambiar estado{% endtrans %}">
</div>
</form>
{% else %}
{% if item.is_active %}
<span class="badge bg-success-subtle text-success border border-success-subtle">{% trans %}Activo{% endtrans %}</span>
{% else %}
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">{% trans %}Inactivo{% endtrans %}</span>
{% endif %}
{% endif %}
</td>
<td class="text-end pe-4">
<div class="btn-group btn-group-sm">
{% if can_edit %}
<button type="button" class="btn btn-outline-primary btn-edit"
data-id="{{ item.id }}"
data-name="{{ item.name }}"
data-code="{{ item.code }}"
data-color="{{ item.color or '#0d6efd' }}"
data-description="{{ item.description or '' }}"
data-sort-order="{{ item.sort_order }}"
data-is-active="{{ 'true' if item.is_active else 'false' }}"
title="{% trans %}Editar Tipificación{% endtrans %}">
<i class="bi bi-pencil-square"></i>
</button>
<button type="button" class="btn btn-outline-danger btn-delete-type"
data-id="{{ item.id }}"
data-name="{{ item.name }}"
data-action-url="{{ url_for('admin.milestone_type_delete', id=item.id) }}"
title="{% trans %}Eliminar Tipificación{% endtrans %}">
<i class="bi bi-trash"></i>
</button>
{% else %}
<span class="text-muted small"><i class="bi bi-lock me-1"></i>{% trans %}Solo lectura{% endtrans %}</span>
{% endif %}
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="7" class="text-center py-5 text-muted">
<i class="bi bi-inbox fs-1 d-block mb-2"></i>
{% trans %}No se han encontrado tipificaciones registradas.{% endtrans %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Modal: Nueva Tipificación -->
{% if can_edit %}
<div class="modal fade" id="modalAddType" tabindex="-1" aria-labelledby="modalAddTypeLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow">
<div class="modal-header bg-body-tertiary">
<h5 class="modal-title fw-bold" id="modalAddTypeLabel">
<i class="bi bi-plus-circle text-primary me-2"></i>{% trans %}Nueva Tipificación de Hito{% endtrans %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="{{ url_for('admin.milestone_type_add') }}" method="POST">
<div class="modal-body p-4">
<div class="mb-3">
<label for="add_name" class="form-label fw-semibold">{% trans %}Nombre Visible{% endtrans %} <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="add_name" name="name" placeholder="Ej: Parcial 3, TP Integrador" required>
<div class="form-text small">{% trans %}Texto que se mostrará en los selectores y tarjetas.{% endtrans %}</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-7">
<label for="add_code" class="form-label fw-semibold">{% trans %}Código / Identificador{% endtrans %}</label>
<input type="text" class="form-control font-monospace" id="add_code" name="code" placeholder="ej. parcial_3">
<div class="form-text small">{% trans %}Slug único (se autogenera si se deja vacío).{% endtrans %}</div>
</div>
<div class="col-md-5">
<label for="add_color" class="form-label fw-semibold">{% trans %}Color Distintivo{% endtrans %}</label>
<input type="color" class="form-control form-control-color w-100" id="add_color" name="color" value="#B43E8E" title="Elegir color">
</div>
</div>
<!-- Paleta rápida de colores UniCABA -->
<div class="mb-3">
<label class="form-label small text-muted mb-1">{% trans %}Paleta Institucional Rápida{% endtrans %}:</label>
<div class="d-flex flex-wrap gap-2" id="presetColorsAdd">
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #B43E8E;" data-color="#B43E8E" title="Magenta UniCABA"></button>
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #0d6efd;" data-color="#0d6efd" title="Azul Institucional"></button>
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #198754;" data-color="#198754" title="Verde TP"></button>
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #fd7e14;" data-color="#fd7e14" title="Naranja Recuperatorio"></button>
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #dc3545;" data-color="#dc3545" title="Rojo Final"></button>
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #6f42c1;" data-color="#6f42c1" title="Púrpura Especial"></button>
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #20c997;" data-color="#20c997" title="Turquesa Cursada"></button>
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #6c757d;" data-color="#6c757d" title="Gris Neutro"></button>
</div>
</div>
<div class="mb-3">
<label for="add_description" class="form-label fw-semibold">{% trans %}Descripción / Notas{% endtrans %}</label>
<textarea class="form-control" id="add_description" name="description" rows="2" placeholder="Detalle o alcance de este tipo de evaluación..."></textarea>
</div>
<div class="row g-3 mb-2">
<div class="col-6">
<label for="add_sort_order" class="form-label fw-semibold">{% trans %}Orden de Visualización{% endtrans %}</label>
<input type="number" class="form-control" id="add_sort_order" name="sort_order" value="10" min="0">
</div>
<div class="col-6 d-flex align-items-center pt-3">
<div class="form-check form-switch pt-2">
<input class="form-check-input" type="checkbox" id="add_is_active" name="is_active" value="1" checked>
<label class="form-check-label fw-semibold" for="add_is_active">{% trans %}Activo de inmediato{% endtrans %}</label>
</div>
</div>
</div>
</div>
<div class="modal-footer bg-body-tertiary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<button type="submit" class="btn btn-primary px-4">
<i class="bi bi-save me-1"></i>{% trans %}Crear Tipificación{% endtrans %}
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Modal: Editar Tipificación -->
<div class="modal fade" id="modalEditType" tabindex="-1" aria-labelledby="modalEditTypeLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow">
<div class="modal-header bg-body-tertiary">
<h5 class="modal-title fw-bold" id="modalEditTypeLabel">
<i class="bi bi-pencil-square text-primary me-2"></i>{% trans %}Editar Tipificación{% endtrans %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="formEditType" method="POST">
<div class="modal-body p-4">
<div class="mb-3">
<label for="edit_name" class="form-label fw-semibold">{% trans %}Nombre Visible{% endtrans %} <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="edit_name" name="name" required>
</div>
<div class="row g-3 mb-3">
<div class="col-md-7">
<label for="edit_code" class="form-label fw-semibold">{% trans %}Código / Slug{% endtrans %} <span class="text-danger">*</span></label>
<input type="text" class="form-control font-monospace" id="edit_code" name="code" required>
</div>
<div class="col-md-5">
<label for="edit_color" class="form-label fw-semibold">{% trans %}Color Distintivo{% endtrans %}</label>
<input type="color" class="form-control form-control-color w-100" id="edit_color" name="color" title="Elegir color">
</div>
</div>
<!-- Paleta rápida de colores en Edición -->
<div class="mb-3">
<label class="form-label small text-muted mb-1">{% trans %}Paleta Institucional Rápida{% endtrans %}:</label>
<div class="d-flex flex-wrap gap-2" id="presetColorsEdit">
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #B43E8E;" data-color="#B43E8E" title="Magenta UniCABA"></button>
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #0d6efd;" data-color="#0d6efd" title="Azul Institucional"></button>
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #198754;" data-color="#198754" title="Verde TP"></button>
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #fd7e14;" data-color="#fd7e14" title="Naranja Recuperatorio"></button>
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #dc3545;" data-color="#dc3545" title="Rojo Final"></button>
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #6f42c1;" data-color="#6f42c1" title="Púrpura Especial"></button>
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #20c997;" data-color="#20c997" title="Turquesa Cursada"></button>
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #6c757d;" data-color="#6c757d" title="Gris Neutro"></button>
</div>
</div>
<div class="mb-3">
<label for="edit_description" class="form-label fw-semibold">{% trans %}Descripción{% endtrans %}</label>
<textarea class="form-control" id="edit_description" name="description" rows="2"></textarea>
</div>
<div class="row g-3 mb-2">
<div class="col-6">
<label for="edit_sort_order" class="form-label fw-semibold">{% trans %}Orden{% endtrans %}</label>
<input type="number" class="form-control" id="edit_sort_order" name="sort_order" min="0">
</div>
<div class="col-6 d-flex align-items-center pt-3">
<div class="form-check form-switch pt-2">
<input class="form-check-input" type="checkbox" id="edit_is_active" name="is_active" value="1">
<label class="form-check-label fw-semibold" for="edit_is_active">{% trans %}Activo{% endtrans %}</label>
</div>
</div>
</div>
</div>
<div class="modal-footer bg-body-tertiary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<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>
<!-- Modal Global de Eliminación de Tipificación (fuera de la tabla) -->
<div class="modal fade" id="deleteMilestoneTypeModal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow-lg" style="border-radius: 14px;">
<div class="modal-header border-0 pb-0">
<div class="d-flex align-items-center">
<div class="badge bg-danger-subtle text-danger p-2 rounded-circle me-2">
<i class="bi bi-exclamation-triangle-fill fs-5"></i>
</div>
<h5 class="modal-title fw-bold text-danger mb-0">{% trans %}Confirmar Eliminación{% endtrans %}</h5>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-start py-3">
<p class="mb-2">{% trans %}¿Está seguro de que desea eliminar la tipificación{% endtrans %} <strong id="deleteMilestoneTypeName" class="text-primary"></strong>?</p>
<p class="text-muted small mb-0"><i class="bi bi-info-circle me-1"></i>{% trans %}Si existen hitos vinculados a este tipo, el sistema lo desactivará automáticamente para preservar el historial.{% endtrans %}</p>
</div>
<div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<form id="deleteMilestoneTypeForm" method="POST" class="d-inline">
<button type="submit" class="btn btn-danger px-3">
<i class="bi bi-trash me-1"></i>{% trans %}Eliminar Tipificación{% endtrans %}
</button>
</form>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Autogenerate code slug in Add Modal
var addNameInput = document.getElementById('add_name');
var addCodeInput = document.getElementById('add_code');
if (addNameInput && addCodeInput) {
addNameInput.addEventListener('input', function() {
if (!addCodeInput.dataset.touched) {
var slug = addNameInput.value.toLowerCase()
.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
addCodeInput.value = slug;
}
});
addCodeInput.addEventListener('input', function() {
addCodeInput.dataset.touched = "true";
});
}
// Color preset clicks for Add Modal
document.querySelectorAll('#presetColorsAdd button').forEach(function(btn) {
btn.addEventListener('click', function() {
var color = this.getAttribute('data-color');
var input = document.getElementById('add_color');
if (input) input.value = color;
});
});
// Color preset clicks for Edit Modal
document.querySelectorAll('#presetColorsEdit button').forEach(function(btn) {
btn.addEventListener('click', function() {
var color = this.getAttribute('data-color');
var input = document.getElementById('edit_color');
if (input) input.value = color;
});
});
// Edit modal bindings
var modalEditEl = document.getElementById('modalEditType');
var modalEdit = modalEditEl ? new bootstrap.Modal(modalEditEl) : null;
var formEdit = document.getElementById('formEditType');
document.querySelectorAll('.btn-edit').forEach(function(btn) {
btn.addEventListener('click', function() {
var id = this.getAttribute('data-id');
var name = this.getAttribute('data-name');
var code = this.getAttribute('data-code');
var color = this.getAttribute('data-color');
var description = this.getAttribute('data-description');
var sortOrder = this.getAttribute('data-sort-order');
var isActive = this.getAttribute('data-is-active') === 'true';
if (formEdit) {
formEdit.action = "{{ url_for('admin.milestone_types_list') }}/edit/" + id;
document.getElementById('edit_name').value = name;
document.getElementById('edit_code').value = code;
document.getElementById('edit_color').value = color;
document.getElementById('edit_description').value = description;
document.getElementById('edit_sort_order').value = sortOrder;
document.getElementById('edit_is_active').checked = isActive;
if (modalEdit) modalEdit.show();
}
});
});
// Delete modal bindings
var deleteModalEl = document.getElementById('deleteMilestoneTypeModal');
var deleteModal = deleteModalEl ? new bootstrap.Modal(deleteModalEl) : null;
var deleteNameEl = document.getElementById('deleteMilestoneTypeName');
var deleteFormEl = document.getElementById('deleteMilestoneTypeForm');
document.querySelectorAll('.btn-delete-type').forEach(function(btn) {
btn.addEventListener('click', function() {
var name = this.getAttribute('data-name');
var actionUrl = this.getAttribute('data-action-url');
if (deleteNameEl) deleteNameEl.textContent = name;
if (deleteFormEl) deleteFormEl.action = actionUrl;
if (deleteModal) deleteModal.show();
});
});
});
</script>
{% endblock %}
+191
View File
@@ -0,0 +1,191 @@
{% extends "base.html" %}
{% block title %}{% if role %}{% trans %}Editar Rol{% endtrans %}: {{ role.name }}{% else %}{% trans %}Nuevo Rol{% 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 flex-wrap gap-2">
<div>
<h1 class="h3 mb-1">
<i class="bi bi-shield-lock text-primary me-2"></i>
{% if role %}{% trans %}Editar Rol y Permisos de Acceso{% endtrans %}{% else %}{% trans %}Crear Rol y Permisos de Acceso{% endtrans %}{% endif %}
</h1>
<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>
<a href="{{ url_for('admin.roles_list') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>{% trans %}Volver a Roles{% endtrans %}
</a>
</div>
</div>
<form method="POST">
<div class="row">
<!-- Left Column: Role Details -->
<div class="col-lg-4 mb-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-body-tertiary py-3">
<h5 class="card-title h6 mb-0">
<i class="bi bi-person-badge text-primary me-2"></i>{% trans %}Información del Rol{% endtrans %}
</h5>
</div>
<div class="card-body">
<div class="mb-3">
<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"
value="{{ role.name if role else '' }}" required
placeholder="{% trans %}ej. Docente, Operador, Bedelía{% endtrans %}"
{% 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 %}
<small class="text-muted d-block mt-1">
<i class="bi bi-info-circle me-1"></i>{% trans %}Rol del sistema protegido.{% endtrans %}
</small>
{% endif %}
</div>
<div class="mb-3">
<label for="description" class="form-label fw-semibold">{% trans %}Descripción{% endtrans %}</label>
<textarea class="form-control" id="description" name="description" rows="3"
placeholder="{% trans %}Describa las responsabilidades o alcance de este rol...{% endtrans %}">{{ role.description if role else '' }}</textarea>
</div>
<hr class="my-4">
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary py-2 fw-semibold">
<i class="bi bi-check-lg me-1"></i>{% trans %}Guardar Rol y Permisos{% endtrans %}
</button>
<a href="{{ url_for('admin.roles_list') }}" class="btn btn-outline-secondary">
{% trans %}Cancelar{% endtrans %}
</a>
</div>
</div>
</div>
</div>
<!-- Right Column: FortiGate Permissions Matrix -->
<div class="col-lg-8 mb-4">
<div class="fg-permission-card">
<!-- Matrix Header -->
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-bold text-primary">
<i class="bi bi-grid-3x3-gap-fill me-2"></i>{% trans %}Permisos de Acceso{% endtrans %}
</span>
<!-- Set All Action Dropdown (FortiGate style) -->
<div class="dropdown">
<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>{% trans %}Establecer Todos{% endtrans %}
</button>
<ul class="dropdown-menu dropdown-menu-end shadow-sm" aria-labelledby="setAllDropdown">
<li>
<button type="button" class="dropdown-item py-2" onclick="setAllPermissions('none')">
<i class="bi bi-slash-circle text-danger me-2"></i>{% trans %}Establecer Todos en Ninguno{% endtrans %}
</button>
</li>
<li>
<button type="button" class="dropdown-item py-2" onclick="setAllPermissions('read')">
<i class="bi bi-eye text-info me-2"></i>{% trans %}Establecer Todos en Solo Lectura{% endtrans %}
</button>
</li>
<li>
<button type="button" class="dropdown-item py-2" onclick="setAllPermissions('read_write')">
<i class="bi bi-pencil text-success me-2"></i>{% trans %}Establecer Todos en Lectura y Escritura{% endtrans %}
</button>
</li>
</ul>
</div>
</div>
<!-- Matrix Table -->
<div class="table-responsive">
<table class="fg-table">
<thead>
<tr>
<th style="width: 48%;">{% trans %}Módulo del Sistema{% endtrans %}</th>
<th style="width: 52%;" class="text-end pe-4">{% trans %}Nivel de Permiso{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for mod in modules %}
{% set cur_level = current_perms.get(mod.id, 'none') %}
<tr data-module-row="{{ mod.id }}">
<td>
<div class="d-flex align-items-center">
<div class="bg-primary bg-opacity-10 text-primary rounded p-2 me-3 d-inline-flex align-items-center justify-content-center" style="width: 36px; height: 36px;">
<i class="bi {{ mod.icon }} fs-6"></i>
</div>
<div>
<div class="fw-semibold">{{ mod.name }}</div>
<small class="text-muted d-block">{{ mod.description }}</small>
</div>
</div>
</td>
<td class="text-end pe-4">
<!-- Hidden Input to submit via POST -->
<input type="hidden" name="perm_{{ mod.id }}" id="input_perm_{{ mod.id }}" value="{{ cur_level }}">
<!-- FortiGate Segmented Control -->
<div class="fg-segmented-control" role="group" aria-label="Nivel de permiso para {{ mod.name }}">
<button type="button"
class="fg-seg-btn {% if cur_level == 'none' %}active{% endif %}"
data-val="none"
data-target="input_perm_{{ mod.id }}"
onclick="selectPerm(this, 'input_perm_{{ mod.id }}', 'none')">
<i class="bi bi-slash-circle"></i> {% trans %}Ninguno{% endtrans %}
</button>
<button type="button"
class="fg-seg-btn {% if cur_level == 'read' %}active{% endif %}"
data-val="read"
data-target="input_perm_{{ mod.id }}"
onclick="selectPerm(this, 'input_perm_{{ mod.id }}', 'read')">
<i class="bi bi-eye"></i> {% trans %}Lectura{% endtrans %}
</button>
<button type="button"
class="fg-seg-btn {% if cur_level == 'read_write' %}active{% endif %}"
data-val="read_write"
data-target="input_perm_{{ mod.id }}"
onclick="selectPerm(this, 'input_perm_{{ mod.id }}', 'read_write')">
<i class="bi bi-pencil"></i> {% trans %}Lectura/Escritura{% endtrans %}
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</form>
</div>
{% endblock %}
{% block extra_js %}
<script>
function selectPerm(btn, inputId, value) {
const input = document.getElementById(inputId);
if (input) {
input.value = value;
}
// Toggle active state in this segmented control
const parentControl = btn.closest('.fg-segmented-control');
if (parentControl) {
parentControl.querySelectorAll('.fg-seg-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
}
}
function setAllPermissions(level) {
document.querySelectorAll('.fg-segmented-control').forEach(control => {
const targetBtn = control.querySelector(`button[data-val="${level}"]`);
if (targetBtn) {
targetBtn.click();
}
});
}
</script>
{% endblock %}
+153
View File
@@ -0,0 +1,153 @@
{% 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 btn-delete-role"
data-role-id="{{ role.id }}"
data-role-name="{{ role.name }}"
data-action-url="{{ url_for('admin.role_delete', id=role.id) }}"
title="{% trans %}Eliminar Rol{% endtrans %}"
{% if role.users|length > 0 %}disabled{% endif %}>
<i class="bi bi-trash"></i>
</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Modal Global de Eliminación fuera de la tabla (evita desplazamientos visuales) -->
<div class="modal fade" id="deleteRoleModal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow-lg" style="border-radius: 14px;">
<div class="modal-header border-0 pb-0">
<div class="d-flex align-items-center">
<div class="badge bg-danger-subtle text-danger p-2 rounded-circle me-2">
<i class="bi bi-exclamation-triangle-fill fs-5"></i>
</div>
<h5 class="modal-title fw-bold text-danger mb-0">{% trans %}Confirmar Eliminación de Rol{% endtrans %}</h5>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-start py-3">
<p class="mb-2">{% trans %}¿Está seguro de que desea eliminar el rol{% endtrans %} <strong id="deleteRoleName" class="text-primary"></strong>?</p>
<p class="text-muted small mb-0"><i class="bi bi-info-circle me-1"></i>{% trans %}Esta acción no se puede deshacer.{% endtrans %}</p>
</div>
<div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<form id="deleteRoleForm" method="POST" class="d-inline">
<button type="submit" class="btn btn-danger px-3">
<i class="bi bi-trash me-1"></i>{% trans %}Eliminar Rol{% endtrans %}
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
var deleteModalEl = document.getElementById('deleteRoleModal');
var deleteModal = deleteModalEl ? new bootstrap.Modal(deleteModalEl) : null;
var deleteRoleName = document.getElementById('deleteRoleName');
var deleteRoleForm = document.getElementById('deleteRoleForm');
document.querySelectorAll('.btn-delete-role').forEach(function(btn) {
btn.addEventListener('click', function() {
var roleName = this.getAttribute('data-role-name');
var actionUrl = this.getAttribute('data-action-url');
if (deleteRoleName) deleteRoleName.textContent = roleName;
if (deleteRoleForm) deleteRoleForm.action = actionUrl;
if (deleteModal) deleteModal.show();
});
});
});
</script>
{% endblock %}
+306
View File
@@ -0,0 +1,306 @@
{% extends "base.html" %}
{% block title %}{% trans %}Gestión de Asignaturas{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<!-- Header -->
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
<div>
<h1 class="h3 fw-bold mb-1">
<i class="bi bi-book text-primary me-2"></i>{% trans %}Gestión de Asignaturas y Materias{% endtrans %}
</h1>
<p class="text-muted mb-0 small">
{% trans %}Catálogo institucional de asignaturas, códigos, créditos académicos y carreras vinculadas.{% endtrans %}
</p>
</div>
<div class="d-flex gap-2">
{% if can_edit %}
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#modalAddSubject">
<i class="bi bi-plus-lg me-1"></i>{% trans %}Nueva Asignatura{% endtrans %}
</button>
{% endif %}
<a href="{{ url_for('schedule.commissions_view') }}" class="btn btn-outline-secondary">
<i class="bi bi-diagram-3 me-1"></i>{% trans %}Ver Comisiones{% endtrans %}
</a>
</div>
</div>
<!-- Filters Bar -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body py-2">
<form method="GET" class="row g-2 align-items-center">
<!-- Buscador -->
<div class="col-md-4">
<div class="input-group input-group-sm">
<span class="input-group-text bg-body-tertiary"><i class="bi bi-search"></i></span>
<input type="text" name="search" class="form-control"
placeholder="{% trans %}Buscar por código, nombre o departamento...{% endtrans %}"
value="{{ search }}">
</div>
</div>
<!-- Filtro Carrera -->
<div class="col-md-4">
<div class="input-group input-group-sm">
<span class="input-group-text bg-body-tertiary"><i class="bi bi-mortarboard text-primary"></i></span>
<select name="career_id" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">{% trans %}Todas las carreras{% endtrans %}</option>
{% for c in careers %}
<option value="{{ c.id }}" {% if career_filter == c.id %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
</div>
</div>
<!-- Filtro Estado -->
<div class="col-md-2">
<select name="active" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">{% trans %}Todos los estados{% endtrans %}</option>
<option value="true" {% if active_filter == 'true' %}selected{% endif %}>{% trans %}Solo activas{% endtrans %}</option>
<option value="false" {% if active_filter == 'false' %}selected{% endif %}>{% trans %}Solo inactivas{% endtrans %}</option>
</select>
</div>
<div class="col-md-2 text-md-end">
<button type="submit" class="btn btn-sm btn-primary me-1">
<i class="bi bi-funnel me-1"></i>{% trans %}Filtrar{% endtrans %}
</button>
<a href="{{ url_for('admin.subjects_list') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-x-circle me-1"></i>{% trans %}Limpiar{% endtrans %}
</a>
</div>
</form>
</div>
</div>
<!-- Subjects Table -->
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>{% trans %}Código{% endtrans %}</th>
<th>{% trans %}Nombre de la Asignatura{% endtrans %}</th>
<th>{% trans %}Carrera{% endtrans %}</th>
<th>{% trans %}Departamento{% endtrans %}</th>
<th>{% trans %}Créditos{% endtrans %}</th>
<th>{% trans %}Comisiones{% endtrans %}</th>
<th>{% trans %}Estado{% endtrans %}</th>
{% if can_edit %}
<th class="text-end">{% trans %}Acciones{% endtrans %}</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for s in subjects %}
<tr>
<td>
<span class="badge font-monospace bg-primary-subtle text-primary border">{{ s.code }}</span>
</td>
<td>
<span class="fw-bold text-body">{{ s.name }}</span>
{% if s.description %}
<small class="text-muted d-block text-truncate" style="max-width: 300px;">{{ s.description }}</small>
{% endif %}
</td>
<td>
{% if s.career_obj %}
<span class="badge bg-secondary-subtle text-secondary border">
<i class="bi bi-mortarboard me-1"></i>{{ s.career_obj.name }}
</span>
{% else %}
<span class="text-muted small">General</span>
{% endif %}
</td>
<td>
<span class="small text-muted">{{ s.department or '-' }}</span>
</td>
<td>
<span class="badge bg-light text-dark border">{{ s.credits }} cr.</span>
</td>
<td>
<span class="badge bg-info-subtle text-info border">
<i class="bi bi-diagram-3 me-1"></i>{{ s.commissions|length if s.commissions else 0 }}
</span>
</td>
<td>
<span class="badge rounded-pill {% if s.is_active %}bg-success-subtle text-success border border-success-subtle{% else %}bg-secondary-subtle text-secondary border{% endif %}">
{% if s.is_active %}{% trans %}Activa{% endtrans %}{% else %}{% trans %}Inactiva{% endtrans %}{% endif %}
</span>
</td>
{% if can_edit %}
<td class="text-end">
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-secondary py-0 px-2"
data-bs-toggle="modal" data-bs-target="#modalEditSubject{{ s.id }}"
title="{% trans %}Editar Asignatura{% endtrans %}">
<i class="bi bi-pencil"></i>
</button>
<form method="POST" action="{{ url_for('admin.subject_toggle', id=s.id) }}" class="d-inline">
<button type="submit" class="btn btn-outline-warning py-0 px-2"
title="{% if s.is_active %}{% trans %}Desactivar{% endtrans %}{% else %}{% trans %}Activar{% endtrans %}{% endif %}">
<i class="bi {% if s.is_active %}bi-pause{% else %}bi-play{% endif %}"></i>
</button>
</form>
</div>
<!-- Modal Editar Asignatura -->
<div class="modal fade text-start" id="modalEditSubject{{ s.id }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" action="{{ url_for('admin.subject_edit', id=s.id) }}">
<div class="modal-header">
<h5 class="modal-title h6 fw-bold">
<i class="bi bi-pencil-square text-primary me-2"></i>{% trans %}Editar Asignatura{% endtrans %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row g-2 mb-3">
<div class="col-md-5">
<label class="form-label small fw-semibold">{% trans %}Código{% endtrans %} *</label>
<input type="text" name="code" class="form-control form-control-sm font-monospace" value="{{ s.code }}" required>
</div>
<div class="col-md-7">
<label class="form-label small fw-semibold">{% trans %}Créditos{% endtrans %}</label>
<input type="number" name="credits" class="form-control form-control-sm" value="{{ s.credits }}" min="1" max="20">
</div>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Nombre de la Asignatura{% endtrans %} *</label>
<input type="text" name="name" class="form-control form-control-sm" value="{{ s.name }}" required>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Carrera Vinculada{% endtrans %}</label>
<select name="career_id" class="form-select form-select-sm">
<option value="">{% trans %}Sin carrera específica (General){% endtrans %}</option>
{% for c in careers %}
<option value="{{ c.id }}" {% if s.career_id == c.id %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Departamento / Área{% endtrans %}</label>
<input type="text" name="department" class="form-control form-control-sm" value="{{ s.department or '' }}">
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Descripción / Objetivos{% endtrans %}</label>
<textarea name="description" class="form-control form-control-sm" rows="2">{{ s.description or '' }}</textarea>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="is_active" id="activeSubj{{ s.id }}" {% if s.is_active %}checked{% endif %}>
<label class="form-check-label small" for="activeSubj{{ s.id }}">{% trans %}Asignatura Activa{% endtrans %}</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<button type="submit" class="btn btn-sm btn-primary">{% trans %}Guardar Cambios{% endtrans %}</button>
</div>
</form>
</div>
</div>
</div>
</td>
{% endif %}
</tr>
{% else %}
<tr>
<td colspan="8" class="text-center py-5 text-muted">
<i class="bi bi-book fs-1 d-block mb-2"></i>
{% trans %}No se encontraron asignaturas con los criterios indicados.{% endtrans %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Paginación -->
{% if pagination.pages > 1 %}
<div class="card-footer bg-body-tertiary border-0 py-2">
<nav aria-label="Subjects pagination">
<ul class="pagination pagination-sm justify-content-center mb-0">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin.subjects_list', page=pagination.prev_num, search=search, career_id=career_filter, active=active_filter) }}">{% trans %}Anterior{% endtrans %}</a>
</li>
{% endif %}
{% for p in pagination.iter_pages(left_edge=2, right_edge=2, 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.subjects_list', page=p, search=search, career_id=career_filter, active=active_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.subjects_list', page=pagination.next_num, search=search, career_id=career_filter, active=active_filter) }}">{% trans %}Siguiente{% endtrans %}</a>
</li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
</div>
</div>
<!-- Modal Nueva Asignatura -->
{% if can_edit %}
<div class="modal fade" id="modalAddSubject" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" action="{{ url_for('admin.subject_add') }}">
<div class="modal-header">
<h5 class="modal-title h6 fw-bold">
<i class="bi bi-book text-primary me-2"></i>{% trans %}Nueva Asignatura{% endtrans %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row g-2 mb-3">
<div class="col-md-5">
<label class="form-label small fw-semibold">{% trans %}Código{% endtrans %} *</label>
<input type="text" name="code" class="form-control form-control-sm font-monospace" placeholder="ej. ASIG0015" required>
</div>
<div class="col-md-7">
<label class="form-label small fw-semibold">{% trans %}Créditos{% endtrans %}</label>
<input type="number" name="credits" class="form-control form-control-sm" value="4" min="1" max="20">
</div>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Nombre de la Asignatura{% endtrans %} *</label>
<input type="text" name="name" class="form-control form-control-sm" placeholder="ej. Álgebra Lineal" required>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Carrera Vinculada{% endtrans %}</label>
<select name="career_id" class="form-select form-select-sm">
<option value="">{% trans %}Sin carrera específica (General){% endtrans %}</option>
{% for c in careers %}
<option value="{{ c.id }}">{{ c.name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Departamento / Área{% endtrans %}</label>
<input type="text" name="department" class="form-control form-control-sm" placeholder="ej. Ciencias Exactas, Informática">
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Descripción{% endtrans %}</label>
<textarea name="description" class="form-control form-control-sm" rows="2" placeholder="Programa de la materia..."></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<button type="submit" class="btn btn-sm btn-primary">{% trans %}Crear Asignatura{% endtrans %}</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% endblock %}
+107
View File
@@ -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 %}
+247
View File
@@ -0,0 +1,247 @@
{% 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>
<button type="button" class="btn btn-outline-danger btn-delete-user"
data-user-id="{{ u.id }}"
data-user-name="{{ u.name }}"
data-user-email="{{ u.email }}"
data-action-url="{{ url_for('admin.user_delete', id=u.id) }}"
title="{% trans %}Eliminar Usuario{% endtrans %}">
<i class="bi bi-trash"></i>
</button>
{% endif %}
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center py-5 text-muted">
<i class="bi bi-people fs-1 d-block mb-2 text-secondary"></i>
{% trans %}No users found matching the criteria.{% endtrans %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if pagination.pages > 1 %}
<div class="card-footer bg-body-tertiary d-flex justify-content-between align-items-center py-2 px-4">
<small class="text-muted">
{% trans %}Showing page{% endtrans %} {{ pagination.page }} {% trans %}of{% endtrans %} {{ pagination.pages }} ({{ pagination.total }} {% trans %}total{% endtrans %})
</small>
<ul class="pagination pagination-sm mb-0">
{% if pagination.has_prev %}
<li class="page-item"><a class="page-link" href="{{ url_for('admin.users_list', page=pagination.prev_num, search=search, role=role_filter, status=status_filter) }}">&laquo;</a></li>
{% endif %}
{% for p in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
{% if p %}
<li class="page-item {% if p == pagination.page %}active{% endif %}">
<a class="page-link" href="{{ url_for('admin.users_list', page=p, search=search, role=role_filter, status=status_filter) }}">{{ p }}</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link">…</span></li>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<li class="page-item"><a class="page-link" href="{{ url_for('admin.users_list', page=pagination.next_num, search=search, role=role_filter, status=status_filter) }}">&raquo;</a></li>
{% endif %}
</ul>
</div>
{% endif %}
</div>
</div>
<!-- Modal Global de Eliminación de Usuario (fuera de la tabla) -->
<div class="modal fade" id="deleteUserModal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow-lg" style="border-radius: 14px;">
<div class="modal-header border-0 pb-0">
<div class="d-flex align-items-center">
<div class="badge bg-danger-subtle text-danger p-2 rounded-circle me-2">
<i class="bi bi-person-x-fill fs-5"></i>
</div>
<h5 class="modal-title fw-bold text-danger mb-0">{% trans %}Confirmar Eliminación de Usuario{% endtrans %}</h5>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-start py-3">
<p class="mb-2">{% trans %}¿Está seguro de que desea eliminar permanentemente al siguiente usuario?{% endtrans %}</p>
<div class="p-2 rounded bg-body-tertiary mb-2">
<div class="fw-bold text-primary" id="deleteUserName"></div>
<div class="small text-muted font-monospace" id="deleteUserEmail"></div>
</div>
<p class="text-muted small mb-0"><i class="bi bi-exclamation-triangle me-1 text-warning"></i>{% trans %}Esta acción desvinculará sus comisiones y reservas asociadas. No se puede deshacer.{% endtrans %}</p>
</div>
<div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<form id="deleteUserForm" method="POST" class="d-inline">
<button type="submit" class="btn btn-danger px-3">
<i class="bi bi-trash me-1"></i>{% trans %}Eliminar Usuario{% endtrans %}
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
var deleteModalEl = document.getElementById('deleteUserModal');
var deleteModal = deleteModalEl ? new bootstrap.Modal(deleteModalEl) : null;
var nameEl = document.getElementById('deleteUserName');
var emailEl = document.getElementById('deleteUserEmail');
var formEl = document.getElementById('deleteUserForm');
document.querySelectorAll('.btn-delete-user').forEach(function(btn) {
btn.addEventListener('click', function() {
var name = this.getAttribute('data-user-name');
var email = this.getAttribute('data-user-email');
var actionUrl = this.getAttribute('data-action-url');
if (nameEl) nameEl.textContent = name;
if (emailEl) emailEl.textContent = email;
if (formEl) formEl.action = actionUrl;
if (deleteModal) deleteModal.show();
});
});
});
</script>
{% endblock %}
+26
View File
@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block title %}Acceso Denegado (403) - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container pt-5 mt-5 text-center">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card border-0 shadow-sm p-5">
<div class="text-danger mb-3" style="font-size: 4rem;">
<i class="bi bi-shield-x"></i>
</div>
<h1 class="h2 mb-2">403 - Acceso Denegado</h1>
<p class="text-muted mb-4">
No tienes los permisos necesarios para acceder a este recurso. Esta sección está restringida exclusivamente a administradores.
</p>
<div>
<a href="{{ url_for('main.dashboard') }}" class="btn btn-primary">
<i class="bi bi-house me-1"></i>Ir al Dashboard
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+62
View File
@@ -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 %}
+63 -48
View File
@@ -1,64 +1,70 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="{{ current_lang }}" data-bs-theme="dark">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - Edu-Space Admin</title> <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@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"> <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
<style> <!-- Design System Theme -->
body { <link href="{{ url_for('static', filename='css/theme.css') }}" rel="stylesheet">
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
max-width: 450px;
width: 100%;
}
.logo-section {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 20px 20px 0 0;
padding: 2rem;
text-align: center;
color: white;
}
</style>
</head> </head>
<body> <body>
<div class="login-card"> <div class="auth-wrapper">
<div class="logo-section"> <div class="auth-card">
<i class="bi bi-building" style="font-size: 3rem;"></i> <!-- Header con Controles Superiores & Logo Oficial -->
<h3 class="mt-3">Edu-Space Admin</h3> <div class="auth-header">
<p class="mb-0">Classroom Management System</p> <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> </div>
<div class="p-4"> <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) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %} {% if messages %}
{% for category, message in messages %} {% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert"> <div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show rounded-3 py-2 small" role="alert">
{{ message }} {{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button> <button type="button" class="btn-close py-2" data-bs-dismiss="alert"></button>
</div> </div>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% endwith %} {% endwith %}
<form method="POST"> <form method="POST" novalidate>
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
<div class="mb-3"> <div class="mb-3">
<label for="email" class="form-label">{{ form.email.label.text }}</label> <label for="email" class="form-label small fw-semibold text-muted">{{ form.email.label.text }}</label>
<div class="input-group"> <div class="input-group">
<span class="input-group-text"><i class="bi bi-envelope"></i></span> <span class="input-group-text bg-body-tertiary border-end-0 text-muted">
{{ form.email(class="form-control", placeholder="Enter your email") }} <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> </div>
{% for error in form.email.errors %} {% for error in form.email.errors %}
<div class="text-danger small mt-1">{{ error }}</div> <div class="text-danger small mt-1">{{ error }}</div>
@@ -66,35 +72,44 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="password" class="form-label">{{ form.password.label.text }}</label> <label for="password" class="form-label small fw-semibold text-muted">{{ form.password.label.text }}</label>
<div class="input-group"> <div class="input-group">
<span class="input-group-text"><i class="bi bi-lock"></i></span> <span class="input-group-text bg-body-tertiary border-end-0 text-muted">
{{ form.password(class="form-control", placeholder="Enter your password") }} <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> </div>
{% for error in form.password.errors %} {% for error in form.password.errors %}
<div class="text-danger small mt-1">{{ error }}</div> <div class="text-danger small mt-1">{{ error }}</div>
{% endfor %} {% endfor %}
</div> </div>
<div class="mb-3 form-check"> <div class="mb-4 form-check">
{{ form.remember_me(class="form-check-input") }} {{ form.remember_me(class="form-check-input") }}
{{ form.remember_me.label(class="form-check-label") }} <label class="form-check-label text-muted small" for="remember_me">
{{ form.remember_me.label.text }}
</label>
</div> </div>
<div class="d-grid"> <div class="d-grid">
{{ form.submit(class="btn btn-primary btn-lg") }} <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> </div>
</form> </form>
<div class="text-center mt-3"> <div class="text-center mt-4">
<small class="text-muted"> <small class="text-muted d-inline-flex align-items-center">
<i class="bi bi-shield-lock"></i> <i class="bi bi-shield-check text-primary me-1"></i>
Secure Login • All access is logged {% trans %}Secure Login • All access is logged{% endtrans %}
</small> </small>
</div> </div>
</div> </div>
</div> </div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <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> </body>
</html> </html>
+188
View File
@@ -0,0 +1,188 @@
{% extends "base.html" %}
{% block title %}{% trans %}User Profile{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container pt-5 mt-4">
<div class="row justify-content-center">
<div class="col-lg-8">
<!-- Header -->
<div class="d-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0">
<i class="bi bi-person-badge text-primary me-2"></i>{% trans %}My Profile{% endtrans %}
</h1>
<a href="{{ url_for('main.dashboard') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>{% trans %}Dashboard{% endtrans %}
</a>
</div>
<!-- Profile Card -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-4">
<div class="d-flex align-items-center mb-4">
<div class="bg-primary text-white rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 72px; height: 72px; font-size: 2rem;">
<i class="bi bi-person"></i>
</div>
<div>
<h4 class="mb-1">{{ current_user.name }}</h4>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-primary">{{ current_user.role }}</span>
{% if current_user.is_active %}
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>{% trans %}Active{% endtrans %}</span>
{% else %}
<span class="badge bg-danger"><i class="bi bi-x-circle me-1"></i>{% trans %}Inactive{% endtrans %}</span>
{% endif %}
</div>
</div>
</div>
<hr class="my-4">
<!-- User Details Grid -->
<div class="row g-3">
<div class="col-sm-6">
<label class="form-label text-muted small mb-1">{% trans %}Email{% endtrans %}</label>
<div class="fw-semibold">{{ current_user.email }}</div>
</div>
<div class="col-sm-6">
<label class="form-label text-muted small mb-1">{% trans %}Full Name{% endtrans %}</label>
<div class="fw-semibold">{{ current_user.name }}</div>
</div>
<div class="col-sm-6">
<label class="form-label text-muted small mb-1">{% trans %}Access Role{% endtrans %}</label>
<div class="fw-semibold">{{ current_user.role }}</div>
</div>
<div class="col-sm-6">
<label class="form-label text-muted small mb-1">{% trans %}Last Login{% endtrans %}</label>
<div class="fw-semibold">
{% if current_user.last_login %}
{{ current_user.last_login.strftime('%Y-%m-%d %H:%M:%S') }}
{% else %}
<span class="text-muted">{% trans %}First login{% endtrans %}</span>
{% endif %}
</div>
</div>
<div class="col-sm-6">
<label class="form-label text-muted small mb-1">{% trans %}Registration Date{% endtrans %}</label>
<div class="fw-semibold">
{% if current_user.created_at %}
{{ current_user.created_at.strftime('%Y-%m-%d') }}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</div>
</div>
</div>
<hr class="my-4">
<!-- Preferences Section -->
<form method="POST" action="{{ url_for('auth.profile') }}">
<h5 class="fw-bold mb-3">
<i class="bi bi-sliders text-primary me-2"></i>{% trans %}User Preferences{% endtrans %}
</h5>
<div class="row g-3">
<!-- Preferred Language -->
<div class="col-md-6 mb-3">
<label for="preferred_language" class="form-label fw-semibold">
<i class="bi bi-translate text-primary me-1"></i>{% trans %}Interface Language{% endtrans %}
</label>
<select class="form-select" id="preferred_language" name="preferred_language">
<option value="" {% if not current_user.preferred_language %}selected{% endif %}>
{% trans %}Auto-detect from browser (Default){% endtrans %}
</option>
<option value="es" {% if current_user.preferred_language == 'es' %}selected{% endif %}>
Español
</option>
<option value="en" {% if current_user.preferred_language == 'en' %}selected{% endif %}>
English
</option>
</select>
<small class="text-muted d-block mt-1">
{% trans %}If set, this language overrides the browser configuration for all your sessions.{% endtrans %}
</small>
</div>
<!-- Theme Preference -->
<div class="col-md-6 mb-3">
<label for="theme_preference" class="form-label fw-semibold">
<i class="bi bi-palette text-primary me-1"></i>{% trans %}Visual Theme{% endtrans %}
</label>
<select class="form-select" id="theme_preference" name="theme_preference">
<option value="auto" {% if current_user.theme_preference == 'auto' or not current_user.theme_preference %}selected{% endif %}>
{% trans %}Automatic (follow system){% endtrans %}
</option>
<option value="light" {% if current_user.theme_preference == 'light' %}selected{% endif %}>
{% trans %}Light Mode{% endtrans %}
</option>
<option value="dark" {% if current_user.theme_preference == 'dark' %}selected{% endif %}>
{% trans %}Dark Mode{% endtrans %}
</option>
</select>
<small class="text-muted d-block mt-1">
{% trans %}Choose your preferred visual theme.{% endtrans %}
</small>
</div>
</div>
<div class="text-end mt-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>{% trans %}Save Preferences{% endtrans %}
</button>
</div>
</form>
</div>
<div class="card-footer bg-body-tertiary px-4 py-3 d-flex justify-content-between align-items-center">
<span class="text-muted small">{% trans %}Account Security{% endtrans %}</span>
<a href="{{ url_for('auth.change_password') }}" class="btn btn-primary btn-sm">
<i class="bi bi-key me-1"></i>{% trans %}Change Password{% endtrans %}
</a>
</div>
</div>
<!-- User Reservations Overview (if any) -->
{% if current_user.reservations %}
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-body-tertiary">
<h5 class="card-title mb-0 h6">
<i class="bi bi-calendar-event me-2"></i>{% trans %}My Recent Reservations{% endtrans %}
</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>{% trans %}Classroom{% endtrans %}</th>
<th>{% trans %}Purpose{% endtrans %}</th>
<th>{% trans %}Date & Time{% endtrans %}</th>
<th>{% trans %}Status{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for res in current_user.reservations[:5] %}
<tr>
<td>
<span class="badge bg-secondary">{{ res.classroom.code_display if res.classroom else 'N/A' }}</span>
</td>
<td>{{ res.purpose }}</td>
<td>{{ res.start_time.strftime('%Y-%m-%d %H:%M') }}</td>
<td>
<span class="badge bg-{{ 'success' if res.status == 'CONFIRMED' else 'warning' if res.status == 'PENDING' else 'secondary' }}">
{{ res.status }}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
+105 -41
View File
@@ -1,26 +1,44 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ session.language or request.accept_languages.best_match(['es', 'en']) or 'en' }}" data-bs-theme="dark"> <html lang="{{ current_lang }}" data-bs-theme="dark">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{% trans %}Edu-Space Admin{% endtrans %}{% endblock %}</title> <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 --> <!-- 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@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons --> <!-- Bootstrap Icons -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
<!-- Custom CSS --> <!-- 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"> <link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
{% block extra_css %}{% endblock %} {% block extra_css %}{% endblock %}
</head> </head>
<body> <body data-user-authenticated="{{ 'true' if (current_user and current_user.is_authenticated) else 'false' }}">
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<!-- Navigation --> <!-- Navigation -->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary fixed-top"> <nav class="navbar navbar-expand-lg navbar-dark navbar-app fixed-top">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('main.dashboard') }}"> <a class="navbar-brand d-flex align-items-center" href="{{ url_for('main.dashboard') }}">
<i class="bi bi-building"></i> Edu-Space Admin <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> </a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
@@ -31,83 +49,127 @@
<ul class="navbar-nav me-auto"> <ul class="navbar-nav me-auto">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('main.dashboard') }}"> <a class="nav-link" href="{{ url_for('main.dashboard') }}">
<i class="bi bi-speedometer2"></i> Dashboard <i class="bi bi-speedometer2"></i> {% trans %}Dashboard{% endtrans %}
</a> </a>
</li> </li>
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"> <a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-door-open"></i> Classrooms <i class="bi bi-door-open"></i> {% trans %}Classrooms{% endtrans %}
</a> </a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms') }}"> <li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms') }}">
<i class="bi bi-list"></i> All Classrooms <i class="bi bi-list"></i> {% trans %}All Classrooms{% endtrans %}
</a></li> </a></li>
{% if current_user.has_permission('classrooms', 'read_write') %}
<li><a class="dropdown-item" href="{{ url_for('classrooms.add_classroom') }}"> <li><a class="dropdown-item" href="{{ url_for('classrooms.add_classroom') }}">
<i class="bi bi-plus-circle"></i> 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> </a></li>
</ul> </ul>
</li> </li>
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"> <a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-calendar3"></i> Schedule <i class="bi bi-calendar3"></i> {% trans %}Schedule{% endtrans %}
</a> </a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ url_for('schedule.calendar_view') }}"> <li><a class="dropdown-item" href="{{ url_for('schedule.calendar_view') }}">
<i class="bi bi-calendar-week"></i> Calendar View <i class="bi bi-calendar-week"></i> {% trans %}Calendar View{% endtrans %}
</a></li> </a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}"> <li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}">
<i class="bi bi-list-check"></i> All Reservations <i class="bi bi-list-check"></i> {% trans %}All Reservations{% endtrans %}
</a></li> </a></li>
{% if current_user.has_permission('reservations', 'read_write') %}
<li><a class="dropdown-item" href="{{ url_for('schedule.add_reservation') }}"> <li><a class="dropdown-item" href="{{ url_for('schedule.add_reservation') }}">
<i class="bi bi-plus-circle"></i> New Reservation <i class="bi bi-plus-circle"></i> {% trans %}New Reservation{% endtrans %}
</a></li> </a></li>
{% endif %}
<li><a class="dropdown-item" href="{{ url_for('schedule.today_schedule') }}"> <li><a class="dropdown-item" href="{{ url_for('schedule.today_schedule') }}">
<i class="bi bi-calendar-today"></i> Today's Schedule <i class="bi bi-calendar-today"></i> {% trans %}Today's Schedule{% endtrans %}
</a></li> </a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.commissions_view') }}">
<i class="bi bi-diagram-3 me-2 text-primary"></i>{% trans %}Comisiones y Cursadas{% endtrans %}
</a></li>
{% if current_user.has_permission('optimizer', 'read') or (current_user.role and current_user.role|upper == 'ADMIN') %}
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('genetic_algorithm_web.genetic_optimizer') }}">
<i class="bi bi-cpu text-info me-2"></i>{% trans %}Optimizador de Aulas (IA){% endtrans %}
</a></li>
{% endif %}
</ul> </ul>
</li> </li>
</ul> {% if current_user.is_authenticated and current_user.can_manage() %}
<ul class="navbar-nav">
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"> <a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-person-circle"></i> {{ current_user.first_name }} {{ current_user.last_name }} <i class="bi bi-shield-lock"></i> {% trans %}Gestión{% endtrans %}
</a> </a>
<ul class="dropdown-menu dropdown-menu-end"> <ul class="dropdown-menu shadow-sm" style="min-width: 260px;">
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}"> <!-- Espacios & Infraestructura -->
<i class="bi bi-person"></i> Profile <li class="dropdown-header text-uppercase small fw-bold text-muted py-1" style="font-size: 0.68rem; letter-spacing: 0.5px;">{% trans %}Espacios e Infraestructura{% endtrans %}</li>
</a></li> <li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}"><i class="bi bi-buildings me-2 text-secondary"></i>{% trans %}Edificios y Sedes{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('auth.change_password') }}"> <li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='physical') }}"><i class="bi bi-door-open me-2 text-primary"></i>{% trans %}Aulas Presenciales{% endtrans %}</a></li>
<i class="bi bi-key"></i> Change Password <li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}"><i class="bi bi-camera-video me-2 text-info"></i>{% trans %}Aulas Virtuales (Reunión){% endtrans %}</a></li>
</a></li> <li><a class="dropdown-item" href="{{ url_for('admin.occupancy_metrics') }}"><i class="bi bi-graph-up-arrow me-2 text-success"></i>{% trans %}Gestión de Ocupación, Métricas{% endtrans %}</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}"> <li><hr class="dropdown-divider my-1"></li>
<i class="bi bi-box-arrow-right"></i> Logout <!-- Gestión Académica -->
</a></li> <li class="dropdown-header text-uppercase small fw-bold text-muted py-1" style="font-size: 0.68rem; letter-spacing: 0.5px;">{% trans %}Gestión Académica{% endtrans %}</li>
<li><a class="dropdown-item" href="{{ url_for('admin.careers_list') }}"><i class="bi bi-mortarboard me-2 text-primary"></i>{% trans %}Carreras{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.subjects_list') }}"><i class="bi bi-book me-2 text-info"></i>{% trans %}Asignaturas{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.commissions_view') }}"><i class="bi bi-diagram-3 me-2 text-primary"></i>{% trans %}Comisiones y Cursadas{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.academic_terms_list') }}"><i class="bi bi-calendar-range me-2 text-warning"></i>{% trans %}Ciclo Lectivo{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}"><i class="bi bi-calendar-check me-2 text-warning"></i>{% trans %}Gestión de Reservas{% endtrans %}</a></li>
<li><hr class="dropdown-divider my-1"></li>
<!-- Administración & Sistema -->
<li class="dropdown-header text-uppercase small fw-bold text-muted py-1" style="font-size: 0.68rem; letter-spacing: 0.5px;">{% trans %}Administración & Seguridad{% endtrans %}</li>
<li><a class="dropdown-item" href="{{ url_for('admin.users_list') }}"><i class="bi bi-people me-2"></i>{% trans %}Usuarios{% 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 y Permisos{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.milestone_types_list') }}"><i class="bi bi-tags me-2 text-secondary"></i>{% trans %}Tipificaciones de Hitos{% endtrans %}</a></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 %}Sincronización Sheets{% endtrans %}</a></li>
</ul> </ul>
</li> </li>
{% endif %}
</ul> </ul>
<ul class="navbar-nav"> <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 --> <!-- Language Switcher -->
<li class="nav-item"> <li class="nav-item me-2">
{% include 'partials/language_switcher.html' %} {% include 'partials/language_switcher.html' %}
</li> </li>
<!-- User Dropdown with Avatar -->
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"> <a class="nav-link dropdown-toggle d-flex align-items-center py-1" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-person-circle"></i> {{ current_user.first_name }} {{ current_user.last_name }} <span class="user-avatar-small">{{ current_user.name[:1]|upper if current_user.name else 'U' }}</span>
<span>{{ current_user.first_name }}</span>
</a> </a>
<ul class="dropdown-menu dropdown-menu-end"> <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') }}"> <li><a class="dropdown-item" href="{{ url_for('auth.profile') }}">
<i class="bi bi-person"></i> Profile <i class="bi bi-person me-2"></i> {% trans %}Profile{% endtrans %}
</a></li> </a></li>
<li><a class="dropdown-item" href="{{ url_for('auth.change_password') }}"> <li><a class="dropdown-item" href="{{ url_for('auth.change_password') }}">
<i class="bi bi-key"></i> Change Password <i class="bi bi-key me-2"></i> {% trans %}Change Password{% endtrans %}
</a></li> </a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}"> <li><a class="dropdown-item text-danger" href="{{ url_for('auth.logout') }}">
<i class="bi bi-box-arrow-right"></i> Logout <i class="bi bi-box-arrow-right me-2"></i> {% trans %}Logout{% endtrans %}
</a></li> </a></li>
</ul> </ul>
</li> </li>
@@ -118,11 +180,11 @@
{% endif %} {% endif %}
<!-- Flash Messages --> <!-- Flash Messages -->
<div class="toast-container position-fixed bottom-0 end-0 p-3"> <div class="toast-container position-fixed bottom-0 end-0 p-3" style="z-index: 1080;">
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %} {% if messages %}
{% for category, message in messages %} {% for category, message in messages %}
<div class="toast align-items-center text-white bg-{{ 'danger' if category == 'error' else 'success' if category == 'success' else 'info' if category == 'info' else 'warning' }} border-0" role="alert"> <div class="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="d-flex">
<div class="toast-body"> <div class="toast-body">
{{ message }} {{ message }}
@@ -142,6 +204,8 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- jQuery --> <!-- jQuery -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script> <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 --> <!-- Custom JS -->
<script src="{{ url_for('static', filename='js/main.js') }}"></script> <script src="{{ url_for('static', filename='js/main.js') }}"></script>
+95
View File
@@ -0,0 +1,95 @@
{% extends "base.html" %}
{% block title %}
{% if building %}{% trans %}Editar Edificio{% endtrans %} - {{ building.name }}{% else %}{% trans %}Nuevo Edificio{% endtrans %}{% endif %} - Edu-Space Admin
{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<div class="row">
<div class="col-lg-8 mx-auto">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">
<i class="bi bi-buildings text-primary me-2"></i>
{% if building %}{% trans %}Editar Edificio{% endtrans %}{% else %}{% trans %}Nuevo Edificio{% endtrans %}{% endif %}
</h1>
<a href="{{ url_for('buildings.list_buildings') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>{% trans %}Volver a Edificios{% endtrans %}
</a>
</div>
<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-info-circle text-primary me-2"></i>{% trans %}Información del Edificio / Sede{% endtrans %}
</h5>
</div>
<div class="card-body p-4">
<form method="POST" novalidate>
{{ form.hidden_tag() }}
<div class="row g-3">
<div class="col-md-8">
{{ form.name.label(class="form-label fw-semibold") }}
{{ form.name(class="form-control" + (" is-invalid" if form.name.errors else ""), placeholder="ej. Edificio Central, Pabellón de Ciencias, Sede Norte") }}
{% if form.name.errors %}
<div class="invalid-feedback">{{ form.name.errors[0] }}</div>
{% endif %}
</div>
<div class="col-md-4">
{{ form.code.label(class="form-label fw-semibold") }}
{{ form.code(class="form-control" + (" is-invalid" if form.code.errors else ""), placeholder="ej. CENTRAL, VIRTUAL, PAB-A") }}
{% if form.code.errors %}
<div class="invalid-feedback">{{ form.code.errors[0] }}</div>
{% endif %}
<small class="text-muted">{% trans %}Código identificador corto{% endtrans %}</small>
</div>
<div class="col-md-8">
{{ 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") }}
{% if form.address.errors %}
<div class="invalid-feedback">{{ form.address.errors[0] }}</div>
{% endif %}
</div>
<div class="col-md-4">
{{ form.floors.label(class="form-label fw-semibold") }}
{{ form.floors(class="form-control" + (" is-invalid" if form.floors.errors else ""), min=1, max=100) }}
{% if form.floors.errors %}
<div class="invalid-feedback">{{ form.floors.errors[0] }}</div>
{% endif %}
<small class="text-muted">{% trans %}Cantidad de plantas/pisos{% endtrans %}</small>
</div>
<div class="col-12">
{{ form.description.label(class="form-label fw-semibold") }}
{{ form.description(class="form-control", rows=3, placeholder="Información relevante, accesos, comodidades o departamentos alojados...") }}
{% if form.description.errors %}
<div class="invalid-feedback">{{ form.description.errors[0] }}</div>
{% endif %}
</div>
<div class="col-12">
<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>
<div class="d-flex justify-content-end gap-2 mt-4 pt-3 border-top">
<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">
<i class="bi bi-check-circle me-1"></i>
{% if building %}{% trans %}Guardar Cambios{% endtrans %}{% else %}{% trans %}Crear Edificio{% endtrans %}{% endif %}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+223
View File
@@ -0,0 +1,223 @@
{% extends "base.html" %}
{% block title %}{% trans %}Gestión de Edificios{% 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-buildings text-primary me-2"></i>{% trans %}Gestión de Edificios{% endtrans %}
</h1>
<p class="text-muted small mb-0">{% trans %}Administra las sedes, edificios físicos y campus virtuales de UniCABA{% endtrans %}</p>
</div>
<div class="col-md-5 text-md-end mt-3 mt-md-0">
{% if current_user.has_permission('classrooms', 'read_write') %}
<a href="{{ url_for('buildings.add_building') }}" class="btn btn-primary me-2">
<i class="bi bi-plus-circle me-1"></i>{% trans %}Nuevo Edificio{% endtrans %}
</a>
{% endif %}
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
<i class="bi bi-door-open me-1"></i>{% trans %}Ver Aulas{% endtrans %}
</a>
</div>
</div>
<!-- Statistics Cards -->
<div class="row g-3 mb-4">
<div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="rounded-3 p-3 bg-primary bg-opacity-10 text-primary me-3">
<i class="bi bi-buildings fs-3"></i>
</div>
<div>
<div class="text-muted small fw-semibold">{% trans %}Total Edificios{% endtrans %}</div>
<h3 class="fw-bold mb-0">{{ buildings|length }}</h3>
</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="rounded-3 p-3 bg-success bg-opacity-10 text-success me-3">
<i class="bi bi-door-open fs-3"></i>
</div>
<div>
<div class="text-muted small fw-semibold">{% trans %}Aulas Activas{% endtrans %}</div>
<h3 class="fw-bold mb-0 text-success">{{ total_classrooms }}</h3>
</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="rounded-3 p-3 bg-info bg-opacity-10 text-info me-3">
<i class="bi bi-people fs-3"></i>
</div>
<div>
<div class="text-muted small fw-semibold">{% trans %}Capacidad Total{% endtrans %}</div>
<h3 class="fw-bold mb-0 text-info">{{ total_capacity }}</h3>
</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="rounded-3 p-3 bg-warning bg-opacity-10 text-warning me-3">
<i class="bi bi-layers fs-3"></i>
</div>
<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>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Filter & Search Toolbar -->
<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-6">
<div class="input-group">
<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" value="{{ search }}" class="form-control border-start-0" placeholder="{% trans %}Buscar por nombre, código o dirección...{% endtrans %}">
</div>
</div>
<div class="col-md-4">
<select name="status" class="form-select" onchange="this.form.submit()">
<option value="">{% trans %}Todos los estados{% endtrans %}</option>
<option value="active" {% if status_filter == 'active' %}selected{% endif %}>{% trans %}Activos únicamente{% endtrans %}</option>
<option value="inactive" {% if status_filter == 'inactive' %}selected{% endif %}>{% trans %}Inactivos{% endtrans %}</option>
</select>
</div>
<div class="col-md-2 d-flex gap-2">
<button type="submit" class="btn btn-primary w-100">{% trans %}Buscar{% endtrans %}</button>
{% if search or status_filter %}
<a href="{{ url_for('buildings.list_buildings') }}" class="btn btn-outline-secondary" title="{% trans %}Limpiar filtro{% endtrans %}">
<i class="bi bi-x-lg"></i>
</a>
{% endif %}
</div>
</form>
</div>
</div>
<!-- Buildings Cards List -->
<div class="row g-3">
{% for b in buildings %}
<div class="col-lg-6">
<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>
<span class="badge bg-secondary-subtle text-secondary font-monospace border mb-1">
{{ b.code or 'EDIF' }}
</span>
<h5 class="card-title fw-bold mb-0">
<a href="{{ url_for('buildings.view_building', id=b.id) }}" class="text-decoration-none text-primary">
{{ b.name }}
</a>
</h5>
</div>
<div>
{% if b.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 %}Activo{% endtrans %}
</span>
{% else %}
<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>{% trans %}Inactivo{% endtrans %}
</span>
{% endif %}
</div>
</div>
<div class="card-body">
{% if b.address %}
<p class="text-muted small mb-3">
<i class="bi bi-geo-alt-fill text-danger me-1"></i>{{ b.address }}
</p>
{% endif %}
{% if b.description %}
<p class="small text-secondary mb-3">{{ b.description }}</p>
{% endif %}
<div class="row g-2 text-center pt-2 border-top">
<div class="col-4">
<div class="p-2 rounded bg-body-tertiary">
<small class="text-muted d-block">{% trans %}Pisos{% endtrans %}</small>
<span class="fw-bold fs-6">{{ b.floors }}</span>
</div>
</div>
<div class="col-4">
<div class="p-2 rounded bg-body-tertiary">
<small class="text-muted d-block">{% trans %}Aulas{% endtrans %}</small>
<span class="fw-bold fs-6 text-primary">{{ b.classrooms_count }}</span>
</div>
</div>
<div class="col-4">
<div class="p-2 rounded bg-body-tertiary">
<small class="text-muted d-block">{% trans %}Capacidad{% endtrans %}</small>
<span class="fw-bold fs-6 text-success">{{ b.total_capacity }}</span>
</div>
</div>
</div>
</div>
<div class="card-footer bg-transparent border-0 py-3 d-flex justify-content-between align-items-center">
<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>{% trans %}Ver Distribución de Aulas{% endtrans %}
</a>
{% if current_user.has_permission('classrooms', 'read_write') %}
<div class="btn-group">
<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"></i>
</a>
<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 %}{% trans %}Desactivar{% endtrans %}{% else %}{% trans %}Activar{% endtrans %}{% endif %}">
<i class="bi bi-power"></i>
</button>
</form>
</div>
{% endif %}
</div>
</div>
</div>
{% else %}
<div class="col-12">
<div class="card border-0 shadow-sm p-5 text-center">
<i class="bi bi-buildings text-muted fs-1 mb-3"></i>
<h5 class="fw-bold">{% trans %}No se encontraron edificios{% endtrans %}</h5>
<p class="text-muted mb-3">{% trans %}No hay edificios que coincidan con los criterios de búsqueda.{% endtrans %}</p>
{% if current_user.has_permission('classrooms', 'read_write') %}
<div>
<a href="{{ url_for('buildings.add_building') }}" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i>{% trans %}Crear Nuevo Edificio{% endtrans %}
</a>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endblock %}
+177
View File
@@ -0,0 +1,177 @@
{% extends "base.html" %}
{% block title %}{{ building.name }} - {% trans %}Detalle del Edificio{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<!-- Breadcrumb & Header -->
<div class="row mb-4 align-items-center">
<div class="col-md-7">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1 small">
<li class="breadcrumb-item"><a href="{{ url_for('buildings.list_buildings') }}">{% trans %}Edificios{% endtrans %}</a></li>
<li class="breadcrumb-item active">{{ building.name }}</li>
</ol>
</nav>
<h1 class="h3 mb-0 d-flex align-items-center flex-wrap gap-2">
<i class="bi bi-building text-primary"></i> {{ building.name }}
<span class="badge bg-secondary-subtle text-secondary font-monospace border fs-6">{{ building.code or 'EDIF' }}</span>
{% if building.is_active %}
<span class="badge bg-success-subtle text-success border border-success-subtle fs-6">
<i class="bi bi-check-circle me-1"></i>{% trans %}Activo{% endtrans %}
</span>
{% else %}
<span class="badge bg-danger-subtle text-danger border border-danger-subtle fs-6">
<i class="bi bi-x-circle me-1"></i>{% trans %}Inactivo{% endtrans %}
</span>
{% endif %}
</h1>
</div>
<div class="col-md-5 text-md-end mt-3 mt-md-0">
{% if current_user.has_permission('classrooms', 'read_write') %}
<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>{% trans %}Editar Edificio{% endtrans %}
</a>
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i>{% trans %}Nueva Aula{% endtrans %}
</a>
{% endif %}
</div>
</div>
<!-- Overview Cards -->
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body p-3">
<div class="text-muted small fw-semibold">{% trans %}Ubicación / Dirección{% endtrans %}</div>
<p class="h6 fw-bold mb-2">
<i class="bi bi-geo-alt text-danger me-1"></i>{{ building.address or 'Sin dirección especificada' }}
</p>
{% if building.description %}
<small class="text-secondary">{{ building.description }}</small>
{% endif %}
</div>
</div>
</div>
<div class="col-md-8">
<div class="row g-3">
<div class="col-sm-4">
<div class="card border-0 shadow-sm text-center p-3">
<small class="text-muted text-uppercase fw-bold">{% trans %}Pisos{% endtrans %}</small>
<span class="display-6 fw-bold text-primary">{{ building.floors }}</span>
</div>
</div>
<div class="col-sm-4">
<div class="card border-0 shadow-sm text-center p-3">
<small class="text-muted text-uppercase fw-bold">{% trans %}Aulas Asociadas{% endtrans %}</small>
<span class="display-6 fw-bold text-success">{{ classrooms|length }}</span>
</div>
</div>
<div class="col-sm-4">
<div class="card border-0 shadow-sm text-center p-3">
<small class="text-muted text-uppercase fw-bold">{% trans %}Capacidad Total{% endtrans %}</small>
<span class="display-6 fw-bold text-info">{{ building.total_capacity }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Classrooms by Floor -->
<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">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-layers text-primary me-2"></i>{% trans %}Distribución de Aulas por Piso{% endtrans %}
</h5>
<span class="badge badge-brand">{{ classrooms|length }} {% trans %}aulas configuradas{% endtrans %}</span>
</div>
<div class="card-body p-4">
{% if classrooms_by_floor %}
{% for floor_num in classrooms_by_floor|sort %}
<div class="mb-4">
<div class="d-flex align-items-center mb-3">
<span class="badge bg-dark bg-opacity-75 fs-6 px-3 py-2 me-2">
<i class="bi bi-layer-forward me-1"></i>
{% if floor_num == 0 %}Planta Baja{% else %}Piso {{ floor_num }}{% endif %}
</span>
<div class="border-bottom flex-grow-1"></div>
<small class="text-muted ms-2">{{ classrooms_by_floor[floor_num]|length }} aulas</small>
</div>
<div class="row g-3">
{% for c in classrooms_by_floor[floor_num] %}
<div class="col-md-6 col-xl-4">
<div class="card border border-light-subtle shadow-sm h-100">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-start mb-2">
<h6 class="fw-bold mb-0">
<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 }}
</a>
</h6>
{% if c.is_active %}
<span class="badge bg-success-subtle text-success border border-success-subtle small">Activa</span>
{% else %}
<span class="badge bg-danger-subtle text-danger border border-danger-subtle small">Inactiva</span>
{% endif %}
</div>
<div class="small text-muted mb-2">
<i class="bi bi-people me-1"></i>Capacidad: <strong>{{ c.capacity }}</strong> estudiantes
</div>
{% if c.description %}
<p class="small text-secondary mb-2 text-truncate">{{ c.description }}</p>
{% endif %}
<div class="d-flex justify-content-between align-items-center pt-2 border-top">
<a href="{{ url_for('classrooms.view_classroom', id=c.id) }}" class="btn btn-outline-primary btn-sm py-0 px-2">
<small>{% trans %}Ver Ficha{% endtrans %}</small>
</a>
{% if current_user.has_permission('classrooms', 'read_write') %}
<a href="{{ url_for('classrooms.edit_classroom', id=c.id) }}" class="btn btn-outline-secondary btn-sm py-0 px-2">
<small>{% trans %}Editar{% endtrans %}</small>
</a>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
{% else %}
<div class="text-center py-5">
<i class="bi bi-door-closed text-muted fs-1 mb-2"></i>
<h6 class="fw-bold">{% trans %}No hay aulas asignadas a este edificio{% endtrans %}</h6>
<p class="text-muted small mb-3">{% trans %}Puedes crear aulas nuevas o editar las existentes para vincularlas aquí.{% endtrans %}</p>
{% if current_user.has_permission('classrooms', 'read_write') %}
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle me-1"></i>{% trans %}Agregar Primera Aula{% endtrans %}
</a>
{% endif %}
</div>
{% endif %}
</div>
</div>
<!-- Danger Zone (Delete) -->
{% if current_user.has_permission('classrooms', 'read_write') %}
<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>
<h6 class="text-danger fw-bold mb-1">{% trans %}Eliminar Edificio{% endtrans %}</h6>
<small class="text-muted">{% trans %}Solo es posible eliminar edificios que no tengan aulas asignadas.{% endtrans %}</small>
</div>
<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 %}>
<i class="bi bi-trash me-1"></i>{% trans %}Eliminar Edificio{% endtrans %}
</button>
</form>
</div>
</div>
{% endif %}
</div>
{% endblock %}
+170 -112
View File
@@ -1,17 +1,25 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Add Classroom - Edu-Space Admin{% endblock %} {% block title %}{% trans %}Add Classroom{% 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">
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<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"> <div>
<i class="bi bi-plus-circle text-primary"></i> Add Classroom <nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1 small">
<li class="breadcrumb-item"><a href="{{ url_for('classrooms.list_classrooms') }}">{% trans %}Classrooms{% endtrans %}</a></li>
<li class="breadcrumb-item active">{% trans %}Add Classroom{% endtrans %}</li>
</ol>
</nav>
<h1 class="h3 mb-0">
<i class="bi bi-plus-circle text-primary me-2"></i>{% trans %}Add Classroom{% endtrans %}
</h1> </h1>
</div>
<a href="{{ url_for('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"></i> Back to Classrooms <i class="bi bi-arrow-left me-1"></i>{% trans %}Back to Classrooms{% endtrans %}
</a> </a>
</div> </div>
</div> </div>
@@ -20,51 +28,84 @@
<div class="row"> <div class="row">
<div class="col-lg-8 mx-auto"> <div class="col-lg-8 mx-auto">
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0"> <div class="card-header bg-body-tertiary border-0 py-3">
<h5 class="mb-0"> <h5 class="mb-0 h6 fw-bold">
<i class="bi bi-door-open text-primary"></i> Classroom Information <i class="bi bi-door-open text-primary me-2"></i>{% trans %}Classroom Information{% endtrans %}
</h5> </h5>
</div> </div>
<div class="card-body"> <div class="card-body p-4">
<form method="POST" id="classroom-form" novalidate> <form method="POST" id="classroom-form" novalidate>
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
<div class="row"> <div class="row g-3 mb-3">
<div class="col-md-4 mb-3"> <!-- Selector de Edificio / Sede -->
{{ form.building.label(class="form-label") }} <div class="col-md-6">
{{ form.building(class="form-control", placeholder="A") }} <label for="building_id" class="form-label fw-semibold">
{% if form.building.errors %} <i class="bi bi-buildings text-primary me-1"></i>{% trans %}Edificio / Sede{% endtrans %} <span class="text-danger">*</span>
</label>
{{ form.building_id(class="form-select", id="building_id") }}
{% if form.building_id.errors %}
<div class="text-danger small mt-1"> <div class="text-danger small mt-1">
{{ form.building.errors[0] }} {{ form.building_id.errors[0] }}
</div> </div>
{% endif %} {% endif %}
<!-- Input emergente si selecciona "+ Crear nuevo edificio" -->
<div id="newBuildingContainer" class="mt-2 p-2 rounded bg-body-tertiary border border-success-subtle" style="display: none;">
<label for="new_building_name" class="form-label small fw-bold text-success mb-1">
<i class="bi bi-plus-circle me-1"></i>{% trans %}Nombre del Nuevo Edificio / Sede{% endtrans %}:
</label>
{{ form.new_building_name(class="form-control form-control-sm", id="new_building_name", placeholder="ej. Anexo Belgrano, Pabellón III") }}
<div class="form-text small text-muted">{% trans %}Se registrará automáticamente en el catálogo de sedes.{% endtrans %}</div>
</div>
</div> </div>
<div class="col-md-4 mb-3"> <!-- Selector / Input de Piso con sugerencias -->
{{ form.room_number.label(class="form-label") }} <div class="col-md-3">
{{ form.room_number(class="form-control", placeholder="101") }} <label for="floor" class="form-label fw-semibold">
{% if form.room_number.errors %} <i class="bi bi-layers text-secondary me-1"></i>{% trans %}Piso / Nivel{% endtrans %} <span class="text-danger">*</span>
<div class="text-danger small mt-1"> </label>
{{ form.room_number.errors[0] }} {{ form.floor(class="form-control", id="floor", list="floorSuggestions", placeholder="ej. PB, 1, 2, 4°P") }}
</div> <datalist id="floorSuggestions">
{% endif %} <!-- Poblado dinámicamente por JS según el edificio -->
</div> </datalist>
<div class="form-text small text-muted">{% trans %}Sugerido o nuevo{% endtrans %}</div>
<div class="col-md-4 mb-3">
{{ form.floor.label(class="form-label") }}
{{ form.floor(class="form-control") }}
{% if form.floor.errors %} {% if form.floor.errors %}
<div class="text-danger small mt-1"> <div class="text-danger small mt-1">
{{ form.floor.errors[0] }} {{ form.floor.errors[0] }}
</div> </div>
{% endif %} {% endif %}
</div> </div>
<!-- Número / Nombre de Aula -->
<div class="col-md-3">
<label for="room_number" class="form-label fw-semibold">
<i class="bi bi-door-open text-info me-1"></i>{% trans %}Aula / Sala{% endtrans %} <span class="text-danger">*</span>
</label>
{{ form.room_number(class="form-control", id="room_number", placeholder="ej. Aula 304, Lab 1") }}
{% if form.room_number.errors %}
<div class="text-danger small mt-1">
{{ form.room_number.errors[0] }}
</div>
{% endif %}
</div>
</div> </div>
<div class="row"> <div class="row g-3 mb-3">
<div class="col-md-6 mb-3"> <div class="col-md-6">
{{ form.capacity.label(class="form-label") }} <div class="d-flex justify-content-between align-items-center mb-1">
{{ form.capacity(class="form-control", placeholder="30") }} <label for="capacity" class="form-label fw-semibold mb-0">{% trans %}Capacidad de Alumnos{% endtrans %} <span class="text-danger">*</span></label>
<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. 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 sin límite de aforo, ingrese <strong>∞</strong> o <strong>0</strong>.{% endtrans %}
</div>
{% if form.capacity.errors %} {% if form.capacity.errors %}
<div class="text-danger small mt-1"> <div class="text-danger small mt-1">
{{ form.capacity.errors[0] }} {{ form.capacity.errors[0] }}
@@ -72,17 +113,17 @@
{% endif %} {% endif %}
</div> </div>
<div class="col-md-6 mb-3"> <div class="col-md-6 d-flex align-items-center pt-md-3">
{{ form.is_active.label(class="form-check-label") }} <div class="form-check form-switch pt-2">
<div class="form-check"> {{ form.is_active(class="form-check-input", id="is_active") }}
{{ form.is_active(class="form-check-input") }} <label class="form-check-label fw-semibold" for="is_active">{% trans %}Aula Habilitada para Reservas{% endtrans %}</label>
</div> </div>
</div> </div>
</div> </div>
<div class="mb-4"> <div class="mb-4">
{{ form.description.label(class="form-label") }} {{ form.description.label(class="form-label fw-semibold") }}
{{ form.description(class="form-control", rows="3", placeholder="Optional: Add any additional details about this classroom...") }} {{ form.description(class="form-control", rows="3", placeholder=_("Opcional: Detalles adicionales de equipamiento, tomas de corriente, proyector...")) }}
{% if form.description.errors %} {% if form.description.errors %}
<div class="text-danger small mt-1"> <div class="text-danger small mt-1">
{{ form.description.errors[0] }} {{ form.description.errors[0] }}
@@ -90,109 +131,126 @@
{% endif %} {% endif %}
</div> </div>
<!-- Preview Section --> <!-- Preview Card -->
<div class="alert alert-info"> <div class="card bg-body-tertiary border-0 shadow-xs mb-4">
<h6 class="alert-heading"> <div class="card-body py-3 px-4">
<i class="bi bi-info-circle"></i> Preview <h6 class="card-subtitle mb-2 text-muted small fw-bold">
<i class="bi bi-eye me-1"></i>{% trans %}Vista Previa de Ubicación{% endtrans %}
</h6> </h6>
<div id="classroom-preview"> <div class="row g-2">
<p class="mb-0">Fill in the form to see a preview of the classroom code: <div class="col-md-4">
<span class="fw-bold" id="preview-code">-</span> <small class="text-muted d-block">🏢 {% trans %}Edificio{% endtrans %}</small>
</p> <strong id="preview-building" class="text-primary">-</strong>
<p class="mb-0">Full location: </div>
<span class="fw-bold" id="preview-location">-</span> <div class="col-md-4">
</p> <small class="text-muted d-block">🪜 {% trans %}Piso{% endtrans %}</small>
<strong id="preview-floor" class="text-secondary">-</strong>
</div>
<div class="col-md-4">
<small class="text-muted d-block">🚪 {% trans %}Aula / Código{% endtrans %}</small>
<strong id="preview-room" class="text-success">-</strong>
</div>
</div>
</div> </div>
</div> </div>
<!-- Buttons -->
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary"> <a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle"></i> Cancel {% trans %}Cancelar{% endtrans %}
</a> </a>
{{ form.submit(class="btn btn-primary") }} <button type="submit" class="btn btn-primary px-4">
<i class="bi bi-check-circle me-1"></i>{% trans %}Crear Aula{% endtrans %}
</button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Resources Section -->
<div class="row mt-4">
<div class="col-lg-8 mx-auto">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0">
<h5 class="mb-0">
<i class="bi bi-tools text-warning"></i> Resources (Optional)
</h5>
</div>
<div class="card-body">
<p class="text-muted">You can add classroom resources after creating the classroom.</p>
</div>
</div>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script> <script>
$(document).ready(function() { document.addEventListener('DOMContentLoaded', function() {
// Map of existing floors per building
var buildingFloors = {{ building_floors|tojson }};
var buildingSelect = document.getElementById('building_id');
var newBuildingContainer = document.getElementById('newBuildingContainer');
var newBuildingNameInput = document.getElementById('new_building_name');
var floorInput = document.getElementById('floor');
var floorDatalist = document.getElementById('floorSuggestions');
var roomInput = document.getElementById('room_number');
var previewBuilding = document.getElementById('preview-building');
var previewFloor = document.getElementById('preview-floor');
var previewRoom = document.getElementById('preview-room');
function updateFloorsDatalist(bId) {
if (!floorDatalist) return;
floorDatalist.innerHTML = '';
var floors = buildingFloors[bId] || [];
floors.forEach(function(f) {
var opt = document.createElement('option');
opt.value = f;
opt.textContent = (f === '0' || f === 'PB') ? 'Planta Baja' : 'Piso ' + f;
floorDatalist.appendChild(opt);
});
}
function updatePreview() { function updatePreview() {
const building = $('#building').val(); var bText = '-';
const roomNumber = $('#room_number').val(); if (buildingSelect) {
const floor = $('#floor').val(); if (buildingSelect.value === '__new__') {
bText = newBuildingNameInput.value.trim() || '{{ _("Nuevo Edificio") }}';
} else if (buildingSelect.selectedIndex >= 0) {
bText = buildingSelect.options[buildingSelect.selectedIndex].text;
}
}
var fText = floorInput ? (floorInput.value.trim() || '-') : '-';
var rText = roomInput ? (roomInput.value.trim() || '-') : '-';
if (building && roomNumber && floor) { if (previewBuilding) previewBuilding.textContent = bText;
$('#preview-code').text(`${building}-${roomNumber}`); if (previewFloor) previewFloor.textContent = (fText === '0' || fText.toUpperCase() === 'PB') ? 'Planta Baja' : fText;
$('#preview-location').text(`${building} - Floor ${floor}`); if (previewRoom) previewRoom.textContent = rText;
}
if (buildingSelect) {
buildingSelect.addEventListener('change', function() {
if (this.value === '__new__') {
newBuildingContainer.style.display = 'block';
if (newBuildingNameInput) newBuildingNameInput.focus();
} else { } else {
$('#preview-code').text('-'); newBuildingContainer.style.display = 'none';
$('#preview-location').text('-'); updateFloorsDatalist(this.value);
}
updatePreview();
});
// Initial call
if (buildingSelect.value === '__new__') {
newBuildingContainer.style.display = 'block';
} else if (buildingSelect.value) {
updateFloorsDatalist(buildingSelect.value);
} }
} }
// Update preview when inputs change if (newBuildingNameInput) newBuildingNameInput.addEventListener('input', updatePreview);
$('#building, #room_number, #floor').on('input', updatePreview); if (floorInput) floorInput.addEventListener('input', updatePreview);
if (roomInput) roomInput.addEventListener('input', updatePreview);
// Initialize preview
updatePreview(); updatePreview();
// Form validation // Infinity button
$('#classroom-form').on('submit', function(e) { var btnInf = document.getElementById('btnInfinityAdd');
let isValid = true; var capInput = document.getElementById('capacity');
if (btnInf && capInput) {
// Required fields validation btnInf.addEventListener('click', function() {
if (!$('#building').val().trim()) { capInput.value = '∞';
$('#building').addClass('is-invalid'); capInput.focus();
isValid = false;
} else {
$('#building').removeClass('is-invalid');
}
if (!$('#room_number').val().trim()) {
$('#room_number').addClass('is-invalid');
isValid = false;
} else {
$('#room_number').removeClass('is-invalid');
}
if (!$('#floor').val()) {
$('#floor').addClass('is-invalid');
isValid = false;
} else {
$('#floor').removeClass('is-invalid');
}
if (!$('#capacity').val() || $('#capacity').val() < 1) {
$('#capacity').addClass('is-invalid');
isValid = false;
} else {
$('#capacity').removeClass('is-invalid');
}
return isValid;
}); });
}
}); });
</script> </script>
{% endblock %} {% endblock %}
+247
View File
@@ -0,0 +1,247 @@
{% extends "base.html" %}
{% block title %}{% trans %}Edit Classroom{% endtrans %} - {{ classroom.code_display }}{% 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">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1 small">
<li class="breadcrumb-item"><a href="{{ url_for('classrooms.list_classrooms') }}">{% trans %}Classrooms{% endtrans %}</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}">{{ classroom.code_display }}</a></li>
<li class="breadcrumb-item active">{% trans %}Edit{% endtrans %}</li>
</ol>
</nav>
<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.location_display }}</p>
</div>
<div class="col-md-5 text-md-end mt-3 mt-md-0">
<a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}" class="btn btn-outline-secondary me-2">
<i class="bi bi-eye me-1"></i>{% trans %}Ver Detalle{% endtrans %}
</a>
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>{% trans %}Volver al Listado{% endtrans %}
</a>
</div>
</div>
<div class="row justify-content-center">
<div class="col-lg-8">
<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 mb-3">
<!-- Edificio -->
<div class="col-md-6">
<label for="building_id" class="form-label fw-semibold">
<i class="bi bi-buildings text-primary me-1"></i>{% trans %}Edificio / Sede{% endtrans %} <span class="text-danger">*</span>
</label>
{{ form.building_id(class="form-select", id="building_id") }}
{% if form.building_id.errors %}
<div class="text-danger small mt-1">{{ form.building_id.errors[0] }}</div>
{% endif %}
<div id="newBuildingContainer" class="mt-2 p-2 rounded bg-body-tertiary border border-success-subtle" style="display: none;">
<label for="new_building_name" class="form-label small fw-bold text-success mb-1">
<i class="bi bi-plus-circle me-1"></i>{% trans %}Nombre del Nuevo Edificio{% endtrans %}:
</label>
{{ form.new_building_name(class="form-control form-control-sm", id="new_building_name", placeholder="ej. Pabellón III") }}
</div>
</div>
<!-- Piso -->
<div class="col-md-3">
<label for="floor" class="form-label fw-semibold">
<i class="bi bi-layers text-secondary me-1"></i>{% trans %}Piso / Nivel{% endtrans %} <span class="text-danger">*</span>
</label>
{{ form.floor(class="form-control", id="floor", list="floorSuggestions", placeholder="ej. PB, 1, 2, 4°P") }}
<datalist id="floorSuggestions">
<!-- sugerencias dinámicas -->
</datalist>
<div class="form-text small text-muted">{% trans %}Sugerido o nuevo{% endtrans %}</div>
{% if form.floor.errors %}
<div class="text-danger small mt-1">{{ form.floor.errors[0] }}</div>
{% endif %}
</div>
<!-- Aula -->
<div class="col-md-3">
<label for="room_number" class="form-label fw-semibold">
<i class="bi bi-door-open text-info me-1"></i>{% trans %}Aula / Sala{% endtrans %} <span class="text-danger">*</span>
</label>
{{ form.room_number(class="form-control", id="room_number", placeholder="ej. Aula 304, Lab 1") }}
{% if form.room_number.errors %}
<div class="text-danger small mt-1">{{ form.room_number.errors[0] }}</div>
{% endif %}
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<div class="d-flex justify-content-between align-items-center mb-1">
<label for="capacityInput" class="form-label fw-semibold mb-0">{% trans %}Capacidad de Alumnos{% endtrans %} <span class="text-danger">*</span></label>
<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>.{% 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-3">
<div class="form-check form-switch pt-2">
{{ form.is_active(class="form-check-input", id="is_active") }}
<label class="form-check-label fw-semibold" for="is_active">
{% trans %}Aula Habilitada para Reservas{% endtrans %}
</label>
</div>
</div>
</div>
<div class="mb-4">
{{ form.description.label(class="form-label fw-semibold") }}
{{ form.description(class="form-control" + (" is-invalid" if form.description.errors else ""), rows="3", placeholder=_("Opcional: Detalles adicionales del equipamiento...")) }}
{% if form.description.errors %}
<div class="invalid-feedback">{{ form.description.errors[0] }}</div>
{% endif %}
</div>
<!-- Preview Card -->
<div class="card bg-body-tertiary border-0 shadow-xs mb-4">
<div class="card-body py-3 px-4">
<h6 class="card-subtitle mb-2 text-muted small fw-bold">
<i class="bi bi-eye me-1"></i>{% trans %}Vista Previa de Ubicación{% endtrans %}
</h6>
<div class="row g-2">
<div class="col-md-4">
<small class="text-muted d-block">🏢 {% trans %}Edificio{% endtrans %}</small>
<strong id="preview-building" class="text-primary">{{ classroom.building_name }}</strong>
</div>
<div class="col-md-4">
<small class="text-muted d-block">🪜 {% trans %}Piso{% endtrans %}</small>
<strong id="preview-floor" class="text-secondary">{{ classroom.floor_display }}</strong>
</div>
<div class="col-md-4">
<small class="text-muted d-block">🚪 {% trans %}Aula / Código{% endtrans %}</small>
<strong id="preview-room" class="text-success">{{ classroom.code }}</strong>
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-between">
<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 buildingFloors = {{ building_floors|tojson }};
var buildingSelect = document.getElementById('building_id');
var newBuildingContainer = document.getElementById('newBuildingContainer');
var newBuildingNameInput = document.getElementById('new_building_name');
var floorInput = document.getElementById('floor');
var floorDatalist = document.getElementById('floorSuggestions');
var roomInput = document.getElementById('room_number');
var previewBuilding = document.getElementById('preview-building');
var previewFloor = document.getElementById('preview-floor');
var previewRoom = document.getElementById('preview-room');
function updateFloorsDatalist(bId) {
if (!floorDatalist) return;
floorDatalist.innerHTML = '';
var floors = buildingFloors[bId] || [];
floors.forEach(function(f) {
var opt = document.createElement('option');
opt.value = f;
opt.textContent = (f === '0' || f === 'PB') ? 'Planta Baja' : 'Piso ' + f;
floorDatalist.appendChild(opt);
});
}
function updatePreview() {
var bText = '-';
if (buildingSelect) {
if (buildingSelect.value === '__new__') {
bText = newBuildingNameInput.value.trim() || '{{ _("Nuevo Edificio") }}';
} else if (buildingSelect.selectedIndex >= 0) {
bText = buildingSelect.options[buildingSelect.selectedIndex].text;
}
}
var fText = floorInput ? (floorInput.value.trim() || '-') : '-';
var rText = roomInput ? (roomInput.value.trim() || '-') : '-';
if (previewBuilding) previewBuilding.textContent = bText;
if (previewFloor) previewFloor.textContent = (fText === '0' || fText.toUpperCase() === 'PB') ? 'Planta Baja' : fText;
if (previewRoom) previewRoom.textContent = rText;
}
if (buildingSelect) {
buildingSelect.addEventListener('change', function() {
if (this.value === '__new__') {
newBuildingContainer.style.display = 'block';
if (newBuildingNameInput) newBuildingNameInput.focus();
} else {
newBuildingContainer.style.display = 'none';
updateFloorsDatalist(this.value);
}
updatePreview();
});
if (buildingSelect.value === '__new__') {
newBuildingContainer.style.display = 'block';
} else if (buildingSelect.value) {
updateFloorsDatalist(buildingSelect.value);
}
}
if (newBuildingNameInput) newBuildingNameInput.addEventListener('input', updatePreview);
if (floorInput) floorInput.addEventListener('input', updatePreview);
if (roomInput) roomInput.addEventListener('input', updatePreview);
updatePreview();
var btnInf = document.getElementById('btnInfinity');
var capInput = document.getElementById('capacityInput');
if (btnInf && capInput) {
btnInf.addEventListener('click', function() {
capInput.value = '∞';
capInput.focus();
});
}
});
</script>
{% endblock %}
+739 -90
View File
@@ -1,50 +1,250 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Classrooms - Edu-Space Admin{% endblock %} {% block title %}{% trans %}Aulas y Espacios{% endtrans %} - Edu-Space Admin{% endblock %}
{% block extra_css %}
<style>
.custom-space-pills .nav-link {
font-weight: 600;
font-size: 0.9rem;
padding: 0.5rem 1.1rem;
border-radius: 0.5rem;
color: var(--bs-body-color);
background-color: var(--bs-tertiary-bg);
border: 1px solid var(--bs-border-color);
transition: all 0.15s ease;
}
.custom-space-pills .nav-link.active {
background-color: var(--bs-primary);
color: #fff !important;
border-color: var(--bs-primary);
box-shadow: 0 4px 12px rgba(180, 62, 142, 0.25);
}
.custom-space-pills .nav-link:hover:not(.active) {
background-color: var(--bs-secondary-bg);
transform: translateY(-1px);
}
.classroom-card, .virtual-room-card {
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
border-radius: 0.65rem;
}
.classroom-card:hover, .virtual-room-card:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.08) !important;
}
.virtual-banner {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.08) 0%, rgba(180, 62, 142, 0.08) 100%);
border: 1px solid rgba(99, 102, 241, 0.25) !important;
border-radius: 0.75rem;
}
[data-bs-theme="dark"] .virtual-banner {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.16) 0%, rgba(180, 62, 142, 0.16) 100%);
border: 1px solid rgba(99, 102, 241, 0.35) !important;
}
.virtual-stat-box {
background-color: var(--bs-body-bg);
border: 1px solid var(--bs-border-color);
border-radius: 0.5rem;
padding: 0.6rem 0.85rem;
transition: transform 0.15s ease, border-color 0.15s ease;
text-decoration: none !important;
display: block;
}
.virtual-stat-box:hover {
transform: translateY(-2px);
border-color: var(--bs-primary) !important;
}
.metric-pill {
padding: 0.35rem 0.2rem;
border-radius: 0.45rem;
transition: all 0.15s ease;
cursor: pointer;
line-height: 1.2;
}
.metric-pill:hover {
transform: translateY(-1px);
filter: brightness(1.06);
}
.metric-label {
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
margin-bottom: 2px;
}
.metric-val {
font-size: 0.85rem;
font-weight: 700;
font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
/* Semantics light */
.metric-high {
background-color: #FEE2E2;
border-color: #FCA5A5 !important;
color: #991B1B !important;
}
.metric-medium {
background-color: #FEF3C7;
border-color: #FCD34D !important;
color: #92400E !important;
}
.metric-low {
background-color: #D1FAE5;
border-color: #6EE7B7 !important;
color: #065F46 !important;
}
.metric-zero {
background-color: rgba(148, 163, 184, 0.1);
border-color: rgba(148, 163, 184, 0.25) !important;
color: #64748B !important;
}
/* Semantics dark */
[data-bs-theme="dark"] .metric-high {
background-color: rgba(239, 68, 68, 0.18);
border-color: rgba(239, 68, 68, 0.4) !important;
color: #FCA5A5 !important;
}
[data-bs-theme="dark"] .metric-medium {
background-color: rgba(245, 158, 11, 0.18);
border-color: rgba(245, 158, 11, 0.4) !important;
color: #FCD34D !important;
}
[data-bs-theme="dark"] .metric-low {
background-color: rgba(16, 185, 129, 0.18);
border-color: rgba(16, 185, 129, 0.4) !important;
color: #6EE7B7 !important;
}
[data-bs-theme="dark"] .metric-zero {
background-color: rgba(148, 163, 184, 0.08);
border-color: rgba(148, 163, 184, 0.18) !important;
color: #94A3B8 !important;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid pt-5 mt-4"> <div class="container-fluid pt-5 mt-4">
<div class="row">
<!-- 1. Encabezado y Selector de Entorno (Presencial vs Virtual) -->
<div class="row mb-3">
<div class="col-12"> <div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
<h1 class="h3">
<i class="bi bi-door-open text-primary"></i> Classrooms
</h1>
<div> <div>
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary"> <h1 class="h3 mb-1">
<i class="bi bi-plus-circle"></i> Add Classroom <i class="bi bi-door-open text-primary me-2"></i>{% trans %}Aulas y Espacios de Aprendizaje{% endtrans %}
</a> </h1>
<p class="text-muted small mb-0">{% trans %}Administre las aulas físicas de sedes presenciales y las aulas virtuales a demanda del Campus Virtual.{% endtrans %}</p>
</div> </div>
<div>
{% if view_mode == 'virtual' %}
<a href="{{ url_for('schedule.add_reservation') }}?is_virtual=1" class="btn btn-success shadow-sm">
<i class="bi bi-camera-video me-1"></i>{% trans %}Nueva Clase Virtual a Demanda{% endtrans %}
</a>
{% else %}
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary shadow-sm">
<i class="bi bi-plus-circle me-1"></i>{% trans %}Agregar Aula Física{% endtrans %}
</a>
{% endif %}
</div>
</div>
<!-- Pestañas de Selección de Modalidad -->
<div class="mt-3">
<ul class="nav nav-pills custom-space-pills gap-2">
<li class="nav-item">
<a class="nav-link {% if view_mode == 'physical' %}active{% endif %}"
href="{{ url_for('classrooms.list_classrooms', view_mode='physical', active_only='true' if active_only else 'false') }}">
<i class="bi bi-buildings me-2"></i>{% trans %}Aulas Presenciales{% endtrans %}
<span class="badge rounded-pill ms-2 {% if view_mode == 'physical' %}bg-light text-primary{% else %}bg-secondary-subtle text-secondary border{% endif %}">
{{ physical_count }} {% trans %}aulas{% endtrans %}
</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if view_mode == 'virtual' %}active{% endif %}"
href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}">
<i class="bi bi-globe2 me-2 text-info"></i>{% trans %}Campus Virtual & Aulas a Demanda{% endtrans %}
<span class="badge rounded-pill ms-2 {% if view_mode == 'virtual' %}bg-light text-primary{% else %}bg-primary-subtle text-primary border{% endif %}">
<i class="bi bi-infinity me-1"></i>{{ virtual_count }} {% trans %}salas activas{% endtrans %}
</span>
</a>
</li>
</ul>
</div> </div>
</div> </div>
</div> </div>
<!-- Filters --> <!-- ========================================================================= -->
<div class="row"> <!-- VISTA 1: AULAS FÍSICAS (PRESENCIALES) -->
<!-- ========================================================================= -->
{% if view_mode == 'physical' %}
<!-- Filtros de Aulas Físicas -->
<div class="row mb-3">
<div class="col-12"> <div class="col-12">
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-body"> <div class="card-body py-2">
<form method="GET" class="row g-3"> <form method="GET" class="row g-2 align-items-center">
<div class="col-md-4"> <input type="hidden" name="view_mode" value="physical">
<div class="col-md-3">
<div class="input-group input-group-sm">
<span class="input-group-text bg-body-tertiary"><i class="bi bi-search"></i></span>
<input type="text" name="search" class="form-control" <input type="text" name="search" class="form-control"
placeholder="Search by building, room, description..." placeholder="{% trans %}Buscar aula, descripción...{% endtrans %}"
value="{{ search }}"> value="{{ search }}">
</div> </div>
</div>
<!-- Filtro por Edificio Físico -->
<div class="col-md-3"> <div class="col-md-3">
<div class="form-check"> <div class="input-group input-group-sm">
<input class="form-check-input" type="checkbox" name="active_only" <span class="input-group-text bg-body-tertiary"><i class="bi bi-buildings text-primary"></i></span>
id="activeOnly" {% if active_only %}checked{% endif %}> <select name="building_id" class="form-select form-select-sm" onchange="this.form.submit()">
<label class="form-check-label" for="activeOnly"> <option value="">{% trans %}Todos los edificios presenciales{% endtrans %}</option>
Show active classrooms only {% for b in buildings %}
<option value="{{ b.id }}" {% if building_filter == (b.id|string) %}selected{% endif %}>{{ b.name }}</option>
{% endfor %}
</select>
</div>
</div>
<!-- Filtro por Piso Real -->
<div class="col-md-2">
<div class="input-group input-group-sm">
<span class="input-group-text bg-body-tertiary"><i class="bi bi-layers text-secondary"></i></span>
<select name="floor" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">{% trans %}Todos los pisos{% endtrans %}</option>
{% for f in floors %}
<option value="{{ f }}" {% if floor_filter == (f|string) %}selected{% endif %}>
{% if f == '0' or f == 'PB' %}{% trans %}Planta Baja{% endtrans %}{% else %}{% trans %}Piso{% endtrans %} {{ f }}{% endif %}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="col-md-2">
<div class="form-check pt-1">
<input class="form-check-input" type="checkbox" name="active_only" value="true"
id="activeOnly" {% if active_only %}checked{% endif %} onchange="this.form.submit()">
<label class="form-check-label small" for="activeOnly">
{% trans %}Solo activas{% endtrans %}
</label> </label>
</div> </div>
</div> </div>
<div class="col-md-3">
<button type="submit" class="btn btn-outline-primary"> <div class="col-md-2 text-md-end">
<i class="bi bi-search"></i> Search <button type="submit" class="btn btn-sm btn-primary me-1">
<i class="bi bi-filter"></i> {% trans %}Filtrar{% endtrans %}
</button> </button>
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary"> <a href="{{ url_for('classrooms.list_classrooms', view_mode='physical') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-x-circle"></i> Clear <i class="bi bi-x-circle"></i> {% trans %}Limpiar{% endtrans %}
</a> </a>
</div> </div>
</form> </form>
@@ -53,77 +253,109 @@
</div> </div>
</div> </div>
<!-- Classrooms Grid --> <!-- Grilla de Aulas Físicas -->
{% if classrooms and classrooms.items %} {% if classrooms and classrooms.items %}
<div class="row mt-4"> <div class="row g-3 mt-1">
{% for classroom in classrooms.items %} {% for classroom in classrooms.items %}
<div class="col-lg-4 col-md-6 mb-4"> <div class="col-xxl-3 col-xl-3 col-lg-4 col-md-6 col-sm-12 mb-2">
<div class="card border-0 shadow-sm h-100"> <div class="card classroom-card border shadow-sm h-100">
<div class="card-body"> <div class="card-body p-3 d-flex flex-column justify-content-between">
<div class="d-flex justify-content-between align-items-start mb-3">
<div> <div>
<h5 class="card-title mb-1"> <!-- Encabezado con Código y Estado -->
{{ classroom.code }} <div class="d-flex justify-content-between align-items-start mb-2">
<div class="text-truncate me-2">
<h5 class="card-title mb-0 fw-bold text-primary text-truncate fs-6" title="{{ classroom.code }}">
<i class="bi bi-door-open me-1"></i>{{ classroom.code }}
</h5> </h5>
<p class="text-muted small mb-2">
<i class="bi bi-geo-alt"></i> {{ classroom.get_full_location() }}
</p>
</div> </div>
<span class="badge {% if classroom.is_active %}bg-success{% else %}bg-secondary{% endif %}"> <span class="badge rounded-pill {% if classroom.is_active %}bg-success-subtle text-success border border-success-subtle{% else %}bg-secondary-subtle text-secondary border{% endif %}" style="font-size: 0.68rem;">
{% if classroom.is_active %}Active{% else %}Inactive{% endif %} {% if classroom.is_active %}{% trans %}Activa{% endtrans %}{% else %}{% trans %}Inactiva{% endtrans %}{% endif %}
</span> </span>
</div> </div>
<div class="row small"> <!-- Ubicación: Edificio y Piso -->
<div class="col-6"> <div class="d-flex flex-wrap gap-1 mb-2">
<span class="text-muted">Capacity:</span> <span class="badge bg-primary-subtle text-primary border" style="font-size: 0.7rem;">
<strong>{{ classroom.capacity }}</strong> <i class="bi bi-buildings me-1"></i>{{ classroom.building_name }}
</div> </span>
<div class="col-6"> <span class="badge bg-secondary-subtle text-secondary border" style="font-size: 0.7rem;">
<span class="text-muted">Building:</span> <i class="bi bi-layers me-1"></i>{{ classroom.floor_display }}
<strong>{{ classroom.building }}</strong> </span>
</div>
</div> </div>
{% if classroom.description %} <!-- Capacidad y Recursos -->
<p class="card-text small text-muted mt-2"> <div class="d-flex justify-content-between align-items-center small py-1 px-2 bg-body-tertiary rounded mb-2" style="font-size: 0.75rem;">
{{ classroom.description[:100] }}{% if classroom.description|length > 100 %}...{% endif %}
</p>
{% endif %}
<!-- Resources indicator -->
<div class="mt-2">
<small class="text-muted">
<i class="bi bi-tools"></i>
{% if classroom.resources %}
{{ classroom.resources|length }} resources
{% else %}
No resources registered
{% endif %}
</small>
</div>
</div>
<div class="card-footer bg-white border-0">
<div class="d-flex justify-content-between">
<div> <div>
<span class="text-muted">{% trans %}Capacidad:{% endtrans %}</span>
<strong class="text-body ms-1">{{ classroom.capacity }} {% trans %}asientos{% endtrans %}</strong>
</div>
<div>
<span class="text-muted"><i class="bi bi-tools me-1"></i>{{ classroom.resources|length if classroom.resources else 0 }}</span>
</div>
</div>
<!-- Barra de Métricas de Ocupación FÍSICA (%) -->
{% set metrics = occupancy_metrics.get(classroom.id, {'day_pct': 0, 'day_count': 0, 'week_pct': 0, 'week_count': 0, 'month_pct': 0, 'month_count': 0}) %}
<div class="classroom-occupancy-bar p-2 rounded bg-body-tertiary border mb-2">
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="small fw-semibold text-muted" style="font-size: 0.72rem;">
<i class="bi bi-graph-up me-1 text-primary"></i>{% trans %}Ocupación{% endtrans %}
</span>
<span class="text-muted" style="font-size: 0.65rem;" title="{% trans %}Haz clic en cualquier métrica para ver las reservas de ese período{% endtrans %}">
<i class="bi bi-info-circle me-1"></i>{% trans %}Ver reservas{% endtrans %}
</span>
</div>
<div class="row g-1 text-center">
<!-- Día -->
<div class="col-4">
<a href="{{ url_for('schedule.list_reservations', classroom_id=classroom.id, date_from=date_filters.today, date_to=date_filters.today) }}"
class="d-block p-1 text-decoration-none border metric-pill metric-{% if metrics.day_pct > 80 %}high{% elif metrics.day_pct > 40 %}medium{% elif metrics.day_pct > 0 %}low{% else %}zero{% endif %}"
title="{% trans %}Ocupación Hoy{% endtrans %}: {{ metrics.day_count }} {% trans %}clases registradas{% endtrans %}">
<div class="metric-label">{% trans %}Hoy{% endtrans %}</div>
<div class="metric-val">{{ metrics.day_pct }}%</div>
</a>
</div>
<!-- Semana -->
<div class="col-4">
<a href="{{ url_for('schedule.list_reservations', classroom_id=classroom.id, date_from=date_filters.week_start, date_to=date_filters.week_end) }}"
class="d-block p-1 text-decoration-none border metric-pill metric-{% if metrics.week_pct > 80 %}high{% elif metrics.week_pct > 40 %}medium{% elif metrics.week_pct > 0 %}low{% else %}zero{% endif %}"
title="{% trans %}Ocupación Semana{% endtrans %}: {{ metrics.week_count }} {% trans %}clases registradas{% endtrans %}">
<div class="metric-label">{% trans %}Semana{% endtrans %}</div>
<div class="metric-val">{{ metrics.week_pct }}%</div>
</a>
</div>
<!-- Mes -->
<div class="col-4">
<a href="{{ url_for('schedule.list_reservations', classroom_id=classroom.id, date_from=date_filters.month_start, date_to=date_filters.month_end) }}"
class="d-block p-1 text-decoration-none border metric-pill metric-{% if metrics.month_pct > 80 %}high{% elif metrics.month_pct > 40 %}medium{% elif metrics.month_pct > 0 %}low{% else %}zero{% endif %}"
title="{% trans %}Ocupación Mes{% endtrans %}: {{ metrics.month_count }} {% trans %}clases registradas{% endtrans %}">
<div class="metric-label">{% trans %}Mes{% endtrans %}</div>
<div class="metric-val">{{ metrics.month_pct }}%</div>
</a>
</div>
</div>
</div>
</div>
<!-- Footer con Botones Compactos -->
<div class="pt-2 border-top d-flex justify-content-between align-items-center">
<div class="btn-group btn-group-sm">
<a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}" <a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}"
class="btn btn-sm btn-outline-primary"> class="btn btn-outline-primary py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}" style="font-size: 0.75rem;">
<i class="bi bi-eye"></i> View <i class="bi bi-eye me-1"></i>{% trans %}Ficha{% endtrans %}
</a> </a>
<a href="{{ url_for('classrooms.edit_classroom', id=classroom.id) }}" <a href="{{ url_for('classrooms.edit_classroom', id=classroom.id) }}"
class="btn btn-sm btn-outline-secondary"> class="btn btn-outline-secondary py-0 px-2" title="{% trans %}Editar Aula{% endtrans %}" style="font-size: 0.75rem;">
<i class="bi bi-pencil"></i> Edit <i class="bi bi-pencil"></i>
</a> </a>
</div> </div>
<form method="POST" action="{{ url_for('classrooms.toggle_classroom', id=classroom.id) }}" <form method="POST" action="{{ url_for('classrooms.toggle_classroom', id=classroom.id) }}" class="d-inline mb-0">
class="d-inline"> <button type="submit" class="btn btn-sm py-0 px-2 {% if classroom.is_active %}btn-outline-warning{% else %}btn-outline-success{% endif %}"
<button type="submit" class="btn btn-sm title="{% if classroom.is_active %}{% trans %}Desactivar{% endtrans %}{% else %}{% trans %}Activar{% endtrans %}{% endif %}" style="font-size: 0.75rem;">
{% if classroom.is_active %}btn-outline-warning{% else %}btn-outline-success{% endif %}">
{% if classroom.is_active %} {% if classroom.is_active %}
<i class="bi bi-pause-circle"></i> Deactivate <i class="bi bi-pause"></i>
{% else %} {% else %}
<i class="bi bi-play-circle"></i> Activate <i class="bi bi-play"></i>
{% endif %} {% endif %}
</button> </button>
</form> </form>
@@ -134,14 +366,14 @@
{% endfor %} {% endfor %}
</div> </div>
<!-- Pagination --> <!-- Paginación Aulas Físicas -->
{% if classrooms.pages > 1 %} {% if classrooms.pages > 1 %}
<nav aria-label="Classroom pagination"> <nav aria-label="Classroom pagination" class="mt-4">
<ul class="pagination justify-content-center"> <ul class="pagination pagination-sm justify-content-center">
{% if classrooms.has_prev %} {% if classrooms.has_prev %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=classrooms.prev_num, search=search, active_only=active_only) }}"> <a class="page-link" href="{{ url_for('classrooms.list_classrooms', view_mode='physical', page=classrooms.prev_num, search=search, active_only='true' if active_only else 'false', building_id=building_filter, floor=floor_filter) }}">
Previous {% trans %}Anterior{% endtrans %}
</a> </a>
</li> </li>
{% endif %} {% endif %}
@@ -150,7 +382,7 @@
{% if page_num %} {% if page_num %}
{% if page_num != classrooms.page %} {% if page_num != classrooms.page %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=page_num, search=search, active_only=active_only) }}"> <a class="page-link" href="{{ url_for('classrooms.list_classrooms', view_mode='physical', page=page_num, search=search, active_only='true' if active_only else 'false', building_id=building_filter, floor=floor_filter) }}">
{{ page_num }} {{ page_num }}
</a> </a>
</li> </li>
@@ -168,8 +400,8 @@
{% if classrooms.has_next %} {% if classrooms.has_next %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=classrooms.next_num, search=search, active_only=active_only) }}"> <a class="page-link" href="{{ url_for('classrooms.list_classrooms', view_mode='physical', page=classrooms.next_num, search=search, active_only='true' if active_only else 'false', building_id=building_filter, floor=floor_filter) }}">
Next {% trans %}Siguiente{% endtrans %}
</a> </a>
</li> </li>
{% endif %} {% endif %}
@@ -182,20 +414,437 @@
<div class="col-12"> <div class="col-12">
<div class="text-center py-5"> <div class="text-center py-5">
<i class="bi bi-door-open text-muted" style="font-size: 4rem;"></i> <i class="bi bi-door-open text-muted" style="font-size: 4rem;"></i>
<h4 class="mt-3 text-muted">No classrooms found</h4> <h4 class="mt-3 text-muted">{% trans %}No se encontraron aulas físicas{% endtrans %}</h4>
<p class="text-muted"> <p class="text-muted">
{% if search %} {% if search %}
No classrooms match your search criteria. {% trans %}Ningún aula coincide con los criterios de búsqueda.{% endtrans %}
{% else %} {% else %}
No classrooms have been registered yet. {% trans %}No hay aulas físicas registradas en esta sede.{% endtrans %}
{% endif %} {% endif %}
</p> </p>
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary"> <a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add First Classroom <i class="bi bi-plus-circle"></i> {% trans %}Agregar Primera Aula{% endtrans %}
</a> </a>
</div> </div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
<!-- ========================================================================= -->
<!-- VISTA 2: CAMPUS VIRTUAL (AULAS A DEMANDA · INFINITAS) -->
<!-- ========================================================================= -->
{% else %}
<!-- Banner de Infraestructura Cloud y Concurrencia Ilimitada -->
<div class="card virtual-banner p-3 mb-4 shadow-sm">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2 mb-3">
<div>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-primary text-white px-2 py-1"><i class="bi bi-cloud-check-fill me-1"></i>Cloud Infrastructure</span>
<h5 class="mb-0 fw-bold text-primary">
{% trans %}Campus Virtual UniCABA · Aulas a Demanda{% endtrans %}
</h5>
</div>
<p class="small text-muted mb-0 mt-1">
{% trans %}A diferencia de los espacios físicos, las aulas virtuales se crean y escalan de manera <strong>infinita y concurrente</strong> según la necesidad pedagógica de cada comisión.{% endtrans %}
</p>
</div>
<div class="d-flex gap-2 align-items-center">
<span class="badge bg-success-subtle text-success border border-success-subtle px-3 py-2 fs-6">
<i class="bi bi-infinity me-1"></i>{% trans %}Capacidad Ilimitada{% endtrans %}
</span>
<a href="{{ url_for('schedule.add_reservation') }}?is_virtual=1" class="btn btn-sm btn-primary">
<i class="bi bi-plus-lg me-1"></i>{% trans %}Programar Clase Virtual{% endtrans %}
</a>
</div>
</div>
<!-- Indicadores de Demanda Global -->
<div class="row g-2">
<div class="col-lg-3 col-sm-6">
<a href="{{ url_for('schedule.today_schedule') }}" class="virtual-stat-box">
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.7rem;">{% trans %}Sesiones Hoy{% endtrans %}</span>
<h4 class="mb-0 fw-bold text-primary">{{ global_virtual_metrics.today_count }}</h4>
</div>
<div class="text-primary fs-4"><i class="bi bi-calendar-event"></i></div>
</div>
<small class="text-muted d-block mt-1" style="font-size: 0.72rem;">{% trans %}Clases virtuales programadas hoy{% endtrans %}</small>
</a>
</div>
<div class="col-lg-3 col-sm-6">
<a href="{{ url_for('schedule.list_reservations', classroom_id=virtual_classroom_id, date_from=date_filters.week_start, date_to=date_filters.week_end) }}" class="virtual-stat-box">
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.7rem;">{% trans %}Sesiones Semana{% endtrans %}</span>
<h4 class="mb-0 fw-bold text-info">{{ global_virtual_metrics.week_count }}</h4>
</div>
<div class="text-info fs-4"><i class="bi bi-calendar-week"></i></div>
</div>
<small class="text-muted d-block mt-1" style="font-size: 0.72rem;">{% trans %}Clases virtuales esta semana{% endtrans %}</small>
</a>
</div>
<div class="col-lg-3 col-sm-6">
<a href="{{ url_for('schedule.list_reservations', classroom_id=virtual_classroom_id, date_from=date_filters.month_start, date_to=date_filters.month_end) }}" class="virtual-stat-box">
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.7rem;">{% trans %}Sesiones del Mes{% endtrans %}</span>
<h4 class="mb-0 fw-bold text-success">{{ global_virtual_metrics.month_count }}</h4>
</div>
<div class="text-success fs-4"><i class="bi bi-calendar3"></i></div>
</div>
<small class="text-muted d-block mt-1" style="font-size: 0.72rem;">{% trans %}Total clases remotas en el mes{% endtrans %}</small>
</a>
</div>
<div class="col-lg-3 col-sm-6">
<div class="virtual-stat-box">
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.7rem;">{% trans %}Salas Activas{% endtrans %}</span>
<h4 class="mb-0 fw-bold text-warning">{{ global_virtual_metrics.commissions_count }}</h4>
</div>
<div class="text-warning fs-4"><i class="bi bi-diagram-3"></i></div>
</div>
<small class="text-muted d-block mt-1" style="font-size: 0.72rem;">{% trans %}Comisiones con entorno virtual{% endtrans %}</small>
</div>
</div>
</div>
</div>
<!-- Barra de Búsqueda de Salas Virtuales -->
<div class="row mb-3">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-body py-2">
<form method="GET" class="row g-2 align-items-center">
<input type="hidden" name="view_mode" value="virtual">
<div class="col-md-6">
<div class="input-group input-group-sm">
<span class="input-group-text bg-body-tertiary"><i class="bi bi-search"></i></span>
<input type="text" name="search" class="form-control"
placeholder="{% trans %}Buscar sala virtual por asignatura, comisión o carrera...{% endtrans %}"
value="{{ search }}">
</div>
</div>
<div class="col-md-6 text-md-end">
<button type="submit" class="btn btn-sm btn-primary me-1">
<i class="bi bi-funnel me-1"></i>{% trans %}Buscar Salas{% endtrans %}
</button>
<a href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-counterclockwise me-1"></i>{% trans %}Limpiar{% endtrans %}
</a>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Catálogo de Aulas Virtuales por Comisión -->
{% if virtual_rooms %}
<div class="row g-3">
{% for room in virtual_rooms %}
<div class="col-xxl-3 col-xl-3 col-lg-4 col-md-6 col-sm-12 mb-2">
<div class="card virtual-room-card border shadow-sm h-100">
<div class="card-body p-3 d-flex flex-column justify-content-between">
<div>
<!-- Encabezado de la Sala Virtual -->
<div class="d-flex justify-content-between align-items-start mb-2">
<div class="text-truncate me-2">
<h5 class="card-title mb-0 fw-bold text-primary text-truncate fs-6" title="{{ room.subject_name }}">
<i class="bi bi-laptop me-1 text-info"></i>{{ room.subject_name }}
</h5>
</div>
<span class="badge rounded-pill bg-info-subtle text-info border border-info-subtle" style="font-size: 0.68rem;">
<i class="bi bi-infinity me-1"></i>A Demanda
</span>
</div>
<!-- Badges: Comisión, Carrera y Turno -->
<div class="d-flex flex-wrap gap-1 mb-2">
<span class="badge bg-primary-subtle text-primary border" style="font-size: 0.7rem;">
<i class="bi bi-diagram-3 me-1"></i>Com. {{ room.commission_code }}
</span>
{% if room.subject_code %}
<span class="badge bg-secondary-subtle text-secondary border" style="font-size: 0.7rem;">
{{ room.subject_code }}
</span>
{% endif %}
<span class="badge bg-light text-dark border" style="font-size: 0.7rem;">
{{ room.shift }}
</span>
</div>
<!-- Cupo e Información Pedagógica -->
<div class="d-flex justify-content-between align-items-center small py-1 px-2 bg-body-tertiary rounded mb-2" style="font-size: 0.75rem;">
<div>
<span class="text-muted">{% trans %}Alumnos:{% endtrans %}</span>
<strong class="text-body ms-1">{{ room.max_students }}</strong>
</div>
<div>
<span class="text-muted">{% trans %}Modalidad:{% endtrans %}</span>
<strong class="text-info ms-1">Remoto ∞</strong>
</div>
</div>
<!-- Botón de Videollamada (Zoom / Meet / Teams) -->
<div class="mb-2">
{% if room.virtual_link %}
<a href="{{ room.virtual_link }}" target="_blank"
class="btn btn-sm btn-outline-success w-100 py-1 d-flex align-items-center justify-content-center"
style="font-size: 0.75rem;" title="{% trans %}Abrir enlace de videollamada{% endtrans %}">
<i class="bi bi-camera-video-fill me-1"></i>{% trans %}Unirse a Videollamada{% endtrans %}
</a>
{% else %}
<div class="p-1 rounded bg-body-tertiary border text-center small text-muted" style="font-size: 0.72rem;">
<i class="bi bi-link-45deg me-1"></i>{% trans %}Enlace a demanda por clase{% endtrans %}
</div>
{% endif %}
</div>
<!-- Barra de Demanda de la Sala Virtual (Hoy, Semana, Mes) -->
<div class="p-2 rounded bg-body-tertiary border mb-2">
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="small fw-semibold text-muted" style="font-size: 0.72rem;">
<i class="bi bi-calendar3 me-1 text-primary"></i>{% trans %}Clases a Demanda{% endtrans %}
</span>
<span class="text-muted" style="font-size: 0.65rem;">
{{ room.total_count }} {% trans %}totales{% endtrans %}
</span>
</div>
<div class="row g-1 text-center">
<!-- Hoy -->
<div class="col-4">
<div class="p-1 border metric-pill {% if room.day_count > 0 %}metric-low{% else %}metric-zero{% endif %}"
data-bs-toggle="modal" data-bs-target="#modalPreviewVirtual{{ room.commission_id }}"
role="button" title="{% trans %}Ver vista previa de clases de hoy{% endtrans %}">
<div class="metric-label">{% trans %}Hoy{% endtrans %}</div>
<div class="metric-val">{{ room.day_count }} cl.</div>
</div>
</div>
<!-- Semana -->
<div class="col-4">
<div class="p-1 border metric-pill {% if room.week_count > 0 %}metric-medium{% else %}metric-zero{% endif %}"
data-bs-toggle="modal" data-bs-target="#modalPreviewVirtual{{ room.commission_id }}"
role="button" title="{% trans %}Ver vista previa de clases de la semana{% endtrans %}">
<div class="metric-label">{% trans %}Semana{% endtrans %}</div>
<div class="metric-val">{{ room.week_count }} cl.</div>
</div>
</div>
<!-- Mes -->
<div class="col-4">
<div class="p-1 border metric-pill {% if room.month_count > 0 %}metric-high{% else %}metric-zero{% endif %}"
data-bs-toggle="modal" data-bs-target="#modalPreviewVirtual{{ room.commission_id }}"
role="button" title="{% trans %}Ver vista previa de clases del mes{% endtrans %}">
<div class="metric-label">{% trans %}Mes{% endtrans %}</div>
<div class="metric-val">{{ room.month_count }} cl.</div>
</div>
</div>
</div>
</div>
</div>
<!-- Footer con Botones -->
<div class="pt-2 border-top d-flex justify-content-between align-items-center gap-1">
<button type="button" class="btn btn-sm btn-primary py-1 px-2 d-flex align-items-center"
data-bs-toggle="modal" data-bs-target="#modalPreviewVirtual{{ room.commission_id }}"
style="font-size: 0.75rem;" title="{% trans %}Vista previa y modificar sesiones{% endtrans %}">
<i class="bi bi-eye me-1"></i>{% trans %}Vista Previa{% endtrans %}
<span class="badge bg-white text-primary ms-1" style="font-size: 0.65rem;">{{ room.total_count }}</span>
</button>
<a href="{{ url_for('schedule.add_reservation') }}?commission_id={{ room.commission_id }}&is_virtual=1"
class="btn btn-sm btn-outline-success py-1 px-2" style="font-size: 0.75rem;" title="{% trans %}Programar clase en esta sala virtual{% endtrans %}">
<i class="bi bi-plus-lg me-1"></i>{% trans %}Programar{% endtrans %}
</a>
</div>
</div>
</div>
<!-- Modal Vista Previa y Modificación de la Sala Virtual -->
<div class="modal fade text-start" id="modalPreviewVirtual{{ room.commission_id }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<div>
<h5 class="modal-title h6 fw-bold mb-1">
<i class="bi bi-laptop text-primary me-2"></i>{{ room.subject_name }}
</h5>
<div class="d-flex flex-wrap gap-1">
<span class="badge bg-primary-subtle text-primary border" style="font-size: 0.7rem;">
<i class="bi bi-diagram-3 me-1"></i>Comisión {{ room.commission_code }}
</span>
{% if room.subject_code %}
<span class="badge bg-secondary-subtle text-secondary border font-monospace" style="font-size: 0.7rem;">
{{ room.subject_code }}
</span>
{% endif %}
<span class="badge bg-info-subtle text-info border" style="font-size: 0.7rem;">
<i class="bi bi-infinity me-1"></i>{% trans %}A Demanda · Capacidad Ilimitada{% endtrans %}
</span>
<span class="badge bg-light text-dark border" style="font-size: 0.7rem;">
{{ room.shift }}
</span>
</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<!-- 1. Enlace de Videollamada de la Comisión (Edición directa) -->
<div class="card bg-body-tertiary border mb-3">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="fw-semibold small text-body">
<i class="bi bi-camera-video text-primary me-1"></i>{% trans %}Enlace de Videollamada (Zoom / Meet / Teams / Moodle){% endtrans %}
</span>
{% if room.virtual_link %}
<a href="{{ room.virtual_link }}" target="_blank" class="btn btn-sm btn-success py-0 px-2" style="font-size: 0.75rem;">
<i class="bi bi-box-arrow-up-right me-1"></i>{% trans %}Abrir Reunión{% endtrans %}
</a>
{% endif %}
</div>
<form method="POST" action="{{ url_for('classrooms.update_virtual_link', commission_id=room.commission_id) }}">
<div class="input-group input-group-sm">
<span class="input-group-text bg-body border"><i class="bi bi-link-45deg"></i></span>
<input type="url" name="virtual_link" class="form-control"
placeholder="{% trans %}Ingresar URL de Zoom, Google Meet, Teams, etc.{% endtrans %}"
value="{{ room.virtual_link or '' }}">
<button type="submit" class="btn btn-primary">
<i class="bi bi-save me-1"></i>{% trans %}Guardar Enlace{% endtrans %}
</button>
</div>
<small class="text-muted d-block mt-1" style="font-size: 0.7rem;">
{% trans %}Este enlace se aplicará a todas las clases y cursadas virtuales de esta comisión.{% endtrans %}
</small>
</form>
</div>
</div>
<!-- 2. Tabla de Clases / Sesiones Programadas -->
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="fw-bold mb-0 text-body small">
<i class="bi bi-calendar3 me-1 text-primary"></i>{% trans %}Sesiones Programadas en el Campus Virtual{% endtrans %} ({{ room.reservations|length }})
</h6>
<a href="{{ url_for('schedule.add_reservation') }}?commission_id={{ room.commission_id }}&is_virtual=1" class="btn btn-sm btn-outline-success py-0 px-2" style="font-size: 0.72rem;">
<i class="bi bi-plus-lg me-1"></i>{% trans %}Nueva Clase{% endtrans %}
</a>
</div>
{% if room.reservations %}
<div class="table-responsive border rounded mb-2">
<table class="table table-sm table-hover align-middle mb-0 small" style="font-size: 0.78rem;">
<thead class="table-light">
<tr>
<th>#ID</th>
<th>{% trans %}Fecha y Horario{% endtrans %}</th>
<th>{% trans %}Turno / Propósito{% endtrans %}</th>
<th>{% trans %}Docente{% endtrans %}</th>
<th>{% trans %}Videollamada{% endtrans %}</th>
<th>{% trans %}Estado{% endtrans %}</th>
<th class="text-end">{% trans %}Acciones{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for r in room.reservations %}
<tr>
<td class="text-muted">#{{ r.id }}</td>
<td>
<span class="fw-bold text-body">{{ r.start_time.strftime('%d/%m/%Y') }}</span>
<span class="text-muted d-block font-monospace" style="font-size: 0.72rem;">
{{ r.start_time.strftime('%H:%M') }} - {{ r.end_time.strftime('%H:%M') }} hs
</span>
</td>
<td>
<span class="badge bg-light text-dark border">{{ r.shift or 'Tarde' }}</span>
<small class="text-muted d-block text-truncate" style="max-width: 130px;">{{ r.purpose or 'Clase regular' }}</small>
</td>
<td>
<small class="text-body">{{ r.user.name if r.user else 'Docente Asignado' }}</small>
</td>
<td>
{% if r.effective_virtual_link %}
<a href="{{ r.effective_virtual_link }}" target="_blank" class="badge bg-success-subtle text-success border border-success-subtle text-decoration-none" title="{{ r.effective_virtual_link }}">
<i class="bi bi-camera-video me-1"></i>{% trans %}Link Activo{% endtrans %}
</a>
{% else %}
<span class="text-muted small">{% trans %}Hereda comisión{% endtrans %}</span>
{% endif %}
</td>
<td>
<span class="badge bg-success-subtle text-success border border-success-subtle" style="font-size: 0.68rem;">
{{ r.status }}
</span>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<a href="{{ url_for('schedule.edit_reservation', id=r.id) }}"
class="btn btn-sm btn-primary py-0 px-2" style="font-size: 0.72rem;"
title="{% trans %}Modificar Fecha, Horario, Aula o Detalles{% endtrans %}">
<i class="bi bi-pencil me-1"></i>{% trans %}Modificar{% endtrans %}
</a>
<a href="{{ url_for('schedule.view_reservation', id=r.id) }}"
class="btn btn-sm btn-outline-secondary py-0 px-2" style="font-size: 0.72rem;"
title="{% trans %}Ver Ficha Completa{% endtrans %}">
<i class="bi bi-eye"></i>
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info py-2 small mb-2">
<i class="bi bi-info-circle me-1"></i>{% trans %}No hay clases programadas para esta sala virtual en el período actual.{% endtrans %}
</div>
{% endif %}
</div>
<div class="modal-footer d-flex justify-content-between">
<a href="{{ url_for('schedule.list_reservations', classroom_id=virtual_classroom_id, search=room.subject_code or room.subject_name) }}"
class="btn btn-sm btn-outline-secondary">
<i class="bi bi-list-check me-1"></i>{% trans %}Ver en Listado de Reservas (Filtrado){% endtrans %}
</a>
<div class="d-flex gap-2">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cerrar{% endtrans %}</button>
<a href="{{ url_for('schedule.add_reservation') }}?commission_id={{ room.commission_id }}&is_virtual=1" class="btn btn-sm btn-success">
<i class="bi bi-plus-lg me-1"></i>{% trans %}Programar Nueva Clase{% endtrans %}
</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="row mt-4">
<div class="col-12">
<div class="text-center py-5">
<i class="bi bi-globe text-muted" style="font-size: 4rem;"></i>
<h4 class="mt-3 text-muted">{% trans %}No se encontraron salas virtuales{% endtrans %}</h4>
<p class="text-muted">
{% if search %}
{% trans %}Ninguna sala virtual coincide con los términos de búsqueda.{% endtrans %}
{% else %}
{% trans %}No hay clases virtuales programadas en el Campus Virtual.{% endtrans %}
{% endif %}
</p>
<a href="{{ url_for('schedule.add_reservation') }}?is_virtual=1" class="btn btn-primary">
<i class="bi bi-camera-video me-1"></i>{% trans %}Crear Primera Reserva Virtual{% endtrans %}
</a>
</div>
</div>
</div>
{% endif %}
{% endif %}
</div> </div>
{% endblock %} {% endblock %}
+173
View File
@@ -0,0 +1,173 @@
{% extends "base.html" %}
{% block title %}{{ classroom.code_display }} - {% trans %}Classroom Details{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<!-- Breadcrumb & Header -->
<div class="row mb-4 align-items-center">
<div class="col-md-7">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1 small">
<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>
</ol>
</nav>
<h1 class="h3 mb-0 d-flex align-items-center flex-wrap gap-2">
<i class="bi bi-door-open text-primary"></i> {{ classroom.code_display }}
{% if classroom.is_active %}
<span class="badge bg-success-subtle text-success border border-success-subtle fs-6">
<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 fs-6">
<i class="bi bi-x-circle me-1"></i>{% trans %}Inactive{% endtrans %}
</span>
{% endif %}
</h1>
</div>
<div class="col-md-5 text-md-end mt-3 mt-md-0">
{% if current_user.has_permission('classrooms', 'read_write') %}
<a href="{{ url_for('classrooms.edit_classroom', id=classroom.id) }}" class="btn btn-primary me-2">
<i class="bi bi-pencil me-1"></i>{% trans %}Edit Classroom{% endtrans %}
</a>
{% endif %}
<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>
<!-- Classroom Details Cards -->
<div class="row g-3 mb-4">
<div class="col-lg-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-body-tertiary border-0 py-3">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-info-circle text-primary me-2"></i>{% trans %}Ficha Técnica{% endtrans %}
</h5>
</div>
<div class="card-body p-3">
<ul class="list-group list-group-flush">
<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>{% trans %}Edificio / Sede{% endtrans %}</span>
<span class="fw-bold">
{% if classroom.building_entity %}
<a href="{{ url_for('buildings.view_building', id=classroom.building_entity.id) }}" class="text-decoration-none">
{{ classroom.building_entity.name }}
</a>
{% else %}
{{ classroom.building }}
{% endif %}
</span>
</li>
<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>{% trans %}Número / Nombre{% endtrans %}</span>
<span class="fw-bold font-monospace">{{ classroom.code }}</span>
</li>
<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>{% trans %}Piso / Planta{% endtrans %}</span>
<span class="fw-bold">{% if classroom.floor == 0 %}Planta Baja{% else %}Piso {{ classroom.floor }}{% endif %}</span>
</li>
<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>{% trans %}Capacidad Máxima{% endtrans %}</span>
{% if classroom.is_unlimited %}
<span class="badge bg-primary-subtle text-primary border px-2 py-1 fs-6">
<i class="bi bi-infinity me-1"></i>{% trans %}Ilimitada (∞){% endtrans %}
</span>
{% else %}
<span class="fw-bold text-success fs-6">{{ classroom.capacity }} {% trans %}estudiantes{% endtrans %}</span>
{% endif %}
</li>
</ul>
{% if classroom.description %}
<div class="mt-3 pt-3 border-top">
<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>
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-lg-8">
<!-- Upcoming Schedule / Reservations -->
<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">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-calendar-check text-primary me-2"></i>{% trans %}Próximas Clases y Reservas{% endtrans %}
</h5>
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-calendar-week me-1"></i>{% 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 %}Fecha y Horario{% endtrans %}</th>
<th>{% trans %}Materia / Motivo{% endtrans %}</th>
<th>{% trans %}Comisión{% endtrans %}</th>
<th>{% trans %}Asistentes{% endtrans %}</th>
<th class="text-end pe-3">{% trans %}Acciones{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for res in upcoming_reservations %}
<tr>
<td class="ps-3">
<div class="fw-bold font-monospace text-primary fs-6 mb-1">
<i class="bi bi-calendar-event text-primary me-1"></i>{{ res.start_time.strftime('%d/%m/%Y') }}
</div>
<div class="small font-monospace text-body-secondary">
<i class="bi bi-clock text-warning me-1"></i>{{ res.start_time.strftime('%H:%M') }} - {{ res.end_time.strftime('%H:%M') }} hs
</div>
</td>
<td>
<div class="fw-bold text-primary">{{ res.subject_name }}</div>
<small class="badge bg-secondary-subtle text-secondary font-monospace">{{ res.subject_code }}</small>
</td>
<td>
<span class="badge bg-info-subtle text-info border">
{{ res.commission.code if res.commission else 'S/C' }}
</span>
</td>
<td>
<span class="small">{{ res.expected_attendees }} / {{ classroom.capacity }}</span>
</td>
<td class="text-end pe-3">
{% if res.effective_virtual_link %}
<a href="{{ res.effective_virtual_link }}" target="_blank" class="btn btn-outline-success btn-sm py-0 px-2 me-1" title="{% trans %}Unirse a Clase Virtual (Zoom / Meet){% endtrans %}">
<i class="bi bi-camera-video"></i>
</a>
{% endif %}
<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 %}">
<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 mb-2"></i>
<h6 class="fw-bold">{% trans %}Sin reservas programadas próximamente{% endtrans %}</h6>
<p class="text-muted small mb-3">{% trans %}Esta aula no tiene clases asignadas en las próximas fechas.{% endtrans %}</p>
{% if current_user.has_permission('reservations', 'read_write') %}
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle me-1"></i>{% trans %}Asignar Nueva Reserva{% endtrans %}
</a>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+142 -84
View File
@@ -8,77 +8,105 @@
{% endif %} {% endif %}
<div class="row mb-4"> <div class="row mb-4">
<div class="col-12"> <div class="col-12">
<h1 class="h3 mb-0"> <h1 class="h3 mb-1">
<i class="bi bi-speedometer2 text-primary"></i> {% trans %}Dashboard{% endtrans %} <i class="bi bi-speedometer2 text-primary me-2"></i>{% trans %}Panel de Control{% endtrans %}
<small class="text-muted ms-2">{% trans %}Overview{% endtrans %}</small>
</h1> </h1>
<p class="text-muted small mb-0">{% trans %}Resumen general de espacios, materias y reservas académicas{% endtrans %}</p>
</div> </div>
</div> </div>
<!-- Statistics Cards --> <!-- Statistics Cards with Direct Shortcuts -->
<div class="row mb-4"> <div class="row mb-4 g-3">
<div class="col-md-3 mb-3"> <!-- 1. Total Aulas -->
<div class="card border-0 shadow-sm"> <div class="col-md-6 col-xl-3">
<div class="card-body"> <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 class="d-flex justify-content-between align-items-center">
<div> <div>
<h6 class="text-muted mb-2">{% trans %}Total Classrooms{% endtrans %}</h6> <span class="text-muted small fw-semibold text-uppercase">{% trans %}Total de Aulas{% endtrans %}</span>
<h2 class="mb-0">{{ stats.total_classrooms }}</h2> <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">
<i class="bi bi-door-open text-primary fs-4"></i>
</div> </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> </div>
</div> </div>
</a>
</div> </div>
<div class="col-md-3 mb-3"> <!-- 2. Total Materias -->
<div class="card border-0 shadow-sm"> <div class="col-md-6 col-xl-3">
<div class="card-body"> <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 class="d-flex justify-content-between align-items-center">
<div> <div>
<h6 class="text-muted mb-2">{% trans %}Total Subjects{% endtrans %}</h6> <span class="text-muted small fw-semibold text-uppercase">{% trans %}Total de Materias{% endtrans %}</span>
<h2 class="mb-0">{{ stats.total_subjects }}</h2> <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">
<i class="bi bi-book text-success fs-4"></i>
</div> </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> </div>
</div> </div>
</a>
</div> </div>
<div class="col-md-3 mb-3"> <!-- 3. Reservas de Hoy -->
<div class="card border-0 shadow-sm"> <div class="col-md-6 col-xl-3">
<div class="card-body"> <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 class="d-flex justify-content-between align-items-center">
<div> <div>
<h6 class="text-muted mb-2">{% trans %}Today's Reservations{% endtrans %}</h6> <span class="text-muted small fw-semibold text-uppercase">{% trans %}Reservas de Hoy{% endtrans %}</span>
<h2 class="mb-0">{{ stats.today_reservations }}</h2> <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">
<i class="bi bi-calendar-today text-info fs-4"></i>
</div> </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> </div>
</div> </div>
</a>
</div> </div>
<div class="col-md-3 mb-3"> <!-- 4. Esta Semana -->
<div class="card border-0 shadow-sm"> <div class="col-md-6 col-xl-3">
<div class="card-body"> <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 class="d-flex justify-content-between align-items-center">
<div> <div>
<h6 class="text-muted mb-2">{% trans %}This Week{% endtrans %}</h6> <span class="text-muted small fw-semibold text-uppercase">{% trans %}Esta Semana{% endtrans %}</span>
<h2 class="mb-0">{{ stats.week_reservations }}</h2> <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">
<i class="bi bi-calendar-week text-warning fs-4"></i>
</div> </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> </div>
</div> </div>
</a>
</div> </div>
</div> </div>
@@ -86,9 +114,9 @@
<!-- Monthly Chart --> <!-- Monthly Chart -->
<div class="col-md-8 mb-4"> <div class="col-md-8 mb-4">
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0"> <div class="card-header border-0 py-3 bg-body-tertiary">
<h5 class="mb-0"> <h5 class="mb-0 h6 fw-bold">
<i class="bi bi-graph-up text-primary"></i> Monthly Reservations <i class="bi bi-graph-up text-primary me-2"></i>{% trans %}Reservas Mensuales{% endtrans %}
</h5> </h5>
</div> </div>
<div class="card-body"> <div class="card-body">
@@ -100,31 +128,40 @@
<!-- Quick Actions --> <!-- Quick Actions -->
<div class="col-md-4 mb-4"> <div class="col-md-4 mb-4">
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0"> <div class="card-header border-0 py-3 bg-body-tertiary">
<h5 class="mb-0"> <h5 class="mb-0 h6 fw-bold">
<i class="bi bi-lightning text-warning"></i> Quick Actions <i class="bi bi-lightning text-warning me-2"></i>{% trans %}Accesos Rápidos{% endtrans %}
</h5> </h5>
</div> </div>
<div class="card-body"> <div class="card-body p-3">
<div class="d-grid gap-2"> <div class="d-grid gap-2">
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-outline-primary"> <a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary text-start">
<i class="bi bi-plus-circle"></i> Add Classroom <i class="bi bi-calendar3 me-2"></i>{% trans %}Calendario de Reservas{% endtrans %}
</a> </a>
<a href="{{ url_for('schedule.commissions_view') }}" class="btn btn-outline-warning"> <a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-info text-start">
<i class="bi bi-people"></i> Manage Commissions <i class="bi bi-calendar-day me-2"></i>{% trans %}Cartelera del Día{% endtrans %}
</a> </a>
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-outline-success"> {% if current_user.has_permission('optimizer', 'read') or (current_user.role and current_user.role|upper == 'ADMIN') %}
<i class="bi bi-calendar-plus"></i> New Reservation <a href="{{ url_for('genetic_algorithm_web.genetic_optimizer') }}" class="btn btn-outline-info text-start">
<i class="bi bi-cpu me-2"></i>{% trans %}Optimizador de Aulas (IA){% endtrans %}
</a> </a>
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-info"> {% endif %}
<i class="bi bi-calendar-today"></i> Today's Schedule <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>
<a href="{{ url_for('genetic_algorithm_web.genetic_optimizer') }}" class="btn btn-info text-white"> <a href="{{ url_for('buildings.list_buildings') }}" class="btn btn-outline-secondary text-start">
<i class="bi bi-cpu"></i> AI Room Optimizer <i class="bi bi-buildings me-2"></i>{% trans %}Gestión de Edificios{% endtrans %}
</a> </a>
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-secondary"> {% if current_user.has_permission('reservations', 'read_write') %}
<i class="bi bi-calendar3"></i> View Calendar <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> </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> </div>
@@ -135,46 +172,66 @@
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0"> <div class="card-header border-0 py-3 bg-body-tertiary d-flex justify-content-between align-items-center">
<div class="d-flex justify-content-between align-items-center"> <h5 class="mb-0 h6 fw-bold">
<h5 class="mb-0"> <i class="bi bi-clock-history text-info me-2"></i>{% trans %}Próximas Reservas (Próximos 7 Días){% endtrans %}
<i class="bi bi-clock-history text-info"></i> Upcoming Reservations (Next 7 Days)
</h5> </h5>
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-sm btn-outline-primary"> <a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-sm btn-outline-primary">
View All {% trans %}Ver en Calendario{% endtrans %}
</a> </a>
</div> </div>
</div> <div class="card-body p-0">
<div class="card-body">
{% if upcoming_reservations %} {% if upcoming_reservations %}
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover"> <table class="table table-hover align-middle mb-0">
<thead> <thead class="table-light small text-uppercase">
<tr> <tr>
<th>Date & Time</th> <th class="ps-3">{% trans %}Horario{% endtrans %}</th>
<th>Classroom</th> <th>{% trans %}Aula{% endtrans %}</th>
<th>Subject</th> <th>{% trans %}Asignatura / Código{% endtrans %}</th>
<th>Teacher</th> <th>{% trans %}Turno / Comisión{% endtrans %}</th>
<th>Status</th> <th>{% trans %}Estado{% endtrans %}</th>
<th>Actions</th> <th class="text-end pe-3">{% trans %}Acciones{% endtrans %}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for reservation in upcoming_reservations %} {% for reservation in upcoming_reservations %}
<tr> <tr>
<td>{{ reservation.start_time.strftime('%Y-%m-%d %H:%M') }}</td> <td class="ps-3">
<td>{{ reservation.classroom.code }}</td> <div class="fw-bold font-monospace text-primary fs-6">
<td>{{ reservation.commission.get_full_code() }}</td> <i class="bi bi-clock text-warning me-1"></i>{{ reservation.start_time.strftime('%H:%M') }} - {{ reservation.end_time.strftime('%H:%M') }} hs
<td>{{ reservation.commission.teacher_name }}</td> </div>
</td>
<td> <td>
<span class="badge bg-{{ 'success' if reservation.status.value == 'CONFIRMED' else 'warning' }}"> <span class="badge bg-secondary-subtle text-secondary border font-monospace">
{{ reservation.status.value }} {{ reservation.classroom.code if reservation.classroom else 'Sin Aula' }}
</span> </span>
</td> </td>
<td> <td>
<a href="{{ url_for('schedule.list_reservations') }}" <div class="fw-semibold text-primary">{{ reservation.subject_name }}</div>
class="btn btn-sm btn-outline-primary"> <small class="text-muted font-monospace">{{ reservation.subject_code }}</small>
<i class="bi bi-eye"></i> View </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> </a>
</td> </td>
</tr> </tr>
@@ -183,9 +240,10 @@
</table> </table>
</div> </div>
{% else %} {% else %}
<div class="text-center py-4"> <div class="text-center py-5">
<i class="bi bi-calendar-x text-muted" style="font-size: 3rem;"></i> <i class="bi bi-calendar-x text-muted fs-1 d-block mb-2"></i>
<p class="text-muted mt-3">No upcoming reservations in the next 7 days</p> <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> </div>
{% endif %} {% endif %}
</div> </div>
+107 -189
View File
@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}AI Room Reservation Optimizer{% endblock %} {% block title %}{% trans %}AI Room Reservation Optimizer{% endtrans %} - Edu-Space Admin{% endblock %}
{% block extra_css %} {% block extra_css %}
<style> <style>
@@ -13,27 +13,24 @@
} }
.commission-item { .commission-item {
border-left: 4px solid #007bff; border-left: 4px solid var(--bs-primary);
transition: all 0.2s ease; transition: all 0.2s ease;
background-color: var(--bs-body-bg);
} }
.commission-item:hover { .commission-item:hover {
border-left-color: #0056b3; border-left-color: var(--bs-info);
background-color: #f8f9fa; background-color: rgba(var(--bs-primary-rgb), 0.05);
} }
.commission-item.selected { .commission-item.selected {
border-left-color: #28a745; border-left-color: var(--bs-success);
background-color: #d4edda; background-color: rgba(var(--bs-success-rgb), 0.1);
} }
.algorithm-status { .algorithm-status {
position: sticky; position: sticky;
top: 20px; top: 90px;
}
.loading-spinner {
display: none;
} }
.spinner-overlay { .spinner-overlay {
@@ -42,99 +39,93 @@
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
background-color: rgba(0, 0, 0, 0.5); background-color: rgba(0, 0, 0, 0.7);
display: none; display: none;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
z-index: 9999; z-index: 9999;
} }
.fitness-meter { .hero-banner {
height: 20px; background: linear-gradient(135deg, rgba(37, 99, 235, 0.2) 0%, rgba(30, 41, 59, 0.9) 100%);
background: linear-gradient(to right, #dc3545, #ffc107, #28a745); border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px; border-radius: 12px;
position: relative;
}
.fitness-indicator {
position: absolute;
top: -5px;
width: 30px;
height: 30px;
background: white;
border: 3px solid #007bff;
border-radius: 50%;
transform: translateX(-50%);
transition: left 0.5s ease;
} }
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="container-fluid"> <div class="container-fluid pt-5 mt-4">
<!-- Header --> <!-- Header -->
<div class="row mb-4"> <div class="row mb-4">
<div class="col-12"> <div class="col-12">
<div class="card bg-primary text-white"> <div class="card hero-banner border-0 shadow-sm p-3">
<div class="card-body"> <div class="card-body">
<h1 class="card-title mb-3"> <div class="d-flex align-items-center mb-2">
<i class="fas fa-brain me-2"></i> <div class="bg-primary bg-opacity-25 rounded-3 p-3 me-3 text-primary">
AI Room Reservation Optimizer <i class="bi bi-cpu fs-2"></i>
</div>
<div>
<h1 class="h3 mb-1 fw-bold">
{% trans %}AI Room Reservation Optimizer{% endtrans %}
</h1> </h1>
<p class="card-text 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>
</div> </div>
</div> </div>
</div>
</div>
<!-- Algorithm Status Card --> <!-- Algorithm Status & Commission Selection -->
<div class="row mb-4"> <div class="row mb-4">
<div class="col-lg-4"> <!-- Algorithm Status Card -->
<div class="card algorithm-status"> <div class="col-lg-4 mb-4">
<div class="card-header bg-info text-white"> <div class="card border-0 shadow-sm algorithm-status">
<h5 class="mb-0"> <div class="card-header bg-body-tertiary border-0 py-3">
<i class="fas fa-cogs me-2"></i> <h5 class="mb-0 h6 fw-bold">
Algorithm Status <i class="bi bi-gear-wide-connected text-primary me-2"></i>
{% trans %}Algorithm Status{% endtrans %}
</h5> </h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="mb-3"> <div class="mb-4">
<small class="text-muted">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"> <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"> <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"> <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-3"> <div class="mb-4">
<small class="text-muted">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"> <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"> <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"> <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="geneticManager.getAlgorithmStatus()"> <button class="btn btn-sm btn-outline-primary w-100" onclick="geneticManager.getAlgorithmStatus()">
<i class="fas fa-sync-alt me-1"></i> <i class="bi bi-arrow-clockwise me-1"></i>
Refresh Status {% trans %}Refresh Status{% endtrans %}
</button> </button>
</div> </div>
</div> </div>
@@ -142,34 +133,27 @@
<!-- Commission Selection --> <!-- Commission Selection -->
<div class="col-lg-8"> <div class="col-lg-8">
<div class="card"> <div class="card border-0 shadow-sm">
<div class="card-header bg-light"> <div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0"> <h5 class="mb-0 h6 fw-bold">
<i class="fas fa-list-check 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> <div class="d-flex gap-2">
<div class="card-body">
<div class="mb-3">
<div class="d-flex gap-2 mb-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 class="btn btn-sm btn-outline-info" onclick="filterCommissions()">
<i class="fas fa-filter me-1"></i>
Filter
</button> </button>
</div> </div>
</div> </div>
<div class="card-body">
<div id="commissions-list" class="row"> <div id="commissions-list" class="row">
<!-- Commissions will be loaded here --> <!-- Commissions will be loaded here -->
<div class="col-12 text-center text-muted"> <div class="col-12 text-center text-muted py-5">
<i class="fas fa-spinner fa-spin me-2"></i> <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>
@@ -180,45 +164,45 @@
<!-- Optimization Controls --> <!-- Optimization Controls -->
<div class="row mb-4"> <div class="row mb-4">
<div class="col-12"> <div class="col-12">
<div class="card"> <div class="card border-0 shadow-sm">
<div class="card-header bg-primary text-white"> <div class="card-header bg-body-tertiary border-0 py-3">
<h5 class="mb-0"> <h5 class="mb-0 h6 fw-bold">
<i class="fas fa-play-circle me-2"></i> <i class="bi bi-sliders text-primary me-2"></i>
Optimization Controls {% trans %}Optimization Controls{% endtrans %}
</h5> </h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row align-items-center"> <div class="row align-items-center mb-3">
<div class="col-md-6"> <div class="col-md-6">
<div class="mb-3"> <div class="mb-3">
<label for="start-date" class="form-label">Start Date Range</label> <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"> <input type="datetime-local" class="form-control" id="start-date" name="start_date">
</div> </div>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<div class="mb-3"> <div class="mb-3">
<label for="end-date" class="form-label">End Date Range</label> <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"> <input type="datetime-local" class="form-control" id="end-date" name="end_date">
</div> </div>
</div> </div>
</div> </div>
<div class="d-flex gap-2 flex-wrap"> <div class="d-flex gap-2 flex-wrap">
<button class="btn btn-info" id="preview-optimization-btn"> <button class="btn btn-info text-white" id="preview-optimization-btn">
<i class="fas fa-eye me-2"></i> <i class="bi bi-eye me-1"></i>
Preview Optimization {% trans %}Preview Optimization{% endtrans %}
</button> </button>
<button class="btn btn-success" id="optimize-assignments-btn"> <button class="btn btn-success" id="optimize-assignments-btn">
<i class="fas fa-rocket me-2"></i> <i class="bi bi-play-circle me-1"></i>
Run Full Optimization {% trans %}Run Full Optimization{% endtrans %}
</button> </button>
<button class="btn btn-warning" id="apply-optimization-btn" style="display: none;"> <button class="btn btn-warning" id="apply-optimization-btn" style="display: none;">
<i class="fas fa-check me-2"></i> <i class="bi bi-check-circle me-1"></i>
Apply Reservations {% trans %}Apply Reservations{% endtrans %}
</button> </button>
<button class="btn btn-secondary" onclick="resetOptimization()"> <button class="btn btn-outline-secondary" onclick="resetOptimization()">
<i class="fas fa-undo me-2"></i> <i class="bi bi-arrow-counterclockwise me-1"></i>
Reset {% trans %}Reset{% endtrans %}
</button> </button>
</div> </div>
</div> </div>
@@ -229,13 +213,8 @@
<!-- Results Section --> <!-- Results Section -->
<div class="row mb-4"> <div class="row mb-4">
<div class="col-12"> <div class="col-12">
<!-- Preview Results -->
<div id="preview-results"></div> <div id="preview-results"></div>
<!-- Optimization Results -->
<div id="optimization-results"></div> <div id="optimization-results"></div>
<!-- Alert Container -->
<div id="alert-container"></div> <div id="alert-container"></div>
</div> </div>
</div> </div>
@@ -244,11 +223,9 @@
<!-- Loading Spinner Overlay --> <!-- Loading Spinner Overlay -->
<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" style="width: 3rem; height: 3rem;" role="status"> <div class="spinner-border text-primary" style="width: 3rem; height: 3rem;" role="status"></div>
<span class="visually-hidden">Optimizing...</span> <p class="mt-3 mb-1 fw-bold fs-5">{% trans %}Running genetic algorithm optimization...{% endtrans %}</p>
</div> <small class="text-white-50">{% trans %}This may take a few moments{% endtrans %}</small>
<p class="mt-3 mb-0">Running genetic algorithm optimization...</p>
<small class="d-block mt-2">This may take a few moments</small>
</div> </div>
</div> </div>
@@ -257,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 will be 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="fas fa-download me-2"></i> <i class="bi bi-download me-1"></i>
Download {% trans %}Download{% endtrans %}
</button> </button>
</div> </div>
</div> </div>
@@ -278,7 +255,6 @@
{% block extra_js %} {% block extra_js %}
<script src="{{ url_for('static', filename='js/genetic-algorithm.js') }}"></script> <script src="{{ url_for('static', filename='js/genetic-algorithm.js') }}"></script>
<script> <script>
// Additional utility functions for the genetic optimizer interface
function selectAllCommissions() { function selectAllCommissions() {
const checkboxes = document.querySelectorAll('input[name="commission_ids"]'); const checkboxes = document.querySelectorAll('input[name="commission_ids"]');
checkboxes.forEach(cb => { checkboxes.forEach(cb => {
@@ -306,78 +282,45 @@ function updateCommissionCard(commissionId, selected) {
} }
} }
function filterCommissions() {
// Implement filtering logic
console.log('Filter commissions feature not implemented yet');
}
function resetOptimization() { function resetOptimization() {
deselectAllCommissions(); deselectAllCommissions();
geneticManager.clearResults(); if (window.geneticManager) {
geneticManager.clearPreview(); geneticManager.clearResults && geneticManager.clearResults();
geneticManager.clearPreview && geneticManager.clearPreview();
}
document.getElementById('start-date').value = ''; document.getElementById('start-date').value = '';
document.getElementById('end-date').value = ''; document.getElementById('end-date').value = '';
} }
function downloadReservations() {
const reservations = geneticManager.getPendingReservations();
if (!reservations || reservations.length === 0) {
alert('No reservations to download');
return;
}
// Create CSV content
const headers = ['Commission ID', 'Classroom ID', 'Purpose', 'Start Time', 'End Time', 'Expected Attendees'];
const csvContent = [
headers.join(','),
...reservations.map(res => [
res.commission_id,
res.classroom_id,
`"${res.purpose}"`,
new Date(res.start_time).toISOString(),
new Date(res.end_time).toISOString(),
res.expected_attendees
].join(','))
].join('\n');
// Download file
const blob = new Blob([csvContent], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `optimization_results_${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
}
// Load commissions on page load
document.addEventListener('DOMContentLoaded', async function() { document.addEventListener('DOMContentLoaded', async function() {
await loadCommissions(); await loadCommissions();
if (window.geneticManager) {
await geneticManager.getAlgorithmStatus(); await geneticManager.getAlgorithmStatus();
}
}); });
async function loadCommissions() { async function loadCommissions() {
try { try {
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.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 => `
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<div class="card commission-item" data-commission-id="${commission.id}"> <div class="card commission-item border-0 shadow-sm" data-commission-id="${commission.id}">
<div class="card-body"> <div class="card-body">
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" name="commission_ids" <input class="form-check-input" type="checkbox" name="commission_ids"
value="${commission.id}" id="commission-${commission.id}" value="${commission.id}" id="commission-${commission.id}"
onchange="updateCommissionCard('${commission.id}', this.checked)"> onchange="updateCommissionCard('${commission.id}', this.checked)">
<label class="form-check-label" for="commission-${commission.id}"> <label class="form-check-label" for="commission-${commission.id}">
<strong>${commission.get_full_code || commission.code}</strong> <strong class="text-primary">${commission.get_full_code || commission.code}</strong>
<br> <br>
<small class="text-muted">${commission.subject?.name || 'Unknown Subject'}</small> <small class="text-muted">${commission.subject?.name || (isSpanish ? 'Materia Desconocida' : 'Unknown Subject')}</small>
<br> <br>
<span class="badge bg-info">${commission.current_students || 0}/${commission.max_students} students</span> <span class="badge bg-info mt-1">${commission.current_students || 0}/${commission.max_students} ${isSpanish ? 'estudiantes' : 'students'}</span>
${commission.teacher_name ? `<span class="badge bg-secondary ms-2">${commission.teacher_name}</span>` : ''} ${commission.teacher_name ? `<span class="badge bg-secondary ms-2">${commission.teacher_name}</span>` : ''}
</label> </label>
</div> </div>
@@ -389,46 +332,21 @@ const response = await fetch('/api/genetic/commissions');
commissionsList.innerHTML = commissionsHtml; commissionsList.innerHTML = commissionsHtml;
} else { } else {
commissionsList.innerHTML = ` commissionsList.innerHTML = `
<div class="col-12 text-center text-muted"> <div class="col-12 text-center text-muted py-5">
<i class="fas fa-info-circle me-2"></i> <i class="bi bi-inbox fs-1 d-block mb-2"></i>
No active commissions found. ${isSpanish ? 'No se encontraron comisiones activas.' : 'No active commissions found.'}
</div> </div>
`; `;
} }
} catch (error) { } catch (error) {
console.error('Error loading commissions:', error); console.error('Error loading commissions:', error);
document.getElementById('commissions-list').innerHTML = ` document.getElementById('commissions-list').innerHTML = `
<div class="col-12 text-center text-danger"> <div class="col-12 text-center text-danger py-4">
<i class="fas fa-exclamation-triangle me-2"></i> <i class="bi bi-exclamation-triangle fs-2 d-block mb-2"></i>
Error loading commissions: ${error.message} Error al cargar comisiones: ${error.message}
</div> </div>
`; `;
} }
} }
// Override the genetic algorithm status handler
const originalGetAlgorithmStatus = geneticManager.getAlgorithmStatus;
geneticManager.getAlgorithmStatus = async function() {
try {
const result = await originalGetAlgorithmStatus.call(this);
if (result.success) {
const data = result.data;
document.getElementById('available-classrooms').textContent = data.system_status.available_classrooms;
document.getElementById('active-commissions').textContent = data.system_status.active_commissions;
document.getElementById('existing-reservations').textContent = data.system_status.confirmed_reservations;
}
} catch (error) {
console.error('Error updating algorithm status:', error);
}
};
// Override the loading function to use our custom spinner
geneticManager.showLoading = function(show) {
const spinner = document.getElementById('loading-spinner');
if (spinner) {
spinner.style.display = show ? 'flex' : 'none';
}
};
</script> </script>
{% endblock %} {% endblock %}
+19 -12
View File
@@ -1,25 +1,32 @@
{% if config.LANGUAGES|length > 1 %} {% if config.LANGUAGES|length > 1 %}
<div class="dropdown language-switcher"> <div class="dropdown language-switcher">
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" type="button" id="languageDropdown" data-bs-toggle="dropdown" aria-expanded="false"> <button class="btn btn-outline-secondary btn-sm dropdown-toggle d-flex align-items-center" type="button" id="languageDropdown" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-globe"></i> <i class="bi bi-globe me-1"></i>
{% if session.language == 'es' or (session.language is none and request.accept_languages.best_match(['es', 'en']) == 'es') %} <span>
Spanish {% if current_lang == 'es' %}
Español
{% else %} {% else %}
English English
{% endif %} {% endif %}
</span>
</button> </button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="languageDropdown"> <ul class="dropdown-menu dropdown-menu-end shadow-sm" aria-labelledby="languageDropdown">
{% set current_lang = session.language or request.accept_languages.best_match(['es', 'en']) or 'en' %}
<li> <li>
<a class="dropdown-item {% if current_lang == 'en' %}active{% endif %}" <a class="dropdown-item d-flex align-items-center justify-content-between {% if current_lang == 'es' %}active{% endif %}"
href="{{ url_for('main.set_language', language='en') }}"> href="{{ url_for('main.set_language', language='es') }}">
<i class="bi bi-check-circle-fill text-primary me-2"></i>English <span>Español</span>
{% if current_lang == 'es' %}
<i class="bi bi-check-lg ms-2 text-white"></i>
{% endif %}
</a> </a>
</li> </li>
<li> <li>
<a class="dropdown-item {% if current_lang == 'es' %}active{% endif %}" <a class="dropdown-item d-flex align-items-center justify-content-between {% if current_lang == 'en' %}active{% endif %}"
href="{{ url_for('main.set_language', language='es') }}"> href="{{ url_for('main.set_language', language='en') }}">
<i class="bi bi-check-circle-fill text-primary me-2"></i>Español <span>English</span>
{% if current_lang == 'en' %}
<i class="bi bi-check-lg ms-2 text-white"></i>
{% endif %}
</a> </a>
</li> </li>
</ul> </ul>
+459 -125
View File
@@ -1,86 +1,131 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Add Reservation - Edu-Space Admin{% endblock %} {% block title %}{% trans %}New Reservation{% 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">
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<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"> <div>
<i class="bi bi-calendar-plus text-success"></i> New Reservation <h1 class="h3 mb-0">
<i class="bi bi-calendar-plus text-primary me-2"></i>{% trans %}New Reservation{% endtrans %}
</h1> </h1>
</div>
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary"> <a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Reservations <i class="bi bi-arrow-left me-1"></i>{% trans %}Back to Reservations{% endtrans %}
</a> </a>
</div> </div>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-lg-8 mx-auto"> <div class="col-lg-10 mx-auto">
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0"> <div class="card-header bg-body-tertiary border-0 py-3">
<h5 class="mb-0"> <h5 class="mb-0 h6 fw-bold">
<i class="bi bi-calendar3 text-success"></i> Reservation Details <i class="bi bi-calendar3 text-primary me-2"></i>{% trans %}Detalles de la Reserva y Espacio Físico{% endtrans %}
</h5> </h5>
</div> </div>
<div class="card-body"> <div class="card-body p-4">
<form method="POST" id="reservation-form" novalidate> <form method="POST" id="reservation-form" novalidate>
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
<div class="row"> <!-- 1. SELECTORES EN CASCADA: Edificio -> Piso -> Aula -->
<div class="col-md-6 mb-3"> <div class="mb-4">
{{ form.classroom_id.label(class="form-label") }} <div class="p-3 rounded bg-body-tertiary border">
{{ form.classroom_id(class="form-select", id="classroom-select") }} <label class="form-label fw-bold text-primary mb-2">
<i class="bi bi-geo-alt-fill me-1"></i>{% trans %}Selección de Espacio Físico / Modalidad{% endtrans %} <span class="text-danger">*</span>
</label>
<div class="row g-3">
<!-- Edificio / Sede -->
<div class="col-md-4">
<label for="cascade-building" class="form-label small fw-semibold text-muted mb-1">
<i class="bi bi-buildings text-primary me-1"></i>1. {% trans %}Edificio / Sede{% endtrans %}
</label>
<select id="cascade-building" class="form-select form-select-sm">
<option value="">{% trans %}-- Selecciona Edificio --{% endtrans %}</option>
{% for b in buildings %}
<option value="{{ b.id }}" data-virtual="{{ 'true' if b.code == 'VIRTUAL' or 'VIRTUAL' in (b.name|upper) else 'false' }}">
{% if b.code == 'VIRTUAL' or 'VIRTUAL' in (b.name|upper) %}🌐{% else %}🏢{% endif %} {{ b.name }}
</option>
{% endfor %}
</select>
<div class="form-text small text-muted">{% trans %}Sede física o Campus Virtual{% endtrans %}</div>
</div>
<!-- Piso / Nivel -->
<div class="col-md-4">
<label for="cascade-floor" class="form-label small fw-semibold text-muted mb-1">
<i class="bi bi-layers text-secondary me-1"></i>2. {% trans %}Piso / Nivel{% endtrans %}
</label>
<select id="cascade-floor" class="form-select form-select-sm" disabled>
<option value="">{% trans %}-- Selecciona Piso --{% endtrans %}</option>
</select>
<div class="form-text small text-muted" id="floorHelpText">{% trans %}Filtra por planta o nivel{% endtrans %}</div>
</div>
<!-- Aula / Espacio (Campo del Formulario) -->
<div class="col-md-4">
<label for="classroom-select" class="form-label small fw-semibold text-muted mb-1">
<i class="bi bi-door-open text-success me-1"></i>3. {% trans %}Aula / Espacio{% endtrans %} <span class="text-danger">*</span>
</label>
{{ form.classroom_id(class="form-select form-select-sm", id="classroom-select") }}
{% if form.classroom_id.errors %} {% if form.classroom_id.errors %}
<div class="text-danger small mt-1"> <div class="text-danger small mt-1">
{{ form.classroom_id.errors[0] }} {{ form.classroom_id.errors[0] }}
</div> </div>
{% endif %} {% endif %}
<small class="text-muted">Select a classroom for your reservation</small> <div class="form-text small text-muted">{% trans %}Capacidad y disponibilidad{% endtrans %}</div>
</div>
</div>
</div>
</div> </div>
<div class="col-md-6 mb-3"> <!-- 2. Comisión y Materia -->
{{ form.commission_id.label(class="form-label") }} <div class="row g-3 mb-3">
<div class="col-12">
{{ form.commission_id.label(class="form-label fw-semibold") }} <span class="text-danger">*</span>
{{ form.commission_id(class="form-select", id="commission-select") }} {{ form.commission_id(class="form-select", id="commission-select") }}
{% if form.commission_id.errors %} {% if form.commission_id.errors %}
<div class="text-danger small mt-1"> <div class="text-danger small mt-1">
{{ form.commission_id.errors[0] }} {{ form.commission_id.errors[0] }}
</div> </div>
{% endif %} {% endif %}
<small class="text-muted">Select the subject/commission</small> <div class="form-text small text-muted">{% trans %}Materia, código de comisión y cantidad de alumnos inscriptos{% endtrans %}</div>
</div> </div>
</div> </div>
<div class="row"> <!-- 3. Horario de Inicio y Fin -->
<div class="col-md-6 mb-3"> <div class="row g-3 mb-3">
{{ form.start_time.label(class="form-label") }} <div class="col-md-6">
{{ form.start_time.label(class="form-label fw-semibold") }} <span class="text-danger">*</span>
{{ form.start_time(class="form-control", id="start-time") }} {{ form.start_time(class="form-control", id="start-time") }}
{% if form.start_time.errors %} {% if form.start_time.errors %}
<div class="text-danger small mt-1"> <div class="text-danger small mt-1">
{{ form.start_time.errors[0] }} {{ form.start_time.errors[0] }}
</div> </div>
{% endif %} {% endif %}
<small class="text-muted">When will the class start?</small> <small class="text-muted">{% trans %}¿Cuándo comenzará la clase?{% endtrans %}</small>
</div> </div>
<div class="col-md-6 mb-3"> <div class="col-md-6">
{{ form.end_time.label(class="form-label") }} {{ form.end_time.label(class="form-label fw-semibold") }} <span class="text-danger">*</span>
{{ form.end_time(class="form-control", id="end-time") }} {{ form.end_time(class="form-control", id="end-time") }}
{% if form.end_time.errors %} {% if form.end_time.errors %}
<div class="text-danger small mt-1"> <div class="text-danger small mt-1">
{{ form.end_time.errors[0] }} {{ form.end_time.errors[0] }}
</div> </div>
{% endif %} {% endif %}
<small class="text-muted">When will the class end?</small> <small class="text-muted">{% trans %}¿Cuándo finalizará la clase?{% endtrans %}</small>
</div> </div>
</div> </div>
<div class="row"> <!-- 4. Propósito y Asistentes -->
<div class="col-md-8 mb-3"> <div class="row g-3 mb-3">
{{ form.purpose.label(class="form-label") }} <div class="col-md-8">
{{ form.purpose(class="form-control", placeholder="Regular class, exam, study session, etc.") }} {{ form.purpose.label(class="form-label fw-semibold") }} <span class="text-danger">*</span>
{{ form.purpose(class="form-control", placeholder=_("Clase regular, examen, sesión de estudio, etc.")) }}
{% if form.purpose.errors %} {% if form.purpose.errors %}
<div class="text-danger small mt-1"> <div class="text-danger small mt-1">
{{ form.purpose.errors[0] }} {{ form.purpose.errors[0] }}
@@ -88,21 +133,22 @@
{% endif %} {% endif %}
</div> </div>
<div class="col-md-4 mb-3"> <div class="col-md-4">
{{ form.expected_attendees.label(class="form-label") }} {{ form.expected_attendees.label(class="form-label fw-semibold") }} <span class="text-danger">*</span>
{{ form.expected_attendees(class="form-control", placeholder="30") }} {{ form.expected_attendees(class="form-control", id="expected_attendees", placeholder="30") }}
{% if form.expected_attendees.errors %} {% if form.expected_attendees.errors %}
<div class="text-danger small mt-1"> <div class="text-danger small mt-1">
{{ form.expected_attendees.errors[0] }} {{ form.expected_attendees.errors[0] }}
</div> </div>
{% endif %} {% endif %}
<small class="text-muted">Number of expected attendees</small> <small class="text-muted">{% trans %}Cantidad de asistentes esperados{% endtrans %}</small>
</div> </div>
</div> </div>
<div class="mb-4"> <!-- 5. Notas -->
{{ form.notes.label(class="form-label") }} <div class="mb-3">
{{ form.notes(class="form-control", rows="3", placeholder="Any additional notes or special requirements...") }} {{ form.notes.label(class="form-label fw-semibold") }}
{{ form.notes(class="form-control", rows="2", placeholder=_("Cualquier nota adicional o requerimientos especiales...")) }}
{% if form.notes.errors %} {% if form.notes.errors %}
<div class="text-danger small mt-1"> <div class="text-danger small mt-1">
{{ form.notes.errors[0] }} {{ form.notes.errors[0] }}
@@ -110,108 +156,375 @@
{% endif %} {% endif %}
</div> </div>
<!-- Summary Section --> <!-- 6. Enlace de Aula Virtual (Zoom, Meet, Teams, Moodle) -->
<div class="alert alert-info" id="reservation-summary"> <div class="mb-4" id="virtual-link-container">
<h6 class="alert-heading"> <label for="virtual_link" class="form-label fw-semibold">
<i class="bi bi-info-circle"></i> Reservation Summary <i class="bi bi-camera-video text-primary me-1"></i>{% trans %}Enlace de Aula Virtual (Zoom, Google Meet, Teams, Moodle){% endtrans %}
</h6> </label>
<div id="summary-content"> {{ form.virtual_link(class="form-control", id="virtual_link", placeholder="https://zoom.us/j/... o https://meet.google.com/...") }}
<p class="mb-2"> {% if form.virtual_link.errors %}
<strong>Selected Classroom:</strong> <div class="text-danger small mt-1">
<span id="summary-classroom">Not selected</span> {{ form.virtual_link.errors[0] }}
</p> </div>
<p class="mb-2"> {% endif %}
<strong>Duration:</strong> <div class="form-text small text-muted">
<span id="summary-duration">Not calculated</span> <i class="bi bi-info-circle me-1"></i>{% trans %}Obligatorio para Campus Virtual o clases remotas/híbridas. Se autocompleta con el enlace de la comisión seleccionada.{% endtrans %}
</p>
<p class="mb-0">
<strong>Commission:</strong>
<span id="summary-commission">Not selected</span>
</p>
</div> </div>
</div> </div>
<!-- 7. TARJETA DE RESUMEN DESGLOSADA: Edificio, Piso, Aula, Modalidad -->
<div class="card bg-body-tertiary border-0 shadow-xs mb-4" id="reservation-summary">
<div class="card-body p-3">
<h6 class="card-subtitle mb-2 text-muted small fw-bold">
<i class="bi bi-info-circle text-info me-1"></i>{% trans %}Resumen de la Reserva{% endtrans %}
</h6>
<!-- Badges desglosados de Ubicación -->
<div class="row g-2 mb-3">
<div class="col-md-4">
<div class="p-2 rounded bg-body border">
<small class="text-muted d-block">🏢 {% trans %}Edificio / Sede{% endtrans %}</small>
<strong id="summary-building" class="text-primary">-</strong>
</div>
</div>
<div class="col-md-4">
<div class="p-2 rounded bg-body border">
<small class="text-muted d-block">🪜 {% trans %}Piso / Nivel{% endtrans %}</small>
<strong id="summary-floor" class="text-secondary">-</strong>
</div>
</div>
<div class="col-md-4">
<div class="p-2 rounded bg-body border">
<small class="text-muted d-block">🚪 {% trans %}Aula Seleccionada{% endtrans %}</small>
<strong id="summary-classroom" class="text-success">{% trans %}No seleccionada{% endtrans %}</strong>
</div>
</div>
</div>
<!-- Fila de Enlace Virtual -->
<div class="row g-2 mb-3" id="summary-virtual-row">
<div class="col-12">
<div class="p-2 rounded bg-body border d-flex align-items-center justify-content-between">
<div class="text-truncate me-2">
<small class="text-muted d-block">🎥 {% trans %}Enlace Virtual (Videoconferencia){% endtrans %}</small>
<span id="summary-virtual-link" class="small font-monospace text-primary text-break">{% trans %}Sin enlace configurado{% endtrans %}</span>
</div>
<span class="badge bg-success-subtle text-success border" id="summary-virtual-badge" style="display:none;">
<i class="bi bi-camera-video me-1"></i>Virtual
</span>
</div>
</div>
</div>
<!-- Duración y Comisión -->
<div class="row g-2 small">
<div class="col-md-4">
<span class="text-muted">⏱️ {% trans %}Duración:{% endtrans %}</span>
<strong id="summary-duration" class="text-body ms-1">{% trans %}No calculada{% endtrans %}</strong>
</div>
<div class="col-md-8">
<span class="text-muted">📚 {% trans %}Comisión:{% endtrans %}</span>
<strong id="summary-commission" class="text-body ms-1">{% trans %}No seleccionada{% endtrans %}</strong>
</div>
</div>
</div>
</div>
<!-- Botones -->
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary"> <a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle"></i> Cancel <i class="bi bi-x-circle me-1"></i>{% trans %}Cancelar{% endtrans %}
</a> </a>
{{ form.submit(class="btn btn-success") }} <button type="submit" class="btn btn-success px-4">
<i class="bi bi-calendar-check me-1"></i>{% trans %}Realizar Reserva{% endtrans %}
</button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Instructions Card -->
<div class="row mt-4">
<div class="col-lg-8 mx-auto">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0">
<h5 class="mb-0">
<i class="bi bi-question-circle text-info"></i> Reservation Guidelines
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6 class="text-primary">📅 Booking Rules</h6>
<ul class="small">
<li>Reservations can be made up to 3 months in advance</li>
<li>Cannot book in the past</li>
<li>Maximum duration: 8 hours per reservation</li>
<li>Cancellations must be 24 hours before start time</li>
</ul>
</div>
<div class="col-md-6">
<h6 class="text-warning">🚨 Important Notes</h6>
<ul class="small">
<li>Check classroom capacity before booking</li>
<li>Pending reservations require approval</li>
<li>Time slots must not overlap</li>
<li>System automatically detects conflicts</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script type="application/json" id="classroom-data">
{
{% for c in classroom_list %}
"{{ c.id }}": {
"id": {{ c.id }},
"building_id": {{ (c.building_id or 0)|tojson }},
"building_name": {{ c.building_name|tojson }},
"floor": {{ c.floor|tojson }},
"floor_display": {{ c.floor_display|tojson }},
"code": {{ c.code|tojson }},
"code_display": {{ c.code_display|tojson }},
"location_display": {{ c.location_display|tojson }},
"capacity": {{ c.capacity_display|tojson }},
"capacity_num": {{ c.capacity }},
"is_virtual": {{ c.is_virtual|tojson }}
}{% if not loop.last %},{% endif %}
{% endfor %}
}
</script>
<script type="application/json" id="commission-data">
{
{% for c in commission_list %}
"{{ c.id }}": {
"id": {{ c.id }},
"code": {{ c.code|tojson }},
"virtual_link": {{ (c.virtual_link or '')|tojson }},
"max_students": {{ c.max_students }}
}{% if not loop.last %},{% endif %}
{% endfor %}
}
</script>
<script type="application/json" id="i18n-strings">
{
"not_selected": "{% trans %}No seleccionada{% endtrans %}",
"not_calculated": "{% trans %}No calculada{% endtrans %}",
"invalid_time": "{% trans %}Rango horario inválido{% endtrans %}",
"hour": "{% trans %}hora{% endtrans %}",
"hours": "{% trans %}horas{% endtrans %}",
"minute": "{% trans %}minuto{% endtrans %}",
"minutes": "{% trans %}minutos{% endtrans %}",
"capacity": "{% trans %}Capacidad{% endtrans %}",
"capacity_exceeded": "{% trans %}Los asistentes esperados superan la capacidad del aula{% endtrans %}",
"no_virtual_link": "{% trans %}Sin enlace configurado (Clase presencial){% endtrans %}"
}
</script>
<script> <script>
$(document).ready(function() { $(document).ready(function() {
// Classroom data for capacity checking let classroomData = {};
const classroomData = {}; try {
{% for classroom in classroom_list %} const rawData = document.getElementById('classroom-data')?.textContent;
classroomData[{{ classroom.id }}] = { if (rawData) classroomData = JSON.parse(rawData);
capacity: {{ classroom.capacity }}, } catch (err) {
code_display: "{{ classroom.code_display }}", console.error('Error parsing classroomData:', err);
name: "{{ classroom.name }}" }
};
{% endfor %} let commissionData = {};
try {
const rawComm = document.getElementById('commission-data')?.textContent;
if (rawComm) commissionData = JSON.parse(rawComm);
} catch (err) {
console.error('Error parsing commissionData:', err);
}
let i18n = {};
try {
const rawI18n = document.getElementById('i18n-strings')?.textContent;
if (rawI18n) i18n = JSON.parse(rawI18n);
} catch (err) {
console.error('Error parsing i18n:', err);
}
const $building = $('#cascade-building');
const $floor = $('#cascade-floor');
const $classroom = $('#classroom-select');
const $floorHelp = $('#floorHelpText');
// Guardar todas las opciones originales de aulas
const originalClassroomOptions = [];
$classroom.find('option').each(function() {
originalClassroomOptions.push({
value: $(this).val(),
text: $(this).text()
});
});
// 1. Manejo del cambio de Edificio
$building.on('change', function() {
const selectedBuildingId = $(this).val();
const isVirtual = $(this).find(':selected').data('virtual') === true || $(this).find(':selected').data('virtual') === 'true';
$floor.empty().append('<option value="">-- ' + (isVirtual ? 'No aplica (Campus Virtual)' : 'Selecciona Piso') + ' --</option>');
$classroom.empty().append('<option value="">-- Selecciona Aula --</option>');
if (!selectedBuildingId) {
$floor.prop('disabled', true);
$classroom.prop('disabled', false);
// Restaurar todas
originalClassroomOptions.forEach(opt => {
if (opt.value) $classroom.append($('<option>', { value: opt.value, text: opt.text }));
});
updateSummary();
return;
}
if (isVirtual) {
$floor.prop('disabled', true);
$floorHelp.text('Modalidad 100% remota sin piso asignado');
// Filtrar solo aulas virtuales
let virtualCount = 0;
let lastVirtualId = null;
Object.values(classroomData).forEach(c => {
if (c.is_virtual || c.building_id == selectedBuildingId) {
$classroom.append($('<option>', {
value: c.id,
text: `${c.code_display} (Cap: ${c.capacity})`
}));
lastVirtualId = c.id;
virtualCount++;
}
});
if (virtualCount === 1 && lastVirtualId) {
$classroom.val(lastVirtualId);
}
updateSummary();
return;
}
// Edificio Físico
$floor.prop('disabled', false);
$floorHelp.text('Filtra por planta o nivel');
// Extraer pisos únicos de este edificio
const buildingFloors = [];
Object.values(classroomData).forEach(c => {
if (c.building_id == selectedBuildingId && c.floor) {
if (!buildingFloors.some(f => f.floor === c.floor)) {
buildingFloors.push({
floor: c.floor,
floor_display: c.floor_display
});
}
}
});
// Ordenar pisos: PB primero, luego numérico
buildingFloors.sort((a, b) => {
if (a.floor === '0' || a.floor.toUpperCase() === 'PB') return -1;
if (b.floor === '0' || b.floor.toUpperCase() === 'PB') return 1;
return a.floor.localeCompare(b.floor, undefined, { numeric: true });
});
buildingFloors.forEach(f => {
$floor.append($('<option>', {
value: f.floor,
text: f.floor_display
}));
});
// Poblar aulas de este edificio inicialmente
Object.values(classroomData).forEach(c => {
if (c.building_id == selectedBuildingId) {
$classroom.append($('<option>', {
value: c.id,
text: `${c.code} · ${c.floor_display} (Cap: ${c.capacity})`
}));
}
});
updateSummary();
});
// 2. Manejo del cambio de Piso
$floor.on('change', function() {
const selectedBuildingId = $building.val();
const selectedFloor = $(this).val();
$classroom.empty().append('<option value="">-- Selecciona Aula --</option>');
let count = 0;
let lastId = null;
Object.values(classroomData).forEach(c => {
const matchesBuilding = !selectedBuildingId || c.building_id == selectedBuildingId;
const matchesFloor = !selectedFloor || c.floor == selectedFloor;
if (matchesBuilding && matchesFloor) {
$classroom.append($('<option>', {
value: c.id,
text: `${c.code} (Cap: ${c.capacity})`
}));
lastId = c.id;
count++;
}
});
if (count === 1 && lastId) {
$classroom.val(lastId);
}
updateSummary();
});
// 3. Manejo del cambio de Aula directo
$classroom.on('change', function() {
const cId = $(this).val();
if (cId && classroomData[cId]) {
const c = classroomData[cId];
// Sincronizar selectores en cascada si no coinciden
if ($building.val() != c.building_id) {
$building.val(c.building_id);
// Si no es virtual, sincronizar piso
if (!c.is_virtual) {
$floor.prop('disabled', false);
$floor.val(c.floor);
}
} else if (!c.is_virtual && $floor.val() != c.floor) {
$floor.val(c.floor);
}
}
updateSummary();
});
// Inicializar cascada si ya hay un aula seleccionada (ej. post-error o query string)
const initialClassroomId = $classroom.val();
if (initialClassroomId && classroomData[initialClassroomId]) {
const c = classroomData[initialClassroomId];
$building.val(c.building_id).trigger('change');
if (!c.is_virtual) {
$floor.val(c.floor).trigger('change');
}
$classroom.val(initialClassroomId);
}
function updateSummary() { function updateSummary() {
const classroomId = $('#classroom-select').val(); const classroomId = $classroom.val();
const commissionId = $('#commission-select').val(); const commissionId = $('#commission-select').val();
const startTime = $('#start-time').val(); const startTime = $('#start-time').val();
const endTime = $('#end-time').val(); const endTime = $('#end-time').val();
// Update classroom summary // Update classroom badges
if (classroomId && classroomData[classroomId]) { if (classroomId && classroomData[classroomId]) {
$('#summary-classroom').text(`${classroomData[classroomId].code_display} - ${classroomData[classroomId].name} (Capacity: ${classroomData[classroomId].capacity})`); const c = classroomData[classroomId];
$('#summary-building').text(c.building_name);
$('#summary-floor').text(c.is_virtual ? 'Remoto (Virtual)' : c.floor_display);
$('#summary-classroom').text(`${c.code} (Cap: ${c.capacity})`);
} else { } else {
$('#summary-classroom').text('Not selected'); $('#summary-building').text('-');
$('#summary-floor').text('-');
$('#summary-classroom').text(i18n.not_selected || 'No seleccionada');
} }
// Update commission summary // Update commission summary and autofill virtual link if available
if (commissionId) { if (commissionId) {
const commissionText = $('#commission-select option:selected').text(); const commissionText = $('#commission-select option:selected').text();
$('#summary-commission').text(commissionText); $('#summary-commission').text(commissionText);
} else { } else {
$('#summary-commission').text('Not selected'); $('#summary-commission').text(i18n.not_selected || 'No seleccionada');
}
// Update virtual link in summary
const vLink = ($('#virtual_link').val() || '').trim();
const isVirtual = (classroomId && classroomData[classroomId] && classroomData[classroomId].is_virtual);
if (vLink) {
$('#summary-virtual-link').text(vLink);
$('#summary-virtual-badge').show();
} else if (isVirtual) {
$('#summary-virtual-link').html('<span class="text-warning fw-semibold"><i class="bi bi-exclamation-triangle me-1"></i>Campus Virtual (Recomendado agregar enlace Zoom/Meet)</span>');
$('#summary-virtual-badge').hide();
} else {
$('#summary-virtual-link').text(i18n.no_virtual_link || 'Sin enlace configurado (Clase presencial)');
$('#summary-virtual-badge').hide();
} }
// Calculate duration // Calculate duration
@@ -226,37 +539,48 @@ $(document).ready(function() {
let durationText = ''; let durationText = '';
if (diffHours > 0) { if (diffHours > 0) {
durationText += `${diffHours} hour${diffHours > 1 ? 's' : ''}`; const hLabel = diffHours > 1 ? (i18n.hours || 'horas') : (i18n.hour || 'hora');
durationText += `${diffHours} ${hLabel}`;
} }
if (diffMins > 0) { if (diffMins > 0) {
if (durationText) durationText += ' '; if (durationText) durationText += ' ';
durationText += `${diffMins} minute${diffMins > 1 ? 's' : ''}`; const mLabel = diffMins > 1 ? (i18n.minutes || 'minutos') : (i18n.minute || 'minuto');
durationText += `${diffMins} ${mLabel}`;
} }
$('#summary-duration').text(durationText); $('#summary-duration').text(durationText || '0 min');
} else { } else {
$('#summary-duration').text('Invalid time range'); $('#summary-duration').text(i18n.invalid_time || 'Rango horario inválido');
} }
} else { } else {
$('#summary-duration').text('Not calculated'); $('#summary-duration').text(i18n.not_calculated || 'No calculada');
} }
} }
// Check capacity vs expected attendees // Check capacity vs expected attendees
function checkCapacity() { function checkCapacity() {
const classroomId = $('#classroom-select').val(); const classroomId = $classroom.val();
const attendees = parseInt($('#expected_attendees').val()) || 0; const attendees = parseInt($('#expected_attendees').val()) || 0;
if (classroomId && classroomData[classroomId] && attendees > 0) { if (classroomId && classroomData[classroomId] && attendees > 0) {
const capacity = classroomData[classroomId].capacity; const c = classroomData[classroomId];
// No validar si es virtual o capacidad ilimitada
if (c.is_virtual || c.capacity === '∞' || c.capacity_num === 0) {
$('#expected_attendees').removeClass('is-invalid');
$('#expected_attendees').siblings('.invalid-feedback').remove();
return;
}
if (attendees > capacity) { if (attendees > c.capacity_num) {
$('#expected_attendees').addClass('is-invalid'); $('#expected_attendees').addClass('is-invalid');
const msg = i18n.capacity_exceeded || 'Los asistentes esperados superan la capacidad del aula';
if ($('#expected_attendees').siblings('.invalid-feedback').length === 0) {
$('#expected_attendees').after(` $('#expected_attendees').after(`
<div class="invalid-feedback"> <div class="invalid-feedback">
Expected attendees (${attendees}) exceeds classroom capacity (${capacity}) ${msg} (${attendees} > ${c.capacity_num})
</div> </div>
`); `);
}
} else { } else {
$('#expected_attendees').removeClass('is-invalid'); $('#expected_attendees').removeClass('is-invalid');
$('#expected_attendees').siblings('.invalid-feedback').remove(); $('#expected_attendees').siblings('.invalid-feedback').remove();
@@ -264,23 +588,35 @@ $(document).ready(function() {
} }
} }
// Update on change $('#commission-select').on('change', function() {
$('#classroom-select, #commission-select, #start-time, #end-time, #expected_attendees').on('change', updateSummary); const cId = $(this).val();
$('#expected_attendees').on('input', checkCapacity); if (cId && commissionData[cId]) {
const comm = commissionData[cId];
if (comm.virtual_link && !$('#virtual_link').val().trim()) {
$('#virtual_link').val(comm.virtual_link);
}
}
updateSummary();
});
$('#virtual_link').on('input change', updateSummary);
$('#start-time, #end-time').on('change', updateSummary);
$('#expected_attendees').on('input', function() {
checkCapacity();
updateSummary();
});
// Initialize summary
updateSummary(); updateSummary();
// Form validation // Form validation
$('#reservation-form').on('submit', function(e) { $('#reservation-form').on('submit', function(e) {
let isValid = true; let isValid = true;
// Check required fields if (!$classroom.val()) {
if (!$('#classroom-select').val()) { $classroom.addClass('is-invalid');
$('#classroom-select').addClass('is-invalid');
isValid = false; isValid = false;
} else { } else {
$('#classroom-select').removeClass('is-invalid'); $classroom.removeClass('is-invalid');
} }
if (!$('#commission-select').val()) { if (!$('#commission-select').val()) {
@@ -304,7 +640,6 @@ $(document).ready(function() {
$('#end-time').removeClass('is-invalid'); $('#end-time').removeClass('is-invalid');
} }
// Validate time range
const startTime = new Date($('#start-time').val()); const startTime = new Date($('#start-time').val());
const endTime = new Date($('#end-time').val()); const endTime = new Date($('#end-time').val());
@@ -313,7 +648,6 @@ $(document).ready(function() {
isValid = false; isValid = false;
} }
// Check capacity
checkCapacity(); checkCapacity();
if ($('#expected_attendees').hasClass('is-invalid')) { if ($('#expected_attendees').hasClass('is-invalid')) {
isValid = false; isValid = false;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+482
View File
@@ -0,0 +1,482 @@
{% extends "base.html" %}
{% block title %}{% trans %}Modificar Reserva{% endtrans %} #{{ reservation.id }} - 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>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1 small">
<li class="breadcrumb-item"><a href="{{ url_for('schedule.today_schedule') }}">{% trans %}Schedule{% endtrans %}</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('schedule.list_reservations') }}">{% trans %}Reservas{% endtrans %}</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}">#{{ reservation.id }}</a></li>
<li class="breadcrumb-item active">{% trans %}Modificar{% endtrans %}</li>
</ol>
</nav>
<h1 class="h3 mb-0">
<i class="bi bi-pencil-square text-warning me-2"></i>{% trans %}Modificar Reserva{% endtrans %} #{{ reservation.id }}
</h1>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>{% trans %}Volver al Detalle{% endtrans %}
</a>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-10 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-calendar3 text-warning me-2"></i>{% trans %}Editar Parámetros de la Reserva{% endtrans %}
</h5>
<span class="badge {% if reservation.status == 'CONFIRMED' %}bg-success{% elif reservation.status == 'PENDING' %}bg-warning{% else %}bg-secondary{% endif %}">
{{ reservation.status }}
</span>
</div>
<div class="card-body p-4">
<form method="POST" id="reservation-form" novalidate>
{{ form.hidden_tag() }}
<!-- 1. SELECTORES EN CASCADA: Edificio -> Piso -> Aula -->
<div class="mb-4">
<div class="p-3 rounded bg-body-tertiary border">
<label class="form-label fw-bold text-primary mb-2">
<i class="bi bi-geo-alt-fill me-1"></i>{% trans %}Espacio Físico / Modalidad{% endtrans %} <span class="text-danger">*</span>
</label>
<div class="row g-3">
<!-- Edificio / Sede -->
<div class="col-md-4">
<label for="cascade-building" class="form-label small fw-semibold text-muted mb-1">
<i class="bi bi-buildings text-primary me-1"></i>1. {% trans %}Edificio / Sede{% endtrans %}
</label>
<select id="cascade-building" class="form-select form-select-sm">
<option value="">{% trans %}-- Selecciona Edificio --{% endtrans %}</option>
{% for b in buildings %}
<option value="{{ b.id }}" data-virtual="{{ 'true' if b.code == 'VIRTUAL' or 'VIRTUAL' in (b.name|upper) else 'false' }}">
{% if b.code == 'VIRTUAL' or 'VIRTUAL' in (b.name|upper) %}🌐{% else %}🏢{% endif %} {{ b.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Piso / Nivel -->
<div class="col-md-4">
<label for="cascade-floor" class="form-label small fw-semibold text-muted mb-1">
<i class="bi bi-layers text-secondary me-1"></i>2. {% trans %}Piso / Nivel{% endtrans %}
</label>
<select id="cascade-floor" class="form-select form-select-sm" disabled>
<option value="">{% trans %}-- Selecciona Piso --{% endtrans %}</option>
</select>
</div>
<!-- Aula / Espacio -->
<div class="col-md-4">
<label for="classroom_id" class="form-label small fw-semibold text-muted mb-1">
<i class="bi bi-door-open text-success me-1"></i>3. {% trans %}Aula Final{% endtrans %} <span class="text-danger">*</span>
</label>
{{ form.classroom_id(class="form-select form-select-sm", id="classroom_id") }}
{% if form.classroom_id.errors %}
<div class="invalid-feedback d-block">{{ form.classroom_id.errors[0] }}</div>
{% endif %}
</div>
</div>
<!-- Card de Info Dinámica del Aula -->
<div id="classroom-info-card" class="mt-3 p-2 rounded bg-body border" style="display: none;">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
<div>
<span class="badge bg-primary me-2" id="info-room-code">-</span>
<small class="text-muted" id="info-room-location">-</small>
</div>
<div>
<span class="badge bg-info-subtle text-info border me-1" id="info-room-capacity">Cap: -</span>
<span class="badge bg-success-subtle text-success border" id="info-room-type">Presencial</span>
</div>
</div>
</div>
</div>
</div>
<!-- 2. DATOS ACADÉMICOS Y HORARIOS -->
<div class="row g-3 mb-4">
<div class="col-md-6">
<label for="commission_id" class="form-label fw-bold text-primary mb-1">
<i class="bi bi-diagram-3 me-1"></i>{% trans %}Comisión Académica{% endtrans %}
</label>
{{ form.commission_id(class="form-select", id="commission_id") }}
<div class="form-text small text-muted">{% trans %}Comisión y asignatura vinculada a esta reserva.{% endtrans %}</div>
{% if form.commission_id.errors %}
<div class="invalid-feedback d-block">{{ form.commission_id.errors[0] }}</div>
{% endif %}
</div>
<div class="col-md-3">
<label for="start_time" class="form-label fw-bold text-primary mb-1">
<i class="bi bi-clock me-1"></i>{% trans %}Inicio{% endtrans %} <span class="text-danger">*</span>
</label>
{{ form.start_time(class="form-control", id="start_time", type="datetime-local") }}
{% if form.start_time.errors %}
<div class="invalid-feedback d-block">{{ form.start_time.errors[0] }}</div>
{% endif %}
</div>
<div class="col-md-3">
<label for="end_time" class="form-label fw-bold text-primary mb-1">
<i class="bi bi-clock-fill me-1"></i>{% trans %}Fin{% endtrans %} <span class="text-danger">*</span>
</label>
{{ form.end_time(class="form-control", id="end_time", type="datetime-local") }}
{% if form.end_time.errors %}
<div class="invalid-feedback d-block">{{ form.end_time.errors[0] }}</div>
{% endif %}
</div>
</div>
<!-- 3. PROPÓSITO Y ASISTENTES -->
<div class="row g-3 mb-4">
<div class="col-md-8">
<label for="purpose" class="form-label fw-bold text-primary mb-1">
<i class="bi bi-journal-text me-1"></i>{% trans %}Propósito / Motivo{% endtrans %} <span class="text-danger">*</span>
</label>
{{ form.purpose(class="form-control", id="purpose", placeholder="ej. Cursada regular, Examen Parcial, Clase Especial") }}
{% if form.purpose.errors %}
<div class="invalid-feedback d-block">{{ form.purpose.errors[0] }}</div>
{% endif %}
</div>
<div class="col-md-4">
<label for="expected_attendees" class="form-label fw-bold text-primary mb-1">
<i class="bi bi-people me-1"></i>{% trans %}Asistentes Estimados{% endtrans %}
</label>
{{ form.expected_attendees(class="form-control", id="expected_attendees", type="number", min="0") }}
{% if form.expected_attendees.errors %}
<div class="invalid-feedback d-block">{{ form.expected_attendees.errors[0] }}</div>
{% endif %}
</div>
</div>
<!-- 4. ENLACE DE AULA VIRTUAL (Zoom, Meet, Teams, etc) -->
<div class="mb-4">
<div class="p-3 rounded bg-primary-subtle border border-primary-subtle">
<label for="virtual_link" class="form-label fw-bold text-primary mb-1">
<i class="bi bi-camera-video-fill me-1"></i>{% trans %}Enlace de Aula Virtual / Videoconferencia{% endtrans %}
</label>
<div class="input-group">
<span class="input-group-text bg-body text-primary"><i class="bi bi-link-45deg"></i></span>
{{ form.virtual_link(class="form-control font-monospace", id="virtual_link", placeholder="https://zoom.us/j/... o https://meet.google.com/...") }}
<button type="button" class="btn btn-outline-primary" id="btn-test-link" title="{% trans %}Probar enlace en nueva pestaña{% endtrans %}">
<i class="bi bi-box-arrow-up-right me-1"></i>{% trans %}Probar{% endtrans %}
</button>
</div>
<div class="form-text small text-muted">
{% trans %}Enlace directo a Zoom, Google Meet, Teams o Campus Virtual. Si se ingresa, se habilitará el botón de acceso directo en cartelera y cronograma.{% endtrans %}
</div>
{% if form.virtual_link.errors %}
<div class="invalid-feedback d-block">{{ form.virtual_link.errors[0] }}</div>
{% endif %}
</div>
</div>
<!-- 5. NOTAS ADICIONALES -->
<div class="mb-4">
<label for="notes" class="form-label fw-bold text-primary mb-1">
<i class="bi bi-card-text me-1"></i>{% trans %}Observaciones / Notas Adicionales{% endtrans %}
</label>
{{ form.notes(class="form-control", id="notes", rows="3", placeholder="Observaciones sobre equipamiento, requerimientos o modalidad...") }}
{% if form.notes.errors %}
<div class="invalid-feedback d-block">{{ form.notes.errors[0] }}</div>
{% endif %}
</div>
<!-- 6. RESUMEN DINÁMICO DE MODIFICACIÓN -->
<div class="card bg-body-tertiary border mb-4 shadow-none">
<div class="card-header bg-transparent py-2 border-bottom">
<h6 class="card-title mb-0 small fw-bold text-primary">
<i class="bi bi-check2-circle me-1"></i>{% trans %}Resumen de Cambios{% endtrans %}
</h6>
</div>
<div class="card-body p-3">
<div class="row g-2 small mb-2">
<div class="col-md-4">
<span class="text-muted">🏢 {% trans %}Edificio:{% endtrans %}</span>
<strong id="summary-building" class="text-body ms-1">-</strong>
</div>
<div class="col-md-4">
<span class="text-muted">📍 {% trans %}Piso / Nivel:{% endtrans %}</span>
<strong id="summary-floor" class="text-body ms-1">-</strong>
</div>
<div class="col-md-4">
<span class="text-muted">🚪 {% trans %}Aula Seleccionada:{% endtrans %}</span>
<strong id="summary-classroom" class="text-primary ms-1">-</strong>
</div>
</div>
<div class="row g-2 small mb-2">
<div class="col-md-4">
<span class="text-muted">⏱️ {% trans %}Duración:{% endtrans %}</span>
<strong id="summary-duration" class="text-body ms-1">-</strong>
</div>
<div class="col-md-4">
<span class="text-muted">👥 {% trans %}Capacidad:{% endtrans %}</span>
<strong id="summary-capacity" class="text-body ms-1">-</strong>
</div>
<div class="col-md-4">
<span class="text-muted">📚 {% trans %}Comisión:{% endtrans %}</span>
<strong id="summary-commission" class="text-body ms-1">-</strong>
</div>
</div>
<div class="p-2 rounded bg-body border d-flex align-items-center justify-content-between">
<div class="text-truncate me-2">
<small class="text-muted d-block">🎥 {% trans %}Enlace de Aula Virtual:{% endtrans %}</small>
<span id="summary-virtual-link" class="small font-monospace text-primary text-break">{% trans %}Sin enlace configurado{% endtrans %}</span>
</div>
<span class="badge bg-success-subtle text-success border" id="summary-virtual-badge" style="display:none;">
<i class="bi bi-camera-video me-1"></i>Virtual
</span>
</div>
</div>
</div>
<!-- Botones -->
<div class="d-flex justify-content-between align-items-center">
<a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle me-1"></i>{% trans %}Cancelar{% endtrans %}
</a>
<button type="submit" class="btn btn-warning px-4 fw-bold">
<i class="bi bi-check-circle me-1"></i>{% trans %}Guardar Modificaciones{% endtrans %}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script type="application/json" id="classroom-data">
{
{% for c in classroom_list %}
"{{ c.id }}": {
"id": {{ c.id }},
"building_id": {{ (c.building_id or 0)|tojson }},
"building_name": {{ c.building_name|tojson }},
"floor": {{ c.floor|tojson }},
"floor_display": {{ c.floor_display|tojson }},
"code": {{ c.code|tojson }},
"code_display": {{ c.code_display|tojson }},
"location_display": {{ c.location_display|tojson }},
"capacity": {{ c.capacity_display|tojson }},
"capacity_num": {{ c.capacity }},
"is_virtual": {{ c.is_virtual|tojson }}
}{% if not loop.last %},{% endif %}
{% endfor %}
}
</script>
<script type="application/json" id="commission-data">
{
{% for c in commission_list %}
"{{ c.id }}": {
"id": {{ c.id }},
"code": {{ c.code|tojson }},
"virtual_link": {{ (c.virtual_link or '')|tojson }},
"max_students": {{ c.max_students }}
}{% if not loop.last %},{% endif %}
{% endfor %}
}
</script>
<script>
$(document).ready(function() {
let classroomData = {};
try {
const rawData = document.getElementById('classroom-data')?.textContent;
if (rawData) classroomData = JSON.parse(rawData);
} catch (err) {
console.error('Error parsing classroomData:', err);
}
let commissionData = {};
try {
const rawComm = document.getElementById('commission-data')?.textContent;
if (rawComm) commissionData = JSON.parse(rawComm);
} catch (err) {
console.error('Error parsing commissionData:', err);
}
const $bldgSelect = $('#cascade-building');
const $floorSelect = $('#cascade-floor');
const $roomSelect = $('#classroom_id');
const $commSelect = $('#commission_id');
const $virtualLinkInput = $('#virtual_link');
const $btnTestLink = $('#btn-test-link');
const initialRoomId = "{{ reservation.classroom_id }}";
// 1. Población de Pisos al cambiar Edificio
$bldgSelect.on('change', function() {
const selectedBldg = $(this).val();
$floorSelect.empty().append('<option value="">-- Selecciona Piso --</option>');
$roomSelect.empty().append('<option value="">-- Selecciona Aula --</option>');
if (!selectedBldg) {
$floorSelect.prop('disabled', true);
$roomSelect.prop('disabled', true);
updateSummary();
return;
}
const floors = new Set();
Object.values(classroomData).forEach(function(room) {
if (String(room.building_id) === String(selectedBldg)) {
floors.add(room.floor);
}
});
const sortedFloors = Array.from(floors).sort(function(a, b) {
if (a === '0' || a === 'PB') return -1;
if (b === '0' || b === 'PB') return 1;
return String(a).localeCompare(String(b), undefined, {numeric: true});
});
sortedFloors.forEach(function(fl) {
let label = (fl === '0' || fl === 'PB') ? 'Planta Baja' : ('Piso ' + fl);
$floorSelect.append($('<option>', { value: fl, text: label }));
});
$floorSelect.prop('disabled', false);
$roomSelect.prop('disabled', true);
updateSummary();
});
// 2. Población de Aulas al cambiar Piso
$floorSelect.on('change', function() {
const selectedBldg = $bldgSelect.val();
const selectedFloor = $(this).val();
$roomSelect.empty().append('<option value="">-- Selecciona Aula --</option>');
if (!selectedFloor) {
$roomSelect.prop('disabled', true);
updateSummary();
return;
}
const matchingRooms = Object.values(classroomData).filter(function(room) {
return String(room.building_id) === String(selectedBldg) && String(room.floor) === String(selectedFloor);
});
matchingRooms.sort(function(a, b) { return a.code.localeCompare(b.code); });
matchingRooms.forEach(function(room) {
const capText = room.is_virtual ? '∞ Remoto' : ('Cap: ' + room.capacity);
$roomSelect.append($('<option>', {
value: room.id,
text: room.code + ' (' + capText + ')'
}));
});
$roomSelect.prop('disabled', false);
updateSummary();
});
// 3. Cambio de Aula
$roomSelect.on('change', function() {
updateClassroomInfo();
updateSummary();
});
// 4. Preselección inicial de Aula actual
if (initialRoomId && classroomData[initialRoomId]) {
const initRoom = classroomData[initialRoomId];
$bldgSelect.val(initRoom.building_id).trigger('change');
$floorSelect.val(initRoom.floor).trigger('change');
$roomSelect.val(initRoom.id).trigger('change');
}
function updateClassroomInfo() {
const roomId = $roomSelect.val();
const room = classroomData[roomId];
const $card = $('#classroom-info-card');
if (!room) {
$card.slideUp(150);
return;
}
$('#info-room-code').text(room.code);
$('#info-room-location').text(room.location_display);
$('#info-room-capacity').text('Capacidad: ' + room.capacity);
if (room.is_virtual) {
$('#info-room-type').removeClass('bg-success-subtle text-success').addClass('bg-primary-subtle text-primary').text('🌐 Campus Virtual');
} else {
$('#info-room-type').removeClass('bg-primary-subtle text-primary').addClass('bg-success-subtle text-success').text('🏢 Presencial');
}
$card.slideDown(150);
}
function updateSummary() {
const bldgName = $bldgSelect.find('option:selected').text().trim();
const floorName = $floorSelect.find('option:selected').text().trim();
const roomId = $roomSelect.val();
const room = classroomData[roomId];
$('#summary-building').text(bldgName && !$bldgSelect.val() ? '-' : (bldgName || '-'));
$('#summary-floor').text(floorName && !$floorSelect.val() ? '-' : (floorName || '-'));
$('#summary-classroom').text(room ? room.code : '-');
$('#summary-capacity').text(room ? (room.is_virtual ? '∞ Ilimitada' : room.capacity) : '-');
const commText = $commSelect.find('option:selected').text().trim();
$('#summary-commission').text(commText && $commSelect.val() ? commText : '-');
// Duración
const st = $('#start_time').val();
const et = $('#end_time').val();
if (st && et) {
const startDate = new Date(st);
const endDate = new Date(et);
const diffMs = endDate - startDate;
if (diffMs > 0) {
const diffHrs = Math.floor(diffMs / 3600000);
const diffMins = Math.round((diffMs % 3600000) / 60000);
let durText = '';
if (diffHrs > 0) durText += diffHrs + ' hs ';
if (diffMins > 0) durText += diffMins + ' min';
$('#summary-duration').text(durText);
} else {
$('#summary-duration').text('Rango inválido');
}
}
// Enlace virtual
const vLink = $virtualLinkInput.val() ? $virtualLinkInput.val().trim() : '';
if (vLink) {
$('#summary-virtual-link').html('<a href="' + vLink + '" target="_blank" class="text-decoration-none text-primary"><i class="bi bi-box-arrow-up-right me-1"></i>' + vLink + '</a>');
$('#summary-virtual-badge').show();
} else {
$('#summary-virtual-link').text('Sin enlace configurado');
$('#summary-virtual-badge').hide();
}
}
// Eventos reactivos
$('#start_time, #end_time, #commission_id, #virtual_link').on('input change', updateSummary);
// Botón Probar enlace
$btnTestLink.on('click', function() {
const link = $virtualLinkInput.val() ? $virtualLinkInput.val().trim() : '';
if (link) {
window.open(link, '_blank');
} else {
alert('Por favor ingresa primero una URL para probar.');
}
});
updateSummary();
});
</script>
{% endblock %}
+193 -198
View File
@@ -1,228 +1,265 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Reservations List - Edu-Space Admin{% endblock %} {% block title %}{% trans %}Listado de Reservas{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %} {% block content %}
<div class="row mb-4"> <div class="container-fluid pt-5 mt-4">
<div class="col-12"> <!-- Header -->
<div class="d-flex justify-content-between align-items-center"> <div class="row mb-4 align-items-center">
<h1 class="h3 mb-0"> <div class="col-md-7">
<i class="bi bi-calendar-list text-primary"></i> Reservations <h1 class="h3 mb-1">
<small class="text-muted ms-2">Manage & View All Reservations</small> <i class="bi bi-calendar-check text-primary me-2"></i>{% trans %}Listado de Reservas{% endtrans %}
</h1> </h1>
<div class="d-flex gap-2"> <p class="text-muted small mb-0">{% trans %}Registro histórico y programación completa de clases y asignación de aulas{% endtrans %}</p>
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary"> </div>
<i class="bi bi-calendar3"></i> Calendar View <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>
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-info"> <a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-info">
<i class="bi bi-calendar-day"></i> Today <i class="bi bi-calendar-day me-1"></i>{% trans %}Cartelera de Hoy{% endtrans %}
</a> </a>
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add Reservation
</a>
</div>
</div>
</div> </div>
</div> </div>
<!-- Filters --> <!-- Filters Card -->
<div class="card border-0 shadow-sm mb-4"> <div class="card border-0 shadow-sm mb-4">
<div class="card-body"> <div class="card-body p-3">
<form method="GET" class="row g-3"> <form method="GET" action="{{ url_for('schedule.list_reservations') }}" class="row g-2 align-items-end">
<div class="col-md-3"> <!-- Search text -->
<label class="form-label">Status</label> <div class="col-lg-3 col-md-6">
<select name="status" class="form-select"> <label class="form-label small fw-bold text-muted mb-1">
<option value="all" {% if status_filter == 'all' %}selected{% endif %}>All Status</option> <i class="bi bi-search me-1"></i>{% trans %}Buscar{% endtrans %}
<option value="pending" {% if status_filter == 'pending' %}selected{% endif %}>Pending</option> </label>
<option value="confirmed" {% if status_filter == 'confirmed' %}selected{% endif %}>Confirmed</option> <input type="text" name="search" class="form-control form-control-sm" placeholder="Materia, código o aula..." value="{{ search or '' }}">
<option value="cancelled" {% if status_filter == 'cancelled' %}selected{% endif %}>Cancelled</option> </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> </select>
</div> </div>
<div class="col-md-3"> <!-- Floor Filter -->
<label class="form-label">From Date</label> <div class="col-lg-2 col-md-3">
<input type="date" name="date_from" class="form-control" value="{{ date_from or '' }}"> <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> </div>
<div class="col-md-3"> <!-- Aula Filter -->
<label class="form-label">To Date</label> <div class="col-lg-3 col-md-4">
<input type="date" name="date_to" class="form-control" value="{{ date_to or '' }}"> <label class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-door-open text-primary me-1"></i>{% trans %}Aula / Espacio{% endtrans %}
</label>
<select name="classroom_id" class="form-select form-select-sm">
<option value="">{% trans %}Todas las aulas{% endtrans %}</option>
{% for c in all_classrooms %}
<option value="{{ c.id }}" {% if classroom_id == c.id %}selected{% endif %}>{{ c.building_name }} - {{ c.code }}</option>
{% endfor %}
</select>
</div> </div>
<div class="col-md-3"> <!-- Shift Filter -->
<label class="form-label">&nbsp;</label> <div class="col-lg-2 col-md-3">
<div class="d-flex gap-2"> <label class="form-label small fw-bold text-muted mb-1">
<button type="submit" class="btn btn-outline-primary"> <i class="bi bi-brightness-high text-warning me-1"></i>{% trans %}Turno{% endtrans %}
<i class="bi bi-funnel"></i> Filter </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 From -->
<div class="col-lg-2 col-md-3">
<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>
<!-- Date Range To -->
<div class="col-lg-2 col-md-3">
<label class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-calendar-event me-1"></i>{% trans %}Hasta Fecha{% endtrans %}
</label>
<input type="date" name="date_to" class="form-control form-control-sm" value="{{ date_to 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> </button>
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary"> <a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary btn-sm px-3">
<i class="bi bi-x-circle"></i> Clear <i class="bi bi-arrow-counterclockwise me-1"></i>{% trans %}Limpiar{% endtrans %}
</a> </a>
</div> </div>
</div>
</form> </form>
</div> </div>
</div> </div>
<!-- Reservations List --> <!-- Reservations Table -->
{% if reservations and reservations.items %} {% if reservations and reservations.items %}
<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 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="card-body p-0">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover align-middle mb-0">
<thead class="table-light"> <thead class="table-light small text-uppercase">
<tr> <tr>
<th class="border-0">Classroom</th> <th class="ps-3">{% trans %}Fecha y Horario{% endtrans %}</th>
<th class="border-0">Commission</th> <th>{% trans %}Aula / Piso{% endtrans %}</th>
<th class="border-0">Date & Time</th> <th>{% trans %}Asignatura / Código{% endtrans %}</th>
<th class="border-0">Purpose</th> <th>{% trans %}Turno / Comisión{% endtrans %}</th>
<th class="border-0">Attendees</th> <th>{% trans %}Capacidad{% endtrans %}</th>
<th class="border-0">Status</th> <th>{% trans %}Estado{% endtrans %}</th>
<th class="border-0">Actions</th> <th class="text-end pe-3">{% trans %}Acciones{% endtrans %}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for reservation in reservations.items %} {% for reservation in reservations.items %}
<tr> <tr>
<td> <td class="ps-3">
<div class="d-flex align-items-center"> <div class="fw-bold">{{ reservation.start_time.strftime('%d/%m/%Y') }}</div>
<div class="avatar avatar-sm bg-primary text-white rounded-circle d-flex align-items-center justify-content-center me-2"> <small class="text-muted font-monospace">{{ reservation.start_time.strftime('%H:%M') }} - {{ reservation.end_time.strftime('%H:%M') }} hs</small>
<i class="bi bi-door-open"></i>
</div>
<div>
<div class="fw-bold">{{ reservation.classroom.code_display }}</div>
<small class="text-muted">Capacity: {{ reservation.classroom.capacity }}</small>
</div>
</div>
</td> </td>
<td> <td>
<div> <span class="badge bg-secondary-subtle text-secondary border font-monospace">
<div class="fw-bold"> {{ reservation.classroom.code if reservation.classroom else 'Sin Aula' }}
{% if reservation.commission %}
{{ reservation.commission.get_full_code() or 'N/A' }}
{% else %}
No Commission
{% endif %}
</div>
<small class="text-muted">
{% if reservation.commission and reservation.commission.teacher_name %}
{{ reservation.commission.teacher_name }}
{% else %}
No Teacher
{% endif %}
</small>
</div>
</td>
<td>
<div>
<div class="fw-bold">{{ reservation.start_time.strftime('%b %d, %Y') }}</div>
<small class="text-muted">
{{ reservation.start_time.strftime('%I:%M %p') }} - {{ reservation.end_time.strftime('%I:%M %p') }}
</small>
</div>
</td>
<td>
<span class="text-truncate" style="max-width: 150px; display: inline-block;">
{{ reservation.purpose or 'No purpose specified' }}
</span> </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>
<td> <td>
<div class="d-flex align-items-center"> <div class="fw-bold text-primary">{{ reservation.subject_name }}</div>
<i class="bi bi-people text-muted me-1"></i> <small class="badge bg-secondary-subtle text-secondary font-monospace">{{ reservation.subject_code }}</small>
{{ reservation.expected_attendees }}
</div>
</td> </td>
<td> <td>
{% if reservation.status.value == 'confirmed' %} <span class="badge bg-info-subtle text-info border me-1">
<span class="badge bg-success">Confirmed</span> {{ reservation.shift or 'Tarde' }}
{% elif reservation.status.value == 'pending' %} </span>
<span class="badge bg-warning">Pending</span> <small class="text-muted">{{ reservation.commission.code if reservation.commission else 'C1' }}</small>
{% elif reservation.status.value == 'cancelled' %} </td>
<span class="badge bg-danger">Cancelled</span> <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 %} {% else %}
<span class="badge bg-secondary">{{ reservation.status.value }}</span> <span class="badge bg-danger-subtle text-danger border border-danger-subtle">
{% trans %}Cancelada{% endtrans %}
</span>
{% endif %} {% endif %}
</td> </td>
<td class="text-end pe-3">
<td> {% if reservation.effective_virtual_link %}
<div class="btn-group btn-group-sm" role="group"> <a href="{{ reservation.effective_virtual_link }}" target="_blank"
{% if reservation.status.value == 'pending' %} class="btn btn-sm btn-outline-success py-0 px-2 me-1" title="{% trans %}Unirse a Clase Virtual (Zoom/Meet){% endtrans %}">
<form method="POST" action="{{ url_for('schedule.confirm_reservation', id=reservation.id) }}" style="display: inline;"> <i class="bi bi-camera-video"></i>
<button type="submit" class="btn btn-outline-success btn-sm" title="Confirm"> </a>
<i class="bi bi-check-circle"></i>
</button>
</form>
{% endif %} {% endif %}
{% if current_user.can_edit_reservation(reservation) %}
{% if reservation.status.value in ['pending', 'confirmed'] %} <a href="{{ url_for('schedule.edit_reservation', id=reservation.id) }}"
<form method="POST" action="{{ url_for('schedule.cancel_reservation', id=reservation.id) }}" style="display: inline;"> class="btn btn-sm btn-outline-warning py-0 px-2 me-1" title="{% trans %}Modificar Reserva{% endtrans %}">
<button type="submit" class="btn btn-outline-danger btn-sm" title="Cancel"> <i class="bi bi-pencil"></i>
<i class="bi bi-x-circle"></i> </a>
</button>
</form>
{% endif %} {% endif %}
<a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}"
{% if reservation.notes %} class="btn btn-sm btn-outline-secondary py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}">
<button type="button" class="btn btn-outline-info btn-sm" <i class="bi bi-eye"></i>
title="{{ reservation.notes }}" data-bs-toggle="tooltip"> </a>
<i class="bi bi-info-circle"></i> {% if current_user.has_permission('reservations', 'read_write') and (reservation.status == 'PENDING' or reservation.status.value == 'PENDING') %}
</button> <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 %} {% endif %}
</div>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
<!-- Pagination --> <!-- Pagination -->
{% if reservations.pages > 1 %} {% if reservations.pages > 1 %}
<div class="card-footer bg-white border-0"> <div class="card-footer bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
<div class="d-flex justify-content-between align-items-center"> <small class="text-muted">{% trans %}Mostrando 20 registros por página{% endtrans %}</small>
<span class="text-muted small">
Showing {{ reservations.items|length }} of {{ reservations.total }} reservations
</span>
<nav> <nav>
<ul class="pagination pagination-sm mb-0"> <ul class="pagination pagination-sm mb-0">
{% if reservations.has_prev %} {% if reservations.has_prev %}
<li class="page-item"> <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) }}"> <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, classroom_id=classroom_id or '', hide_virtual='1' if hide_virtual else '', search=search) }}">
<i class="bi bi-chevron-left"></i> <i class="bi bi-chevron-left"></i>
</a> </a>
</li> </li>
{% endif %} {% endif %}
{% for page_num in reservations.iter_pages() %} {% for p in reservations.iter_pages(left_edge=2, left_current=2, right_current=2, right_edge=2) %}
{% if page_num %} {% if p %}
{% if page_num != reservations.page %} {% if p == reservations.page %}
<li class="page-item"> <li class="page-item active"><span class="page-link">{{ p }}</span></li>
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=page_num, status=status_filter, date_from=date_from, date_to=date_to) }}">
{{ page_num }}
</a>
</li>
{% else %} {% else %}
<li class="page-item active"> <li class="page-item">
<span class="page-link">{{ page_num }}</span> <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, classroom_id=classroom_id or '', hide_virtual='1' if hide_virtual else '', search=search) }}">{{ p }}</a>
</li> </li>
{% endif %} {% endif %}
{% else %} {% else %}
<li class="page-item disabled"> <li class="page-item disabled"><span class="page-link">…</span></li>
<span class="page-link">…</span>
</li>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if reservations.has_next %} {% if reservations.has_next %}
<li class="page-item"> <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) }}"> <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, classroom_id=classroom_id or '', hide_virtual='1' if hide_virtual else '', search=search) }}">
<i class="bi bi-chevron-right"></i> <i class="bi bi-chevron-right"></i>
</a> </a>
</li> </li>
@@ -230,62 +267,20 @@
</ul> </ul>
</nav> </nav>
</div> </div>
</div>
{% endif %} {% endif %}
</div> </div>
</div>
{% else %} {% else %}
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-body text-center py-5"> <div class="card-body text-center py-5">
<div class="mb-4"> <i class="bi bi-calendar-x text-muted fs-1 d-block mb-3"></i>
<i class="bi bi-calendar-x display-1 text-muted"></i> <h5 class="fw-bold">{% trans %}No se encontraron reservas{% endtrans %}</h5>
</div> <p class="text-muted small mb-3">{% trans %}No hay registros que coincidan con los criterios de búsqueda o filtros seleccionados.{% endtrans %}</p>
<h5 class="text-muted">No reservations found</h5> <a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-primary btn-sm">
<p class="text-muted"> <i class="bi bi-arrow-counterclockwise me-1"></i>{% trans %}Restablecer Todos los Filtros{% endtrans %}
{% if status_filter != 'all' or date_from or date_to %}
No reservations match your current filters.
<a href="{{ url_for('schedule.list_reservations') }}">Clear filters</a> to see all reservations.
{% else %}
There are no reservations in the system yet.
<a href="{{ url_for('schedule.add_reservation') }}">Create your first reservation</a>.
{% endif %}
</p>
<div class="d-flex justify-content-center gap-2 mt-3">
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add Reservation
</a>
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-info">
<i class="bi bi-calendar-day"></i> Today's Schedule
</a> </a>
</div> </div>
</div> </div>
</div>
{% endif %} {% endif %}
{% endblock %} </div>
{% block extra_js %}
<script>
// Initialize tooltips
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl)
});
// Form submission confirmation
document.querySelectorAll('form[action*="cancel"]').forEach(function(form) {
form.addEventListener('submit', function(e) {
if (!confirm('Are you sure you want to cancel this reservation?')) {
e.preventDefault();
}
});
});
document.querySelectorAll('form[action*="confirm"]').forEach(function(form) {
form.addEventListener('submit', function(e) {
if (!confirm('Are you sure you want to confirm this reservation?')) {
e.preventDefault();
}
});
});
</script>
{% endblock %} {% endblock %}
+249 -272
View File
@@ -1,98 +1,141 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Today's Schedule - Edu-Space Admin{% endblock %} {% block title %}{% trans %}Cartelera de Hoy{% 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">
<div class="row"> <!-- Header -->
<div class="col-12"> <div class="row mb-4 align-items-center">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="col-md-7">
<h1 class="h3"> <h1 class="h3 mb-1">
<i class="bi bi-calendar-today text-info"></i> Today's Schedule <i class="bi bi-calendar-day text-info me-2"></i>{% trans %}Cartelera del Día{% endtrans %}
<small class="text-muted ms-2">{{ today.strftime('%B %d, %Y') }}</small> <small class="text-muted ms-2 fs-6">{{ format_date(today, format='full')|capitalize }}</small>
</h1> </h1>
<div> <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>
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-success"> </div>
<i class="bi bi-plus-circle"></i> New Reservation <div class="col-md-5 text-md-end mt-3 mt-md-0">
</a> {% if current_user.has_permission('reservations', 'read_write') %}
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-primary ms-2"> <a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-success me-2">
<i class="bi bi-list"></i> All Reservations <i class="bi bi-plus-circle me-1"></i>{% trans %}Nueva Reserva{% endtrans %}
</a>
{# Access to Genetic Algorithm Optimizer - Only for admin/teacher roles #}
{% if current_user and current_user.role in ['admin', 'teacher'] %}
<a href="{{ url_for('main.genetic_optimizer') }}" class="btn btn-info text-white ms-2">
<i class="bi bi-cpu"></i> AI Optimizer
</a> </a>
{% endif %} {% endif %}
</div> <a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary me-2">
</div> <i class="bi bi-calendar3 me-1"></i>{% trans %}Ver Calendario{% endtrans %}
</a>
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
<i class="bi bi-list me-1"></i>{% trans %}Todas las Reservas{% endtrans %}
</a>
</div> </div>
</div> </div>
<!-- Quick Stats --> <!-- Quick Stats Cards with Shortcuts -->
<div class="row mb-4"> <div class="row mb-4 g-3">
<div class="col-md-3 mb-3"> <div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm h-100">
<div class="card-body"> <div class="card-body p-3 d-flex justify-content-between align-items-center">
<div class="d-flex justify-content-between align-items-center">
<div> <div>
<h6 class="text-muted mb-2">Total Reservations</h6> <span class="text-muted small fw-semibold text-uppercase">{% trans %}Total de Reservas{% endtrans %}</span>
<h2 class="mb-0">{{ 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"> <div class="bg-info bg-opacity-10 rounded-circle p-3 text-info">
<i class="bi bi-calendar3 text-info fs-4"></i> <i class="bi bi-calendar3 fs-3"></i>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body p-3 d-flex justify-content-between align-items-center">
<div>
<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>
</div>
<div class="bg-success bg-opacity-10 rounded-circle p-3 text-success">
<i class="bi bi-check-circle fs-3"></i>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body p-3 d-flex justify-content-between align-items-center">
<div>
<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>
</div>
<div class="bg-warning bg-opacity-10 rounded-circle p-3 text-warning">
<i class="bi bi-clock fs-3"></i>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body p-3 d-flex justify-content-between align-items-center">
<div>
<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>
</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> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-3 mb-3"> <!-- Filters Toolbar (Piso, Turno y Ocultar Virtuales) -->
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm mb-4">
<div class="card-body"> <div class="card-body p-3">
<div class="d-flex justify-content-between align-items-center"> <form method="GET" action="{{ url_for('schedule.today_schedule') }}" class="row g-2 align-items-center">
<div> <!-- Filter by Floor -->
<h6 class="text-muted mb-2">Confirmed</h6> <div class="col-lg-3 col-md-4">
<h2 class="mb-0">{{ stats.confirmed }}</h2> <label for="floorSelect" class="form-label small fw-bold text-muted mb-1">
</div> <i class="bi bi-layers text-secondary me-1"></i>{% trans %}Piso / Planta{% endtrans %}
<div class="bg-success bg-opacity-10 rounded-circle p-3"> </label>
<i class="bi bi-check-circle text-success fs-4"></i> <select name="floor" id="floorSelect" class="form-select form-select-sm">
</div> <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 (Piso 0){% endtrans %}{% else %}{% trans %}Piso{% endtrans %} {{ f }}{% endif %}
</option>
{% endfor %}
</select>
</div> </div>
<!-- Filter by Turno -->
<div class="col-lg-3 col-md-4">
<label for="shiftSelect" 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" id="shiftSelect" 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 / Vespertino{% endtrans %}</option>
</select>
</div> </div>
<!-- Hide Virtual Classrooms Toggle -->
<div class="col-lg-4 col-md-4 pt-md-3">
<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()">
<label class="form-check-label fw-semibold small text-dark" for="hideVirtualToday">
<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> </div>
<div class="col-md-3 mb-3"> <!-- Actions -->
<div class="card border-0 shadow-sm"> <div class="col-lg-2 col-md-12 pt-md-3 text-lg-end d-flex gap-2">
<div class="card-body"> <button type="submit" class="btn btn-primary btn-sm flex-fill">
<div class="d-flex justify-content-between align-items-center"> <i class="bi bi-funnel me-1"></i>{% trans %}Filtrar{% endtrans %}
<div> </button>
<h6 class="text-muted mb-2">Pending</h6> <a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-secondary btn-sm" title="{% trans %}Limpiar Filtros{% endtrans %}">
<h2 class="mb-0">{{ stats.pending }}</h2> <i class="bi bi-arrow-counterclockwise"></i>
</div> </a>
<div class="bg-warning bg-opacity-10 rounded-circle p-3">
<i class="bi bi-clock text-warning fs-4"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-2">Active Classrooms</h6>
<h2 class="mb-0">{{ stats.occupied_classrooms }}/{{ stats.total_classrooms }}</h2>
</div>
<div class="bg-primary bg-opacity-10 rounded-circle p-3">
<i class="bi bi-door-open text-primary fs-4"></i>
</div>
</div>
</div>
</div> </div>
</form>
</div> </div>
</div> </div>
@@ -101,83 +144,91 @@
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0"> <div class="card-header border-0 py-3 bg-body-tertiary">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
<h5 class="mb-0"> <h5 class="mb-0 h6 fw-bold">
<i class="bi bi-clock-history text-info"></i> Timeline View <i class="bi bi-clock-history text-info me-2"></i>{% trans %}Vista Cronológica por Horario{% endtrans %}
</h5> </h5>
<div> <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"></i> Refresh <i class="bi bi-arrow-clockwise me-1"></i>{% trans %}Actualizar{% endtrans %}
</button> </button>
<button class="btn btn-sm btn-outline-secondary ms-1" id="toggle-view-btn"> <button class="btn btn-sm btn-outline-secondary" id="toggle-view-btn">
<i class="bi bi-grid"></i> Grid View <i class="bi bi-grid me-1"></i>{% trans %}Vista en Tabla{% endtrans %}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<div class="card-body"> <div class="card-body p-3">
<!-- Timeline View -->
<div id="timeline-view"> <div id="timeline-view">
{% for hour in range(7, 22) %} {% for hour in range(7, 22) %}
<div class="row border-bottom py-3 align-items-start"> <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"> <div class="text-center p-2 rounded bg-body-tertiary border">
<strong>{{ "%02d:00"|format(hour) }}</strong> <strong class="h5 text-primary mb-0 font-monospace">{{ "%02d:00"|format(hour) }}</strong>
<br> <div class="small text-muted font-monospace">{{ hour+1 }}:00 hs</div>
<small class="text-muted">{{ hour+1 }}:00</small>
</div> </div>
</div> </div>
<div class="col-md-10 col-9"> <div class="col-md-10 col-9">
{% if time_blocks[hour] %} {% if time_blocks[hour] %}
<div class="row"> <div class="row g-2">
{% for item in time_blocks[hour] %} {% for item in time_blocks[hour] %}
<div class="col-md-6 mb-3"> <div class="col-md-6">
<div class="card border-0 shadow-sm h-100"> <div class="card border-0 shadow-sm h-100 border-start border-4 border-primary">
<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">
<div> <div>
<h6 class="card-title mb-1"> <h6 class="card-title fw-bold mb-1 text-primary">
{{ item.commission.get_full_code() }} {{ item.reservation.subject_name }}
</h6> </h6>
<p class="text-muted small mb-2 mb-md-0"> <span class="badge bg-secondary-subtle text-secondary font-monospace border me-1">
<i class="bi bi-geo-alt"></i> {{ item.classroom.code }} {{ item.reservation.subject_code }}
<br class="d-md-none"> </span>
<i class="bi bi-person ms-md-2"></i> {{ item.user.first_name }} {{ item.user.last_name }} <span class="badge bg-info-subtle text-info border">
</p> {{ item.reservation.shift or 'Tarde' }}
</span>
</div> </div>
<span class="badge bg-{% if item.reservation.status.value == 'CONFIRMED' %}success{% elif item.reservation.status.value == 'PENDING' %}warning{% else %}secondary{% 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 %}">
{{ item.reservation.status.value }} {% if item.reservation.status == 'CONFIRMED' or item.reservation.status.value == 'CONFIRMED' %}
{% trans %}Confirmada{% endtrans %}
{% else %}
{% trans %}Pendiente{% endtrans %}
{% endif %}
</span> </span>
</div> </div>
<div class="row small"> <div class="small text-muted mb-2">
<div class="col-md-6"> <span class="fw-semibold text-dark me-2">
<i class="bi bi-clock"></i> <i class="bi bi-door-open me-1 text-success"></i>{{ item.classroom.code }}
{{ item.reservation.start_time.strftime('%H:%M') }} - {{ item.reservation.end_time.strftime('%H:%M') }} </span>
</div> <span class="me-2">
<div class="col-md-6"> <i class="bi bi-layers me-1"></i>{% if item.classroom.floor == 0 %}PB{% else %}Piso {{ item.classroom.floor }}{% endif %}
<i class="bi bi-people"></i> </span>
{{ item.reservation.expected_attendees }} attendees <span>
</div> <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>
</div> </div>
{% if item.reservation.purpose %} <div class="d-flex justify-content-between align-items-center pt-2 border-top">
<p class="card-text small text-muted mt-2"> <span class="small text-muted">
{{ item.reservation.purpose }} <i class="bi bi-people me-1"></i>{{ item.reservation.expected_attendees }} {% trans %}estudiantes{% endtrans %}
</p> </span>
{% endif %} <div class="d-flex gap-1">
{% if item.reservation.effective_virtual_link %}
<div class="mt-2"> <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="{% trans %}Unirse a Videollamada (Zoom / Meet){% endtrans %}">
<a href="{{ url_for('schedule.view_reservation', id=item.reservation.id) }}" class="btn btn-sm btn-outline-primary"> <i class="bi bi-camera-video me-1"></i>Zoom/Meet
<i class="bi bi-eye"></i> Details
</a>
{% if item.reservation.status.value == 'PENDING' %}
<a href="{{ url_for('schedule.confirm_reservation', id=item.reservation.id) }}"
class="btn btn-sm btn-outline-success ms-1"
onclick="return confirm('Confirm this reservation?')">
<i class="bi bi-check-circle"></i> Confirm
</a> </a>
{% endif %} {% endif %}
{% if current_user.can_edit_reservation(item.reservation) %}
<a href="{{ url_for('schedule.edit_reservation', id=item.reservation.id) }}" class="btn btn-sm btn-outline-warning py-0 px-2" title="{% trans %}Modificar Reserva{% endtrans %}">
<i class="bi bi-pencil"></i>
</a>
{% endif %}
<a href="{{ url_for('schedule.view_reservation', id=item.reservation.id) }}" class="btn btn-sm btn-outline-secondary py-0 px-2" title="{% trans %}Ver Ficha Detallada{% endtrans %}">
<i class="bi bi-eye me-1"></i>{% trans %}Detalles{% endtrans %}
</a>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -185,8 +236,8 @@
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<div class="text-center text-muted py-3"> <div class="text-muted py-2 small ps-2">
<i class="bi bi-circle"></i> No reservations scheduled <i class="bi bi-dash-circle me-1"></i>{% trans %}Sin clases programadas en este horario{% endtrans %}
</div> </div>
{% endif %} {% endif %}
</div> </div>
@@ -194,54 +245,75 @@
{% endfor %} {% endfor %}
</div> </div>
<!-- Grid View (hidden by default) --> <!-- Table / Grid View (hidden by default) -->
<div id="grid-view" style="display: none;"> <div id="grid-view" style="display: none;">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover"> <table class="table table-hover align-middle mb-0">
<thead> <thead class="table-light small text-uppercase">
<tr> <tr>
<th>Time</th> <th class="ps-3">{% trans %}Horario{% endtrans %}</th>
<th>Classroom</th> <th>{% trans %}Aula / Piso{% endtrans %}</th>
<th>Subject</th> <th>{% trans %}Asignatura / Código{% endtrans %}</th>
<th>Teacher</th> <th>{% trans %}Turno{% endtrans %}</th>
<th>Purpose</th> <th>{% trans %}Asistentes{% endtrans %}</th>
<th>Attendees</th> <th>{% trans %}Estado{% endtrans %}</th>
<th>Status</th> <th class="text-end pe-3">{% trans %}Acciones{% endtrans %}</th>
<th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for reservation in reservations %} {% for reservation in reservations %}
<tr> <tr>
<td> <td class="ps-3 font-monospace fw-bold">
{{ reservation.start_time.strftime('%H:%M') }} - {{ reservation.start_time.strftime('%H:%M') }} - {{ reservation.end_time.strftime('%H:%M') }} hs
{{ reservation.end_time.strftime('%H:%M') }}
</td> </td>
<td> <td>
<i class="bi bi-geo-alt"></i> <span class="badge bg-secondary-subtle text-secondary font-monospace border">
{{ reservation.classroom.code }} {{ reservation.classroom.code if reservation.classroom else 'Sin Aula' }}
</span>
<small class="text-muted d-block">
{% if reservation.classroom and reservation.classroom.floor == 0 %}Planta Baja{% elif reservation.classroom %}Piso {{ reservation.classroom.floor }}{% endif %}
</small>
</td> </td>
<td>{{ reservation.commission.get_full_code() }}</td>
<td>{{ reservation.user.first_name }} {{ reservation.user.last_name }}</td>
<td>{{ reservation.purpose }}</td>
<td>{{ reservation.expected_attendees }}</td>
<td> <td>
<span class="badge bg-{% if reservation.status.value == 'CONFIRMED' %}success{% elif reservation.status.value == 'PENDING' %}warning{% else %}secondary{% endif %}"> <div class="fw-semibold text-primary">{{ reservation.subject_name }}</div>
{{ reservation.status.value }} <small class="text-muted font-monospace">{{ reservation.subject_code }}</small>
</td>
<td>
<span class="badge bg-info-subtle text-info border">
{{ reservation.shift or 'Tarde' }}
</span> </span>
</td> </td>
<td> <td>
<a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}" <span class="small">{{ reservation.expected_attendees }} estudiantes</span>
class="btn btn-sm btn-outline-primary"> </td>
<i class="bi bi-eye"></i> <td>
</a> {% if reservation.status == 'CONFIRMED' or reservation.status.value == 'CONFIRMED' %}
{% if reservation.status.value == 'PENDING' %} <span class="badge bg-success-subtle text-success border border-success-subtle">
<a href="{{ url_for('schedule.confirm_reservation', id=reservation.id) }}" <i class="bi bi-check-circle me-1"></i>{% trans %}Confirmada{% endtrans %}
class="btn btn-sm btn-outline-success ms-1" </span>
onclick="return confirm('Confirm this reservation?')"> {% else %}
<i class="bi bi-check-circle"></i> <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">
{% if reservation.effective_virtual_link %}
<a href="{{ reservation.effective_virtual_link }}" target="_blank"
class="btn btn-sm btn-outline-success py-0 px-2 me-1" title="{% trans %}Unirse a Clase Virtual (Zoom/Meet){% endtrans %}">
<i class="bi bi-camera-video"></i>
</a> </a>
{% endif %} {% endif %}
{% if current_user.can_edit_reservation(reservation) %}
<a href="{{ url_for('schedule.edit_reservation', id=reservation.id) }}"
class="btn btn-sm btn-outline-warning py-0 px-2 me-1" title="{% trans %}Modificar Reserva{% endtrans %}">
<i class="bi bi-pencil"></i>
</a>
{% endif %}
<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> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -253,110 +325,24 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Daily Summary -->
<div class="row mt-4">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0">
<h5 class="mb-0">
<i class="bi bi-graph-up text-warning"></i> Daily Analytics
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4">
<div class="text-center">
<div class="mb-2">
<i class="bi bi-door-open-fill text-success" style="font-size: 2rem;"></i>
</div>
<h6>Most Active Hours</h6>
<!-- Calculate peak hours from data -->
{% set peak_hours = [] %}
{% for item in reservations %}
{% set slot = item.start_time.hour %}
{% if slot not in peak_hours %}
{% set _ = peak_hours.append(slot) %}
{% endif %}
{% endfor %}
<p class="text-muted">
{% if peak_hours %}
{% for hour in peak_hours[:3]|sort %}
{{ "%02d:00"|format(hour) }}{% if not loop.last %}, {% endif %}
{% endfor %}
{% else %}
No data available
{% endif %}
</p>
</div>
</div>
<div class="col-md-4">
<div class="text-center">
<div class="mb-2">
<i class="bi bi-building text-info" style="font-size: 2rem;"></i>
</div>
<h6>Busiest Classrooms</h6>
<p class="text-muted">
{% if reservations %}
{% set classroom_codes = [] %}
{% for item in reservations %}
{% if item.classroom.code not in classroom_codes %}
{% set _ = classroom_codes.append(item.classroom.code) %}
{% endif %}
{% endfor %}
{% for code in classroom_codes[:3] %}
{{ code }}{% if not loop.last %}, {% endif %}
{% endfor %}
{% else %}
No data available
{% endif %}
</p>
</div>
</div>
<div class="col-md-4">
<div class="text-center">
<div class="mb-2">
<i class="bi bi-clock-fill text-primary" style="font-size: 2rem;"></i>
</div>
<h6>Total Hours Booked</h6>
<p class="text-muted">
{% set total_hours = 0 %}
{% for item in reservations %}
{% set duration = (item.end_time - item.start_time).total_seconds() / 3600 %}
{% set total_hours = (total_hours + duration) %}
{% endfor %}
{{ total_hours|round(1) }} hours total
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% else %} {% else %}
<div class="row"> <div class="row">
<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 reservations scheduled for today</h4> <h4 class="mt-3 text-muted">{% trans %}No hay reservas programadas para hoy{% endtrans %}</h4>
<p class="text-muted"> <p class="text-muted">
There are no classroom reservations for {{ today.strftime('%B %d, %Y') }}. {% trans %}No se encontraron clases para los filtros seleccionados.{% endtrans %}
</p> </p>
<div class="mt-3"> <div class="mt-3">
{% if current_user.has_permission('reservations', 'read_write') %}
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-success"> <a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-success">
<i class="bi bi-plus-circle"></i> Schedule First Class <i class="bi bi-plus-circle me-1"></i>{% trans %}Crear Primera Reserva{% endtrans %}
</a>
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-primary ms-2">
<i class="bi bi-calendar-week"></i>View Week Schedule
</a>
{# Access to Genetic Algorithm Optimizer - Only for admin/teacher roles #}
{% if current_user and current_user.role in ['admin', 'teacher'] %}
<a href="{{ url_for('main.genetic_optimizer') }}" class="btn btn-info text-white ms-2">
<i class="bi bi-cpu"></i> AI Room Optimizer
</a> </a>
{% endif %} {% endif %}
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary ms-2">
<i class="bi bi-calendar-week me-1"></i>{% trans %}Ver Calendario Semanal{% endtrans %}
</a>
</div> </div>
</div> </div>
</div> </div>
@@ -367,39 +353,30 @@
{% block extra_js %} {% block extra_js %}
<script> <script>
$(document).ready(function() { document.addEventListener('DOMContentLoaded', function() {
// Toggle between timeline and grid view var toggleBtn = document.getElementById('toggle-view-btn');
$('#toggle-view-btn').on('click', function() { var timelineView = document.getElementById('timeline-view');
const timelineView = $('#timeline-view'); var gridView = document.getElementById('grid-view');
const gridView = $('#grid-view');
const btn = $(this);
if (timelineView.is(':visible')) { if (toggleBtn && timelineView && gridView) {
timelineView.hide(); toggleBtn.addEventListener('click', function() {
gridView.show(); if (timelineView.style.display === 'none') {
btn.html('<i class="bi bi-list"></i> Timeline View'); timelineView.style.display = 'block';
gridView.style.display = 'none';
toggleBtn.innerHTML = '<i class="bi bi-grid me-1"></i>{% trans %}Vista en Tabla{% endtrans %}';
} else { } else {
timelineView.show(); timelineView.style.display = 'none';
gridView.hide(); gridView.style.display = 'block';
btn.html('<i class="bi bi-grid"></i> Grid View'); toggleBtn.innerHTML = '<i class="bi bi-clock-history me-1"></i>{% trans %}Vista Cronológica{% endtrans %}';
} }
}); });
}
// Refresh the page var refreshBtn = document.getElementById('refresh-btn');
$('#refresh-btn').on('click', function() { if (refreshBtn) {
refreshBtn.addEventListener('click', function() {
location.reload(); location.reload();
}); });
// Auto-refresh every 5 minutes
setInterval(function() {
location.reload();
}, 300000); // 5 minutes = 300000 milliseconds
// Highlight current time
const currentHour = new Date().getHours();
if (currentHour >= 7 && currentHour < 22) {
const timeBlockIndex = currentHour - 7;
$('.row.border-bottom').eq(timeBlockIndex).addClass('bg-primary bg-opacity-10');
} }
}); });
</script> </script>
+82 -27
View File
@@ -10,9 +10,14 @@
<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> <div class="d-flex gap-2">
{% if current_user.can_edit_reservation(reservation) %}
<a href="{{ url_for('schedule.edit_reservation', id=reservation.id) }}" class="btn btn-warning">
<i class="bi bi-pencil-square me-1"></i>{% trans %}Modificar Reserva{% endtrans %}
</a>
{% endif %}
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary"> <a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to List <i class="bi bi-arrow-left me-1"></i>{% trans %}Back to List{% endtrans %}
</a> </a>
</div> </div>
</div> </div>
@@ -23,7 +28,7 @@
<!-- Main Reservation Info --> <!-- Main Reservation Info -->
<div class="col-lg-8 mb-4"> <div class="col-lg-8 mb-4">
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0"> <div class="card-header border-0 py-3">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0"> <h5 class="mb-0">
<i class="bi bi-info-circle text-primary"></i> Reservation Information <i class="bi bi-info-circle text-primary"></i> Reservation Information
@@ -54,7 +59,7 @@
<label class="text-muted small">Date & Time</label> <label class="text-muted small">Date & Time</label>
<p class="mb-0"> <p class="mb-0">
<i class="bi bi-clock"></i> <i class="bi bi-clock"></i>
{{ reservation.start_time.strftime('%B %d, %Y') }}<br> {{ format_date(reservation.start_time, format='long')|capitalize }}<br>
{{ reservation.start_time.strftime('%H:%M') }} - {{ reservation.end_time.strftime('%H:%M') }} {{ reservation.start_time.strftime('%H:%M') }} - {{ reservation.end_time.strftime('%H:%M') }}
</p> </p>
</div> </div>
@@ -86,35 +91,70 @@
<!-- Classroom Details --> <!-- Classroom Details -->
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0"> <div class="card-header border-0 py-3 bg-body-tertiary">
<h5 class="mb-0"> <h5 class="mb-0 h6 fw-bold">
<i class="bi bi-building text-info"></i> Classroom Information <i class="bi bi-geo-alt text-primary me-2"></i>{% trans %}Ubicación y Espacio Asignado{% endtrans %}
</h5> </h5>
</div> </div>
<div class="card-body"> <div class="card-body p-4">
{% if reservation.classroom %} {% if reservation.classroom %}
<div class="row"> <div class="row g-3 mb-3">
<div class="col-md-4 mb-3"> <div class="col-md-4">
<label class="text-muted small">Classroom</label> <div class="p-3 rounded bg-body-tertiary border">
<p class="mb-0"><strong>{{ reservation.classroom.code_display }}</strong></p> <small class="text-muted d-block mb-1">🏢 {% trans %}Edificio / Sede{% endtrans %}</small>
<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 mb-3"> <div class="col-md-4">
<label class="text-muted small">Capacity</label> <div class="p-3 rounded bg-body-tertiary border">
<p class="mb-0">{{ reservation.classroom.capacity }} people</p> <small class="text-muted d-block mb-1">🪜 {% trans %}Piso / Nivel{% endtrans %}</small>
<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">🚪 {% trans %}Aula{% endtrans %}</small>
<h6 class="mb-0 fw-bold text-success">{{ reservation.classroom.code }}</h6>
<small class="text-muted">{% trans %}Capacidad:{% endtrans %} <strong>{{ reservation.classroom.capacity_display }}</strong></small>
</div>
</div>
</div>
{% if reservation.classroom.description %} {% if reservation.classroom.description %}
<div class="col-12"> <div class="mt-2">
<label class="text-muted small">Description</label> <label class="text-muted small">{% trans %}Descripción del Aula{% endtrans %}</label>
<p class="mb-0">{{ reservation.classroom.description }}</p> <p class="mb-0 small">{{ 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>{% trans %}Aula Virtual / Videoconferencia{% endtrans %}
</div> </div>
{% if reservation.effective_virtual_link %}
<small class="text-secondary text-break font-monospace d-block">{{ reservation.effective_virtual_link }}</small>
{% else %} {% else %}
<p class="text-muted mb-0">Classroom information not available</p> <small class="text-muted fst-italic">{% trans %}Modalidad remota sin enlace específico registrado{% endtrans %}</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>{% trans %}Unirse a Clase (Zoom / Meet){% endtrans %}
</a>
{% endif %}
</div>
{% endif %}
{% else %}
<p class="text-muted mb-0">{% trans %}Classroom information not available{% endtrans %}</p>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -124,7 +164,7 @@
<div class="col-lg-4"> <div class="col-lg-4">
<!-- Subject & Commission --> <!-- Subject & Commission -->
<div class="card border-0 shadow-sm mb-4"> <div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0"> <div class="card-header border-0 py-3">
<h5 class="mb-0"> <h5 class="mb-0">
<i class="bi bi-book text-warning"></i> Subject & Commission <i class="bi bi-book text-warning"></i> Subject & Commission
</h5> </h5>
@@ -149,7 +189,7 @@
<!-- Attendees --> <!-- Attendees -->
<div class="card border-0 shadow-sm mb-4"> <div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0"> <div class="card-header border-0 py-3">
<h5 class="mb-0"> <h5 class="mb-0">
<i class="bi bi-people text-success"></i> Attendance <i class="bi bi-people text-success"></i> Attendance
</h5> </h5>
@@ -159,14 +199,23 @@
<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 %}"
style="width: {{ percentage }}%"></div> role="progressbar"
style="{{ 'width: ' ~ ([percentage|round(1), 100]|min|string) ~ '%;' }}"></div>
</div> </div>
<small class="text-muted"> <small class="text-muted">
{{ reservation.expected_attendees }} / {{ reservation.classroom.capacity }} capacity {{ reservation.expected_attendees }} / {{ reservation.classroom.capacity }} {% trans %}capacidad{% endtrans %}
</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 %}{% trans %}Capacidad no definida{% endtrans %}{% endif %}
</span>
</div>
{% endif %}
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -174,13 +223,19 @@
<!-- Actions --> <!-- Actions -->
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0"> <div class="card-header border-0 py-3">
<h5 class="mb-0"> <h5 class="mb-0">
<i class="bi bi-gear text-secondary"></i> Actions <i class="bi bi-gear text-secondary"></i> Actions
</h5> </h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="d-grid gap-2"> <div class="d-grid gap-2">
{% if current_user.can_edit_reservation(reservation) %}
<a href="{{ url_for('schedule.edit_reservation', id=reservation.id) }}" class="btn btn-warning">
<i class="bi bi-pencil-square me-1"></i>{% trans %}Modificar Reserva{% endtrans %}
</a>
{% endif %}
{% if reservation.status.value == 'PENDING' %} {% if reservation.status.value == 'PENDING' %}
<form method="POST" action="{{ url_for('schedule.confirm_reservation', id=reservation.id) }}"> <form method="POST" action="{{ url_for('schedule.confirm_reservation', id=reservation.id) }}">
<button type="submit" class="btn btn-success" onclick="return confirm('Confirm this reservation?')"> <button type="submit" class="btn btn-success" onclick="return confirm('Confirm this reservation?')">
+3
View File
@@ -0,0 +1,3 @@
from .decorators import permission_required, admin_required
__all__ = ['permission_required', 'admin_required']
+64
View File
@@ -0,0 +1,64 @@
from functools import wraps
from flask import abort, redirect, url_for, request, flash
from flask_login import current_user
from flask_babel import gettext as _
def permission_required(module, min_level='read'):
"""
Decorator to restrict view access based on RBAC permissions.
Hierarchy: 'read_write' > 'read' > 'none'.
Administrators have implicit access to all modules.
"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for('auth.login', next=request.url))
if not current_user.has_permission(module, min_level):
flash(_('No tienes los permisos necesarios para acceder a esta sección.'), 'error')
abort(403)
return f(*args, **kwargs)
return decorated_function
return decorator
def admin_required(f):
"""
Decorator requiring administrative privileges or 'users' management access.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for('auth.login', next=request.url))
if not current_user.can_manage():
flash(_('Esta sección requiere privilegios de administrador.'), 'error')
abort(403)
return f(*args, **kwargs)
return decorated_function
def can_edit_reservation_required(f):
"""
Decorator requiring permission to modify a specific reservation.
Allows administrators, Bedelía, or the creator/teacher of the reservation.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for('auth.login', next=request.url))
res_id = kwargs.get('id')
if res_id:
from app.models.reservation import Reservation
reservation = Reservation.query.get_or_404(res_id)
if not current_user.can_edit_reservation(reservation):
flash(_('No tienes los permisos necesarios para modificar esta reserva.'), 'error')
abort(403)
return f(*args, **kwargs)
return decorated_function
+2 -2
View File
@@ -1,3 +1,3 @@
[python: **.py] [python: app/**.py]
[jinja2: **/templates/**.html] [jinja2.ext.babel_extract: app/templates/**.html]
extensions=jinja2.ext.i18n extensions=jinja2.ext.i18n
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# ==============================================================================
# backup_db.sh — Admin Edu-Space (UniCABA)
# Generador de backups de PostgreSQL y actualización de classrooms_db.sql
# Uso: bash backup_db.sh
# ==============================================================================
set -euo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
ok() { echo -e "${GREEN} ✔ $*${RESET}"; }
info() { echo -e "${CYAN} ▸ $*${RESET}"; }
warn() { echo -e "${YELLOW} ⚠ $*${RESET}"; }
err() { echo -e "${RED} ✘ $*${RESET}" >&2; exit 1; }
hdr() { echo -e "\n${BOLD}${CYAN}══ $* ══${RESET}\n"; }
APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BACKUP_DIR="${APP_DIR}/backups"
TIMESTAMP="$(date +'%Y%m%d_%H%M%S')"
BACKUP_FILE="${BACKUP_DIR}/classrooms_db_${TIMESTAMP}.sql"
ROOT_BACKUP="${APP_DIR}/classrooms_db.sql"
APP_ENV="${APP_DIR}/.env"
mkdir -p "$BACKUP_DIR"
hdr "Iniciando Backup de la Base de Datos"
# 1. Obtener parámetros de conexión
DB_NAME="classrooms_db"
DB_USER="eduspace_user"
DB_HOST="localhost"
DB_PORT="5432"
DB_PASSWORD=""
if [[ -f "$APP_ENV" ]]; then
set -a
# shellcheck disable=SC1090
source <(grep -v '^#' "$APP_ENV" | grep '=')
set +a
if [[ -n "${DATABASE_URL:-}" ]]; then
# Extraer credenciales desde DATABASE_URL si existe
# Formato: postgresql://usuario:pass@host:port/dbname
DB_USER=$(echo "$DATABASE_URL" | sed -E 's|.*://([^:]+):?([^@]*)@.*|\1|')
DB_PASSWORD=$(echo "$DATABASE_URL" | sed -E 's|.*://[^:]+:([^@]*)@.*|\1|')
DB_HOST=$(echo "$DATABASE_URL" | sed -E 's|.*@([^:/]+):?.*|\1|')
DB_PORT=$(echo "$DATABASE_URL" | sed -E 's|.*:([0-9]+)/.*|\1|')
DB_NAME=$(echo "$DATABASE_URL" | sed -E 's|.*/([^?]+).*|\1|')
fi
fi
DB_USER="${DB_USER:-postgres}"
DB_PASSWORD="${DB_PASSWORD:-password}"
DB_NAME="${DB_NAME:-classrooms_db}"
DB_HOST="${DB_HOST:-localhost}"
DB_PORT="${DB_PORT:-5432}"
info "Base de datos: ${DB_NAME}"
info "Usuario: ${DB_USER}"
info "Host: ${DB_HOST}:${DB_PORT}"
# 2. Ejecutar pg_dump
info "Generando volcado SQL..."
export PGPASSWORD="${DB_PASSWORD:-}"
if command -v pg_dump >/dev/null 2>&1; then
# Intentar como usuario configurado
if pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" \
--no-owner --no-privileges --clean --if-exists > "$BACKUP_FILE" 2>/dev/null; then
ok "Volcado generado con éxito usando pg_dump"
elif [[ $EUID -eq 0 ]] || sudo -n true 2>/dev/null; then
if command -v sudo >/dev/null 2>&1; then
sudo -u postgres pg_dump -d "$DB_NAME" --no-owner --no-privileges > "$BACKUP_FILE"
elif command -v runuser >/dev/null 2>&1; then
runuser -u postgres -- pg_dump -d "$DB_NAME" --no-owner --no-privileges > "$BACKUP_FILE"
else
su - postgres -c "pg_dump -d \"$DB_NAME\" --no-owner --no-privileges" > "$BACKUP_FILE"
fi
ok "Volcado generado como usuario postgres"
else
err "No se pudo realizar el backup. Verifica tus permisos y credenciales."
fi
else
err "No se encontró la herramienta 'pg_dump'. Instala postgresql-client."
fi
# 3. Limpiar comandos de versiones específicas (ej: \restrict de PG 18) para máxima compatibilidad
sed -i '/^\\restrict/d' "$BACKUP_FILE" 2>/dev/null || true
# 4. Actualizar copia en raíz para despliegues automáticos
cp "$BACKUP_FILE" "$ROOT_BACKUP"
BACKUP_SIZE=$(du -h "$BACKUP_FILE" | awk '{print $1}')
hdr "Resumen del Backup"
ok "Archivo histórico: ${BACKUP_FILE} (${BACKUP_SIZE})"
ok "Copia para deploy: ${ROOT_BACKUP} (${BACKUP_SIZE})"
info "Para restaurar en otro servidor:"
echo -e " ${CYAN}sudo -u postgres psql -d ${DB_NAME} < ${ROOT_BACKUP}${RESET}"
echo ""
+2044
View File
File diff suppressed because it is too large Load Diff
+12 -4
View File
@@ -1,8 +1,16 @@
import os import os
from dotenv import load_dotenv
basedir = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
load_dotenv(os.path.join(basedir, '.env'))
class Config: class Config:
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production' SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'postgresql://postgres:password@localhost:5432/classrooms_db'
_db_url = os.environ.get('DATABASE_URL') or 'postgresql://postgres:password@localhost:5432/classrooms_db'
if _db_url.startswith('postgres://'):
_db_url = _db_url.replace('postgres://', 'postgresql://', 1)
SQLALCHEMY_DATABASE_URI = _db_url
SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_TRACK_MODIFICATIONS = False
API_BASE_URL = os.environ.get('API_BASE_URL') or 'http://localhost:8080/api' API_BASE_URL = os.environ.get('API_BASE_URL') or 'http://localhost:8080/api'
@@ -15,7 +23,7 @@ class Config:
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'} ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
# Internationalization # Internationalization
LANGUAGES = ['en', 'es'] LANGUAGES = ['es', 'en']
BABEL_DEFAULT_LOCALE = 'en' BABEL_DEFAULT_LOCALE = 'es'
BABEL_DEFAULT_TIMEZONE = 'UTC' BABEL_DEFAULT_TIMEZONE = 'UTC'
BABEL_TRANSLATION_DIRECTORIES = 'translations' BABEL_TRANSLATION_DIRECTORIES = os.path.join(basedir, 'translations')
@@ -0,0 +1,26 @@
import { NgModule } from '@angular/core';
import { PreloadAllModules, RouterModule, Routes } from '@angular/router';
const routes: Routes = [
{
path: 'home',
loadChildren: () => import('./home/home.module').then( m => m.HomePageModule)
},
{
path: 'perfil',
loadChildren: () => import('./perfil/perfil.module').then( m => m.PerfilPageModule)
},
{
path: '',
redirectTo: 'home',
pathMatch: 'full'
},
];
@NgModule({
imports: [
RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })
],
exports: [RouterModule]
})
export class AppRoutingModule { }
@@ -0,0 +1,3 @@
<ion-app>
<ion-router-outlet></ion-router-outlet>
</ion-app>

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