feat: implementacion de dashboards por rol, soporte de clases virtuales concurrentes en importador de sheets y cartelera del dia
@@ -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/
|
||||||
@@ -1,453 +1,103 @@
|
|||||||
# 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.
|
||||||
|
|
||||||
## 🏛️ Sede Institucional y Ubicaciones
|
---
|
||||||
* **Sede Central UniCABA:** Tte. Gral. Juan Domingo Perón 802, Ciudad Autónoma de Buenos Aires (CABA) — Aulas de grado, auditorio principal y laboratorios distribuidos desde Planta Baja (Piso 0) hasta el 4.° Piso.
|
|
||||||
* **Campus Virtual:** Espacio áulico digital integrado (Microsoft Teams / Moodle) configurado con **capacidad ilimitada ($\infty$)**.
|
## 🏛️ Sede Institucional y Ubicaciones
|
||||||
|
* **Sede Central UniCABA:** Tte. Gral. Juan Domingo Perón 802, Ciudad Autónoma de Buenos Aires (CABA) — Aulas de grado, auditorio principal y laboratorios distribuidos desde Planta Baja (Piso 0) hasta el 4.° Piso.
|
||||||
---
|
* **Campus Virtual:** Espacio áulico digital integrado (Microsoft Teams / Moodle) configurado con **capacidad ilimitada ($\infty$)**.
|
||||||
|
|
||||||
## ✨ Características Principales
|
---
|
||||||
|
|
||||||
### 🛡️ 1. Control de Accesos RBAC Granular (Matriz estilo FortiGate)
|
## 🏗️ Arquitectura del Proyecto (Monorepo)
|
||||||
* **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.
|
El repositorio está dividido en dos aplicaciones principales que interactúan mediante una API REST y un patrón Backend-For-Frontend (BFF).
|
||||||
2. **Edificios y Sedes:** Administración de sedes, pisos y arquitectura edilicia.
|
|
||||||
3. **Reservas de Aulas:** Solicitud, aprobación, confirmación y cancelación.
|
```text
|
||||||
4. **Cartelera y Cronograma:** Calendario interactivo, visualización diaria y comisiones.
|
/
|
||||||
5. **Gestión Académica:** Carreras, asignaturas, códigos y comisiones.
|
├── backend/ # API REST Backend (Python / Flask)
|
||||||
6. **Sincronización Sheets:** Importador y sincronizador de Google Sheets.
|
│ ├── app/ # Lógica de negocio, base de datos y endpoints
|
||||||
7. **Optimizador IA:** Algoritmo genético de distribución y resolución de conflictos.
|
│ ├── tests/ # Pruebas unitarias e integración
|
||||||
8. **Usuarios y Accesos RBAC:** Cuentas, roles y matriz de permisos.
|
│ └── requirements.txt # Dependencias de Python
|
||||||
* **Niveles de Permiso Granulares:** `Ninguno`, `Solo Lectura` o `Lectura y Escritura`.
|
│
|
||||||
* **Controles Rápidos:** Botón *"Establecer Todos"* para configuración masiva inmediata.
|
├── frontend/ # Backend-For-Frontend (Node.js / Express)
|
||||||
* **Roles Predefinidos:** `Admin` (control total), `Docente`, `Operador` y `Consulta`.
|
│ ├── src/ # Controladores, middleware, servicios (Axios)
|
||||||
|
│ ├── views/ # Plantillas Nunjucks (.html)
|
||||||
### 📊 2. Sincronización e Importación desde Google Sheets
|
│ ├── public/ # Archivos estáticos (CSS, JS cliente)
|
||||||
* Integración directa con la cartelera académica de UniCABA en Google Sheets.
|
│ └── package.json # Dependencias de Node.js
|
||||||
* Extracción e importación normalizada de los 5 campos clave:
|
│
|
||||||
* **Código Académico:** Códigos reales de asignatura (ej. `INGA0003`, `ASIG00165`).
|
├── docs/ # Historial de cambios y documentación técnica
|
||||||
* **Asignatura:** Nombre oficial de la materia.
|
└── unicaba_prerequitos/ # Requisitos académicos y de negocio
|
||||||
* **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`).
|
### 🐍 Backend (Python + Flask)
|
||||||
|
- Funciona exclusivamente como una **REST API** que expone endpoints bajo `/api/v1/` retornando **JSON**.
|
||||||
### 📅 3. Calendario Interactivo de Reservas (FullCalendar)
|
- 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).
|
||||||
* **Visualización Semanal, Mensual y Diaria:** Grilla ágil de alta legibilidad con jerarquía visual:
|
- **Prohibido** retornar HTML o utilizar Jinja2/`render_template` en este entorno.
|
||||||
* Asignatura destacada en tipografía grande y negrita.
|
|
||||||
* Badges con código de materia, aula asignada, turno y franja horaria.
|
### 🟢 Frontend BFF (Node.js + Express)
|
||||||
* **Filtros Dinámicos Combinables:**
|
- Aplicación de servicio que renderiza las vistas para el cliente mediante **Nunjucks**.
|
||||||
* Filtrar por **Aula**.
|
- Gestiona la sesión segura del usuario almacenando el token JWT recibido de Flask en una **Cookie HttpOnly**.
|
||||||
* Filtrar por **Materia / Asignatura**.
|
- Actúa como proxy de conexión con el Backend de Python empleando **Axios** (inyectando el Bearer token interceptado).
|
||||||
* Filtrar por **Carrera**.
|
- Utiliza **Chart.js** y JS vanilla en el lado del cliente para visualizaciones interactivas.
|
||||||
* 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.
|
## 🎯 Dashboards Personalizados por Rol
|
||||||
|
|
||||||
### 📋 4. Cartelera del Día y Listado de Reservas
|
La plataforma cuenta con paneles adaptados a las necesidades operativas de cada perfil institucional:
|
||||||
* **Cartelera del Día (`/schedule/today`):**
|
|
||||||
* **Vista Cronológica (Timeline):** Bloques horarios de 07:00 a 22:00 hs con indicador de ocupación.
|
* **Administrador (`/dashboard?role=admin`):** Cockpit institucional con métricas globales, auditoría de seguridad RBAC, tendencias mensuales y catálogo de gestión.
|
||||||
* **Vista en Tabla:** Lista ordenada con acciones rápidas y detalles de comisió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.
|
||||||
* Filtros directos por piso, turno y switch para ocultar virtuales.
|
* **Docente / Profesor (`/dashboard?role=docente`):** Sesiones del día con accesos directos a videollamadas, gestión de comisiones y formulario para solicitar aulas.
|
||||||
* **Listado Paginado de Reservas (`/schedule/list`):**
|
* **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.
|
||||||
* Paginación de 20 registros por página preservando filtros aplicados.
|
|
||||||
* Búsqueda por texto, estado (`Confirmada`, `Pendiente`, `Cancelada`), rango de fechas y piso.
|
> **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.
|
||||||
|
|
||||||
### 🏫 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.
|
## ☁️ Importador de Google Sheets & Campus Virtual
|
||||||
* Badges distintivos `∞ Ilimitada` en listados, tarjetas y fichas técnicas.
|
|
||||||
|
El sistema incluye sincronización automatizada con las planillas oficiales de cursada de UniCABA:
|
||||||
### 🤖 6. Optimizador Inteligente de Aulas (Algoritmo Genético)
|
* **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.
|
||||||
* Motor de optimización heurística para asignación automática de aulas a comisiones.
|
* **Enlaces de Videollamada Automáticos:** Generación de enlaces seguros (Google Meet / Teams) para acceso instantáneo de docentes y estudiantes.
|
||||||
* Pondera capacidad requerida vs. capacidad de aula, equipamiento tecnológico y minimización de desplazamientos entre pisos.
|
* **Filtros en Cartelera:** Opciones para filtrar por turno, piso u ocultar aulas virtuales mediante el switch dedicado en `/schedule/today_schedule`.
|
||||||
|
|
||||||
### 🎨 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`).
|
## 🚀 Instalación y Despliegue Local
|
||||||
* **Tarjetas Estadísticas Interactivas:** Accesos directos rápidos desde el panel general hacia las distintas secciones operativas.
|
|
||||||
|
### Requisitos previos
|
||||||
### 🏛️ 8. Sistema Integral de Gestión Institucional (8 Módulos)
|
- Node.js (v18 o superior)
|
||||||
El menú desplegable **Gestión** en la barra de navegación organiza las áreas operativas de la universidad:
|
- Python 3.10+
|
||||||
* **Espacios e Infraestructura:**
|
- PostgreSQL / SQLite
|
||||||
* **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).
|
### 1. Iniciar el Backend (Flask)
|
||||||
* **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).
|
```bash
|
||||||
* **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.
|
cd backend
|
||||||
* **Gestión Académica:**
|
python -m venv venv
|
||||||
* **Carreras (`/admin/careers`):** ABM de carreras universitarias y planes de estudio con contador de asignaturas y comisiones activas.
|
# Activar venv (Windows: venv\Scripts\activate, Linux/Mac: source venv/bin/activate)
|
||||||
* **Asignaturas (`/admin/subjects`):** Catálogo institucional de materias con créditos, departamentos y filtros por carrera y estado.
|
pip install -r requirements.txt
|
||||||
* **Comisiones y Cursadas (`/schedule/commissions`):** Gestión de cursadas, turnos, docentes, enlaces virtuales e hitos de evaluación.
|
flask run --port=5000
|
||||||
* **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:**
|
*(El backend quedará escuchando en http://localhost:5000)*
|
||||||
* **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.
|
### 2. Iniciar el Frontend (Node/Express)
|
||||||
* **Sincronización Sheets:** Integración con Google Sheets para actualización en lote.
|
```bash
|
||||||
|
cd frontend
|
||||||
### 🔀 9. Selector de Vista Universal (Tabla / Grilla vs. Tarjetas / Mosaico)
|
npm install
|
||||||
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:
|
npm run dev
|
||||||
* **Vista de Tabla / Grilla (`▤ Tabla`):**
|
# o en su defecto:
|
||||||
* Presentación tabular en filas y columnas de alta densidad informativa.
|
node src/app.js
|
||||||
* 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.
|
*(El frontend quedará escuchando en http://localhost:3000)*
|
||||||
* **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:**
|
## 📚 Documentación Técnica & Changelogs
|
||||||
* **Cambio Inmediato:** Conmutación instantánea en el cliente sin recarga de página (`d-none` / manipulación de DOM).
|
* [Changelog: Clases Virtuales, Importador y Dashboards](docs/CHANGELOG_VIRTUAL_CLASSES_AND_DASHBOARDS.md)
|
||||||
* **Persistencia de Preferencia:** Memorización automática mediante `localStorage` para conservar la elección del usuario en futuras visitas.
|
* [Auditoría y Sesión de Reingeniería](docs/CHANGELOG_SESSION.md)
|
||||||
* **Preservación de Filtros:** Inyección automática del parámetro `display` en formularios y sincronización de URL sin recarga (`history.replaceState`).
|
* [Guía de Integración de Cronograma](docs/SCHEDULE_INTEGRATION_GUIDE.md)
|
||||||
* **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
|
|
||||||
admin-edu-space/
|
|
||||||
├── app/
|
|
||||||
│ ├── __init__.py # Factoría de la aplicación Flask y extensiones
|
|
||||||
│ ├── forms/ # Formularios Flask-WTF con validación CSRF
|
|
||||||
│ │ ├── auth.py # Login, perfil y cambio de clave
|
|
||||||
│ │ ├── building.py # Sedes y edificios
|
|
||||||
│ │ ├── classroom.py # Aulas y capacidades (con soporte ∞)
|
|
||||||
│ │ └── reservation.py # Reservas de aulas y horarios
|
|
||||||
│ ├── models/ # Modelos relacionales SQLAlchemy
|
|
||||||
│ │ ├── academic_term.py # Ciclos lectivos y períodos académicos (activo/histórico)
|
|
||||||
│ │ ├── audit_log.py # Bitácora inmutable de auditoría de eventos
|
|
||||||
│ │ ├── building.py # Sedes y edificios institucionales
|
|
||||||
│ │ ├── career.py # Carreras y titulaciones universitarias
|
|
||||||
│ │ ├── classroom.py # Aulas físicas y virtuales con capacidades
|
|
||||||
│ │ ├── genetic_algorithm.py # Estructuras de optimización heurística
|
|
||||||
│ │ ├── milestone.py # Tipificaciones de hitos académicos y exámenes
|
|
||||||
│ │ ├── reservation.py # Reservas horarias, estados y turnos
|
|
||||||
│ │ ├── role.py # Roles y matriz de permisos granulares RBAC
|
|
||||||
│ │ ├── subject.py # Asignaturas, materias y comisiones de cursada
|
|
||||||
│ │ └── user.py # Usuarios, credenciales y asignación de roles
|
|
||||||
│ ├── 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
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Roadmap Académico & Propuesta de Evolución Tecnológica
|
|
||||||
|
|
||||||
En respuesta a los requerimientos y sugerencias docentes, se formaliza el plan de evolución arquitectónica, estándares de seguridad y entrega de valor institucional para la plataforma **Admin Edu-Space (UniCABA)**.
|
|
||||||
|
|
||||||
### 💡 1. Propuesta de Mejora Integral
|
|
||||||
|
|
||||||
#### A. Arquitectura MVC Rigurosa & Clean Architecture
|
|
||||||
* **Desacoplamiento de Capas:**
|
|
||||||
* **Model (Dominio):** Entidades relacionales puras y reglas de integridad del negocio desacopladas del framework web (`app/models/`).
|
|
||||||
* **View (Presentación):** Vistas web en Jinja2 con Bootstrap 5 y respuestas JSON estructuradas para clientes API/móviles.
|
|
||||||
* **Controller (Controladores/Blueprints):** Controladores delgados en `app/routes/` enfocados exclusivamente en la orquestación HTTP y redirección.
|
|
||||||
* **Service Layer & Repositories (`app/services/` & `app/repositories/`):** Aislamiento de la lógica de negocio (detección de colisiones, algoritmo genético de asignación, ETL de Google Sheets) y operaciones de persistencia encapsuladas.
|
|
||||||
* **DTOs y Validación de Entrada:** Incorporación de esquemas tipados (Pydantic / Marshmallow) para filtrar y sanitizar datos de entrada antes de interactuar con el ORM, eliminando validaciones ad-hoc en los controladores.
|
|
||||||
|
|
||||||
#### B. Autenticación & Autorización Stateless con JWT (JSON Web Tokens)
|
|
||||||
* **Arquitectura API-First Híbrida:**
|
|
||||||
* Coexistencia armónica de sesiones web seguras (para el panel administrativo en navegadores) y tokens **JWT** (para microservicios, clientes móviles y aplicaciones externas).
|
|
||||||
* **Esquema Dual de Tokens (Dual Token Pattern):**
|
|
||||||
* `Access Token` (firmado con RS256/HS256, expiración corta de 15 minutos) con claims contextuales (`sub`, `roles`, `permissions`, `exp`).
|
|
||||||
* `Refresh Token` (almacenado en cookie segura `HttpOnly`, `Secure`, `SameSite=Strict`, expiración de 7 días) para renovación silenciosa sin reingreso de credenciales.
|
|
||||||
* **Mecanismo de Revocación Inmediata (Token Blacklist):** Almacenamiento en caché distribuida (Redis) para invalidación instantánea ante cierres de sesión forzados o rotación de contraseñas.
|
|
||||||
|
|
||||||
#### C. Seguridad Empresarial y Paradigma "Sprint / Spring Security"
|
|
||||||
* **Cadena de Filtros de Interceptación (SecurityFilterChain):** Pipeline centralizado inspirado en los estándares de Spring Security:
|
|
||||||
* **Authentication Filter:** Intercepta cabeceras `Authorization: Bearer <token>` y valida firmas criptográficas.
|
|
||||||
* **Security Headers Filter:** Inyección obligatoria de cabeceras de blindaje HTTP (HSTS, CSP, X-Frame-Options: DENY, X-Content-Type-Options: nosniff).
|
|
||||||
* **Rate Limiting Filter:** Prevención de ataques de fuerza bruta y DDoS en endpoints sensibles mediante límites dinámicos (`Flask-Limiter` / Redis).
|
|
||||||
* **Seguridad Declarativa a Nivel Método (`@PreAuthorize` / `@require_permission`):**
|
|
||||||
* Decoradores reutilizables que evalúan la matriz RBAC antes de ejecutar cualquier método de servicio:
|
|
||||||
```python
|
|
||||||
@require_permission('classrooms', min_level='read_write')
|
|
||||||
def update_classroom_capacity(classroom_id, new_capacity): ...
|
|
||||||
```
|
|
||||||
* **Bitácora Inmutable de Auditoría (Audit Trail):** Registro trazable de eventos críticos (creación/modificación de reservas, cambios de roles y accesos fallidos) con IP de origen, usuario actuante, timestamp y delta de cambios.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 📅 2. Planificación de Sprints (Roadmap)
|
|
||||||
|
|
||||||
| Sprint / Fase | Objetivo Principal | Entregables Clave | Estado | Hito de Evaluación |
|
|
||||||
| :--- | :--- | :--- | :--- | :--- |
|
|
||||||
| **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.
|
|
||||||
␍
|
|
||||||
@@ -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>
|
|
||||||
@@ -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
|
# Import models to ensure they are registered
|
||||||
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
|
|
||||||
from app.models import user
|
from app.models import user
|
||||||
# Register blueprints
|
|
||||||
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 import api_auth_bp, api_classrooms_bp, api_reservations_bp, api_optimizer_bp
|
|
||||||
from app.security import SecurityFilterChain
|
|
||||||
|
|
||||||
app.register_blueprint(auth_bp, url_prefix="/")
|
|
||||||
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
|
# Register REST API (v1) Blueprints with Stateless JWT support
|
||||||
|
from app.routes.api.auth import api_auth_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
|
||||||
|
|
||||||
|
# Register API v1 blueprints
|
||||||
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)
|
||||||
@@ -143,9 +142,4 @@ 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 |
@@ -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
|
||||||
|
|
||||||
@@ -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,16 +366,36 @@ 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)
|
||||||
|
|
||||||
existing_res = Reservation.query.filter_by(
|
# En aulas virtuales la capacidad es infinita y pueden cursar múltiples comisiones en paralelo
|
||||||
classroom_id=classroom.id,
|
if is_room_virtual:
|
||||||
start_time=dt_start,
|
existing_res = Reservation.query.filter_by(
|
||||||
end_time=dt_end
|
classroom_id=classroom.id,
|
||||||
).first()
|
commission_id=commission.id,
|
||||||
|
start_time=dt_start,
|
||||||
|
end_time=dt_end
|
||||||
|
).first()
|
||||||
|
else:
|
||||||
|
existing_res = Reservation.query.filter_by(
|
||||||
|
classroom_id=classroom.id,
|
||||||
|
start_time=dt_start,
|
||||||
|
end_time=dt_end
|
||||||
|
).first()
|
||||||
|
|
||||||
if not existing_res:
|
if not existing_res:
|
||||||
res = Reservation(
|
res = Reservation(
|
||||||
@@ -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({
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -335,6 +333,78 @@ def init_database():
|
|||||||
mt.sort_order = item['sort_order']
|
mt.sort_order = item['sort_order']
|
||||||
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.")
|
||||||
|
|
||||||
@@ -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")
|
||||||
@@ -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('===============================================================')
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||