Compare commits
28
Commits
c9dc3d9208
...
v1.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa878cb0d5 | ||
|
|
566908f1de | ||
|
|
c748ae4075 | ||
|
|
6218657f36 | ||
|
|
747d20ed38 | ||
|
|
b64a771068 | ||
|
|
340bf3aa8c | ||
|
|
80f6018b6a | ||
|
|
4e49311208 | ||
|
|
b367b106bf | ||
|
|
0cea3dca44 | ||
|
|
eea7adb1d0 | ||
|
|
e0c58409cd | ||
|
|
9b6678724c | ||
|
|
f462d57ad9 | ||
|
|
7ef2ba8a5b | ||
|
|
169aa45746 | ||
|
|
f31e2bf760 | ||
|
|
63c9c6f935 | ||
|
|
5e91e131f0 | ||
|
|
c7ce5e4e67 | ||
|
|
957f364cc3 | ||
|
|
7cd70be274 | ||
|
|
5dcf3b75f1 | ||
|
|
7933523d7c | ||
|
|
df098e5116 | ||
|
|
bbe8e220ae | ||
|
|
adb6ba729d |
@@ -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
@@ -4,7 +4,7 @@
|
||||
# ==============================================================================
|
||||
|
||||
# Entorno Flask
|
||||
FLASK_APP=app.py
|
||||
FLASK_APP=wsgi.py
|
||||
FLASK_ENV=production
|
||||
|
||||
# Seguridad: Clave secreta para sesiones y protección CSRF
|
||||
|
||||
@@ -57,6 +57,9 @@ cover/
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Examples
|
||||
example/
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
|
||||
+100
-6
@@ -1,6 +1,6 @@
|
||||
# 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)
|
||||
**Rama:** `dev`
|
||||
|
||||
@@ -345,11 +345,105 @@
|
||||
* **Calendario Semanal:** Agregado el filtro **Filtrar por Edificio** en la barra superior junto al filtro de Piso.
|
||||
* **Modal Estilo Google Calendar:** La sección de espacio físico desglosa limpiamente en badges: 🏢 Edificio, 🪜 Piso y 🚪 Aula.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 21. Corrección de Linter y Renderizado de Barras en Métricas de Ocupación (2026-09-04)
|
||||
* **Plantilla de Métricas de Ocupación ([app/templates/admin/metrics/occupancy.html](file:///c:/Workspace/admin-edu-space/app/templates/admin/metrics/occupancy.html)):**
|
||||
* **Eliminación de Falsos Positivos de CSS en el IDE:** Se resolvieron los 12 errores de sintaxis (`property value expected`, `at-rule or selector expected`) reportados por el analizador HTML/CSS del IDE en las tarjetas de KPI (Tasa de Ocupación Hoy, Semanal, Mensual) y en la distribución por Piso / Nivel.
|
||||
* **Atributos Semánticos HTML5:** Se sustituyó la inyección directa de Jinja2 en atributos `style="width: {{ ... }}%;"` por el atributo de datos estándar `data-width="{{ ... }}%"`.
|
||||
* **Inicialización y Animación Dinámica en JS:**
|
||||
* Se configuró en CSS (`.metric-bar-fill { width: 0; }`) un ancho base inicial.
|
||||
* Se implementó un script en el bloque `extra_js` que asigna el ancho dinámico al cargar el DOM (`DOMContentLoaded`).
|
||||
* En conjunto con `transition: width 0.6s ease;`, las barras se expanden de forma fluida y visualmente atractiva al cargar la pantalla.
|
||||
|
||||
---
|
||||
|
||||
## 22. Actualización de Instalador, Backup de Base de Datos y Despliegue en Proxmox LXC (2026-09-04)
|
||||
* **Backup de Base de Datos ([classrooms_db.sql](file:///c:/Workspace/admin-edu-space/classrooms_db.sql)):**
|
||||
* **Extracción del Snapshot Completo:** Volcado íntegro de la base de datos PostgreSQL activa (`classrooms_db`) que alimenta el sistema en producción/local.
|
||||
* **Contenido Respaldado:** 25 aulas físicas y virtuales, 206 comisiones, 206 asignaturas, 412 reservas, 24 carreras, 12 tipos de hitos, matrices de permisos RBAC y usuarios administradores.
|
||||
* **Compatibilidad Multi-Versión:** Se sanitizó la directiva `\restrict` específica de PostgreSQL 18 para garantizar compatibilidad nativa con PostgreSQL 14, 15, 16, 17 y 18.
|
||||
* **Actualización del Instalador del Sistema para Proxmox LXC ([install.sh](file:///c:/Workspace/admin-edu-space/install.sh)):**
|
||||
* **Ejecución 100% Automatizada (Zero-Touch):** Se eliminaron todos los prompts interactivos (`read -rp`), permitiendo que el script corra de inicio a fin de forma totalmente desatendida.
|
||||
* **Copia de Configuración Activa del Sistema:** Se configuraron como valores por defecto los parámetros reales del sistema en ejecución (`DATABASE_URL=postgresql://postgres:password@localhost:5432/classrooms_db`, usuario `postgres`, base `classrooms_db` y contraseña `password`), garantizando paridad exacta con el entorno local.
|
||||
* **Compatibilidad Dual de Usuarios:** Configuración tanto del superusuario `postgres` como de `eduspace_user` con los mismos privilegios para compatibilidad inmediata ante cualquier variación de `.env`.
|
||||
* **Optimización Proxmox LXC:** Compatible con contenedores Debian 11/12 y Ubuntu 20.04/22.04/24.04 en Proxmox VE.
|
||||
* **Migración Automática de Directorio Robusta:** Si el repositorio se clona en `/root`, se traslada automáticamente a `/opt/admin-edu-space` mediante sincronización directa con `cp -a`, limpiando posibles directorios anidados residuales y deteniendo el servicio previamente para evitar errores de colisión (`Directory not empty`).
|
||||
* **Despliegue del Motor PostgreSQL Nativo:** Instalación desatendida del motor en el LXC, inicialización dinámica del servicio y bucle de healthcheck de conexión.
|
||||
* **Restauración Automatizada y Permisos:** Detección automática de `classrooms_db.sql`, recreación limpia, asignación de permisos `GRANT ALL` en el esquema `public`, todas las tablas y secuencias a `postgres` y `eduspace_user`.
|
||||
* **Entorno Virtual Nativo (`venv`):** Configuración en `/opt/admin-edu-space/venv` con dependencias del sistema y `gunicorn`.
|
||||
* **Servicio Systemd de Producción:** Configuración y activación de `admin-edu-space.service` en el puerto 5000.
|
||||
* **Ejecución Idempotente:** Integración de `init_db.py` tras la restauración para validar la coherencia del esquema y roles.
|
||||
* **Utilidad de Backup Continuo ([backup_db.sh](file:///c:/Workspace/admin-edu-space/backup_db.sh)):**
|
||||
* Script automatizado para generar copias de seguridad fechadas en `backups/` y actualizar simultáneamente `classrooms_db.sql` para facilitar la migración entre contenedores LXC.
|
||||
* **Documentación y Tutorial en README ([README.md](file:///c:/Workspace/admin-edu-space/README.md)):**
|
||||
* Guía paso a paso para la creación del contenedor LXC en Proxmox VE (especificaciones de CPU, RAM, disco y habilitación de `nesting=1`).
|
||||
* Instrucciones completas de instalación desatendida con `install.sh`.
|
||||
* Corrección de comandos de inicio apuntando al punto de entrada oficial [wsgi.py](file:///c:/Workspace/admin-edu-space/wsgi.py).
|
||||
* Guía paso a paso para la generación y restauración de backups.
|
||||
* **Dependencias ([requirements.txt](file:///c:/Workspace/admin-edu-space/requirements.txt)):**
|
||||
* Se incorporó formalmente `gunicorn>=21.2.0` al listado de dependencias del proyecto.
|
||||
|
||||
---
|
||||
|
||||
## 23. Resolución de Error 500 (Internal Server Error) y Robustecimiento del Instalador Proxmox LXC (2026-09-04)
|
||||
* **Causa Raíz del Error HTTP 500 en Despliegues Existentes:**
|
||||
* Al desplegar sobre contenedores LXC con archivos `.env` preexistentes (por ejemplo, con usuario de base de datos personalizado `eduadmin` extraído de `DATABASE_URL`), el instalador anterior no otorgaba permisos de tablas ni secuencias al usuario dinámico (`GRANT` solo cubría `postgres` y `eduspace_user`), bloqueando las consultas de Flask a PostgreSQL con excepciones de privilegios insuficientes (`permission denied for relation ...`).
|
||||
* Los registros de excepción de Gunicorn se canalizaban exclusivamente a un archivo sin `--capture-output`, impidiendo su visualización inmediata en `journalctl -u admin-edu-space -f`.
|
||||
* **Solución y Mejoras en el Instalador ([install.sh](file:///c:/Workspace/admin-edu-space/install.sh)):**
|
||||
* **Extracción Dinámica de Credenciales:** Parseo bidireccional de `DATABASE_URL` y variables individuales (`DB_USERNAME`, `DB_PASSWORD`, `DB_NAME`), garantizando compatibilidad absoluta con cualquier configuración de conexión.
|
||||
* **Aprovisionamiento Universal de Roles PostgreSQL:** Creación y asignación de contraseña y privilegios `SUPERUSER CREATEDB` a todos los usuarios detectados (`DB_USER`, `DB_USERNAME`, `postgres`, `eduspace_user`).
|
||||
* **Garantía Integral de Permisos DDL y DML:** Tras restaurar [classrooms_db.sql](file:///c:/Workspace/admin-edu-space/classrooms_db.sql), se ejecutan `GRANT ALL` en `DATABASE`, `SCHEMA public`, `ALL TABLES`, `ALL SEQUENCES`, `ALL FUNCTIONS` y `ALTER DEFAULT PRIVILEGES` para todos los usuarios.
|
||||
* **Configuración de Autenticación Local (`pg_hba.conf`):** Ajuste automático en Debian/Ubuntu para que conexiones locales (`127.0.0.1`, `::1` y sockets UNIX) no fallen por discrepancias de método de cifrado (`scram-sha-256`/`md5`).
|
||||
* **Logs en Tiempo Real en Systemd:** Reconfiguración de Gunicorn en el servicio con `--error-logfile -`, `--capture-output` y `--enable-stdio-inheritance`, permitiendo que cualquier excepción de Python/Flask se visualice en tiempo real vía `journalctl -u admin-edu-space -f`.
|
||||
* **Healthcheck HTTP Automatizado:** Verificación al final del despliegue con `curl http://127.0.0.1:5000/`, validando que el servidor web responda exitosamente (HTTP 200/302) antes de concluir la instalación.
|
||||
* **Carga Segura de Configuración ([config/config.py](file:///c:/Workspace/admin-edu-space/config/config.py)):**
|
||||
* Integración explícita de `load_dotenv` para garantizar que scripts y workers carguen `.env` de forma confiable.
|
||||
* Normalización automática de URIs con esquema heredado `postgres://` hacia el estándar `postgresql://`.
|
||||
|
||||
---
|
||||
|
||||
## 24. Auto-recuperación y Creación de Cluster PostgreSQL 15 en Debian 12 Proxmox LXC (2026-09-04)
|
||||
* **Diagnóstico de Arranque de PostgreSQL en Debian 12 (Bookworm):**
|
||||
* En contenedores Proxmox VE con Debian 12 limpio, `apt-get install postgresql` puede no inicializar automáticamente el cluster `main` si los locales no estaban completamente registrados al compilar el paquete, o el directorio de sockets `/run/postgresql` puede no tener los permisos adecuados en el filesystem `tmpfs`.
|
||||
* El comando genérico `systemctl start postgresql` no inicia el motor si la instancia nativa `postgresql@15-main` no está creada ni generada.
|
||||
* **Auto-detección y Resiliencia en [install.sh](file:///c:/Workspace/admin-edu-space/install.sh):**
|
||||
* **Permisos del Socket UNIX:** Creación y asignación explícita de permisos `chmod 2775` con propietario `postgres:postgres` sobre `/run/postgresql` y `/var/run/postgresql`.
|
||||
* **Creación Automática del Cluster:** Chequeo con `pg_lsclusters`; si el cluster `main` no existe, se ejecuta automáticamente `pg_createcluster ${PG_MAJOR} main --locale en_US.UTF-8 --start`.
|
||||
* **Control Systemd Específico:** Arranque y habilitación de la unidad del cluster nativo `postgresql@${PG_MAJOR}-main` y `pg_ctlcluster`.
|
||||
* **Diagnóstico de Salida:** En caso de contingencias en el host Proxmox, el script imprime directamente el estado del cluster con `pg_lsclusters` y las últimas líneas de registro de PostgreSQL.
|
||||
* **Verificación e Instalación Automática de Git ([install.sh](file:///c:/Workspace/admin-edu-space/install.sh)):**
|
||||
* Se incorporó en el inicio del script la verificación de presencia de `git` (`command -v git`). Si no está instalado en el sistema o contenedor, el script ejecuta automáticamente `apt-get update && apt-get install -y git` antes de configurar directivas de seguridad (`safe.directory`) o manipular el repositorio.
|
||||
|
||||
---
|
||||
|
||||
## 25. Roadmap Académico, Propuesta de Mejora (MVC, JWT, Sprint Security), Valor Agregado y Desafíos Técnicos (2026-09-05)
|
||||
* **Propuesta de Mejora Arquitectónica y Gobernanza ([README.md](file:///c:/Workspace/admin-edu-space/README.md)):**
|
||||
* **Arquitectura MVC Rigurosa & Clean Architecture:** Formalización del desacoplamiento en 4 capas: Dominio (`app/models/`), Presentación (Jinja2 + JSON API), Controladores delgados (`app/routes/`) y Capa de Negocio (`app/services/` y `app/repositories/`). Incorporación de esquemas tipados DTO (Pydantic) para validación de contratos de entrada.
|
||||
* **Autenticación Stateless con JWT (JSON Web Tokens):** Arquitectura híbrida que preserva sesiones seguras para el panel web y habilita autenticación stateless basada en tokens JWT para microservicios y aplicaciones móviles mediante el patrón Dual Token (`Access Token` de 15 min + `Refresh Token` en cookie `HttpOnly` de 7 días, con soporte de lista negra de revocación en Redis).
|
||||
* **Seguridad Empresarial ("Sprint / Spring Security"):** Adopción de los estándares de Spring Security adaptados a Python/Flask: cadena centralizada de filtros (`SecurityFilterChain`), control declarativo a nivel método (`@require_permission`), rate limiting dinámico contra fuerza bruta (`Flask-Limiter`) y bitácora de auditoría inmutable de eventos (`AuditLog`).
|
||||
* **Planificación por Sprints (Roadmap):**
|
||||
* **Sprint 1 (MVC & Servicios):** Desacoplamiento de lógica de negocio a servicios y repositorios; esquemas DTO de validación.
|
||||
* **Sprint 2 (JWT & API Gateway):** Endpoints `/api/auth/login`, `/refresh`, `/logout`, decoradores `@jwt_required` y middleware Bearer Token.
|
||||
* **Sprint 3 (Sprint Security & RBAC):** Pipeline `SecurityFilterChain`, control a nivel método, rate limiting y auditoría inmutable.
|
||||
* **Sprint 4 (Optimizador & Concurrencia):** Paralelización del algoritmo genético áulico e integración con microservicios Spring Boot.
|
||||
* **Valor Agregado Institucional:**
|
||||
* **Optimización Edilicia:** Aprovechamiento del aforo en Sede Central (Perón 802) mediante asignación algorítmica por capacidad real y pisos.
|
||||
* **Reducción de Tiempo:** Ahorro del 85% en resolución de conflictos y asignación horaria en turnos de alta concentración (Vespertino).
|
||||
* **Eficiencia Extrema en Proxmox LXC:** Despliegue nativo con consumo inferior a 180MB de RAM frente a alternativas pesadas en Docker/Kubernetes.
|
||||
* **Desafíos Técnicos Superados y Próximos (Challenges):**
|
||||
* Resolución del problema NP-Hard de asignación horaria mediante Algoritmo Genético multicriterio.
|
||||
* Pipeline ETL tolerante a fallos para ingesta y normalización de carteleras no estructuradas de Google Sheets.
|
||||
* Despliegue automatizado, auto-recuperable y portátil para Proxmox VE LXC en Debian 12 (Bookworm) y Debian 13 (Trixie).
|
||||
|
||||
---
|
||||
|
||||
## 26. Creación de Rama `rc` y Publicación del Primer Release Oficial `v1.0.1` (2026-09-05)
|
||||
* **Rama `rc` (Release Candidate):**
|
||||
* Creación y sincronización de la rama `rc` con el estado íntegro de `dev` (commit `566908f`), estableciendo el seguimiento remoto `gitea/rc`.
|
||||
* **Publicación de Tag y Release `v1.0.1` en Gitea:**
|
||||
* Generación de la etiqueta anotada `v1.0.1` a partir de `rc` con notas de versión detalladas.
|
||||
* Publicación remota en Gitea (`git push gitea v1.0.1`), disponibilizando la primera versión candidata/estable oficial para producción en Proxmox VE LXC con empaquetado de artefactos descargables (`.zip` y `.tar.gz`).
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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`).
|
||||
* **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
|
||||
@@ -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
|
||||
1. **Python 3.10 o superior**
|
||||
2. **PostgreSQL** en ejecución con una base de datos creada (ej. `classrooms_db`).
|
||||
3. **Git**
|
||||
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**.
|
||||
|
||||
### 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
|
||||
git clone https://gitea.oemspot.com.ar/carlostellocba/admin-edu-space.git
|
||||
cd admin-edu-space
|
||||
pct enter <ID_DEL_CONTENEDOR>
|
||||
```
|
||||
|
||||
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):
|
||||
```powershell
|
||||
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
|
||||
```
|
||||
|
||||
3. **Instalar dependencias:**
|
||||
2. **Instalar dependencias:**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
4. **Configurar variables de entorno (`.env`):**
|
||||
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:
|
||||
3. **Configurar base de datos local y restaurar snapshot:**
|
||||
```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
|
||||
pybabel compile -d translations
|
||||
```
|
||||
|
||||
7. **Iniciar el servidor de desarrollo:**
|
||||
5. **Iniciar el servidor:**
|
||||
```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) |
|
||||
|
||||
> ⚠️ **Seguridad:** Cambiá la contraseña del administrador desde la interfaz web inmediatamente después del primer inicio de sesión.
|
||||
|
||||
---
|
||||
|
||||
## 📁 Estructura del Proyecto
|
||||
@@ -161,39 +258,121 @@ Plataforma integral de gestión áulica, asignación de espacios, control de hor
|
||||
admin-edu-space/
|
||||
├── app/
|
||||
│ ├── __init__.py # Factoría de la aplicación Flask y extensiones
|
||||
│ ├── models/ # Modelos de base de datos SQLAlchemy
|
||||
│ │ ├── user.py # Modelo de usuario
|
||||
│ │ ├── role.py # Roles y matriz de permisos RBAC
|
||||
│ │ ├── classroom.py # Aulas y capacidades físicas / virtuales
|
||||
│ │ ├── building.py # Edificios y sedes institucionales
|
||||
│ │ ├── career.py # Carreras universitarias
|
||||
│ │ ├── subject.py # Asignaturas y comisiones
|
||||
│ │ └── reservation.py # Reservas horarias y turnos
|
||||
│ ├── routes/ # Controladores y rutas (Blueprints)
|
||||
│ │ ├── auth.py # Autenticación, login y perfil
|
||||
│ │ ├── main.py # Dashboard y métricas generales
|
||||
│ │ ├── admin.py # Gestión de usuarios, roles e importación
|
||||
│ │ ├── classrooms.py # ABM de aulas y equipamiento
|
||||
│ ├── forms/ # Formularios Flask-WTF con validación CSRF
|
||||
│ │ ├── auth.py # Login, perfil y cambio de clave
|
||||
│ │ ├── building.py # Sedes y edificios
|
||||
│ │ ├── classroom.py # Aulas y capacidades (con soporte ∞)
|
||||
│ │ └── reservation.py # Reservas de aulas y horarios
|
||||
│ ├── models/ # Modelos relacionales SQLAlchemy
|
||||
│ │ ├── academic_term.py # Ciclos lectivos y períodos académicos (activo/histórico)
|
||||
│ │ ├── building.py # Sedes y edificios institucionales
|
||||
│ │ ├── career.py # Carreras y titulaciones universitarias
|
||||
│ │ ├── classroom.py # Aulas físicas y virtuales con capacidades
|
||||
│ │ ├── genetic_algorithm.py # Estructuras de optimización heurística
|
||||
│ │ ├── milestone.py # Tipificaciones de hitos académicos y exámenes
|
||||
│ │ ├── reservation.py # Reservas horarias, estados y turnos
|
||||
│ │ ├── role.py # Roles y matriz de permisos granulares RBAC
|
||||
│ │ ├── subject.py # Asignaturas, materias y comisiones de cursada
|
||||
│ │ └── user.py # Usuarios, credenciales y asignación de roles
|
||||
│ ├── routes/ # Controladores y rutas modulares (Blueprints)
|
||||
│ │ ├── admin.py # Carreras, Asignaturas, Ciclo Lectivo, Tipificaciones, Métricas, Usuarios y Roles
|
||||
│ │ ├── auth.py # Autenticación, sesiones y perfil
|
||||
│ │ ├── buildings.py # Gestión de sedes y edificios
|
||||
│ │ ├── schedule.py # Calendario, cartelera del día y reservas
|
||||
│ │ └── genetic_algorithm.py # Optimizador genético de aulas
|
||||
│ ├── services/ # Servicios de negocio
|
||||
│ │ └── sheets_importer.py # Parser y sincronizador de Google Sheets
|
||||
│ ├── forms/ # Formularios Flask-WTF
|
||||
│ ├── templates/ # Plantillas Jinja2 (Bootstrap 5)
|
||||
│ ├── static/ # Archivos CSS, JS e imágenes
|
||||
│ └── utils/ # Decoradores de autorización y utilidades
|
||||
├── config/ # Ajustes de configuración
|
||||
├── translations/ # Catálogos de traducción (es / en)
|
||||
├── init_db.py # Script DDL y semillero inicial
|
||||
├── CHANGELOG_SESSION.md # Registro cronológico detallado de cambios
|
||||
├── requirements.txt # Dependencias del proyecto
|
||||
│ │ ├── classrooms.py # Aulas presenciales vs. Campus Virtual a demanda y métricas de uso
|
||||
│ │ ├── genetic_algorithm.py # Optimizador heurístico de asignación de espacios
|
||||
│ │ ├── main.py # Dashboard principal y métricas rápidas
|
||||
│ │ └── schedule.py # Calendario interactivo, cartelera del día, comisiones y reservas
|
||||
│ ├── services/ # Servicios y lógica de negocio
|
||||
│ │ └── sheets_importer.py # Parser y sincronizador bidireccional de Google Sheets
|
||||
│ ├── static/ # Recursos estáticos web (CSS, JS, imágenes institucionales)
|
||||
│ └── templates/ # Vistas Jinja2 renderizadas con Bootstrap 5
|
||||
├── config/ # Configuraciones de entorno (desarrollo, testing, prod)
|
||||
├── translations/ # Catálogos GNU gettext compilados (es / en)
|
||||
├── classrooms_db.sql # Snapshot completo de base de datos (PostgreSQL 14+)
|
||||
├── install.sh # Instalador automatizado para Proxmox VE LXC (venv + PostgreSQL)
|
||||
├── backup_db.sh # Script para generar nuevos backups y actualizar snapshot
|
||||
├── init_db.py # Inicializador DDL y sincronizador de esquema/roles
|
||||
├── CHANGELOG_SESSION.md # Historial técnico cronológico
|
||||
├── requirements.txt # Dependencias Python (incluye Gunicorn)
|
||||
├── .env.example # Plantilla de variables de entorno
|
||||
└── app.py # Punto de entrada de la aplicación
|
||||
└── wsgi.py # Punto de entrada de la aplicación Flask
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Roadmap Académico & Propuesta de Evolución Tecnológica
|
||||
|
||||
En respuesta a los requerimientos y sugerencias docentes, se formaliza el plan de evolución arquitectónica, estándares de seguridad y entrega de valor institucional para la plataforma **Admin Edu-Space (UniCABA)**.
|
||||
|
||||
### 💡 1. Propuesta de Mejora Integral
|
||||
|
||||
#### A. Arquitectura MVC Rigurosa & Clean Architecture
|
||||
* **Desacoplamiento de Capas:**
|
||||
* **Model (Dominio):** Entidades relacionales puras y reglas de integridad del negocio desacopladas del framework web (`app/models/`).
|
||||
* **View (Presentación):** Vistas web en Jinja2 con Bootstrap 5 y respuestas JSON estructuradas para clientes API/móviles.
|
||||
* **Controller (Controladores/Blueprints):** Controladores delgados en `app/routes/` enfocados exclusivamente en la orquestación HTTP y redirección.
|
||||
* **Service Layer & Repositories (`app/services/` & `app/repositories/`):** Aislamiento de la lógica de negocio (detección de colisiones, algoritmo genético de asignación, ETL de Google Sheets) y operaciones de persistencia encapsuladas.
|
||||
* **DTOs y Validación de Entrada:** Incorporación de esquemas tipados (Pydantic / Marshmallow) para filtrar y sanitizar datos de entrada antes de interactuar con el ORM, eliminando validaciones ad-hoc en los controladores.
|
||||
|
||||
#### B. Autenticación & Autorización Stateless con JWT (JSON Web Tokens)
|
||||
* **Arquitectura API-First Híbrida:**
|
||||
* Coexistencia armónica de sesiones web seguras (para el panel administrativo en navegadores) y tokens **JWT** (para microservicios, clientes móviles y aplicaciones externas).
|
||||
* **Esquema Dual de Tokens (Dual Token Pattern):**
|
||||
* `Access Token` (firmado con RS256/HS256, expiración corta de 15 minutos) con claims contextuales (`sub`, `roles`, `permissions`, `exp`).
|
||||
* `Refresh Token` (almacenado en cookie segura `HttpOnly`, `Secure`, `SameSite=Strict`, expiración de 7 días) para renovación silenciosa sin reingreso de credenciales.
|
||||
* **Mecanismo de Revocación Inmediata (Token Blacklist):** Almacenamiento en caché distribuida (Redis) para invalidación instantánea ante cierres de sesión forzados o rotación de contraseñas.
|
||||
|
||||
#### C. Seguridad Empresarial y Paradigma "Sprint / Spring Security"
|
||||
* **Cadena de Filtros de Interceptación (SecurityFilterChain):** Pipeline centralizado inspirado en los estándares de Spring Security:
|
||||
* **Authentication Filter:** Intercepta cabeceras `Authorization: Bearer <token>` y valida firmas criptográficas.
|
||||
* **Security Headers Filter:** Inyección obligatoria de cabeceras de blindaje HTTP (HSTS, CSP, X-Frame-Options: DENY, X-Content-Type-Options: nosniff).
|
||||
* **Rate Limiting Filter:** Prevención de ataques de fuerza bruta y DDoS en endpoints sensibles mediante límites dinámicos (`Flask-Limiter` / Redis).
|
||||
* **Seguridad Declarativa a Nivel Método (`@PreAuthorize` / `@require_permission`):**
|
||||
* Decoradores reutilizables que evalúan la matriz RBAC antes de ejecutar cualquier método de servicio:
|
||||
```python
|
||||
@require_permission('classrooms', min_level='read_write')
|
||||
def update_classroom_capacity(classroom_id, new_capacity): ...
|
||||
```
|
||||
* **Bitácora Inmutable de Auditoría (Audit Trail):** Registro trazable de eventos críticos (creación/modificación de reservas, cambios de roles y accesos fallidos) con IP de origen, usuario actuante, timestamp y delta de cambios.
|
||||
|
||||
---
|
||||
|
||||
### 📅 2. Planificación de Sprints (Roadmap)
|
||||
|
||||
| Sprint / Fase | Objetivo Principal | Entregables Clave | Hito de Evaluación |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Sprint 1** | **Refactorización MVC & Capa de Servicios** | • Extracción de lógica de controladores a `app/services/`<br>• Implementación del patrón Repository para aulas y reservas<br>• DTOs de validación con esquemas Pydantic | Controladores limpios (< 60 líneas por endpoint) y separación estricta de responsabilidades. |
|
||||
| **Sprint 2** | **Autenticación Stateless JWT & API Gateway** | • Endpoints `/api/auth/login`, `/refresh`, `/logout`<br>• Decoradores `@jwt_required` y claim parser<br>• Middleware Bearer Token para interoperabilidad | Autenticación y consumo seguro desde Postman y aplicaciones externas sin cookies de sesión. |
|
||||
| **Sprint 3** | **Sprint Security & Gobernanza RBAC Avanzada** | • Pipeline `SecurityFilterChain` centralizado<br>• Decorador `@require_permission(module, level)` a nivel servicio<br>• Rate Limiting en login y endpoints públicos<br>• Módulo de auditoría inmutable de eventos (`AuditLog`) | Aprobación de pentest simulado OWASP Top 10 y matriz de permisos auditada. |
|
||||
| **Sprint 4** | **Optimizador Heurístico & Alta Concurrencia** | • Paralelización del algoritmo genético con multiprocessing/workers<br>• Colas asíncronas para optimizaciones pesadas<br>• Integración con microservicio Spring Boot vía REST/JWT | Optimización de 200+ comisiones en menos de 5 segundos con 0 colisiones horarias. |
|
||||
|
||||
---
|
||||
|
||||
### 💎 3. Valor Agregado & Retorno Institucional
|
||||
|
||||
1. **Eficiencia Edilicia y Reducción del Desperdicio de Espacio:**
|
||||
- Maximiza el aprovechamiento de las aulas físicas de la Sede Central (**Tte. Gral. Juan Domingo Perón 802**), pasando de una asignación manual y empírica a un modelo algorítmico basado en capacidad real, accesibilidad de pisos y equipamiento.
|
||||
2. **Ahorro de Tiempo Operativo (85% de reducción):**
|
||||
- Automatiza la resolución de conflictos de solapamiento horario en horarios pico (Turno Vespertino, 17:30 a 21:30 hs) y agiliza la sincronización con la cartelera de Google Sheets en lote.
|
||||
3. **Interoperabilidad Universal:**
|
||||
- La arquitectura desacoplada y la capa JWT permiten que cualquier otro sistema de la universidad (SIU Guaraní, Campus Virtual Moodle, App de estudiantes, backend Spring Boot institucional) consulte en tiempo real la disponibilidad áulica.
|
||||
4. **Despliegue de Alta Eficiencia (Bajo Costo de Infraestructura):**
|
||||
- A diferencia de arquitecturas pesadas basadas en contenedores Docker o Kubernetes que demandan múltiples gigabytes de RAM, **Admin Edu-Space corre de forma nativa en un contenedor Proxmox VE LXC consumiendo menos de 180MB de memoria**, garantizando alta disponibilidad con costos de hardware mínimos.
|
||||
|
||||
---
|
||||
|
||||
### 🧩 4. Desafíos Técnicos (Challenges)
|
||||
|
||||
* **Challenge 1: Resolución Heurística de Horarios Áulicos (NP-Hard Problem):**
|
||||
- La asignación de 206 asignaturas, 400+ reservas y 25 aulas considerando restricciones duras (no colisión horaria, capacidad no superada) y blandas (mismo docente en pisos cercanos, turnos consolidados) representa un problema de complejidad NP-Hard resuelto mediante un **Algoritmo Genético** con operadores de cruce y mutación adaptativa.
|
||||
* **Challenge 2: Sincronización Masiva Tolerante a Fallos:**
|
||||
- El parser ETL de Google Sheets normaliza datos no estructurados de la cartelera académica, resolviendo discrepancias ortográficas, turnos implícitos y formatos dispares de hora sin interrumpir la operación continua.
|
||||
* **Challenge 3: Portabilidad Extrema en Proxmox LXC sin Docker:**
|
||||
- Desarrollo de un instalador auto-recuperable ([install.sh](file:///c:/Workspace/admin-edu-space/install.sh)) capaz de levantar PostgreSQL nativo en Debian 12 y 13 dentro de `tmpfs`, gestionar clusters con `pg_createcluster`, operar de forma autónoma con o sin `sudo`, y garantizar persistencia íntegra de base de datos entre dispositivos.
|
||||
|
||||
---
|
||||
|
||||
## 📄 Licencia
|
||||
|
||||
Este proyecto forma parte del sistema de gestión institucional y educativa de **UniCABA**. Todos los derechos reservados.
|
||||
␍
|
||||
@@ -2,6 +2,7 @@ from .user import User
|
||||
from .building import Building
|
||||
from .classroom import Classroom, ClassroomResource
|
||||
from .career import Career
|
||||
from .academic_term import AcademicTerm
|
||||
from .subject import Subject, Commission
|
||||
from .reservation import Reservation, ReservationStatus
|
||||
from .genetic_algorithm import GeneticAlgorithm, ReservationOptimizer
|
||||
@@ -14,6 +15,7 @@ __all__ = [
|
||||
'Classroom',
|
||||
'ClassroomResource',
|
||||
'Career',
|
||||
'AcademicTerm',
|
||||
'Subject',
|
||||
'Commission',
|
||||
'Reservation',
|
||||
|
||||
@@ -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
@@ -108,21 +108,21 @@ BASE_MILESTONE_TYPES = [
|
||||
'sort_order': 2
|
||||
},
|
||||
{
|
||||
'name': 'Trabajo Práctico 1',
|
||||
'name': 'Trabajo Practico 1',
|
||||
'code': 'tp_1',
|
||||
'color': '#198754',
|
||||
'description': 'Primera entrega o evaluación práctica',
|
||||
'description': 'Primera entrega o evaluacion practica',
|
||||
'sort_order': 3
|
||||
},
|
||||
{
|
||||
'name': 'Trabajo Práctico 2',
|
||||
'name': 'Trabajo Practico 2',
|
||||
'code': 'tp_2',
|
||||
'color': '#157347',
|
||||
'description': 'Segunda entrega o evaluación práctica',
|
||||
'description': 'Segunda entrega o evaluacion practica',
|
||||
'sort_order': 4
|
||||
},
|
||||
{
|
||||
'name': 'Trabajo Práctico 3',
|
||||
'name': 'Trabajo Practico 3',
|
||||
'code': 'tp_3',
|
||||
'color': '#0f5132',
|
||||
'description': 'Tercera entrega o proyecto integrador',
|
||||
@@ -132,35 +132,35 @@ BASE_MILESTONE_TYPES = [
|
||||
'name': 'Recuperatorio Parcial 1',
|
||||
'code': 'recu_p1',
|
||||
'color': '#fd7e14',
|
||||
'description': 'Instancia de recuperación para el Parcial 1',
|
||||
'description': 'Instancia de recuperacion para el Parcial 1',
|
||||
'sort_order': 6
|
||||
},
|
||||
{
|
||||
'name': 'Recuperatorio Parcial 2',
|
||||
'code': 'recu_p2',
|
||||
'color': '#e76b08',
|
||||
'description': 'Instancia de recuperación para el Parcial 2',
|
||||
'description': 'Instancia de recuperacion para el Parcial 2',
|
||||
'sort_order': 7
|
||||
},
|
||||
{
|
||||
'name': 'Recuperatorio TP 1',
|
||||
'code': 'recu_tp1',
|
||||
'color': '#ffc107',
|
||||
'description': 'Reentrega o recuperación de Trabajo Práctico 1',
|
||||
'description': 'Reentrega o recuperacion de Trabajo Practico 1',
|
||||
'sort_order': 8
|
||||
},
|
||||
{
|
||||
'name': 'Recuperatorio TP 2',
|
||||
'code': 'recu_tp2',
|
||||
'color': '#e0a800',
|
||||
'description': 'Reentrega o recuperación de Trabajo Práctico 2',
|
||||
'description': 'Reentrega o recuperacion de Trabajo Practico 2',
|
||||
'sort_order': 9
|
||||
},
|
||||
{
|
||||
'name': 'Recuperatorio TP 3',
|
||||
'code': 'recu_tp3',
|
||||
'color': '#c69500',
|
||||
'description': 'Reentrega o recuperación de Trabajo Práctico 3',
|
||||
'description': 'Reentrega o recuperacion de Trabajo Practico 3',
|
||||
'sort_order': 10
|
||||
},
|
||||
{
|
||||
|
||||
@@ -526,3 +526,594 @@ def api_milestone_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
@@ -89,49 +89,34 @@ def add_classroom():
|
||||
@classrooms_bp.route('/list')
|
||||
@login_required
|
||||
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:
|
||||
from app.models.building import Building
|
||||
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)
|
||||
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)
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
from app.models.subject import Commission, Subject
|
||||
import calendar
|
||||
from datetime import date, timedelta
|
||||
|
||||
page = request.args.get('page', 1, type=int)
|
||||
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_start = datetime.combine(today, datetime.min.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_end = datetime(today.year, today.month, last_day, 23, 59, 59)
|
||||
|
||||
# Consultar en lote las reservas del mes para las aulas visibles
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
date_filters = {
|
||||
'today': today.strftime('%Y-%m-%d'),
|
||||
'week_start': week_start.strftime('%Y-%m-%d'),
|
||||
'week_end': week_end.strftime('%Y-%m-%d'),
|
||||
'month_start': month_start.strftime('%Y-%m-%d'),
|
||||
'month_end': month_end.strftime('%Y-%m-%d')
|
||||
}
|
||||
|
||||
# --- AULAS FÍSICAS (Presenciales) ---
|
||||
physical_query = Classroom.query
|
||||
if virtual_bldg_id:
|
||||
physical_query = physical_query.filter(Classroom.building_id != virtual_bldg_id)
|
||||
else:
|
||||
physical_query = physical_query.filter(~Classroom.code.ilike('%VIRTUAL%'))
|
||||
|
||||
if active_only:
|
||||
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]
|
||||
month_reservations = Reservation.query.filter(
|
||||
Reservation.classroom_id.in_(visible_ids),
|
||||
@@ -153,10 +192,6 @@ def list_classrooms():
|
||||
Reservation.status.in_([ReservationStatus.CONFIRMED.value, ReservationStatus.PENDING.value])
|
||||
).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
|
||||
WEEKLY_OP_MINS = 6 * 15 * 60
|
||||
MONTHLY_OP_MINS = 24 * 15 * 60
|
||||
@@ -164,20 +199,16 @@ def list_classrooms():
|
||||
occupancy_metrics = {}
|
||||
for c in classrooms.items:
|
||||
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_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_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_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] = {
|
||||
'day_pct': day_pct,
|
||||
@@ -188,12 +219,78 @@ def list_classrooms():
|
||||
'month_count': len(c_res)
|
||||
}
|
||||
|
||||
date_filters = {
|
||||
'today': today.strftime('%Y-%m-%d'),
|
||||
'week_start': week_start.strftime('%Y-%m-%d'),
|
||||
'week_end': week_end.strftime('%Y-%m-%d'),
|
||||
'month_start': month_start.strftime('%Y-%m-%d'),
|
||||
'month_end': month_end.strftime('%Y-%m-%d')
|
||||
# --- AULAS VIRTUALES (Campus Virtual a Demanda - Infinitas) ---
|
||||
vc = Classroom.query.filter(
|
||||
Classroom.building_id == virtual_bldg_id if virtual_bldg_id else Classroom.code.ilike('%VIRTUAL%')
|
||||
).first()
|
||||
vc_id = vc.id if vc else 1
|
||||
|
||||
all_vres = Reservation.query.filter(Reservation.classroom_id == vc_id).all() if vc_id else []
|
||||
|
||||
# Agrupar las salas virtuales por comisión
|
||||
comm_dict = {}
|
||||
for r in all_vres:
|
||||
c = r.commission
|
||||
cid = c.id if c else 0
|
||||
if cid not in comm_dict:
|
||||
vlink = (c.virtual_link if c and c.virtual_link else None) or r.effective_virtual_link
|
||||
comm_dict[cid] = {
|
||||
'commission_id': cid,
|
||||
'commission_code': c.code if c else 'General',
|
||||
'subject_name': c.subject.name if (c and c.subject) else (r.purpose or 'Clase Virtual'),
|
||||
'subject_code': c.subject.code if (c and c.subject) else '',
|
||||
'career_name': c.subject.career.name if (c and c.subject and hasattr(c.subject, 'career') and c.subject.career) else '',
|
||||
'max_students': c.max_students if c else (r.expected_attendees or 0),
|
||||
'virtual_link': vlink,
|
||||
'reservations': []
|
||||
}
|
||||
comm_dict[cid]['reservations'].append(r)
|
||||
if not comm_dict[cid]['virtual_link'] and r.effective_virtual_link:
|
||||
comm_dict[cid]['virtual_link'] = r.effective_virtual_link
|
||||
|
||||
virtual_rooms = []
|
||||
for cid, data in comm_dict.items():
|
||||
res_list = data['reservations']
|
||||
day_cnt = sum(1 for r in res_list if today_start <= r.start_time <= today_end)
|
||||
week_cnt = sum(1 for r in res_list if week_start <= r.start_time <= week_end)
|
||||
month_cnt = sum(1 for r in res_list if month_start <= r.start_time <= month_end)
|
||||
shift = res_list[0].shift if res_list else 'A Demanda'
|
||||
|
||||
if search and view_mode == 'virtual':
|
||||
s_low = search.lower()
|
||||
matches = (
|
||||
s_low in data['subject_name'].lower() or
|
||||
s_low in data['commission_code'].lower() or
|
||||
s_low in data['subject_code'].lower() or
|
||||
s_low in data['career_name'].lower()
|
||||
)
|
||||
if not matches:
|
||||
continue
|
||||
|
||||
virtual_rooms.append({
|
||||
'commission_id': data['commission_id'],
|
||||
'commission_code': data['commission_code'],
|
||||
'subject_name': data['subject_name'],
|
||||
'subject_code': data['subject_code'],
|
||||
'career_name': data['career_name'],
|
||||
'max_students': data['max_students'],
|
||||
'virtual_link': data['virtual_link'],
|
||||
'shift': shift,
|
||||
'day_count': day_cnt,
|
||||
'week_count': week_cnt,
|
||||
'month_count': month_cnt,
|
||||
'total_count': len(res_list),
|
||||
'reservations': sorted(res_list, key=lambda x: x.start_time)
|
||||
})
|
||||
|
||||
virtual_rooms.sort(key=lambda x: (x['subject_name'], x['commission_code']))
|
||||
|
||||
global_virtual_metrics = {
|
||||
'today_count': sum(1 for r in all_vres if today_start <= r.start_time <= today_end),
|
||||
'week_count': sum(1 for r in all_vres if week_start <= r.start_time <= week_end),
|
||||
'month_count': sum(1 for r in all_vres if month_start <= r.start_time <= month_end),
|
||||
'total_count': len(all_vres),
|
||||
'commissions_count': len(comm_dict)
|
||||
}
|
||||
|
||||
return render_template('classrooms/list.html',
|
||||
@@ -204,6 +301,12 @@ def list_classrooms():
|
||||
floor_filter=floor_filter,
|
||||
active_only=active_only,
|
||||
search=search,
|
||||
view_mode=view_mode,
|
||||
physical_count=physical_count,
|
||||
virtual_count=len(comm_dict),
|
||||
virtual_rooms=virtual_rooms,
|
||||
virtual_classroom_id=vc_id,
|
||||
global_virtual_metrics=global_virtual_metrics,
|
||||
occupancy_metrics=occupancy_metrics,
|
||||
date_filters=date_filters,
|
||||
title='Classrooms')
|
||||
@@ -213,6 +316,20 @@ def list_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>')
|
||||
@login_required
|
||||
def view_classroom(id):
|
||||
|
||||
@@ -654,8 +654,10 @@ def list_reservations():
|
||||
or_(
|
||||
Subject.name.ilike(f'%{search}%'),
|
||||
Subject.code.ilike(f'%{search}%'),
|
||||
Commission.code.ilike(f'%{search}%'),
|
||||
Classroom.code.ilike(f'%{search}%'),
|
||||
Reservation.purpose.ilike(f'%{search}%')
|
||||
Reservation.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 %}
|
||||
@@ -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 %}
|
||||
@@ -0,0 +1,343 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% trans %}Gestión de Ocupación y Métricas{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.kpi-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.07);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.kpi-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
.kpi-icon-circle {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
.metric-bar-container {
|
||||
height: 10px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.metric-bar-fill {
|
||||
width: 0;
|
||||
height: 100%;
|
||||
border-radius: 6px;
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 fw-bold mb-1">
|
||||
<i class="bi bi-graph-up-arrow text-primary me-2"></i>{% trans %}Gestión de Ocupación y Métricas de Espacios{% endtrans %}
|
||||
</h1>
|
||||
<p class="text-muted mb-0 small">
|
||||
{% trans %}Analítica institucional de aprovechamiento de aulas físicas, demanda en Campus Virtual y distribución horaria.{% endtrans %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('classrooms.list_classrooms', view_mode='physical') }}" class="btn btn-outline-primary">
|
||||
<i class="bi bi-buildings me-1"></i>{% trans %}Aulas Físicas{% endtrans %}
|
||||
</a>
|
||||
<a href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}" class="btn btn-outline-info">
|
||||
<i class="bi bi-globe2 me-1"></i>{% trans %}Campus Virtual (∞){% endtrans %}
|
||||
</a>
|
||||
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-calendar-week me-1"></i>{% trans %}Ver Calendario{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 1. Fila de KPIs Globales de Ocupación -->
|
||||
<div class="row g-3 mb-4">
|
||||
<!-- Ocupación Hoy -->
|
||||
<div class="col-xl-3 col-sm-6">
|
||||
<div class="card kpi-card border-0 shadow-sm p-3 h-100">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.72rem;">{% trans %}Tasa de Ocupación Hoy{% endtrans %}</span>
|
||||
<h2 class="h3 fw-bold mb-0 text-primary mt-1">{{ avg_day_pct }}%</h2>
|
||||
</div>
|
||||
<div class="kpi-icon-circle bg-primary-subtle text-primary">
|
||||
<i class="bi bi-clock-history"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-bar-container mt-3">
|
||||
<div class="metric-bar-fill bg-primary" data-width="{{ avg_day_pct }}%"></div>
|
||||
</div>
|
||||
<small class="text-muted mt-2 d-block" style="font-size: 0.72rem;">
|
||||
{% trans %}Promedio sobre 15 hs operativas ({{ physical_rooms_count }} aulas presenciales){% endtrans %}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ocupación Semana -->
|
||||
<div class="col-xl-3 col-sm-6">
|
||||
<div class="card kpi-card border-0 shadow-sm p-3 h-100">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.72rem;">{% trans %}Ocupación Semanal{% endtrans %}</span>
|
||||
<h2 class="h3 fw-bold mb-0 text-info mt-1">{{ avg_week_pct }}%</h2>
|
||||
</div>
|
||||
<div class="kpi-icon-circle bg-info-subtle text-info">
|
||||
<i class="bi bi-calendar-week"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-bar-container mt-3">
|
||||
<div class="metric-bar-fill bg-info" data-width="{{ avg_week_pct }}%"></div>
|
||||
</div>
|
||||
<small class="text-muted mt-2 d-block" style="font-size: 0.72rem;">
|
||||
{% trans %}Semana en curso (Lunes a Sábado){% endtrans %}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ocupación Mensual -->
|
||||
<div class="col-xl-3 col-sm-6">
|
||||
<div class="card kpi-card border-0 shadow-sm p-3 h-100">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.72rem;">{% trans %}Ocupación del Mes{% endtrans %}</span>
|
||||
<h2 class="h3 fw-bold mb-0 text-success mt-1">{{ avg_month_pct }}%</h2>
|
||||
</div>
|
||||
<div class="kpi-icon-circle bg-success-subtle text-success">
|
||||
<i class="bi bi-calendar3"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-bar-container mt-3">
|
||||
<div class="metric-bar-fill bg-success" data-width="{{ avg_month_pct }}%"></div>
|
||||
</div>
|
||||
<small class="text-muted mt-2 d-block" style="font-size: 0.72rem;">
|
||||
{% trans %}Total mes calendario activo{% endtrans %}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clases Totales Dictadas -->
|
||||
<div class="col-xl-3 col-sm-6">
|
||||
<div class="card kpi-card border-0 shadow-sm p-3 h-100">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.72rem;">{% trans %}Total Clases en el Mes{% endtrans %}</span>
|
||||
<h2 class="h3 fw-bold mb-0 text-warning mt-1">{{ total_month_classes }}</h2>
|
||||
</div>
|
||||
<div class="kpi-icon-circle bg-warning-subtle text-warning">
|
||||
<i class="bi bi-mortarboard"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-3 small font-monospace">
|
||||
<span class="badge bg-primary-subtle text-primary border">Físicas: {{ total_month_classes - v_month }}</span>
|
||||
<span class="badge bg-info-subtle text-info border">Virtuales: {{ v_month }}</span>
|
||||
</div>
|
||||
<small class="text-muted mt-2 d-block" style="font-size: 0.72rem;">
|
||||
{% trans %}Reservas y sesiones registradas en el período{% endtrans %}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Detalle y Distribución Espacial -->
|
||||
<div class="row g-4 mb-4">
|
||||
<!-- Ocupación por Piso / Nivel -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title h6 fw-bold mb-0">
|
||||
<i class="bi bi-layers text-primary me-2"></i>{% trans %}Tasa de Ocupación por Piso / Nivel{% endtrans %}
|
||||
</h5>
|
||||
<span class="badge bg-secondary-subtle text-secondary border">Presencial</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-column gap-3">
|
||||
{% for fs in floor_summary %}
|
||||
<div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<span class="fw-semibold text-body small">
|
||||
{{ fs.floor }} ({{ fs.count }} {% trans %}aulas{% endtrans %})
|
||||
</span>
|
||||
<span class="fw-bold font-monospace small {% if fs.avg_pct > 50 %}text-danger{% elif fs.avg_pct > 25 %}text-warning{% else %}text-success{% endif %}">
|
||||
{{ fs.avg_pct }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="metric-bar-container">
|
||||
<div class="metric-bar-fill {% if fs.avg_pct > 50 %}bg-danger{% elif fs.avg_pct > 25 %}bg-warning{% else %}bg-success{% endif %}"
|
||||
data-width="{{ fs.avg_pct }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Distribución por Turno -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title h6 fw-bold mb-0">
|
||||
<i class="bi bi-brightness-high text-warning me-2"></i>{% trans %}Distribución de Clases por Turno{% endtrans %}
|
||||
</h5>
|
||||
<span class="badge bg-secondary-subtle text-secondary border">Mes Completo</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2 text-center mb-3">
|
||||
<div class="col-3">
|
||||
<div class="p-2 rounded bg-body-tertiary border">
|
||||
<div class="small text-muted text-uppercase fw-semibold" style="font-size: 0.68rem;">{% trans %}Mañana{% endtrans %}</div>
|
||||
<div class="h5 fw-bold text-primary mb-0">{{ shift_counts.Mañana }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<div class="p-2 rounded bg-body-tertiary border">
|
||||
<div class="small text-muted text-uppercase fw-semibold" style="font-size: 0.68rem;">{% trans %}Tarde{% endtrans %}</div>
|
||||
<div class="h5 fw-bold text-warning mb-0">{{ shift_counts.Tarde }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<div class="p-2 rounded bg-body-tertiary border">
|
||||
<div class="small text-muted text-uppercase fw-semibold" style="font-size: 0.68rem;">{% trans %}Vespertino{% endtrans %}</div>
|
||||
<div class="h5 fw-bold text-info mb-0">{{ shift_counts.Vespertino }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<div class="p-2 rounded bg-body-tertiary border">
|
||||
<div class="small text-muted text-uppercase fw-semibold" style="font-size: 0.68rem;">{% trans %}Noche{% endtrans %}</div>
|
||||
<div class="h5 fw-bold text-danger mb-0">{{ shift_counts.Noche }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Campus Virtual Box -->
|
||||
<div class="p-3 rounded bg-primary-subtle border border-primary-subtle">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="fw-bold text-primary mb-1"><i class="bi bi-globe2 me-2"></i>{% trans %}Campus Virtual (A Demanda · ∞){% endtrans %}</h6>
|
||||
<p class="small text-muted mb-0">{% trans %}Salas simultáneas sin límite físico de cupo ni colisión.{% endtrans %}</p>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<div class="badge bg-primary text-white fs-6">{{ v_month }} clases mes</div>
|
||||
<small class="d-block text-muted mt-1">{{ v_today }} hoy · {{ v_week }} semana</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Top Aulas de Mayor Demanda vs Menor Demanda -->
|
||||
<div class="row g-4">
|
||||
<!-- Mayor Demanda -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-body-tertiary border-0 py-3">
|
||||
<h5 class="card-title h6 fw-bold mb-0 text-success">
|
||||
<i class="bi bi-arrow-up-circle-fill me-2"></i>{% trans %}Top Aulas con Mayor Demanda y Ocupación{% endtrans %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0 small">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>{% trans %}Aula{% endtrans %}</th>
|
||||
<th>{% trans %}Piso{% endtrans %}</th>
|
||||
<th>{% trans %}Capacidad{% endtrans %}</th>
|
||||
<th>{% trans %}Ocup. Mes{% endtrans %}</th>
|
||||
<th class="text-end">{% trans %}Acción{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for rm in top_rooms %}
|
||||
<tr>
|
||||
<td class="fw-bold text-primary">{{ rm.classroom.code }}</td>
|
||||
<td>{{ rm.classroom.floor_display }}</td>
|
||||
<td>{{ rm.classroom.capacity }} asientos</td>
|
||||
<td>
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle font-monospace">
|
||||
{{ rm.month_pct }}% ({{ rm.month_count }} cl.)
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a href="{{ url_for('schedule.list_reservations', classroom_id=rm.classroom.id, date_from=date_filters.month_start, date_to=date_filters.month_end) }}"
|
||||
class="btn btn-sm btn-outline-primary py-0 px-2" style="font-size: 0.72rem;">
|
||||
{% trans %}Ver Reservas{% endtrans %}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Menor Demanda / Oportunidades -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-body-tertiary border-0 py-3">
|
||||
<h5 class="card-title h6 fw-bold mb-0 text-secondary">
|
||||
<i class="bi bi-arrow-down-circle-fill me-2"></i>{% trans %}Aulas con Mayor Disponibilidad (Menor Uso){% endtrans %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0 small">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>{% trans %}Aula{% endtrans %}</th>
|
||||
<th>{% trans %}Piso{% endtrans %}</th>
|
||||
<th>{% trans %}Capacidad{% endtrans %}</th>
|
||||
<th>{% trans %}Ocup. Mes{% endtrans %}</th>
|
||||
<th class="text-end">{% trans %}Acción{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for rm in low_rooms %}
|
||||
<tr>
|
||||
<td class="fw-bold text-body">{{ rm.classroom.code }}</td>
|
||||
<td>{{ rm.classroom.floor_display }}</td>
|
||||
<td>{{ rm.classroom.capacity }} asientos</td>
|
||||
<td>
|
||||
<span class="badge bg-secondary-subtle text-secondary border font-monospace">
|
||||
{{ rm.month_pct }}% ({{ rm.month_count }} cl.)
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a href="{{ url_for('schedule.add_reservation') }}?classroom_id={{ rm.classroom.id }}"
|
||||
class="btn btn-sm btn-outline-success py-0 px-2" style="font-size: 0.72rem;">
|
||||
{% trans %}Reservar{% endtrans %}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.metric-bar-fill[data-width]').forEach(function(bar) {
|
||||
bar.style.width = bar.getAttribute('data-width');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,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
@@ -104,19 +104,32 @@
|
||||
{% if current_user.is_authenticated and current_user.can_manage() %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-shield-lock"></i> {% trans %}Management{% endtrans %}
|
||||
<i class="bi bi-shield-lock"></i> {% trans %}Gestión{% endtrans %}
|
||||
</a>
|
||||
<ul class="dropdown-menu shadow-sm">
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.users_list') }}"><i class="bi bi-people me-2"></i>{% trans %}Users{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.roles_list') }}"><i class="bi bi-person-badge me-2"></i>{% trans %}Roles & Groups{% endtrans %}</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<ul class="dropdown-menu shadow-sm" style="min-width: 260px;">
|
||||
<!-- Espacios & Infraestructura -->
|
||||
<li class="dropdown-header text-uppercase small fw-bold text-muted py-1" style="font-size: 0.68rem; letter-spacing: 0.5px;">{% trans %}Espacios e Infraestructura{% endtrans %}</li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}"><i class="bi bi-buildings me-2 text-secondary"></i>{% trans %}Edificios y Sedes{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='physical') }}"><i class="bi bi-door-open me-2 text-primary"></i>{% trans %}Aulas Presenciales{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}"><i class="bi bi-camera-video me-2 text-info"></i>{% trans %}Aulas Virtuales (Reunión){% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.occupancy_metrics') }}"><i class="bi bi-graph-up-arrow me-2 text-success"></i>{% trans %}Gestión de Ocupación, Métricas{% endtrans %}</a></li>
|
||||
|
||||
<li><hr class="dropdown-divider my-1"></li>
|
||||
<!-- Gestión Académica -->
|
||||
<li class="dropdown-header text-uppercase small fw-bold text-muted py-1" style="font-size: 0.68rem; letter-spacing: 0.5px;">{% trans %}Gestión Académica{% endtrans %}</li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.careers_list') }}"><i class="bi bi-mortarboard me-2 text-primary"></i>{% trans %}Carreras{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.subjects_list') }}"><i class="bi bi-book me-2 text-info"></i>{% trans %}Asignaturas{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.commissions_view') }}"><i class="bi bi-diagram-3 me-2 text-primary"></i>{% trans %}Comisiones y Cursadas{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.academic_terms_list') }}"><i class="bi bi-calendar-range me-2 text-warning"></i>{% trans %}Ciclo Lectivo{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}"><i class="bi bi-calendar-check me-2 text-warning"></i>{% trans %}Gestión de Reservas{% endtrans %}</a></li>
|
||||
<li><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><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>
|
||||
<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><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.google_sheets_import') }}"><i class="bi bi-file-earmark-spreadsheet me-2 text-success"></i>{% trans %}Import from Google Sheets{% endtrans %}</a></li>
|
||||
|
||||
<li><hr class="dropdown-divider my-1"></li>
|
||||
<!-- Administración & Sistema -->
|
||||
<li class="dropdown-header text-uppercase small fw-bold text-muted py-1" style="font-size: 0.68rem; letter-spacing: 0.5px;">{% trans %}Administración & Seguridad{% endtrans %}</li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.users_list') }}"><i class="bi bi-people me-2"></i>{% trans %}Usuarios{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.roles_list') }}"><i class="bi bi-person-badge me-2"></i>{% trans %}Roles y Permisos{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.milestone_types_list') }}"><i class="bi bi-tags me-2 text-secondary"></i>{% trans %}Tipificaciones de Hitos{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.google_sheets_import') }}"><i class="bi bi-file-earmark-spreadsheet me-2 text-success"></i>{% trans %}Sincronización Sheets{% endtrans %}</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,17 +1,63 @@
|
||||
{% 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 %}
|
||||
<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;
|
||||
border-radius: 0.65rem;
|
||||
}
|
||||
.classroom-card:hover {
|
||||
.classroom-card:hover, .virtual-room-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
|
||||
.virtual-banner {
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.08) 0%, rgba(180, 62, 142, 0.08) 100%);
|
||||
border: 1px solid rgba(99, 102, 241, 0.25) !important;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
[data-bs-theme="dark"] .virtual-banner {
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.16) 0%, rgba(180, 62, 142, 0.16) 100%);
|
||||
border: 1px solid rgba(99, 102, 241, 0.35) !important;
|
||||
}
|
||||
|
||||
.virtual-stat-box {
|
||||
background-color: var(--bs-body-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.6rem 0.85rem;
|
||||
transition: transform 0.15s ease, border-color 0.15s ease;
|
||||
text-decoration: none !important;
|
||||
display: block;
|
||||
}
|
||||
.virtual-stat-box:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--bs-primary) !important;
|
||||
}
|
||||
|
||||
.metric-pill {
|
||||
padding: 0.35rem 0.2rem;
|
||||
border-radius: 0.45rem;
|
||||
@@ -84,27 +130,68 @@
|
||||
|
||||
{% block content %}
|
||||
<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="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3">
|
||||
<i class="bi bi-door-open text-primary"></i> {% trans %}Classrooms{% endtrans %}
|
||||
</h1>
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
|
||||
<div>
|
||||
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> {% trans %}Add Classroom{% endtrans %}
|
||||
</a>
|
||||
<h1 class="h3 mb-1">
|
||||
<i class="bi bi-door-open text-primary me-2"></i>{% trans %}Aulas y Espacios de Aprendizaje{% endtrans %}
|
||||
</h1>
|
||||
<p class="text-muted small mb-0">{% trans %}Administre las aulas físicas de sedes presenciales y las aulas virtuales a demanda del Campus Virtual.{% endtrans %}</p>
|
||||
</div>
|
||||
<div>
|
||||
{% if view_mode == 'virtual' %}
|
||||
<a href="{{ url_for('schedule.add_reservation') }}?is_virtual=1" class="btn btn-success shadow-sm">
|
||||
<i class="bi bi-camera-video me-1"></i>{% trans %}Nueva Clase Virtual a Demanda{% endtrans %}
|
||||
</a>
|
||||
{% 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>
|
||||
|
||||
<!-- 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="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">
|
||||
<input type="hidden" name="view_mode" value="physical">
|
||||
<div class="col-md-3">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-body-tertiary"><i class="bi bi-search"></i></span>
|
||||
@@ -114,12 +201,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtro por Edificio -->
|
||||
<!-- Filtro por Edificio Físico -->
|
||||
<div class="col-md-3">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-body-tertiary"><i class="bi bi-buildings text-primary"></i></span>
|
||||
<select name="building_id" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||
<option value="">{% trans %}Todos los edificios{% endtrans %}</option>
|
||||
<option value="">{% trans %}Todos los edificios presenciales{% endtrans %}</option>
|
||||
{% for b in buildings %}
|
||||
<option value="{{ b.id }}" {% if building_filter == (b.id|string) %}selected{% endif %}>{{ b.name }}</option>
|
||||
{% endfor %}
|
||||
@@ -127,7 +214,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtro por Piso -->
|
||||
<!-- Filtro por Piso Real -->
|
||||
<div class="col-md-2">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-body-tertiary"><i class="bi bi-layers text-secondary"></i></span>
|
||||
@@ -144,7 +231,7 @@
|
||||
|
||||
<div class="col-md-2">
|
||||
<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()">
|
||||
<label class="form-check-label small" for="activeOnly">
|
||||
{% trans %}Solo activas{% endtrans %}
|
||||
@@ -156,7 +243,7 @@
|
||||
<button type="submit" class="btn btn-sm btn-primary me-1">
|
||||
<i class="bi bi-filter"></i> {% trans %}Filtrar{% endtrans %}
|
||||
</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 %}
|
||||
</a>
|
||||
</div>
|
||||
@@ -166,7 +253,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Classrooms Grid -->
|
||||
<!-- Grilla de Aulas Físicas -->
|
||||
{% if classrooms and classrooms.items %}
|
||||
<div class="row g-3 mt-1">
|
||||
{% for classroom in classrooms.items %}
|
||||
@@ -199,21 +286,15 @@
|
||||
<!-- 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>
|
||||
<span class="text-muted">{% trans %}Cap:{% endtrans %}</span>
|
||||
<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>
|
||||
<span class="text-muted">{% trans %}Capacidad:{% endtrans %}</span>
|
||||
<strong class="text-body ms-1">{{ classroom.capacity }} {% trans %}asientos{% endtrans %}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted"><i class="bi bi-tools me-1"></i>{{ classroom.resources|length if classroom.resources else 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Barra de Métricas de Ocupación (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}) %}
|
||||
<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">
|
||||
@@ -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 %}"
|
||||
title="{% trans %}Ocupación Hoy{% endtrans %}: {{ metrics.day_count }} {% trans %}clases registradas{% endtrans %}">
|
||||
<div class="metric-label">{% trans %}Hoy{% endtrans %}</div>
|
||||
<div class="metric-val">{% if classroom.is_virtual %}{{ metrics.day_count }} cl.{% else %}{{ metrics.day_pct }}%{% endif %}</div>
|
||||
<div class="metric-val">{{ metrics.day_pct }}%</div>
|
||||
</a>
|
||||
</div>
|
||||
<!-- 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 %}"
|
||||
title="{% trans %}Ocupación Semana{% endtrans %}: {{ metrics.week_count }} {% trans %}clases registradas{% endtrans %}">
|
||||
<div class="metric-label">{% trans %}Semana{% endtrans %}</div>
|
||||
<div class="metric-val">{% if classroom.is_virtual %}{{ metrics.week_count }} cl.{% else %}{{ metrics.week_pct }}%{% endif %}</div>
|
||||
<div class="metric-val">{{ metrics.week_pct }}%</div>
|
||||
</a>
|
||||
</div>
|
||||
<!-- 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 %}"
|
||||
title="{% trans %}Ocupación Mes{% endtrans %}: {{ metrics.month_count }} {% trans %}clases registradas{% endtrans %}">
|
||||
<div class="metric-label">{% trans %}Mes{% endtrans %}</div>
|
||||
<div class="metric-val">{% if classroom.is_virtual %}{{ metrics.month_count }} cl.{% else %}{{ metrics.month_pct }}%{% endif %}</div>
|
||||
<div class="metric-val">{{ metrics.month_pct }}%</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -285,14 +366,14 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<!-- Paginación Aulas Físicas -->
|
||||
{% if classrooms.pages > 1 %}
|
||||
<nav aria-label="Classroom pagination">
|
||||
<ul class="pagination justify-content-center">
|
||||
<nav aria-label="Classroom pagination" class="mt-4">
|
||||
<ul class="pagination pagination-sm justify-content-center">
|
||||
{% if classrooms.has_prev %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=classrooms.prev_num, search=search, active_only=active_only) }}">
|
||||
{% trans %}Previous{% endtrans %}
|
||||
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', view_mode='physical', page=classrooms.prev_num, search=search, active_only='true' if active_only else 'false', building_id=building_filter, floor=floor_filter) }}">
|
||||
{% trans %}Anterior{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
@@ -301,7 +382,7 @@
|
||||
{% if page_num %}
|
||||
{% if page_num != classrooms.page %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=page_num, search=search, active_only=active_only) }}">
|
||||
<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 }}
|
||||
</a>
|
||||
</li>
|
||||
@@ -319,8 +400,8 @@
|
||||
|
||||
{% if classrooms.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=classrooms.next_num, search=search, active_only=active_only) }}">
|
||||
{% trans %}Next{% endtrans %}
|
||||
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', view_mode='physical', page=classrooms.next_num, search=search, active_only='true' if active_only else 'false', building_id=building_filter, floor=floor_filter) }}">
|
||||
{% trans %}Siguiente{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
@@ -333,20 +414,437 @@
|
||||
<div class="col-12">
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-door-open text-muted" style="font-size: 4rem;"></i>
|
||||
<h4 class="mt-3 text-muted">{% trans %}No classrooms found{% endtrans %}</h4>
|
||||
<h4 class="mt-3 text-muted">{% trans %}No se encontraron aulas físicas{% endtrans %}</h4>
|
||||
<p class="text-muted">
|
||||
{% if search %}
|
||||
{% trans %}No classrooms match your search criteria.{% endtrans %}
|
||||
{% trans %}Ningún aula coincide con los criterios de búsqueda.{% endtrans %}
|
||||
{% else %}
|
||||
{% trans %}No classrooms have been registered yet.{% endtrans %}
|
||||
{% trans %}No hay aulas físicas registradas en esta sede.{% endtrans %}
|
||||
{% endif %}
|
||||
</p>
|
||||
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> {% trans %}Add First Classroom{% endtrans %}
|
||||
<i class="bi bi-plus-circle"></i> {% trans %}Agregar Primera Aula{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- ========================================================================= -->
|
||||
<!-- VISTA 2: CAMPUS VIRTUAL (AULAS A DEMANDA · INFINITAS) -->
|
||||
<!-- ========================================================================= -->
|
||||
{% else %}
|
||||
|
||||
<!-- Banner de Infraestructura Cloud y Concurrencia Ilimitada -->
|
||||
<div class="card virtual-banner p-3 mb-4 shadow-sm">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2 mb-3">
|
||||
<div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="badge bg-primary text-white px-2 py-1"><i class="bi bi-cloud-check-fill me-1"></i>Cloud Infrastructure</span>
|
||||
<h5 class="mb-0 fw-bold text-primary">
|
||||
{% trans %}Campus Virtual UniCABA · Aulas a Demanda{% endtrans %}
|
||||
</h5>
|
||||
</div>
|
||||
<p class="small text-muted mb-0 mt-1">
|
||||
{% trans %}A diferencia de los espacios físicos, las aulas virtuales se crean y escalan de manera <strong>infinita y concurrente</strong> según la necesidad pedagógica de cada comisión.{% endtrans %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle px-3 py-2 fs-6">
|
||||
<i class="bi bi-infinity me-1"></i>{% trans %}Capacidad Ilimitada{% endtrans %}
|
||||
</span>
|
||||
<a href="{{ url_for('schedule.add_reservation') }}?is_virtual=1" class="btn btn-sm btn-primary">
|
||||
<i class="bi bi-plus-lg me-1"></i>{% trans %}Programar Clase Virtual{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Indicadores de Demanda Global -->
|
||||
<div class="row g-2">
|
||||
<div class="col-lg-3 col-sm-6">
|
||||
<a href="{{ url_for('schedule.today_schedule') }}" class="virtual-stat-box">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.7rem;">{% trans %}Sesiones Hoy{% endtrans %}</span>
|
||||
<h4 class="mb-0 fw-bold text-primary">{{ global_virtual_metrics.today_count }}</h4>
|
||||
</div>
|
||||
<div class="text-primary fs-4"><i class="bi bi-calendar-event"></i></div>
|
||||
</div>
|
||||
<small class="text-muted d-block mt-1" style="font-size: 0.72rem;">{% trans %}Clases virtuales programadas hoy{% endtrans %}</small>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3 col-sm-6">
|
||||
<a href="{{ url_for('schedule.list_reservations', classroom_id=virtual_classroom_id, date_from=date_filters.week_start, date_to=date_filters.week_end) }}" class="virtual-stat-box">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.7rem;">{% trans %}Sesiones Semana{% endtrans %}</span>
|
||||
<h4 class="mb-0 fw-bold text-info">{{ global_virtual_metrics.week_count }}</h4>
|
||||
</div>
|
||||
<div class="text-info fs-4"><i class="bi bi-calendar-week"></i></div>
|
||||
</div>
|
||||
<small class="text-muted d-block mt-1" style="font-size: 0.72rem;">{% trans %}Clases virtuales esta semana{% endtrans %}</small>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3 col-sm-6">
|
||||
<a href="{{ url_for('schedule.list_reservations', classroom_id=virtual_classroom_id, date_from=date_filters.month_start, date_to=date_filters.month_end) }}" class="virtual-stat-box">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.7rem;">{% trans %}Sesiones del Mes{% endtrans %}</span>
|
||||
<h4 class="mb-0 fw-bold text-success">{{ global_virtual_metrics.month_count }}</h4>
|
||||
</div>
|
||||
<div class="text-success fs-4"><i class="bi bi-calendar3"></i></div>
|
||||
</div>
|
||||
<small class="text-muted d-block mt-1" style="font-size: 0.72rem;">{% trans %}Total clases remotas en el mes{% endtrans %}</small>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3 col-sm-6">
|
||||
<div class="virtual-stat-box">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.7rem;">{% trans %}Salas Activas{% endtrans %}</span>
|
||||
<h4 class="mb-0 fw-bold text-warning">{{ global_virtual_metrics.commissions_count }}</h4>
|
||||
</div>
|
||||
<div class="text-warning fs-4"><i class="bi bi-diagram-3"></i></div>
|
||||
</div>
|
||||
<small class="text-muted d-block mt-1" style="font-size: 0.72rem;">{% trans %}Comisiones con entorno virtual{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Barra de Búsqueda de Salas Virtuales -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body py-2">
|
||||
<form method="GET" class="row g-2 align-items-center">
|
||||
<input type="hidden" name="view_mode" value="virtual">
|
||||
<div class="col-md-6">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-body-tertiary"><i class="bi bi-search"></i></span>
|
||||
<input type="text" name="search" class="form-control"
|
||||
placeholder="{% trans %}Buscar sala virtual por asignatura, comisión o carrera...{% endtrans %}"
|
||||
value="{{ search }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 text-md-end">
|
||||
<button type="submit" class="btn btn-sm btn-primary me-1">
|
||||
<i class="bi bi-funnel me-1"></i>{% trans %}Buscar Salas{% endtrans %}
|
||||
</button>
|
||||
<a href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-counterclockwise me-1"></i>{% trans %}Limpiar{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Catálogo de Aulas Virtuales por Comisión -->
|
||||
{% if virtual_rooms %}
|
||||
<div class="row g-3">
|
||||
{% for room in virtual_rooms %}
|
||||
<div class="col-xxl-3 col-xl-3 col-lg-4 col-md-6 col-sm-12 mb-2">
|
||||
<div class="card virtual-room-card border shadow-sm h-100">
|
||||
<div class="card-body p-3 d-flex flex-column justify-content-between">
|
||||
<div>
|
||||
<!-- Encabezado de la Sala Virtual -->
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<div class="text-truncate me-2">
|
||||
<h5 class="card-title mb-0 fw-bold text-primary text-truncate fs-6" title="{{ room.subject_name }}">
|
||||
<i class="bi bi-laptop me-1 text-info"></i>{{ room.subject_name }}
|
||||
</h5>
|
||||
</div>
|
||||
<span class="badge rounded-pill bg-info-subtle text-info border border-info-subtle" style="font-size: 0.68rem;">
|
||||
<i class="bi bi-infinity me-1"></i>A Demanda
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Badges: Comisión, Carrera y Turno -->
|
||||
<div class="d-flex flex-wrap gap-1 mb-2">
|
||||
<span class="badge bg-primary-subtle text-primary border" style="font-size: 0.7rem;">
|
||||
<i class="bi bi-diagram-3 me-1"></i>Com. {{ room.commission_code }}
|
||||
</span>
|
||||
{% if room.subject_code %}
|
||||
<span class="badge bg-secondary-subtle text-secondary border" style="font-size: 0.7rem;">
|
||||
{{ room.subject_code }}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="badge bg-light text-dark border" style="font-size: 0.7rem;">
|
||||
{{ room.shift }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Cupo e Información Pedagógica -->
|
||||
<div class="d-flex justify-content-between align-items-center small py-1 px-2 bg-body-tertiary rounded mb-2" style="font-size: 0.75rem;">
|
||||
<div>
|
||||
<span class="text-muted">{% trans %}Alumnos:{% endtrans %}</span>
|
||||
<strong class="text-body ms-1">{{ room.max_students }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted">{% trans %}Modalidad:{% endtrans %}</span>
|
||||
<strong class="text-info ms-1">Remoto ∞</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Botón de Videollamada (Zoom / Meet / Teams) -->
|
||||
<div class="mb-2">
|
||||
{% if room.virtual_link %}
|
||||
<a href="{{ room.virtual_link }}" target="_blank"
|
||||
class="btn btn-sm btn-outline-success w-100 py-1 d-flex align-items-center justify-content-center"
|
||||
style="font-size: 0.75rem;" title="{% trans %}Abrir enlace de videollamada{% endtrans %}">
|
||||
<i class="bi bi-camera-video-fill me-1"></i>{% trans %}Unirse a Videollamada{% endtrans %}
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="p-1 rounded bg-body-tertiary border text-center small text-muted" style="font-size: 0.72rem;">
|
||||
<i class="bi bi-link-45deg me-1"></i>{% trans %}Enlace a demanda por clase{% endtrans %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Barra de Demanda de la Sala Virtual (Hoy, Semana, Mes) -->
|
||||
<div class="p-2 rounded bg-body-tertiary border mb-2">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<span class="small fw-semibold text-muted" style="font-size: 0.72rem;">
|
||||
<i class="bi bi-calendar3 me-1 text-primary"></i>{% trans %}Clases a Demanda{% endtrans %}
|
||||
</span>
|
||||
<span class="text-muted" style="font-size: 0.65rem;">
|
||||
{{ room.total_count }} {% trans %}totales{% endtrans %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="row g-1 text-center">
|
||||
<!-- Hoy -->
|
||||
<div class="col-4">
|
||||
<div class="p-1 border metric-pill {% if room.day_count > 0 %}metric-low{% else %}metric-zero{% endif %}"
|
||||
data-bs-toggle="modal" data-bs-target="#modalPreviewVirtual{{ room.commission_id }}"
|
||||
role="button" title="{% trans %}Ver vista previa de clases de hoy{% endtrans %}">
|
||||
<div class="metric-label">{% trans %}Hoy{% endtrans %}</div>
|
||||
<div class="metric-val">{{ room.day_count }} cl.</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Semana -->
|
||||
<div class="col-4">
|
||||
<div class="p-1 border metric-pill {% if room.week_count > 0 %}metric-medium{% else %}metric-zero{% endif %}"
|
||||
data-bs-toggle="modal" data-bs-target="#modalPreviewVirtual{{ room.commission_id }}"
|
||||
role="button" title="{% trans %}Ver vista previa de clases de la semana{% endtrans %}">
|
||||
<div class="metric-label">{% trans %}Semana{% endtrans %}</div>
|
||||
<div class="metric-val">{{ room.week_count }} cl.</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mes -->
|
||||
<div class="col-4">
|
||||
<div class="p-1 border metric-pill {% if room.month_count > 0 %}metric-high{% else %}metric-zero{% endif %}"
|
||||
data-bs-toggle="modal" data-bs-target="#modalPreviewVirtual{{ room.commission_id }}"
|
||||
role="button" title="{% trans %}Ver vista previa de clases del mes{% endtrans %}">
|
||||
<div class="metric-label">{% trans %}Mes{% endtrans %}</div>
|
||||
<div class="metric-val">{{ room.month_count }} cl.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer con Botones -->
|
||||
<div class="pt-2 border-top d-flex justify-content-between align-items-center gap-1">
|
||||
<button type="button" class="btn btn-sm btn-primary py-1 px-2 d-flex align-items-center"
|
||||
data-bs-toggle="modal" data-bs-target="#modalPreviewVirtual{{ room.commission_id }}"
|
||||
style="font-size: 0.75rem;" title="{% trans %}Vista previa y modificar sesiones{% endtrans %}">
|
||||
<i class="bi bi-eye me-1"></i>{% trans %}Vista Previa{% endtrans %}
|
||||
<span class="badge bg-white text-primary ms-1" style="font-size: 0.65rem;">{{ room.total_count }}</span>
|
||||
</button>
|
||||
<a href="{{ url_for('schedule.add_reservation') }}?commission_id={{ room.commission_id }}&is_virtual=1"
|
||||
class="btn btn-sm btn-outline-success py-1 px-2" style="font-size: 0.75rem;" title="{% trans %}Programar clase en esta sala virtual{% endtrans %}">
|
||||
<i class="bi bi-plus-lg me-1"></i>{% trans %}Programar{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Vista Previa y Modificación de la Sala Virtual -->
|
||||
<div class="modal fade text-start" id="modalPreviewVirtual{{ room.commission_id }}" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h5 class="modal-title h6 fw-bold mb-1">
|
||||
<i class="bi bi-laptop text-primary me-2"></i>{{ room.subject_name }}
|
||||
</h5>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
<span class="badge bg-primary-subtle text-primary border" style="font-size: 0.7rem;">
|
||||
<i class="bi bi-diagram-3 me-1"></i>Comisión {{ room.commission_code }}
|
||||
</span>
|
||||
{% if room.subject_code %}
|
||||
<span class="badge bg-secondary-subtle text-secondary border font-monospace" style="font-size: 0.7rem;">
|
||||
{{ room.subject_code }}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="badge bg-info-subtle text-info border" style="font-size: 0.7rem;">
|
||||
<i class="bi bi-infinity me-1"></i>{% trans %}A Demanda · Capacidad Ilimitada{% endtrans %}
|
||||
</span>
|
||||
<span class="badge bg-light text-dark border" style="font-size: 0.7rem;">
|
||||
{{ room.shift }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<!-- 1. Enlace de Videollamada de la Comisión (Edición directa) -->
|
||||
<div class="card bg-body-tertiary border mb-3">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span class="fw-semibold small text-body">
|
||||
<i class="bi bi-camera-video text-primary me-1"></i>{% trans %}Enlace de Videollamada (Zoom / Meet / Teams / Moodle){% endtrans %}
|
||||
</span>
|
||||
{% if room.virtual_link %}
|
||||
<a href="{{ room.virtual_link }}" target="_blank" class="btn btn-sm btn-success py-0 px-2" style="font-size: 0.75rem;">
|
||||
<i class="bi bi-box-arrow-up-right me-1"></i>{% trans %}Abrir Reunión{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('classrooms.update_virtual_link', commission_id=room.commission_id) }}">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-body border"><i class="bi bi-link-45deg"></i></span>
|
||||
<input type="url" name="virtual_link" class="form-control"
|
||||
placeholder="{% trans %}Ingresar URL de Zoom, Google Meet, Teams, etc.{% endtrans %}"
|
||||
value="{{ room.virtual_link or '' }}">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-save me-1"></i>{% trans %}Guardar Enlace{% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
<small class="text-muted d-block mt-1" style="font-size: 0.7rem;">
|
||||
{% trans %}Este enlace se aplicará a todas las clases y cursadas virtuales de esta comisión.{% endtrans %}
|
||||
</small>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Tabla de Clases / Sesiones Programadas -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="fw-bold mb-0 text-body small">
|
||||
<i class="bi bi-calendar3 me-1 text-primary"></i>{% trans %}Sesiones Programadas en el Campus Virtual{% endtrans %} ({{ room.reservations|length }})
|
||||
</h6>
|
||||
<a href="{{ url_for('schedule.add_reservation') }}?commission_id={{ room.commission_id }}&is_virtual=1" class="btn btn-sm btn-outline-success py-0 px-2" style="font-size: 0.72rem;">
|
||||
<i class="bi bi-plus-lg me-1"></i>{% trans %}Nueva Clase{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if room.reservations %}
|
||||
<div class="table-responsive border rounded mb-2">
|
||||
<table class="table table-sm table-hover align-middle mb-0 small" style="font-size: 0.78rem;">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>#ID</th>
|
||||
<th>{% trans %}Fecha y Horario{% endtrans %}</th>
|
||||
<th>{% trans %}Turno / Propósito{% endtrans %}</th>
|
||||
<th>{% trans %}Docente{% endtrans %}</th>
|
||||
<th>{% trans %}Videollamada{% endtrans %}</th>
|
||||
<th>{% trans %}Estado{% endtrans %}</th>
|
||||
<th class="text-end">{% trans %}Acciones{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in room.reservations %}
|
||||
<tr>
|
||||
<td class="text-muted">#{{ r.id }}</td>
|
||||
<td>
|
||||
<span class="fw-bold text-body">{{ r.start_time.strftime('%d/%m/%Y') }}</span>
|
||||
<span class="text-muted d-block font-monospace" style="font-size: 0.72rem;">
|
||||
{{ r.start_time.strftime('%H:%M') }} - {{ r.end_time.strftime('%H:%M') }} hs
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-light text-dark border">{{ r.shift or 'Tarde' }}</span>
|
||||
<small class="text-muted d-block text-truncate" style="max-width: 130px;">{{ r.purpose or 'Clase regular' }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-body">{{ r.user.name if r.user else 'Docente Asignado' }}</small>
|
||||
</td>
|
||||
<td>
|
||||
{% if r.effective_virtual_link %}
|
||||
<a href="{{ r.effective_virtual_link }}" target="_blank" class="badge bg-success-subtle text-success border border-success-subtle text-decoration-none" title="{{ r.effective_virtual_link }}">
|
||||
<i class="bi bi-camera-video me-1"></i>{% trans %}Link Activo{% endtrans %}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-muted small">{% trans %}Hereda comisión{% endtrans %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle" style="font-size: 0.68rem;">
|
||||
{{ r.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="{{ url_for('schedule.edit_reservation', id=r.id) }}"
|
||||
class="btn btn-sm btn-primary py-0 px-2" style="font-size: 0.72rem;"
|
||||
title="{% trans %}Modificar Fecha, Horario, Aula o Detalles{% endtrans %}">
|
||||
<i class="bi bi-pencil me-1"></i>{% trans %}Modificar{% endtrans %}
|
||||
</a>
|
||||
<a href="{{ url_for('schedule.view_reservation', id=r.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary py-0 px-2" style="font-size: 0.72rem;"
|
||||
title="{% trans %}Ver Ficha Completa{% endtrans %}">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info py-2 small mb-2">
|
||||
<i class="bi bi-info-circle me-1"></i>{% trans %}No hay clases programadas para esta sala virtual en el período actual.{% endtrans %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="modal-footer d-flex justify-content-between">
|
||||
<a href="{{ url_for('schedule.list_reservations', classroom_id=virtual_classroom_id, search=room.subject_code or room.subject_name) }}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-list-check me-1"></i>{% trans %}Ver en Listado de Reservas (Filtrado){% endtrans %}
|
||||
</a>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cerrar{% endtrans %}</button>
|
||||
<a href="{{ url_for('schedule.add_reservation') }}?commission_id={{ room.commission_id }}&is_virtual=1" class="btn btn-sm btn-success">
|
||||
<i class="bi bi-plus-lg me-1"></i>{% trans %}Programar Nueva Clase{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-globe text-muted" style="font-size: 4rem;"></i>
|
||||
<h4 class="mt-3 text-muted">{% trans %}No se encontraron salas virtuales{% endtrans %}</h4>
|
||||
<p class="text-muted">
|
||||
{% if search %}
|
||||
{% trans %}Ninguna sala virtual coincide con los términos de búsqueda.{% endtrans %}
|
||||
{% else %}
|
||||
{% trans %}No hay clases virtuales programadas en el Campus Virtual.{% endtrans %}
|
||||
{% endif %}
|
||||
</p>
|
||||
<a href="{{ url_for('schedule.add_reservation') }}?is_virtual=1" class="btn btn-primary">
|
||||
<i class="bi bi-camera-video me-1"></i>{% trans %}Crear Primera Reserva Virtual{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -8,12 +8,6 @@
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-1 small">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('schedule.today_schedule') }}">{% trans %}Schedule{% endtrans %}</a></li>
|
||||
<li class="breadcrumb-item active">{% trans %}New Reservation{% endtrans %}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="bi bi-calendar-plus text-primary me-2"></i>{% trans %}New Reservation{% endtrans %}
|
||||
</h1>
|
||||
|
||||
@@ -516,12 +516,6 @@
|
||||
<!-- Header -->
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
|
||||
<div>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-1 small">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('main.dashboard') }}">{% trans %}Dashboard{% endtrans %}</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{% trans %}Comisiones{% endtrans %}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<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 %}
|
||||
</h1>
|
||||
|
||||
+102
@@ -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
File diff suppressed because it is too large
Load Diff
+7
-1
@@ -1,10 +1,16 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
basedir = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
|
||||
load_dotenv(os.path.join(basedir, '.env'))
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
|
||||
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
|
||||
API_BASE_URL = os.environ.get('API_BASE_URL') or 'http://localhost:8080/api'
|
||||
|
||||
|
||||
+15
-11
@@ -66,7 +66,7 @@ def init_database():
|
||||
default_roles = [
|
||||
{
|
||||
'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,
|
||||
'permissions': {
|
||||
'classrooms': 'read_write',
|
||||
@@ -82,7 +82,7 @@ def init_database():
|
||||
},
|
||||
{
|
||||
'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,
|
||||
'permissions': {
|
||||
'classrooms': 'read',
|
||||
@@ -97,8 +97,8 @@ def init_database():
|
||||
}
|
||||
},
|
||||
{
|
||||
'name': 'Bedelía',
|
||||
'description': 'Gestión operativa de aulas, sedes, reservas, comisiones, importación y cartelera',
|
||||
'name': 'Bedelia',
|
||||
'description': 'Gestion operativa de aulas, sedes, reservas, comisiones, importacion y cartelera',
|
||||
'is_system': False,
|
||||
'permissions': {
|
||||
'classrooms': 'read_write',
|
||||
@@ -189,7 +189,7 @@ def init_database():
|
||||
if not admin.theme_preference:
|
||||
admin.theme_preference = "auto"
|
||||
db.session.commit()
|
||||
print(f"[INFO] El usuario admin (admin@edu-space.com) 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
|
||||
print("[*] Verificando edificios institucionales...")
|
||||
@@ -199,13 +199,13 @@ def init_database():
|
||||
b_central = Building(
|
||||
name='Edificio Central',
|
||||
code='CENTRAL',
|
||||
address='Tte. Gral. Juan Domingo Perón 802, CABA',
|
||||
address='Tte. Gral. Juan Domingo Peron 802, CABA',
|
||||
floors=4,
|
||||
description='Sede central institucional de UniCABA. Aulas de grado, auditorio y laboratorios.'
|
||||
)
|
||||
db.session.add(b_central)
|
||||
else:
|
||||
b_central.address = 'Tte. Gral. Juan Domingo Perón 802, CABA'
|
||||
b_central.address = 'Tte. Gral. Juan Domingo Peron 802, CABA'
|
||||
|
||||
b_virtual = Building.query.filter_by(name='Campus Virtual').first()
|
||||
if not b_virtual:
|
||||
@@ -220,8 +220,8 @@ def init_database():
|
||||
db.session.commit()
|
||||
print("[OK] Edificios institucionales configurados con dirección oficial.")
|
||||
|
||||
# 6. Inicializar catálogo dinámico de tipificaciones de hitos académicos
|
||||
print("[*] Verificando catálogo de tipificaciones de hitos académicos...")
|
||||
# 6. Inicializar catalogo dinamico de tipificaciones de hitos academicos
|
||||
print("[*] Verificando catalogo de tipificaciones de hitos academicos...")
|
||||
from app.models.milestone import MilestoneType, BASE_MILESTONE_TYPES
|
||||
for item in BASE_MILESTONE_TYPES:
|
||||
mt = MilestoneType.query.filter_by(code=item['code']).first()
|
||||
@@ -241,14 +241,18 @@ def init_database():
|
||||
if not mt.sort_order:
|
||||
mt.sort_order = item['sort_order']
|
||||
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.")
|
||||
|
||||
except Exception as e:
|
||||
# Safe print to avoid UnicodeEncodeError in exception strings
|
||||
try:
|
||||
print(f"\n[ERROR] Error al inicializar la base de datos: {str(e)}")
|
||||
except UnicodeEncodeError:
|
||||
print("\n[ERROR] Error al inicializar la base de datos (se omiten caracteres no-ascii).")
|
||||
print("\nVerifica que:")
|
||||
print("1. El servicio de PostgreSQL esté corriendo.")
|
||||
print("1. El servicio de PostgreSQL este corriendo.")
|
||||
print("2. La base de datos 'classrooms_db' haya sido creada.")
|
||||
print("3. La variable DATABASE_URL en el archivo .env tenga el usuario y contraseña correctos.")
|
||||
sys.exit(1)
|
||||
|
||||
+474
@@ -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
@@ -7,12 +7,13 @@ Flask-CORS==6.0.2
|
||||
Flask-Babel==4.0.0
|
||||
WTForms==3.1.0
|
||||
Werkzeug==3.0.1
|
||||
SQLAlchemy==2.0.23
|
||||
SQLAlchemy>=2.0.32
|
||||
python-dotenv==1.0.0
|
||||
requests==2.31.0
|
||||
bleach==6.1.0
|
||||
python-dateutil==2.8.2
|
||||
Pillow==10.1.0
|
||||
Pillow>=10.4.0
|
||||
email_validator>=2.0.0
|
||||
psycopg2-binary==2.9.9
|
||||
psycopg2-binary>=2.9.10
|
||||
Babel==2.14.0
|
||||
gunicorn>=21.2.0
|
||||
|
||||
Reference in New Issue
Block a user