feat: implementacion de dashboards por rol, soporte de clases virtuales concurrentes en importador de sheets y cartelera del dia

This commit is contained in:
2026-09-18 23:04:48 -03:00
parent 47b50ede7e
commit 1080acd43a
240 changed files with 8354 additions and 8886 deletions
+11
View File
@@ -210,3 +210,14 @@ cython_debug/
marimo/_static/ marimo/_static/
marimo/_lsp/ marimo/_lsp/
__marimo__/ __marimo__/
# Node.js / Frontend
node_modules/
npm-debug.log
.env
.env.*
!.env.example
frontend/.env
legacy_admin-edu-space/
.vscode/
+76 -426
View File
@@ -1,6 +1,8 @@
# Panel de Administración Edu-Space (UniCABA) # Panel de Administración Edu-Space (UniCABA)
Plataforma integral de gestión áulica, asignación de espacios, control de horarios y reservas académicas para la **Universidad de la Ciudad de Buenos Aires (UniCABA)**, desarrollada con **Python / Flask**, **SQLAlchemy**, **PostgreSQL** y **Bootstrap 5**. Plataforma integral de gestión áulica, asignación de espacios, control de horarios y reservas académicas para la **Universidad de la Ciudad de Buenos Aires (UniCABA)**.
El proyecto se basa en una arquitectura **desacoplada (Frontend / Backend)** en formato monorepo.
--- ---
@@ -10,444 +12,92 @@ Plataforma integral de gestión áulica, asignación de espacios, control de hor
--- ---
## ✨ Características Principales ## 🏗️ Arquitectura del Proyecto (Monorepo)
### 🛡️ 1. Control de Accesos RBAC Granular (Matriz estilo FortiGate) El repositorio está dividido en dos aplicaciones principales que interactúan mediante una API REST y un patrón Backend-For-Frontend (BFF).
* **Gestión por Módulos:** Permisos independientes por cada área del sistema:
1. **Aulas y Espacios:** Creación, edición, capacidad y estado de aulas físicas y virtuales.
2. **Edificios y Sedes:** Administración de sedes, pisos y arquitectura edilicia.
3. **Reservas de Aulas:** Solicitud, aprobación, confirmación y cancelación.
4. **Cartelera y Cronograma:** Calendario interactivo, visualización diaria y comisiones.
5. **Gestión Académica:** Carreras, asignaturas, códigos y comisiones.
6. **Sincronización Sheets:** Importador y sincronizador de Google Sheets.
7. **Optimizador IA:** Algoritmo genético de distribución y resolución de conflictos.
8. **Usuarios y Accesos RBAC:** Cuentas, roles y matriz de permisos.
* **Niveles de Permiso Granulares:** `Ninguno`, `Solo Lectura` o `Lectura y Escritura`.
* **Controles Rápidos:** Botón *"Establecer Todos"* para configuración masiva inmediata.
* **Roles Predefinidos:** `Admin` (control total), `Docente`, `Operador` y `Consulta`.
### 📊 2. Sincronización e Importación desde Google Sheets
* Integración directa con la cartelera académica de UniCABA en Google Sheets.
* Extracción e importación normalizada de los 5 campos clave:
* **Código Académico:** Códigos reales de asignatura (ej. `INGA0003`, `ASIG00165`).
* **Asignatura:** Nombre oficial de la materia.
* **Aula:** Mapeo a aulas físicas (PB a 4.° piso) o aula `VIRTUAL` ($\infty$).
* **Carrera:** Normalización y vinculación relacional con entidades de carrera.
* **Horarios y Turnos:** Detección automática de franjas horarias y delimitadores de bloque (`Turno Mañana` y `Turno Tarde / Vespertino`).
### 📅 3. Calendario Interactivo de Reservas (FullCalendar)
* **Visualización Semanal, Mensual y Diaria:** Grilla ágil de alta legibilidad con jerarquía visual:
* Asignatura destacada en tipografía grande y negrita.
* Badges con código de materia, aula asignada, turno y franja horaria.
* **Filtros Dinámicos Combinables:**
* Filtrar por **Aula**.
* Filtrar por **Materia / Asignatura**.
* Filtrar por **Carrera**.
* Filtrar por **Turno** (`Turno Mañana` / `Turno Tarde`).
* Filtrar por **Piso / Planta** (`Planta Baja`, `Piso 1`, `Piso 2`, `Piso 3`, `Piso 4`).
* **Switch "Ocultar Aulas Virtuales":** Aísla la ocupación física real en el edificio central excluyendo los eventos virtuales de capacidad infinita.
* **Ficha Modal Instantánea:** Vista previa detallada al hacer clic en cualquier evento.
### 📋 4. Cartelera del Día y Listado de Reservas
* **Cartelera del Día (`/schedule/today`):**
* **Vista Cronológica (Timeline):** Bloques horarios de 07:00 a 22:00 hs con indicador de ocupación.
* **Vista en Tabla:** Lista ordenada con acciones rápidas y detalles de comisión.
* Filtros directos por piso, turno y switch para ocultar virtuales.
* **Listado Paginado de Reservas (`/schedule/list`):**
* Paginación de 20 registros por página preservando filtros aplicados.
* Búsqueda por texto, estado (`Confirmada`, `Pendiente`, `Cancelada`), rango de fechas y piso.
### 🏫 5. Gestión de Aulas con Capacidad Infinita ($\infty$)
* Soporte nativo para el símbolo **`∞`** o `0` para designar aulas virtuales o espacios sin aforo restringido.
* Botón de un solo clic `Ilimitada (∞)` en formularios de alta y edición con leyendas explicativas.
* Badges distintivos `∞ Ilimitada` en listados, tarjetas y fichas técnicas.
### 🤖 6. Optimizador Inteligente de Aulas (Algoritmo Genético)
* Motor de optimización heurística para asignación automática de aulas a comisiones.
* Pondera capacidad requerida vs. capacidad de aula, equipamiento tecnológico y minimización de desplazamientos entre pisos.
### 🎨 7. Experiencia de Usuario, Temas y Localización
* **Tema Oscuro / Claro:** Alternador con botón flotante y persistencia en `localStorage` y perfil de usuario.
* **Soporte Multilingüe:** Internacionalización integral (Español e Inglés) compilada con GNU gettext (`pybabel`).
* **Tarjetas Estadísticas Interactivas:** Accesos directos rápidos desde el panel general hacia las distintas secciones operativas.
### 🏛️ 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.
### 🔀 9. Selector de Vista Universal (Tabla / Grilla vs. Tarjetas / Mosaico)
La plataforma implementa una arquitectura desacoplada de visualización (`app/static/js/view_switcher.js` y `app/templates/partials/view_switcher.html`) que permite al usuario alternar instantáneamente entre diferentes formatos de presentación sin modificar los datos ni perder los filtros o búsquedas activas:
* **Vista de Tabla / Grilla (`▤ Tabla`):**
* Presentación tabular en filas y columnas de alta densidad informativa.
* Muestra múltiples atributos simultáneamente (edificio, piso, aforo, métricas de ocupación, estado, acciones).
* Ideal para consulta y comparación masiva de registros.
* **Vista de Tarjetas / Mosaico (`▦ Tarjetas`):**
* Presentación visual en tarjetas independientes distribuidas automáticamente según el ancho de pantalla (1 a 4 columnas).
* Enfoque en la información destacada con badges semánticos, barras de uso y botones de acceso rápido.
* **Características del Selector:**
* **Cambio Inmediato:** Conmutación instantánea en el cliente sin recarga de página (`d-none` / manipulación de DOM).
* **Persistencia de Preferencia:** Memorización automática mediante `localStorage` para conservar la elección del usuario en futuras visitas.
* **Preservación de Filtros:** Inyección automática del parámetro `display` en formularios y sincronización de URL sin recarga (`history.replaceState`).
* **Arquitectura Extensible:** Permite registrar futuras vistas (kanban, calendario, compacta) mediante atributos `data-view` y `data-view-container` sin alterar la lógica de negocio.
### 🧭 10. Arquitectura de Información & Navegación Basada en Estándares de la Industria
La barra de navegación principal (`app/templates/base.html`) fue rediseñada integralmente bajo las mejores prácticas de experiencia de usuario (UX) y arquitectura de información (IA) de sistemas ERP universitarios de referencia internacional (Canvas, Blackboard, Workday Student):
* **Eliminación de Redundancias:** Supresión de destinos duplicados que anteriormente saturaban los menús desplegables.
* **Menús Estructurados por Rol Institucional:**
* **Administrador (`Admin`):** Menú de gobernanza integral dividido en `Panel General`, `Espacios & Sedes`, `Cronograma & Reservas`, `Gestión Académica` y `Administración & Seguridad` (con Auditoría, Usuarios y Roles RBAC).
* **Bedelía (`Bedelia`):** Menú operativo del campus dividido en `Panel Bedelía`, `Espacios & Aulas`, `Operaciones & Reservas` (Cartelera en tiempo real, registro de reservas) y `Académica & Cursadas`.
* **Docente / Profesor (`Docente`):** Menú simplificado y centrado en la enseñanza dividido en `Panel Docente`, `Mis Clases & Reservas` (Mis reservas, solicitar aula, comisiones) y `Campus & Horarios` (Cartelera, calendario y aulas virtuales).
* **Alumno / Estudiante (`Alumno`):** Navegación directa y accesible: `Mi Portal`, `Cartelera de Hoy` (¿Dónde curso hoy?), `Cronograma de Clases`, `Aulas Virtuales` y `Espacios del Campus`.
* **Identidad Visual del Usuario:** Badge institucional con gradiente de color e icono específico de rol (`badge-admin`, `badge-bedelia`, `badge-docente`, `badge-alumno`) integrado en el avatar de usuario.
### 🎯 11. Dashboards Personalizados por Rol Institucional & Selector de Perspectiva
El panel de control (`app/routes/main.py` y `app/templates/dashboard.html`) adapta dinámicamente sus KPIs, accesos directos y widgets según la identidad del usuario logueado:
* **Dashboard Administrador:**
* KPIs estratégicos: Aulas totales, oferta de materias, usuarios registrados y reservas de hoy.
* Módulos visuales de lanzamiento rápido a las áreas clave del sistema.
* Gráfico analítico de evolución mensual de reservas (`Chart.js`) y tabla de eventos recientes de auditoría.
* **Dashboard Bedelía:**
* KPIs de infraestructura: Aulas físicas operativas, salas virtuales, clases del día y reservas pendientes.
* **Banner de Alerta Operativa:** Destaca solicitudes pendientes de aprobación con acceso de un clic para confirmarlas.
* Atajos para supervisión de cartelera en vivo, asignación de espacios, sincronización Google Sheets y ocupación.
* **Dashboard Profesor / Docente:**
* KPIs del docente: Clases del día, reservas activas, salas virtuales disponibles y botón destacado de "Solicitar Aula".
* **Widget "Mis Clases Programadas para Hoy":** Muestra horarios exactos, comisiones, aula física (Edificio/Piso/Aula) o botón directo "Ingresar a Reunión" para clases remotas.
* **Dashboard Alumno / Estudiante:**
* **Brújula Diaria "¿Dónde curso hoy?":** Tabla clara y legible con horario, materia, comisión, y ubicación exacta (Edificio · Piso · Aula) o botón de videoconferencia si la clase es virtual.
* **Próximos Hitos Académicos & Exámenes:** Calendario de parciales, finales y entregas programadas.
* **Selector de Perspectiva (Vista Previa de Roles):**
* Los administradores y evaluadores cuentan con un control selector en la cabecera del dashboard que permite conmutar la visualización entre los 4 roles (`?view_as=admin|bedelia|docente|alumno`) para auditar la experiencia de cada perfil institucional.
### 🛡️ 12. Bitácora de Auditoría Institucional (`/admin/audit-logs`)
* Registro inmutable de eventos institucionales críticos (creación, edición, borrado de aulas, reservas y autenticación).
* Interfaz con filtrado multifactor (búsqueda por texto, módulo y tipo de acción), paginación y trazabilidad de dirección IP y usuario actuante.
---
## 🛠️ Stack Tecnológico
| Componente | Tecnología |
| :--- | :--- |
| **Backend** | Python 3.12+ / Flask |
| **Base de Datos** | PostgreSQL 16+ / SQLAlchemy ORM |
| **Seguridad & Sesiones** | Flask-Login / Werkzeug / CSRF Protect |
| **Formularios** | Flask-WTF / WTForms |
| **Internacionalización** | Flask-Babel / GNU gettext |
| **Frontend** | Bootstrap 5.3, Bootstrap Icons, Vanilla JS, CSS Variables |
| **Calendario** | FullCalendar 5.11 |
---
## 🚀 Despliegue en Proxmox VE (LXC) con Entorno Virtual `venv`
El sistema está especialmente optimizado para ejecutarse en contenedores **LXC (Debian 12 Bookworm o Ubuntu 22.04/24.04 LTS)** dentro de **Proxmox VE**, utilizando un entorno virtual nativo de Python (`venv`), el motor **PostgreSQL** instalado en el propio contenedor y el servidor WSGI **Gunicorn** gestionado como servicio por **systemd**.
---
### 📋 Paso 1: Creación del Contenedor LXC en Proxmox VE
Podés crear el contenedor desde la interfaz Web de Proxmox (*Create CT*) o por terminal Proxmox con `pct create`:
* **Plantilla (Template):** `debian-12-standard` (*Recomendado*) o `ubuntu-22.04-standard` / `ubuntu-24.04-standard`.
* **CPU:** 1 a 2 vCPU.
* **Memoria RAM:** 1024 MB a 2048 MB (Swap: 512 MB).
* **Disco:** 8 GB a 15 GB (almacenamiento `local-lvm` o `zfs`).
* **Red (Network):** Modo Bridge `vmbr0` con IP estática en tu red local o DHCP (ej: `192.168.1.150/24`, Gateway: `192.168.1.1`).
* **Opciones del Contenedor:**
* *Unprivileged container:* Sí (marcado por defecto, compatible).
* *Features (Características):* Habilitar **Nesting (`nesting=1`)** en `CT > Opciones > Características` (requerido para systemd).
---
### ⚡ Paso 2: Instalación Automatizada con `install.sh`
1. **Ingresar a la consola del contenedor LXC:**
Desde la Web UI de Proxmox (consola noVNC) o desde el shell de tu nodo Proxmox:
```bash
pct enter <ID_DEL_CONTENEDOR>
```
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
.\venv\Scripts\Activate.ps1
```
* En Linux / macOS:
```bash
python3 -m venv venv
source venv/bin/activate
```
2. **Instalar dependencias:**
```bash
pip install -r requirements.txt
```
3. **Configurar base de datos local y restaurar snapshot:**
```bash
cp .env.example .env
# Crear base de datos en PostgreSQL local y cargar datos
sudo -u postgres psql -c "CREATE DATABASE classrooms_db;"
sudo -u postgres psql -d classrooms_db < classrooms_db.sql
```
4. **Compilar catálogos de traducción:**
```bash
pybabel compile -d translations
```
5. **Iniciar el servidor:**
```bash
python wsgi.py
```
Disponible en: **`http://localhost:5000`**
---
## 🔑 Credenciales Predeterminadas
| Usuario | Correo Electrónico | Contraseña | Rol |
| :--- | :--- | :--- | :--- |
| **Administrador del Sistema** | `admin@edu-space.com` | `admin123` | `Admin` (Acceso Total) |
> ⚠️ **Seguridad:** Cambiá la contraseña del administrador desde la interfaz web inmediatamente después del primer inicio de sesión.
---
## 📁 Estructura del Proyecto
```text ```text
admin-edu-space/ /
├── app/ ├── backend/ # API REST Backend (Python / Flask)
│ ├── __init__.py # Factoría de la aplicación Flask y extensiones │ ├── app/ # Lógica de negocio, base de datos y endpoints
│ ├── forms/ # Formularios Flask-WTF con validación CSRF │ ├── tests/ # Pruebas unitarias e integración
│ │ ├── auth.py # Login, perfil y cambio de clave │ └── requirements.txt # Dependencias de Python
│ │ ├── building.py # Sedes y edificios │
│ │ ├── classroom.py # Aulas y capacidades (con soporte ∞) ├── frontend/ # Backend-For-Frontend (Node.js / Express)
│ │ └── reservation.py # Reservas de aulas y horarios │ ├── src/ # Controladores, middleware, servicios (Axios)
│ ├── models/ # Modelos relacionales SQLAlchemy │ ├── views/ # Plantillas Nunjucks (.html)
│ │ ├── academic_term.py # Ciclos lectivos y períodos académicos (activo/histórico) │ ├── public/ # Archivos estáticos (CSS, JS cliente)
│ │ ├── audit_log.py # Bitácora inmutable de auditoría de eventos │ └── package.json # Dependencias de Node.js
│ │ ├── building.py # Sedes y edificios institucionales │
│ │ ├── career.py # Carreras y titulaciones universitarias ├── docs/ # Historial de cambios y documentación técnica
│ │ ├── classroom.py # Aulas físicas y virtuales con capacidades └── unicaba_prerequitos/ # Requisitos académicos y de negocio
│ │ ├── 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
│ ├── repositories/ # Capa de persistencia desacoplada (Patrón Repository)
│ │ ├── base_repository.py # CRUD genérico tipado BaseRepository[T]
│ │ ├── classroom_repository.py # Filtrado de aulas físicas y virtuales
│ │ ├── reservation_repository.py# Detección de solapamiento y consultas temporales
│ │ └── user_repository.py # Consultas seguras de usuarios y roles
│ ├── schemas/ # Contratos DTOs con validación Pydantic v2
│ │ ├── auth_dto.py # Esquemas de login y refresh tokens
│ │ ├── classroom_dto.py # Contratos de creación y edición de aulas
│ │ ├── optimizer_dto.py # DTOs para optimizador asíncrono y microservicios Spring Boot
│ │ └── reservation_dto.py # Validación de franjas horarias y aforo
│ ├── security/ # Pipeline de seguridad inspirado en Spring Security
│ │ ├── filter_chain.py # SecurityFilterChain (OWASP Headers & Rate Limiting)
│ │ └── decorators.py # @require_permission declarativo a nivel método
│ ├── services/ # Capa de servicios y lógica de negocio
│ │ ├── audit_service.py # Registro y consulta de auditoría inmutable
│ │ ├── classroom_service.py # Lógica de asignación, aforo y validación áulica
│ │ ├── jwt_service.py # Emisión de tokens (Dual Token) y lista negra
│ │ ├── optimizer_service.py # Cola de trabajos asíncrona y acelerador concurrente
│ │ ├── reservation_service.py # Motor de resolución de conflictos de cronograma
│ │ ├── sheets_importer.py # Parser y sincronizador de Google Sheets
│ │ └── user_service.py # Autenticación y perfil con matriz RBAC
│ ├── routes/ # Controladores y rutas modulares (Blueprints)
│ │ ├── api/ # Endpoints RESTful (v1) con Stateless JWT
│ │ │ ├── auth.py # /api/v1/auth (login, refresh, logout, me)
│ │ │ ├── classrooms.py # /api/v1/classrooms (CRUD REST tipado)
│ │ │ ├── optimizer.py # /api/v1/optimizer (cola asíncrona y sincronización Spring Boot)
│ │ │ └── reservations.py # /api/v1/reservations (Gestión y disponibilidad)
│ │ ├── admin.py # Carreras, Asignaturas, Tipificaciones, Métricas, Usuarios
│ │ ├── auth.py # Autenticación web tradicional Jinja2
│ │ ├── buildings.py # Gestión de sedes y edificios
│ │ ├── classrooms.py # Aulas presenciales vs. Virtuales (controlador delgado)
│ │ ├── genetic_algorithm.py # Optimizador heurístico de asignación
│ │ ├── main.py # Dashboard principal y métricas rápidas
│ │ └── schedule.py # Calendario interactivo, cartelera del día y reservas
│ ├── utils/ # Utilidades transversales
│ │ └── jwt_decorators.py # @jwt_required y @jwt_role_required
│ ├── static/ # Recursos estáticos web (CSS, JS, imágenes institucionales)
│ └── templates/ # Vistas Jinja2 renderizadas con Bootstrap 5
├── config/ # Configuraciones de entorno (desarrollo, testing, prod)
├── translations/ # Catálogos GNU gettext compilados (es / en)
├── classrooms_db.sql # Snapshot completo de base de datos (PostgreSQL 14+)
├── install.sh # Instalador automatizado para Proxmox VE LXC (venv + PostgreSQL)
├── backup_db.sh # Script para generar nuevos backups y actualizar snapshot
├── init_db.py # Inicializador DDL y sincronizador de esquema/roles
├── CHANGELOG_SESSION.md # Historial técnico cronológico
├── requirements.txt # Dependencias Python (incluye Gunicorn)
├── .env.example # Plantilla de variables de entorno
└── wsgi.py # Punto de entrada de la aplicación Flask
``` ```
### 🐍 Backend (Python + Flask)
- Funciona exclusivamente como una **REST API** que expone endpoints bajo `/api/v1/` retornando **JSON**.
- Encargado de la persistencia de datos (SQLAlchemy / PostgreSQL), lógica de negocio dura, JWT auth service, seguridad, y algoritmos de optimización (Módulo Predictivo).
- **Prohibido** retornar HTML o utilizar Jinja2/`render_template` en este entorno.
### 🟢 Frontend BFF (Node.js + Express)
- Aplicación de servicio que renderiza las vistas para el cliente mediante **Nunjucks**.
- Gestiona la sesión segura del usuario almacenando el token JWT recibido de Flask en una **Cookie HttpOnly**.
- Actúa como proxy de conexión con el Backend de Python empleando **Axios** (inyectando el Bearer token interceptado).
- Utiliza **Chart.js** y JS vanilla en el lado del cliente para visualizaciones interactivas.
--- ---
## 🚀 Roadmap Académico & Propuesta de Evolución Tecnológica ## 🎯 Dashboards Personalizados por Rol
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)**. La plataforma cuenta con paneles adaptados a las necesidades operativas de cada perfil institucional:
### 💡 1. Propuesta de Mejora Integral * **Administrador (`/dashboard?role=admin`):** Cockpit institucional con métricas globales, auditoría de seguridad RBAC, tendencias mensuales y catálogo de gestión.
* **Bedelía (`/dashboard?role=bedelia`):** Control operativo del campus en tiempo real, conteo de aulas físicas y virtuales, cartelera del día y bandeja de aprobación de reservas pendientes.
* **Docente / Profesor (`/dashboard?role=docente`):** Sesiones del día con accesos directos a videollamadas, gestión de comisiones y formulario para solicitar aulas.
* **Alumno / Estudiante (`/dashboard?role=alumno`):** Brújula de cursada *"¿Dónde curso hoy?"* con ubicación por piso y aula o enlace virtual, junto al cronograma de exámenes y entregas de TPs.
#### A. Arquitectura MVC Rigurosa & Clean Architecture > **Modo Vista Previa:** Los usuarios con perfil administrador o directivo pueden previsualizar cualquier perspectiva utilizando el selector de roles en la barra superior o pasando el parámetro `?role={admin|bedelia|docente|alumno}` en la URL.
* **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" ## ☁️ Importador de Google Sheets & Campus Virtual
* **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. El sistema incluye sincronización automatizada con las planillas oficiales de cursada de UniCABA:
* **Security Headers Filter:** Inyección obligatoria de cabeceras de blindaje HTTP (HSTS, CSP, X-Frame-Options: DENY, X-Content-Type-Options: nosniff). * **Concurrencia Virtual Ilimitada:** Reconocimiento de espacios áulicos remotos (`Campus Virtual · VIRTUAL`), permitiendo la coexistencia de múltiples comisiones en la misma franja horaria sin colisiones de agenda.
* **Rate Limiting Filter:** Prevención de ataques de fuerza bruta y DDoS en endpoints sensibles mediante límites dinámicos (`Flask-Limiter` / Redis). * **Enlaces de Videollamada Automáticos:** Generación de enlaces seguros (Google Meet / Teams) para acceso instantáneo de docentes y estudiantes.
* **Seguridad Declarativa a Nivel Método (`@PreAuthorize` / `@require_permission`):** * **Filtros en Cartelera:** Opciones para filtrar por turno, piso u ocultar aulas virtuales mediante el switch dedicado en `/schedule/today_schedule`.
* 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): ... ## 🚀 Instalación y Despliegue Local
### Requisitos previos
- Node.js (v18 o superior)
- Python 3.10+
- PostgreSQL / SQLite
### 1. Iniciar el Backend (Flask)
```bash
cd backend
python -m venv venv
# Activar venv (Windows: venv\Scripts\activate, Linux/Mac: source venv/bin/activate)
pip install -r requirements.txt
flask run --port=5000
``` ```
* **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. *(El backend quedará escuchando en http://localhost:5000)*
### 2. Iniciar el Frontend (Node/Express)
```bash
cd frontend
npm install
npm run dev
# o en su defecto:
node src/app.js
```
*(El frontend quedará escuchando en http://localhost:3000)*
--- ---
### 📅 2. Planificación de Sprints (Roadmap) ## 📚 Documentación Técnica & Changelogs
* [Changelog: Clases Virtuales, Importador y Dashboards](docs/CHANGELOG_VIRTUAL_CLASSES_AND_DASHBOARDS.md)
| Sprint / Fase | Objetivo Principal | Entregables Clave | Estado | Hito de Evaluación | * [Auditoría y Sesión de Reingeniería](docs/CHANGELOG_SESSION.md)
| :--- | :--- | :--- | :--- | :--- | * [Guía de Integración de Cronograma](docs/SCHEDULE_INTEGRATION_GUIDE.md)
| **Sprint 1** | **Refactorización MVC & Capa de Servicios** | • Extracción de lógica a `app/services/`<br>• Patrón Repository para aulas y reservas (`app/repositories/`)<br>• DTOs tipados de validación con esquemas Pydantic v2 | **✔ Completado** | Controladores delgados (< 60 líneas), desacoplamiento Clean Architecture y persistencia aislada. |
| **Sprint 2** | **Autenticación Stateless JWT & API Gateway** | • Endpoints `/api/v1/auth/login`, `/refresh`, `/logout`, `/me`<br>• Endpoints RESTful de aulas y reservas<br>• Decoradores `@jwt_required` y middleware Bearer Token | **✔ Completado** | Consumo seguro desde Swagger/Postman y clientes externos con Dual Token Pattern (15 min + 7 días). |
| **Sprint 3** | **Sprint Security & Gobernanza RBAC** | • Pipeline centralizado `SecurityFilterChain`<br>• Rate Limiting dinámico (fuerza bruta / DoS)<br>• Decorador `@require_permission` a nivel método<br>• Bitácora inmutable de auditoría (`AuditLog`) | **✔ Completado** | Inyección de cabeceras OWASP Secure Headers y trazabilidad completa de eventos críticos con IP/usuario. |
| **Sprint 4** | **Optimizador Heurístico & Alta Concurrencia** | • Paralelización del algoritmo genético con multiprocessing/workers<br>• Colas asíncronas para optimizaciones pesadas (`OptimizerService`)<br>• Integración con microservicio Spring Boot vía REST/JWT (`/api/v1/optimizer/spring-boot/sync`) | **✔ Completado** | Optimización de 200+ comisiones en < 2.8s 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.
␍
-322
View File
@@ -1,322 +0,0 @@
<!DOCTYPE html>
<html lang="{{ current_lang }}" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{% trans %}Edu-Space Admin{% endtrans %}{% endblock %}</title>
<!-- Favicon -->
<link rel="icon" type="image/png" href="{{ url_for('static', filename='img/favicon.png') }}">
<!-- Script Anti-Flicker para Modo Claro / Oscuro -->
<script>
(function() {
let userTheme = "{{ current_user.theme_preference if (current_user and current_user.is_authenticated) else '' }}";
let theme = userTheme;
if (!theme || theme === 'auto') {
const storedTheme = localStorage.getItem('theme');
theme = storedTheme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
}
document.documentElement.setAttribute('data-bs-theme', theme);
})();
</script>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
<!-- Design System & Custom Theme -->
<link href="{{ url_for('static', filename='css/theme.css') }}" rel="stylesheet">
<link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
{% block extra_css %}{% endblock %}
</head>
<body data-user-authenticated="{{ 'true' if (current_user and current_user.is_authenticated) else 'false' }}">
{% if current_user.is_authenticated %}
<!-- Navigation -->
<nav class="navbar navbar-expand-lg navbar-dark navbar-app fixed-top">
<div class="container-fluid">
<a class="navbar-brand d-flex align-items-center" href="{{ url_for('main.dashboard') }}">
<img src="{{ url_for('static', filename='img/logo_unicaba_mini.png') }}" alt="Logo UniCABA" class="navbar-logo me-2">
<span>Edu-Space Admin</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
{% set user_role = current_user.get_institutional_role() %}
<ul class="navbar-nav me-auto">
{% if user_role == 'admin' %}
<!-- ================= ADMIN NAVIGATION ================= -->
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.dashboard') }}">
<i class="bi bi-speedometer2 me-1"></i>{% trans %}Panel General{% endtrans %}
</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-door-open me-1"></i>{% trans %}Espacios & Sedes{% endtrans %}
</a>
<ul class="dropdown-menu shadow-sm">
<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('classrooms.list_classrooms') }}"><i class="bi bi-grid me-2 text-secondary"></i>{% trans %}Todas las Aulas{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('classrooms.add_classroom') }}"><i class="bi bi-plus-circle me-2 text-success"></i>{% trans %}Nueva Aula{% endtrans %}</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}"><i class="bi bi-buildings me-2 text-secondary"></i>{% trans %}Edificios y Sedes{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.occupancy_metrics') }}"><i class="bi bi-graph-up-arrow me-2 text-success"></i>{% trans %}Métricas de Ocupación{% endtrans %}</a></li>
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-calendar3 me-1"></i>{% trans %}Cronograma & Reservas{% endtrans %}
</a>
<ul class="dropdown-menu shadow-sm">
<li><a class="dropdown-item" href="{{ url_for('schedule.calendar_view') }}"><i class="bi bi-calendar-week me-2 text-primary"></i>{% trans %}Calendario General{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.today_schedule') }}"><i class="bi bi-calendar-day me-2 text-info"></i>{% trans %}Cartelera del Día{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}"><i class="bi bi-list-check me-2 text-warning"></i>{% trans %}Gestión de Reservas{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.add_reservation') }}"><i class="bi bi-plus-circle me-2 text-success"></i>{% trans %}Nueva Reserva{% 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><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('genetic_algorithm_web.genetic_optimizer') }}"><i class="bi bi-cpu me-2 text-info"></i>{% trans %}Optimizador de Aulas (IA){% endtrans %}</a></li>
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-mortarboard me-1"></i>{% trans %}Gestión Académica{% endtrans %}
</a>
<ul class="dropdown-menu shadow-sm">
<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('admin.commissions_list') }}"><i class="bi bi-diagram-3 me-2 text-success"></i>{% trans %}Comisiones{% 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>
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-shield-lock me-1"></i>{% trans %}Administració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 text-primary"></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 text-info"></i>{% trans %}Roles y Permisos RBAC{% 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>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('admin.audit_logs') }}"><i class="bi bi-shield-check me-2 text-warning"></i>{% trans %}Registro de Auditoría{% endtrans %}</a></li>
</ul>
</li>
{% elif user_role == 'bedelia' %}
<!-- ================= BEDELÍA NAVIGATION ================= -->
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.dashboard') }}">
<i class="bi bi-speedometer2 me-1"></i>{% trans %}Panel Bedelía{% endtrans %}
</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-door-open me-1"></i>{% trans %}Espacios & Aulas{% endtrans %}
</a>
<ul class="dropdown-menu shadow-sm">
<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('classrooms.list_classrooms') }}"><i class="bi bi-grid me-2 text-secondary"></i>{% trans %}Todas las Aulas{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('classrooms.add_classroom') }}"><i class="bi bi-plus-circle me-2 text-success"></i>{% trans %}Nueva Aula{% endtrans %}</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}"><i class="bi bi-buildings me-2 text-secondary"></i>{% trans %}Edificios y Sedes{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.occupancy_metrics') }}"><i class="bi bi-graph-up-arrow me-2 text-success"></i>{% trans %}Métricas de Ocupación{% endtrans %}</a></li>
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-calendar3 me-1"></i>{% trans %}Operaciones & Reservas{% endtrans %}
</a>
<ul class="dropdown-menu shadow-sm">
<li><a class="dropdown-item" href="{{ url_for('schedule.today_schedule') }}"><i class="bi bi-calendar-day me-2 text-info"></i>{% trans %}Cartelera del Día{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.calendar_view') }}"><i class="bi bi-calendar-week me-2 text-primary"></i>{% trans %}Calendario General{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}"><i class="bi bi-list-check me-2 text-warning"></i>{% trans %}Gestión de Reservas{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.add_reservation') }}"><i class="bi bi-plus-circle me-2 text-success"></i>{% trans %}Registrar Reserva{% 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>
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-mortarboard me-1"></i>{% trans %}Académica & Cursadas{% endtrans %}
</a>
<ul class="dropdown-menu shadow-sm">
<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('admin.commissions_list') }}"><i class="bi bi-diagram-3 me-2 text-success"></i>{% trans %}Gestión de Comisiones{% 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('admin.milestone_types_list') }}"><i class="bi bi-tags me-2 text-secondary"></i>{% trans %}Tipificaciones de Hitos{% 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 %}Sincronización Sheets{% endtrans %}</a></li>
</ul>
</li>
{% elif user_role == 'docente' %}
<!-- ================= DOCENTE / PROFESOR NAVIGATION ================= -->
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.dashboard') }}">
<i class="bi bi-speedometer2 me-1"></i>{% trans %}Panel Docente{% endtrans %}
</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-calendar-check me-1"></i>{% trans %}Mis Clases & Reservas{% endtrans %}
</a>
<ul class="dropdown-menu shadow-sm">
<li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}"><i class="bi bi-calendar2-check me-2 text-primary"></i>{% trans %}Mis Reservas de Aulas{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.add_reservation') }}"><i class="bi bi-plus-circle me-2 text-success"></i>{% trans %}Solicitar Nueva Reserva{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.commissions_view') }}"><i class="bi bi-diagram-3 me-2 text-info"></i>{% trans %}Mis Comisiones & Cursadas{% endtrans %}</a></li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link fw-semibold" href="{{ url_for('my_subjects.my_subjects_list') }}">
<i class="bi bi-mortarboard me-1 text-info"></i>{% trans %}Mis Materias{% endtrans %}
</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-building me-1"></i>{% trans %}Campus & Horarios{% endtrans %}
</a>
<ul class="dropdown-menu shadow-sm">
<li><a class="dropdown-item" href="{{ url_for('schedule.today_schedule') }}"><i class="bi bi-calendar-day me-2 text-info"></i>{% trans %}Cartelera del Día (¿Dónde curso hoy?){% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.calendar_view') }}"><i class="bi bi-calendar-week me-2 text-primary"></i>{% trans %}Calendario General de Aulas{% endtrans %}</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}"><i class="bi bi-camera-video me-2 text-info"></i>{% trans %}Aulas Virtuales (Reuniones){% 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-secondary"></i>{% trans %}Consulta de Aulas Presenciales{% endtrans %}</a></li>
</ul>
</li>
{% else %}
<!-- ================= ALUMNO / ESTUDIANTE NAVIGATION ================= -->
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.dashboard') }}">
<i class="bi bi-house-door me-1"></i>{% trans %}Mi Portal{% endtrans %}
</a>
</li>
<li class="nav-item">
<a class="nav-link fw-semibold" href="{{ url_for('my_subjects.my_subjects_list') }}">
<i class="bi bi-mortarboard me-1 text-success"></i>{% trans %}Mis Materias{% endtrans %}
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('schedule.today_schedule') }}">
<i class="bi bi-geo-alt me-1"></i>{% trans %}Cartelera de Hoy{% endtrans %}
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('schedule.calendar_view') }}">
<i class="bi bi-calendar-week me-1"></i>{% trans %}Cronograma de Clases{% endtrans %}
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}">
<i class="bi bi-camera-video me-1"></i>{% trans %}Aulas Virtuales{% endtrans %}
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('classrooms.list_classrooms', view_mode='physical') }}">
<i class="bi bi-door-open me-1"></i>{% trans %}Espacios del Campus{% endtrans %}
</a>
</li>
{% endif %}
</ul>
<ul class="navbar-nav align-items-center">
<!-- Theme Mode Toggle Button -->
<li class="nav-item me-2">
<button class="theme-toggle-btn" type="button" aria-label="Cambiar tema" title="Modo Claro/Oscuro">
<i class="bi bi-moon-stars-fill"></i>
</button>
</li>
<!-- Language Switcher -->
<li class="nav-item me-2">
{% include 'partials/language_switcher.html' %}
</li>
<!-- User Dropdown with Avatar and Institutional Role -->
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle d-flex align-items-center py-1" href="#" role="button" data-bs-toggle="dropdown">
<span class="user-avatar-small">{{ current_user.name[:1]|upper if current_user.name else 'U' }}</span>
<div class="d-none d-lg-block text-start ms-2">
<div class="lh-1 small fw-semibold">{{ current_user.first_name }}</div>
<span class="badge {{ current_user.role_badge_display.class }} role-pill mt-1" style="font-size: 0.65rem; padding: 0.15rem 0.45rem;">
<i class="bi {{ current_user.role_badge_display.icon }}"></i> {{ current_user.role_badge_display.label }}
</span>
</div>
</a>
<ul class="dropdown-menu dropdown-menu-end shadow-sm border-0">
<li>
<div class="px-3 py-2 border-bottom">
<small class="text-muted d-block">{% trans %}Signed in as{% endtrans %}</small>
<span class="fw-bold small d-block">{{ current_user.email }}</span>
<span class="badge {{ current_user.role_badge_display.class }} role-pill mt-1">
<i class="bi {{ current_user.role_badge_display.icon }}"></i> {{ current_user.role_badge_display.label }}
</span>
</div>
</li>
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}">
<i class="bi bi-person me-2"></i> {% trans %}Profile{% endtrans %}
</a></li>
<li><a class="dropdown-item" href="{{ url_for('auth.change_password') }}">
<i class="bi bi-key me-2"></i> {% trans %}Change Password{% endtrans %}
</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger" href="{{ url_for('auth.logout') }}">
<i class="bi bi-box-arrow-right me-2"></i> {% trans %}Logout{% endtrans %}
</a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
{% endif %}
<!-- Flash Messages -->
<div class="toast-container position-fixed bottom-0 end-0 p-3" style="z-index: 1080;">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="toast align-items-center text-white bg-{{ 'danger' if category == 'error' else 'success' if category == 'success' else 'info' if category == 'info' else 'warning' }} border-0 shadow" role="alert">
<div class="d-flex">
<div class="toast-body">
{{ message }}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
<!-- Main Content -->
{% block content %}{% endblock %}
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- jQuery -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<!-- Theme Toggle JS -->
<script src="{{ url_for('static', filename='js/theme-toggle.js') }}"></script>
<!-- Custom JS -->
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
<!-- View Switcher JS -->
<script src="{{ url_for('static', filename='js/view_switcher.js') }}"></script>
<!-- Show flash messages -->
<script>
$(document).ready(function() {
$('.toast').toast('show');
});
</script>
{% block extra_js %}{% endblock %}
</body>
</html>
+11 -23
View File
@@ -1,7 +1,6 @@
from flask import Flask, request, session from flask import Flask, request, session
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate from flask_migrate import Migrate
from flask_login import LoginManager
from flask_cors import CORS from flask_cors import CORS
from flask_babel import Babel, gettext as _ from flask_babel import Babel, gettext as _
from config.config import Config from config.config import Config
@@ -9,7 +8,6 @@ from config.config import Config
# Initialize extensions # Initialize extensions
db = SQLAlchemy() db = SQLAlchemy()
migrate = Migrate() migrate = Migrate()
login_manager = LoginManager()
cors = CORS() cors = CORS()
babel = Babel() babel = Babel()
@@ -20,37 +18,27 @@ def create_app(config_class=Config):
# Initialize extensions with app # Initialize extensions with app
db.init_app(app) db.init_app(app)
migrate.init_app(app, db) migrate.init_app(app, db)
login_manager.init_app(app)
cors.init_app(app) cors.init_app(app)
# Login manager configuration
from flask_babel import lazy_gettext
login_manager.login_view = 'auth.login'
login_manager.login_message = lazy_gettext('Por favor inicia sesión para acceder a esta página.')
login_manager.login_message_category = 'info'
# Import models to ensure they are registered # Import models to ensure they are registered
from app.models import user from app.models import user
# Register blueprints # Register REST API (v1) Blueprints with Stateless JWT support
from app.routes import auth_bp, classrooms_bp, buildings_bp, main_bp, schedule_bp, genetic_bp, genetic_web_bp, admin_bp, my_subjects_bp from app.routes.api.auth import api_auth_bp
from app.routes.api import api_auth_bp, api_classrooms_bp, api_reservations_bp, api_optimizer_bp from app.routes.api.classrooms import api_classrooms_bp
from app.routes.api.reservations import api_reservations_bp
from app.routes.api.optimizer import api_optimizer_bp
from app.routes.api.dashboard import api_dashboard_bp
from app.routes.api.admin import api_admin_bp
from app.security import SecurityFilterChain from app.security import SecurityFilterChain
app.register_blueprint(auth_bp, url_prefix="/") # Register API v1 blueprints
app.register_blueprint(classrooms_bp, url_prefix="/classrooms")
app.register_blueprint(buildings_bp)
app.register_blueprint(main_bp, url_prefix="/")
app.register_blueprint(schedule_bp, url_prefix="/schedule")
app.register_blueprint(genetic_bp)
app.register_blueprint(genetic_web_bp)
app.register_blueprint(admin_bp)
app.register_blueprint(my_subjects_bp)
# Register REST API (v1) Blueprints with Stateless JWT support
app.register_blueprint(api_auth_bp) app.register_blueprint(api_auth_bp)
app.register_blueprint(api_classrooms_bp) app.register_blueprint(api_classrooms_bp)
app.register_blueprint(api_reservations_bp) app.register_blueprint(api_reservations_bp)
app.register_blueprint(api_optimizer_bp) app.register_blueprint(api_optimizer_bp)
app.register_blueprint(api_dashboard_bp)
app.register_blueprint(api_admin_bp, url_prefix='/api/v1')
app.register_blueprint(api_admin_bp, url_prefix='/api/v1/admin', name='api_admin_prefixed')
# Initialize Enterprise Security Pipeline (SecurityFilterChain) # Initialize Enterprise Security Pipeline (SecurityFilterChain)
SecurityFilterChain(app) SecurityFilterChain(app)
@@ -93,6 +93,7 @@ class Commission(db.Model):
'active': self.active, 'active': self.active,
'created_at': self.created_at.isoformat() if self.created_at else None, 'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None, 'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'full_code': self.get_full_code(),
'subject': self.subject.to_dict() if self.subject else None 'subject': self.subject.to_dict() if self.subject else None
} }
@@ -1,9 +1,8 @@
from app import db, login_manager from app import db
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime from datetime import datetime
class User(UserMixin, db.Model): class User(db.Model):
__tablename__ = 'users' __tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
@@ -144,8 +143,3 @@ class User(UserMixin, db.Model):
def __repr__(self): def __repr__(self):
return f'<User {self.email}>' return f'<User {self.email}>'
@login_manager.user_loader
def load_user(user_id):
"""Load user from database by ID"""
return User.query.get(int(user_id))

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

+930
View File
@@ -0,0 +1,930 @@
from flask import Blueprint, jsonify, request
from datetime import datetime
from app.utils.jwt_decorators import jwt_required
from app.models.user import User
from app.models.role import Role, Permission, SYSTEM_MODULES
from app.models.subject import Subject, Commission
from app.models.career import Career
from app.models.academic_term import AcademicTerm
from app.models.milestone import MilestoneType, AcademicMilestone
from app.models.audit_log import AuditLog
from app import db
api_admin_bp = Blueprint('api_admin', __name__)
@api_admin_bp.route('/users', methods=['GET'])
@jwt_required
def get_users():
search = request.args.get('search', '').strip().lower()
role_id = request.args.get('role', type=int)
status = request.args.get('status', '').strip().lower()
query = User.query
if search:
query = query.filter((User.name.ilike(f'%{search}%')) | (User.email.ilike(f'%{search}%')))
if role_id:
query = query.filter(User.role_id == role_id)
if status == 'active':
query = query.filter(User.is_active == True)
elif status == 'inactive':
query = query.filter(User.is_active == False)
users = query.order_by(User.id.asc()).all()
roles = Role.query.order_by(Role.name.asc()).all()
users_data = []
for u in users:
users_data.append({
'id': u.id,
'name': u.name,
'first_name': u.name.split(' ')[0] if u.name else 'U',
'email': u.email,
'is_active': u.is_active,
'role': u.role,
'role_obj': {
'id': u.role_obj.id,
'name': u.role_obj.name
} if u.role_obj else {'id': 1, 'name': u.role or 'Admin'},
'last_login': getattr(u, 'last_login', None)
})
roles_data = [{'id': r.id, 'name': r.name} for r in roles]
return jsonify({
'status': 'success',
'total': len(users_data),
'users': users_data,
'roles': roles_data
}), 200
@api_admin_bp.route('/roles', methods=['GET'])
@jwt_required
def get_roles():
roles = Role.query.order_by(Role.id.asc()).all()
modules = [
{'id': 'dashboard', 'name': 'Dashboard', 'icon': 'bi-speedometer2'},
{'id': 'classrooms', 'name': 'Aulas y Espacios', 'icon': 'bi-door-open'},
{'id': 'schedule', 'name': 'Cronograma', 'icon': 'bi-calendar3'},
{'id': 'subjects', 'name': 'Asignaturas', 'icon': 'bi-book'},
{'id': 'careers', 'name': 'Carreras', 'icon': 'bi-mortarboard'},
{'id': 'commissions', 'name': 'Comisiones', 'icon': 'bi-diagram-3'},
{'id': 'users', 'name': 'Usuarios', 'icon': 'bi-people'},
{'id': 'roles', 'name': 'Roles (RBAC)', 'icon': 'bi-shield-check'},
{'id': 'optimizer', 'name': 'Optimizador IA', 'icon': 'bi-cpu'},
{'id': 'audit', 'name': 'Auditoría', 'icon': 'bi-clipboard-data'}
]
roles_data = []
for r in roles:
perms_map = {}
for p in r.permissions:
perms_map[p.module] = p.access_level
roles_data.append({
'id': r.id,
'name': r.name,
'description': r.description or f'Rol institucional de {r.name}',
'is_system': getattr(r, 'is_system', True),
'users_count': len(r.users),
'users': [{'id': u.id, 'name': u.name} for u in r.users],
'permissions': perms_map
})
return jsonify({
'status': 'success',
'roles': roles_data,
'modules': modules
}), 200
@api_admin_bp.route('/subjects', methods=['GET'])
@jwt_required
def get_subjects():
search = request.args.get('search', '').strip().lower()
career_id = request.args.get('career_id', type=int)
active = request.args.get('active', '').strip().lower()
query = Subject.query
if search:
query = query.filter((Subject.name.ilike(f'%{search}%')) | (Subject.code.ilike(f'%{search}%')) | (Subject.department.ilike(f'%{search}%')))
if career_id:
query = query.filter(Subject.career_id == career_id)
if active == 'true':
query = query.filter(Subject.is_active == True)
elif active == 'false':
query = query.filter(Subject.is_active == False)
subjects = query.order_by(Subject.id.asc()).all()
careers = Career.query.order_by(Career.name.asc()).all()
subjects_data = []
for s in subjects:
subjects_data.append({
'id': s.id,
'code': s.code,
'name': s.name,
'career_id': s.career_id,
'career_name': s.career_obj.name if s.career_obj else (s.department or 'General'),
'department': s.department or 'General',
'credits': getattr(s, 'credits', 4),
'is_active': s.is_active,
'commissions_count': len(s.commissions),
'commissions': [{'id': c.id, 'code': c.code, 'schedule': c.schedule} for c in s.commissions]
})
careers_data = [{'id': c.id, 'name': c.name} for c in careers]
return jsonify({
'status': 'success',
'total': len(subjects_data),
'subjects': subjects_data,
'careers': careers_data
}), 200
@api_admin_bp.route('/careers', methods=['GET'])
@jwt_required
def get_careers():
search = request.args.get('search', '').strip().lower()
query = Career.query
if search:
query = query.filter(Career.name.ilike(f'%{search}%'))
careers = query.order_by(Career.name.asc()).all()
careers_data = []
for c in careers:
careers_data.append({
'id': c.id,
'name': c.name,
'code': getattr(c, 'code', f'CAR-{c.id}'),
'degree_level': getattr(c, 'degree_level', 'Grado / Tecnicatura'),
'is_active': getattr(c, 'is_active', True),
'subjects_count': len(c.subjects),
'subjects': [{'id': s.id, 'name': s.name, 'code': s.code} for s in c.subjects[:5]]
})
return jsonify({
'status': 'success',
'total': len(careers_data),
'careers': careers_data
}), 200
@api_admin_bp.route('/commissions', methods=['GET'])
@jwt_required
def get_commissions():
search = request.args.get('search', '').strip().lower()
subject_id = request.args.get('subject_id', type=int)
shift = request.args.get('shift', '').strip()
active = request.args.get('active', '').strip().lower()
query = Commission.query
if subject_id:
query = query.filter(Commission.subject_id == subject_id)
if shift:
query = query.filter(Commission.shift == shift)
if active == 'true':
query = query.filter(Commission.active == True)
elif active == 'false':
query = query.filter(Commission.active == False)
commissions = query.order_by(Commission.id.asc()).all()
subjects = Subject.query.order_by(Subject.name.asc()).all()
commissions_data = []
for c in commissions:
if search:
s_name = c.subject.name.lower() if c.subject else ''
c_code = c.code.lower()
if search not in s_name and search not in c_code:
continue
commissions_data.append({
'id': c.id,
'code': c.code,
'subject_id': c.subject_id,
'subject_name': c.subject.name if c.subject else 'Sin materia',
'subject_code': c.subject.code if c.subject else '-',
'schedule': c.schedule or 'A coordinar',
'shift': c.shift or 'Mañana',
'max_students': c.max_students or 35,
'current_students': getattr(c, 'current_students', 0) or 0,
'active': c.active,
'virtual_link': getattr(c, 'virtual_link', '') or ''
})
subjects_data = [{'id': s.id, 'name': s.name, 'code': s.code} for s in subjects]
return jsonify({
'status': 'success',
'total': len(commissions_data),
'commissions': commissions_data,
'subjects': subjects_data
}), 200
# ---------------------------------------------------------
# USERS CRUD
# ---------------------------------------------------------
@api_admin_bp.route('/users', methods=['POST'])
@jwt_required
def create_user():
data = request.get_json(silent=True) or request.form.to_dict() or {}
email = data.get('email', '').strip().lower()
name = data.get('name', '').strip()
password = data.get('password', '').strip()
role_id = data.get('role_id')
is_active = data.get('is_active', True)
if isinstance(is_active, str):
is_active = is_active.lower() in ['true', '1', 'on']
if not email or not name:
return jsonify({'error': 'ValidationError', 'message': 'El nombre y el email son obligatorios.'}), 400
if User.query.filter(User.email.ilike(email)).first():
return jsonify({'error': 'Conflict', 'message': f'Ya existe un usuario con el email {email}.'}), 409
role_obj = None
if role_id:
role_obj = Role.query.get(int(role_id))
role_name = role_obj.name if role_obj else data.get('role', 'Docente')
user = User(
email=email,
name=name,
role=role_name,
role_id=int(role_id) if role_id else None,
is_active=bool(is_active)
)
user.set_password(password or 'edu-space2026')
db.session.add(user)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Usuario creado exitosamente.',
'user': {'id': user.id, 'name': user.name, 'email': user.email, 'role': user.role, 'is_active': user.is_active}
}), 201
@api_admin_bp.route('/users/<int:id>', methods=['PUT'])
@jwt_required
def update_user(id):
user = User.query.get_or_404(id)
data = request.get_json(silent=True) or request.form.to_dict() or {}
if 'name' in data and data['name']:
user.name = data['name'].strip()
if 'email' in data and data['email']:
email = data['email'].strip().lower()
existing = User.query.filter(User.email.ilike(email), User.id != id).first()
if existing:
return jsonify({'error': 'Conflict', 'message': f'El email {email} ya está registrado por otro usuario.'}), 409
user.email = email
if 'password' in data and data['password']:
user.set_password(data['password'].strip())
if 'role_id' in data and data['role_id']:
role_obj = Role.query.get(int(data['role_id']))
if role_obj:
user.role_id = role_obj.id
user.role = role_obj.name
if 'is_active' in data:
val = data['is_active']
user.is_active = val in [True, 'true', '1', 'on']
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Usuario actualizado correctamente.',
'user': {'id': user.id, 'name': user.name, 'email': user.email, 'role': user.role, 'is_active': user.is_active}
}), 200
@api_admin_bp.route('/users/<int:id>/toggle', methods=['POST'])
@jwt_required
def toggle_user(id):
user = User.query.get_or_404(id)
user.is_active = not user.is_active
db.session.commit()
return jsonify({
'status': 'success',
'id': user.id,
'is_active': user.is_active,
'message': f'Usuario {"activado" if user.is_active else "desactivado"} correctamente.'
}), 200
@api_admin_bp.route('/users/<int:id>', methods=['DELETE'])
@jwt_required
def delete_user(id):
user = User.query.get_or_404(id)
has_reservations = len(user.reservations) if user.reservations else 0
if has_reservations > 0:
user.is_active = False
db.session.commit()
return jsonify({
'status': 'success',
'message': 'El usuario tiene reservas asociadas. Se ha desactivado en su lugar.'
}), 200
db.session.delete(user)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Usuario eliminado permanentemente.'
}), 200
# ---------------------------------------------------------
# ROLES CRUD
# ---------------------------------------------------------
@api_admin_bp.route('/roles', methods=['POST'])
@jwt_required
def create_role():
data = request.get_json(silent=True) or request.form.to_dict() or {}
name = data.get('name', '').strip()
description = data.get('description', '').strip()
permissions = data.get('permissions', {})
if not name:
return jsonify({'error': 'ValidationError', 'message': 'El nombre del rol es obligatorio.'}), 400
if Role.query.filter(Role.name.ilike(name)).first():
return jsonify({'error': 'Conflict', 'message': f'Ya existe un rol llamado {name}.'}), 409
role = Role(name=name, description=description, is_system=False)
db.session.add(role)
db.session.flush()
for mod, level in permissions.items():
role.set_permission(mod, level)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Rol creado exitosamente.',
'role': role.to_dict()
}), 201
@api_admin_bp.route('/roles/<int:id>', methods=['PUT'])
@jwt_required
def update_role(id):
role = Role.query.get_or_404(id)
data = request.get_json(silent=True) or request.form.to_dict() or {}
if 'name' in data and data['name']:
name = data['name'].strip()
existing = Role.query.filter(Role.name.ilike(name), Role.id != id).first()
if existing:
return jsonify({'error': 'Conflict', 'message': f'Ya existe otro rol llamado {name}.'}), 409
role.name = name
if 'description' in data:
role.description = data['description'].strip()
if 'permissions' in data and isinstance(data['permissions'], dict):
for mod, level in data['permissions'].items():
role.set_permission(mod, level)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Rol actualizado correctamente.',
'role': role.to_dict()
}), 200
@api_admin_bp.route('/roles/<int:id>', methods=['DELETE'])
@jwt_required
def delete_role(id):
role = Role.query.get_or_404(id)
if role.is_system:
return jsonify({'error': 'Forbidden', 'message': 'No se pueden eliminar roles de sistema.'}), 403
if role.users and len(role.users) > 0:
return jsonify({'error': 'Conflict', 'message': f'El rol tiene {len(role.users)} usuarios asignados. Reasígnelos primero.'}), 409
db.session.delete(role)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Rol eliminado exitosamente.'
}), 200
# ---------------------------------------------------------
# CAREERS CRUD
# ---------------------------------------------------------
@api_admin_bp.route('/careers', methods=['POST'])
@jwt_required
def create_career():
data = request.get_json(silent=True) or request.form.to_dict() or {}
name = data.get('name', '').strip()
code = data.get('code', '').strip()
description = data.get('description', '').strip()
if not name:
return jsonify({'error': 'ValidationError', 'message': 'El nombre de la carrera es obligatorio.'}), 400
if Career.query.filter(Career.name.ilike(name)).first():
return jsonify({'error': 'Conflict', 'message': f'Ya existe una carrera con el nombre {name}.'}), 409
career = Career(name=name, code=code or None, description=description or None)
db.session.add(career)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Carrera creada exitosamente.',
'career': career.to_dict()
}), 201
@api_admin_bp.route('/careers/<int:id>', methods=['PUT'])
@jwt_required
def update_career(id):
career = Career.query.get_or_404(id)
data = request.get_json(silent=True) or request.form.to_dict() or {}
if 'name' in data and data['name']:
name = data['name'].strip()
existing = Career.query.filter(Career.name.ilike(name), Career.id != id).first()
if existing:
return jsonify({'error': 'Conflict', 'message': f'Ya existe otra carrera con el nombre {name}.'}), 409
career.name = name
if 'code' in data:
career.code = data['code'].strip() or None
if 'description' in data:
career.description = data['description'].strip() or None
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Carrera actualizada correctamente.',
'career': career.to_dict()
}), 200
@api_admin_bp.route('/careers/<int:id>', methods=['DELETE'])
@jwt_required
def delete_career(id):
career = Career.query.get_or_404(id)
if career.subjects and len(career.subjects) > 0:
return jsonify({
'error': 'Conflict',
'message': f'No se puede eliminar la carrera porque tiene {len(career.subjects)} asignaturas vinculadas.'
}), 409
db.session.delete(career)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Carrera eliminada permanentemente.'
}), 200
# ---------------------------------------------------------
# SUBJECTS CRUD
# ---------------------------------------------------------
@api_admin_bp.route('/subjects', methods=['POST'])
@jwt_required
def create_subject():
data = request.get_json(silent=True) or request.form.to_dict() or {}
code = data.get('code', '').strip().upper()
name = data.get('name', '').strip()
department = data.get('department', '').strip()
credits = int(data.get('credits', 4) or 4)
career_id = data.get('career_id')
career_id = int(career_id) if career_id else None
description = data.get('description', '').strip()
is_active = data.get('is_active', True)
if isinstance(is_active, str):
is_active = is_active.lower() in ['true', '1', 'on']
if not code or not name:
return jsonify({'error': 'ValidationError', 'message': 'El código y nombre de la asignatura son obligatorios.'}), 400
if Subject.query.filter_by(code=code).first():
return jsonify({'error': 'Conflict', 'message': f'Ya existe una asignatura con el código {code}.'}), 409
subject = Subject(
code=code,
name=name,
department=department,
credits=credits,
career_id=career_id,
description=description,
is_active=bool(is_active)
)
db.session.add(subject)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Asignatura creada exitosamente.',
'subject': subject.to_dict()
}), 201
@api_admin_bp.route('/subjects/<int:id>', methods=['PUT'])
@jwt_required
def update_subject(id):
subject = Subject.query.get_or_404(id)
data = request.get_json(silent=True) or request.form.to_dict() or {}
if 'code' in data and data['code']:
code = data['code'].strip().upper()
existing = Subject.query.filter(Subject.code == code, Subject.id != id).first()
if existing:
return jsonify({'error': 'Conflict', 'message': f'Ya existe otra asignatura con el código {code}.'}), 409
subject.code = code
if 'name' in data and data['name']:
subject.name = data['name'].strip()
if 'department' in data:
subject.department = data['department'].strip()
if 'credits' in data and data['credits'] is not None:
subject.credits = int(data['credits'])
if 'career_id' in data:
c_id = data['career_id']
subject.career_id = int(c_id) if c_id else None
if 'description' in data:
subject.description = data['description'].strip()
if 'is_active' in data:
val = data['is_active']
subject.is_active = val in [True, 'true', '1', 'on']
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Asignatura actualizada correctamente.',
'subject': subject.to_dict()
}), 200
@api_admin_bp.route('/subjects/<int:id>/toggle', methods=['POST'])
@jwt_required
def toggle_subject(id):
subject = Subject.query.get_or_404(id)
subject.is_active = not subject.is_active
db.session.commit()
return jsonify({
'status': 'success',
'id': subject.id,
'is_active': subject.is_active,
'message': f'Asignatura {"activada" if subject.is_active else "desactivada"} correctamente.'
}), 200
@api_admin_bp.route('/subjects/<int:id>', methods=['DELETE'])
@jwt_required
def delete_subject(id):
subject = Subject.query.get_or_404(id)
if subject.commissions and len(subject.commissions) > 0:
subject.is_active = False
db.session.commit()
return jsonify({
'status': 'success',
'message': 'La asignatura tiene comisiones activas. Se ha desactivado en su lugar.'
}), 200
db.session.delete(subject)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Asignatura eliminada permanentemente.'
}), 200
# ---------------------------------------------------------
# COMMISSIONS CRUD
# ---------------------------------------------------------
@api_admin_bp.route('/commissions', methods=['POST'])
@jwt_required
def create_commission():
data = request.get_json(silent=True) or request.form.to_dict() or {}
subject_id = data.get('subject_id')
code = data.get('code', '').strip()
semester = data.get('semester', '1C').strip()
year = int(data.get('year', datetime.now().year) or datetime.now().year)
teacher_id = data.get('teacher_id')
teacher_id = int(teacher_id) if teacher_id else None
max_students = int(data.get('max_students', 35) or 35)
schedule = data.get('schedule', '').strip()
shift = data.get('shift', 'Mañana').strip()
virtual_link = data.get('virtual_link', '').strip()
active = data.get('active', True)
if isinstance(active, str):
active = active.lower() in ['true', '1', 'on']
if not subject_id or not code:
return jsonify({'error': 'ValidationError', 'message': 'La asignatura y código de comisión son obligatorios.'}), 400
commission = Commission(
subject_id=int(subject_id),
code=code,
semester=semester,
year=year,
teacher_id=teacher_id,
max_students=max_students,
schedule=schedule,
shift=shift,
virtual_link=virtual_link,
active=bool(active)
)
db.session.add(commission)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Comisión creada exitosamente.',
'commission': commission.to_dict()
}), 201
@api_admin_bp.route('/commissions/<int:id>', methods=['PUT'])
@jwt_required
def update_commission(id):
comm = Commission.query.get_or_404(id)
data = request.get_json(silent=True) or request.form.to_dict() or {}
if 'code' in data and data['code']:
comm.code = data['code'].strip()
if 'semester' in data and data['semester']:
comm.semester = data['semester'].strip()
if 'year' in data and data['year']:
comm.year = int(data['year'])
if 'teacher_id' in data:
t_id = data['teacher_id']
comm.teacher_id = int(t_id) if t_id else None
if 'max_students' in data and data['max_students']:
comm.max_students = int(data['max_students'])
if 'schedule' in data:
comm.schedule = data['schedule'].strip()
if 'shift' in data:
comm.shift = data['shift'].strip()
if 'virtual_link' in data:
comm.virtual_link = data['virtual_link'].strip()
if 'active' in data:
val = data['active']
comm.active = val in [True, 'true', '1', 'on']
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Comisión actualizada correctamente.',
'commission': comm.to_dict()
}), 200
@api_admin_bp.route('/commissions/<int:id>/toggle', methods=['POST'])
@jwt_required
def toggle_commission(id):
comm = Commission.query.get_or_404(id)
comm.active = not comm.active
db.session.commit()
return jsonify({
'status': 'success',
'id': comm.id,
'active': comm.active,
'message': f'Comisión {"activada" if comm.active else "desactivada"} correctamente.'
}), 200
@api_admin_bp.route('/commissions/<int:id>/assign-teacher', methods=['POST'])
@jwt_required
def assign_commission_teacher(id):
comm = Commission.query.get_or_404(id)
data = request.get_json(silent=True) or request.form.to_dict() or {}
t_id = data.get('teacher_id')
comm.teacher_id = int(t_id) if t_id else None
db.session.commit()
return jsonify({
'status': 'success',
'id': comm.id,
'teacher_id': comm.teacher_id,
'teacher_name': comm.teacher_name,
'message': 'Docente asignado correctamente.'
}), 200
# ---------------------------------------------------------
# ACADEMIC TERMS CRUD
# ---------------------------------------------------------
@api_admin_bp.route('/academic-terms', methods=['GET'])
@jwt_required
def get_academic_terms():
terms = AcademicTerm.query.order_by(AcademicTerm.year.desc(), AcademicTerm.code.desc()).all()
terms_data = []
for t in terms:
comm_count = Commission.query.filter_by(year=t.year).count()
terms_data.append({
'id': t.id,
'code': t.code,
'name': t.name,
'year': t.year,
'period_type': t.period_type,
'start_date': t.start_date.isoformat() if t.start_date else None,
'end_date': t.end_date.isoformat() if t.end_date else None,
'is_current': t.is_current,
'is_active': t.is_active,
'commissions_count': comm_count
})
return jsonify({
'status': 'success',
'total': len(terms_data),
'terms': terms_data
}), 200
@api_admin_bp.route('/academic-terms', methods=['POST'])
@jwt_required
def create_academic_term():
data = request.get_json(silent=True) or request.form.to_dict() or {}
code = data.get('code', '').strip()
name = data.get('name', '').strip()
year = int(data.get('year', datetime.now().year) or datetime.now().year)
period_type = data.get('period_type', 'Segundo Cuatrimestre').strip()
is_current = data.get('is_current', False)
if isinstance(is_current, str):
is_current = is_current.lower() in ['true', '1', 'on']
if not code or not name:
return jsonify({'error': 'ValidationError', 'message': 'Código y nombre son requeridos.'}), 400
existing = AcademicTerm.query.filter_by(code=code).first()
if existing:
return jsonify({'error': 'Conflict', 'message': f'Ya existe un ciclo con el código {code}.'}), 409
if is_current:
AcademicTerm.query.update({'is_current': False})
term = AcademicTerm(
code=code,
name=name,
year=year,
period_type=period_type,
is_current=bool(is_current),
is_active=True
)
db.session.add(term)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Ciclo lectivo creado exitosamente.',
'term': term.to_dict()
}), 201
@api_admin_bp.route('/academic-terms/<int:id>', methods=['PUT'])
@jwt_required
def update_academic_term(id):
term = AcademicTerm.query.get_or_404(id)
data = request.get_json(silent=True) or request.form.to_dict() or {}
if 'code' in data and data['code']:
code = data['code'].strip()
existing = AcademicTerm.query.filter(AcademicTerm.code == code, AcademicTerm.id != id).first()
if existing:
return jsonify({'error': 'Conflict', 'message': f'Ya existe otro ciclo con el código {code}.'}), 409
term.code = code
if 'name' in data and data['name']:
term.name = data['name'].strip()
if 'year' in data and data['year']:
term.year = int(data['year'])
if 'period_type' in data:
term.period_type = data['period_type'].strip()
if 'is_current' in data:
is_cur = data['is_current'] in [True, 'true', '1', 'on']
if is_cur:
AcademicTerm.query.update({'is_current': False})
term.is_current = is_cur
if 'is_active' in data:
term.is_active = data['is_active'] in [True, 'true', '1', 'on']
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Ciclo lectivo actualizado correctamente.',
'term': term.to_dict()
}), 200
@api_admin_bp.route('/academic-terms/<int:id>/set-current', methods=['POST'])
@jwt_required
def set_current_academic_term(id):
term = AcademicTerm.query.get_or_404(id)
AcademicTerm.query.update({'is_current': False})
term.is_current = True
term.is_active = True
db.session.commit()
return jsonify({
'status': 'success',
'id': term.id,
'message': f'"{term.name}" establecido como ciclo activo.'
}), 200
# ---------------------------------------------------------
# MILESTONE TYPES CRUD
# ---------------------------------------------------------
@api_admin_bp.route('/milestone-types', methods=['GET'])
@jwt_required
def get_milestone_types():
types = MilestoneType.query.order_by(MilestoneType.sort_order.asc(), MilestoneType.id.asc()).all()
return jsonify({
'status': 'success',
'total': len(types),
'milestone_types': [t.to_dict() for t in types]
}), 200
@api_admin_bp.route('/milestone-types', methods=['POST'])
@jwt_required
def create_milestone_type():
data = request.get_json(silent=True) or request.form.to_dict() or {}
name = data.get('name', '').strip()
code = data.get('code', '').strip().lower().replace(' ', '_')
color = data.get('color', '#0d6efd').strip()
description = data.get('description', '').strip()
sort_order = int(data.get('sort_order', 0) or 0)
is_active = data.get('is_active', True)
if isinstance(is_active, str):
is_active = is_active.lower() in ['true', '1', 'on']
if not name or not code:
return jsonify({'error': 'ValidationError', 'message': 'Nombre y código son obligatorios.'}), 400
existing = MilestoneType.query.filter_by(code=code).first()
if existing:
return jsonify({'error': 'Conflict', 'message': f'Ya existe una tipificación con el código {code}.'}), 409
mt = MilestoneType(
name=name,
code=code,
color=color,
description=description,
sort_order=sort_order,
is_active=bool(is_active)
)
db.session.add(mt)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Tipificación de hito creada correctamente.',
'milestone_type': mt.to_dict()
}), 201
@api_admin_bp.route('/milestone-types/<int:id>', methods=['PUT'])
@jwt_required
def update_milestone_type(id):
mt = MilestoneType.query.get_or_404(id)
data = request.get_json(silent=True) or request.form.to_dict() or {}
if 'name' in data and data['name']:
mt.name = data['name'].strip()
if 'code' in data and data['code']:
code = data['code'].strip().lower().replace(' ', '_')
existing = MilestoneType.query.filter(MilestoneType.code == code, MilestoneType.id != id).first()
if existing:
return jsonify({'error': 'Conflict', 'message': f'Ya existe otra tipificación con el código {code}.'}), 409
mt.code = code
if 'color' in data:
mt.color = data['color'].strip()
if 'description' in data:
mt.description = data['description'].strip()
if 'sort_order' in data:
mt.sort_order = int(data['sort_order'])
if 'is_active' in data:
mt.is_active = data['is_active'] in [True, 'true', '1', 'on']
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Tipificación actualizada correctamente.',
'milestone_type': mt.to_dict()
}), 200
@api_admin_bp.route('/milestone-types/<int:id>/toggle', methods=['POST'])
@jwt_required
def toggle_milestone_type(id):
mt = MilestoneType.query.get_or_404(id)
mt.is_active = not mt.is_active
db.session.commit()
return jsonify({
'status': 'success',
'id': mt.id,
'is_active': mt.is_active,
'message': f'Tipificación {"activada" if mt.is_active else "desactivada"} correctamente.'
}), 200
@api_admin_bp.route('/milestone-types/<int:id>', methods=['DELETE'])
@jwt_required
def delete_milestone_type(id):
mt = MilestoneType.query.get_or_404(id)
milestone_count = AcademicMilestone.query.filter_by(milestone_type_id=id).count()
if milestone_count > 0:
mt.is_active = False
db.session.commit()
return jsonify({
'status': 'success',
'message': f'La tipificación tiene {milestone_count} hitos registrados. Se ha desactivado en su lugar.'
}), 200
db.session.delete(mt)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Tipificación eliminada permanentemente.'
}), 200
+183
View File
@@ -0,0 +1,183 @@
from flask import Blueprint, jsonify, request
from app.utils.jwt_decorators import jwt_required
from app.models.classroom import Classroom
from app.models.subject import Subject
from app.models.user import User
from app.models.reservation import Reservation
from app.models.audit_log import AuditLog
from datetime import datetime, timedelta
from sqlalchemy import func
from app import db
from app.models.milestone import AcademicMilestone
from app.models.subject import Commission
api_dashboard_bp = Blueprint('api_dashboard', __name__, url_prefix='/api/v1/dashboard')
def serialize_reservation(r):
subject_name = (r.commission.subject.name if (r.commission and r.commission.subject) else r.purpose) or 'Actividad Académica'
subject_code = (r.commission.subject.code if (r.commission and r.commission.subject) else '')
classroom_code = r.classroom.code if r.classroom else 'Sin Aula'
is_virtual = ('virtual' in classroom_code.lower()) or (getattr(r.classroom, 'is_virtual', False) if r.classroom else False)
return {
'id': r.id,
'start_time': r.start_time.strftime('%H:%M') if r.start_time else '',
'end_time': r.end_time.strftime('%H:%M') if r.end_time else '',
'date_display': r.start_time.strftime('%d/%m') if r.start_time else '',
'classroom': {
'id': r.classroom_id,
'code': classroom_code,
'is_virtual': is_virtual,
'location_display': getattr(r.classroom, 'location_display', f'Sede Central · {classroom_code}') if r.classroom else 'Sin Aula'
},
'subject_name': subject_name,
'subject_code': subject_code,
'shift': r.shift or 'Mañana',
'commission': {
'id': r.commission_id,
'code': r.commission.code if r.commission else 'COM-1'
},
'status': str(r.status.value if hasattr(r.status, 'value') else r.status),
'user': {
'id': r.user_id,
'name': r.user.name if r.user else 'Docente'
},
'virtual_link': r.virtual_link or ('https://meet.google.com/edu-space-' + classroom_code.lower().replace(' ', '-') if is_virtual else '')
}
def serialize_milestone(m):
return {
'id': m.id,
'title': m.title,
'materia_codigo': m.materia_codigo,
'subject_name': m.subject.name if (hasattr(m, 'subject') and m.subject) else m.materia_codigo,
'date': m.date.strftime('%d/%m/%Y') if m.date else '',
'type_obj': {
'name': m.type_obj.name if m.type_obj else 'Evaluación',
'color': m.type_obj.color if m.type_obj else '#0d6efd'
},
'classroom': m.classroom or 'Sede Central',
'link': m.link or '',
'notes': m.notes or ''
}
@api_dashboard_bp.route('/stats', methods=['GET'])
@jwt_required
def get_stats():
"""Retorna estadísticas clave y datos operativos para el Dashboard."""
today = datetime.utcnow().date()
try:
total_classrooms = Classroom.query.filter_by(is_active=True).count()
total_subjects = Subject.query.filter_by(is_active=True).count()
total_users = User.query.filter_by(is_active=True).count()
# Aulas presenciales y virtuales
virtual_classrooms = Classroom.query.filter_by(is_active=True).filter(
(Classroom.room_number.ilike('%VIRTUAL%')) | (Classroom.building.ilike('%VIRTUAL%'))
).count()
physical_classrooms = max(1, total_classrooms - virtual_classrooms)
today_reservations_q = Reservation.query.filter(
func.date(Reservation.start_time) == today
).order_by(Reservation.start_time.asc()).all()
today_reservations = len(today_reservations_q)
pending_reservations_q = Reservation.query.filter(
Reservation.status == 'PENDING'
).order_by(Reservation.start_time.asc()).all()
pending_count = len(pending_reservations_q)
upcoming_reservations_q = Reservation.query.filter(
Reservation.start_time >= datetime.utcnow()
).order_by(Reservation.start_time.asc()).limit(10).all()
milestones_q = AcademicMilestone.query.filter(
AcademicMilestone.is_active == True
).order_by(AcademicMilestone.date.asc()).limit(10).all()
recent_audits = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(6).all()
audit_data = [{
'created_at': a.created_at.strftime('%Y-%m-%d %H:%M') if a.created_at else None,
'action_type': a.action_type,
'ip_address': a.ip_address,
'user_email': getattr(a.user, 'email', 'System') if hasattr(a, 'user') else 'System'
} for a in recent_audits]
occupancy_rate = min(100, int((today_reservations / max(1, physical_classrooms * 3)) * 100)) if physical_classrooms > 0 else 0
pending_reservations = [serialize_reservation(r) for r in pending_reservations_q]
today_schedule_list = [serialize_reservation(r) for r in today_reservations_q]
upcoming_reservations = [serialize_reservation(r) for r in upcoming_reservations_q]
milestones = [serialize_milestone(m) for m in milestones_q]
except Exception as e:
print(f"Error fetching stats: {e}")
total_classrooms = 0
total_subjects = 0
total_users = 0
today_reservations = 0
pending_count = 0
physical_classrooms = 0
virtual_classrooms = 0
occupancy_rate = 0
audit_data = []
pending_reservations = []
today_schedule_list = []
upcoming_reservations = []
milestones = []
# Mocked monthly data for chart since complex SQL group by month might differ based on dialect (SQLite vs Postgres)
monthly_data = [
{"month": "Ene", "reservations": 120},
{"month": "Feb", "reservations": 180},
{"month": "Mar", "reservations": 250},
{"month": "Abr", "reservations": 220},
{"month": "May", "reservations": 300},
{"month": "Jun", "reservations": 310},
{"month": "Jul", "reservations": 200},
{"month": "Ago", "reservations": 350},
{"month": "Sep", "reservations": 420},
{"month": "Oct", "reservations": 480},
{"month": "Nov", "reservations": 520},
{"month": "Dic", "reservations": 210}
]
return jsonify({
'status': 'success',
'stats': {
'total_classrooms': total_classrooms,
'active_subjects': total_subjects,
'total_active_users': total_users,
'today_reservations': today_reservations,
'pending_count': pending_count,
'physical_classrooms': physical_classrooms,
'virtual_classrooms': virtual_classrooms,
'occupancy_rate': occupancy_rate
},
'audit_logs': audit_data,
'monthly_data': monthly_data,
'pending_reservations': pending_reservations,
'today_schedule_list': today_schedule_list,
'upcoming_reservations': upcoming_reservations,
'milestones': milestones
}), 200
@api_dashboard_bp.route('/sync-sheets', methods=['POST'])
@jwt_required
def sync_google_sheets():
"""Ejecuta la sincronización real con las hojas de cálculo de Google Sheets."""
from app.services.sheets_importer import GoogleSheetsImporter
try:
importer = GoogleSheetsImporter()
stats = importer.sync()
return jsonify({
'status': 'success',
'stats': stats
}), 200
except Exception as e:
return jsonify({
'status': 'error',
'message': str(e)
}), 500
@@ -28,22 +28,69 @@ def list_reservations():
except ValueError: except ValueError:
return jsonify({'error': 'BadRequest', 'message': 'Formato inválido de end_date (debe ser YYYY-MM-DD).'}), 400 return jsonify({'error': 'BadRequest', 'message': 'Formato inválido de end_date (debe ser YYYY-MM-DD).'}), 400
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
floor = request.args.get('floor')
shift = request.args.get('shift')
search = request.args.get('search')
reservations = reservation_service.list_reservations(start_dt, end_dt, classroom_id=classroom_id) reservations = reservation_service.list_reservations(start_dt, end_dt, classroom_id=classroom_id)
result = [] result = []
for r in reservations: for r in reservations:
is_virt = bool(r.is_virtual or (r.classroom and r.classroom.is_virtual))
if hide_virtual and is_virt:
continue
if floor and r.classroom and str(r.classroom.floor).strip().lower() != str(floor).strip().lower():
continue
if shift and r.shift and shift.lower() not in r.shift.lower():
continue
if search:
s_lower = search.lower()
subj_n = (r.subject_name or '').lower()
subj_c = (r.subject_code or '').lower()
c_name = (r.classroom.code if r.classroom else '').lower()
u_name = (r.user.name if r.user else '').lower()
if s_lower not in subj_n and s_lower not in subj_c and s_lower not in c_name and s_lower not in u_name:
continue
result.append({ result.append({
'id': r.id, 'id': r.id,
'classroom_id': r.classroom_id, 'classroom_id': r.classroom_id,
'classroom_name': r.classroom.code if r.classroom else None, 'classroom_name': r.classroom.code if r.classroom else None,
'classroom': {
'id': r.classroom.id,
'code': r.classroom.code,
'code_display': getattr(r.classroom, 'code_display', r.classroom.code),
'floor': r.classroom.floor,
'floor_display': getattr(r.classroom, 'floor_display', f"Piso {r.classroom.floor}"),
'building': getattr(r.classroom, 'building_name', r.classroom.building),
'is_virtual': getattr(r.classroom, 'is_virtual', False),
'location_display': getattr(r.classroom, 'location_display', r.classroom.code)
} if r.classroom else None,
'commission_id': r.commission_id, 'commission_id': r.commission_id,
'commission': {
'id': r.commission.id,
'code': r.commission.code,
'virtual_link': r.commission.virtual_link,
'max_students': r.commission.max_students
} if r.commission else None,
'teacher_name': r.user.name if r.user else None, 'teacher_name': r.user.name if r.user else None,
'user': {
'id': r.user.id,
'name': r.user.name,
'email': r.user.email
} if r.user else None,
'start_time': r.start_time.isoformat(), 'start_time': r.start_time.isoformat(),
'end_time': r.end_time.isoformat(), 'end_time': r.end_time.isoformat(),
'purpose': r.purpose, 'purpose': r.purpose,
'status': str(r.status), 'subject_name': r.subject_name,
'shift': r.shift, 'subject_code': r.subject_code,
'status': str(r.status.value if hasattr(r.status, 'value') else r.status),
'shift': r.shift or 'Mañana',
'virtual_link': r.virtual_link, 'virtual_link': r.virtual_link,
'expected_attendees': r.expected_attendees 'effective_virtual_link': r.effective_virtual_link,
'is_virtual': is_virt,
'expected_attendees': r.expected_attendees or 30,
'notes': r.notes or ''
}) })
return jsonify({ return jsonify({
@@ -11,7 +11,7 @@ _token_blacklist: Dict[str, datetime] = {}
class JWTService: class JWTService:
"""Servicio de emisión, verificación y revocación de tokens JWT (Dual Token Pattern).""" """Servicio de emisión, verificación y revocación de tokens JWT (Dual Token Pattern)."""
ACCESS_TOKEN_EXPIRES = timedelta(minutes=15) ACCESS_TOKEN_EXPIRES = timedelta(days=1)
REFRESH_TOKEN_EXPIRES = timedelta(days=7) REFRESH_TOKEN_EXPIRES = timedelta(days=7)
ALGORITHM = "HS256" ALGORITHM = "HS256"
@@ -366,11 +366,31 @@ class GoogleSheetsImporter:
# 5. Generate Calendar Reservations # 5. Generate Calendar Reservations
t_start, t_end = self.parse_time_range(horario_raw) t_start, t_end = self.parse_time_range(horario_raw)
if classroom and t_start and t_end and day_idx is not None: if classroom and t_start and t_end and day_idx is not None:
is_room_virtual = bool(classroom.is_virtual or 'VIRTUAL' in (classroom.code or '').upper())
# Generar enlace de videollamada para clases virtuales si no tienen uno asignado
v_link = None
if is_room_virtual:
clean_subj = re.sub(r'[^a-zA-Z0-9]', '', subject.code or 'cls').lower()
clean_comm = re.sub(r'[^a-zA-Z0-9]', '', commission.code or 'c1').lower()
v_link = f"https://meet.google.com/edu-{clean_subj}-{clean_comm}"
if not commission.virtual_link:
commission.virtual_link = v_link
for w in range(weeks_to_generate): for w in range(weeks_to_generate):
class_date = base_monday + timedelta(days=day_idx, weeks=w) class_date = base_monday + timedelta(days=day_idx, weeks=w)
dt_start = datetime.combine(class_date, t_start) dt_start = datetime.combine(class_date, t_start)
dt_end = datetime.combine(class_date, t_end) dt_end = datetime.combine(class_date, t_end)
# En aulas virtuales la capacidad es infinita y pueden cursar múltiples comisiones en paralelo
if is_room_virtual:
existing_res = Reservation.query.filter_by(
classroom_id=classroom.id,
commission_id=commission.id,
start_time=dt_start,
end_time=dt_end
).first()
else:
existing_res = Reservation.query.filter_by( existing_res = Reservation.query.filter_by(
classroom_id=classroom.id, classroom_id=classroom.id,
start_time=dt_start, start_time=dt_start,
@@ -386,8 +406,9 @@ class GoogleSheetsImporter:
end_time=dt_end, end_time=dt_end,
purpose=f"{subject.code} - {subject.name}", purpose=f"{subject.code} - {subject.name}",
status='CONFIRMED', status='CONFIRMED',
expected_attendees=min(classroom.capacity, 30), expected_attendees=min(classroom.capacity, 30) if not is_room_virtual else 35,
shift=turno, shift=turno,
virtual_link=v_link,
notes=f"Clase semanal {dia} ({turno}) - Carrera: {career.name}" notes=f"Clase semanal {dia} ({turno}) - Carrera: {career.name}"
) )
db.session.add(res) db.session.add(res)
@@ -395,6 +416,8 @@ class GoogleSheetsImporter:
else: else:
existing_res.shift = turno existing_res.shift = turno
existing_res.notes = f"Clase semanal {dia} ({turno}) - Carrera: {career.name}" existing_res.notes = f"Clase semanal {dia} ({turno}) - Carrera: {career.name}"
if is_room_virtual and not existing_res.virtual_link:
existing_res.virtual_link = v_link
if len(stats['sample_records']) < 10: if len(stats['sample_records']) < 10:
stats['sample_records'].append({ stats['sample_records'].append({
View File
@@ -7,7 +7,7 @@ load_dotenv(os.path.join(basedir, '.env'))
class Config: class Config:
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production' SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
_db_url = os.environ.get('DATABASE_URL') or 'postgresql://postgres:password@localhost:5432/classrooms_db' _db_url = os.environ.get('DATABASE_URL') or 'sqlite:///app.db'
if _db_url.startswith('postgres://'): if _db_url.startswith('postgres://'):
_db_url = _db_url.replace('postgres://', 'postgresql://', 1) _db_url = _db_url.replace('postgres://', 'postgresql://', 1)
SQLALCHEMY_DATABASE_URI = _db_url SQLALCHEMY_DATABASE_URI = _db_url
+72 -2
View File
@@ -205,8 +205,6 @@ def init_database():
).first() ).first()
bedelia_canonical = Role.query.filter_by(name='Bedelia').first() bedelia_canonical = Role.query.filter_by(name='Bedelia').first()
if bedelia_accented and bedelia_canonical and bedelia_accented.id != bedelia_canonical.id: if bedelia_accented and bedelia_canonical and bedelia_accented.id != bedelia_canonical.id:
# Migrate all users from accented role to canonical
from app.models.user import User
affected = User.query.filter_by(role_id=bedelia_accented.id).all() affected = User.query.filter_by(role_id=bedelia_accented.id).all()
for u in affected: for u in affected:
u.role_id = bedelia_canonical.id u.role_id = bedelia_canonical.id
@@ -336,6 +334,78 @@ def init_database():
db.session.commit() db.session.commit()
print(f"[OK] Catalogo de tipificaciones inicializado ({MilestoneType.query.count()} tipos disponibles).") print(f"[OK] Catalogo de tipificaciones inicializado ({MilestoneType.query.count()} tipos disponibles).")
# 7. Inicializar aulas, materias, comisiones y reservas de muestra
from app.models.classroom import Classroom
from app.models.subject import Subject, Commission
from app.models.reservation import Reservation, ReservationStatus
from datetime import datetime, timedelta, date
if Classroom.query.count() == 0:
print("[*] Generando catalogo de aulas...")
for floor in ['PB', '1', '2', '3']:
for num in range(1, 8):
code = f'{floor}0{num}' if floor != 'PB' else f'PB-0{num}'
c = Classroom(
code=code,
building=b_central.name,
building_id=b_central.id,
floor=floor,
capacity=30 + (num * 5),
description=f'Aula física estándar {code} en {b_central.name}',
is_active=True
)
db.session.add(c)
for v in range(1, 4):
vc = Classroom(
code=f'VIRTUAL-0{v}',
building=b_virtual.name,
building_id=b_virtual.id,
floor='0',
capacity=100,
description=f'Sala virtual Microsoft Teams {v}',
is_active=True
)
db.session.add(vc)
db.session.commit()
print(f"[OK] {Classroom.query.count()} aulas creadas.")
# Materia y Comisión
s = Subject.query.filter_by(code='ARQ-101').first()
if not s:
s = Subject(name='Arquitectura de Software', code='ARQ-101', is_active=True)
db.session.add(s)
db.session.commit()
comm = Commission.query.filter_by(code='COM-A').first()
if not comm:
comm = Commission(code='COM-A', subject_id=s.id, shift='Noche', semester=2, year=2026, max_students=40, is_active=True)
db.session.add(comm)
db.session.commit()
# Reservas de Hoy
if Reservation.query.count() == 0:
print("[*] Generando reservas de muestra...")
today = date.today()
classrooms = Classroom.query.all()
docente = User.query.filter_by(email='docente@edu-space.com').first()
for idx, cl in enumerate(classrooms[:19]):
start_hour = 8 + (idx % 6) * 2
r = Reservation(
classroom_id=cl.id,
commission_id=comm.id,
user_id=docente.id if docente else 1,
start_time=datetime.combine(today, datetime.min.time()) + timedelta(hours=start_hour),
end_time=datetime.combine(today, datetime.min.time()) + timedelta(hours=start_hour + 2),
purpose='Clase Regular',
status=ReservationStatus.CONFIRMED.value,
shift='Noche' if start_hour >= 18 else 'Tarde' if start_hour >= 13 else 'Mañana',
expected_attendees=cl.capacity - 5
)
db.session.add(r)
db.session.commit()
print(f"[OK] {Reservation.query.count()} reservas creadas.")
print("\n[LISTO] Base de datos inicializada y lista para usar.") print("\n[LISTO] Base de datos inicializada y lista para usar.")
except Exception as e: except Exception as e:
View File
View File
+48
View File
@@ -0,0 +1,48 @@
import requests
from app import create_app
from app.models.user import User
from app.services.jwt_service import JWTService
BASE_URL = "http://localhost:3000"
app = create_app()
with app.app_context():
admin = User.query.filter_by(email='admin@edu-space.com').first()
token = JWTService.create_access_token(admin)
cookies = {'auth_token': token}
session = requests.Session()
session.cookies.update(cookies)
routes_to_test = [
("admin.google_sheets_import", "/admin/google_sheets_import", ["Google Sheets", "Official Schedule Board"]),
("classrooms.add_classroom", "/classrooms/add_classroom", ["Add Classroom", "Classroom Information"]),
("admin.occupancy_metrics", "/admin/occupancy_metrics", ["Ocupación", "Métricas"]),
("buildings.list_buildings", "/buildings/list_buildings", ["Edificios", "Edificio Central"]),
("schedule.add_reservation", "/schedule/add_reservation", ["New Reservation", "Detalles de la Reserva"])
]
all_passed = True
print("\n========================================================")
print("Testing coupling of 5 newly added BFF functionalities:")
print("========================================================")
for name, path, expected_texts in routes_to_test:
r = session.get(f"{BASE_URL}{path}", allow_redirects=True)
is_200 = r.status_code == 200
texts_found = [t for t in expected_texts if t.lower() in r.text.lower()]
all_texts_ok = len(texts_found) == len(expected_texts)
has_fallback = "En Construcción" in r.text
status = "PASSED" if is_200 and all_texts_ok and not has_fallback else "FAILED"
print(f"[{status}] {name} ({path}) -> Status: {r.status_code}, Texts match: {texts_found}/{expected_texts}, Has Fallback: {has_fallback}")
if status == "FAILED":
all_passed = False
print(f"Response snippet:\n{r.text[:500]}\n---")
print("========================================================")
if all_passed:
print("SUCCESS: ALL 5 FUNCTIONALITIES ARE COUPLED TO THE NODE.JS BFF!")
else:
print("FAILURE: SOME FUNCTIONALITIES FAILED!")
print("========================================================\n")
+77
View File
@@ -0,0 +1,77 @@
import requests
from app import create_app
from app.models.user import User
from app.services.jwt_service import JWTService
app = create_app()
with app.app_context():
admin = User.query.filter_by(email='admin@edu-space.com').first()
token = JWTService.create_access_token(admin)
cookies = {'auth_token': token}
session = requests.Session()
session.cookies.update(cookies)
tests = [
# Dashboard y redirecciones
('GET /main/dashboard (redirect to /dashboard)', 'http://localhost:3000/main/dashboard', 200, ['Edu-Space Admin', 'Aulas Totales']),
('GET /dashboard', 'http://localhost:3000/dashboard', 200, ['Edu-Space Admin', 'Aulas Totales', 'Reservas de Hoy']),
# Classrooms
('GET /classrooms/list_classrooms', 'http://localhost:3000/classrooms/list_classrooms', 200, ['Aulas y Espacios', 'PB-01', 'Edificio Central']),
('GET /classrooms/list', 'http://localhost:3000/classrooms/list', 200, ['Aulas y Espacios']),
('GET /classrooms/add_classroom (Coupled)', 'http://localhost:3000/classrooms/add_classroom', 200, ['Add Classroom', 'Classroom Information']),
('GET /classrooms/add', 'http://localhost:3000/classrooms/add', 200, ['Add Classroom']),
# Schedule
('GET /schedule/calendar_view', 'http://localhost:3000/schedule/calendar_view', 200, ['Reservations Calendar', 'calendar']),
('GET /schedule/calendar_data (JSON Feed)', 'http://localhost:3000/schedule/calendar_data', 200, None),
('GET /schedule/today', 'http://localhost:3000/schedule/today', 200, ['Reservas']),
('GET /schedule/list_reservations', 'http://localhost:3000/schedule/list_reservations', 200, ['Listado de Reservas']),
('GET /schedule/add_reservation (Coupled)', 'http://localhost:3000/schedule/add_reservation', 200, ['New Reservation', 'Detalles de la Reserva']),
('GET /schedule/add', 'http://localhost:3000/schedule/add', 200, ['New Reservation']),
# Buildings
('GET /buildings/list_buildings (Coupled)', 'http://localhost:3000/buildings/list_buildings', 200, ['Gestión de Edificios', 'Edificio Central']),
('GET /buildings', 'http://localhost:3000/buildings', 200, ['Gestión de Edificios']),
('GET /buildings/add_building', 'http://localhost:3000/buildings/add_building', 200, ['Nuevo Edificio']),
# Admin Features
('GET /admin/google_sheets_import (Coupled)', 'http://localhost:3000/admin/google_sheets_import', 200, ['Google Sheets', 'Official Schedule Board']),
('GET /admin/import', 'http://localhost:3000/admin/import', 200, ['Google Sheets']),
('GET /admin/occupancy_metrics (Coupled)', 'http://localhost:3000/admin/occupancy_metrics', 200, ['Ocupación', 'Métricas']),
('GET /admin/occupancy-metrics', 'http://localhost:3000/admin/occupancy-metrics', 200, ['Ocupación'])
]
all_passed = True
print('=== EJECUTANDO SUITE DE PRUEBAS DE INTEGRACION BFF NODE.JS ===')
for name, url, expected_status, expected_texts in tests:
r = session.get(url, allow_redirects=True)
status_ok = r.status_code == expected_status
text_ok = True
if 'En Construcción' in r.text:
text_ok = False
print(f' [FAIL] Fallback "En Construcción" encountered in {url}')
if expected_texts:
for t in expected_texts:
if t.lower() not in r.text.lower():
text_ok = False
print(f' [FAIL] Expected text "{t}" not found in {url}')
if expected_texts is None: # JSON check
data = r.json()
text_ok = isinstance(data, list) and len(data) > 0
if not text_ok:
print(f' [FAIL] Expected non-empty JSON list in {url}')
if status_ok and text_ok:
print(f'[PASS] {name} -> Status: {r.status_code} OK')
else:
all_passed = False
print(f'[FAIL] {name} -> Status: {r.status_code} FAIL')
print('===============================================================')
if all_passed:
print('TODAS LAS PRUEBAS DE INTEGRACION PASARON SATISFACTORIAMENTE (100% OK)')
else:
print('SE ENCONTRARON FALLOS EN LA SUITE DE PRUEBAS')
print('===============================================================')
+26
View File
@@ -0,0 +1,26 @@
import pytest
from app import create_app, db
@pytest.fixture
def app():
app = create_app()
# Configure testing DB
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
app.config['TESTING'] = True
return app
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def app_context(app):
with app.app_context():
yield
@pytest.fixture
def init_database(app, app_context):
db.create_all()
yield db
db.session.remove()
db.drop_all()
+30
View File
@@ -0,0 +1,30 @@
import pytest
from app.services.jwt_service import JWTService
from app.models.user import User
def test_get_dashboard_stats(client, app_context, init_database):
"""Prueba que el endpoint del dashboard devuelva las métricas correctas en formato JSON"""
# Create a test admin user if not exists
admin = User.query.filter_by(email="admin@edu-space.com").first()
if not admin:
admin = User(email="admin@edu-space.com", name="Admin Test", role_id=1, role="admin", is_active=True)
admin.set_password("admin123")
from app import db
db.session.add(admin)
db.session.commit()
access_token = JWTService.create_access_token(admin)
headers = {
'Authorization': f'Bearer {access_token}'
}
response = client.get('/api/v1/dashboard/stats', headers=headers)
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'success'
assert 'stats' in data
assert 'total_classrooms' in data['stats']
assert 'monthly_data' in data
assert 'audit_logs' in data
@@ -11,6 +11,7 @@ class TestJWTAndAPI(unittest.TestCase):
self.client = self.app.test_client() self.client = self.app.test_client()
self.app_context = self.app.app_context() self.app_context = self.app.app_context()
self.app_context.push() self.app_context.push()
db.create_all()
# Buscar usuario activo o crear de prueba # Buscar usuario activo o crear de prueba
self.test_user = User.query.filter_by(is_active=True).first() self.test_user = User.query.filter_by(is_active=True).first()
View File

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