1 Commits
51 changed files with 1436 additions and 11037 deletions
+7
View File
@@ -0,0 +1,7 @@
FLASK_APP=app.py
FLASK_ENV=development
SECRET_KEY=your-secret-key-here-please-change-in-production
DATABASE_URL=postgresql://postgres:password@localhost:5432/classrooms_db
DB_USERNAME=postgres
DB_PASSWORD=password
API_BASE_URL=http://localhost:8080/api
+1 -1
View File
@@ -4,7 +4,7 @@
# ============================================================================== # ==============================================================================
# Entorno Flask # Entorno Flask
FLASK_APP=wsgi.py FLASK_APP=app.py
FLASK_ENV=production FLASK_ENV=production
# Seguridad: Clave secreta para sesiones y protección CSRF # Seguridad: Clave secreta para sesiones y protección CSRF
-3
View File
@@ -57,9 +57,6 @@ cover/
*.mo *.mo
*.pot *.pot
# Examples
example/
# Django stuff: # Django stuff:
*.log *.log
local_settings.py local_settings.py
+1 -200
View File
@@ -1,6 +1,6 @@
# Resumen de Cambios - Sesión de Desarrollo (Rama `dev`) # Resumen de Cambios - Sesión de Desarrollo (Rama `dev`)
**Fecha:** 2026-09-02 / 2026-09-03 / 2026-09-04 **Fecha:** 2026-09-02 / 2026-09-03
**Proyecto:** Admin Edu-Space (Flask + PostgreSQL) **Proyecto:** Admin Edu-Space (Flask + PostgreSQL)
**Rama:** `dev` **Rama:** `dev`
@@ -245,205 +245,6 @@
* **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)):** * **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`). * 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`).
+195 -324
View File
@@ -1,378 +1,249 @@
# Panel de Administración Edu-Space (UniCABA) # Edu-Space Admin Panel
Plataforma integral de gestión áulica, asignación de espacios, control de horarios y reservas académicas para la **Universidad de la Ciudad de Buenos Aires (UniCABA)**, desarrollada con **Python / Flask**, **SQLAlchemy**, **PostgreSQL** y **Bootstrap 5**. A comprehensive web-based administration interface for the Edu-Space Classroom Reservation System built with Flask.
--- ## Features
## 🏛️ Sede Institucional y Ubicaciones ### 🔐 Authentication & Security
* **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. - Secure login system with hashed passwords
* **Campus Virtual:** Espacio áulico digital integrado (Microsoft Teams / Moodle) configurado con **capacidad ilimitada ($\infty$)**. - Role-based access control
- Session management with Flask-Login
- Password change functionality
--- ### 🏫 Classroom Management
- Create, edit, and delete classrooms
- Campus and building organization
- Capacity and resource tracking
- Real-time availability checking
## ✨ Características Principales ### 📅 Schedule Management
- Interactive calendar view (FullCalendar)
- Reservation creation and management
- Conflict detection and prevention
- Status tracking (Pending, Confirmed, Cancelled)
- Today's schedule overview
### 🛡️ 1. Control de Accesos RBAC Granular (Matriz estilo FortiGate) ### 📊 Dashboard & Analytics
* **Gestión por Módulos:** Permisos independientes por cada área del sistema: - Real-time statistics
1. **Aulas y Espacios:** Creación, edición, capacidad y estado de aulas físicas y virtuales. - Monthly reservation charts
2. **Edificios y Sedes:** Administración de sedes, pisos y arquitectura edilicia. - Quick actions interface
3. **Reservas de Aulas:** Solicitud, aprobación, confirmación y cancelación. - Upcoming reservations overview
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`.
### 📊 2. Sincronización e Importación desde Google Sheets ### 🎨 Modern UI/UX
* Integración directa con la cartelera académica de UniCABA en Google Sheets. - Responsive Bootstrap 5 design
* Extracción e importación normalizada de los 5 campos clave: - Dark theme support
* **Código Académico:** Códigos reales de asignatura (ej. `INGA0003`, `ASIG00165`). - Mobile-friendly interface
* **Asignatura:** Nombre oficial de la materia. - Interactive components and charts
* **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`).
### 📅 3. Calendario Interactivo de Reservas (FullCalendar) ## Tech Stack
* **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.
### 📋 4. Cartelera del Día y Listado de Reservas - **Backend**: Flask (Python)
* **Cartelera del Día (`/schedule/today`):** - **Database**: SQLAlchemy (SQLite/PostgreSQL)
* **Vista Cronológica (Timeline):** Bloques horarios de 07:00 a 22:00 hs con indicador de ocupación. - **Frontend**: Bootstrap 5, jQuery, Chart.js
* **Vista en Tabla:** Lista ordenada con acciones rápidas y detalles de comisión. - **Authentication**: Flask-Login, Werkzeug
* Filtros directos por piso, turno y switch para ocultar virtuales. - **Forms**: Flask-WTF
* **Listado Paginado de Reservas (`/schedule/list`):** - **Calendar**: FullCalendar.js
* Paginación de 20 registros por página preservando filtros aplicados.
* Búsqueda por texto, estado (`Confirmada`, `Pendiente`, `Cancelada`), rango de fechas y piso.
### 🏫 5. Gestión de Aulas con Capacidad Infinita ($\infty$) ## Installation
* 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.
### 🤖 6. Optimizador Inteligente de Aulas (Algoritmo Genético) ### Prerequisites
* Motor de optimización heurística para asignación automática de aulas a comisiones. - Python 3.8+
* Pondera capacidad requerida vs. capacidad de aula, equipamiento tecnológico y minimización de desplazamientos entre pisos. - pip package manager
### 🎨 7. Experiencia de Usuario, Temas y Localización ### Setup
* **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.
### 🏛️ 8. Sistema Integral de Gestión Institucional (8 Módulos) 1. **Clone or setup the project:**
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
pct enter <ID_DEL_CONTENEDOR> cd /Users/alejandro.v/PycharmProjects/admin-edu-space
``` ```
2. **Clonar el repositorio y lanzar el instalador:** 2. **Create virtual environment:**
```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
.\venv\Scripts\Activate.ps1 source venv/bin/activate # On Windows: venv\Scripts\activate
```
* En Linux / macOS:
```bash
python3 -m venv venv
source venv/bin/activate
``` ```
2. **Instalar dependencias:** 3. **Install dependencies:**
```bash ```bash
pip install -r requirements.txt pip install -r requirements.txt
``` ```
3. **Configurar base de datos local y restaurar snapshot:** 4. **Setup environment variables:**
```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
``` ```
4. **Compilar catálogos de traducción:** 5. **Initialize database:**
```bash ```bash
pybabel compile -d translations python app.py
``` ```
5. **Iniciar el servidor:** ## Configuration
### Environment Variables (.env)
```env
FLASK_APP=app.py
FLASK_ENV=development
SECRET_KEY=your-secret-key-here-please-change-in-production
DATABASE_URL=sqlite:///edu_space.db
API_BASE_URL=http://localhost:8080/api
```
## Usage
### Start the Application
```bash ```bash
python wsgi.py python app.py
``` ```
Disponible en: **`http://localhost:5000`**
--- The application will be available at: `http://localhost:5000`
## 🔑 Credenciales Predeterminadas ### Default Login
| Usuario | Correo Electrónico | Contraseña | Rol | - **Email**: admin@edu-space.com
| :--- | :--- | :--- | :--- | - **Password**: admin123
| **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. ### 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
## 📁 Estructura del Proyecto ## API Integration
```text 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/ admin-edu-space/
├── app/ ├── app/
│ ├── __init__.py # Factoría de la aplicación Flask y extensiones │ ├── __init__.py # Flask app factory
│ ├── forms/ # Formularios Flask-WTF con validación CSRF │ ├── models/ # Database models
│ │ ├── auth.py # Login, perfil y cambio de clave │ │ ├── user.py
│ │ ├── building.py # Sedes y edificios │ │ ├── classroom.py
│ │ ├── classroom.py # Aulas y capacidades (con soporte ∞) │ │ ├── subject.py
│ │ └── reservation.py # Reservas de aulas y horarios │ │ └── reservation.py
│ ├── models/ # Modelos relacionales SQLAlchemy │ ├── routes/ # Application routes
│ │ ├── academic_term.py # Ciclos lectivos y períodos académicos (activo/histórico) │ │ ├── auth.py
│ │ ├── building.py # Sedes y edificios institucionales │ │ ├── main.py
│ │ ├── career.py # Carreras y titulaciones universitarias │ │ ├── classrooms.py
│ │ ├── classroom.py # Aulas físicas y virtuales con capacidades │ │ └── schedule.py
│ │ ├── genetic_algorithm.py # Estructuras de optimización heurística │ ├── forms/ # WTForms classes
│ │ ├── milestone.py # Tipificaciones de hitos académicos y exámenes │ │ ├── auth.py
│ │ ├── reservation.py # Reservas horarias, estados y turnos │ │ ├── classroom.py
│ │ ├── role.py # Roles y matriz de permisos granulares RBAC │ │ └── reservation.py
│ │ ├── subject.py # Asignaturas, materias y comisiones de cursada │ ├── templates/ # Jinja2 templates
│ │ └── user.py # Usuarios, credenciales y asignación de roles │ ├── static/ # Static files
│ ├── routes/ # Controladores y rutas modulares (Blueprints) ├── config/ # Configuration files
│ │ ├── admin.py # Carreras, Asignaturas, Ciclo Lectivo, Tipificaciones, Métricas, Usuarios y Roles ├── requirements.txt
│ │ ├── auth.py # Autenticación, sesiones y perfil ├── .env
│ │ ├── buildings.py # Gestión de sedes y edificios ├── app.py
│ │ ├── classrooms.py # Aulas presenciales vs. Campus Virtual a demanda y métricas de uso └── README.md
│ │ ├── 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
``` ```
--- ## Database Schema
## 🚀 Roadmap Académico & Propuesta de Evolución Tecnológica ### Users Table
- User authentication and authorization
- Role management (admin, staff)
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)**. ### Classrooms Table
- Classroom information
- Capacity and location details
- Resource management
### 💡 1. Propuesta de Mejora Integral ### Reservations Table
- Booking management
- Time slot scheduling
- Status tracking
#### A. Arquitectura MVC Rigurosa & Clean Architecture ### Subjects + Commissions Tables
* **Desacoplamiento de Capas:** - Academic information
* **Model (Dominio):** Entidades relacionales puras y reglas de integridad del negocio desacopladas del framework web (`app/models/`). - Teacher assignments
* **View (Presentación):** Vistas web en Jinja2 con Bootstrap 5 y respuestas JSON estructuradas para clientes API/móviles. - Student enrollment tracking
* **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) ## Security Features
* **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" - Password hashing with bcrypt
* **Cadena de Filtros de Interceptación (SecurityFilterChain):** Pipeline centralizado inspirado en los estándares de Spring Security: - Session management
* **Authentication Filter:** Intercepta cabeceras `Authorization: Bearer <token>` y valida firmas criptográficas. - CSRF protection
* **Security Headers Filter:** Inyección obligatoria de cabeceras de blindaje HTTP (HSTS, CSP, X-Frame-Options: DENY, X-Content-Type-Options: nosniff). - Input validation and sanitization
* **Rate Limiting Filter:** Prevención de ataques de fuerza bruta y DDoS en endpoints sensibles mediante límites dinámicos (`Flask-Limiter` / Redis). - SQL injection prevention with SQLAlchemy ORM
* **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: ## Development
```python
@require_permission('classrooms', min_level='read_write') ### Running in Development Mode
def update_classroom_capacity(classroom_id, new_capacity): ...
```bash
export FLASK_ENV=development
python app.py
``` ```
* **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.
### 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
--- ---
### 📅 2. Planificación de Sprints (Roadmap) **Note**: This admin panel is designed to work alongside the Spring Boot microservice. Ensure both services are running for complete functionality.
| 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.
␍
View File
+12 -10
View File
@@ -19,25 +19,27 @@ def validate_capacity(form, field):
raise ValidationError(_l('Ingrese un número entero o el símbolo ∞ para capacidad ilimitada.')) raise ValidationError(_l('Ingrese un número entero o el símbolo ∞ para capacidad ilimitada.'))
class ClassroomForm(FlaskForm): class ClassroomForm(FlaskForm):
building_id = SelectField(_l('Edificio'), validators=[DataRequired()]) building = StringField(_l('Building'), validators=[
new_building_name = StringField(_l('Nombre del Nuevo Edificio'), validators=[Optional(), Length(max=100)]) DataRequired(),
room_number = StringField(_l('Número / Nombre del Aula'), validators=[ Length(min=2, max=100)
])
room_number = StringField(_l('Room Number'), validators=[
DataRequired(), DataRequired(),
Length(min=1, max=50) Length(min=1, max=50)
]) ])
floor = StringField(_l('Piso'), validators=[ floor = IntegerField(_l('Floor'), validators=[
DataRequired(message=_l('El piso es obligatorio (ej: PB, 1, 2, 4°P)')), InputRequired(message=_l('El piso es obligatorio (0 para Planta Baja)')),
Length(min=1, max=50) NumberRange(min=0, max=50, message=_l('El piso debe estar entre 0 (Planta Baja) y 50'))
]) ])
capacity = StringField(_l('Capacidad'), validators=[ capacity = StringField(_l('Capacity'), validators=[
validate_capacity validate_capacity
]) ])
description = TextAreaField(_l('Descripción'), validators=[ description = TextAreaField(_l('Description'), validators=[
Optional(), Optional(),
Length(max=500) Length(max=500)
]) ])
is_active = BooleanField(_l('Activa'), default=True) is_active = BooleanField(_l('Active'), default=True)
submit = SubmitField(_l('Guardar Aula')) submit = SubmitField(_l('Save Classroom'))
class ClassroomResourceForm(FlaskForm): class ClassroomResourceForm(FlaskForm):
resource_type = SelectField(_l('Resource Type'), choices=[ resource_type = SelectField(_l('Resource Type'), choices=[
+2 -10
View File
@@ -21,16 +21,8 @@ class ReservationForm(FlaskForm):
Optional(), Optional(),
Length(max=1000) Length(max=1000)
]) ])
virtual_link = StringField(_l('Virtual Classroom Link'), validators=[
Optional(),
Length(max=500)
])
submit = SubmitField(_l('Make Reservation')) submit = SubmitField(_l('Make Reservation'))
def __init__(self, *args, is_edit=False, **kwargs):
super().__init__(*args, **kwargs)
self.is_edit = is_edit
def validate_end_time(self, field): def validate_end_time(self, field):
if field.data <= self.start_time.data: if field.data <= self.start_time.data:
raise ValidationError('End time must be after start time.') raise ValidationError('End time must be after start time.')
@@ -40,8 +32,8 @@ class ReservationForm(FlaskForm):
raise ValidationError('Reservation cannot exceed 8 hours.') raise ValidationError('Reservation cannot exceed 8 hours.')
def validate_start_time(self, field): def validate_start_time(self, field):
# Don't allow new reservations in the past # Don't allow reservations in the past
if not self.is_edit and field.data < datetime.now() - timedelta(minutes=10): if field.data < datetime.now():
raise ValidationError('Start time cannot be in the past.') raise ValidationError('Start time cannot be in the past.')
# Don't allow reservations too far in the future (max 3 months) # Don't allow reservations too far in the future (max 3 months)
+1 -7
View File
@@ -2,12 +2,10 @@ from .user import User
from .building import Building from .building import Building
from .classroom import Classroom, ClassroomResource from .classroom import Classroom, ClassroomResource
from .career import Career from .career import Career
from .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 .role import Role, Permission, SYSTEM_MODULES
from .milestone import MilestoneType, AcademicMilestone, BASE_MILESTONE_TYPES
__all__ = [ __all__ = [
'User', 'User',
@@ -15,7 +13,6 @@ __all__ = [
'Classroom', 'Classroom',
'ClassroomResource', 'ClassroomResource',
'Career', 'Career',
'AcademicTerm',
'Subject', 'Subject',
'Commission', 'Commission',
'Reservation', 'Reservation',
@@ -24,8 +21,5 @@ __all__ = [
'ReservationOptimizer', 'ReservationOptimizer',
'Role', 'Role',
'Permission', 'Permission',
'SYSTEM_MODULES', 'SYSTEM_MODULES'
'MilestoneType',
'AcademicMilestone',
'BASE_MILESTONE_TYPES'
] ]
-34
View File
@@ -1,34 +0,0 @@
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}>'
+13 -52
View File
@@ -9,57 +9,23 @@ class Classroom(db.Model):
building_id = db.Column(db.Integer, db.ForeignKey('buildings.id'), nullable=True) building_id = db.Column(db.Integer, db.ForeignKey('buildings.id'), nullable=True)
building = db.Column(db.String(100), nullable=False) building = db.Column(db.String(100), nullable=False)
code = db.Column('room_number', db.String(50), nullable=False) # Maps to room_number column in DB code = db.Column('room_number', db.String(50), nullable=False) # Maps to room_number column in DB
floor = db.Column(db.String(50), nullable=False, default='1') floor = db.Column(db.Integer, nullable=False)
capacity = db.Column(db.Integer, nullable=False) capacity = db.Column(db.Integer, nullable=False)
description = db.Column(db.Text) description = db.Column(db.Text)
is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relación con Building (mapeada vía backref 'building_entity' en Building.classrooms)
@property
def building_rel(self):
return self.building_entity
@property
def building_name(self):
"""Nombre del edificio desacoplado"""
if self.building_rel:
return self.building_rel.name
return self.building or 'Edificio Central'
@property
def floor_display(self):
"""Formato amigable para el piso: PB, Planta Baja, Piso X"""
f = str(self.floor).strip() if self.floor is not None else ''
if f in ['0', 'PB', 'pb', 'Planta Baja', 'planta baja']:
return 'Planta Baja'
if f.lower().startswith('piso') or '°' in f:
return f
return f"Piso {f}"
@property
def is_virtual(self):
"""Indica si el aula es virtual"""
return (self.code and 'VIRTUAL' in self.code.upper()) or (self.building and 'VIRTUAL' in self.building.upper())
@property
def location_display(self):
"""Ubicación completa y limpia: Edificio · Piso · Aula"""
if self.is_virtual:
return '🌐 Campus Virtual'
return f"{self.building_name} · {self.floor_display} · {self.code}"
# Virtual properties for compatibility # Virtual properties for compatibility
@property @property
def name(self): def name(self):
"""Generate name from building and code""" """Generate name from building and code"""
return f"{self.building_name}-{self.code}" return f"{self.building}-{self.code}"
@property @property
def campus_location(self): def campus_location(self):
"""Placeholder for campus location""" """Placeholder for campus location - can be expanded when campus model is implemented"""
return self.building_rel.address if self.building_rel else None return None
# Virtual field for compatibility - combines building and code # Virtual field for compatibility - combines building and code
@property @property
@@ -73,34 +39,28 @@ class Classroom(db.Model):
# Override code property for consistency # Override code property for consistency
@property @property
def code_display(self): def code_display(self):
return f"{self.building_name} - {self.code}" return f"{self.building}-{self.code}"
@property @property
def capacity_display(self): def capacity_display(self):
"""Retorna '∞' para aulas virtuales o capacidad ilimitada, o el número entero.""" """Retorna '∞' para aulas virtuales o capacidad ilimitada, o el número entero."""
if self.capacity == 0 or self.is_virtual: if self.capacity == 0 or (self.code and 'VIRTUAL' in self.code.upper()):
return '∞' return '∞'
return str(self.capacity) return str(self.capacity)
@property @property
def is_unlimited(self): def is_unlimited(self):
return self.capacity == 0 or self.is_virtual return self.capacity == 0 or (self.code and 'VIRTUAL' in self.code.upper())
def to_dict(self): def to_dict(self):
"""Convert classroom to dictionary""" """Convert classroom to dictionary"""
return { return {
'id': self.id, 'id': self.id,
'building_id': self.building_id, 'code': self.code_display,
'building': self.building_name, 'building': self.building,
'code': self.code,
'code_display': self.code_display,
'name': self.name, 'name': self.name,
'floor': self.floor, 'floor': self.floor,
'floor_display': self.floor_display,
'location_display': self.location_display,
'capacity': self.capacity, 'capacity': self.capacity,
'capacity_display': self.capacity_display,
'is_virtual': self.is_virtual,
'description': self.description, 'description': self.description,
'campus_location': self.campus_location, 'campus_location': self.campus_location,
'is_active': self.is_active, 'is_active': self.is_active,
@@ -111,9 +71,10 @@ class Classroom(db.Model):
def get_full_location(self): def get_full_location(self):
"""Get full classroom location""" """Get full classroom location"""
if self.is_virtual: location = f"{self.building} - Floor {self.floor}"
return 'Campus Virtual (Remoto)' if self.campus_location:
return f"{self.building_name} - {self.floor_display}" location += f" ({self.campus_location})"
return location
@staticmethod @staticmethod
def find_best_classroom(max_students, start_time, end_time, exclude_reservation_id=None): def find_best_classroom(max_students, start_time, end_time, exclude_reservation_id=None):
-180
View File
@@ -1,180 +0,0 @@
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
}
]
-21
View File
@@ -47,7 +47,6 @@ class Reservation(db.Model):
status = db.Column(StatusType, default='PENDING', nullable=False) # Maps to VARCHAR(20) with StatusString status = db.Column(StatusType, default='PENDING', nullable=False) # Maps to VARCHAR(20) with StatusString
expected_attendees = db.Column(db.Integer, nullable=False) expected_attendees = db.Column(db.Integer, nullable=False)
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
virtual_link = db.Column(db.String(500), nullable=True) # Zoom, Meet, Teams, etc.
notes = db.Column(db.Text) notes = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
@@ -83,24 +82,6 @@ class Reservation(db.Model):
return self.classroom.code return self.classroom.code
return '' return ''
@property
def effective_virtual_link(self):
"""Devuelve el enlace explícito de la reserva o hereda el de la comisión"""
if self.virtual_link and self.virtual_link.strip():
return self.virtual_link.strip()
if self.commission and self.commission.virtual_link:
return self.commission.virtual_link.strip()
return None
@property
def is_virtual(self):
"""Verifica si la reserva es virtual por aula o por contar con enlace virtual"""
if self.classroom 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'))
@@ -119,8 +100,6 @@ class Reservation(db.Model):
'status': self.status, # Already a string in DB 'status': self.status, # Already a string in DB
'expected_attendees': self.expected_attendees, 'expected_attendees': self.expected_attendees,
'notes': self.notes, 'notes': self.notes,
'virtual_link': self.effective_virtual_link,
'is_virtual': self.is_virtual,
'created_at': self.created_at.isoformat() if self.created_at else None, 'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None, 'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'classroom': self.classroom.to_dict() if self.classroom else None, 'classroom': self.classroom.to_dict() if self.classroom else None,
+1 -15
View File
@@ -22,7 +22,7 @@ SYSTEM_MODULES = [
'name': 'Reservas de Aulas', 'name': 'Reservas de Aulas',
'name_en': 'Classroom Reservations', 'name_en': 'Classroom Reservations',
'icon': 'bi-calendar-check', 'icon': 'bi-calendar-check',
'description': 'Creación, modificación, aprobación, confirmación y cancelación de reservas.' 'description': 'Creación, aprobación, confirmación y cancelación de reservas.'
}, },
{ {
'id': 'schedule', 'id': 'schedule',
@@ -58,20 +58,6 @@ SYSTEM_MODULES = [
'name_en': 'Users & Access RBAC', 'name_en': 'Users & Access RBAC',
'icon': 'bi-shield-lock', 'icon': 'bi-shield-lock',
'description': 'Administración de cuentas, roles y matriz granular de permisos.' 'description': 'Administración de cuentas, roles y matriz granular de permisos.'
},
{
'id': 'milestone_types',
'name': 'Tipificaciones de Hitos',
'name_en': 'Milestone Types',
'icon': 'bi-tags',
'description': 'Catálogo dinámico de tipos de evaluaciones, parciales, TPs y eventos.'
},
{
'id': 'metrics',
'name': 'Métricas de Ocupación',
'name_en': 'Occupancy Metrics',
'icon': 'bi-graph-up',
'description': 'Visualización y análisis de porcentajes de ocupación por aula (día, semana y mes).'
} }
] ]
+1 -17
View File
@@ -50,7 +50,6 @@ class Commission(db.Model):
current_students = db.Column(db.Integer, default=0) current_students = db.Column(db.Integer, default=0)
schedule = db.Column(db.String(255)) schedule = db.Column(db.String(255))
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
virtual_link = db.Column(db.String(500), nullable=True) # Link Zoom, Meet, Teams, Moodle
active = db.Column(db.Boolean, default=True) # Changed from is_active to active active = db.Column(db.Boolean, default=True) # Changed from is_active to active
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -88,8 +87,6 @@ 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,
@@ -98,20 +95,7 @@ class Commission(db.Model):
def get_full_code(self): def get_full_code(self):
"""Get full commission code""" """Get full commission code"""
subj = self.subject.code if self.subject else 'N/A' return f"{self.subject.code if self.subject else 'N/A'}-{self.code}-{self.semester.upper()}{self.year if self.year else ''}"
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()}>'
+1 -28
View File
@@ -55,34 +55,7 @@ class User(UserMixin, db.Model):
def can_manage(self): def can_manage(self):
"""Check if user can access the Management / Admin section""" """Check if user can access the Management / Admin section"""
return ( return self.is_admin() or self.has_permission('users', 'read')
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):
-777
View File
@@ -4,10 +4,8 @@ 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.models.role import Role, Permission, SYSTEM_MODULES, ACCESS_LEVELS from app.models.role import Role, Permission, SYSTEM_MODULES, ACCESS_LEVELS
from app.models.milestone import MilestoneType, AcademicMilestone, BASE_MILESTONE_TYPES
from app.utils.decorators import admin_required, permission_required from app.utils.decorators import admin_required, permission_required
from sqlalchemy import or_ from sqlalchemy import or_
import re
admin_bp = Blueprint('admin', __name__, url_prefix='/admin') admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
@@ -179,36 +177,6 @@ def user_toggle(id):
return redirect(url_for('admin.users_list')) return redirect(url_for('admin.users_list'))
@admin_bp.route('/users/delete/<int:id>', methods=['POST'])
@admin_required
def user_delete(id):
"""Delete a user account"""
if id == current_user.id:
flash(_('No puedes eliminar tu propia cuenta mientras estás en sesión activa.'), 'error')
return redirect(url_for('admin.users_list'))
user = User.query.get_or_404(id)
# Protect sole system administrator
if user.is_admin():
admin_count = User.query.filter(User.role.ilike('%ADMIN%'), User.is_active == True).count()
if admin_count <= 1:
flash(_('No es posible eliminar al único administrador activo del sistema.'), 'error')
return redirect(url_for('admin.users_list'))
# Unassign user from commissions as teacher
from app.models.subject import Commission
Commission.query.filter_by(teacher_id=id).update({'teacher_id': None})
name = user.name
email = user.email
db.session.delete(user)
db.session.commit()
flash(_('El usuario "%(name)s" (%(email)s) ha sido eliminado del sistema.', name=name, email=email), 'success')
return redirect(url_for('admin.users_list'))
# --------------------------------------------------------- # ---------------------------------------------------------
# ROLES & PERMISSIONS MANAGEMENT (FortiGate Matrix) # ROLES & PERMISSIONS MANAGEMENT (FortiGate Matrix)
# --------------------------------------------------------- # ---------------------------------------------------------
@@ -372,748 +340,3 @@ def google_sheets_import():
recent_subjects=recent_subjects recent_subjects=recent_subjects
) )
# ---------------------------------------------------------
# MILESTONE TYPES & ACADEMIC CATEGORIES (ABM / CRUD)
# ---------------------------------------------------------
@admin_bp.route('/milestone-types')
@permission_required('milestone_types', 'read')
def milestone_types_list():
"""List all milestone categories / types"""
# Preload default milestone types if table is empty
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.order_by(MilestoneType.sort_order.asc(), MilestoneType.id.asc()).all()
can_edit = current_user.has_permission('milestone_types', 'read_write')
return render_template('admin/milestone_types/list.html', types=types, can_edit=can_edit)
@admin_bp.route('/milestone-types/add', methods=['POST'])
@permission_required('milestone_types', 'read_write')
def milestone_type_add():
"""Create a new milestone type"""
name = request.form.get('name', '').strip()
code = request.form.get('code', '').strip().lower()
color = request.form.get('color', '#0d6efd').strip()
description = request.form.get('description', '').strip()
sort_order = request.form.get('sort_order', 0, type=int)
is_active = True if request.form.get('is_active') in ['1', 'true', 'True', 'on'] else False
if not name:
flash(_('El nombre de la tipificación es obligatorio.'), 'error')
return redirect(url_for('admin.milestone_types_list'))
# Auto-generate code slug if not provided
if not code:
code = re.sub(r'[^a-zA-Z0-9_]+', '_', name.lower()).strip('_')
# Check code uniqueness
existing = MilestoneType.query.filter_by(code=code).first()
if existing:
flash(_('Ya existe una tipificación con el código "%(code)s".', code=code), 'error')
return redirect(url_for('admin.milestone_types_list'))
new_type = MilestoneType(
name=name,
code=code,
color=color or '#0d6efd',
description=description,
sort_order=sort_order,
is_active=is_active
)
db.session.add(new_type)
db.session.commit()
flash(_('Tipificación "%(name)s" creada exitosamente.', name=new_type.name), 'success')
return redirect(url_for('admin.milestone_types_list'))
@admin_bp.route('/milestone-types/edit/<int:id>', methods=['POST'])
@permission_required('milestone_types', 'read_write')
def milestone_type_edit(id):
"""Edit an existing milestone type"""
item = MilestoneType.query.get_or_404(id)
name = request.form.get('name', '').strip()
code = request.form.get('code', '').strip().lower()
color = request.form.get('color', '#0d6efd').strip()
description = request.form.get('description', '').strip()
sort_order = request.form.get('sort_order', item.sort_order, type=int)
is_active = True if request.form.get('is_active') in ['1', 'true', 'True', 'on'] else False
if not name:
flash(_('El nombre de la tipificación es obligatorio.'), 'error')
return redirect(url_for('admin.milestone_types_list'))
if not code:
code = re.sub(r'[^a-zA-Z0-9_]+', '_', name.lower()).strip('_')
# Check code uniqueness against other records
existing = MilestoneType.query.filter(MilestoneType.code == code, MilestoneType.id != id).first()
if existing:
flash(_('Ya existe otra tipificación con el código "%(code)s".', code=code), 'error')
return redirect(url_for('admin.milestone_types_list'))
item.name = name
item.code = code
item.color = color or item.color
item.description = description
item.sort_order = sort_order
item.is_active = is_active
db.session.commit()
flash(_('Tipificación "%(name)s" actualizada correctamente.', name=item.name), 'success')
return redirect(url_for('admin.milestone_types_list'))
@admin_bp.route('/milestone-types/toggle/<int:id>', methods=['POST'])
@permission_required('milestone_types', 'read_write')
def milestone_type_toggle(id):
"""Toggle active status of a milestone type"""
item = MilestoneType.query.get_or_404(id)
item.is_active = not item.is_active
db.session.commit()
if request.is_json or request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({
'success': True,
'id': item.id,
'is_active': item.is_active,
'message': _('Tipificación "%(name)s" %(status)s.', name=item.name, status=_('activada') if item.is_active else _('desactivada'))
})
status_str = _('activada') if item.is_active else _('desactivada')
flash(_('Tipificación "%(name)s" ha sido %(status)s.', name=item.name, status=status_str), 'success')
return redirect(url_for('admin.milestone_types_list'))
@admin_bp.route('/milestone-types/delete/<int:id>', methods=['POST'])
@permission_required('milestone_types', 'read_write')
def milestone_type_delete(id):
"""Delete a milestone type if unused, or deactivate with notice"""
item = MilestoneType.query.get_or_404(id)
from app.models.milestone import AcademicMilestone
milestone_count = AcademicMilestone.query.filter_by(milestone_type_id=id).count()
if milestone_count > 0:
item.is_active = False
db.session.commit()
flash(_('La tipificación "%(name)s" tiene %(count)d hitos registrados. Se ha desactivado para conservar el historial académico.', name=item.name, count=milestone_count), 'warning')
else:
name = item.name
db.session.delete(item)
db.session.commit()
flash(_('Tipificación "%(name)s" eliminada permanentemente.', name=name), 'success')
return redirect(url_for('admin.milestone_types_list'))
@admin_bp.route('/api/milestone-types')
@login_required
def api_milestone_types():
"""API endpoint to get active milestone types for forms and dropdowns"""
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])
# ---------------------------------------------------------
# 1. GESTIÓN DE CARRERAS (CAREERS)
# ---------------------------------------------------------
@admin_bp.route('/careers')
@login_required
def careers_list():
"""List and manage academic careers"""
if not (current_user.has_permission('academic', 'read') or current_user.can_manage()):
abort(403)
from app.models.career import Career
from app.models.subject import Subject
search = request.args.get('search', '').strip()
query = Career.query
if search:
query = query.filter(
or_(
Career.name.ilike(f'%{search}%'),
Career.code.ilike(f'%{search}%'),
Career.description.ilike(f'%{search}%')
)
)
careers = query.order_by(Career.name.asc()).all()
# Calculate stats per career
stats = {}
for c in careers:
subj_count = len(c.subjects) if c.subjects else 0
comm_count = sum(len(s.commissions) for s in c.subjects) if c.subjects else 0
stats[c.id] = {
'subjects_count': subj_count,
'commissions_count': comm_count
}
can_edit = current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')
return render_template(
'admin/careers/list.html',
careers=careers,
stats=stats,
search=search,
can_edit=can_edit
)
@admin_bp.route('/careers/add', methods=['POST'])
@login_required
def career_add():
"""Create a new academic career"""
if not (current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')):
abort(403)
from app.models.career import Career
name = request.form.get('name', '').strip()
code = request.form.get('code', '').strip()
description = request.form.get('description', '').strip()
if not name:
flash(_('El nombre de la carrera es obligatorio.'), 'error')
return redirect(url_for('admin.careers_list'))
existing = Career.query.filter(Career.name.ilike(name)).first()
if existing:
flash(_('Ya existe una carrera registrada con el nombre "%(name)s".', name=name), 'error')
return redirect(url_for('admin.careers_list'))
career = Career(name=name, code=code or None, description=description or None)
db.session.add(career)
db.session.commit()
flash(_('Carrera "%(name)s" creada exitosamente.', name=career.name), 'success')
return redirect(url_for('admin.careers_list'))
@admin_bp.route('/careers/<int:id>/edit', methods=['POST'])
@login_required
def career_edit(id):
"""Edit an academic career"""
if not (current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')):
abort(403)
from app.models.career import Career
career = Career.query.get_or_404(id)
name = request.form.get('name', '').strip()
code = request.form.get('code', '').strip()
description = request.form.get('description', '').strip()
if not name:
flash(_('El nombre de la carrera es obligatorio.'), 'error')
return redirect(url_for('admin.careers_list'))
existing = Career.query.filter(Career.name.ilike(name), Career.id != id).first()
if existing:
flash(_('Ya existe otra carrera registrada con el nombre "%(name)s".', name=name), 'error')
return redirect(url_for('admin.careers_list'))
career.name = name
career.code = code or None
career.description = description or None
db.session.commit()
flash(_('Carrera "%(name)s" actualizada correctamente.', name=career.name), 'success')
return redirect(url_for('admin.careers_list'))
@admin_bp.route('/careers/<int:id>/delete', methods=['POST'])
@login_required
def career_delete(id):
"""Delete an academic career if it has no associated subjects"""
if not (current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')):
abort(403)
from app.models.career import Career
career = Career.query.get_or_404(id)
if career.subjects and len(career.subjects) > 0:
flash(_('No se puede eliminar la carrera "%(name)s" porque tiene %(count)d asignaturas vinculadas.', name=career.name, count=len(career.subjects)), 'error')
return redirect(url_for('admin.careers_list'))
name = career.name
db.session.delete(career)
db.session.commit()
flash(_('Carrera "%(name)s" eliminada permanentemente.', name=name), 'success')
return redirect(url_for('admin.careers_list'))
# ---------------------------------------------------------
# 2. GESTIÓN DE ASIGNATURAS (SUBJECTS)
# ---------------------------------------------------------
@admin_bp.route('/subjects')
@login_required
def subjects_list():
"""List and manage subjects / materias"""
if not (current_user.has_permission('academic', 'read') or current_user.can_manage()):
abort(403)
from app.models.subject import Subject
from app.models.career import Career
search = request.args.get('search', '').strip()
career_filter = request.args.get('career_id', type=int)
active_filter = request.args.get('active', '').strip()
page = request.args.get('page', 1, type=int)
per_page = 20
query = Subject.query
if search:
query = query.filter(
or_(
Subject.name.ilike(f'%{search}%'),
Subject.code.ilike(f'%{search}%'),
Subject.department.ilike(f'%{search}%')
)
)
if career_filter:
query = query.filter(Subject.career_id == career_filter)
if active_filter in ['true', 'false']:
query = query.filter(Subject.is_active == (active_filter == 'true'))
pagination = query.order_by(Subject.code.asc(), Subject.name.asc()).paginate(page=page, per_page=per_page, error_out=False)
careers = Career.query.order_by(Career.name.asc()).all()
can_edit = current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')
return render_template(
'admin/subjects/list.html',
subjects=pagination.items,
pagination=pagination,
careers=careers,
search=search,
career_filter=career_filter,
active_filter=active_filter,
can_edit=can_edit
)
@admin_bp.route('/subjects/add', methods=['POST'])
@login_required
def subject_add():
"""Create a new subject"""
if not (current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')):
abort(403)
from app.models.subject import Subject
from app.models.career import Career
code = request.form.get('code', '').strip().upper()
name = request.form.get('name', '').strip()
department = request.form.get('department', '').strip()
career_id = request.form.get('career_id', type=int)
credits = request.form.get('credits', 4, type=int)
description = request.form.get('description', '').strip()
if not code or not name:
flash(_('El código y el nombre de la asignatura son obligatorios.'), 'error')
return redirect(url_for('admin.subjects_list'))
existing = Subject.query.filter_by(code=code).first()
if existing:
flash(_('Ya existe una asignatura con el código "%(code)s".', code=code), 'error')
return redirect(url_for('admin.subjects_list'))
career_name = None
if career_id:
c = Career.query.get(career_id)
if c:
career_name = c.name
subject = Subject(
code=code,
name=name,
department=department or career_name or 'General',
career_id=career_id if career_id else None,
credits=credits or 4,
description=description or None,
is_active=True
)
db.session.add(subject)
db.session.commit()
flash(_('Asignatura "%(name)s" (%(code)s) creada exitosamente.', name=subject.name, code=subject.code), 'success')
return redirect(url_for('admin.subjects_list'))
@admin_bp.route('/subjects/<int:id>/edit', methods=['POST'])
@login_required
def subject_edit(id):
"""Edit an existing subject"""
if not (current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')):
abort(403)
from app.models.subject import Subject
from app.models.career import Career
subject = Subject.query.get_or_404(id)
code = request.form.get('code', '').strip().upper()
name = request.form.get('name', '').strip()
department = request.form.get('department', '').strip()
career_id = request.form.get('career_id', type=int)
credits = request.form.get('credits', 4, type=int)
description = request.form.get('description', '').strip()
is_active = request.form.get('is_active') == 'on'
if not code or not name:
flash(_('El código y el nombre de la asignatura son obligatorios.'), 'error')
return redirect(url_for('admin.subjects_list'))
existing = Subject.query.filter(Subject.code == code, Subject.id != id).first()
if existing:
flash(_('Ya existe otra asignatura con el código "%(code)s".', code=code), 'error')
return redirect(url_for('admin.subjects_list'))
career_name = None
if career_id:
c = Career.query.get(career_id)
if c:
career_name = c.name
subject.code = code
subject.name = name
subject.department = department or career_name or subject.department
subject.career_id = career_id if career_id else None
subject.credits = credits or 4
subject.description = description or None
subject.is_active = is_active
db.session.commit()
flash(_('Asignatura "%(name)s" actualizada exitosamente.', name=subject.name), 'success')
return redirect(url_for('admin.subjects_list'))
@admin_bp.route('/subjects/<int:id>/toggle', methods=['POST'])
@login_required
def subject_toggle(id):
"""Toggle active status of a subject"""
if not (current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')):
abort(403)
from app.models.subject import Subject
subject = Subject.query.get_or_404(id)
subject.is_active = not subject.is_active
db.session.commit()
status_str = _('activada') if subject.is_active else _('desactivada')
flash(_('Asignatura "%(name)s" %(status)s.', name=subject.name, status=status_str), 'success')
return redirect(url_for('admin.subjects_list'))
# ---------------------------------------------------------
# 3. GESTIÓN DE CICLO LECTIVO (ACADEMIC TERMS)
# ---------------------------------------------------------
@admin_bp.route('/academic-terms')
@login_required
def academic_terms_list():
"""List and manage academic cycles / terms"""
if not (current_user.has_permission('academic', 'read') or current_user.can_manage()):
abort(403)
from app.models.academic_term import AcademicTerm
from app.models.subject import Commission
from app.models.reservation import Reservation
terms = AcademicTerm.query.order_by(AcademicTerm.year.desc(), AcademicTerm.code.desc()).all()
# Calculate associated commissions and reservations
stats = {}
for t in terms:
comm_count = Commission.query.filter(
or_(Commission.semester == t.code, db.and_(Commission.year == t.year, Commission.semester.ilike(f'%{t.code}%')))
).count()
stats[t.id] = {
'commissions_count': comm_count
}
can_edit = current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')
return render_template(
'admin/academic_terms/list.html',
terms=terms,
stats=stats,
can_edit=can_edit
)
@admin_bp.route('/academic-terms/add', methods=['POST'])
@login_required
def academic_term_add():
"""Create a new academic cycle"""
if not (current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')):
abort(403)
from app.models.academic_term import AcademicTerm
from datetime import datetime
code = request.form.get('code', '').strip()
name = request.form.get('name', '').strip()
year = request.form.get('year', type=int)
period_type = request.form.get('period_type', '').strip()
start_date_str = request.form.get('start_date', '').strip()
end_date_str = request.form.get('end_date', '').strip()
is_current = request.form.get('is_current') == 'on'
if not code or not name or not year:
flash(_('El código, nombre y año del ciclo lectivo son obligatorios.'), 'error')
return redirect(url_for('admin.academic_terms_list'))
existing = AcademicTerm.query.filter_by(code=code).first()
if existing:
flash(_('Ya existe un ciclo lectivo con el código "%(code)s".', code=code), 'error')
return redirect(url_for('admin.academic_terms_list'))
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date() if start_date_str else None
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() if end_date_str else None
if is_current:
# Unset previous current terms
AcademicTerm.query.update({'is_current': False})
term = AcademicTerm(
code=code,
name=name,
year=year,
period_type=period_type or 'Segundo Cuatrimestre',
start_date=start_date,
end_date=end_date,
is_current=is_current,
is_active=True
)
db.session.add(term)
db.session.commit()
flash(_('Ciclo lectivo "%(name)s" creado exitosamente.', name=term.name), 'success')
return redirect(url_for('admin.academic_terms_list'))
@admin_bp.route('/academic-terms/<int:id>/edit', methods=['POST'])
@login_required
def academic_term_edit(id):
"""Edit an academic cycle"""
if not (current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')):
abort(403)
from app.models.academic_term import AcademicTerm
from datetime import datetime
term = AcademicTerm.query.get_or_404(id)
code = request.form.get('code', '').strip()
name = request.form.get('name', '').strip()
year = request.form.get('year', type=int)
period_type = request.form.get('period_type', '').strip()
start_date_str = request.form.get('start_date', '').strip()
end_date_str = request.form.get('end_date', '').strip()
is_current = request.form.get('is_current') == 'on'
is_active = request.form.get('is_active') == 'on'
if not code or not name or not year:
flash(_('El código, nombre y año del ciclo lectivo son obligatorios.'), 'error')
return redirect(url_for('admin.academic_terms_list'))
existing = AcademicTerm.query.filter(AcademicTerm.code == code, AcademicTerm.id != id).first()
if existing:
flash(_('Ya existe otro ciclo lectivo con el código "%(code)s".', code=code), 'error')
return redirect(url_for('admin.academic_terms_list'))
if is_current and not term.is_current:
AcademicTerm.query.update({'is_current': False})
term.code = code
term.name = name
term.year = year
term.period_type = period_type or term.period_type
term.start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date() if start_date_str else None
term.end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() if end_date_str else None
term.is_current = is_current
term.is_active = is_active
db.session.commit()
flash(_('Ciclo lectivo "%(name)s" actualizado correctamente.', name=term.name), 'success')
return redirect(url_for('admin.academic_terms_list'))
@admin_bp.route('/academic-terms/<int:id>/set-current', methods=['POST'])
@login_required
def academic_term_set_current(id):
"""Set an academic cycle as the currently active one"""
if not (current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')):
abort(403)
from app.models.academic_term import AcademicTerm
term = AcademicTerm.query.get_or_404(id)
AcademicTerm.query.update({'is_current': False})
term.is_current = True
term.is_active = True
db.session.commit()
flash(_('El ciclo lectivo "%(name)s" fue establecido como Ciclo Activo del sistema.', name=term.name), 'success')
return redirect(url_for('admin.academic_terms_list'))
# ---------------------------------------------------------
# 4. GESTIÓN DE OCUPACIÓN Y MÉTRICAS (OCCUPANCY DASHBOARD)
# ---------------------------------------------------------
@admin_bp.route('/occupancy-metrics')
@login_required
def occupancy_metrics():
"""Consolidated institutional dashboard of classroom occupancy, shifts, and space usage"""
if not (current_user.has_permission('metrics', 'read') or current_user.can_manage()):
abort(403)
from app.models.classroom import Classroom
from app.models.building import Building
from app.models.reservation import Reservation, ReservationStatus
import calendar
from datetime import datetime, date, timedelta
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)
# Buildings & Classrooms
buildings = Building.query.filter_by(is_active=True).all()
vb = next((b for b in buildings if b.code == 'VIRTUAL' or 'virtual' in b.name.lower()), None)
vb_id = vb.id if vb else 0
physical_rooms = Classroom.query.filter(
Classroom.is_active == True,
Classroom.building_id != vb_id if vb_id else True,
~Classroom.code.ilike('%VIRTUAL%')
).all()
vc = Classroom.query.filter(
Classroom.building_id == vb_id if vb_id else Classroom.code.ilike('%VIRTUAL%')
).first()
# Reservations in month
month_res = Reservation.query.filter(
Reservation.start_time >= month_start,
Reservation.start_time <= month_end,
Reservation.status.in_([ReservationStatus.CONFIRMED.value, ReservationStatus.PENDING.value])
).all()
# Calculate physical stats
DAILY_OP_MINS = 15 * 60
WEEKLY_OP_MINS = 6 * 15 * 60
MONTHLY_OP_MINS = 24 * 15 * 60
room_metrics = []
for c in physical_rooms:
c_res = [r for r in month_res 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)))
room_metrics.append({
'classroom': c,
'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)
})
room_metrics.sort(key=lambda x: x['month_pct'], reverse=True)
top_rooms = room_metrics[:5]
low_rooms = list(reversed(room_metrics))[:5]
# Global averages
avg_day_pct = int(round(sum(rm['day_pct'] for rm in room_metrics) / len(room_metrics))) if room_metrics else 0
avg_week_pct = int(round(sum(rm['week_pct'] for rm in room_metrics) / len(room_metrics))) if room_metrics else 0
avg_month_pct = int(round(sum(rm['month_pct'] for rm in room_metrics) / len(room_metrics))) if room_metrics else 0
# Virtual classes
v_res = [r for r in month_res if vc and r.classroom_id == vc.id]
v_today = sum(1 for r in v_res if today_start <= r.start_time <= today_end)
v_week = sum(1 for r in v_res if week_start <= r.start_time <= week_end)
v_month = len(v_res)
# Shifts distribution
shift_counts = {'Mañana': 0, 'Tarde': 0, 'Vespertino': 0, 'Noche': 0, 'Otro': 0}
for r in month_res:
s = (r.shift or '').strip().capitalize()
if s in shift_counts:
shift_counts[s] += 1
elif 'Mañana' in s:
shift_counts['Mañana'] += 1
elif 'Tarde' in s:
shift_counts['Tarde'] += 1
elif 'Noche' in s:
shift_counts['Noche'] += 1
else:
shift_counts['Otro'] += 1
# Floors occupancy
floor_stats = {}
for rm in room_metrics:
flr = rm['classroom'].floor_display
if flr not in floor_stats:
floor_stats[flr] = {'rooms': 0, 'month_pct_sum': 0}
floor_stats[flr]['rooms'] += 1
floor_stats[flr]['month_pct_sum'] += rm['month_pct']
floor_summary = [
{'floor': f, 'avg_pct': int(round(data['month_pct_sum'] / data['rooms'])), 'count': data['rooms']}
for f, data in floor_stats.items()
]
floor_summary.sort(key=lambda x: x['avg_pct'], reverse=True)
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')
}
return render_template(
'admin/metrics/occupancy.html',
physical_rooms_count=len(physical_rooms),
avg_day_pct=avg_day_pct,
avg_week_pct=avg_week_pct,
avg_month_pct=avg_month_pct,
top_rooms=top_rooms,
low_rooms=low_rooms,
v_today=v_today,
v_week=v_week,
v_month=v_month,
shift_counts=shift_counts,
floor_summary=floor_summary,
total_month_classes=len(month_res),
date_filters=date_filters
)
+44 -301
View File
@@ -12,49 +12,23 @@ 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 with dynamic building creation and floor suggestion""" """Add a new classroom"""
from app.models.building import Building
import re
from sqlalchemy import func
form = ClassroomForm() form = ClassroomForm()
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
form.building_id.choices = [(str(b.id), b.name) for b in buildings] + [('__new__', '+ Crear nuevo edificio')]
# Map of existing floors per building for datalist # TODO: Update when campus model is implemented
building_floors = {str(b.id): list(dict.fromkeys(c.floor for c in b.classrooms if c.floor)) for b in buildings} # form.campus_id.choices = [(1, 'Main Campus')]
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_id=building.id, building=form.building.data,
code=room_code, code=form.room_number.data,
floor=floor_val floor=form.floor.data
).first() ).first()
if existing_classroom: if existing_classroom:
flash(f'Ya existe un aula con el nombre "{room_code}" en el piso {floor_val} de {building.name}.', 'error') flash('Classroom with this building, room number, and floor already exists.', 'error')
return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom') return render_template('classrooms/add.html', form=form, title='Add Classroom')
cap_raw = str(form.capacity.data or '').strip() cap_raw = str(form.capacity.data or '').strip()
if cap_raw in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']: if cap_raw in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']:
@@ -63,10 +37,10 @@ def add_classroom():
cap_val = int(cap_raw) cap_val = int(cap_raw)
classroom = Classroom( classroom = Classroom(
building_id=building.id, # campus_id=form.campus_id.data,
building=building.name, building=form.building.data,
code=room_code, code=form.room_number.data,
floor=floor_val, floor=form.floor.data,
capacity=cap_val, capacity=cap_val,
description=form.description.data, description=form.description.data,
is_active=form.is_active.data is_active=form.is_active.data
@@ -75,91 +49,34 @@ def add_classroom():
db.session.add(classroom) db.session.add(classroom)
db.session.commit() db.session.commit()
flash(f'Aula {classroom.location_display} creada exitosamente.', 'success') flash(f'Classroom {classroom.code_display} has been created successfully!', '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, building_floors=building_floors, title='Add Classroom') return render_template('classrooms/add.html', form=form, title='Add Classroom')
@classrooms_bp.route('/', endpoint='index') @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 with separation between Physical spaces and Virtual on-demand rooms""" """List all classrooms"""
try: try:
from app.models.building import Building # Get filters
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).strip() search = request.args.get('search', '', type=str)
building_filter = request.args.get('building_id', '', type=str).strip()
floor_filter = request.args.get('floor', '', type=str).strip()
view_mode = request.args.get('view_mode', '').strip().lower()
# Identificar edificio virtual y aula virtual base # Build query
virtual_building = Building.query.filter( query = Classroom.query
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:
physical_query = physical_query.filter_by(is_active=True) query = query.filter_by(is_active=True)
physical_count = physical_query.count() if search:
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}%'),
@@ -167,175 +84,29 @@ def list_classrooms():
) )
) )
classrooms = physical_query.order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).paginate( # Paginate results
page=page, per_page=20, error_out=False classrooms = query.order_by(Classroom.building, Classroom.floor, Classroom.code).paginate(
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(
@@ -353,63 +124,35 @@ 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 with decoupled building and floor""" """Edit classroom"""
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)
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
form.building_id.choices = [(str(b.id), b.name) for b in buildings] + [('__new__', '+ Crear nuevo edificio')]
building_floors = {str(b.id): list(dict.fromkeys(c.floor for c in b.classrooms if c.floor)) for b in buildings}
if request.method == 'GET': if request.method == 'GET':
form.building_id.data = str(classroom.building_id or '') if classroom.capacity == 0 or (classroom.code and 'VIRTUAL' in classroom.code.upper()):
form.room_number.data = classroom.code
form.floor.data = classroom.floor
if classroom.capacity == 0 or classroom.is_virtual:
form.capacity.data = '∞' form.capacity.data = '∞'
else: else:
form.capacity.data = str(classroom.capacity) form.capacity.data = str(classroom.capacity)
# TODO: Update when campus model is implemented
# form.campus_id.choices = [(1, 'Main Campus')]
if form.validate_on_submit(): if form.validate_on_submit():
try: try:
building = None # Check for duplicates (excluding current classroom)
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_id == building.id, Classroom.building == form.building.data,
Classroom.code == room_code, Classroom.code == form.room_number.data,
Classroom.floor == floor_val, Classroom.floor == form.floor.data,
Classroom.id != id Classroom.id != id
).first() ).first()
if existing_classroom: if existing_classroom:
flash(f'Ya existe otra aula con el nombre "{room_code}" en el piso {floor_val} de {building.name}.', 'error') flash('Classroom with this building, room number, and floor already exists.', 'error')
return render_template('classrooms/edit.html', form=form, classroom=classroom, building_floors=building_floors, title='Edit Classroom') return render_template('classrooms/edit.html', form=form, classroom=classroom, title='Edit Classroom')
cap_raw = str(form.capacity.data or '').strip() cap_raw = str(form.capacity.data or '').strip()
if cap_raw in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']: if cap_raw in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']:
@@ -417,10 +160,10 @@ def edit_classroom(id):
else: else:
cap_val = int(cap_raw) cap_val = int(cap_raw)
classroom.building_id = building.id # Update classroom
classroom.building = building.name classroom.building = form.building.data
classroom.code = room_code classroom.code = form.room_number.data
classroom.floor = floor_val classroom.floor = form.floor.data
classroom.capacity = cap_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
@@ -428,14 +171,14 @@ def edit_classroom(id):
db.session.commit() db.session.commit()
flash(f'Aula {classroom.location_display} actualizada correctamente.', 'success') flash(f'Classroom {classroom.code_display} has been updated successfully!', '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, building_floors=building_floors, title='Edit Classroom') return render_template('classrooms/edit.html', form=form, classroom=classroom, 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 not (current_user.has_permission('optimizer', 'read') or (current_user.role and current_user.role.upper() == 'ADMIN')): if current_user.role not in ['ADMIN']:
return render_template('auth/403.html'), 403 return render_template('auth/403.html'), 403
return render_template('genetic_optimizer.html') return render_template('genetic_optimizer.html')
+2 -23
View File
@@ -113,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 not (current_user.has_permission('optimizer', 'read') or (current_user.role and current_user.role.upper() == 'ADMIN')): if current_user.role not in ['ADMIN']:
flash(_('No tienes permisos para acceder a la herramienta de optimización.'), 'danger') flash('You do not have permission to access the optimization tool.', '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')
@@ -135,24 +135,3 @@ def set_language(language=None):
db.session.commit() 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])␍
+24 -415
View File
@@ -40,12 +40,8 @@ def today_schedule():
~Classroom.building.ilike('%Virtual%') ~Classroom.building.ilike('%Virtual%')
) )
if floor_filter: if floor_filter and floor_filter.isdigit():
flr = str(floor_filter).strip() query = query.filter(Classroom.floor == int(floor_filter))
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
query = query.filter(or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
else:
query = query.filter(Classroom.floor == flr)
if shift_filter: if shift_filter:
query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%')) query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%'))
@@ -139,102 +135,14 @@ def view_reservation(id):
flash(f'Error loading reservation details: {str(e)}', 'error') flash(f'Error loading reservation details: {str(e)}', 'error')
return redirect(url_for('schedule.today_schedule')) return redirect(url_for('schedule.today_schedule'))
@schedule_bp.route('/reservation/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def edit_reservation(id):
"""Edit existing reservation"""
from app.models.classroom import Classroom
from app.models.subject import Commission
from app.models.building import Building
from flask_babel import gettext as _
reservation = Reservation.query.options(
joinedload(Reservation.classroom),
joinedload(Reservation.commission),
joinedload(Reservation.user)
).get_or_404(id)
if not current_user.can_edit_reservation(reservation):
flash(_('No tienes los permisos necesarios para modificar esta reserva.'), 'error')
return redirect(url_for('schedule.view_reservation', id=reservation.id))
form = ReservationForm(is_edit=True)
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all()
commissions = Commission.query.filter_by(active=True).all()
form.classroom_id.choices = [(c.id, f'{c.building_name} - {c.code} (Cap: {c.capacity_display})') for c in classrooms]
form.commission_id.choices = [(c.id, f'{c.get_full_code()} - {c.subject.name if c.subject else "No Subject"} (Students: {c.max_students})') for c in commissions]
if request.method == 'GET':
form.classroom_id.data = reservation.classroom_id
form.commission_id.data = reservation.commission_id
form.start_time.data = reservation.start_time
form.end_time.data = reservation.end_time
form.purpose.data = reservation.purpose
form.expected_attendees.data = reservation.expected_attendees
form.notes.data = reservation.notes or ''
form.virtual_link.data = reservation.virtual_link or ''
elif request.method == 'POST':
if form.validate_on_submit():
try:
# Verificar colisión de horario si no es virtual
target_room = Classroom.query.get(form.classroom_id.data)
if target_room and not target_room.is_virtual:
conflict = Reservation.query.filter(
Reservation.id != reservation.id,
Reservation.classroom_id == form.classroom_id.data,
Reservation.status == ReservationStatus.CONFIRMED.value,
Reservation.start_time < form.end_time.data,
Reservation.end_time > form.start_time.data
).first()
if conflict:
flash(_('El aula seleccionada ya cuenta con una reserva confirmada en esa franja horaria.'), 'danger')
return render_template('schedule/edit.html', form=form, reservation=reservation, classroom_list=classrooms, commission_list=commissions, buildings=buildings)
# Asignar cambios
reservation.classroom_id = form.classroom_id.data
reservation.commission_id = form.commission_id.data
reservation.start_time = form.start_time.data
reservation.end_time = form.end_time.data
reservation.purpose = form.purpose.data
reservation.expected_attendees = form.expected_attendees.data
reservation.notes = form.notes.data
reservation.virtual_link = form.virtual_link.data.strip() if form.virtual_link.data else None
# Turno
h = reservation.start_time.hour
if h < 13:
reservation.shift = 'Mañana'
elif h < 18:
reservation.shift = 'Tarde'
elif h < 21:
reservation.shift = 'Vespertino'
else:
reservation.shift = 'Noche'
db.session.commit()
flash(_('Reserva #%(id)d modificada exitosamente.', id=reservation.id), 'success')
return redirect(url_for('schedule.view_reservation', id=reservation.id))
except Exception as e:
db.session.rollback()
flash(f'Error al modificar reserva: {str(e)}', 'danger')
return render_template('schedule/edit.html', form=form, reservation=reservation, classroom_list=classrooms, commission_list=commissions, buildings=buildings)
@schedule_bp.route('/calendar') @schedule_bp.route('/calendar')
@login_required @login_required
def calendar_view(): def calendar_view():
"""Show calendar view with classroom, subject, career, shift, floor and virtual filtering""" """Show calendar view with classroom, subject, career, shift, floor and virtual filtering"""
from app.models.career import Career from app.models.career import Career
from app.models.building import Building
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building.asc(), Classroom.code.asc()).all() classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building.asc(), Classroom.code.asc()).all()
subjects = Subject.query.filter_by(is_active=True).order_by(Subject.name.asc()).all() subjects = Subject.query.filter_by(is_active=True).order_by(Subject.name.asc()).all()
careers = Career.query.order_by(Career.name.asc()).all() careers = Career.query.order_by(Career.name.asc()).all()
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all() floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()
available_floors = [f[0] for f in floors] available_floors = [f[0] for f in floors]
@@ -246,246 +154,22 @@ def calendar_view():
classrooms=classrooms, classrooms=classrooms,
subjects=subjects, subjects=subjects,
careers=careers, careers=careers,
buildings=buildings,
available_floors=available_floors available_floors=available_floors
) )
def user_can_manage_commission_milestones(user, commission=None):
"""Verifica si el usuario (Docente, Admin, Bedelía) puede gestionar hitos y links virtuales"""
if user.is_admin():
return True
role_name = (user.role_obj.name if user.role_obj else user.role or '').lower()
if role_name in ['admin', 'bedelia', 'bedelía', 'operador']:
return True
if role_name in ['docente', 'profesor']:
return True
return user.has_permission('schedule', 'read_write')
@schedule_bp.route('/commissions') @schedule_bp.route('/commissions')
@login_required
def commissions_view(): def commissions_view():
"""Vista y ABM de Comisiones con soporte de enlace virtual e hitos académicos""" subjects = Subject.query.all()
from app.models.subject import Commission, Subject
from app.models.classroom import Classroom
from app.models.user import User
from app.models.milestone import MilestoneType
subjects = Subject.query.order_by(Subject.code.asc()).all() # Calculamos el total de capacidad sumando todas las comisiones de todas las materias
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.code.asc()).all()
teachers = User.query.filter_by(is_active=True).order_by(User.name.asc()).all()
milestone_types = MilestoneType.query.filter_by(is_active=True).order_by(MilestoneType.sort_order.asc()).all()
# Totales para indicadores superiores
total_capacity = sum(c.max_students for s in subjects for c in s.commissions) total_capacity = sum(c.max_students for s in subjects for c in s.commissions)
# Calculamos el total de comisiones
total_commissions = sum(len(s.commissions) for s in subjects) total_commissions = sum(len(s.commissions) for s in subjects)
can_manage_milestones = user_can_manage_commission_milestones(current_user)
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
can_edit_commissions = current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')
return render_template('schedule/commissions.html', return render_template('schedule/commissions.html',
subjects=subjects, subjects=subjects,
classrooms=classrooms,
teachers=teachers,
milestone_types=milestone_types,
total_capacity=total_capacity, total_capacity=total_capacity,
total_commissions=total_commissions, total_commissions=total_commissions)
can_manage_milestones=can_manage_milestones,
can_edit_commissions=can_edit_commissions)
@schedule_bp.route('/commissions/add', methods=['POST'])
@login_required
def commission_add():
"""Crear una nueva comisión de cursada"""
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')):
flash(_('No tienes permisos para crear comisiones.'), 'error')
return redirect(url_for('schedule.commissions_view'))
from app.models.subject import Commission
subject_id = request.form.get('subject_id', type=int)
code = request.form.get('code', '').strip()
semester = request.form.get('semester', '1C').strip()
year = request.form.get('year', datetime.now().year, type=int)
teacher_id = request.form.get('teacher_id', type=int) or None
max_students = request.form.get('max_students', 40, type=int)
schedule = request.form.get('schedule', '').strip()
shift = request.form.get('shift', 'Tarde').strip()
virtual_link = request.form.get('virtual_link', '').strip()
if not subject_id or not code:
flash(_('La asignatura y el código de comisión son obligatorios.'), 'error')
return redirect(url_for('schedule.commissions_view'))
# Verificar existencia duplicada
existing = Commission.query.filter_by(subject_id=subject_id, code=code, semester=semester, year=year).first()
if existing:
flash(_('Ya existe una comisión %(code)s para el ciclo %(sem)s %(yr)d.', code=code, sem=semester, yr=year), 'error')
return redirect(url_for('schedule.commissions_view'))
new_comm = Commission(
subject_id=subject_id,
code=code,
semester=semester,
year=year,
teacher_id=teacher_id,
max_students=max_students,
schedule=schedule,
shift=shift,
virtual_link=virtual_link or None,
active=True
)
db.session.add(new_comm)
db.session.commit()
flash(_('Comisión %(code)s creada exitosamente.', code=new_comm.get_full_code()), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/commissions/<int:id>/edit', methods=['POST'])
@login_required
def commission_edit(id):
"""Editar datos de una comisión"""
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')):
flash(_('No tienes permisos para modificar comisiones.'), 'error')
return redirect(url_for('schedule.commissions_view'))
from app.models.subject import Commission
commission = Commission.query.get_or_404(id)
commission.code = request.form.get('code', commission.code).strip()
commission.semester = request.form.get('semester', commission.semester).strip()
commission.year = request.form.get('year', commission.year, type=int)
commission.teacher_id = request.form.get('teacher_id', type=int) or None
commission.max_students = request.form.get('max_students', commission.max_students, type=int)
commission.schedule = request.form.get('schedule', commission.schedule).strip()
commission.shift = request.form.get('shift', commission.shift).strip()
commission.virtual_link = request.form.get('virtual_link', '').strip() or None
commission.active = True if request.form.get('active') in ['1', 'true', 'on'] else False
db.session.commit()
flash(_('Comisión %(code)s actualizada con éxito.', code=commission.get_full_code()), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/commissions/<int:id>/delete', methods=['POST'])
@login_required
def commission_delete(id):
"""Eliminar una comisión"""
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')):
flash(_('No tienes permisos para eliminar comisiones.'), 'error')
return redirect(url_for('schedule.commissions_view'))
from app.models.subject import Commission
commission = Commission.query.get_or_404(id)
full_code = commission.get_full_code()
db.session.delete(commission)
db.session.commit()
flash(_('Comisión %(code)s eliminada permanentemente.', code=full_code), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/commissions/<int:id>/virtual-link', methods=['POST'])
@login_required
def commission_virtual_link(id):
"""Actualizar enlace del aula virtual (Zoom, Meet, Teams, Moodle) - Profesores, Admin y Bedelía"""
from app.models.subject import Commission
commission = Commission.query.get_or_404(id)
if not user_can_manage_commission_milestones(current_user, commission):
flash(_('No tienes permisos para actualizar el aula virtual de esta comisión.'), 'error')
return redirect(url_for('schedule.commissions_view'))
link = request.form.get('virtual_link', '').strip()
commission.virtual_link = link or None
db.session.commit()
flash(_('Enlace del aula virtual actualizado para la comisión %(code)s.', code=commission.get_full_code()), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/commissions/<int:id>/milestones/add', methods=['POST'])
@login_required
def commission_milestone_add(id):
"""Agregar un hito académico (Parcial, TP, Recuperatorio) a una comisión - Profesores, Admin y Bedelía"""
from app.models.subject import Commission
from app.models.milestone import AcademicMilestone, MilestoneType
commission = Commission.query.get_or_404(id)
if not user_can_manage_commission_milestones(current_user, commission):
flash(_('No tienes permisos para agregar hitos a esta comisión.'), 'error')
return redirect(url_for('schedule.commissions_view'))
milestone_type_id = request.form.get('milestone_type_id', type=int)
title = request.form.get('title', '').strip()
date_str = request.form.get('date', '').strip()
time_str = request.form.get('time', '18:00').strip()
classroom = request.form.get('classroom', '').strip()
link = request.form.get('link', '').strip() or commission.virtual_link or ''
notes = request.form.get('notes', '').strip()
if not milestone_type_id or not date_str:
flash(_('Debes especificar el tipo de hito y la fecha.'), 'error')
return redirect(url_for('schedule.commissions_view'))
m_type = MilestoneType.query.get(milestone_type_id)
if not m_type:
flash(_('Tipo de hito inválido.'), 'error')
return redirect(url_for('schedule.commissions_view'))
try:
dt = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M")
except ValueError:
try:
dt = datetime.strptime(date_str, "%Y-%m-%d")
except ValueError:
flash(_('Formato de fecha inválido.'), 'error')
return redirect(url_for('schedule.commissions_view'))
milestone = AcademicMilestone(
title=title or m_type.name,
materia_codigo=commission.subject.code if commission.subject else 'ASIG',
subject_id=commission.subject_id,
commission_id=commission.id,
milestone_type_id=m_type.id,
date=dt,
classroom=classroom or 'Campus Virtual',
link=link or None,
notes=notes or None,
is_active=True
)
db.session.add(milestone)
db.session.commit()
flash(_('Hito "%(title)s" registrado correctamente en %(comm)s.', title=milestone.title, comm=commission.get_full_code()), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/commissions/<int:commission_id>/milestones/<int:milestone_id>/delete', methods=['POST'])
@login_required
def commission_milestone_delete(commission_id, milestone_id):
"""Eliminar un hito académico de una comisión"""
from app.models.subject import Commission
from app.models.milestone import AcademicMilestone
commission = Commission.query.get_or_404(commission_id)
if not user_can_manage_commission_milestones(current_user, commission):
flash(_('No tienes permisos para eliminar hitos de esta comisión.'), 'error')
return redirect(url_for('schedule.commissions_view'))
milestone = AcademicMilestone.query.filter_by(id=milestone_id, commission_id=commission_id).first_or_404()
title = milestone.title
db.session.delete(milestone)
db.session.commit()
flash(_('Hito "%(title)s" eliminado de la comisión.', title=title), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/add', methods=['GET', 'POST']) @schedule_bp.route('/add', methods=['GET', 'POST'])
@login_required @login_required
@@ -493,17 +177,17 @@ def add_reservation():
"""Add new reservation""" """Add new reservation"""
from app.models.classroom import Classroom from app.models.classroom import Classroom
from app.models.subject import Commission from app.models.subject import Commission
from app.models.building import Building
form = ReservationForm() form = ReservationForm()
# Get active buildings and classrooms # Get active classrooms for the dropdown
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all() classrooms = Classroom.query.filter_by(is_active=True).all()
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all()
# Get active commissions for the dropdown
commissions = Commission.query.filter_by(active=True).all() commissions = Commission.query.filter_by(active=True).all()
# Populate form choices # Populate form choices
form.classroom_id.choices = [(c.id, f'{c.building_name} - {c.code} (Cap: {c.capacity_display})') for c in classrooms] form.classroom_id.choices = [(c.id, f'{c.code_display} - {c.name} (Cap: {c.capacity})') for c in classrooms]
form.commission_id.choices = [(c.id, f'{c.get_full_code()} - {c.subject.name if c.subject else "No Subject"} (Students: {c.max_students})') for c in commissions] form.commission_id.choices = [(c.id, f'{c.get_full_code()} - {c.subject.name if c.subject else "No Subject"} (Students: {c.max_students})') for c in commissions]
if request.method == 'POST': if request.method == 'POST':
@@ -521,7 +205,7 @@ def add_reservation():
if existing_reservation: if existing_reservation:
flash('This classroom is already booked for the selected time slot.', 'danger') flash('This classroom is already booked for the selected time slot.', 'danger')
return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions, buildings=buildings) return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions)
# Create new reservation # Create new reservation
reservation = Reservation( reservation = Reservation(
@@ -532,7 +216,6 @@ def add_reservation():
end_time=form.end_time.data, end_time=form.end_time.data,
purpose=form.purpose.data, purpose=form.purpose.data,
notes=form.notes.data, notes=form.notes.data,
virtual_link=form.virtual_link.data.strip() if form.virtual_link.data else None,
status=ReservationStatus.PENDING.value, status=ReservationStatus.PENDING.value,
expected_attendees=form.expected_attendees.data expected_attendees=form.expected_attendees.data
) )
@@ -545,53 +228,10 @@ def add_reservation():
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
current_app.logger.error(f"Error creating reservation: {str(e)}")
flash(f'Error creating reservation: {str(e)}', 'danger') flash(f'Error creating reservation: {str(e)}', 'danger')
return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions, buildings=buildings) return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions)
@schedule_bp.route('/reservation/add', methods=['POST'])
@login_required
def quick_reservation_add():
"""Endpoint AJAX para crear reserva rápida desde el modal de comisiones"""
try:
commission_id = request.form.get('commission_id', type=int)
classroom_id = request.form.get('classroom_id', type=int)
purpose = request.form.get('purpose', '').strip() or 'Clase de cursada'
start_str = request.form.get('start_time', '').strip()
end_str = request.form.get('end_time', '').strip()
virtual_link = request.form.get('virtual_link', '').strip() or None
if not commission_id or not classroom_id or not start_str or not end_str:
return jsonify({'error': 'Faltan campos obligatorios'}), 400
start_time = datetime.strptime(start_str, '%Y-%m-%d %H:%M')
end_time = datetime.strptime(end_str, '%Y-%m-%d %H:%M')
from app.models.subject import Commission
from app.models.classroom import Classroom
comm = Commission.query.get_or_404(commission_id)
if not virtual_link and comm.virtual_link:
virtual_link = comm.virtual_link
new_res = Reservation(
classroom_id=classroom_id,
commission_id=commission_id,
user_id=comm.teacher_id or current_user.id,
start_time=start_time,
end_time=end_time,
purpose=purpose,
expected_attendees=comm.max_students or 30,
shift=comm.shift or 'Tarde',
virtual_link=virtual_link,
status=ReservationStatus.CONFIRMED.value if current_user.is_admin() else ReservationStatus.PENDING.value
)
db.session.add(new_res)
db.session.commit()
return jsonify({'success': True, 'id': new_res.id})
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 500
@schedule_bp.route('/list') @schedule_bp.route('/list')
@login_required @login_required
@@ -604,7 +244,6 @@ def list_reservations():
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True'] hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
floor_filter = request.args.get('floor', '').strip() floor_filter = request.args.get('floor', '').strip()
shift_filter = request.args.get('shift', '').strip() shift_filter = request.args.get('shift', '').strip()
classroom_id = request.args.get('classroom_id', type=int)
search = request.args.get('search', '').strip() search = request.args.get('search', '').strip()
query = Reservation.query.options( query = Reservation.query.options(
@@ -613,9 +252,6 @@ def list_reservations():
joinedload(Reservation.user) joinedload(Reservation.user)
).join(Reservation.classroom).outerjoin(Reservation.commission).outerjoin(Commission.subject) ).join(Reservation.classroom).outerjoin(Reservation.commission).outerjoin(Commission.subject)
if classroom_id:
query = query.filter(Reservation.classroom_id == classroom_id)
if status_filter and status_filter != 'all': if status_filter and status_filter != 'all':
query = query.filter(Reservation.status == status_filter.upper()) query = query.filter(Reservation.status == status_filter.upper())
@@ -639,12 +275,8 @@ def list_reservations():
~Classroom.building.ilike('%Virtual%') ~Classroom.building.ilike('%Virtual%')
) )
if floor_filter: if floor_filter and floor_filter.isdigit():
flr = str(floor_filter).strip() query = query.filter(Classroom.floor == int(floor_filter))
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
query = query.filter(or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
else:
query = query.filter(Classroom.floor == flr)
if shift_filter: if shift_filter:
query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%')) query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%'))
@@ -654,10 +286,8 @@ def list_reservations():
or_( or_(
Subject.name.ilike(f'%{search}%'), Subject.name.ilike(f'%{search}%'),
Subject.code.ilike(f'%{search}%'), Subject.code.ilike(f'%{search}%'),
Commission.code.ilike(f'%{search}%'),
Classroom.code.ilike(f'%{search}%'), Classroom.code.ilike(f'%{search}%'),
Reservation.purpose.ilike(f'%{search}%'), Reservation.purpose.ilike(f'%{search}%')
Reservation.shift.ilike(f'%{search}%')
) )
) )
@@ -667,7 +297,6 @@ def list_reservations():
floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all() floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()
available_floors = [f[0] for f in floors] available_floors = [f[0] for f in floors]
all_classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all()
return render_template( return render_template(
'schedule/list.html', 'schedule/list.html',
@@ -678,8 +307,6 @@ def list_reservations():
hide_virtual=hide_virtual, hide_virtual=hide_virtual,
floor_filter=floor_filter, floor_filter=floor_filter,
shift_filter=shift_filter, shift_filter=shift_filter,
classroom_id=classroom_id,
all_classrooms=all_classrooms,
search=search, search=search,
available_floors=available_floors available_floors=available_floors
) )
@@ -816,6 +443,7 @@ def calendar_data():
search = request.args.get('search', '').strip() search = request.args.get('search', '').strip()
if start and end: if start and end:
from datetime import datetime
# Clean up ISO timezone strings for PostgreSQL naive timestamp comparison # Clean up ISO timezone strings for PostgreSQL naive timestamp comparison
start_clean = start.replace('Z', '+00:00') start_clean = start.replace('Z', '+00:00')
end_clean = end.replace('Z', '+00:00') end_clean = end.replace('Z', '+00:00')
@@ -844,16 +472,6 @@ def calendar_data():
if career_id and career_id.isdigit(): if career_id and career_id.isdigit():
query = query.filter(Subject.career_id == int(career_id)) query = query.filter(Subject.career_id == int(career_id))
building = request.args.get('building', '').strip()
if building:
from app.models.building import Building
query = query.filter(
or_(
Classroom.building.ilike(f'%{building}%'),
Classroom.building_entity.has(Building.name.ilike(f'%{building}%'))
)
)
if shift: if shift:
query = query.filter(Reservation.shift.ilike(f'%{shift}%')) query = query.filter(Reservation.shift.ilike(f'%{shift}%'))
@@ -863,12 +481,8 @@ def calendar_data():
~Classroom.building.ilike('%Virtual%') ~Classroom.building.ilike('%Virtual%')
) )
if floor: if floor and floor.isdigit():
flr = str(floor).strip() query = query.filter(Classroom.floor == int(floor))
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
query = query.filter(or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
else:
query = query.filter(Classroom.floor == flr)
if search: if search:
query = query.filter( query = query.filter(
@@ -888,9 +502,8 @@ def calendar_data():
subject_code = subject.code if subject else (reservation.commission.code if reservation.commission else '') subject_code = subject.code if subject else (reservation.commission.code if reservation.commission else '')
subject_name = subject.name if subject else (reservation.purpose or 'Clase') subject_name = subject.name if subject else (reservation.purpose or 'Clase')
classroom_name = reservation.classroom.code if reservation.classroom else 'Sin Aula' classroom_name = reservation.classroom.code if reservation.classroom else 'Sin Aula'
classroom_floor = reservation.classroom.floor_display if reservation.classroom else None classroom_floor = reservation.classroom.floor if reservation.classroom else None
classroom_building = reservation.classroom.building_name if reservation.classroom else '' is_virtual = bool(reservation.classroom and ('VIRTUAL' in (reservation.classroom.code or '').upper() or 'VIRTUAL' in (reservation.classroom.building or '').upper()))
is_virtual = bool(reservation.classroom and reservation.classroom.is_virtual)
career_name = subject.career_obj.name if (subject and subject.career_obj) else (subject.department if subject else '') career_name = subject.career_obj.name if (subject and subject.career_obj) else (subject.department if subject else '')
status_val = str(reservation.status.value if hasattr(reservation.status, 'value') else reservation.status) status_val = str(reservation.status.value if hasattr(reservation.status, 'value') else reservation.status)
@@ -905,20 +518,16 @@ def calendar_data():
'subject_code': subject_code, 'subject_code': subject_code,
'subject_name': subject_name, 'subject_name': subject_name,
'classroom': classroom_name, 'classroom': classroom_name,
'building': classroom_building, 'building': reservation.classroom.building if reservation.classroom else '',
'floor': classroom_floor, 'floor': classroom_floor,
'is_virtual': is_virtual, 'is_virtual': is_virtual,
'location_display': reservation.classroom.location_display if reservation.classroom else '',
'career': career_name, 'career': career_name,
'commission': reservation.commission.code if reservation.commission else '', 'commission': reservation.commission.code if reservation.commission else '',
'shift': reservation.shift or '', 'shift': reservation.shift or '',
'status': status_val, 'status': status_val,
'purpose': reservation.purpose or '', 'purpose': reservation.purpose or '',
'attendees': reservation.expected_attendees or 0, 'attendees': reservation.expected_attendees or 0,
'notes': reservation.notes or '', 'notes': reservation.notes or ''
'teacher': reservation.user.name if reservation.user else '',
'virtual_link': reservation.effective_virtual_link,
'cancel_url': url_for('schedule.cancel_reservation', id=reservation.id)
} }
}) })
@@ -1,252 +0,0 @@
{% 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
@@ -1,208 +0,0 @@
{% 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 %}
-343
View File
@@ -1,343 +0,0 @@
{% 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 %}
@@ -1,404 +0,0 @@
{% 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 %}
+27 -56
View File
@@ -82,16 +82,37 @@
<i class="bi bi-pencil-square me-1"></i>{% trans %}Permisos{% endtrans %} <i class="bi bi-pencil-square me-1"></i>{% trans %}Permisos{% endtrans %}
</a> </a>
{% if not role.is_system %} {% if not role.is_system %}
<button type="button" class="btn btn-outline-danger btn-delete-role" <button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#deleteModal{{ role.id }}" title="{% trans %}Eliminar Rol{% endtrans %}" {% if role.users|length > 0 %}disabled{% endif %}>
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> <i class="bi bi-trash"></i>
</button> </button>
{% endif %} {% endif %}
</div> </div>
{% if not role.is_system %}
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal{{ role.id }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow">
<div class="modal-header border-0">
<h5 class="modal-title text-danger">
<i class="bi bi-exclamation-triangle-fill me-2"></i>{% trans %}Confirmar Eliminación de Rol{% endtrans %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-start">
<p>{% trans %}¿Está seguro de que desea eliminar el rol{% endtrans %} <strong>{{ role.name }}</strong>?</p>
<p class="text-muted small mb-0">{% trans %}Esta acción no se puede deshacer.{% endtrans %}</p>
</div>
<div class="modal-footer border-0">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<form action="{{ url_for('admin.role_delete', id=role.id) }}" method="POST" class="d-inline">
<button type="submit" class="btn btn-danger">{% trans %}Eliminar Rol{% endtrans %}</button>
</form>
</div>
</div>
</div>
</div>
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -100,54 +121,4 @@
</div> </div>
</div> </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 %} {% endblock %}
-306
View File
@@ -1,306 +0,0 @@
{% 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 %}
-65
View File
@@ -136,14 +136,6 @@
<i class="bi {% if u.is_active %}bi-person-x{% else %}bi-person-check{% endif %}"></i> <i class="bi {% if u.is_active %}bi-person-x{% else %}bi-person-check{% endif %}"></i>
</button> </button>
</form> </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 %} {% endif %}
</div> </div>
</td> </td>
@@ -187,61 +179,4 @@
{% endif %} {% endif %}
</div> </div>
</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 %} {% endblock %}
+7 -34
View File
@@ -90,46 +90,19 @@
<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> {% trans %}Today's Schedule{% endtrans %} <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>
{% if current_user.is_authenticated and current_user.can_manage() %} {% if current_user.is_authenticated and current_user.can_manage() %}
<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-shield-lock"></i> {% trans %}Gestión{% endtrans %} <i class="bi bi-shield-lock"></i> {% trans %}Management{% endtrans %}
</a> </a>
<ul class="dropdown-menu shadow-sm" style="min-width: 260px;"> <ul class="dropdown-menu shadow-sm">
<!-- Espacios & Infraestructura --> <li><a class="dropdown-item" href="{{ url_for('admin.users_list') }}"><i class="bi bi-people me-2"></i>{% trans %}Users{% endtrans %}</a></li>
<li 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> <li><a class="dropdown-item" href="{{ url_for('admin.roles_list') }}"><i class="bi bi-person-badge me-2"></i>{% trans %}Roles & Groups{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}"><i class="bi bi-buildings me-2 text-secondary"></i>{% trans %}Edificios y Sedes{% endtrans %}</a></li> <li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}"><i class="bi bi-buildings me-2"></i>{% trans %}Buildings{% endtrans %}</a></li>
<li><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> <li><hr class="dropdown-divider"></li>
<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> <li><a class="dropdown-item" href="{{ url_for('admin.google_sheets_import') }}"><i class="bi bi-file-earmark-spreadsheet me-2 text-success"></i>{% trans %}Import from Google Sheets{% endtrans %}</a></li>
<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 my-1"></li>
<!-- Gestión Académica -->
<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 %} {% endif %}
+118 -156
View File
@@ -7,19 +7,11 @@
<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">
<div> <h1 class="h3">
<nav aria-label="breadcrumb"> <i class="bi bi-plus-circle text-primary"></i> {% trans %}Add Classroom{% endtrans %}
<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 me-1"></i>{% trans %}Back to Classrooms{% endtrans %} <i class="bi bi-arrow-left"></i> {% trans %}Back to Classrooms{% endtrans %}
</a> </a>
</div> </div>
</div> </div>
@@ -33,78 +25,56 @@
<i class="bi bi-door-open text-primary me-2"></i> {% trans %}Classroom Information{% endtrans %} <i class="bi bi-door-open text-primary me-2"></i> {% trans %}Classroom Information{% endtrans %}
</h5> </h5>
</div> </div>
<div class="card-body p-4"> <div class="card-body">
<form method="POST" id="classroom-form" novalidate> <form method="POST" id="classroom-form" novalidate>
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
<div class="row g-3 mb-3"> <div class="row">
<!-- Selector de Edificio / Sede --> <div class="col-md-4 mb-3">
<div class="col-md-6"> {{ form.building.label(class="form-label") }}
<label for="building_id" class="form-label fw-semibold"> {{ form.building(class="form-control", placeholder="A") }}
<i class="bi bi-buildings text-primary me-1"></i>{% trans %}Edificio / Sede{% endtrans %} <span class="text-danger">*</span> {% if form.building.errors %}
</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_id.errors[0] }} {{ form.building.errors[0] }}
</div>
{% 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>
<!-- Selector / Input de Piso con sugerencias -->
<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">
<!-- Poblado dinámicamente por JS según el edificio -->
</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> </div>
{% endif %} {% endif %}
</div> </div>
<!-- Número / Nombre de Aula --> <div class="col-md-4 mb-3">
<div class="col-md-3"> {{ form.room_number.label(class="form-label") }}
<label for="room_number" class="form-label fw-semibold"> {{ form.room_number(class="form-control", placeholder="101") }}
<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 %} {% if form.room_number.errors %}
<div class="text-danger small mt-1"> <div class="text-danger small mt-1">
{{ form.room_number.errors[0] }} {{ form.room_number.errors[0] }}
</div> </div>
{% endif %} {% endif %}
</div> </div>
<div class="col-md-4 mb-3">
{{ form.floor.label(class="form-label") }}
{{ form.floor(class="form-control") }}
{% if form.floor.errors %}
<div class="text-danger small mt-1">
{{ form.floor.errors[0] }}
</div>
{% endif %}
</div>
</div> </div>
<div class="row g-3 mb-3"> <div class="row">
<div class="col-md-6"> <div class="col-md-6 mb-3">
<div class="d-flex justify-content-between align-items-center mb-1"> <div class="d-flex justify-content-between align-items-center mb-1">
<label for="capacity" class="form-label fw-semibold mb-0">{% trans %}Capacidad de Alumnos{% endtrans %} <span class="text-danger">*</span></label> {{ form.capacity.label(class="form-label mb-0") }}
<button type="button" class="btn btn-sm btn-outline-primary py-0 px-2" id="btnInfinityAdd" style="font-size: 0.8rem;" title="{% trans %}Asignar capacidad ilimitada{% endtrans %}"> <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 %} <i class="bi bi-infinity me-1"></i>{% trans %}Ilimitada (∞){% endtrans %}
</button> </button>
</div> </div>
<div class="input-group"> <div class="input-group">
{{ form.capacity(class="form-control", id="capacity", placeholder="ej. 35 o ∞") }} {{ form.capacity(class="form-control", id="capacity", placeholder="ej. 30 o ∞") }}
<span class="input-group-text bg-body-tertiary text-muted font-monospace"><i class="bi bi-people"></i></span> <span class="input-group-text bg-body-tertiary text-muted font-monospace"><i class="bi bi-people"></i></span>
</div> </div>
<div class="form-text small text-muted mt-1"> <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 %} <i class="bi bi-info-circle text-info me-1"></i>{% trans %}Para aulas virtuales o sin límite de aforo, ingrese <strong>∞</strong> o <strong>0</strong> (Capacidad ilimitada).{% endtrans %}
</div> </div>
{% if form.capacity.errors %} {% if form.capacity.errors %}
<div class="text-danger small mt-1"> <div class="text-danger small mt-1">
@@ -113,17 +83,18 @@
{% endif %} {% endif %}
</div> </div>
<div class="col-md-6 d-flex align-items-center pt-md-3"> <div class="col-md-6 mb-3">
<div class="form-check form-switch pt-2"> <label class="form-label d-block">&nbsp;</label>
{{ form.is_active(class="form-check-input", id="is_active") }} <div class="form-check pt-2">
<label class="form-check-label fw-semibold" for="is_active">{% trans %}Aula Habilitada para Reservas{% endtrans %}</label> {{ form.is_active(class="form-check-input") }}
{{ form.is_active.label(class="form-check-label") }}
</div> </div>
</div> </div>
</div> </div>
<div class="mb-4"> <div class="mb-4">
{{ form.description.label(class="form-label fw-semibold") }} {{ form.description.label(class="form-label") }}
{{ form.description(class="form-control", rows="3", placeholder=_("Opcional: Detalles adicionales de equipamiento, tomas de corriente, proyector...")) }} {{ form.description(class="form-control", rows="3", placeholder=_("Optional: Add any additional details about this classroom...")) }}
{% 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] }}
@@ -131,39 +102,46 @@
{% endif %} {% endif %}
</div> </div>
<!-- Preview Card --> <!-- Preview Section -->
<div class="card bg-body-tertiary border-0 shadow-xs mb-4"> <div class="alert alert-info">
<div class="card-body py-3 px-4"> <h6 class="alert-heading">
<h6 class="card-subtitle mb-2 text-muted small fw-bold"> <i class="bi bi-info-circle"></i> {% trans %}Preview{% endtrans %}
<i class="bi bi-eye me-1"></i>{% trans %}Vista Previa de Ubicación{% endtrans %}
</h6> </h6>
<div class="row g-2"> <div id="classroom-preview">
<div class="col-md-4"> <p class="mb-1">{% trans %}Fill in the form to see a preview of the classroom code:{% endtrans %}
<small class="text-muted d-block">🏢 {% trans %}Edificio{% endtrans %}</small> <span class="fw-bold" id="preview-code">-</span>
<strong id="preview-building" class="text-primary">-</strong> </p>
<p class="mb-0">{% trans %}Full location:{% endtrans %}
<span class="fw-bold" id="preview-location">-</span>
</p>
</div> </div>
<div class="col-md-4">
<small class="text-muted d-block">🪜 {% trans %}Piso{% endtrans %}</small>
<strong id="preview-floor" class="text-secondary">-</strong>
</div> </div>
<div class="col-md-4">
<small class="text-muted d-block">🚪 {% trans %}Aula / Código{% endtrans %}</small> <div class="d-flex justify-content-between">
<strong id="preview-room" class="text-success">-</strong> <a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle"></i> {% trans %}Cancel{% endtrans %}
</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-save me-1"></i> {{ form.submit.label.text }}
</button>
</div>
</form>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Buttons --> <!-- Resources Section -->
<div class="d-flex justify-content-between"> <div class="row mt-4 mb-4">
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary"> <div class="col-lg-8 mx-auto">
{% trans %}Cancelar{% endtrans %} <div class="card border-0 shadow-sm">
</a> <div class="card-header bg-body-tertiary border-0 py-3">
<button type="submit" class="btn btn-primary px-4"> <h5 class="mb-0 h6 fw-bold">
<i class="bi bi-check-circle me-1"></i>{% trans %}Crear Aula{% endtrans %} <i class="bi bi-tools text-warning me-2"></i> {% trans %}Resources (Optional){% endtrans %}
</button> </h5>
</div> </div>
</form> <div class="card-body">
<p class="text-muted mb-0">{% trans %}You can add classroom resources after creating the classroom.{% endtrans %}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -173,84 +151,68 @@
{% block extra_js %} {% block extra_js %}
<script> <script>
document.addEventListener('DOMContentLoaded', function() { $(document).ready(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() {
var bText = '-'; const building = $('#building').val();
if (buildingSelect) { const roomNumber = $('#room_number').val();
if (buildingSelect.value === '__new__') { const floor = $('#floor').val();
bText = newBuildingNameInput.value.trim() || '{{ _("Nuevo Edificio") }}'; const floorLabel = '{{ _("Floor") }}';
} 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 (building && roomNumber && floor) {
if (previewFloor) previewFloor.textContent = (fText === '0' || fText.toUpperCase() === 'PB') ? 'Planta Baja' : fText; $('#preview-code').text(`${building}-${roomNumber}`);
if (previewRoom) previewRoom.textContent = rText; $('#preview-location').text(`${building} - ${floorLabel} ${floor}`);
}
if (buildingSelect) {
buildingSelect.addEventListener('change', function() {
if (this.value === '__new__') {
newBuildingContainer.style.display = 'block';
if (newBuildingNameInput) newBuildingNameInput.focus();
} else { } else {
newBuildingContainer.style.display = 'none'; $('#preview-code').text('-');
updateFloorsDatalist(this.value); $('#preview-location').text('-');
}
updatePreview();
});
// Initial call
if (buildingSelect.value === '__new__') {
newBuildingContainer.style.display = 'block';
} else if (buildingSelect.value) {
updateFloorsDatalist(buildingSelect.value);
} }
} }
if (newBuildingNameInput) newBuildingNameInput.addEventListener('input', updatePreview); // Update preview when inputs change
if (floorInput) floorInput.addEventListener('input', updatePreview); $('#building, #room_number, #floor').on('input', updatePreview);
if (roomInput) roomInput.addEventListener('input', updatePreview);
// Initialize preview
updatePreview(); updatePreview();
// Infinity button // Form validation
var btnInf = document.getElementById('btnInfinityAdd'); $('#classroom-form').on('submit', function(e) {
var capInput = document.getElementById('capacity'); let isValid = true;
if (btnInf && capInput) {
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');
}
var floorVal = $('#floor').val();
if (floorVal === '' || isNaN(floorVal)) {
$('#floor').addClass('is-invalid');
isValid = false;
} else {
$('#floor').removeClass('is-invalid');
}
var capVal = $('#capacity').val() ? $('#capacity').val().trim() : '';
if (!capVal || (capVal !== '∞' && capVal !== '0' && isNaN(capVal))) {
$('#capacity').addClass('is-invalid');
isValid = false;
} else {
$('#capacity').removeClass('is-invalid');
}
return isValid;
});
$('#btnInfinityAdd').on('click', function() {
$('#capacity').val('∞').focus();
});
}); });
</script> </script>
{% endblock %} {% endblock %}
+65 -150
View File
@@ -1,36 +1,32 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{% trans %}Edit Classroom{% endtrans %} - {{ classroom.code_display }}{% endblock %} {% block title %}{% trans %}Edit Classroom{% endtrans %} - {{ classroom.code_display }} - Edu-Space Admin{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid pt-5 mt-4"> <div class="container-fluid pt-5 mt-4">
<!-- Header --> <div class="row">
<div class="row mb-4 align-items-center"> <div class="col-12">
<div class="col-md-7"> <div class="d-flex justify-content-between align-items-center mb-4">
<nav aria-label="breadcrumb"> <div>
<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"> <h1 class="h3 mb-1">
<i class="bi bi-pencil-square text-primary me-2"></i>{% trans %}Edit Classroom{% endtrans %} <i class="bi bi-pencil-square text-primary me-2"></i>{% trans %}Edit Classroom{% endtrans %}
</h1> </h1>
<p class="text-muted small mb-0">{{ classroom.location_display }}</p> <p class="text-muted small mb-0">{{ classroom.building }} — {{ classroom.code }}</p>
</div> </div>
<div class="col-md-5 text-md-end mt-3 mt-md-0"> <div class="d-flex gap-2">
<a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}" class="btn btn-outline-secondary me-2"> <a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}" class="btn btn-outline-primary">
<i class="bi bi-eye me-1"></i>{% trans %}Ver Detalle{% endtrans %} <i class="bi bi-eye me-1"></i>{% trans %}Ver Ficha{% endtrans %}
</a> </a>
<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 me-1"></i>{% trans %}Volver al Listado{% endtrans %} <i class="bi bi-arrow-left me-1"></i>{% trans %}Back to Classrooms{% endtrans %}
</a> </a>
</div> </div>
</div> </div>
</div>
</div>
<div class="row justify-content-center"> <div class="row">
<div class="col-lg-8"> <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-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center"> <div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 h6 fw-bold"> <h5 class="mb-0 h6 fw-bold">
@@ -42,56 +38,34 @@
<form method="POST" id="classroom-form" novalidate> <form method="POST" id="classroom-form" novalidate>
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
<div class="row g-3 mb-3"> <div class="row g-3">
<!-- Edificio --> <div class="col-md-4">
<div class="col-md-6"> {{ form.building.label(class="form-label fw-semibold") }}
<label for="building_id" class="form-label fw-semibold"> {{ form.building(class="form-control" + (" is-invalid" if form.building.errors else ""), placeholder="Edificio Central") }}
<i class="bi bi-buildings text-primary me-1"></i>{% trans %}Edificio / Sede{% endtrans %} <span class="text-danger">*</span> {% if form.building.errors %}
</label> <div class="invalid-feedback">{{ form.building.errors[0] }}</div>
{{ 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 %} {% endif %}
</div> </div>
<!-- Aula --> <div class="col-md-4">
<div class="col-md-3"> {{ form.room_number.label(class="form-label fw-semibold") }}
<label for="room_number" class="form-label fw-semibold"> {{ form.room_number(class="form-control" + (" is-invalid" if form.room_number.errors else ""), placeholder="402") }}
<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 %} {% if form.room_number.errors %}
<div class="text-danger small mt-1">{{ form.room_number.errors[0] }}</div> <div class="invalid-feedback">{{ form.room_number.errors[0] }}</div>
{% endif %} {% endif %}
</div> </div>
<div class="col-md-4">
{{ form.floor.label(class="form-label fw-semibold") }}
{{ form.floor(class="form-control" + (" is-invalid" if form.floor.errors else "")) }}
{% if form.floor.errors %}
<div class="invalid-feedback">{{ form.floor.errors[0] }}</div>
{% endif %}
</div> </div>
<div class="row g-3 mb-3">
<div class="col-md-6"> <div class="col-md-6">
<div class="d-flex justify-content-between align-items-center mb-1"> <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> {{ form.capacity.label(class="form-label fw-semibold mb-0") }}
<button type="button" class="btn btn-sm btn-outline-primary py-0 px-2" id="btnInfinity" style="font-size: 0.8rem;" title="{% trans %}Asignar capacidad ilimitada{% endtrans %}"> <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 %} <i class="bi bi-infinity me-1"></i>{% trans %}Ilimitada (∞){% endtrans %}
</button> </button>
@@ -101,55 +75,43 @@
<span class="input-group-text bg-body-tertiary text-muted font-monospace"><i class="bi bi-people"></i></span> <span class="input-group-text bg-body-tertiary text-muted font-monospace"><i class="bi bi-people"></i></span>
</div> </div>
<div class="form-text small text-muted mt-1"> <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 %} <i class="bi bi-info-circle text-info me-1"></i>{% trans %}Para aulas virtuales o eventos sin aforo restringido, ingrese <strong>∞</strong> o <strong>0</strong> (Capacidad ilimitada).{% endtrans %}
</div> </div>
{% if form.capacity.errors %} {% if form.capacity.errors %}
<div class="invalid-feedback d-block">{{ form.capacity.errors[0] }}</div> <div class="invalid-feedback d-block">{{ form.capacity.errors[0] }}</div>
{% endif %} {% endif %}
</div> </div>
<div class="col-md-6 d-flex align-items-center pt-md-3"> <div class="col-md-6 d-flex align-items-center pt-md-4">
<div class="form-check form-switch pt-2"> <div class="form-check form-switch pt-2">
{{ form.is_active(class="form-check-input", id="is_active") }} {{ form.is_active(class="form-check-input", role="switch") }}
<label class="form-check-label fw-semibold" for="is_active"> {{ form.is_active.label(class="form-check-label fw-semibold") }}
{% trans %}Aula Habilitada para Reservas{% endtrans %}
</label>
</div>
</div> </div>
</div> </div>
<div class="mb-4"> <div class="col-12">
{{ form.description.label(class="form-label fw-semibold") }} {{ 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...")) }} {{ form.description(class="form-control" + (" is-invalid" if form.description.errors else ""), rows="3", placeholder="Detalles sobre equipamiento o uso preferente del aula...") }}
{% if form.description.errors %} {% if form.description.errors %}
<div class="invalid-feedback">{{ form.description.errors[0] }}</div> <div class="invalid-feedback">{{ form.description.errors[0] }}</div>
{% endif %} {% endif %}
</div> </div>
</div>
<!-- Preview Card --> <!-- Live Preview Card -->
<div class="card bg-body-tertiary border-0 shadow-xs mb-4"> <div class="alert alert-info border-0 mt-4 mb-0">
<div class="card-body py-3 px-4"> <h6 class="alert-heading fw-bold mb-2">
<h6 class="card-subtitle mb-2 text-muted small fw-bold"> <i class="bi bi-info-circle me-1"></i>{% trans %}Vista Previa de Identificador{% endtrans %}
<i class="bi bi-eye me-1"></i>{% trans %}Vista Previa de Ubicación{% endtrans %}
</h6> </h6>
<div class="row g-2"> <div id="classroom-preview">
<div class="col-md-4"> <p class="mb-0">
<small class="text-muted d-block">🏢 {% trans %}Edificio{% endtrans %}</small> {% trans %}Código de visualización{% endtrans %}:
<strong id="preview-building" class="text-primary">{{ classroom.building_name }}</strong> <strong id="preview-code" class="text-primary font-monospace fs-6">{{ classroom.code_display }}</strong>
</div> </p>
<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> </div>
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-end gap-2 mt-4 pt-3 border-top">
<a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}" class="btn btn-outline-secondary"> <a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}" class="btn btn-outline-secondary">
{% trans %}Cancelar{% endtrans %} {% trans %}Cancelar{% endtrans %}
</a> </a>
@@ -168,78 +130,31 @@
{% block extra_js %} {% block extra_js %}
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
var buildingFloors = {{ building_floors|tojson }}; var buildingInput = document.getElementById('building');
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 roomInput = document.getElementById('room_number');
var previewCode = document.getElementById('preview-code');
var previewBuilding = document.getElementById('preview-building'); var capacityInput = document.getElementById('capacityInput');
var previewFloor = document.getElementById('preview-floor'); var btnInfinity = document.getElementById('btnInfinity');
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() {
var bText = '-'; var b = buildingInput ? buildingInput.value.trim() : '';
if (buildingSelect) { var r = roomInput ? roomInput.value.trim() : '';
if (buildingSelect.value === '__new__') { if (b && r) {
bText = newBuildingNameInput.value.trim() || '{{ _("Nuevo Edificio") }}'; previewCode.textContent = b + '-' + r;
} else if (buildingSelect.selectedIndex >= 0) { } else if (r) {
bText = buildingSelect.options[buildingSelect.selectedIndex].text; previewCode.textContent = r;
}
}
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 { } else {
newBuildingContainer.style.display = 'none'; previewCode.textContent = '---';
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 (buildingInput) buildingInput.addEventListener('input', updatePreview);
if (floorInput) floorInput.addEventListener('input', updatePreview);
if (roomInput) roomInput.addEventListener('input', updatePreview); if (roomInput) roomInput.addEventListener('input', updatePreview);
updatePreview(); if (btnInfinity && capacityInput) {
btnInfinity.addEventListener('click', function() {
var btnInf = document.getElementById('btnInfinity'); capacityInput.value = '∞';
var capInput = document.getElementById('capacityInput'); capacityInput.focus();
if (btnInf && capInput) {
btnInf.addEventListener('click', function() {
capInput.value = '∞';
capInput.focus();
}); });
} }
}); });
+94 -737
View File
@@ -1,250 +1,50 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{% trans %}Aulas y Espacios{% endtrans %} - Edu-Space Admin{% endblock %} {% block title %}{% trans %}Classrooms{% 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 flex-wrap gap-3"> <div class="d-flex justify-content-between align-items-center mb-4">
<div> <h1 class="h3">
<h1 class="h3 mb-1"> <i class="bi bi-door-open text-primary"></i> {% trans %}Classrooms{% endtrans %}
<i class="bi bi-door-open text-primary me-2"></i>{% trans %}Aulas y Espacios de Aprendizaje{% endtrans %}
</h1> </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('classrooms.add_classroom') }}" class="btn btn-primary">
<a href="{{ url_for('schedule.add_reservation') }}?is_virtual=1" class="btn btn-success shadow-sm"> <i class="bi bi-plus-circle"></i> {% trans %}Add Classroom{% endtrans %}
<i class="bi bi-camera-video me-1"></i>{% trans %}Nueva Clase Virtual a Demanda{% endtrans %}
</a> </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>
</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>
<!-- ========================================================================= --> <!-- Filters -->
<!-- VISTA 1: AULAS FÍSICAS (PRESENCIALES) --> <div class="row">
<!-- ========================================================================= -->
{% 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 py-2"> <div class="card-body">
<form method="GET" class="row g-2 align-items-center"> <form method="GET" class="row g-3">
<input type="hidden" name="view_mode" value="physical"> <div class="col-md-4">
<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="{% trans %}Buscar aula, descripción...{% endtrans %}" placeholder="{% trans %}Search by building, room, description...{% 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="input-group input-group-sm"> <div class="form-check pt-2">
<span class="input-group-text bg-body-tertiary"><i class="bi bi-buildings text-primary"></i></span> <input class="form-check-input" type="checkbox" name="active_only"
<select name="building_id" class="form-select form-select-sm" onchange="this.form.submit()"> id="activeOnly" {% if active_only %}checked{% endif %}>
<option value="">{% trans %}Todos los edificios presenciales{% endtrans %}</option> <label class="form-check-label" for="activeOnly">
{% for b in buildings %} {% trans %}Show active classrooms only{% endtrans %}
<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">
<div class="col-md-2 text-md-end"> <button type="submit" class="btn btn-outline-primary">
<button type="submit" class="btn btn-sm btn-primary me-1"> <i class="bi bi-search"></i> {% trans %}Search{% endtrans %}
<i class="bi bi-filter"></i> {% trans %}Filtrar{% endtrans %}
</button> </button>
<a href="{{ url_for('classrooms.list_classrooms', view_mode='physical') }}" class="btn btn-sm btn-outline-secondary"> <a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle"></i> {% trans %}Limpiar{% endtrans %} <i class="bi bi-x-circle"></i> {% trans %}Clear{% endtrans %}
</a> </a>
</div> </div>
</form> </form>
@@ -253,109 +53,83 @@
</div> </div>
</div> </div>
<!-- Grilla de Aulas Físicas --> <!-- Classrooms Grid -->
{% if classrooms and classrooms.items %} {% if classrooms and classrooms.items %}
<div class="row g-3 mt-1"> <div class="row mt-4">
{% for classroom in classrooms.items %} {% for classroom in classrooms.items %}
<div class="col-xxl-3 col-xl-3 col-lg-4 col-md-6 col-sm-12 mb-2"> <div class="col-lg-4 col-md-6 mb-4">
<div class="card classroom-card border shadow-sm h-100"> <div class="card border-0 shadow-sm h-100">
<div class="card-body p-3 d-flex flex-column justify-content-between"> <div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-3">
<div> <div>
<!-- Encabezado con Código y Estado --> <h5 class="card-title mb-1">
<div class="d-flex justify-content-between align-items-start mb-2"> {{ classroom.code_display }}
<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 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;"> <span class="badge {% if classroom.is_active %}bg-success{% else %}bg-secondary{% endif %}">
{% if classroom.is_active %}{% trans %}Activa{% endtrans %}{% else %}{% trans %}Inactiva{% endtrans %}{% endif %} {% if classroom.is_active %}{% trans %}Active{% endtrans %}{% else %}{% trans %}Inactive{% endtrans %}{% endif %}
</span> </span>
</div> </div>
<!-- Ubicación: Edificio y Piso --> <div class="row small">
<div class="d-flex flex-wrap gap-1 mb-2"> <div class="col-6">
<span class="badge bg-primary-subtle text-primary border" style="font-size: 0.7rem;"> <span class="text-muted">{% trans %}Capacity{% endtrans %}:</span>
<i class="bi bi-buildings me-1"></i>{{ classroom.building_name }} <strong>
</span> {% if classroom.is_unlimited %}
<span class="badge bg-secondary-subtle text-secondary border" style="font-size: 0.7rem;"> <span class="badge bg-primary-subtle text-primary border"><i class="bi bi-infinity me-1"></i>∞</span>
<i class="bi bi-layers me-1"></i>{{ classroom.floor_display }} {% else %}
</span> {{ classroom.capacity }}
{% endif %}
</strong>
</div>
<div class="col-6">
<span class="text-muted">{% trans %}Building{% endtrans %}:</span>
<strong>{{ classroom.building }}</strong>
</div>
</div> </div>
<!-- Capacidad y Recursos --> {% if classroom.description %}
<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;"> <p class="card-text small text-muted mt-2">
{{ classroom.description[:100] }}{% if classroom.description|length > 100 %}...{% endif %}
</p>
{% endif %}
<!-- Resources indicator -->
<div class="mt-2">
<small class="text-muted">
<i class="bi bi-tools"></i>
{% if classroom.resources %}
{{ classroom.resources|length }} {% trans %}resources{% endtrans %}
{% else %}
{% trans %}No resources registered{% endtrans %}
{% endif %}
</small>
</div>
</div>
<div class="card-footer bg-body-tertiary border-0">
<div class="d-flex justify-content-between">
<div> <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-outline-primary py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}" style="font-size: 0.75rem;"> class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye me-1"></i>{% trans %}Ficha{% endtrans %} <i class="bi bi-eye"></i> {% trans %}View{% 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-outline-secondary py-0 px-2" title="{% trans %}Editar Aula{% endtrans %}" style="font-size: 0.75rem;"> class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i> {% trans %}Edit{% endtrans %}
</a> </a>
</div> </div>
<form method="POST" action="{{ url_for('classrooms.toggle_classroom', id=classroom.id) }}" class="d-inline mb-0"> <form method="POST" action="{{ url_for('classrooms.toggle_classroom', id=classroom.id) }}"
<button type="submit" class="btn btn-sm py-0 px-2 {% if classroom.is_active %}btn-outline-warning{% else %}btn-outline-success{% endif %}" class="d-inline">
title="{% if classroom.is_active %}{% trans %}Desactivar{% endtrans %}{% else %}{% trans %}Activar{% endtrans %}{% endif %}" style="font-size: 0.75rem;"> <button type="submit" class="btn btn-sm
{% 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"></i> <i class="bi bi-pause-circle"></i> {% trans %}Deactivate{% endtrans %}
{% else %} {% else %}
<i class="bi bi-play"></i> <i class="bi bi-play-circle"></i> {% trans %}Activate{% endtrans %}
{% endif %} {% endif %}
</button> </button>
</form> </form>
@@ -366,14 +140,14 @@
{% endfor %} {% endfor %}
</div> </div>
<!-- Paginación Aulas Físicas --> <!-- Pagination -->
{% if classrooms.pages > 1 %} {% if classrooms.pages > 1 %}
<nav aria-label="Classroom pagination" class="mt-4"> <nav aria-label="Classroom pagination">
<ul class="pagination pagination-sm justify-content-center"> <ul class="pagination 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', view_mode='physical', page=classrooms.prev_num, search=search, active_only='true' if active_only else 'false', building_id=building_filter, floor=floor_filter) }}"> <a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=classrooms.prev_num, search=search, active_only=active_only) }}">
{% trans %}Anterior{% endtrans %} {% trans %}Previous{% endtrans %}
</a> </a>
</li> </li>
{% endif %} {% endif %}
@@ -382,7 +156,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', view_mode='physical', page=page_num, search=search, active_only='true' if active_only else 'false', building_id=building_filter, floor=floor_filter) }}"> <a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=page_num, search=search, active_only=active_only) }}">
{{ page_num }} {{ page_num }}
</a> </a>
</li> </li>
@@ -400,8 +174,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', view_mode='physical', page=classrooms.next_num, search=search, active_only='true' if active_only else 'false', building_id=building_filter, floor=floor_filter) }}"> <a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=classrooms.next_num, search=search, active_only=active_only) }}">
{% trans %}Siguiente{% endtrans %} {% trans %}Next{% endtrans %}
</a> </a>
</li> </li>
{% endif %} {% endif %}
@@ -414,437 +188,20 @@
<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">{% trans %}No se encontraron aulas físicas{% endtrans %}</h4> <h4 class="mt-3 text-muted">{% trans %}No classrooms found{% endtrans %}</h4>
<p class="text-muted"> <p class="text-muted">
{% if search %} {% if search %}
{% trans %}Ningún aula coincide con los criterios de búsqueda.{% endtrans %} {% trans %}No classrooms match your search criteria.{% endtrans %}
{% else %} {% else %}
{% trans %}No hay aulas físicas registradas en esta sede.{% endtrans %} {% trans %}No classrooms have been registered yet.{% 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> {% trans %}Agregar Primera Aula{% endtrans %} <i class="bi bi-plus-circle"></i> {% trans %}Add First Classroom{% 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 %}
+2 -10
View File
@@ -108,7 +108,7 @@
<table class="table table-hover align-middle mb-0"> <table class="table table-hover align-middle mb-0">
<thead class="table-light small text-uppercase"> <thead class="table-light small text-uppercase">
<tr> <tr>
<th class="ps-3">{% trans %}Fecha y Horario{% endtrans %}</th> <th class="ps-3">{% trans %}Horario{% endtrans %}</th>
<th>{% trans %}Materia / Motivo{% endtrans %}</th> <th>{% trans %}Materia / Motivo{% endtrans %}</th>
<th>{% trans %}Comisión{% endtrans %}</th> <th>{% trans %}Comisión{% endtrans %}</th>
<th>{% trans %}Asistentes{% endtrans %}</th> <th>{% trans %}Asistentes{% endtrans %}</th>
@@ -119,10 +119,7 @@
{% for res in upcoming_reservations %} {% for res in upcoming_reservations %}
<tr> <tr>
<td class="ps-3"> <td class="ps-3">
<div class="fw-bold font-monospace text-primary fs-6 mb-1"> <div class="fw-bold font-monospace text-primary fs-6">
<i class="bi bi-calendar-event text-primary me-1"></i>{{ res.start_time.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 <i class="bi bi-clock text-warning me-1"></i>{{ res.start_time.strftime('%H:%M') }} - {{ res.end_time.strftime('%H:%M') }} hs
</div> </div>
</td> </td>
@@ -139,11 +136,6 @@
<span class="small">{{ res.expected_attendees }} / {{ classroom.capacity }}</span> <span class="small">{{ res.expected_attendees }} / {{ classroom.capacity }}</span>
</td> </td>
<td class="text-end pe-3"> <td class="text-end pe-3">
{% if res.effective_virtual_link %}
<a href="{{ 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 %}"> <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> <i class="bi bi-eye"></i>
</a> </a>
-5
View File
@@ -141,11 +141,6 @@
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-info text-start"> <a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-info text-start">
<i class="bi bi-calendar-day me-2"></i>{% trans %}Cartelera del Día{% endtrans %} <i class="bi bi-calendar-day me-2"></i>{% trans %}Cartelera del Día{% endtrans %}
</a> </a>
{% if current_user.has_permission('optimizer', 'read') or (current_user.role and current_user.role|upper == 'ADMIN') %}
<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>
{% endif %}
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary text-start"> <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 %} <i class="bi bi-door-open me-2"></i>{% trans %}Listado de Aulas{% endtrans %}
</a> </a>
+130 -422
View File
@@ -7,125 +7,80 @@
<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">
<div> <h1 class="h3">
<h1 class="h3 mb-0"> <i class="bi bi-calendar-plus text-success"></i> {% trans %}New Reservation{% endtrans %}
<i class="bi bi-calendar-plus text-primary me-2"></i>{% trans %}New Reservation{% endtrans %}
</h1> </h1>
</div> <a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-secondary">
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary"> <i class="bi bi-arrow-left"></i> {% trans %}Back to Reservations{% endtrans %}
<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-10 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-body-tertiary border-0 py-3"> <div class="card-header border-0 py-3">
<h5 class="mb-0 h6 fw-bold"> <h5 class="mb-0">
<i class="bi bi-calendar3 text-primary me-2"></i>{% trans %}Detalles de la Reserva y Espacio Físico{% endtrans %} <i class="bi bi-calendar3 text-success"></i> {% trans %}Reservation Details{% endtrans %}
</h5> </h5>
</div> </div>
<div class="card-body p-4"> <div class="card-body">
<form method="POST" id="reservation-form" novalidate> <form method="POST" id="reservation-form" novalidate>
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
<!-- 1. SELECTORES EN CASCADA: Edificio -> Piso -> Aula --> <div class="row">
<div class="mb-4"> <div class="col-md-6 mb-3">
<div class="p-3 rounded bg-body-tertiary border"> {{ form.classroom_id.label(class="form-label") }}
<label class="form-label fw-bold text-primary mb-2"> {{ form.classroom_id(class="form-select", id="classroom-select") }}
<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 %}
<div class="form-text small text-muted">{% trans %}Capacidad y disponibilidad{% endtrans %}</div> <small class="text-muted">{% trans %}Select a classroom for your reservation{% endtrans %}</small>
</div>
</div>
</div>
</div> </div>
<!-- 2. Comisión y Materia --> <div class="col-md-6 mb-3">
<div class="row g-3 mb-3"> {{ form.commission_id.label(class="form-label") }}
<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 %}
<div class="form-text small text-muted">{% trans %}Materia, código de comisión y cantidad de alumnos inscriptos{% endtrans %}</div> <small class="text-muted">{% trans %}Select the subject/commission{% endtrans %}</small>
</div> </div>
</div> </div>
<!-- 3. Horario de Inicio y Fin --> <div class="row">
<div class="row g-3 mb-3"> <div class="col-md-6 mb-3">
<div class="col-md-6"> {{ form.start_time.label(class="form-label") }}
{{ 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">{% trans %}¿Cuándo comenzará la clase?{% endtrans %}</small> <small class="text-muted">{% trans %}When will the class start?{% endtrans %}</small>
</div> </div>
<div class="col-md-6"> <div class="col-md-6 mb-3">
{{ form.end_time.label(class="form-label fw-semibold") }} <span class="text-danger">*</span> {{ form.end_time.label(class="form-label") }}
{{ 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">{% trans %}¿Cuándo finalizará la clase?{% endtrans %}</small> <small class="text-muted">{% trans %}When will the class end?{% endtrans %}</small>
</div> </div>
</div> </div>
<!-- 4. Propósito y Asistentes --> <div class="row">
<div class="row g-3 mb-3"> <div class="col-md-8 mb-3">
<div class="col-md-8"> {{ form.purpose.label(class="form-label") }}
{{ form.purpose.label(class="form-label fw-semibold") }} <span class="text-danger">*</span> {{ form.purpose(class="form-control", placeholder=_("Regular class, exam, study session, etc.")) }}
{{ 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] }}
@@ -133,22 +88,21 @@
{% endif %} {% endif %}
</div> </div>
<div class="col-md-4"> <div class="col-md-4 mb-3">
{{ form.expected_attendees.label(class="form-label fw-semibold") }} <span class="text-danger">*</span> {{ form.expected_attendees.label(class="form-label") }}
{{ form.expected_attendees(class="form-control", id="expected_attendees", placeholder="30") }} {{ form.expected_attendees(class="form-control", 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">{% trans %}Cantidad de asistentes esperados{% endtrans %}</small> <small class="text-muted">{% trans %}Number of expected attendees{% endtrans %}</small>
</div> </div>
</div> </div>
<!-- 5. Notas --> <div class="mb-4">
<div class="mb-3"> {{ form.notes.label(class="form-label") }}
{{ form.notes.label(class="form-label fw-semibold") }} {{ form.notes(class="form-control", rows="3", placeholder=_("Any additional notes or special requirements...")) }}
{{ 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] }}
@@ -156,126 +110,84 @@
{% endif %} {% endif %}
</div> </div>
<!-- 6. Enlace de Aula Virtual (Zoom, Meet, Teams, Moodle) --> <!-- Summary Section -->
<div class="mb-4" id="virtual-link-container"> <div class="alert alert-info" id="reservation-summary">
<label for="virtual_link" class="form-label fw-semibold"> <h6 class="alert-heading">
<i class="bi bi-camera-video text-primary me-1"></i>{% trans %}Enlace de Aula Virtual (Zoom, Google Meet, Teams, Moodle){% endtrans %} <i class="bi bi-info-circle"></i> {% trans %}Reservation Summary{% endtrans %}
</label>
{{ form.virtual_link(class="form-control", id="virtual_link", placeholder="https://zoom.us/j/... o https://meet.google.com/...") }}
{% if form.virtual_link.errors %}
<div class="text-danger small mt-1">
{{ form.virtual_link.errors[0] }}
</div>
{% endif %}
<div class="form-text small text-muted">
<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 %}
</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> </h6>
<div id="summary-content">
<!-- Badges desglosados de Ubicación --> <p class="mb-2">
<div class="row g-2 mb-3"> <strong>{% trans %}Selected Classroom:{% endtrans %}</strong>
<div class="col-md-4"> <span id="summary-classroom">{% trans %}Not selected{% endtrans %}</span>
<div class="p-2 rounded bg-body border"> </p>
<small class="text-muted d-block">🏢 {% trans %}Edificio / Sede{% endtrans %}</small> <p class="mb-2">
<strong id="summary-building" class="text-primary">-</strong> <strong>{% trans %}Duration:{% endtrans %}</strong>
</div> <span id="summary-duration">{% trans %}Not calculated{% endtrans %}</span>
</div> </p>
<div class="col-md-4"> <p class="mb-0">
<div class="p-2 rounded bg-body border"> <strong>{% trans %}Commission:{% endtrans %}</strong>
<small class="text-muted d-block">🪜 {% trans %}Piso / Nivel{% endtrans %}</small> <span id="summary-commission">{% trans %}Not selected{% endtrans %}</span>
<strong id="summary-floor" class="text-secondary">-</strong> </p>
</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>
</div> </div>
<!-- Botones -->
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-secondary"> <a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle me-1"></i>{% trans %}Cancelar{% endtrans %} <i class="bi bi-x-circle"></i> {% trans %}Cancel{% endtrans %}
</a> </a>
<button type="submit" class="btn btn-success px-4"> {{ form.submit(class="btn btn-success") }}
<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 border-0 py-3">
<h5 class="mb-0">
<i class="bi bi-question-circle text-info"></i> {% trans %}Reservation Guidelines{% endtrans %}
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6 class="text-primary">📅 {% trans %}Booking Rules{% endtrans %}</h6>
<ul class="small">
<li>{% trans %}Reservations can be made up to 3 months in advance{% endtrans %}</li>
<li>{% trans %}Cannot book in the past{% endtrans %}</li>
<li>{% trans %}Maximum duration: 8 hours per reservation{% endtrans %}</li>
<li>{% trans %}Cancellations must be 24 hours before start time{% endtrans %}</li>
</ul>
</div>
<div class="col-md-6">
<h6 class="text-warning">🚨 {% trans %}Important Notes{% endtrans %}</h6>
<ul class="small">
<li>{% trans %}Check classroom capacity before booking{% endtrans %}</li>
<li>{% trans %}Pending reservations require approval{% endtrans %}</li>
<li>{% trans %}Time slots must not overlap{% endtrans %}</li>
<li>{% trans %}System automatically detects conflicts{% endtrans %}</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script type="application/json" id="classroom-data"> <script type="application/json" id="classroom-data">
{ {
{% for c in classroom_list %} {% for classroom in classroom_list %}
"{{ c.id }}": { "{{ classroom.id }}": {
"id": {{ c.id }}, "capacity": {{ classroom.capacity }},
"building_id": {{ (c.building_id or 0)|tojson }}, "code_display": {{ classroom.code_display|tojson }},
"building_name": {{ c.building_name|tojson }}, "name": {{ classroom.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 %} }{% if not loop.last %},{% endif %}
{% endfor %} {% endfor %}
} }
@@ -283,228 +195,57 @@
<script type="application/json" id="i18n-strings"> <script type="application/json" id="i18n-strings">
{ {
"not_selected": "{% trans %}No seleccionada{% endtrans %}", "not_selected": "{% trans %}Not selected{% endtrans %}",
"not_calculated": "{% trans %}No calculada{% endtrans %}", "not_calculated": "{% trans %}Not calculated{% endtrans %}",
"invalid_time": "{% trans %}Rango horario inválido{% endtrans %}", "invalid_time": "{% trans %}Invalid time range{% endtrans %}",
"hour": "{% trans %}hora{% endtrans %}", "hour": "{% trans %}hour{% endtrans %}",
"hours": "{% trans %}horas{% endtrans %}", "hours": "{% trans %}hours{% endtrans %}",
"minute": "{% trans %}minuto{% endtrans %}", "minute": "{% trans %}minute{% endtrans %}",
"minutes": "{% trans %}minutos{% endtrans %}", "minutes": "{% trans %}minutes{% endtrans %}",
"capacity": "{% trans %}Capacidad{% endtrans %}", "capacity": "{% trans %}Capacity{% endtrans %}",
"capacity_exceeded": "{% trans %}Los asistentes esperados superan la capacidad del aula{% endtrans %}", "capacity_exceeded": "{% trans %}Expected attendees exceeds classroom capacity{% endtrans %}"
"no_virtual_link": "{% trans %}Sin enlace configurado (Clase presencial){% endtrans %}"
} }
</script> </script>
<script> <script>
$(document).ready(function() { $(document).ready(function() {
// Classroom data for capacity checking
let classroomData = {}; let classroomData = {};
try { try {
const rawData = document.getElementById('classroom-data')?.textContent; const rawData = document.getElementById('classroom-data')?.textContent;
if (rawData) classroomData = JSON.parse(rawData); if (rawData) {
classroomData = JSON.parse(rawData);
}
} catch (err) { } catch (err) {
console.error('Error parsing classroomData:', err); console.error('Error parsing classroomData:', err);
} }
let commissionData = {}; // I18n strings
try {
const rawComm = document.getElementById('commission-data')?.textContent;
if (rawComm) commissionData = JSON.parse(rawComm);
} catch (err) {
console.error('Error parsing commissionData:', err);
}
let i18n = {}; let i18n = {};
try { try {
const rawI18n = document.getElementById('i18n-strings')?.textContent; const rawI18n = document.getElementById('i18n-strings')?.textContent;
if (rawI18n) i18n = JSON.parse(rawI18n); if (rawI18n) {
i18n = JSON.parse(rawI18n);
}
} catch (err) { } catch (err) {
console.error('Error parsing i18n:', 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.val(); const classroomId = $('#classroom-select').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 badges // Update classroom summary
if (classroomId && classroomData[classroomId]) { if (classroomId && classroomData[classroomId]) {
const c = classroomData[classroomId]; const capLabel = i18n.capacity || 'Capacidad';
$('#summary-building').text(c.building_name); $('#summary-classroom').text(`${classroomData[classroomId].code_display} - ${classroomData[classroomId].name} (${capLabel}: ${classroomData[classroomId].capacity})`);
$('#summary-floor').text(c.is_virtual ? 'Remoto (Virtual)' : c.floor_display);
$('#summary-classroom').text(`${c.code} (Cap: ${c.capacity})`);
} else { } else {
$('#summary-building').text('-');
$('#summary-floor').text('-');
$('#summary-classroom').text(i18n.not_selected || 'No seleccionada'); $('#summary-classroom').text(i18n.not_selected || 'No seleccionada');
} }
// Update commission summary and autofill virtual link if available // Update commission summary
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);
@@ -512,21 +253,6 @@ $(document).ready(function() {
$('#summary-commission').text(i18n.not_selected || 'No seleccionada'); $('#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
if (startTime && endTime) { if (startTime && endTime) {
const start = new Date(startTime); const start = new Date(startTime);
@@ -559,28 +285,20 @@ $(document).ready(function() {
// Check capacity vs expected attendees // Check capacity vs expected attendees
function checkCapacity() { function checkCapacity() {
const classroomId = $classroom.val(); const classroomId = $('#classroom-select').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 c = classroomData[classroomId]; const capacity = classroomData[classroomId].capacity;
// 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 > c.capacity_num) { if (attendees > capacity) {
$('#expected_attendees').addClass('is-invalid'); $('#expected_attendees').addClass('is-invalid');
const msg = i18n.capacity_exceeded || 'Los asistentes esperados superan la capacidad del aula'; 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">
${msg} (${attendees} > ${c.capacity_num}) ${msg} (${attendees} > ${capacity})
</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();
@@ -588,35 +306,23 @@ $(document).ready(function() {
} }
} }
$('#commission-select').on('change', function() { // Update on change
const cId = $(this).val(); $('#classroom-select, #commission-select, #start-time, #end-time, #expected_attendees').on('change', updateSummary);
if (cId && commissionData[cId]) { $('#expected_attendees').on('input', checkCapacity);
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;
if (!$classroom.val()) { // Check required fields
$classroom.addClass('is-invalid'); if (!$('#classroom-select').val()) {
$('#classroom-select').addClass('is-invalid');
isValid = false; isValid = false;
} else { } else {
$classroom.removeClass('is-invalid'); $('#classroom-select').removeClass('is-invalid');
} }
if (!$('#commission-select').val()) { if (!$('#commission-select').val()) {
@@ -640,6 +346,7 @@ $(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());
@@ -648,6 +355,7 @@ $(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;
+166 -654
View File
@@ -80,21 +80,8 @@
</div> </div>
</div> </div>
<!-- Second row of filters: Building, Floor & Hide Virtual --> <!-- Second row of filters: Floor & Hide Virtual -->
<div class="row g-2 align-items-center mt-2 pt-2 border-top"> <div class="row g-2 align-items-center mt-2 pt-2 border-top">
<!-- Filter by Building -->
<div class="col-lg-3 col-md-4">
<label for="filterBuilding" class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-buildings text-primary me-1"></i>{% trans %}Filtrar por Edificio{% endtrans %}
</label>
<select id="filterBuilding" class="form-select form-select-sm">
<option value="">{% trans %}Todos los edificios{% endtrans %}</option>
{% for b in buildings %}
<option value="{{ b.name }}">{{ b.name }}</option>
{% endfor %}
</select>
</div>
<!-- Filter by Floor --> <!-- Filter by Floor -->
<div class="col-lg-3 col-md-4"> <div class="col-lg-3 col-md-4">
<label for="filterFloor" class="form-label small fw-bold text-muted mb-1"> <label for="filterFloor" class="form-label small fw-bold text-muted mb-1">
@@ -103,25 +90,26 @@
<select id="filterFloor" class="form-select form-select-sm"> <select id="filterFloor" class="form-select form-select-sm">
<option value="">{% trans %}Todos los pisos{% endtrans %}</option> <option value="">{% trans %}Todos los pisos{% endtrans %}</option>
{% for f in available_floors %} {% for f in available_floors %}
<option value="{{ f }}">{% if f == '0' or f == 0 or f == 'PB' %}{% trans %}Planta Baja{% endtrans %}{% else %}{% trans %}Piso{% endtrans %} {{ f }}{% endif %}</option> <option value="{{ f }}">{% if f == 0 %}{% trans %}Planta Baja (Piso 0){% endtrans %}{% else %}{% trans %}Piso{% endtrans %} {{ f }}{% endif %}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<!-- Hide Virtual Classrooms Toggle --> <!-- Hide Virtual Classrooms Toggle -->
<div class="col-lg-4 col-md-4 pt-md-3"> <div class="col-lg-6 col-md-5 pt-md-3">
<div class="form-check form-switch pt-1"> <div class="form-check form-switch pt-1">
<input class="form-check-input" type="checkbox" role="switch" id="filterHideVirtual"> <input class="form-check-input" type="checkbox" role="switch" id="filterHideVirtual">
<label class="form-check-label fw-semibold small text-dark" for="filterHideVirtual"> <label class="form-check-label fw-semibold small text-dark" for="filterHideVirtual">
<i class="bi bi-laptop me-1 text-primary"></i>{% trans %}Ocultar Aulas Virtuales{% endtrans %} <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> </label>
</div> </div>
</div> </div>
<!-- Reset Filters Button --> <!-- Reset Filters Button -->
<div class="col-lg-2 col-md-12 text-md-end pt-md-3"> <div class="col-lg-3 col-md-3 text-md-end pt-md-3">
<button type="button" class="btn btn-outline-secondary btn-sm w-100 w-md-auto" id="btnResetFilters"> <button type="button" id="btnResetFilters" class="btn btn-outline-secondary btn-sm px-3" title="{% trans %}Restablecer filtros{% endtrans %}">
<i class="bi bi-x-circle me-1"></i>{% trans %}Limpiar{% endtrans %} <i class="bi bi-arrow-counterclockwise me-1"></i>{% trans %}Limpiar Filtros{% endtrans %}
</button> </button>
</div> </div>
</div> </div>
@@ -170,126 +158,68 @@
</div> </div>
</div> </div>
<!-- Modal Interactivo al Clic (Estilo Google Calendar Preview & Edición Rápida) --> <!-- Reservation Details Modal -->
<div class="modal fade" id="eventPreviewModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="reservationModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" style="max-width: 490px;"> <div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content border-0 shadow-lg gc-modal-card"> <div class="modal-content border-0 shadow-lg">
<!-- Banner Artístico Superior con Botones Rápidos (Estilo Google Calendar) --> <div class="modal-header border-0 pb-0">
<div class="gc-modal-banner" id="gcModalBanner">
<div class="d-flex justify-content-end align-items-center gap-2 p-2 gc-banner-actions">
<a id="btnPreviewEdit" href="#" class="btn btn-sm btn-icon text-white" title="{% trans %}Editar / Ver Ficha{% endtrans %}">
<i class="bi bi-pencil fs-6"></i>
</a>
{% if current_user.has_permission('reservations', 'read_write') %}
<form id="formPreviewCancel" method="POST" class="d-inline" onsubmit="return confirm('{% trans %}¿Confirma que desea cancelar esta reserva?{% endtrans %}');">
<button type="submit" class="btn btn-sm btn-icon text-white" title="{% trans %}Cancelar Reserva{% endtrans %}">
<i class="bi bi-trash fs-6"></i>
</button>
</form>
{% endif %}
<button type="button" class="btn btn-sm btn-icon text-white" data-bs-dismiss="modal" aria-label="Close">
<i class="bi bi-x-lg fs-6"></i>
</button>
</div>
</div>
<!-- Contenido de la Tarjeta Google Calendar -->
<div class="modal-body p-4 pt-3">
<!-- Fila Principal con Cuadrado de Color y Título en Grande -->
<div class="d-flex align-items-start gap-3 mb-3">
<div class="gc-color-box rounded-2 mt-1 shadow-sm" id="gcPreviewColorBox" style="width: 22px; height: 22px; background-color: #B43E8E; flex-shrink: 0;"></div>
<div class="flex-grow-1 min-w-0">
<h4 class="h5 fw-bold mb-1 text-break text-body" id="previewSubjectName">Nombre de Asignatura</h4>
<div class="d-flex align-items-center flex-wrap gap-2">
<span class="badge font-monospace bg-secondary-subtle text-secondary border" id="previewSubjectCode">ASIG001</span>
<span class="badge" id="previewStatusBadge">Confirmada</span>
<span class="badge bg-secondary-subtle text-secondary border d-none" id="previewShiftBadge">Turno Tarde</span>
</div>
</div>
</div>
<!-- Lista de Atributos con Iconografía Limpia -->
<div class="d-flex flex-column gap-3 ps-2 gc-info-list">
<!-- 🕒 Horario y Fecha -->
<div class="d-flex align-items-center gap-3">
<div class="gc-icon-circle text-muted">
<i class="bi bi-clock fs-5"></i>
</div>
<div> <div>
<div class="fw-semibold text-body" id="previewTimeRange">Lunes, 1 de septiembre · 17:30 a 21:30 hs</div> <span id="modalCategoryBadge" class="badge badge-brand mb-1">Clase Académica</span>
<small class="text-muted" id="previewDateFull">Horario regular de cursada</small> <h4 class="modal-title fw-bold text-primary" id="modalSubjectTitle">Detalle de la Clase</h4>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body pt-3" id="reservationDetails">
<div class="row g-3">
<div class="col-md-6">
<div class="p-3 rounded bg-body-tertiary h-100">
<h6 class="text-muted small text-uppercase fw-bold mb-2">
<i class="bi bi-book text-primary me-1"></i>{% trans %}Materia y Código{% endtrans %}
</h6>
<p class="h6 fw-bold mb-1" id="modalSubjectName">-</p>
<p class="mb-2">
<span class="badge bg-dark bg-opacity-75 font-monospace text-white" id="modalSubjectCode">-</span>
</p>
<p class="small text-muted mb-0" id="modalCareerWrap">
<i class="bi bi-mortarboard me-1"></i><span id="modalCareer">-</span>
</p>
</div> </div>
</div> </div>
<div class="col-md-6">
<!-- 📍 Espacio / Aula / Virtual Desglosado --> <div class="p-3 rounded bg-body-tertiary h-100">
<div class="d-flex align-items-start gap-3"> <h6 class="text-muted small text-uppercase fw-bold mb-2">
<div class="gc-icon-circle text-danger mt-1" id="previewLocationIcon"> <i class="bi bi-door-open text-success me-1"></i>{% trans %}Espacio Asignado{% endtrans %}
<i class="bi bi-geo-alt fs-5"></i> </h6>
</div> <p class="h5 fw-bold text-success mb-1" id="modalClassroom">-</p>
<div class="flex-grow-1"> <p class="small text-muted mb-0" id="modalBuilding">-</p>
<div class="d-flex flex-wrap gap-1 mb-1" id="previewLocationBadges">
<span class="badge bg-primary-subtle text-primary border" id="previewBadgeBuilding">🏢 Edificio Central</span>
<span class="badge bg-secondary-subtle text-secondary border" id="previewBadgeFloor">🪜 Piso 3</span>
<span class="badge bg-success-subtle text-success border" id="previewBadgeRoom">🚪 Aula 304</span>
</div>
<div id="previewVirtualWrapper" class="d-none">
<span class="badge bg-info-subtle text-info border">
<i class="bi bi-camera-video me-1"></i>{% trans %}Campus Virtual UniCABA (Teams / Moodle){% endtrans %}
</span>
</div>
<div id="previewVirtualLinkWrapper" class="d-none mt-2">
<a id="previewVirtualButton" href="#" target="_blank" class="btn btn-sm btn-success d-inline-flex align-items-center">
<i class="bi bi-camera-video me-1"></i>{% trans %}Unirse a Videollamada (Zoom / Meet){% endtrans %}
</a>
</div> </div>
</div> </div>
</div> <div class="col-md-6">
<div class="p-3 rounded bg-body-tertiary h-100">
<!-- 👤 Docente, Carrera y Comisión --> <h6 class="text-muted small text-uppercase fw-bold mb-2">
<div class="d-flex align-items-center gap-3"> <i class="bi bi-clock text-warning me-1"></i>{% trans %}Horario Programado{% endtrans %}
<div class="gc-icon-circle text-primary"> </h6>
<i class="bi bi-person fs-5"></i> <p class="h6 fw-bold mb-0" id="modalTimeRange">-</p>
</div>
<div>
<div class="fw-semibold text-body" id="previewCareer">Licenciatura en Sistemas</div>
<small class="text-muted">
<span id="previewCommission">Comisión 1</span>
<span class="mx-1">·</span>
<span id="previewTeacher">Docente Asignado</span>
<span class="mx-1">·</span>
<span id="previewAttendees">35 estudiantes</span>
</small>
</div> </div>
</div> </div>
<div class="col-md-6">
<!-- 📝 Hito Académico / Detalle --> <div class="p-3 rounded bg-body-tertiary h-100">
<div class="d-flex align-items-start gap-3" id="previewNotesSection"> <h6 class="text-muted small text-uppercase fw-bold mb-2">
<div class="gc-icon-circle text-warning mt-1"> <i class="bi bi-shield-check text-info me-1"></i>{% trans %}Estado y Capacidad{% endtrans %}
<i class="bi bi-flag fs-5"></i> </h6>
</div> <p class="mb-1">
<div> <span class="badge bg-success-subtle text-success border border-success-subtle px-2 py-1" id="modalStatus">-</span>
<div class="fw-semibold text-body" id="previewPurpose">Clase Regular</div> </p>
<small class="text-muted text-break" id="previewNotes">Sin observaciones adicionales</small> <small class="text-muted" id="modalAttendees">-</small>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="modal-footer border-0 pt-0">
<!-- Footer de Acciones Rápidas --> <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Close{% endtrans %}</button>
<div class="modal-footer border-0 p-3 pt-0 d-flex justify-content-between"> <a href="#" id="modalFullLink" class="btn btn-primary">
<div class="d-flex gap-2">
<a id="previewFullLink" href="#" class="btn btn-outline-primary btn-sm px-3">
<i class="bi bi-box-arrow-up-right me-1"></i>{% trans %}Ver Ficha Completa{% endtrans %} <i class="bi bi-box-arrow-up-right me-1"></i>{% trans %}Ver Ficha Completa{% endtrans %}
</a> </a>
{% if current_user.has_permission('reservations', 'read_write') or (current_user.role and current_user.role|upper in ['ADMIN', 'BEDELIA']) %}
<a id="previewFooterEditLink" href="#" class="btn btn-warning btn-sm px-3">
<i class="bi bi-pencil me-1"></i>{% trans %}Modificar Reserva{% endtrans %}
</a>
{% endif %}
</div>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">
{% trans %}Cerrar{% endtrans %}
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -300,19 +230,16 @@
{% block extra_css %} {% block extra_css %}
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@5.11.0/main.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/fullcalendar@5.11.0/main.min.css" rel="stylesheet">
<style> <style>
/* ========================================================================== /* FullCalendar Modern Overrides */
Google Calendar Aesthetic & FullCalendar Overrides (Light & Dark)
========================================================================== */
#calendar { #calendar {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-family: inherit;
min-height: 750px; min-height: 700px;
} }
.fc .fc-toolbar-title { .fc .fc-toolbar-title {
font-size: 1.35rem !important; font-size: 1.25rem !important;
font-weight: 700 !important; font-weight: 700 !important;
color: var(--text-primary); color: var(--text-primary);
letter-spacing: -0.2px;
} }
.fc .fc-button-primary { .fc .fc-button-primary {
@@ -335,366 +262,116 @@
} }
.fc-theme-standard td, .fc-theme-standard th { .fc-theme-standard td, .fc-theme-standard th {
border-color: rgba(0, 0, 0, 0.08) !important; border-color: var(--card-border, #E2E8F0) !important;
}
[data-bs-theme="dark"] .fc-theme-standard td,
[data-bs-theme="dark"] .fc-theme-standard th {
border-color: rgba(255, 255, 255, 0.08) !important;
}
/* Column Headers (Google Calendar Style) */
.fc .fc-col-header-cell {
padding: 6px 0 !important;
background: transparent !important;
} }
.fc .fc-col-header-cell-cushion { .fc .fc-col-header-cell-cushion {
color: var(--bs-body-color, #3c4043); color: var(--text-primary);
font-weight: 600; font-weight: 600;
font-size: 0.82rem; padding: 8px 4px !important;
text-decoration: none !important; text-decoration: none !important;
letter-spacing: 0.3px;
}
.fc-day-today .fc-col-header-cell-cushion {
color: #B43E8E !important;
font-weight: 700 !important;
}
/* Slot Lines & Label Height */
.fc .fc-timegrid-slot {
height: 28px !important;
} }
.fc .fc-timegrid-slot-label-cushion { .fc .fc-timegrid-slot-label-cushion {
color: var(--text-secondary, #64748B); color: var(--text-secondary, #64748B);
font-size: 0.72rem; font-size: 0.78rem;
font-weight: 500;
} }
/* Red Now Indicator Line */ /* Event Card Visual Hierarchy */
.fc .fc-timegrid-now-indicator-line { .fc-event {
border-color: #ea4335 !important;
border-width: 2px !important;
z-index: 12 !important;
}
.fc .fc-timegrid-now-indicator-arrow {
border-color: #ea4335 !important;
border-top-color: transparent !important;
border-bottom-color: transparent !important;
}
/* Base Event Wrapper Overrides */
.fc-timegrid-event-harness {
margin: 0 !important;
}
.fc-timegrid-event, .fc-daygrid-event {
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 0 !important;
}
.fc-event-main {
padding: 0 !important;
height: 100% !important;
display: flex !important;
flex-direction: column !important;
}
/* -------------------------------------------------------------
Google Calendar Event Pill (.gc-event-pill)
------------------------------------------------------------- */
.gc-event-pill {
position: relative;
display: flex;
height: 100%;
width: 100%;
min-height: 26px;
border-radius: 6px;
background: rgba(180, 62, 142, 0.12);
border: 1px solid rgba(180, 62, 142, 0.28);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
overflow: hidden;
cursor: pointer; cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease, filter 0.12s ease; border: none !important;
border-radius: 8px !important;
overflow: hidden !important;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12) !important;
transition: transform 0.15s ease, box-shadow 0.15s ease !important;
} }
.gc-event-pill:hover { .fc-event:hover {
transform: translateY(-1px) scale(1.01); transform: translateY(-2px) scale(1.01);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.18); box-shadow: 0 6px 16px rgba(0, 0, 0, 0.22) !important;
z-index: 20 !important; z-index: 10 !important;
filter: brightness(0.97);
} }
[data-bs-theme="dark"] .gc-event-pill { .fc-event-confirmed {
background: rgba(180, 62, 142, 0.22); background: linear-gradient(135deg, #B43E8E 0%, #832765 100%) !important;
border-color: rgba(180, 62, 142, 0.45); color: #FFFFFF !important;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
} }
[data-bs-theme="dark"] .gc-event-pill:hover { .fc-event-pending {
filter: brightness(1.2); background: linear-gradient(135deg, #F59E0B 0%, #D97706 100%) !important;
color: #FFFFFF !important;
} }
/* Left Accent Stripe */ .fc-event-custom-container {
.gc-event-stripe { padding: 5px 7px;
width: 4px;
flex-shrink: 0;
height: 100%; height: 100%;
border-top-left-radius: 5px;
border-bottom-left-radius: 5px;
}
/* Event Content Container */
.gc-event-content {
flex: 1;
min-width: 0;
padding: 2px 5px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
overflow: hidden;
} }
/* Event Header: Time + Room Badge */ .fc-event-time-pill {
.gc-event-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 3px;
line-height: 1.1;
margin-bottom: 2px;
}
.gc-event-time {
font-size: 0.68rem;
font-weight: 700;
color: #7d2662;
white-space: nowrap;
}
[data-bs-theme="dark"] .gc-event-time {
color: #ffb1d9;
}
.gc-event-room {
font-size: 0.64rem !important;
font-weight: 600;
padding: 1px 4px !important;
border-radius: 4px;
background-color: rgba(180, 62, 142, 0.20) !important;
color: #63174c !important;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 70%;
line-height: 1.2;
}
[data-bs-theme="dark"] .gc-event-room {
background-color: rgba(255, 255, 255, 0.16) !important;
color: #ffffff !important;
}
.gc-event-room.gc-badge-virtual {
background-color: rgba(99, 102, 241, 0.20) !important;
color: #4338ca !important;
}
[data-bs-theme="dark"] .gc-event-room.gc-badge-virtual {
background-color: rgba(99, 102, 241, 0.35) !important;
color: #c7d2fe !important;
}
/* Subject Title (Compact with Ellipsis) */
.gc-event-title {
font-size: 0.78rem;
font-weight: 600;
line-height: 1.18;
color: #202124;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: -0.1px;
}
[data-bs-theme="dark"] .gc-event-title {
color: #f8f9fa;
}
/* Status Variants */
.gc-event-pill.status-pending {
background: rgba(245, 158, 11, 0.12);
border-color: rgba(245, 158, 11, 0.32);
}
.gc-event-pill.status-pending .gc-event-time {
color: #b45309;
}
[data-bs-theme="dark"] .gc-event-pill.status-pending {
background: rgba(245, 158, 11, 0.22);
}
[data-bs-theme="dark"] .gc-event-pill.status-pending .gc-event-time {
color: #fcd34d;
}
.gc-event-pill.status-virtual {
background: rgba(99, 102, 241, 0.12);
border-color: rgba(99, 102, 241, 0.30);
}
[data-bs-theme="dark"] .gc-event-pill.status-virtual {
background: rgba(99, 102, 241, 0.22);
}
/* -------------------------------------------------------------
Month View Pill (.gc-month-pill)
------------------------------------------------------------- */
.gc-month-pill {
display: flex;
align-items: center;
gap: 4px;
padding: 1px 6px;
margin: 1px 2px;
border-radius: 4px;
background: rgba(180, 62, 142, 0.12);
color: #202124;
font-size: 0.72rem; font-size: 0.72rem;
font-weight: 600; font-weight: 500;
white-space: nowrap; color: rgba(255, 255, 255, 0.85);
overflow: hidden; margin-bottom: 2px;
text-overflow: ellipsis; display: flex;
cursor: pointer; align-items: center;
border-left: 3px solid var(--event-color, #B43E8E);
transition: filter 0.12s ease;
} }
.gc-month-pill:hover { /* High Visual Hierarchy for Subject Name */
filter: brightness(0.95); .fc-event-subject-title {
} font-size: 0.88rem !important;
[data-bs-theme="dark"] .gc-month-pill {
background: rgba(180, 62, 142, 0.25);
color: #f1f3f4;
}
.gc-month-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.gc-month-time {
font-size: 0.68rem;
font-weight: 700;
color: #7d2662;
flex-shrink: 0;
}
[data-bs-theme="dark"] .gc-month-time {
color: #ffb1d9;
}
.gc-month-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.gc-month-room {
font-size: 0.64rem;
color: #5f6368;
flex-shrink: 0;
}
[data-bs-theme="dark"] .gc-month-room {
color: #9aa0a6;
}
/* FullCalendar "+X más" Popover estilo Google Calendar */
.fc-popover {
border-radius: 12px !important;
border: none !important;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.22) !important;
background: var(--bs-body-bg, #ffffff) !important;
z-index: 1055 !important;
}
.fc-popover-header {
background: var(--bs-tertiary-bg, #f8f9fa) !important;
padding: 8px 12px !important;
border-top-left-radius: 12px !important;
border-top-right-radius: 12px !important;
font-weight: 700 !important; font-weight: 700 !important;
font-size: 0.85rem !important; line-height: 1.22 !important;
color: #FFFFFF !important;
letter-spacing: -0.15px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
margin-bottom: 4px;
} }
.fc-more-link { /* Secondary Badges */
font-size: 0.72rem !important; .fc-event-badges {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 3px;
margin-top: auto;
}
.fc-badge-code {
background-color: rgba(0, 0, 0, 0.3) !important;
color: #FFFFFF !important;
font-size: 0.70rem !important;
font-weight: 600 !important; font-weight: 600 !important;
color: #B43E8E !important; padding: 2px 5px !important;
text-decoration: none !important; border-radius: 4px !important;
padding: 1px 4px !important;
border-radius: 3px !important;
} }
.fc-more-link:hover { .fc-badge-room {
background-color: rgba(180, 62, 142, 0.12) !important; background-color: rgba(255, 255, 255, 0.28) !important;
} color: #FFFFFF !important;
font-size: 0.70rem !important;
/* ------------------------------------------------------------- font-weight: 700 !important;
Google Calendar Modal Card (.gc-modal-card) padding: 2px 5px !important;
------------------------------------------------------------- */ border-radius: 4px !important;
.gc-modal-card {
border-radius: 16px !important;
overflow: hidden !important;
}
.gc-modal-banner {
height: 80px;
background: linear-gradient(135deg, #B43E8E 0%, #832765 60%, #4338CA 100%);
position: relative;
background-size: cover;
}
.gc-modal-banner.banner-virtual {
background: linear-gradient(135deg, #4338CA 0%, #2563EB 60%, #0284C7 100%);
}
.gc-modal-banner.banner-pending {
background: linear-gradient(135deg, #D97706 0%, #F59E0B 100%);
}
.gc-banner-actions .btn-icon {
width: 32px;
height: 32px;
border-radius: 50%;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.28);
border: none;
transition: background 0.15s ease, transform 0.15s ease;
} }
.gc-banner-actions .btn-icon:hover { /* Month View Optimization */
background: rgba(0, 0, 0, 0.50); .fc-daygrid-event .fc-event-custom-container {
transform: scale(1.05); padding: 2px 4px;
} }
.fc-daygrid-event .fc-event-subject-title {
.gc-icon-circle { font-size: 0.78rem !important;
width: 34px; -webkit-line-clamp: 1;
height: 34px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--bs-tertiary-bg, #f1f3f4);
flex-shrink: 0;
} }
</style> </style>
{% endblock %} {% endblock %}
@@ -712,15 +389,11 @@ document.addEventListener('DOMContentLoaded', function() {
var filterSubj = document.getElementById('filterSubject'); var filterSubj = document.getElementById('filterSubject');
var filterCar = document.getElementById('filterCareer'); var filterCar = document.getElementById('filterCareer');
var filterShift = document.getElementById('filterShift'); var filterShift = document.getElementById('filterShift');
var filterBuilding = document.getElementById('filterBuilding');
var filterFloor = document.getElementById('filterFloor'); var filterFloor = document.getElementById('filterFloor');
var filterHideVirtual = document.getElementById('filterHideVirtual'); var filterHideVirtual = document.getElementById('filterHideVirtual');
var activeBadge = document.getElementById('activeFilterBadge'); var activeBadge = document.getElementById('activeFilterBadge');
var countEl = document.getElementById('eventCountNumber'); var countEl = document.getElementById('eventCountNumber');
var days = ['Domingo', 'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado'];
var months = ['enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', 'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre'];
function updateFilterSummaryBadge() { function updateFilterSummaryBadge() {
var parts = []; var parts = [];
if (filterRoom.value && filterRoom.selectedOptions[0]) { if (filterRoom.value && filterRoom.selectedOptions[0]) {
@@ -732,9 +405,6 @@ document.addEventListener('DOMContentLoaded', function() {
if (filterCar.value && filterCar.selectedOptions[0]) { if (filterCar.value && filterCar.selectedOptions[0]) {
parts.push('Carrera: ' + filterCar.selectedOptions[0].text.trim()); parts.push('Carrera: ' + filterCar.selectedOptions[0].text.trim());
} }
if (filterBuilding && filterBuilding.value) {
parts.push('Edificio: ' + filterBuilding.value);
}
if (filterShift && filterShift.value) { if (filterShift && filterShift.value) {
parts.push('Turno ' + filterShift.value); parts.push('Turno ' + filterShift.value);
} }
@@ -769,21 +439,10 @@ document.addEventListener('DOMContentLoaded', function() {
minute: '2-digit', minute: '2-digit',
hour12: false hour12: false
}, },
slotMinTime: '07:00:00', slotMinTime: '07:30:00',
slotMaxTime: '23:00:00', slotMaxTime: '22:30:00',
slotDuration: '00:30:00',
slotLabelInterval: '01:00:00',
nowIndicator: true,
height: 'auto', height: 'auto',
/* Manejo de Eventos Simultáneos estilo Google Calendar */
dayMaxEvents: 3,
dayMaxEventRows: true,
eventMaxStack: 3,
moreLinkClick: 'popover',
slotEventOverlap: true, slotEventOverlap: true,
expandRows: true,
stickyHeaderDates: true,
/* Dynamic Server-Side Filtered Events */ /* Dynamic Server-Side Filtered Events */
events: function(info, successCallback, failureCallback) { events: function(info, successCallback, failureCallback) {
@@ -794,7 +453,6 @@ document.addEventListener('DOMContentLoaded', function() {
subject_id: filterSubj.value || '', subject_id: filterSubj.value || '',
career_id: filterCar.value || '', career_id: filterCar.value || '',
shift: (filterShift ? filterShift.value : '') || '', shift: (filterShift ? filterShift.value : '') || '',
building: (filterBuilding ? filterBuilding.value : '') || '',
floor: (filterFloor ? filterFloor.value : '') || '', floor: (filterFloor ? filterFloor.value : '') || '',
hide_virtual: (filterHideVirtual && filterHideVirtual.checked) ? '1' : '' hide_virtual: (filterHideVirtual && filterHideVirtual.checked) ? '1' : ''
}); });
@@ -818,203 +476,64 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
}, },
/* Diseño de Tarjetas de Evento Estilo Google Calendar */ /* Custom Card Render with Visual Hierarchy */
eventContent: function(arg) { eventContent: function(arg) {
var ext = arg.event.extendedProps || {}; var ext = arg.event.extendedProps || {};
var subjectName = ext.subject_name || arg.event.title || ''; var subjectName = ext.subject_name || arg.event.title || '';
var subjectCode = ext.subject_code || ''; var subjectCode = ext.subject_code || '';
var classroom = ext.classroom || ''; var classroom = ext.classroom || '';
var isVirtual = ext.is_virtual || false;
var timeText = arg.timeText || ''; var timeText = arg.timeText || '';
var status = (ext.status || 'CONFIRMED').toUpperCase();
var isMonthView = arg.view.type === 'dayGridMonth';
var eventColor = '#B43E8E'; var container = document.createElement('div');
if (status === 'PENDING') { container.className = 'fc-event-custom-container';
eventColor = '#F59E0B';
} else if (isVirtual) {
eventColor = '#6366F1';
}
// Vista de Mes: Píldora Google Calendar Compacta var timeHtml = timeText ? `<div class="fc-event-time-pill"><i class="bi bi-clock me-1"></i>${timeText}</div>` : '';
if (isMonthView) { var titleHtml = `<div class="fc-event-subject-title" title="${subjectName}">${subjectName}</div>`;
var monthPill = document.createElement('div'); var badgesHtml = `
monthPill.className = 'gc-month-pill ' + (isVirtual ? 'gc-month-virtual' : ''); <div class="fc-event-badges">
monthPill.style.setProperty('--event-color', eventColor); ${subjectCode ? `<span class="fc-badge-code font-monospace">${subjectCode}</span>` : ''}
monthPill.title = subjectName + (classroom ? ' · ' + classroom : ''); ${classroom ? `<span class="fc-badge-room"><i class="bi bi-door-open me-1"></i>${classroom}</span>` : ''}
var startHour = timeText ? timeText.split(' - ')[0] : '';
monthPill.innerHTML = `
<span class="gc-month-dot" style="background-color: ${eventColor};"></span>
${startHour ? `<span class="gc-month-time">${startHour}</span>` : ''}
<span class="gc-month-title">${subjectName}</span>
<span class="gc-month-room">${isVirtual ? '<i class="bi bi-camera-video"></i>' : classroom}</span>
`;
return { domNodes: [monthPill] };
}
// Vista Semanal / Diaria: Tarjeta Píldora con Franja Lateral y Acento
var pill = document.createElement('div');
pill.className = 'gc-event-pill ' + (status === 'PENDING' ? 'status-pending' : '') + (isVirtual ? ' status-virtual' : '');
pill.style.setProperty('--event-color', eventColor);
pill.title = subjectName + (classroom ? ' - ' + classroom : '');
var stripe = `<div class="gc-event-stripe" style="background-color: ${eventColor};"></div>`;
var startHourOnly = timeText ? timeText.split(' - ')[0] : '';
var roomBadge = classroom ? `
<span class="gc-event-room badge ${isVirtual ? 'gc-badge-virtual' : ''}">
<i class="bi ${isVirtual ? 'bi-camera-video' : 'bi-door-open'} me-1"></i>${classroom}
</span>` : '';
var header = `
<div class="gc-event-header">
<span class="gc-event-time">${startHourOnly}</span>
${roomBadge}
</div>`; </div>`;
var title = `<div class="gc-event-title" title="${subjectName}">${subjectName}</div>`;
pill.innerHTML = stripe + `<div class="gc-event-content">${header}${title}</div>`; container.innerHTML = timeHtml + titleHtml + badgesHtml;
return { domNodes: [pill] }; return { domNodes: [container] };
}, },
/* Intercepción de Clic: Popover / Modal Flotante de Vista Previa Google Calendar */ /* Interactive Instant Modal */
eventClick: function(info) { eventClick: function(info) {
info.jsEvent.preventDefault(); info.jsEvent.preventDefault();
var ext = info.event.extendedProps || {}; var ext = info.event.extendedProps || {};
var subjectName = ext.subject_name || info.event.title || 'Asignatura'; var subjectName = ext.subject_name || info.event.title || '';
var subjectCode = ext.subject_code || '-'; var subjectCode = ext.subject_code || '-';
var classroom = ext.classroom || 'Sin Aula Asignada'; var classroom = ext.classroom || 'Sin Aula';
var building = ext.building || 'Edificio Central'; var building = ext.building || 'Edificio Central';
var career = ext.career || 'Tecnicatura / Licenciatura UniCABA'; var career = ext.career || 'General';
var status = (ext.status || 'CONFIRMED').toUpperCase(); var status = ext.status || 'CONFIRMED';
var isVirtual = ext.is_virtual || false;
var teacher = ext.teacher || 'Docente Responsable';
var commission = ext.commission ? `Comisión ${ext.commission}` : 'Comisión Regular';
var attendees = ext.attendees || 0; var attendees = ext.attendees || 0;
var purpose = ext.purpose || 'Clase Regular';
var notes = ext.notes || 'Sin observaciones registradas';
var shift = ext.shift || '';
var eventColor = '#B43E8E'; var startStr = info.event.start ? info.event.start.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit', hour12: false}) : '';
var bannerClass = 'gc-modal-banner'; var endStr = info.event.end ? info.event.end.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit', hour12: false}) : '';
if (status === 'PENDING') { var dateStr = info.event.start ? info.event.start.toLocaleDateString([], {weekday: 'long', day: 'numeric', month: 'long'}) : '';
eventColor = '#F59E0B';
bannerClass += ' banner-pending';
} else if (isVirtual) {
eventColor = '#6366F1';
bannerClass += ' banner-virtual';
}
// Actualizar Banner y Color Box document.getElementById('modalSubjectTitle').textContent = subjectName;
var bannerEl = document.getElementById('gcModalBanner'); document.getElementById('modalSubjectName').textContent = subjectName;
if (bannerEl) bannerEl.className = bannerClass; document.getElementById('modalSubjectCode').textContent = subjectCode;
document.getElementById('modalCareer').textContent = career;
document.getElementById('modalClassroom').textContent = classroom;
document.getElementById('modalBuilding').textContent = building;
document.getElementById('modalTimeRange').textContent = `${dateStr} (${startStr} a ${endStr} hs)`;
document.getElementById('modalStatus').textContent = status;
document.getElementById('modalAttendees').textContent = `${attendees} estudiantes esperados`;
var colorBox = document.getElementById('gcPreviewColorBox'); if (info.event.url) {
if (colorBox) colorBox.style.backgroundColor = eventColor; var btn = document.getElementById('modalFullLink');
btn.href = info.event.url;
// Textos Principales btn.classList.remove('d-none');
document.getElementById('previewSubjectName').textContent = subjectName;
document.getElementById('previewSubjectCode').textContent = subjectCode;
// Badges
var statusBadge = document.getElementById('previewStatusBadge');
if (statusBadge) {
if (status === 'CONFIRMED') {
statusBadge.className = 'badge bg-success-subtle text-success border border-success-subtle';
statusBadge.textContent = 'Confirmada';
} else if (status === 'PENDING') {
statusBadge.className = 'badge bg-warning-subtle text-warning border border-warning-subtle';
statusBadge.textContent = 'Pendiente';
} else { } else {
statusBadge.className = 'badge bg-secondary-subtle text-secondary border'; document.getElementById('modalFullLink').classList.add('d-none');
statusBadge.textContent = status;
}
} }
var shiftBadge = document.getElementById('previewShiftBadge'); var modal = new bootstrap.Modal(document.getElementById('reservationModal'));
if (shiftBadge) {
if (shift) {
shiftBadge.textContent = 'Turno ' + shift;
shiftBadge.classList.remove('d-none');
} else {
shiftBadge.classList.add('d-none');
}
}
// Horario Formateado
if (info.event.start) {
var start = info.event.start;
var end = info.event.end;
var dayName = days[start.getDay()];
var dayNum = start.getDate();
var monthName = months[start.getMonth()];
var startTime = start.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit', hour12: false});
var endTime = end ? end.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit', hour12: false}) : '';
document.getElementById('previewTimeRange').textContent = `${dayName}, ${dayNum} de ${monthName} · ${startTime}${endTime ? ' a ' + endTime + ' hs' : ''}`;
document.getElementById('previewDateFull').textContent = `Horario académico registrado`;
}
// Espacio Asignado Desglosado con Badges
var locIcon = document.getElementById('previewLocationIcon');
var virtualWrap = document.getElementById('previewVirtualWrapper');
var badgeB = document.getElementById('previewBadgeBuilding');
var badgeF = document.getElementById('previewBadgeFloor');
var badgeR = document.getElementById('previewBadgeRoom');
if (isVirtual) {
if (locIcon) locIcon.innerHTML = '<i class="bi bi-camera-video fs-5 text-primary"></i>';
if (badgeB) badgeB.textContent = '🏢 Campus Virtual';
if (badgeF) badgeF.textContent = '🪜 Remoto';
if (badgeR) badgeR.textContent = '🚪 ' + classroom;
if (virtualWrap) virtualWrap.classList.remove('d-none');
} else {
if (locIcon) locIcon.innerHTML = '<i class="bi bi-geo-alt fs-5 text-danger"></i>';
if (badgeB) badgeB.textContent = '🏢 ' + (building || 'Edificio Central');
if (badgeF) badgeF.textContent = '🪜 ' + (ext.floor || 'Planta Baja');
if (badgeR) badgeR.textContent = '🚪 ' + classroom;
if (virtualWrap) virtualWrap.classList.add('d-none');
}
// Enlace de Aula Virtual / Videollamada
var vLink = ext.virtual_link;
var vBtnWrap = document.getElementById('previewVirtualLinkWrapper');
var vBtn = document.getElementById('previewVirtualButton');
if (vLink && vBtnWrap && vBtn) {
vBtn.href = vLink;
vBtnWrap.classList.remove('d-none');
} else if (vBtnWrap) {
vBtnWrap.classList.add('d-none');
}
// Carrera, Comisión, Docente y Asistentes
document.getElementById('previewCareer').textContent = career;
document.getElementById('previewCommission').textContent = commission;
document.getElementById('previewTeacher').textContent = teacher;
document.getElementById('previewAttendees').textContent = `${attendees} estudiantes esperados`;
// Hito / Notas
document.getElementById('previewPurpose').textContent = purpose;
document.getElementById('previewNotes').textContent = notes;
// Enlaces y Acciones
var detailUrl = info.event.url || ('/schedule/reservation/' + info.event.id + '/view');
var editUrl = '/schedule/reservation/' + info.event.id + '/edit';
var fullLink = document.getElementById('previewFullLink');
if (fullLink) fullLink.href = detailUrl;
var editBtn = document.getElementById('btnPreviewEdit');
if (editBtn) editBtn.href = editUrl;
var footerEditBtn = document.getElementById('previewFooterEditLink');
if (footerEditBtn) footerEditBtn.href = editUrl;
var cancelForm = document.getElementById('formPreviewCancel');
if (cancelForm) {
cancelForm.action = ext.cancel_url || ('/schedule/cancel/' + info.event.id);
}
// Mostrar Modal Google Calendar
var modal = new bootstrap.Modal(document.getElementById('eventPreviewModal'));
modal.show(); modal.show();
} }
}); });
@@ -1040,12 +559,6 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
} }
if (filterBuilding) {
filterBuilding.addEventListener('change', function() {
calendar.refetchEvents();
});
}
if (filterFloor) { if (filterFloor) {
filterFloor.addEventListener('change', function() { filterFloor.addEventListener('change', function() {
calendar.refetchEvents(); calendar.refetchEvents();
@@ -1063,7 +576,6 @@ document.addEventListener('DOMContentLoaded', function() {
filterSubj.value = ''; filterSubj.value = '';
filterCar.value = ''; filterCar.value = '';
if (filterShift) filterShift.value = ''; if (filterShift) filterShift.value = '';
if (filterBuilding) filterBuilding.value = '';
if (filterFloor) filterFloor.value = ''; if (filterFloor) filterFloor.value = '';
if (filterHideVirtual) filterHideVirtual.checked = false; if (filterHideVirtual) filterHideVirtual.checked = false;
calendar.refetchEvents(); calendar.refetchEvents();
File diff suppressed because it is too large Load Diff
-482
View File
@@ -1,482 +0,0 @@
{% 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 %}
+6 -39
View File
@@ -67,21 +67,8 @@
</select> </select>
</div> </div>
<!-- Aula Filter -->
<div class="col-lg-3 col-md-4">
<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>
<!-- Shift Filter --> <!-- Shift Filter -->
<div class="col-lg-2 col-md-3"> <div class="col-lg-2 col-md-4">
<label class="form-label small fw-bold text-muted mb-1"> <label class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-brightness-high text-warning me-1"></i>{% trans %}Turno{% endtrans %} <i class="bi bi-brightness-high text-warning me-1"></i>{% trans %}Turno{% endtrans %}
</label> </label>
@@ -92,22 +79,14 @@
</select> </select>
</div> </div>
<!-- Date Range From --> <!-- Date Range -->
<div class="col-lg-2 col-md-3"> <div class="col-lg-3 col-md-4">
<label class="form-label small fw-bold text-muted mb-1"> <label class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-calendar-event me-1"></i>{% trans %}Desde Fecha{% endtrans %} <i class="bi bi-calendar-event me-1"></i>{% trans %}Desde Fecha{% endtrans %}
</label> </label>
<input type="date" name="date_from" class="form-control form-control-sm" value="{{ date_from or '' }}"> <input type="date" name="date_from" class="form-control form-control-sm" value="{{ date_from or '' }}">
</div> </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 --> <!-- Row 2: Hide Virtual, Submit, Clear -->
<div class="col-lg-6 col-md-6 pt-2"> <div class="col-lg-6 col-md-6 pt-2">
<div class="form-check form-switch pt-1"> <div class="form-check form-switch pt-1">
@@ -200,18 +179,6 @@
{% endif %} {% endif %}
</td> </td>
<td class="text-end pe-3"> <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>
{% 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) }}" <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 %}"> class="btn btn-sm btn-outline-secondary py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
@@ -237,7 +204,7 @@
<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, floor=floor_filter, shift=shift_filter, classroom_id=classroom_id or '', hide_virtual='1' if hide_virtual else '', search=search) }}"> <a class="page-link" href="{{ url_for('schedule.list_reservations', page=reservations.prev_num, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, hide_virtual='1' if hide_virtual else '', search=search) }}">
<i class="bi bi-chevron-left"></i> <i class="bi bi-chevron-left"></i>
</a> </a>
</li> </li>
@@ -249,7 +216,7 @@
<li class="page-item active"><span class="page-link">{{ p }}</span></li> <li class="page-item active"><span class="page-link">{{ p }}</span></li>
{% else %} {% else %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=p, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, classroom_id=classroom_id or '', hide_virtual='1' if hide_virtual else '', search=search) }}">{{ p }}</a> <a class="page-link" href="{{ url_for('schedule.list_reservations', page=p, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, hide_virtual='1' if hide_virtual else '', search=search) }}">{{ p }}</a>
</li> </li>
{% endif %} {% endif %}
{% else %} {% else %}
@@ -259,7 +226,7 @@
{% 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, floor=floor_filter, shift=shift_filter, classroom_id=classroom_id or '', hide_virtual='1' if hide_virtual else '', search=search) }}"> <a class="page-link" href="{{ url_for('schedule.list_reservations', page=reservations.next_num, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, hide_virtual='1' if hide_virtual else '', search=search) }}">
<i class="bi bi-chevron-right"></i> <i class="bi bi-chevron-right"></i>
</a> </a>
</li> </li>
+1 -25
View File
@@ -214,25 +214,13 @@
<span class="small text-muted"> <span class="small text-muted">
<i class="bi bi-people me-1"></i>{{ item.reservation.expected_attendees }} {% trans %}estudiantes{% endtrans %} <i class="bi bi-people me-1"></i>{{ item.reservation.expected_attendees }} {% trans %}estudiantes{% endtrans %}
</span> </span>
<div class="d-flex gap-1"> <a href="{{ url_for('schedule.view_reservation', id=item.reservation.id) }}" class="btn btn-sm btn-outline-secondary py-0 px-2">
{% if item.reservation.effective_virtual_link %}
<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 %}">
<i class="bi bi-camera-video me-1"></i>Zoom/Meet
</a>
{% 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 %} <i class="bi bi-eye me-1"></i>{% trans %}Detalles{% endtrans %}
</a> </a>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
@@ -298,18 +286,6 @@
{% endif %} {% endif %}
</td> </td>
<td class="text-end pe-3"> <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>
{% 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) }}" <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 %}"> class="btn btn-sm btn-outline-secondary py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
+22 -76
View File
@@ -10,14 +10,9 @@
<h1 class="h3"> <h1 class="h3">
<i class="bi bi-calendar-check text-info"></i> Reservation Details <i class="bi bi-calendar-check text-info"></i> Reservation Details
</h1> </h1>
<div class="d-flex gap-2"> <div>
{% if 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 me-1"></i>{% trans %}Back to List{% endtrans %} <i class="bi bi-arrow-left"></i> Back to List
</a> </a>
</div> </div>
</div> </div>
@@ -91,70 +86,35 @@
<!-- Classroom Details --> <!-- Classroom Details -->
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-header border-0 py-3 bg-body-tertiary"> <div class="card-header border-0 py-3">
<h5 class="mb-0 h6 fw-bold"> <h5 class="mb-0">
<i class="bi bi-geo-alt text-primary me-2"></i>{% trans %}Ubicación y Espacio Asignado{% endtrans %} <i class="bi bi-building text-info"></i> Classroom Information
</h5> </h5>
</div> </div>
<div class="card-body p-4"> <div class="card-body">
{% if reservation.classroom %} {% if reservation.classroom %}
<div class="row g-3 mb-3"> <div class="row">
<div class="col-md-4"> <div class="col-md-4 mb-3">
<div class="p-3 rounded bg-body-tertiary border"> <label class="text-muted small">Classroom</label>
<small class="text-muted d-block mb-1">🏢 {% trans %}Edificio / Sede{% endtrans %}</small> <p class="mb-0"><strong>{{ reservation.classroom.code_display }}</strong></p>
<h6 class="mb-0 fw-bold text-primary">{{ reservation.classroom.building_name }}</h6>
</div> </div>
<div class="col-md-4 mb-3">
<label class="text-muted small">Building</label>
<p class="mb-0">{{ reservation.classroom.building }}</p>
</div> </div>
<div class="col-md-4"> <div class="col-md-4 mb-3">
<div class="p-3 rounded bg-body-tertiary border"> <label class="text-muted small">Capacity</label>
<small class="text-muted d-block mb-1">🪜 {% trans %}Piso / Nivel{% endtrans %}</small> <p class="mb-0">{{ reservation.classroom.capacity }} people</p>
<h6 class="mb-0 fw-bold text-secondary">
{% if reservation.classroom.is_virtual %}
<span class="badge bg-primary-subtle text-primary border">Campus Virtual</span>
{% else %}
{{ reservation.classroom.floor_display }}
{% endif %}
</h6>
</div> </div>
</div>
<div class="col-md-4">
<div class="p-3 rounded bg-body-tertiary border">
<small class="text-muted d-block mb-1">🚪 {% 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="mt-2"> <div class="col-12">
<label class="text-muted small">{% trans %}Descripción del Aula{% endtrans %}</label> <label class="text-muted small">Description</label>
<p class="mb-0 small">{{ reservation.classroom.description }}</p> <p class="mb-0">{{ reservation.classroom.description }}</p>
</div> </div>
{% endif %} {% endif %}
<!-- Banner de Aula Virtual (Zoom, Meet, Teams) -->
{% if reservation.effective_virtual_link or (reservation.classroom and reservation.classroom.is_virtual) %}
<div class="mt-3 p-3 rounded bg-primary bg-opacity-10 border border-primary border-opacity-25 d-flex flex-column flex-sm-row justify-content-between align-items-sm-center gap-3">
<div>
<div class="fw-bold text-primary mb-1">
<i class="bi bi-camera-video me-1"></i>{% 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 %}
<small class="text-muted fst-italic">{% trans %}Modalidad remota sin enlace específico registrado{% endtrans %}</small> <p class="text-muted mb-0">Classroom information not available</p>
{% 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>
@@ -199,23 +159,15 @@
<h3 class="mb-1">{{ reservation.expected_attendees }}</h3> <h3 class="mb-1">{{ reservation.expected_attendees }}</h3>
<p class="text-muted small">Expected Attendees</p> <p class="text-muted small">Expected Attendees</p>
{% if reservation.classroom %} {% if reservation.classroom %}
{% if reservation.classroom.capacity and reservation.classroom.capacity > 0 %}
<div class="progress mt-3" style="height: 8px;"> <div class="progress mt-3" style="height: 8px;">
{% set percentage = (reservation.expected_attendees / reservation.classroom.capacity * 100) %} {% set percentage = (reservation.expected_attendees / reservation.classroom.capacity * 100) %}
<div class="progress-bar bg-{% if percentage > 90 %}danger{% elif percentage > 75 %}warning{% else %}success{% endif %}" <div class="progress-bar bg-{% if percentage > 90 %}danger{% elif percentage > 75 %}warning{% else %}success{% endif %}"
role="progressbar" role="progressbar"
style="{{ 'width: ' ~ ([percentage|round(1), 100]|min|string) ~ '%;' }}"></div> style="{{ 'width: ' ~ (percentage|round(1)|string) ~ '%;' }}"></div>
</div> </div>
<small class="text-muted"> <small class="text-muted">
{{ reservation.expected_attendees }} / {{ reservation.classroom.capacity }} {% trans %}capacidad{% endtrans %} {{ reservation.expected_attendees }} / {{ reservation.classroom.capacity }} capacity
</small> </small>
{% else %}
<div class="mt-2">
<span class="badge bg-primary-subtle text-primary border">
{% if reservation.classroom.is_virtual %}🌐 Campus Virtual (Sin límite físico){% else %}{% trans %}Capacidad no definida{% endtrans %}{% endif %}
</span>
</div>
{% endif %}
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -230,12 +182,6 @@
</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?')">
-23
View File
@@ -39,26 +39,3 @@ def admin_required(f):
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated_function 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
-102
View File
@@ -1,102 +0,0 @@
#!/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
+1 -7
View File
@@ -1,16 +1,10 @@
import os import os
from dotenv import load_dotenv
basedir = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) 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'
@@ -16,10 +16,19 @@
<ion-list lines="inset" class="compact-list"> <ion-list lines="inset" class="compact-list">
<ion-item class="input-item"> <ion-item class="input-item">
<ion-label position="stacked">Tipo e Hito</ion-label> <ion-label position="stacked">Tipo e Hito</ion-label>
<ion-select [(ngModel)]="tipo" interface="popover" placeholder="Seleccionar tipo de hito"> <ion-select [(ngModel)]="tipo" interface="popover">
<ion-select-option *ngFor="let opt of milestoneTypes" [value]="opt.name"> <ion-select-option value="Parcial 1">Parcial 1</ion-select-option>
{{ opt.name }} <ion-select-option value="Parcial 2">Parcial 2</ion-select-option>
</ion-select-option> <ion-select-option value="TP 1">Trabajo Práctico 1</ion-select-option>
<ion-select-option value="TP 2">Trabajo Práctico 2</ion-select-option>
<ion-select-option value="TP 3">Trabajo Práctico 3</ion-select-option>
<ion-select-option value="Recu. P1">Recuperatorio Parcial 1</ion-select-option>
<ion-select-option value="Recu. P2">Recuperatorio Parcial 2</ion-select-option>
<ion-select-option value="Recu. TP 1">Recuperatorio TP 1</ion-select-option>
<ion-select-option value="Recu. TP 2">Recuperatorio TP 2</ion-select-option>
<ion-select-option value="Recu. TP 3">Recuperatorio TP 3</ion-select-option>
<ion-select-option value="Final">Final</ion-select-option>
<ion-select-option value="Otro">Otro</ion-select-option>
</ion-select> </ion-select>
</ion-item> </ion-item>
@@ -7,36 +7,6 @@ import { EventosService } from '../../services/eventos.service';
import { CapacitorCalendar } from '@ebarooni/capacitor-calendar'; import { CapacitorCalendar } from '@ebarooni/capacitor-calendar';
@Component({
selector: 'app-evento-modal',
templateUrl: './evento-modal.component.html',
styleUrls: ['./evento-modal.component.scss'],
standalone: true,
imports: [CommonModule, IonicModule, FormsModule]
})
export interface MilestoneTypeOption {
id?: number;
name: string;
code?: string;
color?: string;
is_active?: boolean;
}
export const DEFAULT_MILESTONE_TYPES: MilestoneTypeOption[] = [
{ name: 'Parcial 1', code: 'parcial_1', color: '#0d6efd' },
{ name: 'Parcial 2', code: 'parcial_2', color: '#0b5ed7' },
{ name: 'Trabajo Práctico 1', code: 'tp_1', color: '#198754' },
{ name: 'Trabajo Práctico 2', code: 'tp_2', color: '#157347' },
{ name: 'Trabajo Práctico 3', code: 'tp_3', color: '#0f5132' },
{ name: 'Recuperatorio Parcial 1', code: 'recu_p1', color: '#fd7e14' },
{ name: 'Recuperatorio Parcial 2', code: 'recu_p2', color: '#e76b08' },
{ name: 'Recuperatorio TP 1', code: 'recu_tp1', color: '#ffc107' },
{ name: 'Recuperatorio TP 2', code: 'recu_tp2', color: '#e0a800' },
{ name: 'Recuperatorio TP 3', code: 'recu_tp3', color: '#c69500' },
{ name: 'Final', code: 'final', color: '#dc3545' },
{ name: 'Otro', code: 'otro', color: '#6c757d' }
];
@Component({ @Component({
selector: 'app-evento-modal', selector: 'app-evento-modal',
templateUrl: './evento-modal.component.html', templateUrl: './evento-modal.component.html',
@@ -49,8 +19,7 @@ export class EventoModalComponent implements OnInit {
@Input() materiaNombre!: string; @Input() materiaNombre!: string;
@Input() eventoExistente?: EventoAcademico; @Input() eventoExistente?: EventoAcademico;
tipo: string = 'Parcial 1'; tipo: 'Parcial' | 'TP' | 'Final' | 'Otro' = 'Parcial';
milestoneTypes: MilestoneTypeOption[] = DEFAULT_MILESTONE_TYPES;
fecha: string = new Date().toISOString(); fecha: string = new Date().toISOString();
aula: string = ''; aula: string = '';
link: string = ''; link: string = '';
@@ -65,9 +34,7 @@ export class EventoModalComponent implements OnInit {
private toastCtrl: ToastController private toastCtrl: ToastController
) { } ) { }
async ngOnInit() { ngOnInit() {
await this.cargarTipificaciones();
if (this.eventoExistente) { if (this.eventoExistente) {
this.isEdit = true; this.isEdit = true;
this.tipo = this.eventoExistente.tipo; this.tipo = this.eventoExistente.tipo;
@@ -78,20 +45,6 @@ export class EventoModalComponent implements OnInit {
} }
} }
async cargarTipificaciones() {
try {
const response = await fetch('/api/milestone-types');
if (response.ok) {
const data: MilestoneTypeOption[] = await response.json();
if (Array.isArray(data) && data.length > 0) {
this.milestoneTypes = data.filter(d => d.is_active !== false);
}
}
} catch {
// Mantiene catálogo predeterminado si no hay conexión al backend
}
}
dismiss() { dismiss() {
this.modalCtrl.dismiss(); this.modalCtrl.dismiss();
} }
@@ -18,7 +18,7 @@ export interface ClaseHorario {
export interface EventoAcademico { export interface EventoAcademico {
id: string; id: string;
materiaCodigo: string; materiaCodigo: string;
tipo: string; // Dinámico según catálogo MilestoneType tipo: 'Parcial' | 'TP' | 'Final' | 'Otro';
fecha: string; // ISO string fecha: string; // ISO string
aula?: string; aula?: string;
link?: string; link?: string;
+13 -44
View File
@@ -66,7 +66,7 @@ def init_database():
default_roles = [ default_roles = [
{ {
'name': 'Admin', 'name': 'Admin',
'description': 'Control total institucional, infraestructura, gestion academica y accesos RBAC', 'description': 'Control total institucional, infraestructura, gestión académica y accesos RBAC',
'is_system': True, 'is_system': True,
'permissions': { 'permissions': {
'classrooms': 'read_write', 'classrooms': 'read_write',
@@ -76,13 +76,12 @@ def init_database():
'academic': 'read_write', 'academic': 'read_write',
'import': 'read_write', 'import': 'read_write',
'optimizer': 'read_write', 'optimizer': 'read_write',
'users': 'read_write', 'users': 'read_write'
'milestone_types': 'read_write'
} }
}, },
{ {
'name': 'Docente', 'name': 'Docente',
'description': 'Gestion de reservas de cursada, consulta de cronograma y materias', 'description': 'Gestión de reservas de cursada, consulta de cronograma y materias',
'is_system': False, 'is_system': False,
'permissions': { 'permissions': {
'classrooms': 'read', 'classrooms': 'read',
@@ -92,13 +91,12 @@ def init_database():
'academic': 'read', 'academic': 'read',
'import': 'none', 'import': 'none',
'optimizer': 'read', 'optimizer': 'read',
'users': 'none', 'users': 'none'
'milestone_types': 'read'
} }
}, },
{ {
'name': 'Bedelia', 'name': 'Operador',
'description': 'Gestion operativa de aulas, sedes, reservas, comisiones, importacion y cartelera', 'description': 'Gestión operativa de aulas, sedes, reservas, importación y cartelera',
'is_system': False, 'is_system': False,
'permissions': { 'permissions': {
'classrooms': 'read_write', 'classrooms': 'read_write',
@@ -108,12 +106,11 @@ def init_database():
'academic': 'read_write', 'academic': 'read_write',
'import': 'read_write', 'import': 'read_write',
'optimizer': 'none', 'optimizer': 'none',
'users': 'none', 'users': 'none'
'milestone_types': 'read_write'
} }
}, },
{ {
'name': 'Alumno', 'name': 'Consulta',
'description': 'Acceso de solo lectura al cronograma general, asignaturas y aulas', 'description': 'Acceso de solo lectura al cronograma general, asignaturas y aulas',
'is_system': False, 'is_system': False,
'permissions': { 'permissions': {
@@ -124,8 +121,7 @@ def init_database():
'academic': 'read', 'academic': 'read',
'import': 'none', 'import': 'none',
'optimizer': 'none', 'optimizer': 'none',
'users': 'none', 'users': 'none'
'milestone_types': 'read'
} }
} }
] ]
@@ -189,7 +185,7 @@ def init_database():
if not admin.theme_preference: if not admin.theme_preference:
admin.theme_preference = "auto" admin.theme_preference = "auto"
db.session.commit() db.session.commit()
print(f"[INFO] El usuario admin (admin@edu-space.com) esta activo y vinculado al rol '{admin_role.name if admin_role else 'ADMIN'}'.") print(f"[INFO] El usuario admin (admin@edu-space.com) está activo y vinculado al rol '{admin_role.name if admin_role else 'ADMIN'}'.")
# 5. Inicializar edificios institucionales oficiales # 5. Inicializar edificios institucionales oficiales
print("[*] Verificando edificios institucionales...") print("[*] Verificando edificios institucionales...")
@@ -199,13 +195,13 @@ def init_database():
b_central = Building( b_central = Building(
name='Edificio Central', name='Edificio Central',
code='CENTRAL', code='CENTRAL',
address='Tte. Gral. Juan Domingo Peron 802, CABA', address='Tte. Gral. Juan Domingo Perón 802, CABA',
floors=4, floors=4,
description='Sede central institucional de UniCABA. Aulas de grado, auditorio y laboratorios.' description='Sede central institucional de UniCABA. Aulas de grado, auditorio y laboratorios.'
) )
db.session.add(b_central) db.session.add(b_central)
else: else:
b_central.address = 'Tte. Gral. Juan Domingo Peron 802, CABA' b_central.address = 'Tte. Gral. Juan Domingo Perón 802, CABA'
b_virtual = Building.query.filter_by(name='Campus Virtual').first() b_virtual = Building.query.filter_by(name='Campus Virtual').first()
if not b_virtual: if not b_virtual:
@@ -220,39 +216,12 @@ def init_database():
db.session.commit() db.session.commit()
print("[OK] Edificios institucionales configurados con dirección oficial.") print("[OK] Edificios institucionales configurados con dirección oficial.")
# 6. Inicializar catalogo dinamico de tipificaciones de hitos academicos
print("[*] Verificando catalogo de tipificaciones de hitos academicos...")
from app.models.milestone import MilestoneType, BASE_MILESTONE_TYPES
for item in BASE_MILESTONE_TYPES:
mt = MilestoneType.query.filter_by(code=item['code']).first()
if not mt:
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)
else:
if not mt.color:
mt.color = item['color']
if not mt.sort_order:
mt.sort_order = item['sort_order']
db.session.commit()
print(f"[OK] Catalogo de tipificaciones inicializado ({MilestoneType.query.count()} tipos disponibles).")
print("\n[LISTO] Base de datos inicializada y lista para usar.") print("\n[LISTO] Base de datos inicializada y lista para usar.")
except Exception as e: except Exception as e:
# Safe print to avoid UnicodeEncodeError in exception strings
try:
print(f"\n[ERROR] Error al inicializar la base de datos: {str(e)}") print(f"\n[ERROR] Error al inicializar la base de datos: {str(e)}")
except UnicodeEncodeError:
print("\n[ERROR] Error al inicializar la base de datos (se omiten caracteres no-ascii).")
print("\nVerifica que:") print("\nVerifica que:")
print("1. El servicio de PostgreSQL este corriendo.") print("1. El servicio de PostgreSQL esté corriendo.")
print("2. La base de datos 'classrooms_db' haya sido creada.") print("2. La base de datos 'classrooms_db' haya sido creada.")
print("3. La variable DATABASE_URL en el archivo .env tenga el usuario y contraseña correctos.") print("3. La variable DATABASE_URL en el archivo .env tenga el usuario y contraseña correctos.")
sys.exit(1) sys.exit(1)
-474
View File
@@ -1,474 +0,0 @@
#!/usr/bin/env bash
# ==============================================================================
# install.sh — Admin Edu-Space (UniCABA)
# Instalador automatizado para Proxmox VE (LXC) / Debian 12 / Ubuntu (entorno venv)
# Uso: sudo bash install.sh
# ==============================================================================
set -euo pipefail
# ─── Colores y Helpers ─────────────────────────────────────────────────────────
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"; }
# ─── Variables Principales ─────────────────────────────────────────────────────
APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VENV_DIR="${APP_DIR}/venv"
APP_ENV="${APP_DIR}/.env"
SERVICE_NAME="admin-edu-space"
APP_USER="eduspace"
PYTHON_REQUIRED_MAJOR=3
PYTHON_REQUIRED_MINOR=10
LOG_DIR="/var/log/${SERVICE_NAME}"
SQL_BACKUP_FILE="${APP_DIR}/classrooms_db.sql"
# ─── Verificación de Permisos Root ─────────────────────────────────────────────
[[ $EUID -ne 0 ]] && err "Ejecutá este script con permisos de root: sudo bash install.sh"
# Si el directorio está dentro de /root, lo trasladamos a /opt para evitar problemas de permisos con el usuario de servicio
if [[ "${APP_DIR}" == /root/* ]]; then
NEW_DIR="/opt/admin-edu-space"
warn "Detectado directorio en ${APP_DIR}. Trasladando a ${NEW_DIR} para evitar bloqueos de permisos..."
systemctl stop "${SERVICE_NAME}" 2>/dev/null || true
# Limpiar cualquier subdirectorio anidado previo si quedó de un intento fallido
rm -rf "${NEW_DIR}/admin-edu-space" 2>/dev/null || true
mkdir -p "${NEW_DIR}"
# Copiar recursivamente todo el contenido (incluyendo dotfiles) directamente a NEW_DIR
cp -a "${APP_DIR}/." "${NEW_DIR}/"
cd "${NEW_DIR}"
exec bash install.sh
fi
# Limpiar posible subdirectorio anidado residual en caso de ejecuciones previas
rm -rf "${APP_DIR}/admin-edu-space" 2>/dev/null || true
# Verificar e instalar git si no está presente en el sistema
if ! command -v git &>/dev/null; then
info "Git no está instalado. Instalando paquete git..."
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq >/dev/null 2>&1 || true
apt-get install -y -qq git >/dev/null 2>&1 || apt-get install -y git
ok "Git instalado correctamente"
fi
# Configurar safe.directory en Git para evitar errores de propiedad cruzada (root vs eduspace)
git config --global --add safe.directory '*' 2>/dev/null || true
[[ ! -f "${APP_DIR}/wsgi.py" ]] && err "No se encontró wsgi.py en ${APP_DIR}. ¿Estás en el directorio raíz del proyecto?"
echo -e "\n${BOLD}${CYAN}"
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ Admin Edu-Space (UniCABA) — Proxmox LXC Installer ║"
echo "║ Despliegue Nativo con venv + Motor PostgreSQL ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo -e "${RESET}"
OS_NAME=$(grep -oP '(?<=^PRETTY_NAME=).+' /etc/os-release 2>/dev/null | tr -d '"' || uname -s)
info "Sistema Operativo: ${OS_NAME}"
info "Directorio del proyecto: ${APP_DIR}"
info "Servicio systemd: ${SERVICE_NAME}"
info "Usuario de servicio: ${APP_USER}"
# ─── 1. Actualizar sistema e instalar dependencias base ────────────────────────
hdr "1. Actualizando repositorios y paquetes base"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq \
sudo curl wget git ca-certificates gnupg lsb-release \
build-essential libpq-dev \
locales \
> /dev/null 2>&1
ok "Paquetes base instalados"
# Función auxiliar para ejecutar comandos psql como superusuario postgres de forma universal (sin depender de sudo)
pg_cmd() {
if command -v sudo >/dev/null 2>&1; then
sudo -u postgres psql "$@"
elif command -v runuser >/dev/null 2>&1; then
runuser -u postgres -- psql "$@"
else
su - postgres -c "psql $(printf '%q ' "$@")"
fi
}
# ─── 2. Configurar Locale UTF-8 ────────────────────────────────────────────────
hdr "2. Configurando soporte de codificación (Locales UTF-8)"
if [ -f /etc/locale.gen ]; then
sed -i 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen
sed -i 's/# es_AR.UTF-8 UTF-8/es_AR.UTF-8 UTF-8/' /etc/locale.gen
locale-gen en_US.UTF-8 es_AR.UTF-8 > /dev/null 2>&1 || true
fi
update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 > /dev/null 2>&1 || true
export LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 PYTHONUTF8=1 PYTHONIOENCODING=utf-8
ok "Locales configurados: en_US.UTF-8 / es_AR.UTF-8"
# ─── 3. Instalar Python 3.10+ y entorno de desarrollo ──────────────────────────
hdr "3. Verificando entorno Python"
apt-get install -y -qq python3 python3-venv python3-pip python3-dev > /dev/null 2>&1
PY_VERSION=$(python3 --version 2>/dev/null | awk '{print $2}' || echo "0.0")
PY_MAJOR=$(echo "$PY_VERSION" | cut -d. -f1)
PY_MINOR=$(echo "$PY_VERSION" | cut -d. -f2)
if [[ "$PY_MAJOR" -ge "$PYTHON_REQUIRED_MAJOR" && "$PY_MINOR" -ge "$PYTHON_REQUIRED_MINOR" ]]; then
ok "Python $PY_VERSION disponible"
else
info "Python $PY_VERSION es menor a 3.10 — instalando Python 3.11..."
apt-get install -y -qq python3.11 python3.11-venv python3.11-dev > /dev/null 2>&1
update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.11 1 > /dev/null 2>&1
PY_VERSION=$(python3 --version | awk '{print $2}')
ok "Python $PY_VERSION instalado"
fi
# ─── 4. Instalar y Levantar Motor PostgreSQL ───────────────────────────────────
hdr "4. Instalando y configurando motor de base de datos PostgreSQL"
apt-get install -y -qq postgresql postgresql-contrib postgresql-client > /dev/null 2>&1
PG_VER=$(psql --version 2>/dev/null | awk '{print $3}' || echo "instalado")
PG_MAJOR=$(psql --version 2>/dev/null | awk '{print $3}' | cut -d. -f1 || echo "")
[[ -z "$PG_MAJOR" ]] && PG_MAJOR=$(ls /etc/postgresql/ 2>/dev/null | sort -V | tail -n1 || echo "")
[[ -z "$PG_MAJOR" ]] && PG_MAJOR="15"
ok "Motor PostgreSQL instalado: ${PG_VER}"
# Asegurar directorios de socket y permisos para PostgreSQL en contenedores LXC
mkdir -p /run/postgresql /var/run/postgresql /var/lib/postgresql /var/log/postgresql
chown -R postgres:postgres /run/postgresql /var/run/postgresql /var/lib/postgresql /var/log/postgresql
chmod 2775 /run/postgresql /var/run/postgresql
# Verificar si existe el cluster 'main'. Si no existe (común en Debian si falló la creación automática), crearlo
if ! pg_lsclusters 2>/dev/null | grep -q 'main'; then
info "Cluster PostgreSQL no detectado — inicializando cluster ${PG_MAJOR} main..."
pg_createcluster "${PG_MAJOR}" main --locale en_US.UTF-8 --start >/dev/null 2>&1 \
|| pg_createcluster "${PG_MAJOR}" main --locale C.UTF-8 --start >/dev/null 2>&1 \
|| true
fi
# Levantar servicio de PostgreSQL asegurando compatibilidad con contenedores LXC y VMs
info "Iniciando servicio de PostgreSQL..."
pg_ctlcluster "${PG_MAJOR}" main start >/dev/null 2>&1 || true
systemctl start "postgresql@${PG_MAJOR}-main" 2>/dev/null || true
systemctl start postgresql 2>/dev/null || true
service postgresql start 2>/dev/null || true
# Esperar a que el socket de PostgreSQL esté completamente activo
PG_READY=false
for i in {1..20}; do
if pg_cmd -c '\q' >/dev/null 2>&1; then
PG_READY=true
break
fi
if [[ $i -eq 3 || $i -eq 8 ]]; then
pg_ctlcluster "${PG_MAJOR}" main start 2>/dev/null || true
systemctl restart "postgresql@${PG_MAJOR}-main" 2>/dev/null || true
fi
sleep 1
done
if [ "$PG_READY" = true ]; then
# Configurar pg_hba.conf para permitir conexiones locales (socket y 127.0.0.1) sin fallos de autenticación
PG_HBA=$(find /etc/postgresql/ -name pg_hba.conf 2>/dev/null | sort -V | tail -n1)
if [[ -n "$PG_HBA" && -f "$PG_HBA" ]]; then
sed -i -E 's/^(local\s+all\s+all\s+)peer/\1trust/' "$PG_HBA" 2>/dev/null || true
sed -i -E 's/^(host\s+all\s+all\s+127\.0\.0\.1\/32\s+)(scram-sha-256|md5)/\1trust/' "$PG_HBA" 2>/dev/null || true
sed -i -E 's/^(host\s+all\s+all\s+::1\/128\s+)(scram-sha-256|md5)/\1trust/' "$PG_HBA" 2>/dev/null || true
systemctl reload "postgresql@${PG_MAJOR}-main" 2>/dev/null || systemctl reload postgresql 2>/dev/null || true
fi
systemctl enable "postgresql@${PG_MAJOR}-main" > /dev/null 2>&1 || true
systemctl enable postgresql > /dev/null 2>&1 || true
ok "PostgreSQL activo y aceptando conexiones"
else
warn "Diagnóstico de arranque de PostgreSQL en LXC:"
pg_lsclusters 2>/dev/null || true
systemctl status "postgresql@${PG_MAJOR}-main" --no-pager -n 10 2>/dev/null || true
journalctl -u postgresql -n 15 --no-pager 2>/dev/null || true
info "Prueba directa de conexión psql:"
pg_cmd -c '\q' || true
err "No se pudo conectar al motor PostgreSQL. Verificá los registros del sistema."
fi
# ─── 5. Configurar Variables de Entorno (.env) ─────────────────────────────────
hdr "5. Configurando variables de entorno (.env)"
# Valores predeterminados (paridad con sistema activo)
DB_NAME="classrooms_db"
DB_USER="postgres"
DB_PASSWORD="password"
if [[ -f "$APP_ENV" ]]; then
ok ".env ya existe — se preserva la configuración actual"
set -a
# shellcheck disable=SC1090
source <(grep -v '^#' "$APP_ENV" | grep '=')
set +a
# Extraer credenciales desde DATABASE_URL si está presente
if [[ -n "${DATABASE_URL:-}" ]]; then
URL_USER=$(echo "$DATABASE_URL" | sed -E 's|^.*://([^:@]+).*|\1|')
URL_PASS=$(echo "$DATABASE_URL" | sed -E 's|^.*://[^:]+:([^@]+)@.*|\1|')
URL_NAME=$(echo "$DATABASE_URL" | sed -E 's|^.*/([^?]+).*|\1|')
if [[ "$URL_USER" != "$DATABASE_URL" && -n "$URL_USER" ]]; then
DB_USER="$URL_USER"
fi
if [[ "$URL_PASS" != "$DATABASE_URL" && -n "$URL_PASS" ]]; then
DB_PASSWORD="$URL_PASS"
fi
if [[ "$URL_NAME" != "$DATABASE_URL" && -n "$URL_NAME" ]]; then
DB_NAME="$URL_NAME"
fi
fi
DB_USER="${DB_USER:-${DB_USERNAME:-postgres}}"
DB_PASSWORD="${DB_PASSWORD:-password}"
DB_NAME="${DB_NAME:-classrooms_db}"
else
SECRET_KEY=$(python3 -c 'import secrets; print(secrets.token_hex(32))')
cat > "$APP_ENV" <<ENVEOF
# Admin Edu-Space — Configuración de producción
# Generado automáticamente el $(date '+%Y-%m-%d %H:%M:%S')
FLASK_APP=wsgi.py
FLASK_ENV=production
SECRET_KEY=${SECRET_KEY}
DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@localhost:5432/${DB_NAME}
DB_USERNAME=${DB_USER}
DB_PASSWORD=${DB_PASSWORD}
API_BASE_URL=http://localhost:8080/api
BABEL_DEFAULT_LOCALE=es
BABEL_DEFAULT_TIMEZONE=America/Argentina/Buenos_Aires
ENVEOF
chmod 640 "$APP_ENV"
ok ".env generado automáticamente con configuración de producción"
fi
# ─── 6. Configurar Usuario y Base de Datos en PostgreSQL ───────────────────────
hdr "6. Configurando motor PostgreSQL y credenciales de base de datos"
# Configurar usuarios en PostgreSQL con privilegios completos
for u in "${DB_USER}" "${DB_USERNAME:-}" eduspace_user; do
if [[ -n "$u" && "$u" != "postgres" ]]; then
pg_cmd -c "CREATE USER \"${u}\" WITH PASSWORD '${DB_PASSWORD}';" >/dev/null 2>&1 \
|| pg_cmd -c "ALTER USER \"${u}\" WITH PASSWORD '${DB_PASSWORD}';" >/dev/null 2>&1 \
|| true
pg_cmd -c "ALTER USER \"${u}\" WITH SUPERUSER CREATEDB;" >/dev/null 2>&1 || true
fi
done
pg_cmd -c "ALTER USER postgres WITH PASSWORD '${DB_PASSWORD}';" >/dev/null 2>&1 || true
pg_cmd -c "ALTER USER postgres WITH SUPERUSER CREATEDB;" >/dev/null 2>&1 || true
# Crear base de datos
pg_cmd -c "CREATE DATABASE \"${DB_NAME}\" OWNER postgres;" >/dev/null 2>&1 \
|| info "La base de datos '${DB_NAME}' ya existía."
for u in "${DB_USER}" "${DB_USERNAME:-}" postgres eduspace_user; do
if [[ -n "$u" ]]; then
pg_cmd -c "GRANT ALL PRIVILEGES ON DATABASE \"${DB_NAME}\" TO \"${u}\";" >/dev/null 2>&1 || true
pg_cmd -d "${DB_NAME}" -c "GRANT ALL ON SCHEMA public TO \"${u}\";" >/dev/null 2>&1 || true
fi
done
ok "Motor PostgreSQL configurado (usuario: ${DB_USER}, base: ${DB_NAME})"
# ─── 7. Crear Entorno Virtual e Instalar Dependencias ──────────────────────────
hdr "7. Creando virtualenv e instalando dependencias Python"
if [[ -d "$VENV_DIR" ]]; then
info "Recreando entorno virtual para asegurar dependencias limpias..."
rm -rf "$VENV_DIR"
fi
python3 -m venv "$VENV_DIR" > /dev/null 2>&1
"${VENV_DIR}/bin/pip" install --upgrade pip setuptools wheel --quiet
info "Instalando paquetes desde requirements.txt..."
if "${VENV_DIR}/bin/pip" install -r "${APP_DIR}/requirements.txt" --quiet 2>/dev/null; then
ok "Dependencias instaladas correctamente"
else
warn "Reintentando instalación con --no-build-isolation (compatibilidad Python 3.12/3.13)..."
"${VENV_DIR}/bin/pip" install -r "${APP_DIR}/requirements.txt" --no-build-isolation --quiet
ok "Dependencias instaladas"
fi
# ─── 8. Despliegue de Datos: Restauración o Inicialización ─────────────────────
hdr "8. Desplegando esquema y datos de la aplicación"
cd "$APP_DIR"
if [[ -f "$SQL_BACKUP_FILE" ]]; then
BACKUP_SIZE=$(du -h "$SQL_BACKUP_FILE" | awk '{print $1}')
info "Se detectó snapshot de base de datos '${SQL_BACKUP_FILE}' (${BACKUP_SIZE})."
info "Restaurando volcado integral de aulas, comisiones, materias, reservas y usuarios..."
# Recrear base de datos limpia para carga sin colisiones
pg_cmd -c "DROP DATABASE IF EXISTS \"${DB_NAME}\";" > /dev/null 2>&1 || true
pg_cmd -c "CREATE DATABASE \"${DB_NAME}\" OWNER postgres;" > /dev/null 2>&1
# Restaurar backup filtrando directivas específicas de PG18 si existiesen
if sed '/^\\restrict/d' "$SQL_BACKUP_FILE" | pg_cmd -d "${DB_NAME}" > /dev/null 2>&1; then
ok "Volcado SQL restaurado exitosamente."
else
warn "Restauración completada con avisos menores — aplicando permisos..."
fi
# Otorgar propiedad y permisos absolutos sobre todas las tablas y secuencias al usuario de la app
info "Garantizando permisos y secuencias para ${DB_USER}..."
for u in "${DB_USER}" "${DB_USERNAME:-}" postgres eduspace_user; do
if [[ -n "$u" ]]; then
pg_cmd -d "${DB_NAME}" -c "GRANT ALL PRIVILEGES ON DATABASE \"${DB_NAME}\" TO \"${u}\";" > /dev/null 2>&1 || true
pg_cmd -d "${DB_NAME}" -c "GRANT ALL ON SCHEMA public TO \"${u}\";" > /dev/null 2>&1 || true
pg_cmd -d "${DB_NAME}" -c "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO \"${u}\";" > /dev/null 2>&1 || true
pg_cmd -d "${DB_NAME}" -c "GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO \"${u}\";" > /dev/null 2>&1 || true
pg_cmd -d "${DB_NAME}" -c "GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO \"${u}\";" > /dev/null 2>&1 || true
pg_cmd -d "${DB_NAME}" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO \"${u}\";" > /dev/null 2>&1 || true
pg_cmd -d "${DB_NAME}" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO \"${u}\";" > /dev/null 2>&1 || true
pg_cmd -d "${DB_NAME}" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON FUNCTIONS TO \"${u}\";" > /dev/null 2>&1 || true
fi
done
if [[ "${DB_USER}" != "postgres" ]]; then
pg_cmd -d "${DB_NAME}" -c "ALTER SCHEMA public OWNER TO \"${DB_USER}\";" > /dev/null 2>&1 || true
fi
# Ejecutar init_db.py de forma idempotente para verificar roles y migraciones incrementales
info "Validando coherencia de modelos con init_db.py..."
LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 PYTHONUTF8=1 PYTHONIOENCODING=utf-8 \
FLASK_APP=wsgi.py "${VENV_DIR}/bin/python" -X utf8 init_db.py
ok "Base de datos sincronizada y verificada"
else
info "No se encontró classrooms_db.sql. Creando esquema desde cero con init_db.py..."
LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 PYTHONUTF8=1 PYTHONIOENCODING=utf-8 \
FLASK_APP=wsgi.py "${VENV_DIR}/bin/python" -X utf8 init_db.py
ok "Esquema, roles RBAC, edificios y usuario admin creados"
fi
cd - > /dev/null
# ─── 9. Usuario del Sistema y Permisos de Directorio ───────────────────────────
hdr "9. Configurando permisos y usuario de ejecución del sistema"
if id "$APP_USER" &>/dev/null; then
info "El usuario de sistema '${APP_USER}' ya existe."
else
useradd --system --no-create-home --shell /usr/sbin/nologin "$APP_USER"
ok "Usuario de sistema '${APP_USER}' creado (sin shell)"
fi
mkdir -p "${APP_DIR}/app/static/uploads"
chown -R "${APP_USER}:${APP_USER}" "$APP_DIR"
chmod -R 755 "$APP_DIR"
chmod -R 775 "${APP_DIR}/app/static/uploads"
chmod 640 "$APP_ENV"
mkdir -p "$LOG_DIR"
chown -R "${APP_USER}:${APP_USER}" "$LOG_DIR"
chmod 755 "$LOG_DIR"
ok "Permisos de seguridad aplicados en ${APP_DIR} y ${LOG_DIR}"
# ─── 10. Servicio Systemd (Gunicorn de Producción) ─────────────────────────────
hdr "10. Configurando servicio systemd de arranque automático"
GUNICORN_BIN="${VENV_DIR}/bin/gunicorn"
cat > "/etc/systemd/system/${SERVICE_NAME}.service" <<SVCEOF
[Unit]
Description=Admin Edu-Space — Flask/Gunicorn (UniCABA)
Documentation=https://gitea.oemspot.com.ar/carlostellocba/admin-edu-space
After=network.target postgresql.service
Wants=postgresql.service
[Service]
Type=simple
User=${APP_USER}
Group=${APP_USER}
WorkingDirectory=${APP_DIR}
EnvironmentFile=${APP_ENV}
Environment="LANG=en_US.UTF-8"
Environment="LC_ALL=en_US.UTF-8"
Environment="PYTHONUTF8=1"
Environment="PYTHONIOENCODING=utf-8"
Environment="FLASK_ENV=production"
Environment="HOME=${APP_DIR}"
ExecStart=${GUNICORN_BIN} \
--workers 3 \
--bind 0.0.0.0:5000 \
--timeout 120 \
--access-logfile ${LOG_DIR}/access.log \
--error-logfile - \
--capture-output \
--enable-stdio-inheritance \
--log-level info \
wsgi:app
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=${SERVICE_NAME}
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
SVCEOF
systemctl daemon-reload
systemctl enable "${SERVICE_NAME}" > /dev/null 2>&1
systemctl restart "${SERVICE_NAME}"
sleep 3
if systemctl is-active --quiet "$SERVICE_NAME"; then
ok "Servicio '${SERVICE_NAME}' activo y en ejecución"
# Validar respuesta HTTP local
info "Verificando respuesta del servidor web..."
sleep 2
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:5000/ || echo "000")
if [[ "$HTTP_CODE" == "200" || "$HTTP_CODE" == "302" ]]; then
ok "Servidor web respondiendo correctamente (HTTP ${HTTP_CODE})"
else
warn "El servidor web devolvió código HTTP ${HTTP_CODE}. Podés revisar los logs con: journalctl -u ${SERVICE_NAME} -n 30 --no-pager"
fi
else
warn "El servicio tardó en arrancar. Podés revisar los logs con:"
warn " journalctl -u ${SERVICE_NAME} -n 50 --no-pager"
fi
# ─── Resumen Final y Datos de Acceso ──────────────────────────────────────────
hdr "¡Despliegue Completado con Éxito!"
IP=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "127.0.0.1")
echo -e " ${BOLD}Acceso Web a la Aplicación:${RESET}"
echo -e " ${CYAN}http://${IP}:5000${RESET} o ${CYAN}http://localhost:5000${RESET}"
echo ""
echo -e " ${BOLD}Credenciales Iniciales de Administrador:${RESET}"
echo -e " Email: ${CYAN}admin@edu-space.com${RESET}"
echo -e " Password: ${CYAN}admin123${RESET}"
echo ""
echo -e " ${BOLD}Base de Datos Desplegada:${RESET}"
echo -e " Motor: PostgreSQL (${PG_VER})"
echo -e " Base: ${DB_NAME}"
echo -e " Usuario: ${DB_USER}"
echo -e " Snapshot: $( [ -f "$SQL_BACKUP_FILE" ] && echo "Restaurada desde classrooms_db.sql" || echo "Inicializada limpia" )"
echo ""
echo -e " ${BOLD}Comandos de Gestión del Servicio:${RESET}"
echo -e " Estado: ${CYAN}systemctl status ${SERVICE_NAME}${RESET}"
echo -e " Logs: ${CYAN}journalctl -u ${SERVICE_NAME} -f${RESET}"
echo -e " Reiniciar: ${CYAN}systemctl restart ${SERVICE_NAME}${RESET}"
echo -e " Detener: ${CYAN}systemctl stop ${SERVICE_NAME}${RESET}"
echo ""
echo -e " ${BOLD}Generar Nuevos Backups:${RESET}"
echo -e " ${CYAN}bash backup_db.sh${RESET}"
echo ""
ok "Despliegue finalizado. El sistema se encuentra 100% operativo."
echo ""
+3 -4
View File
@@ -7,13 +7,12 @@ Flask-CORS==6.0.2
Flask-Babel==4.0.0 Flask-Babel==4.0.0
WTForms==3.1.0 WTForms==3.1.0
Werkzeug==3.0.1 Werkzeug==3.0.1
SQLAlchemy>=2.0.32 SQLAlchemy==2.0.23
python-dotenv==1.0.0 python-dotenv==1.0.0
requests==2.31.0 requests==2.31.0
bleach==6.1.0 bleach==6.1.0
python-dateutil==2.8.2 python-dateutil==2.8.2
Pillow>=10.4.0 Pillow==10.1.0
email_validator>=2.0.0 email_validator>=2.0.0
psycopg2-binary>=2.9.10 psycopg2-binary==2.9.9
Babel==2.14.0 Babel==2.14.0
gunicorn>=21.2.0