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
FLASK_APP=wsgi.py
FLASK_APP=app.py
FLASK_ENV=production
# Seguridad: Clave secreta para sesiones y protección CSRF
-3
View File
@@ -57,9 +57,6 @@ cover/
*.mo
*.pot
# Examples
example/
# Django stuff:
*.log
local_settings.py
+1 -200
View File
@@ -1,6 +1,6 @@
# 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)
**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)):**
* 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
* **Sede Central UniCABA:** Tte. Gral. Juan Domingo Perón 802, Ciudad Autónoma de Buenos Aires (CABA) — Aulas de grado, auditorio principal y laboratorios distribuidos desde Planta Baja (Piso 0) hasta el 4.° Piso.
* **Campus Virtual:** Espacio áulico digital integrado (Microsoft Teams / Moodle) configurado con **capacidad ilimitada ($\infty$)**.
### 🔐 Authentication & Security
- Secure login system with hashed passwords
- Role-based access control
- Session management with Flask-Login
- Password change functionality
---
### 🏫 Classroom Management
- Create, edit, and delete classrooms
- Campus and building organization
- Capacity and resource tracking
- Real-time availability checking
## ✨ 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)
* **Gestión por Módulos:** Permisos independientes por cada área del sistema:
1. **Aulas y Espacios:** Creación, edición, capacidad y estado de aulas físicas y virtuales.
2. **Edificios y Sedes:** Administración de sedes, pisos y arquitectura edilicia.
3. **Reservas de Aulas:** Solicitud, aprobación, confirmación y cancelación.
4. **Cartelera y Cronograma:** Calendario interactivo, visualización diaria y comisiones.
5. **Gestión Académica:** Carreras, asignaturas, códigos y comisiones.
6. **Sincronización Sheets:** Importador y sincronizador de Google Sheets.
7. **Optimizador IA:** Algoritmo genético de distribución y resolución de conflictos.
8. **Usuarios y Accesos RBAC:** Cuentas, roles y matriz de permisos.
* **Niveles de Permiso Granulares:** `Ninguno`, `Solo Lectura` o `Lectura y Escritura`.
* **Controles Rápidos:** Botón *"Establecer Todos"* para configuración masiva inmediata.
* **Roles Predefinidos:** `Admin` (control total), `Docente`, `Operador` y `Consulta`.
### 📊 Dashboard & Analytics
- Real-time statistics
- Monthly reservation charts
- Quick actions interface
- Upcoming reservations overview
### 📊 2. Sincronización e Importación desde Google Sheets
* Integración directa con la cartelera académica de UniCABA en Google Sheets.
* Extracción e importación normalizada de los 5 campos clave:
* **Código Académico:** Códigos reales de asignatura (ej. `INGA0003`, `ASIG00165`).
* **Asignatura:** Nombre oficial de la materia.
* **Aula:** Mapeo a aulas físicas (PB a 4.° piso) o aula `VIRTUAL` ($\infty$).
* **Carrera:** Normalización y vinculación relacional con entidades de carrera.
* **Horarios y Turnos:** Detección automática de franjas horarias y delimitadores de bloque (`Turno Mañana` y `Turno Tarde / Vespertino`).
### 🎨 Modern UI/UX
- Responsive Bootstrap 5 design
- Dark theme support
- Mobile-friendly interface
- Interactive components and charts
### 📅 3. Calendario Interactivo de Reservas (FullCalendar)
* **Visualización Semanal, Mensual y Diaria:** Grilla ágil de alta legibilidad con jerarquía visual:
* Asignatura destacada en tipografía grande y negrita.
* Badges con código de materia, aula asignada, turno y franja horaria.
* **Filtros Dinámicos Combinables:**
* Filtrar por **Aula**.
* Filtrar por **Materia / Asignatura**.
* Filtrar por **Carrera**.
* Filtrar por **Turno** (`Turno Mañana` / `Turno Tarde`).
* Filtrar por **Piso / Planta** (`Planta Baja`, `Piso 1`, `Piso 2`, `Piso 3`, `Piso 4`).
* **Switch "Ocultar Aulas Virtuales":** Aísla la ocupación física real en el edificio central excluyendo los eventos virtuales de capacidad infinita.
* **Ficha Modal Instantánea:** Vista previa detallada al hacer clic en cualquier evento.
## Tech Stack
### 📋 4. Cartelera del Día y Listado de Reservas
* **Cartelera del Día (`/schedule/today`):**
* **Vista Cronológica (Timeline):** Bloques horarios de 07:00 a 22:00 hs con indicador de ocupación.
* **Vista en Tabla:** Lista ordenada con acciones rápidas y detalles de comisión.
* Filtros directos por piso, turno y switch para ocultar virtuales.
* **Listado Paginado de Reservas (`/schedule/list`):**
* Paginación de 20 registros por página preservando filtros aplicados.
* Búsqueda por texto, estado (`Confirmada`, `Pendiente`, `Cancelada`), rango de fechas y piso.
- **Backend**: Flask (Python)
- **Database**: SQLAlchemy (SQLite/PostgreSQL)
- **Frontend**: Bootstrap 5, jQuery, Chart.js
- **Authentication**: Flask-Login, Werkzeug
- **Forms**: Flask-WTF
- **Calendar**: FullCalendar.js
### 🏫 5. Gestión de Aulas con Capacidad Infinita ($\infty$)
* Soporte nativo para el símbolo **`∞`** o `0` para designar aulas virtuales o espacios sin aforo restringido.
* Botón de un solo clic `Ilimitada (∞)` en formularios de alta y edición con leyendas explicativas.
* Badges distintivos `∞ Ilimitada` en listados, tarjetas y fichas técnicas.
## Installation
### 🤖 6. Optimizador Inteligente de Aulas (Algoritmo Genético)
* Motor de optimización heurística para asignación automática de aulas a comisiones.
* Pondera capacidad requerida vs. capacidad de aula, equipamiento tecnológico y minimización de desplazamientos entre pisos.
### Prerequisites
- Python 3.8+
- pip package manager
### 🎨 7. Experiencia de Usuario, Temas y Localización
* **Tema Oscuro / Claro:** Alternador con botón flotante y persistencia en `localStorage` y perfil de usuario.
* **Soporte Multilingüe:** Internacionalización integral (Español e Inglés) compilada con GNU gettext (`pybabel`).
* **Tarjetas Estadísticas Interactivas:** Accesos directos rápidos desde el panel general hacia las distintas secciones operativas.
### Setup
### 🏛️ 8. Sistema Integral de Gestión Institucional (8 Módulos)
El menú desplegable **Gestión** en la barra de navegación organiza las áreas operativas de la universidad:
* **Espacios e Infraestructura:**
* **Edificios y Sedes (`/buildings/list`):** Administración de edificios físicos, arquitectura y estado edilicio.
* **Aulas Presenciales (`/classrooms/list?view_mode=physical`):** Catálogo de aulas físicas por piso, capacidades y zócalos compactos con métricas de ocupación en tiempo real (día, semana, mes).
* **Aulas Virtuales con Reunión (`/classrooms/list?view_mode=virtual`):** Campus Virtual con salas a demanda agrupadas por comisión, capacidad ilimitada ($\infty$) y enlaces directos de acceso a videollamadas (Zoom, Google Meet, Microsoft Teams).
* **Gestión de Ocupación y Métricas (`/admin/occupancy-metrics`):** Dashboard analítico de tasas de ocupación horaria (día, semana, mes), horas pico, distribución por turno y comparativa física vs. virtual.
* **Gestión Académica:**
* **Carreras (`/admin/careers`):** ABM de carreras universitarias y planes de estudio con contador de asignaturas y comisiones activas.
* **Asignaturas (`/admin/subjects`):** Catálogo institucional de materias con créditos, departamentos y filtros por carrera y estado.
* **Comisiones y Cursadas (`/schedule/commissions`):** Gestión de cursadas, turnos, docentes, enlaces virtuales e hitos de evaluación.
* **Ciclo Lectivo (`/admin/academic-terms`):** Modelo y panel de administración de períodos académicos, cuatrimestres y activación del ciclo lectivo en curso.
* **Administración & Seguridad:**
* **Usuarios y Roles RBAC:** Cuentas, asignación de permisos matriciales y auditoría.
* **Tipificaciones de Hitos:** Catálogo de tipos de hitos de evaluación académica.
* **Sincronización Sheets:** Integración con Google Sheets para actualización en lote.
---
## 🛠️ Stack Tecnológico
| Componente | Tecnología |
| :--- | :--- |
| **Backend** | Python 3.12+ / Flask |
| **Base de Datos** | PostgreSQL 16+ / SQLAlchemy ORM |
| **Seguridad & Sesiones** | Flask-Login / Werkzeug / CSRF Protect |
| **Formularios** | Flask-WTF / WTForms |
| **Internacionalización** | Flask-Babel / GNU gettext |
| **Frontend** | Bootstrap 5.3, Bootstrap Icons, Vanilla JS, CSS Variables |
| **Calendario** | FullCalendar 5.11 |
---
## 🚀 Despliegue en Proxmox VE (LXC) con Entorno Virtual `venv`
El sistema está especialmente optimizado para ejecutarse en contenedores **LXC (Debian 12 Bookworm o Ubuntu 22.04/24.04 LTS)** dentro de **Proxmox VE**, utilizando un entorno virtual nativo de Python (`venv`), el motor **PostgreSQL** instalado en el propio contenedor y el servidor WSGI **Gunicorn** gestionado como servicio por **systemd**.
---
### 📋 Paso 1: Creación del Contenedor LXC en Proxmox VE
Podés crear el contenedor desde la interfaz Web de Proxmox (*Create CT*) o por terminal Proxmox con `pct create`:
* **Plantilla (Template):** `debian-12-standard` (*Recomendado*) o `ubuntu-22.04-standard` / `ubuntu-24.04-standard`.
* **CPU:** 1 a 2 vCPU.
* **Memoria RAM:** 1024 MB a 2048 MB (Swap: 512 MB).
* **Disco:** 8 GB a 15 GB (almacenamiento `local-lvm` o `zfs`).
* **Red (Network):** Modo Bridge `vmbr0` con IP estática en tu red local o DHCP (ej: `192.168.1.150/24`, Gateway: `192.168.1.1`).
* **Opciones del Contenedor:**
* *Unprivileged container:* Sí (marcado por defecto, compatible).
* *Features (Características):* Habilitar **Nesting (`nesting=1`)** en `CT > Opciones > Características` (requerido para systemd).
---
### ⚡ Paso 2: Instalación Automatizada con `install.sh`
1. **Ingresar a la consola del contenedor LXC:**
Desde la Web UI de Proxmox (consola noVNC) o desde el shell de tu nodo Proxmox:
1. **Clone or setup the project:**
```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
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
.\venv\Scripts\Activate.ps1
```
* En Linux / macOS:
```bash
python3 -m venv venv
source venv/bin/activate
source venv/bin/activate # On Windows: venv\Scripts\activate
```
2. **Instalar dependencias:**
3. **Install dependencies:**
```bash
pip install -r requirements.txt
```
3. **Configurar base de datos local y restaurar snapshot:**
4. **Setup environment variables:**
```bash
cp .env.example .env
# 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
# Edit .env with your configuration
```
4. **Compilar catálogos de traducción:**
5. **Initialize database:**
```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
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 |
| :--- | :--- | :--- | :--- |
| **Administrador del Sistema** | `admin@edu-space.com` | `admin123` | `Admin` (Acceso Total) |
- **Email**: admin@edu-space.com
- **Password**: admin123
> ⚠️ **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/
├── app/
│ ├── __init__.py # Factoría de la aplicación Flask y extensiones
│ ├── forms/ # Formularios Flask-WTF con validación CSRF
│ │ ├── auth.py # Login, perfil y cambio de clave
│ │ ├── building.py # Sedes y edificios
│ │ ├── classroom.py # Aulas y capacidades (con soporte ∞)
│ │ └── reservation.py # Reservas de aulas y horarios
│ ├── models/ # Modelos relacionales SQLAlchemy
│ │ ├── academic_term.py # Ciclos lectivos y períodos académicos (activo/histórico)
│ │ ├── building.py # Sedes y edificios institucionales
│ │ ├── career.py # Carreras y titulaciones universitarias
│ │ ├── classroom.py # Aulas físicas y virtuales con capacidades
│ │ ├── genetic_algorithm.py # Estructuras de optimización heurística
│ │ ├── milestone.py # Tipificaciones de hitos académicos y exámenes
│ │ ├── reservation.py # Reservas horarias, estados y turnos
│ │ ├── role.py # Roles y matriz de permisos granulares RBAC
│ │ ├── subject.py # Asignaturas, materias y comisiones de cursada
│ │ └── user.py # Usuarios, credenciales y asignación de roles
│ ├── routes/ # Controladores y rutas modulares (Blueprints)
│ │ ├── admin.py # Carreras, Asignaturas, Ciclo Lectivo, Tipificaciones, Métricas, Usuarios y Roles
│ │ ├── auth.py # Autenticación, sesiones y perfil
│ │ ├── buildings.py # Gestión de sedes y edificios
│ │ ├── classrooms.py # Aulas presenciales vs. Campus Virtual a demanda y métricas de uso
│ │ ├── genetic_algorithm.py # Optimizador heurístico de asignación de espacios
│ │ ├── main.py # Dashboard principal y métricas rápidas
│ │ └── schedule.py # Calendario interactivo, cartelera del día, comisiones y reservas
│ ├── services/ # Servicios y lógica de negocio
│ │ └── sheets_importer.py # Parser y sincronizador bidireccional de Google Sheets
│ ├── static/ # Recursos estáticos web (CSS, JS, imágenes institucionales)
│ └── templates/ # Vistas Jinja2 renderizadas con Bootstrap 5
├── config/ # Configuraciones de entorno (desarrollo, testing, prod)
├── translations/ # Catálogos GNU gettext compilados (es / en)
├── classrooms_db.sql # Snapshot completo de base de datos (PostgreSQL 14+)
├── install.sh # Instalador automatizado para Proxmox VE LXC (venv + PostgreSQL)
├── backup_db.sh # Script para generar nuevos backups y actualizar snapshot
├── init_db.py # Inicializador DDL y sincronizador de esquema/roles
├── CHANGELOG_SESSION.md # Historial técnico cronológico
├── requirements.txt # Dependencias Python (incluye Gunicorn)
├── .env.example # Plantilla de variables de entorno
└── wsgi.py # Punto de entrada de la aplicación Flask
│ ├── __init__.py # Flask app factory
│ ├── models/ # Database models
│ │ ├── user.py
│ │ ├── classroom.py
│ │ ├── subject.py
│ │ └── reservation.py
│ ├── routes/ # Application routes
│ │ ├── auth.py
│ │ ├── main.py
│ │ ├── classrooms.py
│ │ └── schedule.py
│ ├── forms/ # WTForms classes
│ │ ├── auth.py
│ │ ├── classroom.py
│ │ └── reservation.py
│ ├── templates/ # Jinja2 templates
│ ├── static/ # Static files
├── config/ # Configuration files
├── requirements.txt
├── .env
├── app.py
└── README.md
```
---
## Database Schema
## 🚀 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
* **Desacoplamiento de Capas:**
* **Model (Dominio):** Entidades relacionales puras y reglas de integridad del negocio desacopladas del framework web (`app/models/`).
* **View (Presentación):** Vistas web en Jinja2 con Bootstrap 5 y respuestas JSON estructuradas para clientes API/móviles.
* **Controller (Controladores/Blueprints):** Controladores delgados en `app/routes/` enfocados exclusivamente en la orquestación HTTP y redirección.
* **Service Layer & Repositories (`app/services/` & `app/repositories/`):** Aislamiento de la lógica de negocio (detección de colisiones, algoritmo genético de asignación, ETL de Google Sheets) y operaciones de persistencia encapsuladas.
* **DTOs y Validación de Entrada:** Incorporación de esquemas tipados (Pydantic / Marshmallow) para filtrar y sanitizar datos de entrada antes de interactuar con el ORM, eliminando validaciones ad-hoc en los controladores.
### Subjects + Commissions Tables
- Academic information
- Teacher assignments
- Student enrollment tracking
#### B. Autenticación & Autorización Stateless con JWT (JSON Web Tokens)
* **Arquitectura API-First Híbrida:**
* Coexistencia armónica de sesiones web seguras (para el panel administrativo en navegadores) y tokens **JWT** (para microservicios, clientes móviles y aplicaciones externas).
* **Esquema Dual de Tokens (Dual Token Pattern):**
* `Access Token` (firmado con RS256/HS256, expiración corta de 15 minutos) con claims contextuales (`sub`, `roles`, `permissions`, `exp`).
* `Refresh Token` (almacenado en cookie segura `HttpOnly`, `Secure`, `SameSite=Strict`, expiración de 7 días) para renovación silenciosa sin reingreso de credenciales.
* **Mecanismo de Revocación Inmediata (Token Blacklist):** Almacenamiento en caché distribuida (Redis) para invalidación instantánea ante cierres de sesión forzados o rotación de contraseñas.
## Security Features
#### C. Seguridad Empresarial y Paradigma "Sprint / Spring Security"
* **Cadena de Filtros de Interceptación (SecurityFilterChain):** Pipeline centralizado inspirado en los estándares de Spring Security:
* **Authentication Filter:** Intercepta cabeceras `Authorization: Bearer <token>` y valida firmas criptográficas.
* **Security Headers Filter:** Inyección obligatoria de cabeceras de blindaje HTTP (HSTS, CSP, X-Frame-Options: DENY, X-Content-Type-Options: nosniff).
* **Rate Limiting Filter:** Prevención de ataques de fuerza bruta y DDoS en endpoints sensibles mediante límites dinámicos (`Flask-Limiter` / Redis).
* **Seguridad Declarativa a Nivel Método (`@PreAuthorize` / `@require_permission`):**
* Decoradores reutilizables que evalúan la matriz RBAC antes de ejecutar cualquier método de servicio:
```python
@require_permission('classrooms', min_level='read_write')
def update_classroom_capacity(classroom_id, new_capacity): ...
- Password hashing with bcrypt
- Session management
- CSRF protection
- Input validation and sanitization
- SQL injection prevention with SQLAlchemy ORM
## Development
### Running in Development Mode
```bash
export FLASK_ENV=development
python app.py
```
* **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)
| 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.
␍
**Note**: This admin panel is designed to work alongside the Spring Boot microservice. Ensure both services are running for complete functionality.
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.'))
class ClassroomForm(FlaskForm):
building_id = SelectField(_l('Edificio'), validators=[DataRequired()])
new_building_name = StringField(_l('Nombre del Nuevo Edificio'), validators=[Optional(), Length(max=100)])
room_number = StringField(_l('Número / Nombre del Aula'), validators=[
building = StringField(_l('Building'), validators=[
DataRequired(),
Length(min=2, max=100)
])
room_number = StringField(_l('Room Number'), validators=[
DataRequired(),
Length(min=1, max=50)
])
floor = StringField(_l('Piso'), validators=[
DataRequired(message=_l('El piso es obligatorio (ej: PB, 1, 2, 4°P)')),
Length(min=1, max=50)
floor = IntegerField(_l('Floor'), validators=[
InputRequired(message=_l('El piso es obligatorio (0 para Planta Baja)')),
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
])
description = TextAreaField(_l('Descripción'), validators=[
description = TextAreaField(_l('Description'), validators=[
Optional(),
Length(max=500)
])
is_active = BooleanField(_l('Activa'), default=True)
submit = SubmitField(_l('Guardar Aula'))
is_active = BooleanField(_l('Active'), default=True)
submit = SubmitField(_l('Save Classroom'))
class ClassroomResourceForm(FlaskForm):
resource_type = SelectField(_l('Resource Type'), choices=[
+2 -10
View File
@@ -21,16 +21,8 @@ class ReservationForm(FlaskForm):
Optional(),
Length(max=1000)
])
virtual_link = StringField(_l('Virtual Classroom Link'), validators=[
Optional(),
Length(max=500)
])
submit = SubmitField(_l('Make Reservation'))
def __init__(self, *args, is_edit=False, **kwargs):
super().__init__(*args, **kwargs)
self.is_edit = is_edit
def validate_end_time(self, field):
if field.data <= self.start_time.data:
raise ValidationError('End time must be after start time.')
@@ -40,8 +32,8 @@ class ReservationForm(FlaskForm):
raise ValidationError('Reservation cannot exceed 8 hours.')
def validate_start_time(self, field):
# Don't allow new reservations in the past
if not self.is_edit and field.data < datetime.now() - timedelta(minutes=10):
# Don't allow reservations in the past
if field.data < datetime.now():
raise ValidationError('Start time cannot be in the past.')
# 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 .classroom import Classroom, ClassroomResource
from .career import Career
from .academic_term import AcademicTerm
from .subject import Subject, Commission
from .reservation import Reservation, ReservationStatus
from .genetic_algorithm import GeneticAlgorithm, ReservationOptimizer
from .role import Role, Permission, SYSTEM_MODULES
from .milestone import MilestoneType, AcademicMilestone, BASE_MILESTONE_TYPES
__all__ = [
'User',
@@ -15,7 +13,6 @@ __all__ = [
'Classroom',
'ClassroomResource',
'Career',
'AcademicTerm',
'Subject',
'Commission',
'Reservation',
@@ -24,8 +21,5 @@ __all__ = [
'ReservationOptimizer',
'Role',
'Permission',
'SYSTEM_MODULES',
'MilestoneType',
'AcademicMilestone',
'BASE_MILESTONE_TYPES'
'SYSTEM_MODULES'
]
-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 = db.Column(db.String(100), nullable=False)
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)
description = db.Column(db.Text)
is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# 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
@property
def name(self):
"""Generate name from building and code"""
return f"{self.building_name}-{self.code}"
return f"{self.building}-{self.code}"
@property
def campus_location(self):
"""Placeholder for campus location"""
return self.building_rel.address if self.building_rel else None
"""Placeholder for campus location - can be expanded when campus model is implemented"""
return None
# Virtual field for compatibility - combines building and code
@property
@@ -73,34 +39,28 @@ class Classroom(db.Model):
# Override code property for consistency
@property
def code_display(self):
return f"{self.building_name} - {self.code}"
return f"{self.building}-{self.code}"
@property
def capacity_display(self):
"""Retorna '∞' para aulas virtuales o capacidad ilimitada, o el número entero."""
if self.capacity == 0 or self.is_virtual:
if self.capacity == 0 or (self.code and 'VIRTUAL' in self.code.upper()):
return '∞'
return str(self.capacity)
@property
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):
"""Convert classroom to dictionary"""
return {
'id': self.id,
'building_id': self.building_id,
'building': self.building_name,
'code': self.code,
'code_display': self.code_display,
'code': self.code_display,
'building': self.building,
'name': self.name,
'floor': self.floor,
'floor_display': self.floor_display,
'location_display': self.location_display,
'capacity': self.capacity,
'capacity_display': self.capacity_display,
'is_virtual': self.is_virtual,
'description': self.description,
'campus_location': self.campus_location,
'is_active': self.is_active,
@@ -111,9 +71,10 @@ class Classroom(db.Model):
def get_full_location(self):
"""Get full classroom location"""
if self.is_virtual:
return 'Campus Virtual (Remoto)'
return f"{self.building_name} - {self.floor_display}"
location = f"{self.building} - Floor {self.floor}"
if self.campus_location:
location += f" ({self.campus_location})"
return location
@staticmethod
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
expected_attendees = db.Column(db.Integer, nullable=False)
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
virtual_link = db.Column(db.String(500), nullable=True) # Zoom, Meet, Teams, etc.
notes = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
@@ -83,24 +82,6 @@ class Reservation(db.Model):
return self.classroom.code
return ''
@property
def effective_virtual_link(self):
"""Devuelve el enlace explícito de la reserva o hereda el de la comisión"""
if self.virtual_link and self.virtual_link.strip():
return self.virtual_link.strip()
if self.commission and self.commission.virtual_link:
return self.commission.virtual_link.strip()
return None
@property
def is_virtual(self):
"""Verifica si la reserva es virtual por aula o por contar con enlace virtual"""
if self.classroom and self.classroom.is_virtual:
return True
if self.effective_virtual_link:
return True
return False
# Relationships
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'))
@@ -119,8 +100,6 @@ class Reservation(db.Model):
'status': self.status, # Already a string in DB
'expected_attendees': self.expected_attendees,
'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,
'updated_at': self.updated_at.isoformat() if self.updated_at 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_en': 'Classroom Reservations',
'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',
@@ -58,20 +58,6 @@ SYSTEM_MODULES = [
'name_en': 'Users & Access RBAC',
'icon': 'bi-shield-lock',
'description': 'Administración de cuentas, roles y matriz granular de permisos.'
},
{
'id': 'milestone_types',
'name': 'Tipificaciones de Hitos',
'name_en': 'Milestone Types',
'icon': 'bi-tags',
'description': 'Catálogo dinámico de tipos de evaluaciones, parciales, TPs y eventos.'
},
{
'id': 'metrics',
'name': 'Métricas de Ocupación',
'name_en': 'Occupancy Metrics',
'icon': 'bi-graph-up',
'description': 'Visualización y análisis de porcentajes de ocupación por aula (día, semana y mes).'
}
]
+1 -17
View File
@@ -50,7 +50,6 @@ class Commission(db.Model):
current_students = db.Column(db.Integer, default=0)
schedule = db.Column(db.String(255))
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
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
created_at = db.Column(db.DateTime, default=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,
'current_students': self.current_students,
'schedule': self.schedule,
'shift': self.shift,
'virtual_link': self.virtual_link,
'active': self.active,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
@@ -98,20 +95,7 @@ class Commission(db.Model):
def get_full_code(self):
"""Get full commission code"""
subj = self.subject.code if self.subject else 'N/A'
code = self.code or ''
sem = str(self.semester or '').strip()
yr = str(self.year or '').strip()
if yr and yr in sem:
return f"{subj}-{code}-{sem}"
elif sem and yr:
return f"{subj}-{code}-{sem}{yr}"
elif sem:
return f"{subj}-{code}-{sem}"
elif yr:
return f"{subj}-{code}-{yr}"
return f"{subj}-{code}"
return f"{self.subject.code if self.subject else 'N/A'}-{self.code}-{self.semester.upper()}{self.year if self.year else ''}"
def __repr__(self):
return f'<Commission {self.get_full_code()}>'
+1 -28
View File
@@ -55,34 +55,7 @@ class User(UserMixin, db.Model):
def can_manage(self):
"""Check if user can access the Management / Admin section"""
return (
self.is_admin() or
self.has_permission('users', 'read') or
self.has_permission('milestone_types', 'read') or
self.has_permission('import', 'read') or
self.has_permission('reservations', 'read_write') or
self.has_permission('classrooms', 'read_write') or
self.has_permission('buildings', 'read_write') or
self.has_permission('metrics', 'read')
)
def can_edit_reservation(self, reservation):
"""Check if user has permission to edit a given reservation"""
if not reservation:
return False
if self.is_admin():
return True
role_name = (self.role_obj.name if self.role_obj else self.role or '').lower()
if role_name in ['admin', 'administrador', 'bedelia', 'bedelía', 'operador']:
return True
if self.has_permission('reservations', 'read_write'):
# El docente creador puede modificar su propia reserva
if reservation.user_id == self.id:
return True
# Bedelía o roles de gestión con permiso de escritura total
if role_name in ['bedelia', 'bedelía', 'operador']:
return True
return False
return self.is_admin() or self.has_permission('users', 'read')
def update_last_login(self):
-777
View File
@@ -4,10 +4,8 @@ from flask_babel import gettext as _
from app import db
from app.models.user import User
from app.models.role import Role, Permission, SYSTEM_MODULES, ACCESS_LEVELS
from app.models.milestone import MilestoneType, AcademicMilestone, BASE_MILESTONE_TYPES
from app.utils.decorators import admin_required, permission_required
from sqlalchemy import or_
import re
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
@@ -179,36 +177,6 @@ def user_toggle(id):
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)
# ---------------------------------------------------------
@@ -372,748 +340,3 @@ def google_sheets_import():
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'])
@login_required
def add_classroom():
"""Add a new classroom with dynamic building creation and floor suggestion"""
from app.models.building import Building
import re
from sqlalchemy import func
"""Add a new classroom"""
form = ClassroomForm()
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
form.building_id.choices = [(str(b.id), b.name) for b in buildings] + [('__new__', '+ Crear nuevo edificio')]
# Map of existing floors per building for datalist
building_floors = {str(b.id): list(dict.fromkeys(c.floor for c in b.classrooms if c.floor)) for b in buildings}
# TODO: Update when campus model is implemented
# form.campus_id.choices = [(1, 'Main Campus')]
if form.validate_on_submit():
try:
building = None
if form.building_id.data == '__new__':
new_building_name = form.new_building_name.data.strip() if form.new_building_name.data else ''
if not new_building_name:
flash('Debe ingresar el nombre del nuevo edificio.', 'error')
return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom')
building = Building.query.filter(func.lower(Building.name) == func.lower(new_building_name)).first()
if not building:
code_slug = re.sub(r'[^a-zA-Z0-9]+', '_', new_building_name.upper()).strip('_')
building = Building(name=new_building_name, code=code_slug, is_active=True)
db.session.add(building)
db.session.commit()
else:
building = Building.query.get_or_404(int(form.building_id.data))
room_code = form.room_number.data.strip()
floor_val = str(form.floor.data).strip()
# Prevent duplicate room in same building & floor
existing_classroom = Classroom.query.filter_by(
building_id=building.id,
code=room_code,
floor=floor_val
building=form.building.data,
code=form.room_number.data,
floor=form.floor.data
).first()
if existing_classroom:
flash(f'Ya existe un aula con el nombre "{room_code}" en el piso {floor_val} de {building.name}.', 'error')
return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom')
flash('Classroom with this building, room number, and floor already exists.', 'error')
return render_template('classrooms/add.html', form=form, title='Add Classroom')
cap_raw = str(form.capacity.data or '').strip()
if cap_raw in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']:
@@ -63,10 +37,10 @@ def add_classroom():
cap_val = int(cap_raw)
classroom = Classroom(
building_id=building.id,
building=building.name,
code=room_code,
floor=floor_val,
# campus_id=form.campus_id.data,
building=form.building.data,
code=form.room_number.data,
floor=form.floor.data,
capacity=cap_val,
description=form.description.data,
is_active=form.is_active.data
@@ -75,91 +49,34 @@ def add_classroom():
db.session.add(classroom)
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'))
except Exception as e:
db.session.rollback()
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('/list')
@login_required
def list_classrooms():
"""List all classrooms with separation between Physical spaces and Virtual on-demand rooms"""
"""List all classrooms"""
try:
from app.models.building import Building
from app.models.reservation import Reservation, ReservationStatus
from app.models.subject import Commission, Subject
import calendar
from datetime import date, timedelta
# Get filters
page = request.args.get('page', 1, type=int)
active_only = request.args.get('active_only', 'true').lower() == 'true'
search = request.args.get('search', '', type=str).strip()
building_filter = request.args.get('building_id', '', type=str).strip()
floor_filter = request.args.get('floor', '', type=str).strip()
view_mode = request.args.get('view_mode', '').strip().lower()
search = request.args.get('search', '', type=str)
# Identificar edificio virtual y aula virtual base
virtual_building = Building.query.filter(
db.or_(Building.code == 'VIRTUAL', Building.name.ilike('%Virtual%'))
).first()
virtual_bldg_id = virtual_building.id if virtual_building else None
# Si el usuario seleccionó "Campus Virtual" en el filtro de edificios, conmutar a pestaña virtual
if building_filter and str(building_filter) == str(virtual_bldg_id):
view_mode = 'virtual'
elif not view_mode:
view_mode = 'physical'
# Fechas operativas (Día, Semana, Mes)
today = datetime.utcnow().date()
today_start = datetime.combine(today, datetime.min.time())
today_end = datetime.combine(today, datetime.max.time())
week_start = datetime.combine(today - timedelta(days=today.weekday()), datetime.min.time())
week_end = datetime.combine(week_start.date() + timedelta(days=6), datetime.max.time())
_, last_day = calendar.monthrange(today.year, today.month)
month_start = datetime(today.year, today.month, 1, 0, 0, 0)
month_end = datetime(today.year, today.month, last_day, 23, 59, 59)
date_filters = {
'today': today.strftime('%Y-%m-%d'),
'week_start': week_start.strftime('%Y-%m-%d'),
'week_end': week_end.strftime('%Y-%m-%d'),
'month_start': month_start.strftime('%Y-%m-%d'),
'month_end': month_end.strftime('%Y-%m-%d')
}
# --- AULAS FÍSICAS (Presenciales) ---
physical_query = Classroom.query
if virtual_bldg_id:
physical_query = physical_query.filter(Classroom.building_id != virtual_bldg_id)
else:
physical_query = physical_query.filter(~Classroom.code.ilike('%VIRTUAL%'))
# Build query
query = Classroom.query
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 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(
if search:
query = query.filter(
db.or_(
Classroom.building.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(
page=page, per_page=20, error_out=False
# Paginate results
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',
classrooms=classrooms,
buildings=buildings,
floors=floors,
building_filter=building_filter,
floor_filter=floor_filter,
active_only=active_only,
search=search,
view_mode=view_mode,
physical_count=physical_count,
virtual_count=len(comm_dict),
virtual_rooms=virtual_rooms,
virtual_classroom_id=vc_id,
global_virtual_metrics=global_virtual_metrics,
occupancy_metrics=occupancy_metrics,
date_filters=date_filters,
title='Classrooms')
except Exception as e:
flash(f'Error loading classrooms: {str(e)}', 'error')
return render_template('classrooms/list.html', classrooms=None, title='Classrooms')
@classrooms_bp.route('/virtual-room/<int:commission_id>/update-link', methods=['POST'])
@login_required
def update_virtual_link(commission_id):
"""Actualizar enlace de videollamada para una comisión virtual"""
from app.models.subject import Commission
from flask_babel import gettext as _
comm = Commission.query.get_or_404(commission_id)
new_link = request.form.get('virtual_link', '').strip() or None
comm.virtual_link = new_link
db.session.commit()
flash(_('Enlace de videollamada para %(code)s actualizado correctamente.', code=comm.get_full_code()), 'success')
return redirect(request.referrer or url_for('classrooms.list_classrooms', view_mode='virtual'))
@classrooms_bp.route('/<int:id>')
@login_required
def view_classroom(id):
"""View classroom details"""
try:
classroom = Classroom.query.get_or_404(id)
# Get upcoming reservations for this classroom
from app.models.reservation import Reservation, ReservationStatus
upcoming_reservations = Reservation.query.filter(
@@ -353,63 +124,35 @@ def view_classroom(id):
flash(f'Error loading classroom: {str(e)}', 'error')
return redirect(url_for('classrooms.list_classrooms'))
@classrooms_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def edit_classroom(id):
"""Edit classroom with decoupled building and floor"""
from app.models.building import Building
import re
from sqlalchemy import func
"""Edit classroom"""
classroom = Classroom.query.get_or_404(id)
form = ClassroomForm(obj=classroom)
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
form.building_id.choices = [(str(b.id), b.name) for b in buildings] + [('__new__', '+ Crear nuevo edificio')]
building_floors = {str(b.id): list(dict.fromkeys(c.floor for c in b.classrooms if c.floor)) for b in buildings}
if request.method == 'GET':
form.building_id.data = str(classroom.building_id or '')
form.room_number.data = classroom.code
form.floor.data = classroom.floor
if classroom.capacity == 0 or classroom.is_virtual:
if classroom.capacity == 0 or (classroom.code and 'VIRTUAL' in classroom.code.upper()):
form.capacity.data = '∞'
else:
form.capacity.data = str(classroom.capacity)
# TODO: Update when campus model is implemented
# form.campus_id.choices = [(1, 'Main Campus')]
if form.validate_on_submit():
try:
building = None
if form.building_id.data == '__new__':
new_building_name = form.new_building_name.data.strip() if form.new_building_name.data else ''
if not new_building_name:
flash('Debe ingresar el nombre del nuevo edificio.', 'error')
return render_template('classrooms/edit.html', form=form, classroom=classroom, building_floors=building_floors, title='Edit Classroom')
building = Building.query.filter(func.lower(Building.name) == func.lower(new_building_name)).first()
if not building:
code_slug = re.sub(r'[^a-zA-Z0-9]+', '_', new_building_name.upper()).strip('_')
building = Building(name=new_building_name, code=code_slug, is_active=True)
db.session.add(building)
db.session.commit()
else:
building = Building.query.get_or_404(int(form.building_id.data))
room_code = form.room_number.data.strip()
floor_val = str(form.floor.data).strip()
# Check duplicate excluding current
# Check for duplicates (excluding current classroom)
existing_classroom = Classroom.query.filter(
Classroom.building_id == building.id,
Classroom.code == room_code,
Classroom.floor == floor_val,
Classroom.building == form.building.data,
Classroom.code == form.room_number.data,
Classroom.floor == form.floor.data,
Classroom.id != id
).first()
if existing_classroom:
flash(f'Ya existe otra aula con el nombre "{room_code}" en el piso {floor_val} de {building.name}.', 'error')
return render_template('classrooms/edit.html', form=form, classroom=classroom, building_floors=building_floors, title='Edit Classroom')
flash('Classroom with this building, room number, and floor already exists.', 'error')
return render_template('classrooms/edit.html', form=form, classroom=classroom, title='Edit Classroom')
cap_raw = str(form.capacity.data or '').strip()
if cap_raw in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']:
@@ -417,10 +160,10 @@ def edit_classroom(id):
else:
cap_val = int(cap_raw)
classroom.building_id = building.id
classroom.building = building.name
classroom.code = room_code
classroom.floor = floor_val
# Update classroom
classroom.building = form.building.data
classroom.code = form.room_number.data
classroom.floor = form.floor.data
classroom.capacity = cap_val
classroom.description = form.description.data
classroom.is_active = form.is_active.data
@@ -428,14 +171,14 @@ def edit_classroom(id):
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))
except Exception as e:
db.session.rollback()
flash(f'Error updating classroom: {str(e)}', 'error')
return render_template('classrooms/edit.html', form=form, classroom=classroom, building_floors=building_floors, title='Edit Classroom')
return render_template('classrooms/edit.html', form=form, classroom=classroom, title='Edit Classroom')
@classrooms_bp.route('/<int:id>/toggle', methods=['POST'])
@login_required
+1 -1
View File
@@ -249,7 +249,7 @@ genetic_web_bp = Blueprint('genetic_algorithm_web', __name__)
@login_required
def genetic_optimizer():
"""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('genetic_optimizer.html')
+2 -23
View File
@@ -113,8 +113,8 @@ def dashboard_stats_api():
@login_required
def genetic_optimizer():
"""Serve the genetic algorithm optimization interface"""
if not (current_user.has_permission('optimizer', 'read') or (current_user.role and current_user.role.upper() == 'ADMIN')):
flash(_('No tienes permisos para acceder a la herramienta de optimización.'), 'danger')
if current_user.role not in ['ADMIN']:
flash('You do not have permission to access the optimization tool.', 'danger')
return redirect(url_for('main.dashboard'))
return render_template('genetic_optimizer.html')
@@ -135,24 +135,3 @@ def set_language(language=None):
db.session.commit()
return redirect(request.referrer or url_for('main.dashboard'))␍
@main_bp.route('/api/milestone-types')
def api_milestone_types():
"""Retorna las tipificaciones de hitos activas en JSON para consumo de frontend/mobile"""
from app.models.milestone import MilestoneType, BASE_MILESTONE_TYPES
if MilestoneType.query.count() == 0:
for item in BASE_MILESTONE_TYPES:
mt = MilestoneType(
name=item['name'],
code=item['code'],
color=item['color'],
description=item['description'],
sort_order=item['sort_order'],
is_active=True
)
db.session.add(mt)
db.session.commit()
types = MilestoneType.query.filter_by(is_active=True).order_by(MilestoneType.sort_order.asc(), MilestoneType.id.asc()).all()
return jsonify([t.to_dict() for t in types])␍
+24 -415
View File
@@ -40,12 +40,8 @@ def today_schedule():
~Classroom.building.ilike('%Virtual%')
)
if floor_filter:
flr = str(floor_filter).strip()
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
query = query.filter(or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
else:
query = query.filter(Classroom.floor == flr)
if floor_filter and floor_filter.isdigit():
query = query.filter(Classroom.floor == int(floor_filter))
if 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')
return redirect(url_for('schedule.today_schedule'))
@schedule_bp.route('/reservation/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def edit_reservation(id):
"""Edit existing reservation"""
from app.models.classroom import Classroom
from app.models.subject import Commission
from app.models.building import Building
from flask_babel import gettext as _
reservation = Reservation.query.options(
joinedload(Reservation.classroom),
joinedload(Reservation.commission),
joinedload(Reservation.user)
).get_or_404(id)
if not current_user.can_edit_reservation(reservation):
flash(_('No tienes los permisos necesarios para modificar esta reserva.'), 'error')
return redirect(url_for('schedule.view_reservation', id=reservation.id))
form = ReservationForm(is_edit=True)
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all()
commissions = Commission.query.filter_by(active=True).all()
form.classroom_id.choices = [(c.id, f'{c.building_name} - {c.code} (Cap: {c.capacity_display})') for c in classrooms]
form.commission_id.choices = [(c.id, f'{c.get_full_code()} - {c.subject.name if c.subject else "No Subject"} (Students: {c.max_students})') for c in commissions]
if request.method == 'GET':
form.classroom_id.data = reservation.classroom_id
form.commission_id.data = reservation.commission_id
form.start_time.data = reservation.start_time
form.end_time.data = reservation.end_time
form.purpose.data = reservation.purpose
form.expected_attendees.data = reservation.expected_attendees
form.notes.data = reservation.notes or ''
form.virtual_link.data = reservation.virtual_link or ''
elif request.method == 'POST':
if form.validate_on_submit():
try:
# Verificar colisión de horario si no es virtual
target_room = Classroom.query.get(form.classroom_id.data)
if target_room and not target_room.is_virtual:
conflict = Reservation.query.filter(
Reservation.id != reservation.id,
Reservation.classroom_id == form.classroom_id.data,
Reservation.status == ReservationStatus.CONFIRMED.value,
Reservation.start_time < form.end_time.data,
Reservation.end_time > form.start_time.data
).first()
if conflict:
flash(_('El aula seleccionada ya cuenta con una reserva confirmada en esa franja horaria.'), 'danger')
return render_template('schedule/edit.html', form=form, reservation=reservation, classroom_list=classrooms, commission_list=commissions, buildings=buildings)
# Asignar cambios
reservation.classroom_id = form.classroom_id.data
reservation.commission_id = form.commission_id.data
reservation.start_time = form.start_time.data
reservation.end_time = form.end_time.data
reservation.purpose = form.purpose.data
reservation.expected_attendees = form.expected_attendees.data
reservation.notes = form.notes.data
reservation.virtual_link = form.virtual_link.data.strip() if form.virtual_link.data else None
# Turno
h = reservation.start_time.hour
if h < 13:
reservation.shift = 'Mañana'
elif h < 18:
reservation.shift = 'Tarde'
elif h < 21:
reservation.shift = 'Vespertino'
else:
reservation.shift = 'Noche'
db.session.commit()
flash(_('Reserva #%(id)d modificada exitosamente.', id=reservation.id), 'success')
return redirect(url_for('schedule.view_reservation', id=reservation.id))
except Exception as e:
db.session.rollback()
flash(f'Error al modificar reserva: {str(e)}', 'danger')
return render_template('schedule/edit.html', form=form, reservation=reservation, classroom_list=classrooms, commission_list=commissions, buildings=buildings)
@schedule_bp.route('/calendar')
@login_required
def calendar_view():
"""Show calendar view with classroom, subject, career, shift, floor and virtual filtering"""
from app.models.career import Career
from app.models.building import Building
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building.asc(), Classroom.code.asc()).all()
subjects = Subject.query.filter_by(is_active=True).order_by(Subject.name.asc()).all()
careers = Career.query.order_by(Career.name.asc()).all()
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()
available_floors = [f[0] for f in floors]
@@ -246,246 +154,22 @@ def calendar_view():
classrooms=classrooms,
subjects=subjects,
careers=careers,
buildings=buildings,
available_floors=available_floors
)
def user_can_manage_commission_milestones(user, commission=None):
"""Verifica si el usuario (Docente, Admin, Bedelía) puede gestionar hitos y links virtuales"""
if user.is_admin():
return True
role_name = (user.role_obj.name if user.role_obj else user.role or '').lower()
if role_name in ['admin', 'bedelia', 'bedelía', 'operador']:
return True
if role_name in ['docente', 'profesor']:
return True
return user.has_permission('schedule', 'read_write')
@schedule_bp.route('/commissions')
@login_required
def commissions_view():
"""Vista y ABM de Comisiones con soporte de enlace virtual e hitos académicos"""
from app.models.subject import Commission, Subject
from app.models.classroom import Classroom
from app.models.user import User
from app.models.milestone import MilestoneType
subjects = Subject.query.all()
subjects = Subject.query.order_by(Subject.code.asc()).all()
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.code.asc()).all()
teachers = User.query.filter_by(is_active=True).order_by(User.name.asc()).all()
milestone_types = MilestoneType.query.filter_by(is_active=True).order_by(MilestoneType.sort_order.asc()).all()
# Totales para indicadores superiores
# Calculamos el total de capacidad sumando todas las comisiones de todas las materias
total_capacity = sum(c.max_students for s in subjects for c in s.commissions)
# Calculamos el total de comisiones
total_commissions = sum(len(s.commissions) for s in subjects)
can_manage_milestones = user_can_manage_commission_milestones(current_user)
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
can_edit_commissions = current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')
return render_template('schedule/commissions.html',
subjects=subjects,
classrooms=classrooms,
teachers=teachers,
milestone_types=milestone_types,
total_capacity=total_capacity,
total_commissions=total_commissions,
can_manage_milestones=can_manage_milestones,
can_edit_commissions=can_edit_commissions)
@schedule_bp.route('/commissions/add', methods=['POST'])
@login_required
def commission_add():
"""Crear una nueva comisión de cursada"""
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')):
flash(_('No tienes permisos para crear comisiones.'), 'error')
return redirect(url_for('schedule.commissions_view'))
from app.models.subject import Commission
subject_id = request.form.get('subject_id', type=int)
code = request.form.get('code', '').strip()
semester = request.form.get('semester', '1C').strip()
year = request.form.get('year', datetime.now().year, type=int)
teacher_id = request.form.get('teacher_id', type=int) or None
max_students = request.form.get('max_students', 40, type=int)
schedule = request.form.get('schedule', '').strip()
shift = request.form.get('shift', 'Tarde').strip()
virtual_link = request.form.get('virtual_link', '').strip()
if not subject_id or not code:
flash(_('La asignatura y el código de comisión son obligatorios.'), 'error')
return redirect(url_for('schedule.commissions_view'))
# Verificar existencia duplicada
existing = Commission.query.filter_by(subject_id=subject_id, code=code, semester=semester, year=year).first()
if existing:
flash(_('Ya existe una comisión %(code)s para el ciclo %(sem)s %(yr)d.', code=code, sem=semester, yr=year), 'error')
return redirect(url_for('schedule.commissions_view'))
new_comm = Commission(
subject_id=subject_id,
code=code,
semester=semester,
year=year,
teacher_id=teacher_id,
max_students=max_students,
schedule=schedule,
shift=shift,
virtual_link=virtual_link or None,
active=True
)
db.session.add(new_comm)
db.session.commit()
flash(_('Comisión %(code)s creada exitosamente.', code=new_comm.get_full_code()), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/commissions/<int:id>/edit', methods=['POST'])
@login_required
def commission_edit(id):
"""Editar datos de una comisión"""
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')):
flash(_('No tienes permisos para modificar comisiones.'), 'error')
return redirect(url_for('schedule.commissions_view'))
from app.models.subject import Commission
commission = Commission.query.get_or_404(id)
commission.code = request.form.get('code', commission.code).strip()
commission.semester = request.form.get('semester', commission.semester).strip()
commission.year = request.form.get('year', commission.year, type=int)
commission.teacher_id = request.form.get('teacher_id', type=int) or None
commission.max_students = request.form.get('max_students', commission.max_students, type=int)
commission.schedule = request.form.get('schedule', commission.schedule).strip()
commission.shift = request.form.get('shift', commission.shift).strip()
commission.virtual_link = request.form.get('virtual_link', '').strip() or None
commission.active = True if request.form.get('active') in ['1', 'true', 'on'] else False
db.session.commit()
flash(_('Comisión %(code)s actualizada con éxito.', code=commission.get_full_code()), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/commissions/<int:id>/delete', methods=['POST'])
@login_required
def commission_delete(id):
"""Eliminar una comisión"""
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')):
flash(_('No tienes permisos para eliminar comisiones.'), 'error')
return redirect(url_for('schedule.commissions_view'))
from app.models.subject import Commission
commission = Commission.query.get_or_404(id)
full_code = commission.get_full_code()
db.session.delete(commission)
db.session.commit()
flash(_('Comisión %(code)s eliminada permanentemente.', code=full_code), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/commissions/<int:id>/virtual-link', methods=['POST'])
@login_required
def commission_virtual_link(id):
"""Actualizar enlace del aula virtual (Zoom, Meet, Teams, Moodle) - Profesores, Admin y Bedelía"""
from app.models.subject import Commission
commission = Commission.query.get_or_404(id)
if not user_can_manage_commission_milestones(current_user, commission):
flash(_('No tienes permisos para actualizar el aula virtual de esta comisión.'), 'error')
return redirect(url_for('schedule.commissions_view'))
link = request.form.get('virtual_link', '').strip()
commission.virtual_link = link or None
db.session.commit()
flash(_('Enlace del aula virtual actualizado para la comisión %(code)s.', code=commission.get_full_code()), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/commissions/<int:id>/milestones/add', methods=['POST'])
@login_required
def commission_milestone_add(id):
"""Agregar un hito académico (Parcial, TP, Recuperatorio) a una comisión - Profesores, Admin y Bedelía"""
from app.models.subject import Commission
from app.models.milestone import AcademicMilestone, MilestoneType
commission = Commission.query.get_or_404(id)
if not user_can_manage_commission_milestones(current_user, commission):
flash(_('No tienes permisos para agregar hitos a esta comisión.'), 'error')
return redirect(url_for('schedule.commissions_view'))
milestone_type_id = request.form.get('milestone_type_id', type=int)
title = request.form.get('title', '').strip()
date_str = request.form.get('date', '').strip()
time_str = request.form.get('time', '18:00').strip()
classroom = request.form.get('classroom', '').strip()
link = request.form.get('link', '').strip() or commission.virtual_link or ''
notes = request.form.get('notes', '').strip()
if not milestone_type_id or not date_str:
flash(_('Debes especificar el tipo de hito y la fecha.'), 'error')
return redirect(url_for('schedule.commissions_view'))
m_type = MilestoneType.query.get(milestone_type_id)
if not m_type:
flash(_('Tipo de hito inválido.'), 'error')
return redirect(url_for('schedule.commissions_view'))
try:
dt = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M")
except ValueError:
try:
dt = datetime.strptime(date_str, "%Y-%m-%d")
except ValueError:
flash(_('Formato de fecha inválido.'), 'error')
return redirect(url_for('schedule.commissions_view'))
milestone = AcademicMilestone(
title=title or m_type.name,
materia_codigo=commission.subject.code if commission.subject else 'ASIG',
subject_id=commission.subject_id,
commission_id=commission.id,
milestone_type_id=m_type.id,
date=dt,
classroom=classroom or 'Campus Virtual',
link=link or None,
notes=notes or None,
is_active=True
)
db.session.add(milestone)
db.session.commit()
flash(_('Hito "%(title)s" registrado correctamente en %(comm)s.', title=milestone.title, comm=commission.get_full_code()), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/commissions/<int:commission_id>/milestones/<int:milestone_id>/delete', methods=['POST'])
@login_required
def commission_milestone_delete(commission_id, milestone_id):
"""Eliminar un hito académico de una comisión"""
from app.models.subject import Commission
from app.models.milestone import AcademicMilestone
commission = Commission.query.get_or_404(commission_id)
if not user_can_manage_commission_milestones(current_user, commission):
flash(_('No tienes permisos para eliminar hitos de esta comisión.'), 'error')
return redirect(url_for('schedule.commissions_view'))
milestone = AcademicMilestone.query.filter_by(id=milestone_id, commission_id=commission_id).first_or_404()
title = milestone.title
db.session.delete(milestone)
db.session.commit()
flash(_('Hito "%(title)s" eliminado de la comisión.', title=title), 'success')
return redirect(url_for('schedule.commissions_view'))
total_commissions=total_commissions)
@schedule_bp.route('/add', methods=['GET', 'POST'])
@login_required
@@ -493,17 +177,17 @@ def add_reservation():
"""Add new reservation"""
from app.models.classroom import Classroom
from app.models.subject import Commission
from app.models.building import Building
form = ReservationForm()
# Get active buildings and classrooms
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all()
# Get active classrooms for the dropdown
classrooms = Classroom.query.filter_by(is_active=True).all()
# Get active commissions for the dropdown
commissions = Commission.query.filter_by(active=True).all()
# Populate form choices
form.classroom_id.choices = [(c.id, f'{c.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]
if request.method == 'POST':
@@ -521,7 +205,7 @@ def add_reservation():
if existing_reservation:
flash('This classroom is already booked for the selected time slot.', 'danger')
return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions, buildings=buildings)
return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions)
# Create new reservation
reservation = Reservation(
@@ -532,7 +216,6 @@ def add_reservation():
end_time=form.end_time.data,
purpose=form.purpose.data,
notes=form.notes.data,
virtual_link=form.virtual_link.data.strip() if form.virtual_link.data else None,
status=ReservationStatus.PENDING.value,
expected_attendees=form.expected_attendees.data
)
@@ -545,53 +228,10 @@ def add_reservation():
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error creating reservation: {str(e)}")
flash(f'Error creating reservation: {str(e)}', 'danger')
return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions, buildings=buildings)
@schedule_bp.route('/reservation/add', methods=['POST'])
@login_required
def quick_reservation_add():
"""Endpoint AJAX para crear reserva rápida desde el modal de comisiones"""
try:
commission_id = request.form.get('commission_id', type=int)
classroom_id = request.form.get('classroom_id', type=int)
purpose = request.form.get('purpose', '').strip() or 'Clase de cursada'
start_str = request.form.get('start_time', '').strip()
end_str = request.form.get('end_time', '').strip()
virtual_link = request.form.get('virtual_link', '').strip() or None
if not commission_id or not classroom_id or not start_str or not end_str:
return jsonify({'error': 'Faltan campos obligatorios'}), 400
start_time = datetime.strptime(start_str, '%Y-%m-%d %H:%M')
end_time = datetime.strptime(end_str, '%Y-%m-%d %H:%M')
from app.models.subject import Commission
from app.models.classroom import Classroom
comm = Commission.query.get_or_404(commission_id)
if not virtual_link and comm.virtual_link:
virtual_link = comm.virtual_link
new_res = Reservation(
classroom_id=classroom_id,
commission_id=commission_id,
user_id=comm.teacher_id or current_user.id,
start_time=start_time,
end_time=end_time,
purpose=purpose,
expected_attendees=comm.max_students or 30,
shift=comm.shift or 'Tarde',
virtual_link=virtual_link,
status=ReservationStatus.CONFIRMED.value if current_user.is_admin() else ReservationStatus.PENDING.value
)
db.session.add(new_res)
db.session.commit()
return jsonify({'success': True, 'id': new_res.id})
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 500
return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions)
@schedule_bp.route('/list')
@login_required
@@ -604,7 +244,6 @@ def list_reservations():
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
floor_filter = request.args.get('floor', '').strip()
shift_filter = request.args.get('shift', '').strip()
classroom_id = request.args.get('classroom_id', type=int)
search = request.args.get('search', '').strip()
query = Reservation.query.options(
@@ -613,9 +252,6 @@ def list_reservations():
joinedload(Reservation.user)
).join(Reservation.classroom).outerjoin(Reservation.commission).outerjoin(Commission.subject)
if classroom_id:
query = query.filter(Reservation.classroom_id == classroom_id)
if status_filter and status_filter != 'all':
query = query.filter(Reservation.status == status_filter.upper())
@@ -639,12 +275,8 @@ def list_reservations():
~Classroom.building.ilike('%Virtual%')
)
if floor_filter:
flr = str(floor_filter).strip()
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
query = query.filter(or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
else:
query = query.filter(Classroom.floor == flr)
if floor_filter and floor_filter.isdigit():
query = query.filter(Classroom.floor == int(floor_filter))
if shift_filter:
query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%'))
@@ -654,10 +286,8 @@ def list_reservations():
or_(
Subject.name.ilike(f'%{search}%'),
Subject.code.ilike(f'%{search}%'),
Commission.code.ilike(f'%{search}%'),
Classroom.code.ilike(f'%{search}%'),
Reservation.purpose.ilike(f'%{search}%'),
Reservation.shift.ilike(f'%{search}%')
Reservation.purpose.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()
available_floors = [f[0] for f in floors]
all_classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all()
return render_template(
'schedule/list.html',
@@ -678,8 +307,6 @@ def list_reservations():
hide_virtual=hide_virtual,
floor_filter=floor_filter,
shift_filter=shift_filter,
classroom_id=classroom_id,
all_classrooms=all_classrooms,
search=search,
available_floors=available_floors
)
@@ -816,6 +443,7 @@ def calendar_data():
search = request.args.get('search', '').strip()
if start and end:
from datetime import datetime
# Clean up ISO timezone strings for PostgreSQL naive timestamp comparison
start_clean = start.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():
query = query.filter(Subject.career_id == int(career_id))
building = request.args.get('building', '').strip()
if building:
from app.models.building import Building
query = query.filter(
or_(
Classroom.building.ilike(f'%{building}%'),
Classroom.building_entity.has(Building.name.ilike(f'%{building}%'))
)
)
if shift:
query = query.filter(Reservation.shift.ilike(f'%{shift}%'))
@@ -863,12 +481,8 @@ def calendar_data():
~Classroom.building.ilike('%Virtual%')
)
if floor:
flr = str(floor).strip()
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
query = query.filter(or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
else:
query = query.filter(Classroom.floor == flr)
if floor and floor.isdigit():
query = query.filter(Classroom.floor == int(floor))
if search:
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_name = subject.name if subject else (reservation.purpose or 'Clase')
classroom_name = reservation.classroom.code if reservation.classroom else 'Sin Aula'
classroom_floor = reservation.classroom.floor_display if reservation.classroom else None
classroom_building = reservation.classroom.building_name if reservation.classroom else ''
is_virtual = bool(reservation.classroom and reservation.classroom.is_virtual)
classroom_floor = reservation.classroom.floor if reservation.classroom else None
is_virtual = bool(reservation.classroom and ('VIRTUAL' in (reservation.classroom.code or '').upper() or 'VIRTUAL' in (reservation.classroom.building or '').upper()))
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)
@@ -905,20 +518,16 @@ def calendar_data():
'subject_code': subject_code,
'subject_name': subject_name,
'classroom': classroom_name,
'building': classroom_building,
'building': reservation.classroom.building if reservation.classroom else '',
'floor': classroom_floor,
'is_virtual': is_virtual,
'location_display': reservation.classroom.location_display if reservation.classroom else '',
'career': career_name,
'commission': reservation.commission.code if reservation.commission else '',
'shift': reservation.shift or '',
'status': status_val,
'purpose': reservation.purpose or '',
'attendees': reservation.expected_attendees or 0,
'notes': reservation.notes or '',
'teacher': reservation.user.name if reservation.user else '',
'virtual_link': reservation.effective_virtual_link,
'cancel_url': url_for('schedule.cancel_reservation', id=reservation.id)
'notes': reservation.notes or ''
}
})
@@ -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 %}
</a>
{% if not role.is_system %}
<button type="button" class="btn btn-outline-danger btn-delete-role"
data-role-id="{{ role.id }}"
data-role-name="{{ role.name }}"
data-action-url="{{ url_for('admin.role_delete', id=role.id) }}"
title="{% trans %}Eliminar Rol{% endtrans %}"
{% if role.users|length > 0 %}disabled{% endif %}>
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#deleteModal{{ role.id }}" title="{% trans %}Eliminar Rol{% endtrans %}" {% if role.users|length > 0 %}disabled{% endif %}>
<i class="bi bi-trash"></i>
</button>
{% endif %}
</div>
{% if not role.is_system %}
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal{{ role.id }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow">
<div class="modal-header border-0">
<h5 class="modal-title text-danger">
<i class="bi bi-exclamation-triangle-fill me-2"></i>{% trans %}Confirmar Eliminación de Rol{% endtrans %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-start">
<p>{% trans %}¿Está seguro de que desea eliminar el rol{% endtrans %} <strong>{{ role.name }}</strong>?</p>
<p class="text-muted small mb-0">{% trans %}Esta acción no se puede deshacer.{% endtrans %}</p>
</div>
<div class="modal-footer border-0">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<form action="{{ url_for('admin.role_delete', id=role.id) }}" method="POST" class="d-inline">
<button type="submit" class="btn btn-danger">{% trans %}Eliminar Rol{% endtrans %}</button>
</form>
</div>
</div>
</div>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
@@ -100,54 +121,4 @@
</div>
</div>
</div>
<!-- Modal Global de Eliminación fuera de la tabla (evita desplazamientos visuales) -->
<div class="modal fade" id="deleteRoleModal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow-lg" style="border-radius: 14px;">
<div class="modal-header border-0 pb-0">
<div class="d-flex align-items-center">
<div class="badge bg-danger-subtle text-danger p-2 rounded-circle me-2">
<i class="bi bi-exclamation-triangle-fill fs-5"></i>
</div>
<h5 class="modal-title fw-bold text-danger mb-0">{% trans %}Confirmar Eliminación de Rol{% endtrans %}</h5>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-start py-3">
<p class="mb-2">{% trans %}¿Está seguro de que desea eliminar el rol{% endtrans %} <strong id="deleteRoleName" class="text-primary"></strong>?</p>
<p class="text-muted small mb-0"><i class="bi bi-info-circle me-1"></i>{% trans %}Esta acción no se puede deshacer.{% endtrans %}</p>
</div>
<div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<form id="deleteRoleForm" method="POST" class="d-inline">
<button type="submit" class="btn btn-danger px-3">
<i class="bi bi-trash me-1"></i>{% trans %}Eliminar Rol{% endtrans %}
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
var deleteModalEl = document.getElementById('deleteRoleModal');
var deleteModal = deleteModalEl ? new bootstrap.Modal(deleteModalEl) : null;
var deleteRoleName = document.getElementById('deleteRoleName');
var deleteRoleForm = document.getElementById('deleteRoleForm');
document.querySelectorAll('.btn-delete-role').forEach(function(btn) {
btn.addEventListener('click', function() {
var roleName = this.getAttribute('data-role-name');
var actionUrl = this.getAttribute('data-action-url');
if (deleteRoleName) deleteRoleName.textContent = roleName;
if (deleteRoleForm) deleteRoleForm.action = actionUrl;
if (deleteModal) deleteModal.show();
});
});
});
</script>
{% endblock %}
-306
View File
@@ -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>
</button>
</form>
<button type="button" class="btn btn-outline-danger btn-delete-user"
data-user-id="{{ u.id }}"
data-user-name="{{ u.name }}"
data-user-email="{{ u.email }}"
data-action-url="{{ url_for('admin.user_delete', id=u.id) }}"
title="{% trans %}Eliminar Usuario{% endtrans %}">
<i class="bi bi-trash"></i>
</button>
{% endif %}
</div>
</td>
@@ -187,61 +179,4 @@
{% endif %}
</div>
</div>
<!-- Modal Global de Eliminación de Usuario (fuera de la tabla) -->
<div class="modal fade" id="deleteUserModal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow-lg" style="border-radius: 14px;">
<div class="modal-header border-0 pb-0">
<div class="d-flex align-items-center">
<div class="badge bg-danger-subtle text-danger p-2 rounded-circle me-2">
<i class="bi bi-person-x-fill fs-5"></i>
</div>
<h5 class="modal-title fw-bold text-danger mb-0">{% trans %}Confirmar Eliminación de Usuario{% endtrans %}</h5>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-start py-3">
<p class="mb-2">{% trans %}¿Está seguro de que desea eliminar permanentemente al siguiente usuario?{% endtrans %}</p>
<div class="p-2 rounded bg-body-tertiary mb-2">
<div class="fw-bold text-primary" id="deleteUserName"></div>
<div class="small text-muted font-monospace" id="deleteUserEmail"></div>
</div>
<p class="text-muted small mb-0"><i class="bi bi-exclamation-triangle me-1 text-warning"></i>{% trans %}Esta acción desvinculará sus comisiones y reservas asociadas. No se puede deshacer.{% endtrans %}</p>
</div>
<div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<form id="deleteUserForm" method="POST" class="d-inline">
<button type="submit" class="btn btn-danger px-3">
<i class="bi bi-trash me-1"></i>{% trans %}Eliminar Usuario{% endtrans %}
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
var deleteModalEl = document.getElementById('deleteUserModal');
var deleteModal = deleteModalEl ? new bootstrap.Modal(deleteModalEl) : null;
var nameEl = document.getElementById('deleteUserName');
var emailEl = document.getElementById('deleteUserEmail');
var formEl = document.getElementById('deleteUserForm');
document.querySelectorAll('.btn-delete-user').forEach(function(btn) {
btn.addEventListener('click', function() {
var name = this.getAttribute('data-user-name');
var email = this.getAttribute('data-user-email');
var actionUrl = this.getAttribute('data-action-url');
if (nameEl) nameEl.textContent = name;
if (emailEl) emailEl.textContent = email;
if (formEl) formEl.action = actionUrl;
if (deleteModal) deleteModal.show();
});
});
});
</script>
{% endblock %}
+7 -34
View File
@@ -90,46 +90,19 @@
<li><a class="dropdown-item" href="{{ url_for('schedule.today_schedule') }}">
<i class="bi bi-calendar-today"></i> {% trans %}Today's Schedule{% endtrans %}
</a></li>
<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>
</li>
{% if current_user.is_authenticated and current_user.can_manage() %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-shield-lock"></i> {% trans %}Gestión{% endtrans %}
<i class="bi bi-shield-lock"></i> {% trans %}Management{% endtrans %}
</a>
<ul class="dropdown-menu shadow-sm" style="min-width: 260px;">
<!-- Espacios & Infraestructura -->
<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('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('classrooms.list_classrooms', view_mode='physical') }}"><i class="bi bi-door-open me-2 text-primary"></i>{% trans %}Aulas Presenciales{% endtrans %}</a></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.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 class="dropdown-menu shadow-sm">
<li><a class="dropdown-item" href="{{ url_for('admin.users_list') }}"><i class="bi bi-people me-2"></i>{% trans %}Users{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.roles_list') }}"><i class="bi bi-person-badge me-2"></i>{% trans %}Roles & Groups{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}"><i class="bi bi-buildings me-2"></i>{% trans %}Buildings{% endtrans %}</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('admin.google_sheets_import') }}"><i class="bi bi-file-earmark-spreadsheet me-2 text-success"></i>{% trans %}Import from Google Sheets{% endtrans %}</a></li>
</ul>
</li>
{% endif %}
+118 -156
View File
@@ -7,19 +7,11 @@
<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('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 class="h3">
<i class="bi bi-plus-circle text-primary"></i> {% trans %}Add Classroom{% endtrans %}
</h1>
</div>
<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>
</div>
</div>
@@ -33,78 +25,56 @@
<i class="bi bi-door-open text-primary me-2"></i> {% trans %}Classroom Information{% endtrans %}
</h5>
</div>
<div class="card-body p-4">
<div class="card-body">
<form method="POST" id="classroom-form" novalidate>
{{ form.hidden_tag() }}
<div class="row g-3 mb-3">
<!-- Selector de Edificio / Sede -->
<div class="col-md-6">
<label for="building_id" class="form-label fw-semibold">
<i class="bi bi-buildings text-primary me-1"></i>{% trans %}Edificio / Sede{% endtrans %} <span class="text-danger">*</span>
</label>
{{ form.building_id(class="form-select", id="building_id") }}
{% if form.building_id.errors %}
<div class="row">
<div class="col-md-4 mb-3">
{{ form.building.label(class="form-label") }}
{{ form.building(class="form-control", placeholder="A") }}
{% if form.building.errors %}
<div class="text-danger small mt-1">
{{ form.building_id.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] }}
{{ form.building.errors[0] }}
</div>
{% endif %}
</div>
<!-- Número / Nombre de Aula -->
<div class="col-md-3">
<label for="room_number" class="form-label fw-semibold">
<i class="bi bi-door-open text-info me-1"></i>{% trans %}Aula / Sala{% endtrans %} <span class="text-danger">*</span>
</label>
{{ form.room_number(class="form-control", id="room_number", placeholder="ej. Aula 304, Lab 1") }}
<div class="col-md-4 mb-3">
{{ form.room_number.label(class="form-label") }}
{{ form.room_number(class="form-control", placeholder="101") }}
{% if form.room_number.errors %}
<div class="text-danger small mt-1">
{{ form.room_number.errors[0] }}
</div>
{% endif %}
</div>
<div class="col-md-4 mb-3">
{{ form.floor.label(class="form-label") }}
{{ form.floor(class="form-control") }}
{% if form.floor.errors %}
<div class="text-danger small mt-1">
{{ form.floor.errors[0] }}
</div>
{% endif %}
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<div class="row">
<div class="col-md-6 mb-3">
<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 %}">
<i class="bi bi-infinity me-1"></i>{% trans %}Ilimitada (∞){% endtrans %}
</button>
</div>
<div class="input-group">
{{ form.capacity(class="form-control", id="capacity", placeholder="ej. 35 o ∞") }}
{{ form.capacity(class="form-control", id="capacity", placeholder="ej. 30 o ∞") }}
<span class="input-group-text bg-body-tertiary text-muted font-monospace"><i class="bi bi-people"></i></span>
</div>
<div class="form-text small text-muted mt-1">
<i class="bi bi-info-circle text-info me-1"></i>{% trans %}Para aulas virtuales o sin límite de aforo, ingrese <strong>∞</strong> o <strong>0</strong>.{% 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>
{% if form.capacity.errors %}
<div class="text-danger small mt-1">
@@ -113,17 +83,18 @@
{% endif %}
</div>
<div class="col-md-6 d-flex align-items-center pt-md-3">
<div class="form-check form-switch pt-2">
{{ form.is_active(class="form-check-input", id="is_active") }}
<label class="form-check-label fw-semibold" for="is_active">{% trans %}Aula Habilitada para Reservas{% endtrans %}</label>
<div class="col-md-6 mb-3">
<label class="form-label d-block">&nbsp;</label>
<div class="form-check pt-2">
{{ form.is_active(class="form-check-input") }}
{{ form.is_active.label(class="form-check-label") }}
</div>
</div>
</div>
<div class="mb-4">
{{ form.description.label(class="form-label fw-semibold") }}
{{ form.description(class="form-control", rows="3", placeholder=_("Opcional: Detalles adicionales de equipamiento, tomas de corriente, proyector...")) }}
{{ form.description.label(class="form-label") }}
{{ form.description(class="form-control", rows="3", placeholder=_("Optional: Add any additional details about this classroom...")) }}
{% if form.description.errors %}
<div class="text-danger small mt-1">
{{ form.description.errors[0] }}
@@ -131,39 +102,46 @@
{% endif %}
</div>
<!-- Preview Card -->
<div class="card bg-body-tertiary border-0 shadow-xs mb-4">
<div class="card-body py-3 px-4">
<h6 class="card-subtitle mb-2 text-muted small fw-bold">
<i class="bi bi-eye me-1"></i>{% trans %}Vista Previa de Ubicación{% endtrans %}
<!-- Preview Section -->
<div class="alert alert-info">
<h6 class="alert-heading">
<i class="bi bi-info-circle"></i> {% trans %}Preview{% endtrans %}
</h6>
<div class="row g-2">
<div class="col-md-4">
<small class="text-muted d-block">🏢 {% trans %}Edificio{% endtrans %}</small>
<strong id="preview-building" class="text-primary">-</strong>
<div id="classroom-preview">
<p class="mb-1">{% trans %}Fill in the form to see a preview of the classroom code:{% endtrans %}
<span class="fw-bold" id="preview-code">-</span>
</p>
<p class="mb-0">{% trans %}Full location:{% endtrans %}
<span class="fw-bold" id="preview-location">-</span>
</p>
</div>
<div class="col-md-4">
<small class="text-muted d-block">🪜 {% trans %}Piso{% endtrans %}</small>
<strong id="preview-floor" class="text-secondary">-</strong>
</div>
<div class="col-md-4">
<small class="text-muted d-block">🚪 {% trans %}Aula / Código{% endtrans %}</small>
<strong id="preview-room" class="text-success">-</strong>
<div class="d-flex justify-content-between">
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle"></i> {% trans %}Cancel{% endtrans %}
</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-save me-1"></i> {{ form.submit.label.text }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Buttons -->
<div class="d-flex justify-content-between">
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
{% trans %}Cancelar{% endtrans %}
</a>
<button type="submit" class="btn btn-primary px-4">
<i class="bi bi-check-circle me-1"></i>{% trans %}Crear Aula{% endtrans %}
</button>
<!-- Resources Section -->
<div class="row mt-4 mb-4">
<div class="col-lg-8 mx-auto">
<div class="card border-0 shadow-sm">
<div class="card-header bg-body-tertiary border-0 py-3">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-tools text-warning me-2"></i> {% trans %}Resources (Optional){% endtrans %}
</h5>
</div>
</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>
@@ -173,84 +151,68 @@
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Map of existing floors per building
var buildingFloors = {{ building_floors|tojson }};
var buildingSelect = document.getElementById('building_id');
var newBuildingContainer = document.getElementById('newBuildingContainer');
var newBuildingNameInput = document.getElementById('new_building_name');
var floorInput = document.getElementById('floor');
var floorDatalist = document.getElementById('floorSuggestions');
var roomInput = document.getElementById('room_number');
var previewBuilding = document.getElementById('preview-building');
var previewFloor = document.getElementById('preview-floor');
var previewRoom = document.getElementById('preview-room');
function updateFloorsDatalist(bId) {
if (!floorDatalist) return;
floorDatalist.innerHTML = '';
var floors = buildingFloors[bId] || [];
floors.forEach(function(f) {
var opt = document.createElement('option');
opt.value = f;
opt.textContent = (f === '0' || f === 'PB') ? 'Planta Baja' : 'Piso ' + f;
floorDatalist.appendChild(opt);
});
}
$(document).ready(function() {
function updatePreview() {
var bText = '-';
if (buildingSelect) {
if (buildingSelect.value === '__new__') {
bText = newBuildingNameInput.value.trim() || '{{ _("Nuevo Edificio") }}';
} else if (buildingSelect.selectedIndex >= 0) {
bText = buildingSelect.options[buildingSelect.selectedIndex].text;
}
}
var fText = floorInput ? (floorInput.value.trim() || '-') : '-';
var rText = roomInput ? (roomInput.value.trim() || '-') : '-';
const building = $('#building').val();
const roomNumber = $('#room_number').val();
const floor = $('#floor').val();
const floorLabel = '{{ _("Floor") }}';
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();
if (building && roomNumber && floor) {
$('#preview-code').text(`${building}-${roomNumber}`);
$('#preview-location').text(`${building} - ${floorLabel} ${floor}`);
} else {
newBuildingContainer.style.display = 'none';
updateFloorsDatalist(this.value);
}
updatePreview();
});
// Initial call
if (buildingSelect.value === '__new__') {
newBuildingContainer.style.display = 'block';
} else if (buildingSelect.value) {
updateFloorsDatalist(buildingSelect.value);
$('#preview-code').text('-');
$('#preview-location').text('-');
}
}
if (newBuildingNameInput) newBuildingNameInput.addEventListener('input', updatePreview);
if (floorInput) floorInput.addEventListener('input', updatePreview);
if (roomInput) roomInput.addEventListener('input', updatePreview);
// Update preview when inputs change
$('#building, #room_number, #floor').on('input', updatePreview);
// Initialize preview
updatePreview();
// Infinity button
var btnInf = document.getElementById('btnInfinityAdd');
var capInput = document.getElementById('capacity');
if (btnInf && capInput) {
btnInf.addEventListener('click', function() {
capInput.value = '∞';
capInput.focus();
});
// Form validation
$('#classroom-form').on('submit', function(e) {
let isValid = true;
if (!$('#building').val().trim()) {
$('#building').addClass('is-invalid');
isValid = false;
} else {
$('#building').removeClass('is-invalid');
}
if (!$('#room_number').val().trim()) {
$('#room_number').addClass('is-invalid');
isValid = false;
} else {
$('#room_number').removeClass('is-invalid');
}
var floorVal = $('#floor').val();
if (floorVal === '' || isNaN(floorVal)) {
$('#floor').addClass('is-invalid');
isValid = false;
} else {
$('#floor').removeClass('is-invalid');
}
var capVal = $('#capacity').val() ? $('#capacity').val().trim() : '';
if (!capVal || (capVal !== '∞' && capVal !== '0' && isNaN(capVal))) {
$('#capacity').addClass('is-invalid');
isValid = false;
} else {
$('#capacity').removeClass('is-invalid');
}
return isValid;
});
$('#btnInfinityAdd').on('click', function() {
$('#capacity').val('∞').focus();
});
});
</script>
{% endblock %}
+65 -150
View File
@@ -1,36 +1,32 @@
{% 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 %}
<div class="container-fluid pt-5 mt-4">
<!-- Header -->
<div class="row mb-4 align-items-center">
<div class="col-md-7">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1 small">
<li class="breadcrumb-item"><a href="{{ url_for('classrooms.list_classrooms') }}">{% trans %}Classrooms{% endtrans %}</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}">{{ classroom.code_display }}</a></li>
<li class="breadcrumb-item active">{% trans %}Edit{% endtrans %}</li>
</ol>
</nav>
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-1">
<i class="bi bi-pencil-square text-primary me-2"></i>{% trans %}Edit Classroom{% endtrans %}
</h1>
<p class="text-muted small mb-0">{{ classroom.location_display }}</p>
<p class="text-muted small mb-0">{{ classroom.building }} — {{ classroom.code }}</p>
</div>
<div class="col-md-5 text-md-end mt-3 mt-md-0">
<a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}" class="btn btn-outline-secondary me-2">
<i class="bi bi-eye me-1"></i>{% trans %}Ver Detalle{% endtrans %}
<div class="d-flex gap-2">
<a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}" class="btn btn-outline-primary">
<i class="bi bi-eye me-1"></i>{% trans %}Ver Ficha{% endtrans %}
</a>
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>{% trans %}Volver al Listado{% endtrans %}
<i class="bi bi-arrow-left me-1"></i>{% trans %}Back to Classrooms{% endtrans %}
</a>
</div>
</div>
</div>
</div>
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="row">
<div class="col-lg-8 mx-auto">
<div class="card border-0 shadow-sm">
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 h6 fw-bold">
@@ -42,56 +38,34 @@
<form method="POST" id="classroom-form" novalidate>
{{ form.hidden_tag() }}
<div class="row g-3 mb-3">
<!-- Edificio -->
<div class="col-md-6">
<label for="building_id" class="form-label fw-semibold">
<i class="bi bi-buildings text-primary me-1"></i>{% trans %}Edificio / Sede{% endtrans %} <span class="text-danger">*</span>
</label>
{{ form.building_id(class="form-select", id="building_id") }}
{% if form.building_id.errors %}
<div class="text-danger small mt-1">{{ form.building_id.errors[0] }}</div>
{% endif %}
<div id="newBuildingContainer" class="mt-2 p-2 rounded bg-body-tertiary border border-success-subtle" style="display: none;">
<label for="new_building_name" class="form-label small fw-bold text-success mb-1">
<i class="bi bi-plus-circle me-1"></i>{% trans %}Nombre del Nuevo Edificio{% endtrans %}:
</label>
{{ form.new_building_name(class="form-control form-control-sm", id="new_building_name", placeholder="ej. Pabellón III") }}
</div>
</div>
<!-- Piso -->
<div class="col-md-3">
<label for="floor" class="form-label fw-semibold">
<i class="bi bi-layers text-secondary me-1"></i>{% trans %}Piso / Nivel{% endtrans %} <span class="text-danger">*</span>
</label>
{{ form.floor(class="form-control", id="floor", list="floorSuggestions", placeholder="ej. PB, 1, 2, 4°P") }}
<datalist id="floorSuggestions">
<!-- sugerencias dinámicas -->
</datalist>
<div class="form-text small text-muted">{% trans %}Sugerido o nuevo{% endtrans %}</div>
{% if form.floor.errors %}
<div class="text-danger small mt-1">{{ form.floor.errors[0] }}</div>
<div class="row g-3">
<div class="col-md-4">
{{ form.building.label(class="form-label fw-semibold") }}
{{ form.building(class="form-control" + (" is-invalid" if form.building.errors else ""), placeholder="Edificio Central") }}
{% if form.building.errors %}
<div class="invalid-feedback">{{ form.building.errors[0] }}</div>
{% endif %}
</div>
<!-- Aula -->
<div class="col-md-3">
<label for="room_number" class="form-label fw-semibold">
<i class="bi bi-door-open text-info me-1"></i>{% trans %}Aula / Sala{% endtrans %} <span class="text-danger">*</span>
</label>
{{ form.room_number(class="form-control", id="room_number", placeholder="ej. Aula 304, Lab 1") }}
<div class="col-md-4">
{{ form.room_number.label(class="form-label fw-semibold") }}
{{ form.room_number(class="form-control" + (" is-invalid" if form.room_number.errors else ""), placeholder="402") }}
{% if form.room_number.errors %}
<div class="text-danger small mt-1">{{ form.room_number.errors[0] }}</div>
<div class="invalid-feedback">{{ form.room_number.errors[0] }}</div>
{% endif %}
</div>
<div class="col-md-4">
{{ form.floor.label(class="form-label fw-semibold") }}
{{ form.floor(class="form-control" + (" is-invalid" if form.floor.errors else "")) }}
{% if form.floor.errors %}
<div class="invalid-feedback">{{ form.floor.errors[0] }}</div>
{% endif %}
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<div class="d-flex justify-content-between align-items-center mb-1">
<label for="capacityInput" class="form-label fw-semibold mb-0">{% trans %}Capacidad de Alumnos{% endtrans %} <span class="text-danger">*</span></label>
{{ form.capacity.label(class="form-label fw-semibold mb-0") }}
<button type="button" class="btn btn-sm btn-outline-primary py-0 px-2" id="btnInfinity" style="font-size: 0.8rem;" title="{% trans %}Asignar capacidad ilimitada{% endtrans %}">
<i class="bi bi-infinity me-1"></i>{% trans %}Ilimitada (∞){% endtrans %}
</button>
@@ -101,55 +75,43 @@
<span class="input-group-text bg-body-tertiary text-muted font-monospace"><i class="bi bi-people"></i></span>
</div>
<div class="form-text small text-muted mt-1">
<i class="bi bi-info-circle text-info me-1"></i>{% trans %}Para aulas virtuales o eventos sin aforo restringido, ingrese <strong>∞</strong> o <strong>0</strong>.{% endtrans %}
<i class="bi bi-info-circle text-info me-1"></i>{% trans %}Para aulas virtuales o eventos sin aforo restringido, ingrese <strong>∞</strong> o <strong>0</strong> (Capacidad ilimitada).{% endtrans %}
</div>
{% if form.capacity.errors %}
<div class="invalid-feedback d-block">{{ form.capacity.errors[0] }}</div>
{% endif %}
</div>
<div class="col-md-6 d-flex align-items-center pt-md-3">
<div class="col-md-6 d-flex align-items-center pt-md-4">
<div class="form-check form-switch pt-2">
{{ form.is_active(class="form-check-input", id="is_active") }}
<label class="form-check-label fw-semibold" for="is_active">
{% trans %}Aula Habilitada para Reservas{% endtrans %}
</label>
</div>
{{ form.is_active(class="form-check-input", role="switch") }}
{{ form.is_active.label(class="form-check-label fw-semibold") }}
</div>
</div>
<div class="mb-4">
<div class="col-12">
{{ form.description.label(class="form-label fw-semibold") }}
{{ form.description(class="form-control" + (" is-invalid" if form.description.errors else ""), rows="3", placeholder=_("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 %}
<div class="invalid-feedback">{{ form.description.errors[0] }}</div>
{% endif %}
</div>
</div>
<!-- Preview Card -->
<div class="card bg-body-tertiary border-0 shadow-xs mb-4">
<div class="card-body py-3 px-4">
<h6 class="card-subtitle mb-2 text-muted small fw-bold">
<i class="bi bi-eye me-1"></i>{% trans %}Vista Previa de Ubicación{% endtrans %}
<!-- Live Preview Card -->
<div class="alert alert-info border-0 mt-4 mb-0">
<h6 class="alert-heading fw-bold mb-2">
<i class="bi bi-info-circle me-1"></i>{% trans %}Vista Previa de Identificador{% endtrans %}
</h6>
<div class="row g-2">
<div class="col-md-4">
<small class="text-muted d-block">🏢 {% trans %}Edificio{% endtrans %}</small>
<strong id="preview-building" class="text-primary">{{ classroom.building_name }}</strong>
</div>
<div class="col-md-4">
<small class="text-muted d-block">🪜 {% trans %}Piso{% endtrans %}</small>
<strong id="preview-floor" class="text-secondary">{{ classroom.floor_display }}</strong>
</div>
<div class="col-md-4">
<small class="text-muted d-block">🚪 {% trans %}Aula / Código{% endtrans %}</small>
<strong id="preview-room" class="text-success">{{ classroom.code }}</strong>
</div>
</div>
<div id="classroom-preview">
<p class="mb-0">
{% trans %}Código de visualización{% endtrans %}:
<strong id="preview-code" class="text-primary font-monospace fs-6">{{ classroom.code_display }}</strong>
</p>
</div>
</div>
<div class="d-flex justify-content-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">
{% trans %}Cancelar{% endtrans %}
</a>
@@ -168,78 +130,31 @@
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
var buildingFloors = {{ building_floors|tojson }};
var buildingSelect = document.getElementById('building_id');
var newBuildingContainer = document.getElementById('newBuildingContainer');
var newBuildingNameInput = document.getElementById('new_building_name');
var floorInput = document.getElementById('floor');
var floorDatalist = document.getElementById('floorSuggestions');
var buildingInput = document.getElementById('building');
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);
});
}
var previewCode = document.getElementById('preview-code');
var capacityInput = document.getElementById('capacityInput');
var btnInfinity = document.getElementById('btnInfinity');
function updatePreview() {
var bText = '-';
if (buildingSelect) {
if (buildingSelect.value === '__new__') {
bText = newBuildingNameInput.value.trim() || '{{ _("Nuevo Edificio") }}';
} else if (buildingSelect.selectedIndex >= 0) {
bText = buildingSelect.options[buildingSelect.selectedIndex].text;
}
}
var fText = floorInput ? (floorInput.value.trim() || '-') : '-';
var rText = roomInput ? (roomInput.value.trim() || '-') : '-';
if (previewBuilding) previewBuilding.textContent = bText;
if (previewFloor) previewFloor.textContent = (fText === '0' || fText.toUpperCase() === 'PB') ? 'Planta Baja' : fText;
if (previewRoom) previewRoom.textContent = rText;
}
if (buildingSelect) {
buildingSelect.addEventListener('change', function() {
if (this.value === '__new__') {
newBuildingContainer.style.display = 'block';
if (newBuildingNameInput) newBuildingNameInput.focus();
var b = buildingInput ? buildingInput.value.trim() : '';
var r = roomInput ? roomInput.value.trim() : '';
if (b && r) {
previewCode.textContent = b + '-' + r;
} else if (r) {
previewCode.textContent = r;
} else {
newBuildingContainer.style.display = 'none';
updateFloorsDatalist(this.value);
}
updatePreview();
});
if (buildingSelect.value === '__new__') {
newBuildingContainer.style.display = 'block';
} else if (buildingSelect.value) {
updateFloorsDatalist(buildingSelect.value);
previewCode.textContent = '---';
}
}
if (newBuildingNameInput) newBuildingNameInput.addEventListener('input', updatePreview);
if (floorInput) floorInput.addEventListener('input', updatePreview);
if (buildingInput) buildingInput.addEventListener('input', updatePreview);
if (roomInput) roomInput.addEventListener('input', updatePreview);
updatePreview();
var btnInf = document.getElementById('btnInfinity');
var capInput = document.getElementById('capacityInput');
if (btnInf && capInput) {
btnInf.addEventListener('click', function() {
capInput.value = '∞';
capInput.focus();
if (btnInfinity && capacityInput) {
btnInfinity.addEventListener('click', function() {
capacityInput.value = '∞';
capacityInput.focus();
});
}
});
+94 -737
View File
@@ -1,250 +1,50 @@
{% extends "base.html" %}
{% block title %}{% trans %}Aulas y Espacios{% endtrans %} - Edu-Space Admin{% endblock %}
{% block extra_css %}
<style>
.custom-space-pills .nav-link {
font-weight: 600;
font-size: 0.9rem;
padding: 0.5rem 1.1rem;
border-radius: 0.5rem;
color: var(--bs-body-color);
background-color: var(--bs-tertiary-bg);
border: 1px solid var(--bs-border-color);
transition: all 0.15s ease;
}
.custom-space-pills .nav-link.active {
background-color: var(--bs-primary);
color: #fff !important;
border-color: var(--bs-primary);
box-shadow: 0 4px 12px rgba(180, 62, 142, 0.25);
}
.custom-space-pills .nav-link:hover:not(.active) {
background-color: var(--bs-secondary-bg);
transform: translateY(-1px);
}
.classroom-card, .virtual-room-card {
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
border-radius: 0.65rem;
}
.classroom-card:hover, .virtual-room-card:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.08) !important;
}
.virtual-banner {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.08) 0%, rgba(180, 62, 142, 0.08) 100%);
border: 1px solid rgba(99, 102, 241, 0.25) !important;
border-radius: 0.75rem;
}
[data-bs-theme="dark"] .virtual-banner {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.16) 0%, rgba(180, 62, 142, 0.16) 100%);
border: 1px solid rgba(99, 102, 241, 0.35) !important;
}
.virtual-stat-box {
background-color: var(--bs-body-bg);
border: 1px solid var(--bs-border-color);
border-radius: 0.5rem;
padding: 0.6rem 0.85rem;
transition: transform 0.15s ease, border-color 0.15s ease;
text-decoration: none !important;
display: block;
}
.virtual-stat-box:hover {
transform: translateY(-2px);
border-color: var(--bs-primary) !important;
}
.metric-pill {
padding: 0.35rem 0.2rem;
border-radius: 0.45rem;
transition: all 0.15s ease;
cursor: pointer;
line-height: 1.2;
}
.metric-pill:hover {
transform: translateY(-1px);
filter: brightness(1.06);
}
.metric-label {
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
margin-bottom: 2px;
}
.metric-val {
font-size: 0.85rem;
font-weight: 700;
font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
/* Semantics light */
.metric-high {
background-color: #FEE2E2;
border-color: #FCA5A5 !important;
color: #991B1B !important;
}
.metric-medium {
background-color: #FEF3C7;
border-color: #FCD34D !important;
color: #92400E !important;
}
.metric-low {
background-color: #D1FAE5;
border-color: #6EE7B7 !important;
color: #065F46 !important;
}
.metric-zero {
background-color: rgba(148, 163, 184, 0.1);
border-color: rgba(148, 163, 184, 0.25) !important;
color: #64748B !important;
}
/* Semantics dark */
[data-bs-theme="dark"] .metric-high {
background-color: rgba(239, 68, 68, 0.18);
border-color: rgba(239, 68, 68, 0.4) !important;
color: #FCA5A5 !important;
}
[data-bs-theme="dark"] .metric-medium {
background-color: rgba(245, 158, 11, 0.18);
border-color: rgba(245, 158, 11, 0.4) !important;
color: #FCD34D !important;
}
[data-bs-theme="dark"] .metric-low {
background-color: rgba(16, 185, 129, 0.18);
border-color: rgba(16, 185, 129, 0.4) !important;
color: #6EE7B7 !important;
}
[data-bs-theme="dark"] .metric-zero {
background-color: rgba(148, 163, 184, 0.08);
border-color: rgba(148, 163, 184, 0.18) !important;
color: #94A3B8 !important;
}
</style>
{% endblock %}
{% block title %}{% trans %}Classrooms{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<!-- 1. Encabezado y Selector de Entorno (Presencial vs Virtual) -->
<div class="row mb-3">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
<div>
<h1 class="h3 mb-1">
<i class="bi bi-door-open text-primary me-2"></i>{% trans %}Aulas y Espacios de Aprendizaje{% endtrans %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3">
<i class="bi bi-door-open text-primary"></i> {% trans %}Classrooms{% endtrans %}
</h1>
<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>
{% if view_mode == 'virtual' %}
<a href="{{ url_for('schedule.add_reservation') }}?is_virtual=1" class="btn btn-success shadow-sm">
<i class="bi bi-camera-video me-1"></i>{% trans %}Nueva Clase Virtual a Demanda{% endtrans %}
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> {% trans %}Add Classroom{% endtrans %}
</a>
{% else %}
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary shadow-sm">
<i class="bi bi-plus-circle me-1"></i>{% trans %}Agregar Aula Física{% endtrans %}
</a>
{% endif %}
</div>
</div>
<!-- Pestañas de Selección de Modalidad -->
<div class="mt-3">
<ul class="nav nav-pills custom-space-pills gap-2">
<li class="nav-item">
<a class="nav-link {% if view_mode == 'physical' %}active{% endif %}"
href="{{ url_for('classrooms.list_classrooms', view_mode='physical', active_only='true' if active_only else 'false') }}">
<i class="bi bi-buildings me-2"></i>{% trans %}Aulas Presenciales{% endtrans %}
<span class="badge rounded-pill ms-2 {% if view_mode == 'physical' %}bg-light text-primary{% else %}bg-secondary-subtle text-secondary border{% endif %}">
{{ physical_count }} {% trans %}aulas{% endtrans %}
</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if view_mode == 'virtual' %}active{% endif %}"
href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}">
<i class="bi bi-globe2 me-2 text-info"></i>{% trans %}Campus Virtual & Aulas a Demanda{% endtrans %}
<span class="badge rounded-pill ms-2 {% if view_mode == 'virtual' %}bg-light text-primary{% else %}bg-primary-subtle text-primary border{% endif %}">
<i class="bi bi-infinity me-1"></i>{{ virtual_count }} {% trans %}salas activas{% endtrans %}
</span>
</a>
</li>
</ul>
</div>
</div>
</div>
<!-- ========================================================================= -->
<!-- VISTA 1: AULAS FÍSICAS (PRESENCIALES) -->
<!-- ========================================================================= -->
{% if view_mode == 'physical' %}
<!-- Filtros de Aulas Físicas -->
<div class="row mb-3">
<!-- Filters -->
<div class="row">
<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="physical">
<div class="col-md-3">
<div class="input-group input-group-sm">
<span class="input-group-text bg-body-tertiary"><i class="bi bi-search"></i></span>
<div class="card-body">
<form method="GET" class="row g-3">
<div class="col-md-4">
<input type="text" name="search" class="form-control"
placeholder="{% trans %}Buscar aula, descripción...{% endtrans %}"
placeholder="{% trans %}Search by building, room, description...{% endtrans %}"
value="{{ search }}">
</div>
</div>
<!-- Filtro por Edificio Físico -->
<div class="col-md-3">
<div class="input-group input-group-sm">
<span class="input-group-text bg-body-tertiary"><i class="bi bi-buildings text-primary"></i></span>
<select name="building_id" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">{% trans %}Todos los edificios presenciales{% endtrans %}</option>
{% for b in buildings %}
<option value="{{ b.id }}" {% if building_filter == (b.id|string) %}selected{% endif %}>{{ b.name }}</option>
{% endfor %}
</select>
</div>
</div>
<!-- Filtro por Piso Real -->
<div class="col-md-2">
<div class="input-group input-group-sm">
<span class="input-group-text bg-body-tertiary"><i class="bi bi-layers text-secondary"></i></span>
<select name="floor" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">{% trans %}Todos los pisos{% endtrans %}</option>
{% for f in floors %}
<option value="{{ f }}" {% if floor_filter == (f|string) %}selected{% endif %}>
{% if f == '0' or f == 'PB' %}{% trans %}Planta Baja{% endtrans %}{% else %}{% trans %}Piso{% endtrans %} {{ f }}{% endif %}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="col-md-2">
<div class="form-check pt-1">
<input class="form-check-input" type="checkbox" name="active_only" value="true"
id="activeOnly" {% if active_only %}checked{% endif %} onchange="this.form.submit()">
<label class="form-check-label small" for="activeOnly">
{% trans %}Solo activas{% endtrans %}
<div class="form-check pt-2">
<input class="form-check-input" type="checkbox" name="active_only"
id="activeOnly" {% if active_only %}checked{% endif %}>
<label class="form-check-label" for="activeOnly">
{% trans %}Show active classrooms only{% endtrans %}
</label>
</div>
</div>
<div class="col-md-2 text-md-end">
<button type="submit" class="btn btn-sm btn-primary me-1">
<i class="bi bi-filter"></i> {% trans %}Filtrar{% endtrans %}
<div class="col-md-3">
<button type="submit" class="btn btn-outline-primary">
<i class="bi bi-search"></i> {% trans %}Search{% endtrans %}
</button>
<a href="{{ url_for('classrooms.list_classrooms', view_mode='physical') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-x-circle"></i> {% trans %}Limpiar{% endtrans %}
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle"></i> {% trans %}Clear{% endtrans %}
</a>
</div>
</form>
@@ -253,109 +53,83 @@
</div>
</div>
<!-- Grilla de Aulas Físicas -->
<!-- Classrooms Grid -->
{% if classrooms and classrooms.items %}
<div class="row g-3 mt-1">
<div class="row mt-4">
{% 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="card classroom-card border shadow-sm h-100">
<div class="card-body p-3 d-flex flex-column justify-content-between">
<div class="col-lg-4 col-md-6 mb-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<!-- Encabezado con Código y Estado -->
<div class="d-flex justify-content-between align-items-start mb-2">
<div class="text-truncate me-2">
<h5 class="card-title mb-0 fw-bold text-primary text-truncate fs-6" title="{{ classroom.code }}">
<i class="bi bi-door-open me-1"></i>{{ classroom.code }}
<h5 class="card-title mb-1">
{{ classroom.code_display }}
</h5>
<p class="text-muted small mb-2">
<i class="bi bi-geo-alt"></i> {{ classroom.get_full_location() }}
</p>
</div>
<span class="badge rounded-pill {% if classroom.is_active %}bg-success-subtle text-success border border-success-subtle{% else %}bg-secondary-subtle text-secondary border{% endif %}" style="font-size: 0.68rem;">
{% if classroom.is_active %}{% trans %}Activa{% endtrans %}{% else %}{% trans %}Inactiva{% endtrans %}{% endif %}
<span class="badge {% if classroom.is_active %}bg-success{% else %}bg-secondary{% endif %}">
{% if classroom.is_active %}{% trans %}Active{% endtrans %}{% else %}{% trans %}Inactive{% endtrans %}{% endif %}
</span>
</div>
<!-- Ubicación: Edificio y Piso -->
<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-buildings me-1"></i>{{ classroom.building_name }}
</span>
<span class="badge bg-secondary-subtle text-secondary border" style="font-size: 0.7rem;">
<i class="bi bi-layers me-1"></i>{{ classroom.floor_display }}
</span>
<div class="row small">
<div class="col-6">
<span class="text-muted">{% trans %}Capacity{% endtrans %}:</span>
<strong>
{% if classroom.is_unlimited %}
<span class="badge bg-primary-subtle text-primary border"><i class="bi bi-infinity me-1"></i>∞</span>
{% else %}
{{ classroom.capacity }}
{% endif %}
</strong>
</div>
<div class="col-6">
<span class="text-muted">{% trans %}Building{% endtrans %}:</span>
<strong>{{ classroom.building }}</strong>
</div>
</div>
<!-- Capacidad y Recursos -->
<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;">
{% if classroom.description %}
<p class="card-text small text-muted mt-2">
{{ classroom.description[:100] }}{% if classroom.description|length > 100 %}...{% endif %}
</p>
{% endif %}
<!-- Resources indicator -->
<div class="mt-2">
<small class="text-muted">
<i class="bi bi-tools"></i>
{% if classroom.resources %}
{{ classroom.resources|length }} {% trans %}resources{% endtrans %}
{% else %}
{% trans %}No resources registered{% endtrans %}
{% endif %}
</small>
</div>
</div>
<div class="card-footer bg-body-tertiary border-0">
<div class="d-flex justify-content-between">
<div>
<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) }}"
class="btn btn-outline-primary py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}" style="font-size: 0.75rem;">
<i class="bi bi-eye me-1"></i>{% trans %}Ficha{% endtrans %}
class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> {% trans %}View{% endtrans %}
</a>
<a href="{{ url_for('classrooms.edit_classroom', id=classroom.id) }}"
class="btn btn-outline-secondary py-0 px-2" title="{% trans %}Editar Aula{% endtrans %}" style="font-size: 0.75rem;">
<i class="bi bi-pencil"></i>
class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pencil"></i> {% trans %}Edit{% endtrans %}
</a>
</div>
<form method="POST" action="{{ url_for('classrooms.toggle_classroom', id=classroom.id) }}" class="d-inline mb-0">
<button type="submit" class="btn btn-sm py-0 px-2 {% if classroom.is_active %}btn-outline-warning{% else %}btn-outline-success{% endif %}"
title="{% if classroom.is_active %}{% trans %}Desactivar{% endtrans %}{% else %}{% trans %}Activar{% endtrans %}{% endif %}" style="font-size: 0.75rem;">
<form method="POST" action="{{ url_for('classrooms.toggle_classroom', id=classroom.id) }}"
class="d-inline">
<button type="submit" class="btn btn-sm
{% if classroom.is_active %}btn-outline-warning{% else %}btn-outline-success{% endif %}">
{% if classroom.is_active %}
<i class="bi bi-pause"></i>
<i class="bi bi-pause-circle"></i> {% trans %}Deactivate{% endtrans %}
{% else %}
<i class="bi bi-play"></i>
<i class="bi bi-play-circle"></i> {% trans %}Activate{% endtrans %}
{% endif %}
</button>
</form>
@@ -366,14 +140,14 @@
{% endfor %}
</div>
<!-- Paginación Aulas Físicas -->
<!-- Pagination -->
{% if classrooms.pages > 1 %}
<nav aria-label="Classroom pagination" class="mt-4">
<ul class="pagination pagination-sm justify-content-center">
<nav aria-label="Classroom pagination">
<ul class="pagination justify-content-center">
{% if classrooms.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', view_mode='physical', page=classrooms.prev_num, search=search, active_only='true' if active_only else 'false', building_id=building_filter, floor=floor_filter) }}">
{% trans %}Anterior{% endtrans %}
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=classrooms.prev_num, search=search, active_only=active_only) }}">
{% trans %}Previous{% endtrans %}
</a>
</li>
{% endif %}
@@ -382,7 +156,7 @@
{% if page_num %}
{% if page_num != classrooms.page %}
<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 }}
</a>
</li>
@@ -400,8 +174,8 @@
{% if classrooms.has_next %}
<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) }}">
{% trans %}Siguiente{% endtrans %}
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=classrooms.next_num, search=search, active_only=active_only) }}">
{% trans %}Next{% endtrans %}
</a>
</li>
{% endif %}
@@ -414,437 +188,20 @@
<div class="col-12">
<div class="text-center py-5">
<i class="bi bi-door-open text-muted" style="font-size: 4rem;"></i>
<h4 class="mt-3 text-muted">{% trans %}No se encontraron aulas físicas{% endtrans %}</h4>
<h4 class="mt-3 text-muted">{% trans %}No classrooms found{% endtrans %}</h4>
<p class="text-muted">
{% if search %}
{% trans %}Ningún aula coincide con los criterios de búsqueda.{% endtrans %}
{% trans %}No classrooms match your search criteria.{% endtrans %}
{% else %}
{% trans %}No hay aulas físicas registradas en esta sede.{% endtrans %}
{% trans %}No classrooms have been registered yet.{% endtrans %}
{% endif %}
</p>
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> {% trans %}Agregar Primera Aula{% endtrans %}
<i class="bi bi-plus-circle"></i> {% trans %}Add First Classroom{% endtrans %}
</a>
</div>
</div>
</div>
{% 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>
{% endblock %}
+2 -10
View File
@@ -108,7 +108,7 @@
<table class="table table-hover align-middle mb-0">
<thead class="table-light small text-uppercase">
<tr>
<th class="ps-3">{% trans %}Fecha y Horario{% endtrans %}</th>
<th class="ps-3">{% trans %}Horario{% endtrans %}</th>
<th>{% trans %}Materia / Motivo{% endtrans %}</th>
<th>{% trans %}Comisión{% endtrans %}</th>
<th>{% trans %}Asistentes{% endtrans %}</th>
@@ -119,10 +119,7 @@
{% for res in upcoming_reservations %}
<tr>
<td class="ps-3">
<div class="fw-bold font-monospace text-primary fs-6 mb-1">
<i class="bi bi-calendar-event text-primary me-1"></i>{{ res.start_time.strftime('%d/%m/%Y') }}
</div>
<div class="small font-monospace text-body-secondary">
<div class="fw-bold font-monospace text-primary fs-6">
<i class="bi bi-clock text-warning me-1"></i>{{ res.start_time.strftime('%H:%M') }} - {{ res.end_time.strftime('%H:%M') }} hs
</div>
</td>
@@ -139,11 +136,6 @@
<span class="small">{{ res.expected_attendees }} / {{ classroom.capacity }}</span>
</td>
<td class="text-end pe-3">
{% if res.effective_virtual_link %}
<a href="{{ res.effective_virtual_link }}" target="_blank" class="btn btn-outline-success btn-sm py-0 px-2 me-1" title="{% trans %}Unirse a Clase Virtual (Zoom / Meet){% endtrans %}">
<i class="bi bi-camera-video"></i>
</a>
{% endif %}
<a href="{{ url_for('schedule.view_reservation', id=res.id) }}" class="btn btn-outline-secondary btn-sm py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}">
<i class="bi bi-eye"></i>
</a>
-5
View File
@@ -141,11 +141,6 @@
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-info text-start">
<i class="bi bi-calendar-day me-2"></i>{% trans %}Cartelera del Día{% endtrans %}
</a>
{% 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">
<i class="bi bi-door-open me-2"></i>{% trans %}Listado de Aulas{% endtrans %}
</a>
+130 -422
View File
@@ -7,125 +7,80 @@
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-0">
<i class="bi bi-calendar-plus text-primary me-2"></i>{% trans %}New Reservation{% endtrans %}
<h1 class="h3">
<i class="bi bi-calendar-plus text-success"></i> {% trans %}New Reservation{% endtrans %}
</h1>
</div>
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>{% trans %}Back to Reservations{% endtrans %}
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> {% trans %}Back to Reservations{% endtrans %}
</a>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-10 mx-auto">
<div class="col-lg-8 mx-auto">
<div class="card border-0 shadow-sm">
<div class="card-header bg-body-tertiary border-0 py-3">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-calendar3 text-primary me-2"></i>{% trans %}Detalles de la Reserva y Espacio Físico{% endtrans %}
<div class="card-header border-0 py-3">
<h5 class="mb-0">
<i class="bi bi-calendar3 text-success"></i> {% trans %}Reservation Details{% endtrans %}
</h5>
</div>
<div class="card-body p-4">
<div class="card-body">
<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 %}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") }}
<div class="row">
<div class="col-md-6 mb-3">
{{ form.classroom_id.label(class="form-label") }}
{{ form.classroom_id(class="form-select", id="classroom-select") }}
{% if form.classroom_id.errors %}
<div class="text-danger small mt-1">
{{ form.classroom_id.errors[0] }}
</div>
{% endif %}
<div class="form-text small text-muted">{% trans %}Capacidad y disponibilidad{% endtrans %}</div>
</div>
</div>
</div>
<small class="text-muted">{% trans %}Select a classroom for your reservation{% endtrans %}</small>
</div>
<!-- 2. Comisión y Materia -->
<div class="row g-3 mb-3">
<div class="col-12">
{{ form.commission_id.label(class="form-label fw-semibold") }} <span class="text-danger">*</span>
<div class="col-md-6 mb-3">
{{ form.commission_id.label(class="form-label") }}
{{ form.commission_id(class="form-select", id="commission-select") }}
{% if form.commission_id.errors %}
<div class="text-danger small mt-1">
{{ form.commission_id.errors[0] }}
</div>
{% endif %}
<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>
<!-- 3. Horario de Inicio y Fin -->
<div class="row g-3 mb-3">
<div class="col-md-6">
{{ form.start_time.label(class="form-label fw-semibold") }} <span class="text-danger">*</span>
<div class="row">
<div class="col-md-6 mb-3">
{{ form.start_time.label(class="form-label") }}
{{ form.start_time(class="form-control", id="start-time") }}
{% if form.start_time.errors %}
<div class="text-danger small mt-1">
{{ form.start_time.errors[0] }}
</div>
{% endif %}
<small class="text-muted">{% trans %}¿Cuándo comenzará la clase?{% endtrans %}</small>
<small class="text-muted">{% trans %}When will the class start?{% endtrans %}</small>
</div>
<div class="col-md-6">
{{ form.end_time.label(class="form-label fw-semibold") }} <span class="text-danger">*</span>
<div class="col-md-6 mb-3">
{{ form.end_time.label(class="form-label") }}
{{ form.end_time(class="form-control", id="end-time") }}
{% if form.end_time.errors %}
<div class="text-danger small mt-1">
{{ form.end_time.errors[0] }}
</div>
{% endif %}
<small class="text-muted">{% trans %}¿Cuándo finalizará la clase?{% endtrans %}</small>
<small class="text-muted">{% trans %}When will the class end?{% endtrans %}</small>
</div>
</div>
<!-- 4. Propósito y Asistentes -->
<div class="row g-3 mb-3">
<div class="col-md-8">
{{ form.purpose.label(class="form-label fw-semibold") }} <span class="text-danger">*</span>
{{ form.purpose(class="form-control", placeholder=_("Clase regular, examen, sesión de estudio, etc.")) }}
<div class="row">
<div class="col-md-8 mb-3">
{{ form.purpose.label(class="form-label") }}
{{ form.purpose(class="form-control", placeholder=_("Regular class, exam, study session, etc.")) }}
{% if form.purpose.errors %}
<div class="text-danger small mt-1">
{{ form.purpose.errors[0] }}
@@ -133,22 +88,21 @@
{% endif %}
</div>
<div class="col-md-4">
{{ form.expected_attendees.label(class="form-label fw-semibold") }} <span class="text-danger">*</span>
{{ form.expected_attendees(class="form-control", id="expected_attendees", placeholder="30") }}
<div class="col-md-4 mb-3">
{{ form.expected_attendees.label(class="form-label") }}
{{ form.expected_attendees(class="form-control", placeholder="30") }}
{% if form.expected_attendees.errors %}
<div class="text-danger small mt-1">
{{ form.expected_attendees.errors[0] }}
</div>
{% endif %}
<small class="text-muted">{% trans %}Cantidad de asistentes esperados{% endtrans %}</small>
<small class="text-muted">{% trans %}Number of expected attendees{% endtrans %}</small>
</div>
</div>
<!-- 5. Notas -->
<div class="mb-3">
{{ form.notes.label(class="form-label fw-semibold") }}
{{ form.notes(class="form-control", rows="2", placeholder=_("Cualquier nota adicional o requerimientos especiales...")) }}
<div class="mb-4">
{{ form.notes.label(class="form-label") }}
{{ form.notes(class="form-control", rows="3", placeholder=_("Any additional notes or special requirements...")) }}
{% if form.notes.errors %}
<div class="text-danger small mt-1">
{{ form.notes.errors[0] }}
@@ -156,126 +110,84 @@
{% endif %}
</div>
<!-- 6. Enlace de Aula Virtual (Zoom, Meet, Teams, Moodle) -->
<div class="mb-4" id="virtual-link-container">
<label for="virtual_link" class="form-label fw-semibold">
<i class="bi bi-camera-video text-primary me-1"></i>{% trans %}Enlace de Aula Virtual (Zoom, Google Meet, Teams, Moodle){% 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 %}
<!-- Summary Section -->
<div class="alert alert-info" id="reservation-summary">
<h6 class="alert-heading">
<i class="bi bi-info-circle"></i> {% trans %}Reservation Summary{% endtrans %}
</h6>
<!-- Badges desglosados de Ubicación -->
<div class="row g-2 mb-3">
<div class="col-md-4">
<div class="p-2 rounded bg-body border">
<small class="text-muted d-block">🏢 {% trans %}Edificio / Sede{% endtrans %}</small>
<strong id="summary-building" class="text-primary">-</strong>
</div>
</div>
<div class="col-md-4">
<div class="p-2 rounded bg-body border">
<small class="text-muted d-block">🪜 {% trans %}Piso / Nivel{% endtrans %}</small>
<strong id="summary-floor" class="text-secondary">-</strong>
</div>
</div>
<div class="col-md-4">
<div class="p-2 rounded bg-body border">
<small class="text-muted d-block">🚪 {% trans %}Aula Seleccionada{% endtrans %}</small>
<strong id="summary-classroom" class="text-success">{% trans %}No seleccionada{% endtrans %}</strong>
</div>
</div>
</div>
<!-- Fila de Enlace Virtual -->
<div class="row g-2 mb-3" id="summary-virtual-row">
<div class="col-12">
<div class="p-2 rounded bg-body border d-flex align-items-center justify-content-between">
<div class="text-truncate me-2">
<small class="text-muted d-block">🎥 {% trans %}Enlace Virtual (Videoconferencia){% endtrans %}</small>
<span id="summary-virtual-link" class="small font-monospace text-primary text-break">{% trans %}Sin enlace configurado{% endtrans %}</span>
</div>
<span class="badge bg-success-subtle text-success border" id="summary-virtual-badge" style="display:none;">
<i class="bi bi-camera-video me-1"></i>Virtual
</span>
</div>
</div>
</div>
<!-- Duración y Comisión -->
<div class="row g-2 small">
<div class="col-md-4">
<span class="text-muted">⏱️ {% trans %}Duración:{% endtrans %}</span>
<strong id="summary-duration" class="text-body ms-1">{% trans %}No calculada{% endtrans %}</strong>
</div>
<div class="col-md-8">
<span class="text-muted">📚 {% trans %}Comisión:{% endtrans %}</span>
<strong id="summary-commission" class="text-body ms-1">{% trans %}No seleccionada{% endtrans %}</strong>
</div>
</div>
<div id="summary-content">
<p class="mb-2">
<strong>{% trans %}Selected Classroom:{% endtrans %}</strong>
<span id="summary-classroom">{% trans %}Not selected{% endtrans %}</span>
</p>
<p class="mb-2">
<strong>{% trans %}Duration:{% endtrans %}</strong>
<span id="summary-duration">{% trans %}Not calculated{% endtrans %}</span>
</p>
<p class="mb-0">
<strong>{% trans %}Commission:{% endtrans %}</strong>
<span id="summary-commission">{% trans %}Not selected{% endtrans %}</span>
</p>
</div>
</div>
<!-- Botones -->
<div class="d-flex justify-content-between">
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle me-1"></i>{% trans %}Cancelar{% endtrans %}
<i class="bi bi-x-circle"></i> {% trans %}Cancel{% endtrans %}
</a>
<button type="submit" class="btn btn-success px-4">
<i class="bi bi-calendar-check me-1"></i>{% trans %}Realizar Reserva{% endtrans %}
</button>
{{ form.submit(class="btn btn-success") }}
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Instructions Card -->
<div class="row mt-4">
<div class="col-lg-8 mx-auto">
<div class="card border-0 shadow-sm">
<div class="card-header border-0 py-3">
<h5 class="mb-0">
<i class="bi bi-question-circle text-info"></i> {% trans %}Reservation Guidelines{% endtrans %}
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6 class="text-primary">📅 {% trans %}Booking Rules{% endtrans %}</h6>
<ul class="small">
<li>{% trans %}Reservations can be made up to 3 months in advance{% endtrans %}</li>
<li>{% trans %}Cannot book in the past{% endtrans %}</li>
<li>{% trans %}Maximum duration: 8 hours per reservation{% endtrans %}</li>
<li>{% trans %}Cancellations must be 24 hours before start time{% endtrans %}</li>
</ul>
</div>
<div class="col-md-6">
<h6 class="text-warning">🚨 {% trans %}Important Notes{% endtrans %}</h6>
<ul class="small">
<li>{% trans %}Check classroom capacity before booking{% endtrans %}</li>
<li>{% trans %}Pending reservations require approval{% endtrans %}</li>
<li>{% trans %}Time slots must not overlap{% endtrans %}</li>
<li>{% trans %}System automatically detects conflicts{% endtrans %}</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script type="application/json" id="classroom-data">
{
{% for 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 }}
{% for classroom in classroom_list %}
"{{ classroom.id }}": {
"capacity": {{ classroom.capacity }},
"code_display": {{ classroom.code_display|tojson }},
"name": {{ classroom.name|tojson }}
}{% if not loop.last %},{% endif %}
{% endfor %}
}
@@ -283,228 +195,57 @@
<script type="application/json" id="i18n-strings">
{
"not_selected": "{% trans %}No seleccionada{% endtrans %}",
"not_calculated": "{% trans %}No calculada{% endtrans %}",
"invalid_time": "{% trans %}Rango horario inválido{% endtrans %}",
"hour": "{% trans %}hora{% endtrans %}",
"hours": "{% trans %}horas{% endtrans %}",
"minute": "{% trans %}minuto{% endtrans %}",
"minutes": "{% trans %}minutos{% endtrans %}",
"capacity": "{% trans %}Capacidad{% endtrans %}",
"capacity_exceeded": "{% trans %}Los asistentes esperados superan la capacidad del aula{% endtrans %}",
"no_virtual_link": "{% trans %}Sin enlace configurado (Clase presencial){% endtrans %}"
"not_selected": "{% trans %}Not selected{% endtrans %}",
"not_calculated": "{% trans %}Not calculated{% endtrans %}",
"invalid_time": "{% trans %}Invalid time range{% endtrans %}",
"hour": "{% trans %}hour{% endtrans %}",
"hours": "{% trans %}hours{% endtrans %}",
"minute": "{% trans %}minute{% endtrans %}",
"minutes": "{% trans %}minutes{% endtrans %}",
"capacity": "{% trans %}Capacity{% endtrans %}",
"capacity_exceeded": "{% trans %}Expected attendees exceeds classroom capacity{% endtrans %}"
}
</script>
<script>
$(document).ready(function() {
// Classroom data for capacity checking
let classroomData = {};
try {
const rawData = document.getElementById('classroom-data')?.textContent;
if (rawData) classroomData = JSON.parse(rawData);
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);
}
// I18n strings
let i18n = {};
try {
const rawI18n = document.getElementById('i18n-strings')?.textContent;
if (rawI18n) i18n = JSON.parse(rawI18n);
if (rawI18n) {
i18n = JSON.parse(rawI18n);
}
} catch (err) {
console.error('Error parsing i18n:', err);
}
const $building = $('#cascade-building');
const $floor = $('#cascade-floor');
const $classroom = $('#classroom-select');
const $floorHelp = $('#floorHelpText');
// Guardar todas las opciones originales de aulas
const originalClassroomOptions = [];
$classroom.find('option').each(function() {
originalClassroomOptions.push({
value: $(this).val(),
text: $(this).text()
});
});
// 1. Manejo del cambio de Edificio
$building.on('change', function() {
const selectedBuildingId = $(this).val();
const isVirtual = $(this).find(':selected').data('virtual') === true || $(this).find(':selected').data('virtual') === 'true';
$floor.empty().append('<option value="">-- ' + (isVirtual ? 'No aplica (Campus Virtual)' : 'Selecciona Piso') + ' --</option>');
$classroom.empty().append('<option value="">-- Selecciona Aula --</option>');
if (!selectedBuildingId) {
$floor.prop('disabled', true);
$classroom.prop('disabled', false);
// Restaurar todas
originalClassroomOptions.forEach(opt => {
if (opt.value) $classroom.append($('<option>', { value: opt.value, text: opt.text }));
});
updateSummary();
return;
}
if (isVirtual) {
$floor.prop('disabled', true);
$floorHelp.text('Modalidad 100% remota sin piso asignado');
// Filtrar solo aulas virtuales
let virtualCount = 0;
let lastVirtualId = null;
Object.values(classroomData).forEach(c => {
if (c.is_virtual || c.building_id == selectedBuildingId) {
$classroom.append($('<option>', {
value: c.id,
text: `${c.code_display} (Cap: ${c.capacity})`
}));
lastVirtualId = c.id;
virtualCount++;
}
});
if (virtualCount === 1 && lastVirtualId) {
$classroom.val(lastVirtualId);
}
updateSummary();
return;
}
// Edificio Físico
$floor.prop('disabled', false);
$floorHelp.text('Filtra por planta o nivel');
// Extraer pisos únicos de este edificio
const buildingFloors = [];
Object.values(classroomData).forEach(c => {
if (c.building_id == selectedBuildingId && c.floor) {
if (!buildingFloors.some(f => f.floor === c.floor)) {
buildingFloors.push({
floor: c.floor,
floor_display: c.floor_display
});
}
}
});
// Ordenar pisos: PB primero, luego numérico
buildingFloors.sort((a, b) => {
if (a.floor === '0' || a.floor.toUpperCase() === 'PB') return -1;
if (b.floor === '0' || b.floor.toUpperCase() === 'PB') return 1;
return a.floor.localeCompare(b.floor, undefined, { numeric: true });
});
buildingFloors.forEach(f => {
$floor.append($('<option>', {
value: f.floor,
text: f.floor_display
}));
});
// Poblar aulas de este edificio inicialmente
Object.values(classroomData).forEach(c => {
if (c.building_id == selectedBuildingId) {
$classroom.append($('<option>', {
value: c.id,
text: `${c.code} · ${c.floor_display} (Cap: ${c.capacity})`
}));
}
});
updateSummary();
});
// 2. Manejo del cambio de Piso
$floor.on('change', function() {
const selectedBuildingId = $building.val();
const selectedFloor = $(this).val();
$classroom.empty().append('<option value="">-- Selecciona Aula --</option>');
let count = 0;
let lastId = null;
Object.values(classroomData).forEach(c => {
const matchesBuilding = !selectedBuildingId || c.building_id == selectedBuildingId;
const matchesFloor = !selectedFloor || c.floor == selectedFloor;
if (matchesBuilding && matchesFloor) {
$classroom.append($('<option>', {
value: c.id,
text: `${c.code} (Cap: ${c.capacity})`
}));
lastId = c.id;
count++;
}
});
if (count === 1 && lastId) {
$classroom.val(lastId);
}
updateSummary();
});
// 3. Manejo del cambio de Aula directo
$classroom.on('change', function() {
const cId = $(this).val();
if (cId && classroomData[cId]) {
const c = classroomData[cId];
// Sincronizar selectores en cascada si no coinciden
if ($building.val() != c.building_id) {
$building.val(c.building_id);
// Si no es virtual, sincronizar piso
if (!c.is_virtual) {
$floor.prop('disabled', false);
$floor.val(c.floor);
}
} else if (!c.is_virtual && $floor.val() != c.floor) {
$floor.val(c.floor);
}
}
updateSummary();
});
// Inicializar cascada si ya hay un aula seleccionada (ej. post-error o query string)
const initialClassroomId = $classroom.val();
if (initialClassroomId && classroomData[initialClassroomId]) {
const c = classroomData[initialClassroomId];
$building.val(c.building_id).trigger('change');
if (!c.is_virtual) {
$floor.val(c.floor).trigger('change');
}
$classroom.val(initialClassroomId);
}
function updateSummary() {
const classroomId = $classroom.val();
const classroomId = $('#classroom-select').val();
const commissionId = $('#commission-select').val();
const startTime = $('#start-time').val();
const endTime = $('#end-time').val();
// Update classroom badges
// Update classroom summary
if (classroomId && classroomData[classroomId]) {
const c = classroomData[classroomId];
$('#summary-building').text(c.building_name);
$('#summary-floor').text(c.is_virtual ? 'Remoto (Virtual)' : c.floor_display);
$('#summary-classroom').text(`${c.code} (Cap: ${c.capacity})`);
const capLabel = i18n.capacity || 'Capacidad';
$('#summary-classroom').text(`${classroomData[classroomId].code_display} - ${classroomData[classroomId].name} (${capLabel}: ${classroomData[classroomId].capacity})`);
} else {
$('#summary-building').text('-');
$('#summary-floor').text('-');
$('#summary-classroom').text(i18n.not_selected || 'No seleccionada');
}
// Update commission summary and autofill virtual link if available
// Update commission summary
if (commissionId) {
const commissionText = $('#commission-select option:selected').text();
$('#summary-commission').text(commissionText);
@@ -512,21 +253,6 @@ $(document).ready(function() {
$('#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
if (startTime && endTime) {
const start = new Date(startTime);
@@ -559,28 +285,20 @@ $(document).ready(function() {
// Check capacity vs expected attendees
function checkCapacity() {
const classroomId = $classroom.val();
const classroomId = $('#classroom-select').val();
const attendees = parseInt($('#expected_attendees').val()) || 0;
if (classroomId && classroomData[classroomId] && attendees > 0) {
const c = classroomData[classroomId];
// No validar si es virtual o capacidad ilimitada
if (c.is_virtual || c.capacity === '∞' || c.capacity_num === 0) {
$('#expected_attendees').removeClass('is-invalid');
$('#expected_attendees').siblings('.invalid-feedback').remove();
return;
}
const capacity = classroomData[classroomId].capacity;
if (attendees > c.capacity_num) {
if (attendees > capacity) {
$('#expected_attendees').addClass('is-invalid');
const msg = i18n.capacity_exceeded || 'Los asistentes esperados superan la capacidad del aula';
if ($('#expected_attendees').siblings('.invalid-feedback').length === 0) {
$('#expected_attendees').after(`
<div class="invalid-feedback">
${msg} (${attendees} > ${c.capacity_num})
${msg} (${attendees} > ${capacity})
</div>
`);
}
} else {
$('#expected_attendees').removeClass('is-invalid');
$('#expected_attendees').siblings('.invalid-feedback').remove();
@@ -588,35 +306,23 @@ $(document).ready(function() {
}
}
$('#commission-select').on('change', function() {
const cId = $(this).val();
if (cId && commissionData[cId]) {
const comm = commissionData[cId];
if (comm.virtual_link && !$('#virtual_link').val().trim()) {
$('#virtual_link').val(comm.virtual_link);
}
}
updateSummary();
});
$('#virtual_link').on('input change', updateSummary);
$('#start-time, #end-time').on('change', updateSummary);
$('#expected_attendees').on('input', function() {
checkCapacity();
updateSummary();
});
// Update on change
$('#classroom-select, #commission-select, #start-time, #end-time, #expected_attendees').on('change', updateSummary);
$('#expected_attendees').on('input', checkCapacity);
// Initialize summary
updateSummary();
// Form validation
$('#reservation-form').on('submit', function(e) {
let isValid = true;
if (!$classroom.val()) {
$classroom.addClass('is-invalid');
// Check required fields
if (!$('#classroom-select').val()) {
$('#classroom-select').addClass('is-invalid');
isValid = false;
} else {
$classroom.removeClass('is-invalid');
$('#classroom-select').removeClass('is-invalid');
}
if (!$('#commission-select').val()) {
@@ -640,6 +346,7 @@ $(document).ready(function() {
$('#end-time').removeClass('is-invalid');
}
// Validate time range
const startTime = new Date($('#start-time').val());
const endTime = new Date($('#end-time').val());
@@ -648,6 +355,7 @@ $(document).ready(function() {
isValid = false;
}
// Check capacity
checkCapacity();
if ($('#expected_attendees').hasClass('is-invalid')) {
isValid = false;
+166 -654
View File
@@ -80,21 +80,8 @@
</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">
<!-- 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 -->
<div class="col-lg-3 col-md-4">
<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">
<option value="">{% trans %}Todos los pisos{% endtrans %}</option>
{% 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 %}
</select>
</div>
<!-- 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">
<input class="form-check-input" type="checkbox" role="switch" id="filterHideVirtual">
<label class="form-check-label fw-semibold small text-dark" for="filterHideVirtual">
<i class="bi bi-laptop me-1 text-primary"></i>{% trans %}Ocultar Aulas Virtuales{% endtrans %}
<span class="badge bg-secondary-subtle text-secondary border ms-1" style="font-size: 0.72rem;">{% trans %}Capacidad ilimitada{% endtrans %}</span>
</label>
</div>
</div>
<!-- Reset Filters Button -->
<div class="col-lg-2 col-md-12 text-md-end pt-md-3">
<button type="button" class="btn btn-outline-secondary btn-sm w-100 w-md-auto" id="btnResetFilters">
<i class="bi bi-x-circle me-1"></i>{% trans %}Limpiar{% endtrans %}
<div class="col-lg-3 col-md-3 text-md-end pt-md-3">
<button type="button" id="btnResetFilters" class="btn btn-outline-secondary btn-sm px-3" title="{% trans %}Restablecer filtros{% endtrans %}">
<i class="bi bi-arrow-counterclockwise me-1"></i>{% trans %}Limpiar Filtros{% endtrans %}
</button>
</div>
</div>
@@ -170,126 +158,68 @@
</div>
</div>
<!-- Modal Interactivo al Clic (Estilo Google Calendar Preview & Edición Rápida) -->
<div class="modal fade" id="eventPreviewModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" style="max-width: 490px;">
<div class="modal-content border-0 shadow-lg gc-modal-card">
<!-- Banner Artístico Superior con Botones Rápidos (Estilo Google Calendar) -->
<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>
<!-- Reservation Details Modal -->
<div class="modal fade" id="reservationModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content border-0 shadow-lg">
<div class="modal-header border-0 pb-0">
<div>
<div class="fw-semibold text-body" id="previewTimeRange">Lunes, 1 de septiembre · 17:30 a 21:30 hs</div>
<small class="text-muted" id="previewDateFull">Horario regular de cursada</small>
<span id="modalCategoryBadge" class="badge badge-brand mb-1">Clase Académica</span>
<h4 class="modal-title fw-bold text-primary" id="modalSubjectTitle">Detalle de la Clase</h4>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body pt-3" id="reservationDetails">
<div class="row g-3">
<div class="col-md-6">
<div class="p-3 rounded bg-body-tertiary h-100">
<h6 class="text-muted small text-uppercase fw-bold mb-2">
<i class="bi bi-book text-primary me-1"></i>{% trans %}Materia y Código{% endtrans %}
</h6>
<p class="h6 fw-bold mb-1" id="modalSubjectName">-</p>
<p class="mb-2">
<span class="badge bg-dark bg-opacity-75 font-monospace text-white" id="modalSubjectCode">-</span>
</p>
<p class="small text-muted mb-0" id="modalCareerWrap">
<i class="bi bi-mortarboard me-1"></i><span id="modalCareer">-</span>
</p>
</div>
</div>
<!-- 📍 Espacio / Aula / Virtual Desglosado -->
<div class="d-flex align-items-start gap-3">
<div class="gc-icon-circle text-danger mt-1" id="previewLocationIcon">
<i class="bi bi-geo-alt fs-5"></i>
</div>
<div class="flex-grow-1">
<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 class="col-md-6">
<div class="p-3 rounded bg-body-tertiary h-100">
<h6 class="text-muted small text-uppercase fw-bold mb-2">
<i class="bi bi-door-open text-success me-1"></i>{% trans %}Espacio Asignado{% endtrans %}
</h6>
<p class="h5 fw-bold text-success mb-1" id="modalClassroom">-</p>
<p class="small text-muted mb-0" id="modalBuilding">-</p>
</div>
</div>
</div>
<!-- 👤 Docente, Carrera y Comisión -->
<div class="d-flex align-items-center gap-3">
<div class="gc-icon-circle text-primary">
<i class="bi bi-person fs-5"></i>
</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 class="col-md-6">
<div class="p-3 rounded bg-body-tertiary h-100">
<h6 class="text-muted small text-uppercase fw-bold mb-2">
<i class="bi bi-clock text-warning me-1"></i>{% trans %}Horario Programado{% endtrans %}
</h6>
<p class="h6 fw-bold mb-0" id="modalTimeRange">-</p>
</div>
</div>
<!-- 📝 Hito Académico / Detalle -->
<div class="d-flex align-items-start gap-3" id="previewNotesSection">
<div class="gc-icon-circle text-warning mt-1">
<i class="bi bi-flag fs-5"></i>
</div>
<div>
<div class="fw-semibold text-body" id="previewPurpose">Clase Regular</div>
<small class="text-muted text-break" id="previewNotes">Sin observaciones adicionales</small>
<div class="col-md-6">
<div class="p-3 rounded bg-body-tertiary h-100">
<h6 class="text-muted small text-uppercase fw-bold mb-2">
<i class="bi bi-shield-check text-info me-1"></i>{% trans %}Estado y Capacidad{% endtrans %}
</h6>
<p class="mb-1">
<span class="badge bg-success-subtle text-success border border-success-subtle px-2 py-1" id="modalStatus">-</span>
</p>
<small class="text-muted" id="modalAttendees">-</small>
</div>
</div>
</div>
</div>
<!-- Footer de Acciones Rápidas -->
<div class="modal-footer border-0 p-3 pt-0 d-flex justify-content-between">
<div class="d-flex gap-2">
<a id="previewFullLink" href="#" class="btn btn-outline-primary btn-sm px-3">
<div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Close{% endtrans %}</button>
<a href="#" id="modalFullLink" class="btn btn-primary">
<i class="bi bi-box-arrow-up-right me-1"></i>{% trans %}Ver Ficha Completa{% endtrans %}
</a>
{% 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>
@@ -300,19 +230,16 @@
{% block extra_css %}
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@5.11.0/main.min.css" rel="stylesheet">
<style>
/* ==========================================================================
Google Calendar Aesthetic & FullCalendar Overrides (Light & Dark)
========================================================================== */
/* FullCalendar Modern Overrides */
#calendar {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
min-height: 750px;
font-family: inherit;
min-height: 700px;
}
.fc .fc-toolbar-title {
font-size: 1.35rem !important;
font-size: 1.25rem !important;
font-weight: 700 !important;
color: var(--text-primary);
letter-spacing: -0.2px;
}
.fc .fc-button-primary {
@@ -335,366 +262,116 @@
}
.fc-theme-standard td, .fc-theme-standard th {
border-color: rgba(0, 0, 0, 0.08) !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;
border-color: var(--card-border, #E2E8F0) !important;
}
.fc .fc-col-header-cell-cushion {
color: var(--bs-body-color, #3c4043);
color: var(--text-primary);
font-weight: 600;
font-size: 0.82rem;
padding: 8px 4px !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 {
color: var(--text-secondary, #64748B);
font-size: 0.72rem;
font-weight: 500;
font-size: 0.78rem;
}
/* Red Now Indicator Line */
.fc .fc-timegrid-now-indicator-line {
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;
/* Event Card Visual Hierarchy */
.fc-event {
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 {
transform: translateY(-1px) scale(1.01);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.18);
z-index: 20 !important;
filter: brightness(0.97);
.fc-event:hover {
transform: translateY(-2px) scale(1.01);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.22) !important;
z-index: 10 !important;
}
[data-bs-theme="dark"] .gc-event-pill {
background: rgba(180, 62, 142, 0.22);
border-color: rgba(180, 62, 142, 0.45);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
.fc-event-confirmed {
background: linear-gradient(135deg, #B43E8E 0%, #832765 100%) !important;
color: #FFFFFF !important;
}
[data-bs-theme="dark"] .gc-event-pill:hover {
filter: brightness(1.2);
.fc-event-pending {
background: linear-gradient(135deg, #F59E0B 0%, #D97706 100%) !important;
color: #FFFFFF !important;
}
/* Left Accent Stripe */
.gc-event-stripe {
width: 4px;
flex-shrink: 0;
.fc-event-custom-container {
padding: 5px 7px;
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;
flex-direction: column;
justify-content: flex-start;
overflow: hidden;
}
/* Event Header: Time + Room Badge */
.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;
.fc-event-time-pill {
font-size: 0.72rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
border-left: 3px solid var(--event-color, #B43E8E);
transition: filter 0.12s ease;
font-weight: 500;
color: rgba(255, 255, 255, 0.85);
margin-bottom: 2px;
display: flex;
align-items: center;
}
.gc-month-pill:hover {
filter: brightness(0.95);
}
[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;
/* High Visual Hierarchy for Subject Name */
.fc-event-subject-title {
font-size: 0.88rem !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 {
font-size: 0.72rem !important;
/* Secondary Badges */
.fc-event-badges {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 3px;
margin-top: auto;
}
.fc-badge-code {
background-color: rgba(0, 0, 0, 0.3) !important;
color: #FFFFFF !important;
font-size: 0.70rem !important;
font-weight: 600 !important;
color: #B43E8E !important;
text-decoration: none !important;
padding: 1px 4px !important;
border-radius: 3px !important;
padding: 2px 5px !important;
border-radius: 4px !important;
}
.fc-more-link:hover {
background-color: rgba(180, 62, 142, 0.12) !important;
}
/* -------------------------------------------------------------
Google Calendar Modal Card (.gc-modal-card)
------------------------------------------------------------- */
.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%;
.fc-badge-room {
background-color: rgba(255, 255, 255, 0.28) !important;
color: #FFFFFF !important;
font-size: 0.70rem !important;
font-weight: 700 !important;
padding: 2px 5px !important;
border-radius: 4px !important;
display: inline-flex;
align-items: center;
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 {
background: rgba(0, 0, 0, 0.50);
transform: scale(1.05);
/* Month View Optimization */
.fc-daygrid-event .fc-event-custom-container {
padding: 2px 4px;
}
.gc-icon-circle {
width: 34px;
height: 34px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--bs-tertiary-bg, #f1f3f4);
flex-shrink: 0;
.fc-daygrid-event .fc-event-subject-title {
font-size: 0.78rem !important;
-webkit-line-clamp: 1;
}
</style>
{% endblock %}
@@ -712,15 +389,11 @@ document.addEventListener('DOMContentLoaded', function() {
var filterSubj = document.getElementById('filterSubject');
var filterCar = document.getElementById('filterCareer');
var filterShift = document.getElementById('filterShift');
var filterBuilding = document.getElementById('filterBuilding');
var filterFloor = document.getElementById('filterFloor');
var filterHideVirtual = document.getElementById('filterHideVirtual');
var activeBadge = document.getElementById('activeFilterBadge');
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() {
var parts = [];
if (filterRoom.value && filterRoom.selectedOptions[0]) {
@@ -732,9 +405,6 @@ document.addEventListener('DOMContentLoaded', function() {
if (filterCar.value && filterCar.selectedOptions[0]) {
parts.push('Carrera: ' + filterCar.selectedOptions[0].text.trim());
}
if (filterBuilding && filterBuilding.value) {
parts.push('Edificio: ' + filterBuilding.value);
}
if (filterShift && filterShift.value) {
parts.push('Turno ' + filterShift.value);
}
@@ -769,21 +439,10 @@ document.addEventListener('DOMContentLoaded', function() {
minute: '2-digit',
hour12: false
},
slotMinTime: '07:00:00',
slotMaxTime: '23:00:00',
slotDuration: '00:30:00',
slotLabelInterval: '01:00:00',
nowIndicator: true,
slotMinTime: '07:30:00',
slotMaxTime: '22:30:00',
height: 'auto',
/* Manejo de Eventos Simultáneos estilo Google Calendar */
dayMaxEvents: 3,
dayMaxEventRows: true,
eventMaxStack: 3,
moreLinkClick: 'popover',
slotEventOverlap: true,
expandRows: true,
stickyHeaderDates: true,
/* Dynamic Server-Side Filtered Events */
events: function(info, successCallback, failureCallback) {
@@ -794,7 +453,6 @@ document.addEventListener('DOMContentLoaded', function() {
subject_id: filterSubj.value || '',
career_id: filterCar.value || '',
shift: (filterShift ? filterShift.value : '') || '',
building: (filterBuilding ? filterBuilding.value : '') || '',
floor: (filterFloor ? filterFloor.value : '') || '',
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) {
var ext = arg.event.extendedProps || {};
var subjectName = ext.subject_name || arg.event.title || '';
var subjectCode = ext.subject_code || '';
var classroom = ext.classroom || '';
var isVirtual = ext.is_virtual || false;
var timeText = arg.timeText || '';
var status = (ext.status || 'CONFIRMED').toUpperCase();
var isMonthView = arg.view.type === 'dayGridMonth';
var eventColor = '#B43E8E';
if (status === 'PENDING') {
eventColor = '#F59E0B';
} else if (isVirtual) {
eventColor = '#6366F1';
}
var container = document.createElement('div');
container.className = 'fc-event-custom-container';
// Vista de Mes: Píldora Google Calendar Compacta
if (isMonthView) {
var monthPill = document.createElement('div');
monthPill.className = 'gc-month-pill ' + (isVirtual ? 'gc-month-virtual' : '');
monthPill.style.setProperty('--event-color', eventColor);
monthPill.title = subjectName + (classroom ? ' · ' + classroom : '');
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}
var timeHtml = timeText ? `<div class="fc-event-time-pill"><i class="bi bi-clock me-1"></i>${timeText}</div>` : '';
var titleHtml = `<div class="fc-event-subject-title" title="${subjectName}">${subjectName}</div>`;
var badgesHtml = `
<div class="fc-event-badges">
${subjectCode ? `<span class="fc-badge-code font-monospace">${subjectCode}</span>` : ''}
${classroom ? `<span class="fc-badge-room"><i class="bi bi-door-open me-1"></i>${classroom}</span>` : ''}
</div>`;
var title = `<div class="gc-event-title" title="${subjectName}">${subjectName}</div>`;
pill.innerHTML = stripe + `<div class="gc-event-content">${header}${title}</div>`;
return { domNodes: [pill] };
container.innerHTML = timeHtml + titleHtml + badgesHtml;
return { domNodes: [container] };
},
/* Intercepción de Clic: Popover / Modal Flotante de Vista Previa Google Calendar */
/* Interactive Instant Modal */
eventClick: function(info) {
info.jsEvent.preventDefault();
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 classroom = ext.classroom || 'Sin Aula Asignada';
var classroom = ext.classroom || 'Sin Aula';
var building = ext.building || 'Edificio Central';
var career = ext.career || 'Tecnicatura / Licenciatura UniCABA';
var status = (ext.status || 'CONFIRMED').toUpperCase();
var isVirtual = ext.is_virtual || false;
var teacher = ext.teacher || 'Docente Responsable';
var commission = ext.commission ? `Comisión ${ext.commission}` : 'Comisión Regular';
var career = ext.career || 'General';
var status = ext.status || 'CONFIRMED';
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 bannerClass = 'gc-modal-banner';
if (status === 'PENDING') {
eventColor = '#F59E0B';
bannerClass += ' banner-pending';
} else if (isVirtual) {
eventColor = '#6366F1';
bannerClass += ' banner-virtual';
}
var startStr = info.event.start ? info.event.start.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit', hour12: false}) : '';
var endStr = info.event.end ? info.event.end.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit', hour12: false}) : '';
var dateStr = info.event.start ? info.event.start.toLocaleDateString([], {weekday: 'long', day: 'numeric', month: 'long'}) : '';
// Actualizar Banner y Color Box
var bannerEl = document.getElementById('gcModalBanner');
if (bannerEl) bannerEl.className = bannerClass;
document.getElementById('modalSubjectTitle').textContent = subjectName;
document.getElementById('modalSubjectName').textContent = subjectName;
document.getElementById('modalSubjectCode').textContent = subjectCode;
document.getElementById('modalCareer').textContent = career;
document.getElementById('modalClassroom').textContent = classroom;
document.getElementById('modalBuilding').textContent = building;
document.getElementById('modalTimeRange').textContent = `${dateStr} (${startStr} a ${endStr} hs)`;
document.getElementById('modalStatus').textContent = status;
document.getElementById('modalAttendees').textContent = `${attendees} estudiantes esperados`;
var colorBox = document.getElementById('gcPreviewColorBox');
if (colorBox) colorBox.style.backgroundColor = eventColor;
// Textos Principales
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';
if (info.event.url) {
var btn = document.getElementById('modalFullLink');
btn.href = info.event.url;
btn.classList.remove('d-none');
} else {
statusBadge.className = 'badge bg-secondary-subtle text-secondary border';
statusBadge.textContent = status;
}
document.getElementById('modalFullLink').classList.add('d-none');
}
var shiftBadge = document.getElementById('previewShiftBadge');
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'));
var modal = new bootstrap.Modal(document.getElementById('reservationModal'));
modal.show();
}
});
@@ -1040,12 +559,6 @@ document.addEventListener('DOMContentLoaded', function() {
});
}
if (filterBuilding) {
filterBuilding.addEventListener('change', function() {
calendar.refetchEvents();
});
}
if (filterFloor) {
filterFloor.addEventListener('change', function() {
calendar.refetchEvents();
@@ -1063,7 +576,6 @@ document.addEventListener('DOMContentLoaded', function() {
filterSubj.value = '';
filterCar.value = '';
if (filterShift) filterShift.value = '';
if (filterBuilding) filterBuilding.value = '';
if (filterFloor) filterFloor.value = '';
if (filterHideVirtual) filterHideVirtual.checked = false;
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>
</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 -->
<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">
<i class="bi bi-brightness-high text-warning me-1"></i>{% trans %}Turno{% endtrans %}
</label>
@@ -92,22 +79,14 @@
</select>
</div>
<!-- Date Range From -->
<div class="col-lg-2 col-md-3">
<!-- Date Range -->
<div class="col-lg-3 col-md-4">
<label class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-calendar-event me-1"></i>{% trans %}Desde Fecha{% endtrans %}
</label>
<input type="date" name="date_from" class="form-control form-control-sm" value="{{ date_from or '' }}">
</div>
<!-- Date Range To -->
<div class="col-lg-2 col-md-3">
<label class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-calendar-event me-1"></i>{% trans %}Hasta Fecha{% endtrans %}
</label>
<input type="date" name="date_to" class="form-control form-control-sm" value="{{ date_to or '' }}">
</div>
<!-- Row 2: Hide Virtual, Submit, Clear -->
<div class="col-lg-6 col-md-6 pt-2">
<div class="form-check form-switch pt-1">
@@ -200,18 +179,6 @@
{% endif %}
</td>
<td class="text-end pe-3">
{% if reservation.effective_virtual_link %}
<a href="{{ reservation.effective_virtual_link }}" target="_blank"
class="btn btn-sm btn-outline-success py-0 px-2 me-1" title="{% trans %}Unirse a Clase Virtual (Zoom/Meet){% endtrans %}">
<i class="bi bi-camera-video"></i>
</a>
{% endif %}
{% if current_user.can_edit_reservation(reservation) %}
<a href="{{ url_for('schedule.edit_reservation', id=reservation.id) }}"
class="btn btn-sm btn-outline-warning py-0 px-2 me-1" title="{% trans %}Modificar Reserva{% endtrans %}">
<i class="bi bi-pencil"></i>
</a>
{% endif %}
<a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}"
class="btn btn-sm btn-outline-secondary py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}">
<i class="bi bi-eye"></i>
@@ -237,7 +204,7 @@
<ul class="pagination pagination-sm mb-0">
{% if reservations.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=reservations.prev_num, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, 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>
</a>
</li>
@@ -249,7 +216,7 @@
<li class="page-item active"><span class="page-link">{{ p }}</span></li>
{% else %}
<li class="page-item">
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=p, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, 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>
{% endif %}
{% else %}
@@ -259,7 +226,7 @@
{% if reservations.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=reservations.next_num, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, 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>
</a>
</li>
+1 -25
View File
@@ -214,25 +214,13 @@
<span class="small text-muted">
<i class="bi bi-people me-1"></i>{{ item.reservation.expected_attendees }} {% trans %}estudiantes{% endtrans %}
</span>
<div class="d-flex gap-1">
{% 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 %}">
<a href="{{ url_for('schedule.view_reservation', id=item.reservation.id) }}" class="btn btn-sm btn-outline-secondary py-0 px-2">
<i class="bi bi-eye me-1"></i>{% trans %}Detalles{% endtrans %}
</a>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
@@ -298,18 +286,6 @@
{% endif %}
</td>
<td class="text-end pe-3">
{% if reservation.effective_virtual_link %}
<a href="{{ reservation.effective_virtual_link }}" target="_blank"
class="btn btn-sm btn-outline-success py-0 px-2 me-1" title="{% trans %}Unirse a Clase Virtual (Zoom/Meet){% endtrans %}">
<i class="bi bi-camera-video"></i>
</a>
{% endif %}
{% if current_user.can_edit_reservation(reservation) %}
<a href="{{ url_for('schedule.edit_reservation', id=reservation.id) }}"
class="btn btn-sm btn-outline-warning py-0 px-2 me-1" title="{% trans %}Modificar Reserva{% endtrans %}">
<i class="bi bi-pencil"></i>
</a>
{% endif %}
<a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}"
class="btn btn-sm btn-outline-secondary py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}">
<i class="bi bi-eye"></i>
+22 -76
View File
@@ -10,14 +10,9 @@
<h1 class="h3">
<i class="bi bi-calendar-check text-info"></i> Reservation Details
</h1>
<div class="d-flex gap-2">
{% if current_user.can_edit_reservation(reservation) %}
<a href="{{ url_for('schedule.edit_reservation', id=reservation.id) }}" class="btn btn-warning">
<i class="bi bi-pencil-square me-1"></i>{% trans %}Modificar Reserva{% endtrans %}
</a>
{% endif %}
<div>
<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>
</div>
</div>
@@ -91,70 +86,35 @@
<!-- Classroom Details -->
<div class="card border-0 shadow-sm">
<div class="card-header border-0 py-3 bg-body-tertiary">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-geo-alt text-primary me-2"></i>{% trans %}Ubicación y Espacio Asignado{% endtrans %}
<div class="card-header border-0 py-3">
<h5 class="mb-0">
<i class="bi bi-building text-info"></i> Classroom Information
</h5>
</div>
<div class="card-body p-4">
<div class="card-body">
{% if reservation.classroom %}
<div class="row g-3 mb-3">
<div class="col-md-4">
<div class="p-3 rounded bg-body-tertiary border">
<small class="text-muted d-block mb-1">🏢 {% trans %}Edificio / Sede{% endtrans %}</small>
<h6 class="mb-0 fw-bold text-primary">{{ reservation.classroom.building_name }}</h6>
<div class="row">
<div class="col-md-4 mb-3">
<label class="text-muted small">Classroom</label>
<p class="mb-0"><strong>{{ reservation.classroom.code_display }}</strong></p>
</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 class="col-md-4">
<div class="p-3 rounded bg-body-tertiary border">
<small class="text-muted d-block mb-1">🪜 {% trans %}Piso / Nivel{% endtrans %}</small>
<h6 class="mb-0 fw-bold text-secondary">
{% if reservation.classroom.is_virtual %}
<span class="badge bg-primary-subtle text-primary border">Campus Virtual</span>
{% else %}
{{ reservation.classroom.floor_display }}
{% endif %}
</h6>
<div class="col-md-4 mb-3">
<label class="text-muted small">Capacity</label>
<p class="mb-0">{{ reservation.classroom.capacity }} people</p>
</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 %}
<div class="mt-2">
<label class="text-muted small">{% trans %}Descripción del Aula{% endtrans %}</label>
<p class="mb-0 small">{{ reservation.classroom.description }}</p>
<div class="col-12">
<label class="text-muted small">Description</label>
<p class="mb-0">{{ reservation.classroom.description }}</p>
</div>
{% 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>
{% if reservation.effective_virtual_link %}
<small class="text-secondary text-break font-monospace d-block">{{ reservation.effective_virtual_link }}</small>
{% else %}
<small class="text-muted fst-italic">{% trans %}Modalidad remota sin enlace específico registrado{% endtrans %}</small>
{% endif %}
</div>
{% if reservation.effective_virtual_link %}
<a href="{{ reservation.effective_virtual_link }}" target="_blank" class="btn btn-success btn-sm px-3 text-nowrap">
<i class="bi bi-box-arrow-up-right me-1"></i>{% trans %}Unirse a Clase (Zoom / Meet){% endtrans %}
</a>
{% endif %}
</div>
{% endif %}
{% else %}
<p class="text-muted mb-0">{% trans %}Classroom information not available{% endtrans %}</p>
<p class="text-muted mb-0">Classroom information not available</p>
{% endif %}
</div>
</div>
@@ -199,23 +159,15 @@
<h3 class="mb-1">{{ reservation.expected_attendees }}</h3>
<p class="text-muted small">Expected Attendees</p>
{% if reservation.classroom %}
{% if reservation.classroom.capacity and reservation.classroom.capacity > 0 %}
<div class="progress mt-3" style="height: 8px;">
{% 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 %}"
role="progressbar"
style="{{ 'width: ' ~ ([percentage|round(1), 100]|min|string) ~ '%;' }}"></div>
style="{{ 'width: ' ~ (percentage|round(1)|string) ~ '%;' }}"></div>
</div>
<small class="text-muted">
{{ reservation.expected_attendees }} / {{ reservation.classroom.capacity }} {% trans %}capacidad{% endtrans %}
{{ reservation.expected_attendees }} / {{ reservation.classroom.capacity }} capacity
</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 %}
</div>
</div>
@@ -230,12 +182,6 @@
</div>
<div class="card-body">
<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' %}
<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?')">
-23
View File
@@ -39,26 +39,3 @@ def admin_required(f):
return f(*args, **kwargs)
return decorated_function
def can_edit_reservation_required(f):
"""
Decorator requiring permission to modify a specific reservation.
Allows administrators, Bedelía, or the creator/teacher of the reservation.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for('auth.login', next=request.url))
res_id = kwargs.get('id')
if res_id:
from app.models.reservation import Reservation
reservation = Reservation.query.get_or_404(res_id)
if not current_user.can_edit_reservation(reservation):
flash(_('No tienes los permisos necesarios para modificar esta reserva.'), 'error')
abort(403)
return f(*args, **kwargs)
return decorated_function
-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
from dotenv import load_dotenv
basedir = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
load_dotenv(os.path.join(basedir, '.env'))
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
_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_DATABASE_URI = os.environ.get('DATABASE_URL') or 'postgresql://postgres:password@localhost:5432/classrooms_db'
SQLALCHEMY_TRACK_MODIFICATIONS = False
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-item class="input-item">
<ion-label position="stacked">Tipo e Hito</ion-label>
<ion-select [(ngModel)]="tipo" interface="popover" placeholder="Seleccionar tipo de hito">
<ion-select-option *ngFor="let opt of milestoneTypes" [value]="opt.name">
{{ opt.name }}
</ion-select-option>
<ion-select [(ngModel)]="tipo" interface="popover">
<ion-select-option value="Parcial 1">Parcial 1</ion-select-option>
<ion-select-option value="Parcial 2">Parcial 2</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-item>
@@ -7,36 +7,6 @@ import { EventosService } from '../../services/eventos.service';
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({
selector: 'app-evento-modal',
templateUrl: './evento-modal.component.html',
@@ -49,8 +19,7 @@ export class EventoModalComponent implements OnInit {
@Input() materiaNombre!: string;
@Input() eventoExistente?: EventoAcademico;
tipo: string = 'Parcial 1';
milestoneTypes: MilestoneTypeOption[] = DEFAULT_MILESTONE_TYPES;
tipo: 'Parcial' | 'TP' | 'Final' | 'Otro' = 'Parcial';
fecha: string = new Date().toISOString();
aula: string = '';
link: string = '';
@@ -65,9 +34,7 @@ export class EventoModalComponent implements OnInit {
private toastCtrl: ToastController
) { }
async ngOnInit() {
await this.cargarTipificaciones();
ngOnInit() {
if (this.eventoExistente) {
this.isEdit = true;
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() {
this.modalCtrl.dismiss();
}
@@ -18,7 +18,7 @@ export interface ClaseHorario {
export interface EventoAcademico {
id: string;
materiaCodigo: string;
tipo: string; // Dinámico según catálogo MilestoneType
tipo: 'Parcial' | 'TP' | 'Final' | 'Otro';
fecha: string; // ISO string
aula?: string;
link?: string;
+13 -44
View File
@@ -66,7 +66,7 @@ def init_database():
default_roles = [
{
'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,
'permissions': {
'classrooms': 'read_write',
@@ -76,13 +76,12 @@ def init_database():
'academic': 'read_write',
'import': 'read_write',
'optimizer': 'read_write',
'users': 'read_write',
'milestone_types': 'read_write'
'users': 'read_write'
}
},
{
'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,
'permissions': {
'classrooms': 'read',
@@ -92,13 +91,12 @@ def init_database():
'academic': 'read',
'import': 'none',
'optimizer': 'read',
'users': 'none',
'milestone_types': 'read'
'users': 'none'
}
},
{
'name': 'Bedelia',
'description': 'Gestion operativa de aulas, sedes, reservas, comisiones, importacion y cartelera',
'name': 'Operador',
'description': 'Gestión operativa de aulas, sedes, reservas, importación y cartelera',
'is_system': False,
'permissions': {
'classrooms': 'read_write',
@@ -108,12 +106,11 @@ def init_database():
'academic': 'read_write',
'import': 'read_write',
'optimizer': 'none',
'users': 'none',
'milestone_types': 'read_write'
'users': 'none'
}
},
{
'name': 'Alumno',
'name': 'Consulta',
'description': 'Acceso de solo lectura al cronograma general, asignaturas y aulas',
'is_system': False,
'permissions': {
@@ -124,8 +121,7 @@ def init_database():
'academic': 'read',
'import': 'none',
'optimizer': 'none',
'users': 'none',
'milestone_types': 'read'
'users': 'none'
}
}
]
@@ -189,7 +185,7 @@ def init_database():
if not admin.theme_preference:
admin.theme_preference = "auto"
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
print("[*] Verificando edificios institucionales...")
@@ -199,13 +195,13 @@ def init_database():
b_central = Building(
name='Edificio Central',
code='CENTRAL',
address='Tte. Gral. Juan Domingo Peron 802, CABA',
address='Tte. Gral. Juan Domingo Perón 802, CABA',
floors=4,
description='Sede central institucional de UniCABA. Aulas de grado, auditorio y laboratorios.'
)
db.session.add(b_central)
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()
if not b_virtual:
@@ -220,39 +216,12 @@ def init_database():
db.session.commit()
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.")
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)}")
except UnicodeEncodeError:
print("\n[ERROR] Error al inicializar la base de datos (se omiten caracteres no-ascii).")
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("3. La variable DATABASE_URL en el archivo .env tenga el usuario y contraseña correctos.")
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
WTForms==3.1.0
Werkzeug==3.0.1
SQLAlchemy>=2.0.32
SQLAlchemy==2.0.23
python-dotenv==1.0.0
requests==2.31.0
bleach==6.1.0
python-dateutil==2.8.2
Pillow>=10.4.0
Pillow==10.1.0
email_validator>=2.0.0
psycopg2-binary>=2.9.10
psycopg2-binary==2.9.9
Babel==2.14.0
gunicorn>=21.2.0