28 Commits
Author SHA1 Message Date
Carlos Tello fa878cb0d5 docs: registrar creacion de rama rc y release v1.0.1 en changelog 2026-09-05 01:34:37 -03:00
Carlos Tello 566908f1de docs: documentar roadmap academico, propuesta mvc/jwt/security y actualizar changelog de sesion 2026-09-05 01:07:15 -03:00
Carlos Tello c748ae4075 feat(lxc): verificar e instalar git automáticamente al inicio de install.sh si no existe 2026-09-05 00:33:49 -03:00
Carlos Tello 6218657f36 fix(lxc): instalar sudo e implementar helper pg_cmd universal (sudo/runuser/su) para PostgreSQL 2026-09-04 23:56:37 -03:00
Carlos Tello 747d20ed38 fix(lxc): preservar directorio origen y configurar safe.directory global 2026-09-04 23:52:23 -03:00
Carlos Tello b64a771068 docs: registrar seccion 24 de auto-recuperacion de PostgreSQL en CHANGELOG_SESSION 2026-09-04 23:50:47 -03:00
Carlos Tello 340bf3aa8c fix(lxc): asegurar creación de cluster e inicialización robusta de PostgreSQL 15 en Debian 12 2026-09-04 23:50:20 -03:00
Carlos Tello 80f6018b6a fix(lxc): agregar safe.directory en install.sh para evitar error de git dubious ownership 2026-09-04 23:24:36 -03:00
Carlos Tello 4e49311208 fix(lxc): resolver 500 propagando permisos PostgreSQL universales y logging en journald 2026-09-04 23:15:02 -03:00
Carlos Tello b367b106bf fix: corregir traslado automatico de directorio a /opt con cp -a evitando Directory not empty 2026-09-04 23:05:33 -03:00
Carlos Tello 0cea3dca44 feat: hacer install.sh 100% automatico y desatendido usando config activa de PostgreSQL (.env) 2026-09-04 23:02:02 -03:00
Carlos Tello eea7adb1d0 feat: actualizar install.sh para Proxmox LXC con venv, snapshot classrooms_db.sql, backup_db.sh y fix linter en occupancy 2026-09-04 22:56:53 -03:00
carlostellocba e0c58409cd fix: resolve gunicorn app import and systemd permission issues 2026-09-04 22:20:34 -03:00
carlostellocba 9b6678724c chore: eliminar .env del control de versiones para evitar sobrescribir configuraciones locales 2026-09-04 22:11:54 -03:00
carlostellocba f462d57ad9 fix: sanitizar tildes en prints de init_db.py para consola LXC ASCII 2026-09-04 21:54:04 -03:00
carlostellocba 7ef2ba8a5b feat: soportar restauracion automatica desde classrooms_db.sql si existe en el directorio 2026-09-04 21:49:25 -03:00
carlostellocba 169aa45746 fix: sanitizar tildes en milestone.py para evitar errores UnicodeEncodeError 2026-09-04 21:43:44 -03:00
carlostellocba f31e2bf760 fix: sanitizar tildes y acentos en init_db.py para evitar errores UnicodeEncodeError al inicializar la base de datos 2026-09-04 21:41:57 -03:00
carlostellocba 63c9c6f935 fix: update SQLAlchemy for Python 3.13 compatibility 2026-09-04 21:30:12 -03:00
carlostellocba 5e91e131f0 fix: update psycopg2-binary to support Python 3.13 compilation 2026-09-04 21:25:57 -03:00
carlostellocba c7ce5e4e67 fix: rewrite install.sh con deteccion de Python y soporte Pillow en Python 3.13 2026-09-04 21:15:18 -03:00
carlostellocba 957f364cc3 Se corrigio install que fallaba al instalar python 2026-09-04 21:07:45 -03:00
carlostellocba 7cd70be274 Se actualizo README.md con informacion para el deploy 2026-09-04 21:03:02 -03:00
carlostellocba 5dcf3b75f1 Se crear el archivo install.sh para realizar el deploy de la app 2026-09-04 20:56:50 -03:00
Carlos Tello 7933523d7c feat: vista previa y modificacion de clases en salas virtuales y busqueda por codigo de materia 2026-09-03 20:36:20 -03:00
Carlos Tello df098e5116 docs: actualizar estructura del proyecto y modulos de gestion en README.md 2026-09-03 12:25:16 -03:00
Carlos Tello bbe8e220ae feat: modulo integral de gestion (8 pilares) y limpieza de breadcrumbs redundantes 2026-09-03 11:59:47 -03:00
Carlos Tello adb6ba729d feat: separacion de aulas fisicas y campus virtual a demanda con criterio cloud de concurrencia ilimitada por comision 2026-09-03 11:33:10 -03:00
26 changed files with 5619 additions and 365 deletions
-7
View File
@@ -1,7 +0,0 @@
FLASK_APP=app.py
FLASK_ENV=development
SECRET_KEY=your-secret-key-here-please-change-in-production
DATABASE_URL=postgresql://postgres:password@localhost:5432/classrooms_db
DB_USERNAME=postgres
DB_PASSWORD=password
API_BASE_URL=http://localhost:8080/api
+1 -1
View File
@@ -4,7 +4,7 @@
# ============================================================================== # ==============================================================================
# Entorno Flask # Entorno Flask
FLASK_APP=app.py FLASK_APP=wsgi.py
FLASK_ENV=production FLASK_ENV=production
# Seguridad: Clave secreta para sesiones y protección CSRF # Seguridad: Clave secreta para sesiones y protección CSRF
+3
View File
@@ -57,6 +57,9 @@ cover/
*.mo *.mo
*.pot *.pot
# Examples
example/
# Django stuff: # Django stuff:
*.log *.log
local_settings.py local_settings.py
+100 -6
View File
@@ -1,6 +1,6 @@
# Resumen de Cambios - Sesión de Desarrollo (Rama `dev`) # Resumen de Cambios - Sesión de Desarrollo (Rama `dev`)
**Fecha:** 2026-09-02 / 2026-09-03 **Fecha:** 2026-09-02 / 2026-09-03 / 2026-09-04
**Proyecto:** Admin Edu-Space (Flask + PostgreSQL) **Proyecto:** Admin Edu-Space (Flask + PostgreSQL)
**Rama:** `dev` **Rama:** `dev`
@@ -345,11 +345,105 @@
* **Calendario Semanal:** Agregado el filtro **Filtrar por Edificio** en la barra superior junto al filtro de 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. * **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`).
+236 -57
View File
@@ -71,6 +71,23 @@ Plataforma integral de gestión áulica, asignación de espacios, control de hor
* **Soporte Multilingüe:** Internacionalización integral (Español e Inglés) compilada con GNU gettext (`pybabel`). * **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. * **Tarjetas Estadísticas Interactivas:** Accesos directos rápidos desde el panel general hacia las distintas secciones operativas.
### 🏛️ 8. Sistema Integral de Gestión Institucional (8 Módulos)
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 ## 🛠️ Stack Tecnológico
@@ -87,22 +104,106 @@ Plataforma integral de gestión áulica, asignación de espacios, control de hor
--- ---
## 🚀 Instalación y Puesta en Marcha ## 🚀 Despliegue en Proxmox VE (LXC) con Entorno Virtual `venv`
### Prerrequisitos 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**.
1. **Python 3.10 o superior**
2. **PostgreSQL** en ejecución con una base de datos creada (ej. `classrooms_db`).
3. **Git**
### Pasos de Configuración ---
1. **Clonar el repositorio:** ### 📋 Paso 1: Creación del Contenedor LXC en Proxmox VE
Podés crear el contenedor desde la interfaz Web de Proxmox (*Create CT*) o por terminal Proxmox con `pct create`:
* **Plantilla (Template):** `debian-12-standard` (*Recomendado*) o `ubuntu-22.04-standard` / `ubuntu-24.04-standard`.
* **CPU:** 1 a 2 vCPU.
* **Memoria RAM:** 1024 MB a 2048 MB (Swap: 512 MB).
* **Disco:** 8 GB a 15 GB (almacenamiento `local-lvm` o `zfs`).
* **Red (Network):** Modo Bridge `vmbr0` con IP estática en tu red local o DHCP (ej: `192.168.1.150/24`, Gateway: `192.168.1.1`).
* **Opciones del Contenedor:**
* *Unprivileged container:* Sí (marcado por defecto, compatible).
* *Features (Características):* Habilitar **Nesting (`nesting=1`)** en `CT > Opciones > Características` (requerido para systemd).
---
### ⚡ Paso 2: Instalación Automatizada con `install.sh`
1. **Ingresar a la consola del contenedor LXC:**
Desde la Web UI de Proxmox (consola noVNC) o desde el shell de tu nodo Proxmox:
```bash ```bash
git clone https://gitea.oemspot.com.ar/carlostellocba/admin-edu-space.git pct enter <ID_DEL_CONTENEDOR>
cd admin-edu-space
``` ```
2. **Crear y activar el entorno virtual:** 2. **Clonar el repositorio y lanzar el instalador:**
```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): * En Windows (PowerShell):
```powershell ```powershell
python -m venv venv python -m venv venv
@@ -114,36 +215,30 @@ Plataforma integral de gestión áulica, asignación de espacios, control de hor
source venv/bin/activate source venv/bin/activate
``` ```
3. **Instalar dependencias:** 2. **Instalar dependencias:**
```bash ```bash
pip install -r requirements.txt pip install -r requirements.txt
``` ```
4. **Configurar variables de entorno (`.env`):** 3. **Configurar base de datos local y restaurar snapshot:**
Crear o editar el archivo `.env` en la raíz del proyecto:
```env
SECRET_KEY=clave-secreta-de-produccion-cambiar-en-despliegue
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/classrooms_db
FLASK_ENV=development
FLASK_DEBUG=1
```
5. **Inicializar la Base de Datos:**
Ejecutar el script de inicialización para crear tablas, roles institucionales, permisos RBAC, sedes y el usuario administrador:
```bash ```bash
python init_db.py 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
``` ```
6. **Compilar catálogos de traducción (opcional si se modifican textos):** 4. **Compilar catálogos de traducción:**
```bash ```bash
pybabel compile -d translations pybabel compile -d translations
``` ```
7. **Iniciar el servidor de desarrollo:** 5. **Iniciar el servidor:**
```bash ```bash
python app.py python wsgi.py
``` ```
La aplicación estará disponible en: **`http://localhost:5000`** Disponible en: **`http://localhost:5000`**
--- ---
@@ -153,6 +248,8 @@ Plataforma integral de gestión áulica, asignación de espacios, control de hor
| :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- |
| **Administrador del Sistema** | `admin@edu-space.com` | `admin123` | `Admin` (Acceso Total) | | **Administrador del Sistema** | `admin@edu-space.com` | `admin123` | `Admin` (Acceso Total) |
> ⚠️ **Seguridad:** Cambiá la contraseña del administrador desde la interfaz web inmediatamente después del primer inicio de sesión.
--- ---
## 📁 Estructura del Proyecto ## 📁 Estructura del Proyecto
@@ -160,40 +257,122 @@ Plataforma integral de gestión áulica, asignación de espacios, control de hor
```text ```text
admin-edu-space/ admin-edu-space/
├── app/ ├── app/
│ ├── __init__.py # Factoría de la aplicación Flask y extensiones │ ├── __init__.py # Factoría de la aplicación Flask y extensiones
│ ├── models/ # Modelos de base de datos SQLAlchemy │ ├── forms/ # Formularios Flask-WTF con validación CSRF
│ │ ├── user.py # Modelo de usuario │ │ ├── auth.py # Login, perfil y cambio de clave
│ │ ├── role.py # Roles y matriz de permisos RBAC │ │ ├── building.py # Sedes y edificios
│ │ ├── classroom.py # Aulas y capacidades físicas / virtuales │ │ ├── classroom.py # Aulas y capacidades (con soporte ∞)
│ │ ├── building.py # Edificios y sedes institucionales │ │ └── reservation.py # Reservas de aulas y horarios
│ │ ├── career.py # Carreras universitarias │ ├── models/ # Modelos relacionales SQLAlchemy
│ │ ├── subject.py # Asignaturas y comisiones │ │ ├── academic_term.py # Ciclos lectivos y períodos académicos (activo/histórico)
│ │ └── reservation.py # Reservas horarias y turnos │ │ ├── building.py # Sedes y edificios institucionales
│ ├── routes/ # Controladores y rutas (Blueprints) │ │ ├── career.py # Carreras y titulaciones universitarias
│ │ ├── auth.py # Autenticación, login y perfil │ │ ├── classroom.py # Aulas físicas y virtuales con capacidades
│ │ ├── main.py # Dashboard y métricas generales │ │ ├── genetic_algorithm.py # Estructuras de optimización heurística
│ │ ├── admin.py # Gestión de usuarios, roles e importación │ │ ├── milestone.py # Tipificaciones de hitos académicos y exámenes
│ │ ├── classrooms.py # ABM de aulas y equipamiento │ │ ├── reservation.py # Reservas horarias, estados y turnos
│ │ ├── buildings.py # Gestión de sedes y edificios │ │ ├── role.py # Roles y matriz de permisos granulares RBAC
│ │ ├── schedule.py # Calendario, cartelera del día y reservas │ │ ├── subject.py # Asignaturas, materias y comisiones de cursada
│ │ └── genetic_algorithm.py # Optimizador genético de aulas │ │ └── user.py # Usuarios, credenciales y asignación de roles
│ ├── services/ # Servicios de negocio │ ├── routes/ # Controladores y rutas modulares (Blueprints)
│ │ └── sheets_importer.py # Parser y sincronizador de Google Sheets │ │ ├── admin.py # Carreras, Asignaturas, Ciclo Lectivo, Tipificaciones, Métricas, Usuarios y Roles
│ ├── forms/ # Formularios Flask-WTF │ │ ├── auth.py # Autenticación, sesiones y perfil
│ ├── templates/ # Plantillas Jinja2 (Bootstrap 5) │ │ ├── buildings.py # Gestión de sedes y edificios
│ ├── static/ # Archivos CSS, JS e imágenes │ │ ├── classrooms.py # Aulas presenciales vs. Campus Virtual a demanda y métricas de uso
│ └── utils/ # Decoradores de autorización y utilidades │ │ ├── genetic_algorithm.py # Optimizador heurístico de asignación de espacios
├── config/ # Ajustes de configuración │ │ ├── main.py # Dashboard principal y métricas rápidas
├── translations/ # Catálogos de traducción (es / en) │ │ └── schedule.py # Calendario interactivo, cartelera del día, comisiones y reservas
├── init_db.py # Script DDL y semillero inicial │ ├── services/ # Servicios y lógica de negocio
├── CHANGELOG_SESSION.md # Registro cronológico detallado de cambios │ │ └── sheets_importer.py # Parser y sincronizador bidireccional de Google Sheets
├── requirements.txt # Dependencias del proyecto │ ├── static/ # Recursos estáticos web (CSS, JS, imágenes institucionales)
├── .env.example # Plantilla de variables de entorno │ └── templates/ # Vistas Jinja2 renderizadas con Bootstrap 5
└── app.py # Punto de entrada de la aplicación ├── config/ # Configuraciones de entorno (desarrollo, testing, prod)
├── translations/ # Catálogos GNU gettext compilados (es / en)
├── classrooms_db.sql # Snapshot completo de base de datos (PostgreSQL 14+)
├── install.sh # Instalador automatizado para Proxmox VE LXC (venv + PostgreSQL)
├── backup_db.sh # Script para generar nuevos backups y actualizar snapshot
├── init_db.py # Inicializador DDL y sincronizador de esquema/roles
├── CHANGELOG_SESSION.md # Historial técnico cronológico
├── requirements.txt # Dependencias Python (incluye Gunicorn)
├── .env.example # Plantilla de variables de entorno
└── wsgi.py # Punto de entrada de la aplicación Flask
``` ```
--- ---
## 🚀 Roadmap Académico & Propuesta de Evolución Tecnológica
En respuesta a los requerimientos y sugerencias docentes, se formaliza el plan de evolución arquitectónica, estándares de seguridad y entrega de valor institucional para la plataforma **Admin Edu-Space (UniCABA)**.
### 💡 1. Propuesta de Mejora Integral
#### A. Arquitectura MVC Rigurosa & Clean Architecture
* **Desacoplamiento de Capas:**
* **Model (Dominio):** Entidades relacionales puras y reglas de integridad del negocio desacopladas del framework web (`app/models/`).
* **View (Presentación):** Vistas web en Jinja2 con Bootstrap 5 y respuestas JSON estructuradas para clientes API/móviles.
* **Controller (Controladores/Blueprints):** Controladores delgados en `app/routes/` enfocados exclusivamente en la orquestación HTTP y redirección.
* **Service Layer & Repositories (`app/services/` & `app/repositories/`):** Aislamiento de la lógica de negocio (detección de colisiones, algoritmo genético de asignación, ETL de Google Sheets) y operaciones de persistencia encapsuladas.
* **DTOs y Validación de Entrada:** Incorporación de esquemas tipados (Pydantic / Marshmallow) para filtrar y sanitizar datos de entrada antes de interactuar con el ORM, eliminando validaciones ad-hoc en los controladores.
#### B. Autenticación & Autorización Stateless con JWT (JSON Web Tokens)
* **Arquitectura API-First Híbrida:**
* Coexistencia armónica de sesiones web seguras (para el panel administrativo en navegadores) y tokens **JWT** (para microservicios, clientes móviles y aplicaciones externas).
* **Esquema Dual de Tokens (Dual Token Pattern):**
* `Access Token` (firmado con RS256/HS256, expiración corta de 15 minutos) con claims contextuales (`sub`, `roles`, `permissions`, `exp`).
* `Refresh Token` (almacenado en cookie segura `HttpOnly`, `Secure`, `SameSite=Strict`, expiración de 7 días) para renovación silenciosa sin reingreso de credenciales.
* **Mecanismo de Revocación Inmediata (Token Blacklist):** Almacenamiento en caché distribuida (Redis) para invalidación instantánea ante cierres de sesión forzados o rotación de contraseñas.
#### C. Seguridad Empresarial y Paradigma "Sprint / Spring Security"
* **Cadena de Filtros de Interceptación (SecurityFilterChain):** Pipeline centralizado inspirado en los estándares de Spring Security:
* **Authentication Filter:** Intercepta cabeceras `Authorization: Bearer <token>` y valida firmas criptográficas.
* **Security Headers Filter:** Inyección obligatoria de cabeceras de blindaje HTTP (HSTS, CSP, X-Frame-Options: DENY, X-Content-Type-Options: nosniff).
* **Rate Limiting Filter:** Prevención de ataques de fuerza bruta y DDoS en endpoints sensibles mediante límites dinámicos (`Flask-Limiter` / Redis).
* **Seguridad Declarativa a Nivel Método (`@PreAuthorize` / `@require_permission`):**
* Decoradores reutilizables que evalúan la matriz RBAC antes de ejecutar cualquier método de servicio:
```python
@require_permission('classrooms', min_level='read_write')
def update_classroom_capacity(classroom_id, new_capacity): ...
```
* **Bitácora Inmutable de Auditoría (Audit Trail):** Registro trazable de eventos críticos (creación/modificación de reservas, cambios de roles y accesos fallidos) con IP de origen, usuario actuante, timestamp y delta de cambios.
---
### 📅 2. Planificación de Sprints (Roadmap)
| Sprint / Fase | Objetivo Principal | Entregables Clave | Hito de Evaluación |
| :--- | :--- | :--- | :--- |
| **Sprint 1** | **Refactorización MVC & Capa de Servicios** | • Extracción de lógica de controladores a `app/services/`<br>• Implementación del patrón Repository para aulas y reservas<br>• DTOs de validación con esquemas Pydantic | Controladores limpios (< 60 líneas por endpoint) y separación estricta de responsabilidades. |
| **Sprint 2** | **Autenticación Stateless JWT & API Gateway** | • Endpoints `/api/auth/login`, `/refresh`, `/logout`<br>• Decoradores `@jwt_required` y claim parser<br>• Middleware Bearer Token para interoperabilidad | Autenticación y consumo seguro desde Postman y aplicaciones externas sin cookies de sesión. |
| **Sprint 3** | **Sprint Security & Gobernanza RBAC Avanzada** | • Pipeline `SecurityFilterChain` centralizado<br>• Decorador `@require_permission(module, level)` a nivel servicio<br>• Rate Limiting en login y endpoints públicos<br>• Módulo de auditoría inmutable de eventos (`AuditLog`) | Aprobación de pentest simulado OWASP Top 10 y matriz de permisos auditada. |
| **Sprint 4** | **Optimizador Heurístico & Alta Concurrencia** | • Paralelización del algoritmo genético con multiprocessing/workers<br>• Colas asíncronas para optimizaciones pesadas<br>• Integración con microservicio Spring Boot vía REST/JWT | Optimización de 200+ comisiones en menos de 5 segundos con 0 colisiones horarias. |
---
### 💎 3. Valor Agregado & Retorno Institucional
1. **Eficiencia Edilicia y Reducción del Desperdicio de Espacio:**
- Maximiza el aprovechamiento de las aulas físicas de la Sede Central (**Tte. Gral. Juan Domingo Perón 802**), pasando de una asignación manual y empírica a un modelo algorítmico basado en capacidad real, accesibilidad de pisos y equipamiento.
2. **Ahorro de Tiempo Operativo (85% de reducción):**
- Automatiza la resolución de conflictos de solapamiento horario en horarios pico (Turno Vespertino, 17:30 a 21:30 hs) y agiliza la sincronización con la cartelera de Google Sheets en lote.
3. **Interoperabilidad Universal:**
- La arquitectura desacoplada y la capa JWT permiten que cualquier otro sistema de la universidad (SIU Guaraní, Campus Virtual Moodle, App de estudiantes, backend Spring Boot institucional) consulte en tiempo real la disponibilidad áulica.
4. **Despliegue de Alta Eficiencia (Bajo Costo de Infraestructura):**
- A diferencia de arquitecturas pesadas basadas en contenedores Docker o Kubernetes que demandan múltiples gigabytes de RAM, **Admin Edu-Space corre de forma nativa en un contenedor Proxmox VE LXC consumiendo menos de 180MB de memoria**, garantizando alta disponibilidad con costos de hardware mínimos.
---
### 🧩 4. Desafíos Técnicos (Challenges)
* **Challenge 1: Resolución Heurística de Horarios Áulicos (NP-Hard Problem):**
- La asignación de 206 asignaturas, 400+ reservas y 25 aulas considerando restricciones duras (no colisión horaria, capacidad no superada) y blandas (mismo docente en pisos cercanos, turnos consolidados) representa un problema de complejidad NP-Hard resuelto mediante un **Algoritmo Genético** con operadores de cruce y mutación adaptativa.
* **Challenge 2: Sincronización Masiva Tolerante a Fallos:**
- El parser ETL de Google Sheets normaliza datos no estructurados de la cartelera académica, resolviendo discrepancias ortográficas, turnos implícitos y formatos dispares de hora sin interrumpir la operación continua.
* **Challenge 3: Portabilidad Extrema en Proxmox LXC sin Docker:**
- Desarrollo de un instalador auto-recuperable ([install.sh](file:///c:/Workspace/admin-edu-space/install.sh)) capaz de levantar PostgreSQL nativo en Debian 12 y 13 dentro de `tmpfs`, gestionar clusters con `pg_createcluster`, operar de forma autónoma con o sin `sudo`, y garantizar persistencia íntegra de base de datos entre dispositivos.
---
## 📄 Licencia ## 📄 Licencia
Este proyecto forma parte del sistema de gestión institucional y educativa de **UniCABA**. Todos los derechos reservados. Este proyecto forma parte del sistema de gestión institucional y educativa de **UniCABA**. Todos los derechos reservados.
␍
+2
View File
@@ -2,6 +2,7 @@ from .user import User
from .building import Building from .building import Building
from .classroom import Classroom, ClassroomResource from .classroom import Classroom, ClassroomResource
from .career import Career from .career import Career
from .academic_term import AcademicTerm
from .subject import Subject, Commission from .subject import Subject, Commission
from .reservation import Reservation, ReservationStatus from .reservation import Reservation, ReservationStatus
from .genetic_algorithm import GeneticAlgorithm, ReservationOptimizer from .genetic_algorithm import GeneticAlgorithm, ReservationOptimizer
@@ -14,6 +15,7 @@ __all__ = [
'Classroom', 'Classroom',
'ClassroomResource', 'ClassroomResource',
'Career', 'Career',
'AcademicTerm',
'Subject', 'Subject',
'Commission', 'Commission',
'Reservation', 'Reservation',
+34
View File
@@ -0,0 +1,34 @@
from app import db
from datetime import datetime
class AcademicTerm(db.Model):
"""Modelo para Ciclos Lectivos y Períodos Académicos"""
__tablename__ = 'academic_terms'
id = db.Column(db.Integer, primary_key=True)
code = db.Column(db.String(50), unique=True, nullable=False, index=True) # ej. 2026-2, 2026-1, 2025-2
name = db.Column(db.String(150), nullable=False) # ej. Ciclo Lectivo 2026 - 2º Cuatrimestre
year = db.Column(db.Integer, nullable=False, default=2026, index=True)
period_type = db.Column(db.String(50), nullable=False, default='Segundo Cuatrimestre') # Primer Cuatrimestre, Segundo Cuatrimestre, Anual, Verano
start_date = db.Column(db.Date, nullable=True)
end_date = db.Column(db.Date, nullable=True)
is_current = db.Column(db.Boolean, default=False, nullable=False, index=True)
is_active = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def to_dict(self):
return {
'id': self.id,
'code': self.code,
'name': self.name,
'year': self.year,
'period_type': self.period_type,
'start_date': self.start_date.isoformat() if self.start_date else None,
'end_date': self.end_date.isoformat() if self.end_date else None,
'is_current': self.is_current,
'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None
}
def __repr__(self):
return f'<AcademicTerm {self.code} - {self.name}>'
+10 -10
View File
@@ -108,21 +108,21 @@ BASE_MILESTONE_TYPES = [
'sort_order': 2 'sort_order': 2
}, },
{ {
'name': 'Trabajo Práctico 1', 'name': 'Trabajo Practico 1',
'code': 'tp_1', 'code': 'tp_1',
'color': '#198754', 'color': '#198754',
'description': 'Primera entrega o evaluación práctica', 'description': 'Primera entrega o evaluacion practica',
'sort_order': 3 'sort_order': 3
}, },
{ {
'name': 'Trabajo Práctico 2', 'name': 'Trabajo Practico 2',
'code': 'tp_2', 'code': 'tp_2',
'color': '#157347', 'color': '#157347',
'description': 'Segunda entrega o evaluación práctica', 'description': 'Segunda entrega o evaluacion practica',
'sort_order': 4 'sort_order': 4
}, },
{ {
'name': 'Trabajo Práctico 3', 'name': 'Trabajo Practico 3',
'code': 'tp_3', 'code': 'tp_3',
'color': '#0f5132', 'color': '#0f5132',
'description': 'Tercera entrega o proyecto integrador', 'description': 'Tercera entrega o proyecto integrador',
@@ -132,35 +132,35 @@ BASE_MILESTONE_TYPES = [
'name': 'Recuperatorio Parcial 1', 'name': 'Recuperatorio Parcial 1',
'code': 'recu_p1', 'code': 'recu_p1',
'color': '#fd7e14', 'color': '#fd7e14',
'description': 'Instancia de recuperación para el Parcial 1', 'description': 'Instancia de recuperacion para el Parcial 1',
'sort_order': 6 'sort_order': 6
}, },
{ {
'name': 'Recuperatorio Parcial 2', 'name': 'Recuperatorio Parcial 2',
'code': 'recu_p2', 'code': 'recu_p2',
'color': '#e76b08', 'color': '#e76b08',
'description': 'Instancia de recuperación para el Parcial 2', 'description': 'Instancia de recuperacion para el Parcial 2',
'sort_order': 7 'sort_order': 7
}, },
{ {
'name': 'Recuperatorio TP 1', 'name': 'Recuperatorio TP 1',
'code': 'recu_tp1', 'code': 'recu_tp1',
'color': '#ffc107', 'color': '#ffc107',
'description': 'Reentrega o recuperación de Trabajo Práctico 1', 'description': 'Reentrega o recuperacion de Trabajo Practico 1',
'sort_order': 8 'sort_order': 8
}, },
{ {
'name': 'Recuperatorio TP 2', 'name': 'Recuperatorio TP 2',
'code': 'recu_tp2', 'code': 'recu_tp2',
'color': '#e0a800', 'color': '#e0a800',
'description': 'Reentrega o recuperación de Trabajo Práctico 2', 'description': 'Reentrega o recuperacion de Trabajo Practico 2',
'sort_order': 9 'sort_order': 9
}, },
{ {
'name': 'Recuperatorio TP 3', 'name': 'Recuperatorio TP 3',
'code': 'recu_tp3', 'code': 'recu_tp3',
'color': '#c69500', 'color': '#c69500',
'description': 'Reentrega o recuperación de Trabajo Práctico 3', 'description': 'Reentrega o recuperacion de Trabajo Practico 3',
'sort_order': 10 'sort_order': 10
}, },
{ {
+591
View File
@@ -526,3 +526,594 @@ def api_milestone_types():
return jsonify([t.to_dict() for t in types]) 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
)
+175 -58
View File
@@ -89,49 +89,34 @@ def add_classroom():
@classrooms_bp.route('/list') @classrooms_bp.route('/list')
@login_required @login_required
def list_classrooms(): def list_classrooms():
"""List all classrooms with Building and Floor filters""" """List all classrooms with separation between Physical spaces and Virtual on-demand rooms"""
try: try:
from app.models.building import Building from app.models.building import Building
page = request.args.get('page', 1, type=int) from app.models.reservation import Reservation, ReservationStatus
active_only = request.args.get('active_only', 'true').lower() == 'true' from app.models.subject import Commission, Subject
search = request.args.get('search', '', type=str)
building_filter = request.args.get('building_id', '', type=str).strip()
floor_filter = request.args.get('floor', '', type=str).strip()
query = Classroom.query
if active_only:
query = query.filter_by(is_active=True)
if building_filter and building_filter.isdigit():
query = query.filter(Classroom.building_id == int(building_filter))
if floor_filter:
flr = str(floor_filter).strip()
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
query = query.filter(db.or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
else:
query = query.filter(Classroom.floor == flr)
if search:
query = query.filter(
db.or_(
Classroom.building.ilike(f'%{search}%'),
Classroom.code.ilike(f'%{search}%'),
Classroom.description.ilike(f'%{search}%')
)
)
classrooms = query.order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).paginate(
page=page, per_page=20, error_out=False
)
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
floors = [f[0] for f in db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()]
# Fechas y cálculo de métricas de ocupación (Día, Semana, Mes)
import calendar import calendar
from datetime import date, timedelta from datetime import date, timedelta
page = request.args.get('page', 1, type=int)
active_only = request.args.get('active_only', 'true').lower() == 'true'
search = request.args.get('search', '', type=str).strip()
building_filter = request.args.get('building_id', '', type=str).strip()
floor_filter = request.args.get('floor', '', type=str).strip()
view_mode = request.args.get('view_mode', '').strip().lower()
# Identificar edificio virtual y aula virtual base
virtual_building = Building.query.filter(
db.or_(Building.code == 'VIRTUAL', Building.name.ilike('%Virtual%'))
).first()
virtual_bldg_id = virtual_building.id if virtual_building else None
# Si el usuario seleccionó "Campus Virtual" en el filtro de edificios, conmutar a pestaña virtual
if building_filter and str(building_filter) == str(virtual_bldg_id):
view_mode = 'virtual'
elif not view_mode:
view_mode = 'physical'
# Fechas operativas (Día, Semana, Mes)
today = datetime.utcnow().date() today = datetime.utcnow().date()
today_start = datetime.combine(today, datetime.min.time()) today_start = datetime.combine(today, datetime.min.time())
today_end = datetime.combine(today, datetime.max.time()) today_end = datetime.combine(today, datetime.max.time())
@@ -143,8 +128,62 @@ def list_classrooms():
month_start = datetime(today.year, today.month, 1, 0, 0, 0) month_start = datetime(today.year, today.month, 1, 0, 0, 0)
month_end = datetime(today.year, today.month, last_day, 23, 59, 59) month_end = datetime(today.year, today.month, last_day, 23, 59, 59)
# Consultar en lote las reservas del mes para las aulas visibles date_filters = {
from app.models.reservation import Reservation, ReservationStatus 'today': today.strftime('%Y-%m-%d'),
'week_start': week_start.strftime('%Y-%m-%d'),
'week_end': week_end.strftime('%Y-%m-%d'),
'month_start': month_start.strftime('%Y-%m-%d'),
'month_end': month_end.strftime('%Y-%m-%d')
}
# --- AULAS FÍSICAS (Presenciales) ---
physical_query = Classroom.query
if virtual_bldg_id:
physical_query = physical_query.filter(Classroom.building_id != virtual_bldg_id)
else:
physical_query = physical_query.filter(~Classroom.code.ilike('%VIRTUAL%'))
if active_only:
physical_query = physical_query.filter_by(is_active=True)
physical_count = physical_query.count()
if building_filter and building_filter.isdigit() and str(building_filter) != str(virtual_bldg_id):
physical_query = physical_query.filter(Classroom.building_id == int(building_filter))
if floor_filter:
flr = str(floor_filter).strip()
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
physical_query = physical_query.filter(db.or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
else:
physical_query = physical_query.filter(Classroom.floor == flr)
if search and view_mode == 'physical':
physical_query = physical_query.filter(
db.or_(
Classroom.building.ilike(f'%{search}%'),
Classroom.code.ilike(f'%{search}%'),
Classroom.description.ilike(f'%{search}%')
)
)
classrooms = physical_query.order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).paginate(
page=page, per_page=20, error_out=False
)
# Edificios y Pisos estrictamente físicos para el filtro
buildings = Building.query.filter_by(is_active=True)
if virtual_bldg_id:
buildings = buildings.filter(Building.id != virtual_bldg_id)
buildings = buildings.order_by(Building.name.asc()).all()
floors = [f[0] for f in db.session.query(Classroom.floor).filter(
Classroom.is_active == True,
Classroom.building_id != virtual_bldg_id if virtual_bldg_id else True,
Classroom.floor.isnot(None)
).distinct().order_by(Classroom.floor.asc()).all()]
# Métricas de ocupación física
visible_ids = [c.id for c in classrooms.items] visible_ids = [c.id for c in classrooms.items]
month_reservations = Reservation.query.filter( month_reservations = Reservation.query.filter(
Reservation.classroom_id.in_(visible_ids), Reservation.classroom_id.in_(visible_ids),
@@ -153,10 +192,6 @@ def list_classrooms():
Reservation.status.in_([ReservationStatus.CONFIRMED.value, ReservationStatus.PENDING.value]) Reservation.status.in_([ReservationStatus.CONFIRMED.value, ReservationStatus.PENDING.value])
).all() if visible_ids else [] ).all() if visible_ids else []
# Horas base operativas académicas:
# Día: 15 hs (07:00 a 22:00 = 900 min)
# Semana: 6 días x 15 hs = 5400 min
# Mes: ~24 días hábiles x 15 hs = 21600 min
DAILY_OP_MINS = 15 * 60 DAILY_OP_MINS = 15 * 60
WEEKLY_OP_MINS = 6 * 15 * 60 WEEKLY_OP_MINS = 6 * 15 * 60
MONTHLY_OP_MINS = 24 * 15 * 60 MONTHLY_OP_MINS = 24 * 15 * 60
@@ -164,20 +199,16 @@ def list_classrooms():
occupancy_metrics = {} occupancy_metrics = {}
for c in classrooms.items: for c in classrooms.items:
c_res = [r for r in month_reservations if r.classroom_id == c.id] c_res = [r for r in month_reservations if r.classroom_id == c.id]
# Día
day_res = [r for r in c_res if today_start <= r.start_time <= today_end] 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_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))) if not c.is_virtual else len(day_res) day_pct = min(100, int(round((day_mins / DAILY_OP_MINS) * 100)))
# Semana
week_res = [r for r in c_res if week_start <= r.start_time <= week_end] 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_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))) if not c.is_virtual else len(week_res) week_pct = min(100, int(round((week_mins / WEEKLY_OP_MINS) * 100)))
# Mes
month_mins = sum((min(r.end_time, month_end) - max(r.start_time, month_start)).total_seconds() / 60 for r in c_res) 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))) if not c.is_virtual else len(c_res) month_pct = min(100, int(round((month_mins / MONTHLY_OP_MINS) * 100)))
occupancy_metrics[c.id] = { occupancy_metrics[c.id] = {
'day_pct': day_pct, 'day_pct': day_pct,
@@ -188,12 +219,78 @@ def list_classrooms():
'month_count': len(c_res) 'month_count': len(c_res)
} }
date_filters = { # --- AULAS VIRTUALES (Campus Virtual a Demanda - Infinitas) ---
'today': today.strftime('%Y-%m-%d'), vc = Classroom.query.filter(
'week_start': week_start.strftime('%Y-%m-%d'), Classroom.building_id == virtual_bldg_id if virtual_bldg_id else Classroom.code.ilike('%VIRTUAL%')
'week_end': week_end.strftime('%Y-%m-%d'), ).first()
'month_start': month_start.strftime('%Y-%m-%d'), vc_id = vc.id if vc else 1
'month_end': month_end.strftime('%Y-%m-%d')
all_vres = Reservation.query.filter(Reservation.classroom_id == vc_id).all() if vc_id else []
# Agrupar las salas virtuales por comisión
comm_dict = {}
for r in all_vres:
c = r.commission
cid = c.id if c else 0
if cid not in comm_dict:
vlink = (c.virtual_link if c and c.virtual_link else None) or r.effective_virtual_link
comm_dict[cid] = {
'commission_id': cid,
'commission_code': c.code if c else 'General',
'subject_name': c.subject.name if (c and c.subject) else (r.purpose or 'Clase Virtual'),
'subject_code': c.subject.code if (c and c.subject) else '',
'career_name': c.subject.career.name if (c and c.subject and hasattr(c.subject, 'career') and c.subject.career) else '',
'max_students': c.max_students if c else (r.expected_attendees or 0),
'virtual_link': vlink,
'reservations': []
}
comm_dict[cid]['reservations'].append(r)
if not comm_dict[cid]['virtual_link'] and r.effective_virtual_link:
comm_dict[cid]['virtual_link'] = r.effective_virtual_link
virtual_rooms = []
for cid, data in comm_dict.items():
res_list = data['reservations']
day_cnt = sum(1 for r in res_list if today_start <= r.start_time <= today_end)
week_cnt = sum(1 for r in res_list if week_start <= r.start_time <= week_end)
month_cnt = sum(1 for r in res_list if month_start <= r.start_time <= month_end)
shift = res_list[0].shift if res_list else 'A Demanda'
if search and view_mode == 'virtual':
s_low = search.lower()
matches = (
s_low in data['subject_name'].lower() or
s_low in data['commission_code'].lower() or
s_low in data['subject_code'].lower() or
s_low in data['career_name'].lower()
)
if not matches:
continue
virtual_rooms.append({
'commission_id': data['commission_id'],
'commission_code': data['commission_code'],
'subject_name': data['subject_name'],
'subject_code': data['subject_code'],
'career_name': data['career_name'],
'max_students': data['max_students'],
'virtual_link': data['virtual_link'],
'shift': shift,
'day_count': day_cnt,
'week_count': week_cnt,
'month_count': month_cnt,
'total_count': len(res_list),
'reservations': sorted(res_list, key=lambda x: x.start_time)
})
virtual_rooms.sort(key=lambda x: (x['subject_name'], x['commission_code']))
global_virtual_metrics = {
'today_count': sum(1 for r in all_vres if today_start <= r.start_time <= today_end),
'week_count': sum(1 for r in all_vres if week_start <= r.start_time <= week_end),
'month_count': sum(1 for r in all_vres if month_start <= r.start_time <= month_end),
'total_count': len(all_vres),
'commissions_count': len(comm_dict)
} }
return render_template('classrooms/list.html', return render_template('classrooms/list.html',
@@ -204,6 +301,12 @@ def list_classrooms():
floor_filter=floor_filter, floor_filter=floor_filter,
active_only=active_only, active_only=active_only,
search=search, search=search,
view_mode=view_mode,
physical_count=physical_count,
virtual_count=len(comm_dict),
virtual_rooms=virtual_rooms,
virtual_classroom_id=vc_id,
global_virtual_metrics=global_virtual_metrics,
occupancy_metrics=occupancy_metrics, occupancy_metrics=occupancy_metrics,
date_filters=date_filters, date_filters=date_filters,
title='Classrooms') title='Classrooms')
@@ -213,6 +316,20 @@ def list_classrooms():
return render_template('classrooms/list.html', classrooms=None, title='Classrooms') return render_template('classrooms/list.html', classrooms=None, title='Classrooms')
@classrooms_bp.route('/virtual-room/<int:commission_id>/update-link', methods=['POST'])
@login_required
def update_virtual_link(commission_id):
"""Actualizar enlace de videollamada para una comisión virtual"""
from app.models.subject import Commission
from flask_babel import gettext as _
comm = Commission.query.get_or_404(commission_id)
new_link = request.form.get('virtual_link', '').strip() or None
comm.virtual_link = new_link
db.session.commit()
flash(_('Enlace de videollamada para %(code)s actualizado correctamente.', code=comm.get_full_code()), 'success')
return redirect(request.referrer or url_for('classrooms.list_classrooms', view_mode='virtual'))
@classrooms_bp.route('/<int:id>') @classrooms_bp.route('/<int:id>')
@login_required @login_required
def view_classroom(id): def view_classroom(id):
+3 -1
View File
@@ -654,8 +654,10 @@ def list_reservations():
or_( or_(
Subject.name.ilike(f'%{search}%'), Subject.name.ilike(f'%{search}%'),
Subject.code.ilike(f'%{search}%'), Subject.code.ilike(f'%{search}%'),
Commission.code.ilike(f'%{search}%'),
Classroom.code.ilike(f'%{search}%'), Classroom.code.ilike(f'%{search}%'),
Reservation.purpose.ilike(f'%{search}%') Reservation.purpose.ilike(f'%{search}%'),
Reservation.shift.ilike(f'%{search}%')
) )
) )
@@ -0,0 +1,252 @@
{% extends "base.html" %}
{% block title %}{% trans %}Gestión de Ciclo Lectivo{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<!-- Header -->
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
<div>
<h1 class="h3 fw-bold mb-1">
<i class="bi bi-calendar-range text-primary me-2"></i>{% trans %}Gestión de Ciclo Lectivo y Períodos Académicos{% endtrans %}
</h1>
<p class="text-muted mb-0 small">
{% trans %}Administración de cuatrimestres, años académicos, fechas de inicio/fin y selección del ciclo lectivo activo.{% endtrans %}
</p>
</div>
<div class="d-flex gap-2">
{% if can_edit %}
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#modalAddTerm">
<i class="bi bi-plus-lg me-1"></i>{% trans %}Nuevo Ciclo Lectivo{% endtrans %}
</button>
{% endif %}
<a href="{{ url_for('schedule.commissions_view') }}" class="btn btn-outline-secondary">
<i class="bi bi-diagram-3 me-1"></i>{% trans %}Ver Cursadas{% endtrans %}
</a>
</div>
</div>
<!-- Academic Terms Table -->
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>{% trans %}Código{% endtrans %}</th>
<th>{% trans %}Nombre del Ciclo Lectivo{% endtrans %}</th>
<th>{% trans %}Año{% endtrans %}</th>
<th>{% trans %}Período / Modalidad{% endtrans %}</th>
<th>{% trans %}Fechas del Período{% endtrans %}</th>
<th>{% trans %}Comisiones{% endtrans %}</th>
<th>{% trans %}Estado Institucional{% endtrans %}</th>
{% if can_edit %}
<th class="text-end">{% trans %}Acciones{% endtrans %}</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for term in terms %}
{% set t_stat = stats.get(term.id, {'commissions_count': 0}) %}
<tr class="{% if term.is_current %}table-primary-subtle{% endif %}">
<td>
<span class="badge font-monospace bg-primary-subtle text-primary border fs-6">{{ term.code }}</span>
</td>
<td>
<div class="fw-bold text-body">{{ term.name }}</div>
<small class="text-muted">ID: #{{ term.id }}</small>
</td>
<td>
<span class="fw-semibold text-body">{{ term.year }}</span>
</td>
<td>
<span class="badge bg-secondary-subtle text-secondary border">
{{ term.period_type }}
</span>
</td>
<td>
{% if term.start_date and term.end_date %}
<small class="text-body font-monospace">
<i class="bi bi-calendar3 me-1 text-primary"></i>{{ term.start_date.strftime('%d/%m/%Y') }} - {{ term.end_date.strftime('%d/%m/%Y') }}
</small>
{% else %}
<small class="text-muted">{% trans %}Sin fechas fijadas{% endtrans %}</small>
{% endif %}
</td>
<td>
<span class="badge bg-info-subtle text-info border">
<i class="bi bi-diagram-3 me-1"></i>{{ t_stat.commissions_count }} {% trans %}comisiones{% endtrans %}
</span>
</td>
<td>
{% if term.is_current %}
<span class="badge bg-success text-white px-2 py-1 shadow-sm">
<i class="bi bi-check-circle-fill me-1"></i>{% trans %}Ciclo Activo Actual{% endtrans %}
</span>
{% else %}
<span class="badge bg-secondary-subtle text-secondary border">
{% if term.is_active %}{% trans %}Habilitado{% endtrans %}{% else %}{% trans %}Archivado{% endtrans %}{% endif %}
</span>
{% endif %}
</td>
{% if can_edit %}
<td class="text-end">
<div class="btn-group btn-group-sm">
{% if not term.is_current %}
<form method="POST" action="{{ url_for('admin.academic_term_set_current', id=term.id) }}" class="d-inline"
onsubmit="return confirm('{% trans %}¿Desea establecer este ciclo lectivo como el Activo para todo el sistema?{% endtrans %}');">
<button type="submit" class="btn btn-outline-success py-0 px-2" title="{% trans %}Establecer como Ciclo Activo{% endtrans %}">
<i class="bi bi-check2-circle me-1"></i>{% trans %}Activar{% endtrans %}
</button>
</form>
{% endif %}
<button type="button" class="btn btn-outline-secondary py-0 px-2"
data-bs-toggle="modal" data-bs-target="#modalEditTerm{{ term.id }}"
title="{% trans %}Editar Ciclo Lectivo{% endtrans %}">
<i class="bi bi-pencil"></i>
</button>
</div>
<!-- Modal Editar Ciclo Lectivo -->
<div class="modal fade text-start" id="modalEditTerm{{ term.id }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" action="{{ url_for('admin.academic_term_edit', id=term.id) }}">
<div class="modal-header">
<h5 class="modal-title h6 fw-bold">
<i class="bi bi-pencil-square text-primary me-2"></i>{% trans %}Editar Ciclo Lectivo{% endtrans %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row g-2 mb-3">
<div class="col-md-5">
<label class="form-label small fw-semibold">{% trans %}Código{% endtrans %} *</label>
<input type="text" name="code" class="form-control form-control-sm font-monospace" value="{{ term.code }}" required>
</div>
<div class="col-md-7">
<label class="form-label small fw-semibold">{% trans %}Año Calendario{% endtrans %} *</label>
<input type="number" name="year" class="form-control form-control-sm" value="{{ term.year }}" min="2020" max="2050" required>
</div>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Nombre Descriptivo{% endtrans %} *</label>
<input type="text" name="name" class="form-control form-control-sm" value="{{ term.name }}" required>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Tipo de Período{% endtrans %}</label>
<select name="period_type" class="form-select form-select-sm">
<option value="Primer Cuatrimestre" {% if term.period_type == 'Primer Cuatrimestre' %}selected{% endif %}>Primer Cuatrimestre</option>
<option value="Segundo Cuatrimestre" {% if term.period_type == 'Segundo Cuatrimestre' %}selected{% endif %}>Segundo Cuatrimestre</option>
<option value="Anual" {% if term.period_type == 'Anual' %}selected{% endif %}>Anual</option>
<option value="Curso de Verano" {% if term.period_type == 'Curso de Verano' %}selected{% endif %}>Curso de Verano</option>
</select>
</div>
<div class="row g-2 mb-3">
<div class="col-md-6">
<label class="form-label small fw-semibold">{% trans %}Fecha Inicio{% endtrans %}</label>
<input type="date" name="start_date" class="form-control form-control-sm" value="{{ term.start_date.strftime('%Y-%m-%d') if term.start_date else '' }}">
</div>
<div class="col-md-6">
<label class="form-label small fw-semibold">{% trans %}Fecha Fin{% endtrans %}</label>
<input type="date" name="end_date" class="form-control form-control-sm" value="{{ term.end_date.strftime('%Y-%m-%d') if term.end_date else '' }}">
</div>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" name="is_current" id="isCurr{{ term.id }}" {% if term.is_current %}checked{% endif %}>
<label class="form-check-label small fw-semibold text-primary" for="isCurr{{ term.id }}">
{% trans %}Establecer como Ciclo Lectivo Activo Actual{% endtrans %}
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="is_active" id="isAct{{ term.id }}" {% if term.is_active %}checked{% endif %}>
<label class="form-check-label small" for="isAct{{ term.id }}">{% trans %}Habilitado en el Sistema{% endtrans %}</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<button type="submit" class="btn btn-sm btn-primary">{% trans %}Guardar Cambios{% endtrans %}</button>
</div>
</form>
</div>
</div>
</div>
</td>
{% endif %}
</tr>
{% else %}
<tr>
<td colspan="8" class="text-center py-5 text-muted">
<i class="bi bi-calendar-range fs-1 d-block mb-2"></i>
{% trans %}No se encontraron ciclos lectivos registrados.{% endtrans %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Modal Nuevo Ciclo Lectivo -->
{% if can_edit %}
<div class="modal fade" id="modalAddTerm" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" action="{{ url_for('admin.academic_term_add') }}">
<div class="modal-header">
<h5 class="modal-title h6 fw-bold">
<i class="bi bi-calendar-plus text-primary me-2"></i>{% trans %}Nuevo Ciclo Lectivo / Período Académico{% endtrans %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row g-2 mb-3">
<div class="col-md-5">
<label class="form-label small fw-semibold">{% trans %}Código{% endtrans %} *</label>
<input type="text" name="code" class="form-control form-control-sm font-monospace" placeholder="ej. 2027-1" required>
</div>
<div class="col-md-7">
<label class="form-label small fw-semibold">{% trans %}Año Calendario{% endtrans %} *</label>
<input type="number" name="year" class="form-control form-control-sm" value="2026" min="2020" max="2050" required>
</div>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Nombre Descriptivo{% endtrans %} *</label>
<input type="text" name="name" class="form-control form-control-sm" placeholder="ej. Ciclo Lectivo 2027 - 1º Cuatrimestre" required>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Tipo de Período{% endtrans %}</label>
<select name="period_type" class="form-select form-select-sm">
<option value="Primer Cuatrimestre">Primer Cuatrimestre</option>
<option value="Segundo Cuatrimestre" selected>Segundo Cuatrimestre</option>
<option value="Anual">Anual</option>
<option value="Curso de Verano">Curso de Verano</option>
</select>
</div>
<div class="row g-2 mb-3">
<div class="col-md-6">
<label class="form-label small fw-semibold">{% trans %}Fecha Inicio{% endtrans %}</label>
<input type="date" name="start_date" class="form-control form-control-sm">
</div>
<div class="col-md-6">
<label class="form-label small fw-semibold">{% trans %}Fecha Fin{% endtrans %}</label>
<input type="date" name="end_date" class="form-control form-control-sm">
</div>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="is_current" id="addIsCurr">
<label class="form-check-label small fw-semibold text-primary" for="addIsCurr">
{% trans %}Establecer de inmediato como Ciclo Lectivo Activo Actual{% endtrans %}
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<button type="submit" class="btn btn-sm btn-primary">{% trans %}Crear Ciclo Lectivo{% endtrans %}</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% endblock %}
+208
View File
@@ -0,0 +1,208 @@
{% extends "base.html" %}
{% block title %}{% trans %}Gestión de Carreras{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<!-- Header -->
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
<div>
<h1 class="h3 fw-bold mb-1">
<i class="bi bi-mortarboard text-primary me-2"></i>{% trans %}Gestión de Carreras y Titulaciones{% endtrans %}
</h1>
<p class="text-muted mb-0 small">
{% trans %}Administración de programas académicos, tecnicaturas, licenciaturas y planes de estudio.{% endtrans %}
</p>
</div>
<div class="d-flex gap-2">
{% if can_edit %}
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#modalAddCareer">
<i class="bi bi-plus-lg me-1"></i>{% trans %}Nueva Carrera{% endtrans %}
</button>
{% endif %}
<a href="{{ url_for('admin.subjects_list') }}" class="btn btn-outline-secondary">
<i class="bi bi-book me-1"></i>{% trans %}Ver Asignaturas{% endtrans %}
</a>
</div>
</div>
<!-- Search & Filter Bar -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body py-2">
<form method="GET" class="row g-2 align-items-center">
<div class="col-md-6">
<div class="input-group input-group-sm">
<span class="input-group-text bg-body-tertiary"><i class="bi bi-search"></i></span>
<input type="text" name="search" class="form-control"
placeholder="{% trans %}Buscar carrera por nombre, código o descripción...{% endtrans %}"
value="{{ search }}">
</div>
</div>
<div class="col-md-6 text-md-end">
<button type="submit" class="btn btn-sm btn-primary me-1">
<i class="bi bi-funnel me-1"></i>{% trans %}Filtrar{% endtrans %}
</button>
<a href="{{ url_for('admin.careers_list') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-x-circle me-1"></i>{% trans %}Limpiar{% endtrans %}
</a>
</div>
</form>
</div>
</div>
<!-- Careers Table -->
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>{% trans %}Carrera / Titulación{% endtrans %}</th>
<th>{% trans %}Código{% endtrans %}</th>
<th>{% trans %}Asignaturas{% endtrans %}</th>
<th>{% trans %}Comisiones Activas{% endtrans %}</th>
<th>{% trans %}Descripción{% endtrans %}</th>
{% if can_edit %}
<th class="text-end">{% trans %}Acciones{% endtrans %}</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for career in careers %}
{% set c_stat = stats.get(career.id, {'subjects_count': 0, 'commissions_count': 0}) %}
<tr>
<td>
<div class="d-flex align-items-center">
<div class="rounded-circle p-2 bg-primary-subtle text-primary me-3">
<i class="bi bi-mortarboard-fill"></i>
</div>
<div>
<span class="fw-bold text-body">{{ career.name }}</span>
</div>
</div>
</td>
<td>
{% if career.code %}
<span class="badge font-monospace bg-secondary-subtle text-secondary border">{{ career.code }}</span>
{% else %}
<span class="text-muted small">-</span>
{% endif %}
</td>
<td>
<a href="{{ url_for('admin.subjects_list', career_id=career.id) }}" class="badge bg-info-subtle text-info border text-decoration-none">
<i class="bi bi-book me-1"></i>{{ c_stat.subjects_count }} {% trans %}asignaturas{% endtrans %}
</a>
</td>
<td>
<span class="badge bg-primary-subtle text-primary border">
<i class="bi bi-diagram-3 me-1"></i>{{ c_stat.commissions_count }} {% trans %}comisiones{% endtrans %}
</span>
</td>
<td>
<span class="small text-muted text-truncate d-inline-block" style="max-width: 250px;">
{{ career.description or '-' }}
</span>
</td>
{% if can_edit %}
<td class="text-end">
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-secondary py-0 px-2"
data-bs-toggle="modal" data-bs-target="#modalEditCareer{{ career.id }}"
title="{% trans %}Editar Carrera{% endtrans %}">
<i class="bi bi-pencil"></i>
</button>
{% if c_stat.subjects_count == 0 %}
<form method="POST" action="{{ url_for('admin.career_delete', id=career.id) }}" class="d-inline"
onsubmit="return confirm('{% trans %}¿Confirma que desea eliminar esta carrera permanentemente?{% endtrans %}');">
<button type="submit" class="btn btn-outline-danger py-0 px-2" title="{% trans %}Eliminar{% endtrans %}">
<i class="bi bi-trash"></i>
</button>
</form>
{% endif %}
</div>
<!-- Modal Editar Carrera -->
<div class="modal fade text-start" id="modalEditCareer{{ career.id }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" action="{{ url_for('admin.career_edit', id=career.id) }}">
<div class="modal-header">
<h5 class="modal-title h6 fw-bold">
<i class="bi bi-pencil-square text-primary me-2"></i>{% trans %}Editar Carrera{% endtrans %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Nombre de la Carrera{% endtrans %} *</label>
<input type="text" name="name" class="form-control" value="{{ career.name }}" required>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Código / Sigla{% endtrans %}</label>
<input type="text" name="code" class="form-control" value="{{ career.code or '' }}" placeholder="ej. LIC-SIS, PUEP">
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Descripción{% endtrans %}</label>
<textarea name="description" class="form-control" rows="3">{{ career.description or '' }}</textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<button type="submit" class="btn btn-sm btn-primary">{% trans %}Guardar Cambios{% endtrans %}</button>
</div>
</form>
</div>
</div>
</div>
</td>
{% endif %}
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center py-5 text-muted">
<i class="bi bi-mortarboard fs-1 d-block mb-2"></i>
{% trans %}No se encontraron carreras registradas.{% endtrans %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Modal Nueva Carrera -->
{% if can_edit %}
<div class="modal fade" id="modalAddCareer" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" action="{{ url_for('admin.career_add') }}">
<div class="modal-header">
<h5 class="modal-title h6 fw-bold">
<i class="bi bi-mortarboard text-primary me-2"></i>{% trans %}Nueva Carrera / Titulación{% endtrans %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Nombre de la Carrera{% endtrans %} *</label>
<input type="text" name="name" class="form-control" placeholder="ej. Licenciatura en Tecnologías Digitales" required>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Código / Sigla{% endtrans %}</label>
<input type="text" name="code" class="form-control" placeholder="ej. TEC-DIG">
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Descripción{% endtrans %}</label>
<textarea name="description" class="form-control" rows="3" placeholder="Objetivos y alcance de la titulación..."></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<button type="submit" class="btn btn-sm btn-primary">{% trans %}Crear Carrera{% endtrans %}</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% endblock %}
+343
View File
@@ -0,0 +1,343 @@
{% extends "base.html" %}
{% block title %}{% trans %}Gestión de Ocupación y Métricas{% endtrans %} - Edu-Space Admin{% endblock %}
{% block extra_css %}
<style>
.kpi-card {
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.07);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.kpi-card:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.08) !important;
}
.kpi-icon-circle {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.4rem;
}
.metric-bar-container {
height: 10px;
border-radius: 6px;
background-color: var(--bs-tertiary-bg);
overflow: hidden;
}
.metric-bar-fill {
width: 0;
height: 100%;
border-radius: 6px;
transition: width 0.6s ease;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<!-- Header -->
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
<div>
<h1 class="h3 fw-bold mb-1">
<i class="bi bi-graph-up-arrow text-primary me-2"></i>{% trans %}Gestión de Ocupación y Métricas de Espacios{% endtrans %}
</h1>
<p class="text-muted mb-0 small">
{% trans %}Analítica institucional de aprovechamiento de aulas físicas, demanda en Campus Virtual y distribución horaria.{% endtrans %}
</p>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('classrooms.list_classrooms', view_mode='physical') }}" class="btn btn-outline-primary">
<i class="bi bi-buildings me-1"></i>{% trans %}Aulas Físicas{% endtrans %}
</a>
<a href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}" class="btn btn-outline-info">
<i class="bi bi-globe2 me-1"></i>{% trans %}Campus Virtual (∞){% endtrans %}
</a>
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-secondary">
<i class="bi bi-calendar-week me-1"></i>{% trans %}Ver Calendario{% endtrans %}
</a>
</div>
</div>
<!-- 1. Fila de KPIs Globales de Ocupación -->
<div class="row g-3 mb-4">
<!-- Ocupación Hoy -->
<div class="col-xl-3 col-sm-6">
<div class="card kpi-card border-0 shadow-sm p-3 h-100">
<div class="d-flex justify-content-between align-items-start">
<div>
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.72rem;">{% trans %}Tasa de Ocupación Hoy{% endtrans %}</span>
<h2 class="h3 fw-bold mb-0 text-primary mt-1">{{ avg_day_pct }}%</h2>
</div>
<div class="kpi-icon-circle bg-primary-subtle text-primary">
<i class="bi bi-clock-history"></i>
</div>
</div>
<div class="metric-bar-container mt-3">
<div class="metric-bar-fill bg-primary" data-width="{{ avg_day_pct }}%"></div>
</div>
<small class="text-muted mt-2 d-block" style="font-size: 0.72rem;">
{% trans %}Promedio sobre 15 hs operativas ({{ physical_rooms_count }} aulas presenciales){% endtrans %}
</small>
</div>
</div>
<!-- Ocupación Semana -->
<div class="col-xl-3 col-sm-6">
<div class="card kpi-card border-0 shadow-sm p-3 h-100">
<div class="d-flex justify-content-between align-items-start">
<div>
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.72rem;">{% trans %}Ocupación Semanal{% endtrans %}</span>
<h2 class="h3 fw-bold mb-0 text-info mt-1">{{ avg_week_pct }}%</h2>
</div>
<div class="kpi-icon-circle bg-info-subtle text-info">
<i class="bi bi-calendar-week"></i>
</div>
</div>
<div class="metric-bar-container mt-3">
<div class="metric-bar-fill bg-info" data-width="{{ avg_week_pct }}%"></div>
</div>
<small class="text-muted mt-2 d-block" style="font-size: 0.72rem;">
{% trans %}Semana en curso (Lunes a Sábado){% endtrans %}
</small>
</div>
</div>
<!-- Ocupación Mensual -->
<div class="col-xl-3 col-sm-6">
<div class="card kpi-card border-0 shadow-sm p-3 h-100">
<div class="d-flex justify-content-between align-items-start">
<div>
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.72rem;">{% trans %}Ocupación del Mes{% endtrans %}</span>
<h2 class="h3 fw-bold mb-0 text-success mt-1">{{ avg_month_pct }}%</h2>
</div>
<div class="kpi-icon-circle bg-success-subtle text-success">
<i class="bi bi-calendar3"></i>
</div>
</div>
<div class="metric-bar-container mt-3">
<div class="metric-bar-fill bg-success" data-width="{{ avg_month_pct }}%"></div>
</div>
<small class="text-muted mt-2 d-block" style="font-size: 0.72rem;">
{% trans %}Total mes calendario activo{% endtrans %}
</small>
</div>
</div>
<!-- Clases Totales Dictadas -->
<div class="col-xl-3 col-sm-6">
<div class="card kpi-card border-0 shadow-sm p-3 h-100">
<div class="d-flex justify-content-between align-items-start">
<div>
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.72rem;">{% trans %}Total Clases en el Mes{% endtrans %}</span>
<h2 class="h3 fw-bold mb-0 text-warning mt-1">{{ total_month_classes }}</h2>
</div>
<div class="kpi-icon-circle bg-warning-subtle text-warning">
<i class="bi bi-mortarboard"></i>
</div>
</div>
<div class="d-flex gap-2 mt-3 small font-monospace">
<span class="badge bg-primary-subtle text-primary border">Físicas: {{ total_month_classes - v_month }}</span>
<span class="badge bg-info-subtle text-info border">Virtuales: {{ v_month }}</span>
</div>
<small class="text-muted mt-2 d-block" style="font-size: 0.72rem;">
{% trans %}Reservas y sesiones registradas en el período{% endtrans %}
</small>
</div>
</div>
</div>
<!-- 2. Detalle y Distribución Espacial -->
<div class="row g-4 mb-4">
<!-- Ocupación por Piso / Nivel -->
<div class="col-lg-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
<h5 class="card-title h6 fw-bold mb-0">
<i class="bi bi-layers text-primary me-2"></i>{% trans %}Tasa de Ocupación por Piso / Nivel{% endtrans %}
</h5>
<span class="badge bg-secondary-subtle text-secondary border">Presencial</span>
</div>
<div class="card-body">
<div class="d-flex flex-column gap-3">
{% for fs in floor_summary %}
<div>
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="fw-semibold text-body small">
{{ fs.floor }} ({{ fs.count }} {% trans %}aulas{% endtrans %})
</span>
<span class="fw-bold font-monospace small {% if fs.avg_pct > 50 %}text-danger{% elif fs.avg_pct > 25 %}text-warning{% else %}text-success{% endif %}">
{{ fs.avg_pct }}%
</span>
</div>
<div class="metric-bar-container">
<div class="metric-bar-fill {% if fs.avg_pct > 50 %}bg-danger{% elif fs.avg_pct > 25 %}bg-warning{% else %}bg-success{% endif %}"
data-width="{{ fs.avg_pct }}%"></div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<!-- Distribución por Turno -->
<div class="col-lg-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
<h5 class="card-title h6 fw-bold mb-0">
<i class="bi bi-brightness-high text-warning me-2"></i>{% trans %}Distribución de Clases por Turno{% endtrans %}
</h5>
<span class="badge bg-secondary-subtle text-secondary border">Mes Completo</span>
</div>
<div class="card-body">
<div class="row g-2 text-center mb-3">
<div class="col-3">
<div class="p-2 rounded bg-body-tertiary border">
<div class="small text-muted text-uppercase fw-semibold" style="font-size: 0.68rem;">{% trans %}Mañana{% endtrans %}</div>
<div class="h5 fw-bold text-primary mb-0">{{ shift_counts.Mañana }}</div>
</div>
</div>
<div class="col-3">
<div class="p-2 rounded bg-body-tertiary border">
<div class="small text-muted text-uppercase fw-semibold" style="font-size: 0.68rem;">{% trans %}Tarde{% endtrans %}</div>
<div class="h5 fw-bold text-warning mb-0">{{ shift_counts.Tarde }}</div>
</div>
</div>
<div class="col-3">
<div class="p-2 rounded bg-body-tertiary border">
<div class="small text-muted text-uppercase fw-semibold" style="font-size: 0.68rem;">{% trans %}Vespertino{% endtrans %}</div>
<div class="h5 fw-bold text-info mb-0">{{ shift_counts.Vespertino }}</div>
</div>
</div>
<div class="col-3">
<div class="p-2 rounded bg-body-tertiary border">
<div class="small text-muted text-uppercase fw-semibold" style="font-size: 0.68rem;">{% trans %}Noche{% endtrans %}</div>
<div class="h5 fw-bold text-danger mb-0">{{ shift_counts.Noche }}</div>
</div>
</div>
</div>
<!-- Campus Virtual Box -->
<div class="p-3 rounded bg-primary-subtle border border-primary-subtle">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="fw-bold text-primary mb-1"><i class="bi bi-globe2 me-2"></i>{% trans %}Campus Virtual (A Demanda · ∞){% endtrans %}</h6>
<p class="small text-muted mb-0">{% trans %}Salas simultáneas sin límite físico de cupo ni colisión.{% endtrans %}</p>
</div>
<div class="text-end">
<div class="badge bg-primary text-white fs-6">{{ v_month }} clases mes</div>
<small class="d-block text-muted mt-1">{{ v_today }} hoy · {{ v_week }} semana</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 3. Top Aulas de Mayor Demanda vs Menor Demanda -->
<div class="row g-4">
<!-- Mayor Demanda -->
<div class="col-lg-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-body-tertiary border-0 py-3">
<h5 class="card-title h6 fw-bold mb-0 text-success">
<i class="bi bi-arrow-up-circle-fill me-2"></i>{% trans %}Top Aulas con Mayor Demanda y Ocupación{% endtrans %}
</h5>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0 small">
<thead class="table-light">
<tr>
<th>{% trans %}Aula{% endtrans %}</th>
<th>{% trans %}Piso{% endtrans %}</th>
<th>{% trans %}Capacidad{% endtrans %}</th>
<th>{% trans %}Ocup. Mes{% endtrans %}</th>
<th class="text-end">{% trans %}Acción{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for rm in top_rooms %}
<tr>
<td class="fw-bold text-primary">{{ rm.classroom.code }}</td>
<td>{{ rm.classroom.floor_display }}</td>
<td>{{ rm.classroom.capacity }} asientos</td>
<td>
<span class="badge bg-success-subtle text-success border border-success-subtle font-monospace">
{{ rm.month_pct }}% ({{ rm.month_count }} cl.)
</span>
</td>
<td class="text-end">
<a href="{{ url_for('schedule.list_reservations', classroom_id=rm.classroom.id, date_from=date_filters.month_start, date_to=date_filters.month_end) }}"
class="btn btn-sm btn-outline-primary py-0 px-2" style="font-size: 0.72rem;">
{% trans %}Ver Reservas{% endtrans %}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Menor Demanda / Oportunidades -->
<div class="col-lg-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-body-tertiary border-0 py-3">
<h5 class="card-title h6 fw-bold mb-0 text-secondary">
<i class="bi bi-arrow-down-circle-fill me-2"></i>{% trans %}Aulas con Mayor Disponibilidad (Menor Uso){% endtrans %}
</h5>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0 small">
<thead class="table-light">
<tr>
<th>{% trans %}Aula{% endtrans %}</th>
<th>{% trans %}Piso{% endtrans %}</th>
<th>{% trans %}Capacidad{% endtrans %}</th>
<th>{% trans %}Ocup. Mes{% endtrans %}</th>
<th class="text-end">{% trans %}Acción{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for rm in low_rooms %}
<tr>
<td class="fw-bold text-body">{{ rm.classroom.code }}</td>
<td>{{ rm.classroom.floor_display }}</td>
<td>{{ rm.classroom.capacity }} asientos</td>
<td>
<span class="badge bg-secondary-subtle text-secondary border font-monospace">
{{ rm.month_pct }}% ({{ rm.month_count }} cl.)
</span>
</td>
<td class="text-end">
<a href="{{ url_for('schedule.add_reservation') }}?classroom_id={{ rm.classroom.id }}"
class="btn btn-sm btn-outline-success py-0 px-2" style="font-size: 0.72rem;">
{% trans %}Reservar{% endtrans %}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.metric-bar-fill[data-width]').forEach(function(bar) {
bar.style.width = bar.getAttribute('data-width');
});
});
</script>
{% endblock %}
+306
View File
@@ -0,0 +1,306 @@
{% extends "base.html" %}
{% block title %}{% trans %}Gestión de Asignaturas{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<!-- Header -->
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
<div>
<h1 class="h3 fw-bold mb-1">
<i class="bi bi-book text-primary me-2"></i>{% trans %}Gestión de Asignaturas y Materias{% endtrans %}
</h1>
<p class="text-muted mb-0 small">
{% trans %}Catálogo institucional de asignaturas, códigos, créditos académicos y carreras vinculadas.{% endtrans %}
</p>
</div>
<div class="d-flex gap-2">
{% if can_edit %}
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#modalAddSubject">
<i class="bi bi-plus-lg me-1"></i>{% trans %}Nueva Asignatura{% endtrans %}
</button>
{% endif %}
<a href="{{ url_for('schedule.commissions_view') }}" class="btn btn-outline-secondary">
<i class="bi bi-diagram-3 me-1"></i>{% trans %}Ver Comisiones{% endtrans %}
</a>
</div>
</div>
<!-- Filters Bar -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body py-2">
<form method="GET" class="row g-2 align-items-center">
<!-- Buscador -->
<div class="col-md-4">
<div class="input-group input-group-sm">
<span class="input-group-text bg-body-tertiary"><i class="bi bi-search"></i></span>
<input type="text" name="search" class="form-control"
placeholder="{% trans %}Buscar por código, nombre o departamento...{% endtrans %}"
value="{{ search }}">
</div>
</div>
<!-- Filtro Carrera -->
<div class="col-md-4">
<div class="input-group input-group-sm">
<span class="input-group-text bg-body-tertiary"><i class="bi bi-mortarboard text-primary"></i></span>
<select name="career_id" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">{% trans %}Todas las carreras{% endtrans %}</option>
{% for c in careers %}
<option value="{{ c.id }}" {% if career_filter == c.id %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
</div>
</div>
<!-- Filtro Estado -->
<div class="col-md-2">
<select name="active" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">{% trans %}Todos los estados{% endtrans %}</option>
<option value="true" {% if active_filter == 'true' %}selected{% endif %}>{% trans %}Solo activas{% endtrans %}</option>
<option value="false" {% if active_filter == 'false' %}selected{% endif %}>{% trans %}Solo inactivas{% endtrans %}</option>
</select>
</div>
<div class="col-md-2 text-md-end">
<button type="submit" class="btn btn-sm btn-primary me-1">
<i class="bi bi-funnel me-1"></i>{% trans %}Filtrar{% endtrans %}
</button>
<a href="{{ url_for('admin.subjects_list') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-x-circle me-1"></i>{% trans %}Limpiar{% endtrans %}
</a>
</div>
</form>
</div>
</div>
<!-- Subjects Table -->
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>{% trans %}Código{% endtrans %}</th>
<th>{% trans %}Nombre de la Asignatura{% endtrans %}</th>
<th>{% trans %}Carrera{% endtrans %}</th>
<th>{% trans %}Departamento{% endtrans %}</th>
<th>{% trans %}Créditos{% endtrans %}</th>
<th>{% trans %}Comisiones{% endtrans %}</th>
<th>{% trans %}Estado{% endtrans %}</th>
{% if can_edit %}
<th class="text-end">{% trans %}Acciones{% endtrans %}</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for s in subjects %}
<tr>
<td>
<span class="badge font-monospace bg-primary-subtle text-primary border">{{ s.code }}</span>
</td>
<td>
<span class="fw-bold text-body">{{ s.name }}</span>
{% if s.description %}
<small class="text-muted d-block text-truncate" style="max-width: 300px;">{{ s.description }}</small>
{% endif %}
</td>
<td>
{% if s.career_obj %}
<span class="badge bg-secondary-subtle text-secondary border">
<i class="bi bi-mortarboard me-1"></i>{{ s.career_obj.name }}
</span>
{% else %}
<span class="text-muted small">General</span>
{% endif %}
</td>
<td>
<span class="small text-muted">{{ s.department or '-' }}</span>
</td>
<td>
<span class="badge bg-light text-dark border">{{ s.credits }} cr.</span>
</td>
<td>
<span class="badge bg-info-subtle text-info border">
<i class="bi bi-diagram-3 me-1"></i>{{ s.commissions|length if s.commissions else 0 }}
</span>
</td>
<td>
<span class="badge rounded-pill {% if s.is_active %}bg-success-subtle text-success border border-success-subtle{% else %}bg-secondary-subtle text-secondary border{% endif %}">
{% if s.is_active %}{% trans %}Activa{% endtrans %}{% else %}{% trans %}Inactiva{% endtrans %}{% endif %}
</span>
</td>
{% if can_edit %}
<td class="text-end">
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-secondary py-0 px-2"
data-bs-toggle="modal" data-bs-target="#modalEditSubject{{ s.id }}"
title="{% trans %}Editar Asignatura{% endtrans %}">
<i class="bi bi-pencil"></i>
</button>
<form method="POST" action="{{ url_for('admin.subject_toggle', id=s.id) }}" class="d-inline">
<button type="submit" class="btn btn-outline-warning py-0 px-2"
title="{% if s.is_active %}{% trans %}Desactivar{% endtrans %}{% else %}{% trans %}Activar{% endtrans %}{% endif %}">
<i class="bi {% if s.is_active %}bi-pause{% else %}bi-play{% endif %}"></i>
</button>
</form>
</div>
<!-- Modal Editar Asignatura -->
<div class="modal fade text-start" id="modalEditSubject{{ s.id }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" action="{{ url_for('admin.subject_edit', id=s.id) }}">
<div class="modal-header">
<h5 class="modal-title h6 fw-bold">
<i class="bi bi-pencil-square text-primary me-2"></i>{% trans %}Editar Asignatura{% endtrans %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row g-2 mb-3">
<div class="col-md-5">
<label class="form-label small fw-semibold">{% trans %}Código{% endtrans %} *</label>
<input type="text" name="code" class="form-control form-control-sm font-monospace" value="{{ s.code }}" required>
</div>
<div class="col-md-7">
<label class="form-label small fw-semibold">{% trans %}Créditos{% endtrans %}</label>
<input type="number" name="credits" class="form-control form-control-sm" value="{{ s.credits }}" min="1" max="20">
</div>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Nombre de la Asignatura{% endtrans %} *</label>
<input type="text" name="name" class="form-control form-control-sm" value="{{ s.name }}" required>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Carrera Vinculada{% endtrans %}</label>
<select name="career_id" class="form-select form-select-sm">
<option value="">{% trans %}Sin carrera específica (General){% endtrans %}</option>
{% for c in careers %}
<option value="{{ c.id }}" {% if s.career_id == c.id %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Departamento / Área{% endtrans %}</label>
<input type="text" name="department" class="form-control form-control-sm" value="{{ s.department or '' }}">
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Descripción / Objetivos{% endtrans %}</label>
<textarea name="description" class="form-control form-control-sm" rows="2">{{ s.description or '' }}</textarea>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="is_active" id="activeSubj{{ s.id }}" {% if s.is_active %}checked{% endif %}>
<label class="form-check-label small" for="activeSubj{{ s.id }}">{% trans %}Asignatura Activa{% endtrans %}</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<button type="submit" class="btn btn-sm btn-primary">{% trans %}Guardar Cambios{% endtrans %}</button>
</div>
</form>
</div>
</div>
</div>
</td>
{% endif %}
</tr>
{% else %}
<tr>
<td colspan="8" class="text-center py-5 text-muted">
<i class="bi bi-book fs-1 d-block mb-2"></i>
{% trans %}No se encontraron asignaturas con los criterios indicados.{% endtrans %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Paginación -->
{% if pagination.pages > 1 %}
<div class="card-footer bg-body-tertiary border-0 py-2">
<nav aria-label="Subjects pagination">
<ul class="pagination pagination-sm justify-content-center mb-0">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin.subjects_list', page=pagination.prev_num, search=search, career_id=career_filter, active=active_filter) }}">{% trans %}Anterior{% endtrans %}</a>
</li>
{% endif %}
{% for p in pagination.iter_pages(left_edge=2, right_edge=2, left_current=2, right_current=2) %}
{% if p %}
<li class="page-item {% if p == pagination.page %}active{% endif %}">
<a class="page-link" href="{{ url_for('admin.subjects_list', page=p, search=search, career_id=career_filter, active=active_filter) }}">{{ p }}</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link">…</span></li>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin.subjects_list', page=pagination.next_num, search=search, career_id=career_filter, active=active_filter) }}">{% trans %}Siguiente{% endtrans %}</a>
</li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
</div>
</div>
<!-- Modal Nueva Asignatura -->
{% if can_edit %}
<div class="modal fade" id="modalAddSubject" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" action="{{ url_for('admin.subject_add') }}">
<div class="modal-header">
<h5 class="modal-title h6 fw-bold">
<i class="bi bi-book text-primary me-2"></i>{% trans %}Nueva Asignatura{% endtrans %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row g-2 mb-3">
<div class="col-md-5">
<label class="form-label small fw-semibold">{% trans %}Código{% endtrans %} *</label>
<input type="text" name="code" class="form-control form-control-sm font-monospace" placeholder="ej. ASIG0015" required>
</div>
<div class="col-md-7">
<label class="form-label small fw-semibold">{% trans %}Créditos{% endtrans %}</label>
<input type="number" name="credits" class="form-control form-control-sm" value="4" min="1" max="20">
</div>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Nombre de la Asignatura{% endtrans %} *</label>
<input type="text" name="name" class="form-control form-control-sm" placeholder="ej. Álgebra Lineal" required>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Carrera Vinculada{% endtrans %}</label>
<select name="career_id" class="form-select form-select-sm">
<option value="">{% trans %}Sin carrera específica (General){% endtrans %}</option>
{% for c in careers %}
<option value="{{ c.id }}">{{ c.name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Departamento / Área{% endtrans %}</label>
<input type="text" name="department" class="form-control form-control-sm" placeholder="ej. Ciencias Exactas, Informática">
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Descripción{% endtrans %}</label>
<textarea name="description" class="form-control form-control-sm" rows="2" placeholder="Programa de la materia..."></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<button type="submit" class="btn btn-sm btn-primary">{% trans %}Crear Asignatura{% endtrans %}</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% endblock %}
+24 -11
View File
@@ -104,19 +104,32 @@
{% if current_user.is_authenticated and current_user.can_manage() %} {% if current_user.is_authenticated and current_user.can_manage() %}
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"> <a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-shield-lock"></i> {% trans %}Management{% endtrans %} <i class="bi bi-shield-lock"></i> {% trans %}Gestión{% endtrans %}
</a> </a>
<ul class="dropdown-menu shadow-sm"> <ul class="dropdown-menu shadow-sm" style="min-width: 260px;">
<li><a class="dropdown-item" href="{{ url_for('admin.users_list') }}"><i class="bi bi-people me-2"></i>{% trans %}Users{% endtrans %}</a></li> <!-- Espacios & Infraestructura -->
<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 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><hr class="dropdown-divider"></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><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><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms') }}"><i class="bi bi-door-open me-2 text-info"></i>{% trans %}Aulas y Métricas{% 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{% endtrans %}</a></li> <li><hr class="dropdown-divider my-1"></li>
<li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}"><i class="bi bi-buildings me-2 text-secondary"></i>{% trans %}Buildings{% endtrans %}</a></li> <!-- Administración & Sistema -->
<li><a class="dropdown-item" href="{{ url_for('admin.milestone_types_list') }}"><i class="bi bi-tags me-2 text-warning"></i>{% trans %}Tipificaciones{% endtrans %}</a></li> <li class="dropdown-header text-uppercase small fw-bold text-muted py-1" style="font-size: 0.68rem; letter-spacing: 0.5px;">{% trans %}Administración & Seguridad{% endtrans %}</li>
<li><hr class="dropdown-divider"></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.google_sheets_import') }}"><i class="bi bi-file-earmark-spreadsheet me-2 text-success"></i>{% trans %}Import from Google Sheets{% endtrans %}</a></li> <li><a class="dropdown-item" href="{{ url_for('admin.roles_list') }}"><i class="bi bi-person-badge me-2"></i>{% trans %}Roles y Permisos{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.milestone_types_list') }}"><i class="bi bi-tags me-2 text-secondary"></i>{% trans %}Tipificaciones de Hitos{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.google_sheets_import') }}"><i class="bi bi-file-earmark-spreadsheet me-2 text-success"></i>{% trans %}Sincronización Sheets{% endtrans %}</a></li>
</ul> </ul>
</li> </li>
{% endif %} {% endif %}
+542 -44
View File
@@ -1,17 +1,63 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{% trans %}Classrooms{% endtrans %} - Edu-Space Admin{% endblock %} {% block title %}{% trans %}Aulas y Espacios{% endtrans %} - Edu-Space Admin{% endblock %}
{% block extra_css %} {% block extra_css %}
<style> <style>
.classroom-card { .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; transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
border-radius: 0.65rem; border-radius: 0.65rem;
} }
.classroom-card:hover { .classroom-card:hover, .virtual-room-card:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.08) !important; 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 { .metric-pill {
padding: 0.35rem 0.2rem; padding: 0.35rem 0.2rem;
border-radius: 0.45rem; border-radius: 0.45rem;
@@ -84,27 +130,68 @@
{% block content %} {% block content %}
<div class="container-fluid pt-5 mt-4"> <div class="container-fluid pt-5 mt-4">
<div class="row">
<!-- 1. Encabezado y Selector de Entorno (Presencial vs Virtual) -->
<div class="row mb-3">
<div class="col-12"> <div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
<h1 class="h3">
<i class="bi bi-door-open text-primary"></i> {% trans %}Classrooms{% endtrans %}
</h1>
<div> <div>
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary"> <h1 class="h3 mb-1">
<i class="bi bi-plus-circle"></i> {% trans %}Add Classroom{% endtrans %} <i class="bi bi-door-open text-primary me-2"></i>{% trans %}Aulas y Espacios de Aprendizaje{% endtrans %}
</a> </h1>
<p class="text-muted small mb-0">{% trans %}Administre las aulas físicas de sedes presenciales y las aulas virtuales a demanda del Campus Virtual.{% endtrans %}</p>
</div> </div>
<div>
{% if view_mode == 'virtual' %}
<a href="{{ url_for('schedule.add_reservation') }}?is_virtual=1" class="btn btn-success shadow-sm">
<i class="bi bi-camera-video me-1"></i>{% trans %}Nueva Clase Virtual a Demanda{% endtrans %}
</a>
{% else %}
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary shadow-sm">
<i class="bi bi-plus-circle me-1"></i>{% trans %}Agregar Aula Física{% endtrans %}
</a>
{% endif %}
</div>
</div>
<!-- Pestañas de Selección de Modalidad -->
<div class="mt-3">
<ul class="nav nav-pills custom-space-pills gap-2">
<li class="nav-item">
<a class="nav-link {% if view_mode == 'physical' %}active{% endif %}"
href="{{ url_for('classrooms.list_classrooms', view_mode='physical', active_only='true' if active_only else 'false') }}">
<i class="bi bi-buildings me-2"></i>{% trans %}Aulas Presenciales{% endtrans %}
<span class="badge rounded-pill ms-2 {% if view_mode == 'physical' %}bg-light text-primary{% else %}bg-secondary-subtle text-secondary border{% endif %}">
{{ physical_count }} {% trans %}aulas{% endtrans %}
</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if view_mode == 'virtual' %}active{% endif %}"
href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}">
<i class="bi bi-globe2 me-2 text-info"></i>{% trans %}Campus Virtual & Aulas a Demanda{% endtrans %}
<span class="badge rounded-pill ms-2 {% if view_mode == 'virtual' %}bg-light text-primary{% else %}bg-primary-subtle text-primary border{% endif %}">
<i class="bi bi-infinity me-1"></i>{{ virtual_count }} {% trans %}salas activas{% endtrans %}
</span>
</a>
</li>
</ul>
</div> </div>
</div> </div>
</div> </div>
<!-- Filters --> <!-- ========================================================================= -->
<div class="row"> <!-- VISTA 1: AULAS FÍSICAS (PRESENCIALES) -->
<!-- ========================================================================= -->
{% if view_mode == 'physical' %}
<!-- Filtros de Aulas Físicas -->
<div class="row mb-3">
<div class="col-12"> <div class="col-12">
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-body"> <div class="card-body py-2">
<form method="GET" class="row g-2 align-items-center"> <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="col-md-3">
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<span class="input-group-text bg-body-tertiary"><i class="bi bi-search"></i></span> <span class="input-group-text bg-body-tertiary"><i class="bi bi-search"></i></span>
@@ -114,12 +201,12 @@
</div> </div>
</div> </div>
<!-- Filtro por Edificio --> <!-- Filtro por Edificio Físico -->
<div class="col-md-3"> <div class="col-md-3">
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<span class="input-group-text bg-body-tertiary"><i class="bi bi-buildings text-primary"></i></span> <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()"> <select name="building_id" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">{% trans %}Todos los edificios{% endtrans %}</option> <option value="">{% trans %}Todos los edificios presenciales{% endtrans %}</option>
{% for b in buildings %} {% for b in buildings %}
<option value="{{ b.id }}" {% if building_filter == (b.id|string) %}selected{% endif %}>{{ b.name }}</option> <option value="{{ b.id }}" {% if building_filter == (b.id|string) %}selected{% endif %}>{{ b.name }}</option>
{% endfor %} {% endfor %}
@@ -127,7 +214,7 @@
</div> </div>
</div> </div>
<!-- Filtro por Piso --> <!-- Filtro por Piso Real -->
<div class="col-md-2"> <div class="col-md-2">
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<span class="input-group-text bg-body-tertiary"><i class="bi bi-layers text-secondary"></i></span> <span class="input-group-text bg-body-tertiary"><i class="bi bi-layers text-secondary"></i></span>
@@ -144,7 +231,7 @@
<div class="col-md-2"> <div class="col-md-2">
<div class="form-check pt-1"> <div class="form-check pt-1">
<input class="form-check-input" type="checkbox" name="active_only" <input class="form-check-input" type="checkbox" name="active_only" value="true"
id="activeOnly" {% if active_only %}checked{% endif %} onchange="this.form.submit()"> id="activeOnly" {% if active_only %}checked{% endif %} onchange="this.form.submit()">
<label class="form-check-label small" for="activeOnly"> <label class="form-check-label small" for="activeOnly">
{% trans %}Solo activas{% endtrans %} {% trans %}Solo activas{% endtrans %}
@@ -156,7 +243,7 @@
<button type="submit" class="btn btn-sm btn-primary me-1"> <button type="submit" class="btn btn-sm btn-primary me-1">
<i class="bi bi-filter"></i> {% trans %}Filtrar{% endtrans %} <i class="bi bi-filter"></i> {% trans %}Filtrar{% endtrans %}
</button> </button>
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-sm btn-outline-secondary"> <a href="{{ url_for('classrooms.list_classrooms', view_mode='physical') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-x-circle"></i> {% trans %}Limpiar{% endtrans %} <i class="bi bi-x-circle"></i> {% trans %}Limpiar{% endtrans %}
</a> </a>
</div> </div>
@@ -166,7 +253,7 @@
</div> </div>
</div> </div>
<!-- Classrooms Grid --> <!-- Grilla de Aulas Físicas -->
{% if classrooms and classrooms.items %} {% if classrooms and classrooms.items %}
<div class="row g-3 mt-1"> <div class="row g-3 mt-1">
{% for classroom in classrooms.items %} {% for classroom in classrooms.items %}
@@ -199,21 +286,15 @@
<!-- Capacidad y Recursos --> <!-- 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;"> <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> <div>
<span class="text-muted">{% trans %}Cap:{% endtrans %}</span> <span class="text-muted">{% trans %}Capacidad:{% endtrans %}</span>
<strong> <strong class="text-body ms-1">{{ classroom.capacity }} {% trans %}asientos{% endtrans %}</strong>
{% if classroom.is_unlimited %}
<span class="badge bg-primary-subtle text-primary border"><i class="bi bi-infinity"></i></span>
{% else %}
{{ classroom.capacity }}
{% endif %}
</strong>
</div> </div>
<div> <div>
<span class="text-muted"><i class="bi bi-tools me-1"></i>{{ classroom.resources|length if classroom.resources else 0 }}</span> <span class="text-muted"><i class="bi bi-tools me-1"></i>{{ classroom.resources|length if classroom.resources else 0 }}</span>
</div> </div>
</div> </div>
<!-- Barra de Métricas de Ocupación (Día, Semana, Mes) --> <!-- 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}) %} {% 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="classroom-occupancy-bar p-2 rounded bg-body-tertiary border mb-2">
<div class="d-flex justify-content-between align-items-center mb-1"> <div class="d-flex justify-content-between align-items-center mb-1">
@@ -231,7 +312,7 @@
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 %}" 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 %}"> title="{% trans %}Ocupación Hoy{% endtrans %}: {{ metrics.day_count }} {% trans %}clases registradas{% endtrans %}">
<div class="metric-label">{% trans %}Hoy{% endtrans %}</div> <div class="metric-label">{% trans %}Hoy{% endtrans %}</div>
<div class="metric-val">{% if classroom.is_virtual %}{{ metrics.day_count }} cl.{% else %}{{ metrics.day_pct }}%{% endif %}</div> <div class="metric-val">{{ metrics.day_pct }}%</div>
</a> </a>
</div> </div>
<!-- Semana --> <!-- Semana -->
@@ -240,7 +321,7 @@
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 %}" 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 %}"> title="{% trans %}Ocupación Semana{% endtrans %}: {{ metrics.week_count }} {% trans %}clases registradas{% endtrans %}">
<div class="metric-label">{% trans %}Semana{% endtrans %}</div> <div class="metric-label">{% trans %}Semana{% endtrans %}</div>
<div class="metric-val">{% if classroom.is_virtual %}{{ metrics.week_count }} cl.{% else %}{{ metrics.week_pct }}%{% endif %}</div> <div class="metric-val">{{ metrics.week_pct }}%</div>
</a> </a>
</div> </div>
<!-- Mes --> <!-- Mes -->
@@ -249,7 +330,7 @@
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 %}" 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 %}"> title="{% trans %}Ocupación Mes{% endtrans %}: {{ metrics.month_count }} {% trans %}clases registradas{% endtrans %}">
<div class="metric-label">{% trans %}Mes{% endtrans %}</div> <div class="metric-label">{% trans %}Mes{% endtrans %}</div>
<div class="metric-val">{% if classroom.is_virtual %}{{ metrics.month_count }} cl.{% else %}{{ metrics.month_pct }}%{% endif %}</div> <div class="metric-val">{{ metrics.month_pct }}%</div>
</a> </a>
</div> </div>
</div> </div>
@@ -285,14 +366,14 @@
{% endfor %} {% endfor %}
</div> </div>
<!-- Pagination --> <!-- Paginación Aulas Físicas -->
{% if classrooms.pages > 1 %} {% if classrooms.pages > 1 %}
<nav aria-label="Classroom pagination"> <nav aria-label="Classroom pagination" class="mt-4">
<ul class="pagination justify-content-center"> <ul class="pagination pagination-sm justify-content-center">
{% if classrooms.has_prev %} {% if classrooms.has_prev %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=classrooms.prev_num, search=search, active_only=active_only) }}"> <a class="page-link" href="{{ url_for('classrooms.list_classrooms', view_mode='physical', page=classrooms.prev_num, search=search, active_only='true' if active_only else 'false', building_id=building_filter, floor=floor_filter) }}">
{% trans %}Previous{% endtrans %} {% trans %}Anterior{% endtrans %}
</a> </a>
</li> </li>
{% endif %} {% endif %}
@@ -301,7 +382,7 @@
{% if page_num %} {% if page_num %}
{% if page_num != classrooms.page %} {% if page_num != classrooms.page %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=page_num, search=search, active_only=active_only) }}"> <a class="page-link" href="{{ url_for('classrooms.list_classrooms', view_mode='physical', page=page_num, search=search, active_only='true' if active_only else 'false', building_id=building_filter, floor=floor_filter) }}">
{{ page_num }} {{ page_num }}
</a> </a>
</li> </li>
@@ -319,8 +400,8 @@
{% if classrooms.has_next %} {% if classrooms.has_next %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=classrooms.next_num, search=search, active_only=active_only) }}"> <a class="page-link" href="{{ url_for('classrooms.list_classrooms', view_mode='physical', page=classrooms.next_num, search=search, active_only='true' if active_only else 'false', building_id=building_filter, floor=floor_filter) }}">
{% trans %}Next{% endtrans %} {% trans %}Siguiente{% endtrans %}
</a> </a>
</li> </li>
{% endif %} {% endif %}
@@ -333,20 +414,437 @@
<div class="col-12"> <div class="col-12">
<div class="text-center py-5"> <div class="text-center py-5">
<i class="bi bi-door-open text-muted" style="font-size: 4rem;"></i> <i class="bi bi-door-open text-muted" style="font-size: 4rem;"></i>
<h4 class="mt-3 text-muted">{% trans %}No classrooms found{% endtrans %}</h4> <h4 class="mt-3 text-muted">{% trans %}No se encontraron aulas físicas{% endtrans %}</h4>
<p class="text-muted"> <p class="text-muted">
{% if search %} {% if search %}
{% trans %}No classrooms match your search criteria.{% endtrans %} {% trans %}Ningún aula coincide con los criterios de búsqueda.{% endtrans %}
{% else %} {% else %}
{% trans %}No classrooms have been registered yet.{% endtrans %} {% trans %}No hay aulas físicas registradas en esta sede.{% endtrans %}
{% endif %} {% endif %}
</p> </p>
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary"> <a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> {% trans %}Add First Classroom{% endtrans %} <i class="bi bi-plus-circle"></i> {% trans %}Agregar Primera Aula{% endtrans %}
</a> </a>
</div> </div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
<!-- ========================================================================= -->
<!-- VISTA 2: CAMPUS VIRTUAL (AULAS A DEMANDA · INFINITAS) -->
<!-- ========================================================================= -->
{% else %}
<!-- Banner de Infraestructura Cloud y Concurrencia Ilimitada -->
<div class="card virtual-banner p-3 mb-4 shadow-sm">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2 mb-3">
<div>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-primary text-white px-2 py-1"><i class="bi bi-cloud-check-fill me-1"></i>Cloud Infrastructure</span>
<h5 class="mb-0 fw-bold text-primary">
{% trans %}Campus Virtual UniCABA · Aulas a Demanda{% endtrans %}
</h5>
</div>
<p class="small text-muted mb-0 mt-1">
{% trans %}A diferencia de los espacios físicos, las aulas virtuales se crean y escalan de manera <strong>infinita y concurrente</strong> según la necesidad pedagógica de cada comisión.{% endtrans %}
</p>
</div>
<div class="d-flex gap-2 align-items-center">
<span class="badge bg-success-subtle text-success border border-success-subtle px-3 py-2 fs-6">
<i class="bi bi-infinity me-1"></i>{% trans %}Capacidad Ilimitada{% endtrans %}
</span>
<a href="{{ url_for('schedule.add_reservation') }}?is_virtual=1" class="btn btn-sm btn-primary">
<i class="bi bi-plus-lg me-1"></i>{% trans %}Programar Clase Virtual{% endtrans %}
</a>
</div>
</div>
<!-- Indicadores de Demanda Global -->
<div class="row g-2">
<div class="col-lg-3 col-sm-6">
<a href="{{ url_for('schedule.today_schedule') }}" class="virtual-stat-box">
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.7rem;">{% trans %}Sesiones Hoy{% endtrans %}</span>
<h4 class="mb-0 fw-bold text-primary">{{ global_virtual_metrics.today_count }}</h4>
</div>
<div class="text-primary fs-4"><i class="bi bi-calendar-event"></i></div>
</div>
<small class="text-muted d-block mt-1" style="font-size: 0.72rem;">{% trans %}Clases virtuales programadas hoy{% endtrans %}</small>
</a>
</div>
<div class="col-lg-3 col-sm-6">
<a href="{{ url_for('schedule.list_reservations', classroom_id=virtual_classroom_id, date_from=date_filters.week_start, date_to=date_filters.week_end) }}" class="virtual-stat-box">
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.7rem;">{% trans %}Sesiones Semana{% endtrans %}</span>
<h4 class="mb-0 fw-bold text-info">{{ global_virtual_metrics.week_count }}</h4>
</div>
<div class="text-info fs-4"><i class="bi bi-calendar-week"></i></div>
</div>
<small class="text-muted d-block mt-1" style="font-size: 0.72rem;">{% trans %}Clases virtuales esta semana{% endtrans %}</small>
</a>
</div>
<div class="col-lg-3 col-sm-6">
<a href="{{ url_for('schedule.list_reservations', classroom_id=virtual_classroom_id, date_from=date_filters.month_start, date_to=date_filters.month_end) }}" class="virtual-stat-box">
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.7rem;">{% trans %}Sesiones del Mes{% endtrans %}</span>
<h4 class="mb-0 fw-bold text-success">{{ global_virtual_metrics.month_count }}</h4>
</div>
<div class="text-success fs-4"><i class="bi bi-calendar3"></i></div>
</div>
<small class="text-muted d-block mt-1" style="font-size: 0.72rem;">{% trans %}Total clases remotas en el mes{% endtrans %}</small>
</a>
</div>
<div class="col-lg-3 col-sm-6">
<div class="virtual-stat-box">
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.7rem;">{% trans %}Salas Activas{% endtrans %}</span>
<h4 class="mb-0 fw-bold text-warning">{{ global_virtual_metrics.commissions_count }}</h4>
</div>
<div class="text-warning fs-4"><i class="bi bi-diagram-3"></i></div>
</div>
<small class="text-muted d-block mt-1" style="font-size: 0.72rem;">{% trans %}Comisiones con entorno virtual{% endtrans %}</small>
</div>
</div>
</div>
</div>
<!-- Barra de Búsqueda de Salas Virtuales -->
<div class="row mb-3">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-body py-2">
<form method="GET" class="row g-2 align-items-center">
<input type="hidden" name="view_mode" value="virtual">
<div class="col-md-6">
<div class="input-group input-group-sm">
<span class="input-group-text bg-body-tertiary"><i class="bi bi-search"></i></span>
<input type="text" name="search" class="form-control"
placeholder="{% trans %}Buscar sala virtual por asignatura, comisión o carrera...{% endtrans %}"
value="{{ search }}">
</div>
</div>
<div class="col-md-6 text-md-end">
<button type="submit" class="btn btn-sm btn-primary me-1">
<i class="bi bi-funnel me-1"></i>{% trans %}Buscar Salas{% endtrans %}
</button>
<a href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-counterclockwise me-1"></i>{% trans %}Limpiar{% endtrans %}
</a>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Catálogo de Aulas Virtuales por Comisión -->
{% if virtual_rooms %}
<div class="row g-3">
{% for room in virtual_rooms %}
<div class="col-xxl-3 col-xl-3 col-lg-4 col-md-6 col-sm-12 mb-2">
<div class="card virtual-room-card border shadow-sm h-100">
<div class="card-body p-3 d-flex flex-column justify-content-between">
<div>
<!-- Encabezado de la Sala Virtual -->
<div class="d-flex justify-content-between align-items-start mb-2">
<div class="text-truncate me-2">
<h5 class="card-title mb-0 fw-bold text-primary text-truncate fs-6" title="{{ room.subject_name }}">
<i class="bi bi-laptop me-1 text-info"></i>{{ room.subject_name }}
</h5>
</div>
<span class="badge rounded-pill bg-info-subtle text-info border border-info-subtle" style="font-size: 0.68rem;">
<i class="bi bi-infinity me-1"></i>A Demanda
</span>
</div>
<!-- Badges: Comisión, Carrera y Turno -->
<div class="d-flex flex-wrap gap-1 mb-2">
<span class="badge bg-primary-subtle text-primary border" style="font-size: 0.7rem;">
<i class="bi bi-diagram-3 me-1"></i>Com. {{ room.commission_code }}
</span>
{% if room.subject_code %}
<span class="badge bg-secondary-subtle text-secondary border" style="font-size: 0.7rem;">
{{ room.subject_code }}
</span>
{% endif %}
<span class="badge bg-light text-dark border" style="font-size: 0.7rem;">
{{ room.shift }}
</span>
</div>
<!-- Cupo e Información Pedagógica -->
<div class="d-flex justify-content-between align-items-center small py-1 px-2 bg-body-tertiary rounded mb-2" style="font-size: 0.75rem;">
<div>
<span class="text-muted">{% trans %}Alumnos:{% endtrans %}</span>
<strong class="text-body ms-1">{{ room.max_students }}</strong>
</div>
<div>
<span class="text-muted">{% trans %}Modalidad:{% endtrans %}</span>
<strong class="text-info ms-1">Remoto ∞</strong>
</div>
</div>
<!-- Botón de Videollamada (Zoom / Meet / Teams) -->
<div class="mb-2">
{% if room.virtual_link %}
<a href="{{ room.virtual_link }}" target="_blank"
class="btn btn-sm btn-outline-success w-100 py-1 d-flex align-items-center justify-content-center"
style="font-size: 0.75rem;" title="{% trans %}Abrir enlace de videollamada{% endtrans %}">
<i class="bi bi-camera-video-fill me-1"></i>{% trans %}Unirse a Videollamada{% endtrans %}
</a>
{% else %}
<div class="p-1 rounded bg-body-tertiary border text-center small text-muted" style="font-size: 0.72rem;">
<i class="bi bi-link-45deg me-1"></i>{% trans %}Enlace a demanda por clase{% endtrans %}
</div>
{% endif %}
</div>
<!-- Barra de Demanda de la Sala Virtual (Hoy, Semana, Mes) -->
<div class="p-2 rounded bg-body-tertiary border mb-2">
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="small fw-semibold text-muted" style="font-size: 0.72rem;">
<i class="bi bi-calendar3 me-1 text-primary"></i>{% trans %}Clases a Demanda{% endtrans %}
</span>
<span class="text-muted" style="font-size: 0.65rem;">
{{ room.total_count }} {% trans %}totales{% endtrans %}
</span>
</div>
<div class="row g-1 text-center">
<!-- Hoy -->
<div class="col-4">
<div class="p-1 border metric-pill {% if room.day_count > 0 %}metric-low{% else %}metric-zero{% endif %}"
data-bs-toggle="modal" data-bs-target="#modalPreviewVirtual{{ room.commission_id }}"
role="button" title="{% trans %}Ver vista previa de clases de hoy{% endtrans %}">
<div class="metric-label">{% trans %}Hoy{% endtrans %}</div>
<div class="metric-val">{{ room.day_count }} cl.</div>
</div>
</div>
<!-- Semana -->
<div class="col-4">
<div class="p-1 border metric-pill {% if room.week_count > 0 %}metric-medium{% else %}metric-zero{% endif %}"
data-bs-toggle="modal" data-bs-target="#modalPreviewVirtual{{ room.commission_id }}"
role="button" title="{% trans %}Ver vista previa de clases de la semana{% endtrans %}">
<div class="metric-label">{% trans %}Semana{% endtrans %}</div>
<div class="metric-val">{{ room.week_count }} cl.</div>
</div>
</div>
<!-- Mes -->
<div class="col-4">
<div class="p-1 border metric-pill {% if room.month_count > 0 %}metric-high{% else %}metric-zero{% endif %}"
data-bs-toggle="modal" data-bs-target="#modalPreviewVirtual{{ room.commission_id }}"
role="button" title="{% trans %}Ver vista previa de clases del mes{% endtrans %}">
<div class="metric-label">{% trans %}Mes{% endtrans %}</div>
<div class="metric-val">{{ room.month_count }} cl.</div>
</div>
</div>
</div>
</div>
</div>
<!-- Footer con Botones -->
<div class="pt-2 border-top d-flex justify-content-between align-items-center gap-1">
<button type="button" class="btn btn-sm btn-primary py-1 px-2 d-flex align-items-center"
data-bs-toggle="modal" data-bs-target="#modalPreviewVirtual{{ room.commission_id }}"
style="font-size: 0.75rem;" title="{% trans %}Vista previa y modificar sesiones{% endtrans %}">
<i class="bi bi-eye me-1"></i>{% trans %}Vista Previa{% endtrans %}
<span class="badge bg-white text-primary ms-1" style="font-size: 0.65rem;">{{ room.total_count }}</span>
</button>
<a href="{{ url_for('schedule.add_reservation') }}?commission_id={{ room.commission_id }}&is_virtual=1"
class="btn btn-sm btn-outline-success py-1 px-2" style="font-size: 0.75rem;" title="{% trans %}Programar clase en esta sala virtual{% endtrans %}">
<i class="bi bi-plus-lg me-1"></i>{% trans %}Programar{% endtrans %}
</a>
</div>
</div>
</div>
<!-- Modal Vista Previa y Modificación de la Sala Virtual -->
<div class="modal fade text-start" id="modalPreviewVirtual{{ room.commission_id }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<div>
<h5 class="modal-title h6 fw-bold mb-1">
<i class="bi bi-laptop text-primary me-2"></i>{{ room.subject_name }}
</h5>
<div class="d-flex flex-wrap gap-1">
<span class="badge bg-primary-subtle text-primary border" style="font-size: 0.7rem;">
<i class="bi bi-diagram-3 me-1"></i>Comisión {{ room.commission_code }}
</span>
{% if room.subject_code %}
<span class="badge bg-secondary-subtle text-secondary border font-monospace" style="font-size: 0.7rem;">
{{ room.subject_code }}
</span>
{% endif %}
<span class="badge bg-info-subtle text-info border" style="font-size: 0.7rem;">
<i class="bi bi-infinity me-1"></i>{% trans %}A Demanda · Capacidad Ilimitada{% endtrans %}
</span>
<span class="badge bg-light text-dark border" style="font-size: 0.7rem;">
{{ room.shift }}
</span>
</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<!-- 1. Enlace de Videollamada de la Comisión (Edición directa) -->
<div class="card bg-body-tertiary border mb-3">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="fw-semibold small text-body">
<i class="bi bi-camera-video text-primary me-1"></i>{% trans %}Enlace de Videollamada (Zoom / Meet / Teams / Moodle){% endtrans %}
</span>
{% if room.virtual_link %}
<a href="{{ room.virtual_link }}" target="_blank" class="btn btn-sm btn-success py-0 px-2" style="font-size: 0.75rem;">
<i class="bi bi-box-arrow-up-right me-1"></i>{% trans %}Abrir Reunión{% endtrans %}
</a>
{% endif %}
</div>
<form method="POST" action="{{ url_for('classrooms.update_virtual_link', commission_id=room.commission_id) }}">
<div class="input-group input-group-sm">
<span class="input-group-text bg-body border"><i class="bi bi-link-45deg"></i></span>
<input type="url" name="virtual_link" class="form-control"
placeholder="{% trans %}Ingresar URL de Zoom, Google Meet, Teams, etc.{% endtrans %}"
value="{{ room.virtual_link or '' }}">
<button type="submit" class="btn btn-primary">
<i class="bi bi-save me-1"></i>{% trans %}Guardar Enlace{% endtrans %}
</button>
</div>
<small class="text-muted d-block mt-1" style="font-size: 0.7rem;">
{% trans %}Este enlace se aplicará a todas las clases y cursadas virtuales de esta comisión.{% endtrans %}
</small>
</form>
</div>
</div>
<!-- 2. Tabla de Clases / Sesiones Programadas -->
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="fw-bold mb-0 text-body small">
<i class="bi bi-calendar3 me-1 text-primary"></i>{% trans %}Sesiones Programadas en el Campus Virtual{% endtrans %} ({{ room.reservations|length }})
</h6>
<a href="{{ url_for('schedule.add_reservation') }}?commission_id={{ room.commission_id }}&is_virtual=1" class="btn btn-sm btn-outline-success py-0 px-2" style="font-size: 0.72rem;">
<i class="bi bi-plus-lg me-1"></i>{% trans %}Nueva Clase{% endtrans %}
</a>
</div>
{% if room.reservations %}
<div class="table-responsive border rounded mb-2">
<table class="table table-sm table-hover align-middle mb-0 small" style="font-size: 0.78rem;">
<thead class="table-light">
<tr>
<th>#ID</th>
<th>{% trans %}Fecha y Horario{% endtrans %}</th>
<th>{% trans %}Turno / Propósito{% endtrans %}</th>
<th>{% trans %}Docente{% endtrans %}</th>
<th>{% trans %}Videollamada{% endtrans %}</th>
<th>{% trans %}Estado{% endtrans %}</th>
<th class="text-end">{% trans %}Acciones{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for r in room.reservations %}
<tr>
<td class="text-muted">#{{ r.id }}</td>
<td>
<span class="fw-bold text-body">{{ r.start_time.strftime('%d/%m/%Y') }}</span>
<span class="text-muted d-block font-monospace" style="font-size: 0.72rem;">
{{ r.start_time.strftime('%H:%M') }} - {{ r.end_time.strftime('%H:%M') }} hs
</span>
</td>
<td>
<span class="badge bg-light text-dark border">{{ r.shift or 'Tarde' }}</span>
<small class="text-muted d-block text-truncate" style="max-width: 130px;">{{ r.purpose or 'Clase regular' }}</small>
</td>
<td>
<small class="text-body">{{ r.user.name if r.user else 'Docente Asignado' }}</small>
</td>
<td>
{% if r.effective_virtual_link %}
<a href="{{ r.effective_virtual_link }}" target="_blank" class="badge bg-success-subtle text-success border border-success-subtle text-decoration-none" title="{{ r.effective_virtual_link }}">
<i class="bi bi-camera-video me-1"></i>{% trans %}Link Activo{% endtrans %}
</a>
{% else %}
<span class="text-muted small">{% trans %}Hereda comisión{% endtrans %}</span>
{% endif %}
</td>
<td>
<span class="badge bg-success-subtle text-success border border-success-subtle" style="font-size: 0.68rem;">
{{ r.status }}
</span>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<a href="{{ url_for('schedule.edit_reservation', id=r.id) }}"
class="btn btn-sm btn-primary py-0 px-2" style="font-size: 0.72rem;"
title="{% trans %}Modificar Fecha, Horario, Aula o Detalles{% endtrans %}">
<i class="bi bi-pencil me-1"></i>{% trans %}Modificar{% endtrans %}
</a>
<a href="{{ url_for('schedule.view_reservation', id=r.id) }}"
class="btn btn-sm btn-outline-secondary py-0 px-2" style="font-size: 0.72rem;"
title="{% trans %}Ver Ficha Completa{% endtrans %}">
<i class="bi bi-eye"></i>
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info py-2 small mb-2">
<i class="bi bi-info-circle me-1"></i>{% trans %}No hay clases programadas para esta sala virtual en el período actual.{% endtrans %}
</div>
{% endif %}
</div>
<div class="modal-footer d-flex justify-content-between">
<a href="{{ url_for('schedule.list_reservations', classroom_id=virtual_classroom_id, search=room.subject_code or room.subject_name) }}"
class="btn btn-sm btn-outline-secondary">
<i class="bi bi-list-check me-1"></i>{% trans %}Ver en Listado de Reservas (Filtrado){% endtrans %}
</a>
<div class="d-flex gap-2">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cerrar{% endtrans %}</button>
<a href="{{ url_for('schedule.add_reservation') }}?commission_id={{ room.commission_id }}&is_virtual=1" class="btn btn-sm btn-success">
<i class="bi bi-plus-lg me-1"></i>{% trans %}Programar Nueva Clase{% endtrans %}
</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="row mt-4">
<div class="col-12">
<div class="text-center py-5">
<i class="bi bi-globe text-muted" style="font-size: 4rem;"></i>
<h4 class="mt-3 text-muted">{% trans %}No se encontraron salas virtuales{% endtrans %}</h4>
<p class="text-muted">
{% if search %}
{% trans %}Ninguna sala virtual coincide con los términos de búsqueda.{% endtrans %}
{% else %}
{% trans %}No hay clases virtuales programadas en el Campus Virtual.{% endtrans %}
{% endif %}
</p>
<a href="{{ url_for('schedule.add_reservation') }}?is_virtual=1" class="btn btn-primary">
<i class="bi bi-camera-video me-1"></i>{% trans %}Crear Primera Reserva Virtual{% endtrans %}
</a>
</div>
</div>
</div>
{% endif %}
{% endif %}
</div> </div>
{% endblock %} {% endblock %}
-6
View File
@@ -8,12 +8,6 @@
<div class="col-12"> <div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<div> <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 active">{% trans %}New Reservation{% endtrans %}</li>
</ol>
</nav>
<h1 class="h3 mb-0"> <h1 class="h3 mb-0">
<i class="bi bi-calendar-plus text-primary me-2"></i>{% trans %}New Reservation{% endtrans %} <i class="bi bi-calendar-plus text-primary me-2"></i>{% trans %}New Reservation{% endtrans %}
</h1> </h1>
-6
View File
@@ -516,12 +516,6 @@
<!-- Header --> <!-- Header -->
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4"> <div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
<div> <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 %}Comisiones{% endtrans %}</li>
</ol>
</nav>
<h1 class="h3 fw-bold mb-1"> <h1 class="h3 fw-bold mb-1">
<i class="bi bi-diagram-3 text-primary me-2"></i>{% trans %}Gestión de Comisiones y Cursadas{% endtrans %} <i class="bi bi-diagram-3 text-primary me-2"></i>{% trans %}Gestión de Comisiones y Cursadas{% endtrans %}
</h1> </h1>
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# ==============================================================================
# backup_db.sh — Admin Edu-Space (UniCABA)
# Generador de backups de PostgreSQL y actualización de classrooms_db.sql
# Uso: bash backup_db.sh
# ==============================================================================
set -euo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
ok() { echo -e "${GREEN} ✔ $*${RESET}"; }
info() { echo -e "${CYAN} ▸ $*${RESET}"; }
warn() { echo -e "${YELLOW} ⚠ $*${RESET}"; }
err() { echo -e "${RED} ✘ $*${RESET}" >&2; exit 1; }
hdr() { echo -e "\n${BOLD}${CYAN}══ $* ══${RESET}\n"; }
APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BACKUP_DIR="${APP_DIR}/backups"
TIMESTAMP="$(date +'%Y%m%d_%H%M%S')"
BACKUP_FILE="${BACKUP_DIR}/classrooms_db_${TIMESTAMP}.sql"
ROOT_BACKUP="${APP_DIR}/classrooms_db.sql"
APP_ENV="${APP_DIR}/.env"
mkdir -p "$BACKUP_DIR"
hdr "Iniciando Backup de la Base de Datos"
# 1. Obtener parámetros de conexión
DB_NAME="classrooms_db"
DB_USER="eduspace_user"
DB_HOST="localhost"
DB_PORT="5432"
DB_PASSWORD=""
if [[ -f "$APP_ENV" ]]; then
set -a
# shellcheck disable=SC1090
source <(grep -v '^#' "$APP_ENV" | grep '=')
set +a
if [[ -n "${DATABASE_URL:-}" ]]; then
# Extraer credenciales desde DATABASE_URL si existe
# Formato: postgresql://usuario:pass@host:port/dbname
DB_USER=$(echo "$DATABASE_URL" | sed -E 's|.*://([^:]+):?([^@]*)@.*|\1|')
DB_PASSWORD=$(echo "$DATABASE_URL" | sed -E 's|.*://[^:]+:([^@]*)@.*|\1|')
DB_HOST=$(echo "$DATABASE_URL" | sed -E 's|.*@([^:/]+):?.*|\1|')
DB_PORT=$(echo "$DATABASE_URL" | sed -E 's|.*:([0-9]+)/.*|\1|')
DB_NAME=$(echo "$DATABASE_URL" | sed -E 's|.*/([^?]+).*|\1|')
fi
fi
DB_USER="${DB_USER:-postgres}"
DB_PASSWORD="${DB_PASSWORD:-password}"
DB_NAME="${DB_NAME:-classrooms_db}"
DB_HOST="${DB_HOST:-localhost}"
DB_PORT="${DB_PORT:-5432}"
info "Base de datos: ${DB_NAME}"
info "Usuario: ${DB_USER}"
info "Host: ${DB_HOST}:${DB_PORT}"
# 2. Ejecutar pg_dump
info "Generando volcado SQL..."
export PGPASSWORD="${DB_PASSWORD:-}"
if command -v pg_dump >/dev/null 2>&1; then
# Intentar como usuario configurado
if pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" \
--no-owner --no-privileges --clean --if-exists > "$BACKUP_FILE" 2>/dev/null; then
ok "Volcado generado con éxito usando pg_dump"
elif [[ $EUID -eq 0 ]] || sudo -n true 2>/dev/null; then
if command -v sudo >/dev/null 2>&1; then
sudo -u postgres pg_dump -d "$DB_NAME" --no-owner --no-privileges > "$BACKUP_FILE"
elif command -v runuser >/dev/null 2>&1; then
runuser -u postgres -- pg_dump -d "$DB_NAME" --no-owner --no-privileges > "$BACKUP_FILE"
else
su - postgres -c "pg_dump -d \"$DB_NAME\" --no-owner --no-privileges" > "$BACKUP_FILE"
fi
ok "Volcado generado como usuario postgres"
else
err "No se pudo realizar el backup. Verifica tus permisos y credenciales."
fi
else
err "No se encontró la herramienta 'pg_dump'. Instala postgresql-client."
fi
# 3. Limpiar comandos de versiones específicas (ej: \restrict de PG 18) para máxima compatibilidad
sed -i '/^\\restrict/d' "$BACKUP_FILE" 2>/dev/null || true
# 4. Actualizar copia en raíz para despliegues automáticos
cp "$BACKUP_FILE" "$ROOT_BACKUP"
BACKUP_SIZE=$(du -h "$BACKUP_FILE" | awk '{print $1}')
hdr "Resumen del Backup"
ok "Archivo histórico: ${BACKUP_FILE} (${BACKUP_SIZE})"
ok "Copia para deploy: ${ROOT_BACKUP} (${BACKUP_SIZE})"
info "Para restaurar en otro servidor:"
echo -e " ${CYAN}sudo -u postgres psql -d ${DB_NAME} < ${ROOT_BACKUP}${RESET}"
echo ""
+2044
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -1,10 +1,16 @@
import os import os
from dotenv import load_dotenv
basedir = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) basedir = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
load_dotenv(os.path.join(basedir, '.env'))
class Config: class Config:
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production' SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'postgresql://postgres:password@localhost:5432/classrooms_db'
_db_url = os.environ.get('DATABASE_URL') or 'postgresql://postgres:password@localhost:5432/classrooms_db'
if _db_url.startswith('postgres://'):
_db_url = _db_url.replace('postgres://', 'postgresql://', 1)
SQLALCHEMY_DATABASE_URI = _db_url
SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_TRACK_MODIFICATIONS = False
API_BASE_URL = os.environ.get('API_BASE_URL') or 'http://localhost:8080/api' API_BASE_URL = os.environ.get('API_BASE_URL') or 'http://localhost:8080/api'
+16 -12
View File
@@ -66,7 +66,7 @@ def init_database():
default_roles = [ default_roles = [
{ {
'name': 'Admin', 'name': 'Admin',
'description': 'Control total institucional, infraestructura, gestión académica y accesos RBAC', 'description': 'Control total institucional, infraestructura, gestion academica y accesos RBAC',
'is_system': True, 'is_system': True,
'permissions': { 'permissions': {
'classrooms': 'read_write', 'classrooms': 'read_write',
@@ -82,7 +82,7 @@ def init_database():
}, },
{ {
'name': 'Docente', 'name': 'Docente',
'description': 'Gestión de reservas de cursada, consulta de cronograma y materias', 'description': 'Gestion de reservas de cursada, consulta de cronograma y materias',
'is_system': False, 'is_system': False,
'permissions': { 'permissions': {
'classrooms': 'read', 'classrooms': 'read',
@@ -97,8 +97,8 @@ def init_database():
} }
}, },
{ {
'name': 'Bedelía', 'name': 'Bedelia',
'description': 'Gestión operativa de aulas, sedes, reservas, comisiones, importación y cartelera', 'description': 'Gestion operativa de aulas, sedes, reservas, comisiones, importacion y cartelera',
'is_system': False, 'is_system': False,
'permissions': { 'permissions': {
'classrooms': 'read_write', 'classrooms': 'read_write',
@@ -189,7 +189,7 @@ def init_database():
if not admin.theme_preference: if not admin.theme_preference:
admin.theme_preference = "auto" admin.theme_preference = "auto"
db.session.commit() db.session.commit()
print(f"[INFO] El usuario admin (admin@edu-space.com) está activo y vinculado al rol '{admin_role.name if admin_role else 'ADMIN'}'.") print(f"[INFO] El usuario admin (admin@edu-space.com) esta activo y vinculado al rol '{admin_role.name if admin_role else 'ADMIN'}'.")
# 5. Inicializar edificios institucionales oficiales # 5. Inicializar edificios institucionales oficiales
print("[*] Verificando edificios institucionales...") print("[*] Verificando edificios institucionales...")
@@ -199,13 +199,13 @@ def init_database():
b_central = Building( b_central = Building(
name='Edificio Central', name='Edificio Central',
code='CENTRAL', code='CENTRAL',
address='Tte. Gral. Juan Domingo Perón 802, CABA', address='Tte. Gral. Juan Domingo Peron 802, CABA',
floors=4, floors=4,
description='Sede central institucional de UniCABA. Aulas de grado, auditorio y laboratorios.' description='Sede central institucional de UniCABA. Aulas de grado, auditorio y laboratorios.'
) )
db.session.add(b_central) db.session.add(b_central)
else: else:
b_central.address = 'Tte. Gral. Juan Domingo Perón 802, CABA' b_central.address = 'Tte. Gral. Juan Domingo Peron 802, CABA'
b_virtual = Building.query.filter_by(name='Campus Virtual').first() b_virtual = Building.query.filter_by(name='Campus Virtual').first()
if not b_virtual: if not b_virtual:
@@ -220,8 +220,8 @@ def init_database():
db.session.commit() db.session.commit()
print("[OK] Edificios institucionales configurados con dirección oficial.") print("[OK] Edificios institucionales configurados con dirección oficial.")
# 6. Inicializar catálogo dinámico de tipificaciones de hitos académicos # 6. Inicializar catalogo dinamico de tipificaciones de hitos academicos
print("[*] Verificando catálogo de tipificaciones de hitos académicos...") print("[*] Verificando catalogo de tipificaciones de hitos academicos...")
from app.models.milestone import MilestoneType, BASE_MILESTONE_TYPES from app.models.milestone import MilestoneType, BASE_MILESTONE_TYPES
for item in BASE_MILESTONE_TYPES: for item in BASE_MILESTONE_TYPES:
mt = MilestoneType.query.filter_by(code=item['code']).first() mt = MilestoneType.query.filter_by(code=item['code']).first()
@@ -241,14 +241,18 @@ def init_database():
if not mt.sort_order: if not mt.sort_order:
mt.sort_order = item['sort_order'] mt.sort_order = item['sort_order']
db.session.commit() db.session.commit()
print(f"[OK] Catálogo de tipificaciones inicializado ({MilestoneType.query.count()} tipos disponibles).") print(f"[OK] Catalogo de tipificaciones inicializado ({MilestoneType.query.count()} tipos disponibles).")
print("\n[LISTO] Base de datos inicializada y lista para usar.") print("\n[LISTO] Base de datos inicializada y lista para usar.")
except Exception as e: except Exception as e:
print(f"\n[ERROR] Error al inicializar la base de datos: {str(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("\nVerifica que:")
print("1. El servicio de PostgreSQL esté corriendo.") print("1. El servicio de PostgreSQL este corriendo.")
print("2. La base de datos 'classrooms_db' haya sido creada.") print("2. La base de datos 'classrooms_db' haya sido creada.")
print("3. La variable DATABASE_URL en el archivo .env tenga el usuario y contraseña correctos.") print("3. La variable DATABASE_URL en el archivo .env tenga el usuario y contraseña correctos.")
sys.exit(1) sys.exit(1)
+474
View File
@@ -0,0 +1,474 @@
#!/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 ""
+4 -3
View File
@@ -7,12 +7,13 @@ Flask-CORS==6.0.2
Flask-Babel==4.0.0 Flask-Babel==4.0.0
WTForms==3.1.0 WTForms==3.1.0
Werkzeug==3.0.1 Werkzeug==3.0.1
SQLAlchemy==2.0.23 SQLAlchemy>=2.0.32
python-dotenv==1.0.0 python-dotenv==1.0.0
requests==2.31.0 requests==2.31.0
bleach==6.1.0 bleach==6.1.0
python-dateutil==2.8.2 python-dateutil==2.8.2
Pillow==10.1.0 Pillow>=10.4.0
email_validator>=2.0.0 email_validator>=2.0.0
psycopg2-binary==2.9.9 psycopg2-binary>=2.9.10
Babel==2.14.0 Babel==2.14.0
gunicorn>=21.2.0
View File